@chinchillaenterprises/mcp-slack
Version:
MCP server for Slack. Single account loaded from env (SLACK_BOT_TOKEN); multi-workspace via separate server entries.
1,003 lines (1,002 loc) • 324 kB
JavaScript
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { ListToolsRequestSchema, CallToolRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
import { WebClient } from "@slack/web-api";
import { z } from "zod";
import { promises as fs } from "fs";
import { realpathSync } from "fs";
import path from "path";
import os from "os";
import crypto from "crypto";
import { tmpdir } from "os";
import { execFile } from "child_process";
import { promisify } from "util";
import { fileURLToPath } from "url";
import Fuse from "fuse.js";
const execFileAsync = promisify(execFile);
// =====================================================================================
// OUTPUT FORMATTING HELPERS (Bundle 0)
// -------------------------------------------------------------------------------------
// These are the shared, framework-free building blocks the later UX bundles reuse.
//
// WHY THIS EXISTS: this server's single biggest token cost is that verbose tool
// RESULTS linger in the session context window and get re-sent every turn (measured
// at ~21% of a user's usage). Pretty-printed JSON (JSON.stringify(x, null, 2)) is the
// main offender. leanResult() establishes the convention: compact by default, fuller
// only when the caller explicitly asks for verbose. Keep these together.
// =====================================================================================
/**
* Compact result formatter. Default (lean) form is single-line / minimal — NO
* pretty-print — so it doesn't bloat the context window. Pass { verbose: true } for
* the fuller, indented form when a human actually needs to read every field.
*
* Accepts an already-prepared `data` value. Callers are expected to have already
* trimmed to the fields that matter for the lean case (see the READ tools), but this
* function is also safe to hand a full object for the verbose path.
*/
export function leanResult(data, opts = {}) {
if (opts.verbose) {
return JSON.stringify(data, null, 2);
}
// Lean: single-line JSON. For arrays of flat objects we additionally collapse to
// a compact NDJSON-ish form which reads better and tokenizes smaller than one
// giant single-line array for long lists.
if (Array.isArray(data) && data.length > 0 && data.every(isFlatObject)) {
return data.map((row) => JSON.stringify(row)).join("\n");
}
return JSON.stringify(data);
}
// A "flat" object has only primitive / null values (no nested objects or arrays).
export function isFlatObject(v) {
if (typeof v !== "object" || v === null || Array.isArray(v))
return false;
return Object.values(v).every((val) => val === null || typeof val !== "object");
}
/**
* Render tabular data as a clean, column-aligned monospace ASCII table using
* box-drawing characters. The output is meant to be dropped inside a Slack code
* block (```) so it renders with a fixed-width font and column alignment survives.
*
* Accepts any of:
* - array of flat objects → columns inferred from keys (or pass `columns`)
* - 2D array (string[][]) → first row treated as header
* - a markdown table string (| a | b |\n|---|---|\n| 1 | 2 |)
*
* Long cells are truncated with an ellipsis past a per-column max width (default 40).
* Numeric-looking columns are right-aligned automatically unless overridden.
*/
export function renderTable(rows, columns) {
const DEFAULT_MAX = 40;
let headers = [];
let body = [];
let colMeta = [];
if (typeof rows === "string") {
// Parse a markdown table string.
const parsed = parseMarkdownTable(rows);
headers = parsed.headers;
body = parsed.body;
colMeta = headers.map((h) => ({ key: h, label: h }));
}
else if (Array.isArray(rows) && rows.length > 0 && Array.isArray(rows[0])) {
// 2D array: first row is the header.
const grid = rows;
headers = (grid[0] || []).map((c) => String(c ?? ""));
body = grid.slice(1).map((r) => r.map((c) => cellToString(c)));
colMeta = headers.map((h) => ({ key: h, label: h }));
}
else {
// Array of objects.
const objRows = rows;
if (columns && columns.length > 0) {
colMeta = columns;
}
else {
const keys = new Set();
for (const r of objRows)
Object.keys(r || {}).forEach((k) => keys.add(k));
colMeta = Array.from(keys).map((k) => ({ key: k, label: k }));
}
headers = colMeta.map((c) => c.label ?? c.key);
body = objRows.map((r) => colMeta.map((c) => cellToString(r?.[c.key])));
}
if (headers.length === 0)
return "(empty table)";
const colCount = headers.length;
// Decide alignment: explicit override wins; otherwise right-align columns whose
// every body cell looks numeric.
const aligns = colMeta.map((c, i) => {
if (c?.align)
return c.align;
const allNumeric = body.length > 0 &&
body.every((r) => {
const v = (r[i] ?? "").trim();
return v === "" || /^-?[\d,]+(\.\d+)?%?$/.test(v);
});
return allNumeric ? "right" : "left";
});
const maxWidths = colMeta.map((c) => c?.maxWidth ?? DEFAULT_MAX);
// Truncate then compute column widths.
const trunc = (s, max) => s.length > max ? s.slice(0, Math.max(0, max - 1)) + "…" : s;
const hdr = headers.map((h, i) => trunc(h, maxWidths[i]));
const cells = body.map((r) => Array.from({ length: colCount }, (_, i) => trunc(r[i] ?? "", maxWidths[i])));
const widths = Array.from({ length: colCount }, (_, i) => Math.max(hdr[i].length, ...cells.map((r) => r[i].length), 1));
const pad = (s, w, align) => align === "right" ? s.padStart(w) : s.padEnd(w);
const top = "┌" + widths.map((w) => "─".repeat(w + 2)).join("┬") + "┐";
const sep = "├" + widths.map((w) => "─".repeat(w + 2)).join("┼") + "┤";
const bottom = "└" + widths.map((w) => "─".repeat(w + 2)).join("┴") + "┘";
const fmtRow = (r) => "│ " + r.map((c, i) => pad(c, widths[i], aligns[i])).join(" │ ") + " │";
const lines = [top, fmtRow(hdr), sep, ...cells.map(fmtRow), bottom];
return lines.join("\n");
}
export function cellToString(v) {
if (v === null || v === undefined)
return "";
if (typeof v === "object")
return JSON.stringify(v);
return String(v);
}
// Parse a markdown pipe-table string into { headers, body }.
export function parseMarkdownTable(md) {
const lines = md
.split("\n")
.map((l) => l.trim())
.filter((l) => l.includes("|"));
if (lines.length === 0)
return { headers: [], body: [] };
const splitRow = (line) => line
.replace(/^\|/, "")
.replace(/\|$/, "")
.split("|")
.map((c) => c.trim());
const headers = splitRow(lines[0]);
// Drop the separator row (e.g. |---|---|) if present.
const bodyLines = lines.slice(1).filter((l) => !/^\|?[\s:|-]+\|?$/.test(l));
const body = bodyLines.map(splitRow);
return { headers, body };
}
/**
* Render a unicode progress bar, e.g. renderProgressBar(40) => "▰▰▰▰▱▱▱▱▱▱ 40%".
*/
export function renderProgressBar(pct, width = 20) {
const clamped = Math.max(0, Math.min(100, Math.round(pct)));
const filled = Math.round((clamped / 100) * width);
const empty = width - filled;
return "▰".repeat(filled) + "▱".repeat(empty) + ` ${clamped}%`;
}
/**
* Build a Slack Block Kit blocks[] array: a header block, section blocks (each with
* optional two-column fields), dividers between sections, and an optional context
* footer. Used by slack_send_card and the task-lifecycle summary cards.
*
* Returns { blocks } always, plus { attachments } when a color bar is requested
* (Slack only supports the colored left-bar via the legacy attachments wrapper).
*/
export function buildCard(opts) {
const blocks = [];
blocks.push({
type: "header",
text: { type: "plain_text", text: truncatePlain(opts.header, 150), emoji: true },
});
const sections = opts.sections ?? [];
sections.forEach((section, idx) => {
const block = { type: "section" };
if (section.text) {
block.text = { type: "mrkdwn", text: section.text };
}
if (section.fields && section.fields.length > 0) {
block.fields = section.fields.slice(0, 10).map((f) => ({
type: "mrkdwn",
text: f.label ? `*${f.label}*\n${f.value}` : f.value,
}));
}
// A section block must carry either text or fields.
if (block.text || block.fields)
blocks.push(block);
if (idx < sections.length - 1)
blocks.push({ type: "divider" });
});
if (opts.context) {
blocks.push({
type: "context",
elements: [{ type: "mrkdwn", text: opts.context }],
});
}
if (opts.color) {
// Colored cards must be wrapped in an attachment to get the left color bar.
return { blocks: [], attachments: [{ color: opts.color, blocks }] };
}
return { blocks };
}
export function truncatePlain(s, max) {
return s.length > max ? s.slice(0, max - 1) + "…" : s;
}
// =====================================================================================
// VALIDATION SCHEMAS - Account Management Tools (read-only)
// =====================================================================================
const ListAccountsArgsSchema = z.object({});
const GetActiveAccountArgsSchema = z.object({});
// =====================================================================================
// EXISTING TOOL SCHEMAS (from original mcp-slacker)
// =====================================================================================
const ListChannelsArgsSchema = z.object({
limit: z.number().optional().describe("Maximum number of channels to return (default: 100)"),
verbose: z.boolean().optional().describe("Return the full raw channel objects. Default false returns lean output: id + name + is_private + num_members.")
});
// Shared session-identity fields (Bundle 4). Requires the chat:write.customize bot
// scope; Slack ignores them gracefully otherwise. Defaults can be set once via the
// SLACK_SESSION_USERNAME / SLACK_SESSION_ICON env vars.
const IdentityFields = {
username: z.string().optional().describe("Post under this display name (e.g. 'Claude · ChillMCP'). Requires chat:write.customize scope. Falls back to SLACK_SESSION_USERNAME env var."),
icon_emoji: z.string().optional().describe("Icon emoji like ':robot_face:'. Requires chat:write.customize. Falls back to SLACK_SESSION_ICON env var."),
icon_url: z.string().optional().describe("Icon image URL (overrides icon_emoji). Requires chat:write.customize.")
};
const SendMessageArgsSchema = z.object({
channel: z.string().describe("Channel ID or name (e.g., C1234567890 or #general)"),
text: z.string().describe("Text content of the message"),
thread_ts: z.string().optional().describe("Timestamp of the parent message to reply in thread"),
...IdentityFields
});
const GetChannelHistoryArgsSchema = z.object({
channel: z.string().describe("Channel ID to fetch history from"),
limit: z.number().optional().describe("Number of messages to return (default: 100)"),
verbose: z.boolean().optional().describe("Return the full raw API payload. Default false returns lean output: verbatim message text + user + ts, with metadata exhaust stripped.")
});
const AddReactionArgsSchema = z.object({
channel: z.string().describe("Channel ID where the message is"),
timestamp: z.string().describe("Timestamp of the message to react to"),
name: z.string().describe("Reaction emoji name (without colons)")
});
// ----- Legate pull/polling tools (multi-session shared-bot coordination) --------------
const PollArgsSchema = z.object({
channel: z.string().describe("Channel ID or name (e.g., C1234567890 or #echelon)"),
call_sign: z.string().describe("This Legate's NATO call-sign (e.g., Alpha, Bravo)"),
limit: z.number().optional().describe("Max messages to scan/return (default: 15)"),
since: z.string().optional().describe("Slack ts to poll from; overrides the stored cursor"),
include_chatter: z.boolean().optional().describe("Include messages not addressed to anyone (default: false)")
});
const ClaimLaneArgsSchema = z.object({
channel: z.string().describe("Channel ID or name (e.g., C1234567890 or #echelon)"),
call_sign: z.string().describe("This Legate's NATO call-sign"),
task: z.string().describe("The work being claimed (free text; normalized to a lane key)"),
ttl_minutes: z.number().optional().describe("How long a claim stays valid (default: 20)"),
lookback: z.number().optional().describe("How many recent messages to scan for prior claims (default: 30)"),
release: z.boolean().optional().describe("If true, post a LANE RELEASE for this task instead of claiming (default: false)")
});
const CheckinArgsSchema = z.object({
channel: z.string().describe("Channel ID or name (e.g., C1234567890 or #echelon)"),
call_sign: z.string().describe("This Legate's NATO call-sign"),
status: z.string().describe("One-line status to post"),
force: z.boolean().optional().describe("Post even if the status is unchanged (default: false)"),
heartbeat_minutes: z.number().optional().describe("If >0, re-post an unchanged status once this many minutes have elapsed (default: 0)")
});
const ListUsersArgsSchema = z.object({
limit: z.number().optional().describe("Maximum number of users to return (default: 100)"),
verbose: z.boolean().optional().describe("Return the full raw user objects. Default false returns lean output: id + name + real_name only."),
include_bots: z.boolean().optional().describe("Include bot/app users (e.g. agent Slack apps) in the result. Default false returns humans only.")
});
const GetUserByNameArgsSchema = z.object({
name: z.string().describe("Name or display name to search for"),
include_bots: z.boolean().optional().describe("Also search bot/app users (e.g. agent Slack apps). Default false searches humans only.")
});
const GetUserInfoArgsSchema = z.object({
user_id: z.string().describe("User ID to get detailed info for")
});
const SearchFilesArgsSchema = z.object({
query: z.string().describe("Search query for files"),
count: z.number().optional().describe("Number of results to return (default: 20)"),
page: z.number().optional().describe("Page number of results (default: 1)")
});
const SearchMessagesArgsSchema = z.object({
query: z.string().describe("Search query for messages"),
count: z.number().optional().describe("Number of results to return (default: 20)"),
page: z.number().optional().describe("Page number of results (default: 1)"),
verbose: z.boolean().optional().describe("Return the full raw search payload. Default false returns lean output: verbatim text + user + channel + ts + permalink.")
});
const SearchByUserArgsSchema = z.object({
user_id: z.string().describe("User ID to search messages from"),
query: z.string().optional().describe("Additional search query"),
count: z.number().optional().describe("Number of results to return (default: 20)"),
verbose: z.boolean().optional().describe("Return the full raw search payload. Default false returns lean output: verbatim text + channel + ts + permalink.")
});
const SearchByDateRangeArgsSchema = z.object({
from_date: z.string().describe("Start date (YYYY-MM-DD)"),
to_date: z.string().describe("End date (YYYY-MM-DD)"),
query: z.string().optional().describe("Additional search query"),
count: z.number().optional().describe("Number of results to return (default: 20)"),
verbose: z.boolean().optional().describe("Return the full raw search payload. Default false returns lean output: verbatim text + user + channel + ts + permalink.")
});
const PinMessageArgsSchema = z.object({
channel: z.string().describe("Channel ID where the message is"),
timestamp: z.string().describe("Timestamp of the message to pin")
});
const UnpinMessageArgsSchema = z.object({
channel: z.string().describe("Channel ID where the message is"),
timestamp: z.string().describe("Timestamp of the message to unpin")
});
const DeleteMessageArgsSchema = z.object({
channel: z.string().describe("Channel ID where the message is"),
timestamp: z.string().describe("Timestamp of the message to delete")
});
const EditMessageArgsSchema = z.object({
channel: z.string().describe("Channel ID where the message is"),
timestamp: z.string().describe("Timestamp of the message to edit"),
text: z.string().describe("New text for the message")
});
const CreateChannelArgsSchema = z.object({
name: z.string().describe("Channel name (lowercase, no spaces, max 80 chars; Slack normalizes invalid chars)"),
is_private: z.boolean().optional().describe("Create a private channel (default: false)")
});
const InviteUsersArgsSchema = z.object({
channel: z.string().describe("Channel ID to invite users into (e.g., C1234567890)"),
user_ids: z.array(z.string()).min(1).describe("Array of user/bot IDs to invite (e.g., ['U123','U456'])")
});
const ArchiveChannelArgsSchema = z.object({
channel: z.string().describe("Channel ID to archive (e.g., C1234567890)")
});
const RenameChannelArgsSchema = z.object({
channel: z.string().describe("Channel ID to rename (e.g., C1234567890)"),
name: z.string().describe("New channel name (lowercase, no spaces, max 80 chars; Slack normalizes invalid chars)")
});
const SetChannelTopicArgsSchema = z.object({
channel: z.string().describe("Channel ID (e.g., C1234567890)"),
topic: z.string().optional().describe("New channel topic (omit to leave unchanged)"),
purpose: z.string().optional().describe("New channel purpose/description (omit to leave unchanged)")
});
const ScheduleMessageArgsSchema = z.object({
channel: z.string().describe("Channel ID to send to"),
text: z.string().describe("Message text"),
post_at: z.number().describe("Unix timestamp when message should be sent")
});
const GetUserStatusArgsSchema = z.object({
user_id: z.string().describe("User ID to get status for")
});
const GetUserProfileArgsSchema = z.object({
user_id: z.string().describe("User ID to get profile for")
});
const CreateReminderArgsSchema = z.object({
text: z.string().describe("Reminder text"),
time: z.string().describe("When to be reminded (e.g., 'in 20 minutes', 'tomorrow', '3:00pm')")
});
const ListRemindersArgsSchema = z.object({});
const GetCustomEmojiArgsSchema = z.object({});
const GetWorkspaceStatsArgsSchema = z.object({
verbose: z.boolean().optional().describe("Return the fuller stats payload (indented). Default false returns a lean one-line summary.")
});
const SendTableArgsSchema = z.object({
channel: z.string().describe("Channel ID or name to post the table to"),
title: z.string().optional().describe("Optional bold title rendered above the table"),
rows: z.union([
z.array(z.record(z.any())), // array of objects
z.array(z.array(z.any())), // 2D array (first row = header)
z.string() // markdown table string
]).describe("Table data: array of objects, a 2D array (first row = header), OR a markdown pipe-table string"),
as_code_block: z.boolean().optional().describe("Wrap the ASCII table in a ``` code block so columns stay aligned (default true)")
});
// ---- Block Kit cards + threaded task lifecycle (Bundle 2) ----
const SendCardArgsSchema = z.object({
channel: z.string().describe("Channel ID or name to post the card to"),
header: z.string().describe("Card header text (plain text)"),
sections: z.array(z.object({
text: z.string().optional().describe("mrkdwn body text for this section"),
fields: z.array(z.object({
label: z.string().optional(),
value: z.string()
})).optional().describe("Up to 10 two-column key/value fields")
})).optional().describe("Section blocks rendered in order, separated by dividers"),
context: z.string().optional().describe("Small footer context line (mrkdwn)"),
color: z.string().optional().describe("Hex color for the left bar, e.g. #3D8C62"),
...IdentityFields
});
const StartTaskArgsSchema = z.object({
channel: z.string().describe("Channel ID or name to post the task card to"),
title: z.string().describe("Task title"),
total_steps: z.number().optional().describe("Total number of steps (enables a progress bar on updates)"),
...IdentityFields
});
const UpdateTaskArgsSchema = z.object({
channel: z.string().describe("Channel ID where the task card lives"),
task_ts: z.string().describe("Timestamp of the parent task card (from slack_start_task)"),
message: z.string().describe("Progress update — posted as a THREADED reply. Do NOT @-mention here; routine updates must stay quiet."),
step: z.number().optional().describe("Current step number (with total_steps, updates the parent's progress bar)"),
total_steps: z.number().optional().describe("Total steps (defaults to the value from start_task if known)"),
...IdentityFields
});
const CompleteTaskArgsSchema = z.object({
channel: z.string().describe("Channel ID where the task card lives"),
task_ts: z.string().describe("Timestamp of the parent task card (from slack_start_task)"),
summary: z.string().describe("Final summary of the task outcome"),
status: z.enum(["done", "failed"]).describe("Final status"),
details: z.string().optional().describe("Optional details — commits, links, duration, etc. (mrkdwn)"),
notify_user: z.string().optional().describe("Optional Slack user ID to @-mention on completion. This is the ONLY task tool permitted to mention."),
...IdentityFields
});
const SendFormattedMessageArgsSchema = z.object({
channel: z.string().describe("Channel ID or name"),
blocks: z.array(z.object({ type: z.string() }).passthrough()).describe("Slack Block Kit blocks for rich formatting"),
text: z.string().optional().describe("Fallback text"),
...IdentityFields
});
const BulkReactMessagesArgsSchema = z.object({
channel: z.string().describe("Channel ID"),
timestamps: z.array(z.string()).describe("Array of message timestamps"),
reaction: z.string().describe("Emoji name to react with")
});
const ForwardMessageArgsSchema = z.object({
source_channel: z.string().describe("Source channel ID"),
timestamp: z.string().describe("Message timestamp to forward"),
target_channel: z.string().describe("Target channel ID to forward to"),
comment: z.string().optional().describe("Optional comment when forwarding")
});
const BulkForwardMessagesArgsSchema = z.object({
source_channel: z.string().describe("Source channel ID"),
timestamps: z.array(z.string()).describe("Array of message timestamps"),
target_channels: z.array(z.string()).describe("Array of target channel IDs"),
comment: z.string().optional().describe("Optional comment when forwarding")
});
const AuditUserActivityArgsSchema = z.object({
user_id: z.string().describe("User ID to audit"),
limit: z.number().optional().describe("Number of audit entries to return")
});
const GetThreadRepliesArgsSchema = z.object({
channel: z.string().describe("Channel ID where the thread is"),
thread_ts: z.string().describe("Timestamp of the parent message (thread starter)"),
limit: z.number().optional().describe("Number of replies to return (default: 100)")
});
const UploadFileArgsSchema = z.object({
channel: z.string().describe("Channel ID to upload to"),
file_path: z.string().describe("Local file path to upload"),
title: z.string().optional().describe("File title in Slack"),
initial_comment: z.string().optional().describe("Message posted with the file")
});
const SendDiagramArgsSchema = z.object({
channel: z.string().describe("Channel ID to post to"),
diagram_content: z.string().describe("HTML string or Mermaid diagram code"),
diagram_type: z.enum(["html", "mermaid"]).optional().default("html").describe("Diagram type: 'html' or 'mermaid'"),
title: z.string().optional().describe("Title for the diagram"),
initial_comment: z.string().optional().describe("Message posted with the diagram")
});
export class SlackerV3Server {
server;
accountState;
// Per-account TTL caches for users.list / conversations.list (120s).
CACHE_TTL_MS = 120_000;
usersCache = new Map();
channelsCache = new Map();
// In-memory registry of active task cards keyed by `${channel}:${task_ts}`.
// Lets slack_update_task/slack_complete_task recall the title, total_steps, and
// current status reaction so they can re-render the parent and swap the reaction.
taskRegistry = new Map();
// Singleton Puppeteer browser, launched lazily and kept warm.
browser = null;
browserIdleTimer = null;
BROWSER_IDLE_MS = 5 * 60 * 1000; // close after 5 min idle
// Friendly message returned (never thrown on boot) when no bot token is configured.
static NO_TOKEN_MESSAGE = "No Slack bot token. Set SLACK_BOT_TOKEN in this MCP server's env block in your " +
"Claude config (~/.claude.json or .mcp.json).";
constructor() {
this.server = new Server({
name: "mcp-slack",
version: "4.4.0",
}, {
capabilities: {
tools: {},
},
});
// Load the single account from the environment. Never throws — if no token is
// present the server still boots and tool calls return NO_TOKEN_MESSAGE.
this.accountState = {
account: null,
client: null,
userClient: null,
workspaceFetched: false
};
this.loadAccountFromEnv();
// Setup handlers immediately so the server can start
this.setupHandlers();
}
// =====================================================================================
// ACCOUNT (env-first, single account)
// =====================================================================================
// Load exactly one account from env vars. SLACK_* is preferred; DEFAULT_* is a
// back-compat fallback. Multi-workspace = run a second MCP server entry with its own
// env block. No disk persistence — workspace/teamId hydrate lazily via auth.test.
loadAccountFromEnv() {
const botToken = process.env.SLACK_BOT_TOKEN || process.env.DEFAULT_BOT_TOKEN;
const userToken = process.env.SLACK_USER_TOKEN || process.env.DEFAULT_USER_TOKEN;
const teamId = process.env.SLACK_TEAM_ID || process.env.DEFAULT_TEAM_ID;
const name = process.env.SLACK_WORKSPACE || process.env.DEFAULT_ACCOUNT_NAME || 'Slack Workspace';
console.error("[SlackServer] Environment:", {
SLACK_BOT_TOKEN: botToken ? "Set" : "Not set",
SLACK_USER_TOKEN: userToken ? "Set" : "Not set",
SLACK_TEAM_ID: teamId || "(lazy)",
});
if (!botToken) {
console.error("[SlackServer] No bot token configured — server will boot but tool calls will error until SLACK_BOT_TOKEN is set.");
return;
}
this.accountState.account = {
id: 'default',
name,
workspace: name && name !== 'Slack Workspace' ? name : 'Unknown',
teamId: teamId || '',
botToken,
userToken: userToken || undefined,
};
this.accountState.client = new WebClient(botToken);
this.accountState.userClient = userToken ? new WebClient(userToken) : null;
}
async getActiveClient() {
if (!this.accountState.client) {
throw new Error(SlackerV3Server.NO_TOKEN_MESSAGE);
}
return this.accountState.client;
}
async getActiveUserClient() {
// Prefer the user client; fall back to the bot client.
const client = this.accountState.userClient || this.accountState.client;
if (!client) {
throw new Error(SlackerV3Server.NO_TOKEN_MESSAGE);
}
return client;
}
// Lazily hydrate workspace name + teamId via auth.test on first use, cached in memory.
async ensureWorkspaceInfo() {
if (this.accountState.workspaceFetched || !this.accountState.account || !this.accountState.client) {
return;
}
try {
const info = await this.accountState.client.auth.test();
if (info?.team)
this.accountState.account.workspace = info.team;
if (info?.team_id)
this.accountState.account.teamId = info.team_id;
}
catch (error) {
console.error("[SlackServer] auth.test failed (workspace stays 'Unknown'):", error);
}
finally {
this.accountState.workspaceFetched = true;
}
}
// =====================================================================================
// TTL CACHE HELPERS (users.list / conversations.list)
// =====================================================================================
currentAccountKey() {
return this.accountState.account?.id || '__none__';
}
// Fetch ALL active (non-deleted) users for the active account, paginated, cached 120s.
// The cache holds the full non-deleted roster INCLUDING bots; the bot filter is
// applied per-call so human-only callers stay unaffected while agent/bot users
// (Slack apps like the Legate fleet) can be enumerated on demand.
async getAllUsersCached(client, includeBots = false) {
const key = this.currentAccountKey();
const cached = this.usersCache.get(key);
if (cached && cached.expires > Date.now()) {
return includeBots ? cached.value : cached.value.filter((u) => !u.is_bot);
}
const members = [];
let cursor = undefined;
do {
const result = await client.users.list({ limit: 200, cursor });
if (result.members)
members.push(...result.members);
cursor = result.response_metadata?.next_cursor || undefined;
} while (cursor);
const active = members.filter((u) => !u.deleted);
this.usersCache.set(key, { value: active, expires: Date.now() + this.CACHE_TTL_MS });
return includeBots ? active : active.filter((u) => !u.is_bot);
}
// Fetch ALL public+private channels for the active account, paginated, cached 120s.
async getAllChannelsCached(client) {
const key = this.currentAccountKey();
const cached = this.channelsCache.get(key);
if (cached && cached.expires > Date.now()) {
return cached.value;
}
const channels = [];
let cursor = undefined;
do {
const result = await client.conversations.list({
types: 'public_channel,private_channel',
limit: 1000,
cursor
});
if (result.channels)
channels.push(...result.channels);
cursor = result.response_metadata?.next_cursor || undefined;
} while (cursor);
this.channelsCache.set(key, { value: channels, expires: Date.now() + this.CACHE_TTL_MS });
return channels;
}
// =====================================================================================
// ACCOUNT TOOL IMPLEMENTATIONS (read-only — single env-loaded account)
// =====================================================================================
// Build the public (no-secrets) view of the active account, hydrating workspace/teamId
// lazily via auth.test on first use.
async describeActiveAccount() {
const account = this.accountState.account;
if (!account)
return null;
await this.ensureWorkspaceInfo();
return {
id: account.id,
name: account.name,
workspace: account.workspace,
team_id: account.teamId,
has_user_token: !!account.userToken,
};
}
// get_active_account — returns the wired workspace/team/name (no secrets) so the user
// can confirm which workspace this server is talking to.
async getActiveAccount(args) {
GetActiveAccountArgsSchema.parse(args);
const active = await this.describeActiveAccount();
if (!active) {
return {
content: [{ type: "text", text: JSON.stringify({
active_account: null,
message: SlackerV3Server.NO_TOKEN_MESSAGE,
}, null, 2) }]
};
}
return {
content: [{ type: "text", text: JSON.stringify({ active_account: active }, null, 2) }]
};
}
// list_accounts — kept for back-compat; this server is single-account, so it simply
// returns the one active account (or none).
async listAccounts(args) {
ListAccountsArgsSchema.parse(args);
const active = await this.describeActiveAccount();
const accounts = active ? [{ ...active, is_active: true }] : [];
return {
content: [{ type: "text", text: JSON.stringify({
total_accounts: accounts.length,
active_account_id: this.accountState.account?.id ?? null,
accounts,
}, null, 2) }]
};
}
// =====================================================================================
// EXISTING SLACK TOOL IMPLEMENTATIONS
// =====================================================================================
async slackListChannels(args) {
const validatedArgs = ListChannelsArgsSchema.parse(args);
const client = await this.getActiveClient();
// Collect all channels with pagination
const allChannels = [];
let cursor;
try {
do {
const result = await client.conversations.list({
// Always pull BOTH public and private channels — no public-only option.
// (Private channels still require the app to be a member + groups:read scope.)
types: "public_channel,private_channel",
limit: validatedArgs.limit || 200,
exclude_archived: true,
cursor: cursor
});
if (result.channels) {
allChannels.push(...result.channels);
}
// Get next cursor for pagination
cursor = result.response_metadata?.next_cursor;
} while (cursor);
// VERBOSE: full channel objects including topic/purpose.
if (validatedArgs.verbose) {
const channels = allChannels.map(channel => ({
id: channel.id,
name: channel.name,
is_private: channel.is_private,
is_member: channel.is_member,
topic: channel.topic?.value,
purpose: channel.purpose?.value,
num_members: channel.num_members
}));
return {
content: [{ type: "text", text: JSON.stringify(channels, null, 2) }]
};
}
// LEAN: just id + name + privacy + member count.
const channels = allChannels.map(channel => ({
id: channel.id,
name: channel.name,
is_private: !!channel.is_private,
num_members: channel.num_members
}));
return {
content: [
{
type: "text",
text: leanResult(channels)
}
]
};
}
catch (error) {
// Debug the exact error
console.error("Slack API Error:", error);
console.error("Error details:", {
message: error.message,
data: error.data,
code: error.code,
statusCode: error.statusCode
});
// Get current account info for debugging
const activeAccount = this.accountState.account;
console.error("Active account:", {
id: activeAccount?.id,
name: activeAccount?.name,
teamId: activeAccount?.teamId,
botToken: activeAccount?.botToken ? "Set" : "Not set"
});
throw error;
}
}
convertMarkdownToSlackMrkdwn(text) {
// Convert markdown links [text](url) to Slack format <url|text>
let converted = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<$2|$1>');
// Convert **text** to *text* for bold (Slack uses single asterisk)
converted = converted.replace(/\*\*([^*]+)\*\*/g, '*$1*');
// Convert ~~text~~ to ~text~ for strikethrough
converted = converted.replace(/~~([^~]+)~~/g, '~$1~');
// Convert headers (Slack doesn't support headers, so we'll just make them bold)
converted = converted.replace(/^#{1,6}\s+(.+)$/gm, '*$1*');
// Convert bullet lists from * or - to •
converted = converted.replace(/^[\*\-]\s+/gm, '• ');
// Convert numbered lists (Slack auto-formats these)
converted = converted.replace(/^\d+\.\s+/gm, '');
return converted;
}
async processExplicitMentions(text, client) {
// Fast path: no @mentions present, skip the (cached) users.list entirely.
if (!/@\w+/.test(text))
return text;
// ONLY process @mentions - nothing else (cached, paginated)
const activeUsers = await this.getAllUsersCached(client);
return text.replace(/@(\w+)/g, (match, username) => {
// Try exact match first
let user = activeUsers.find(u => u.name?.toLowerCase() === username.toLowerCase() ||
u.real_name?.toLowerCase() === username.toLowerCase() ||
u.profile?.display_name?.toLowerCase() === username.toLowerCase());
// If no exact match, use fuzzy matching with STRICTER threshold
if (!user && username.length > 2) {
const fuse = new Fuse(activeUsers, {
keys: ['name', 'real_name', 'profile.display_name'],
threshold: 0.2, // Much stricter - 80% similarity required
includeScore: true
});
const fuzzyResults = fuse.search(username);
if (fuzzyResults.length > 0 && fuzzyResults[0].score < 0.2) {
user = fuzzyResults[0].item;
}
}
return user?.id ? `<@${user.id}>` : match;
});
}
async resolveRecipient(channelOrUser, client) {
// If it's already a channel ID (C...) or user ID (U...), return as-is
if (/^[CU][0-9A-Z]+$/.test(channelOrUser)) {
return channelOrUser;
}
// Handle #channel-name format
const cleanName = channelOrUser.replace(/^#/, '');
// Try to find a channel first (cached, fully paginated so we don't miss page 2+)
const channels = await this.getAllChannelsCached(client);
const channel = channels.find(ch => ch.name?.toLowerCase() === cleanName.toLowerCase());
if (channel?.id)
return channel.id;
// No exact channel match. Try to find a user for DM — but ONLY with high
// confidence. A loose fuzzy match here can silently DM the WRONG human on a
// channel-name typo, so we require a strong match and otherwise throw.
const activeUsers = await this.getAllUsersCached(client);
// Exact (case-insensitive) match on a user handle/name takes priority.
const exactUser = activeUsers.find(u => u.name?.toLowerCase() === cleanName.toLowerCase() ||
u.real_name?.toLowerCase() === cleanName.toLowerCase() ||
u.profile?.display_name?.toLowerCase() === cleanName.toLowerCase());
if (exactUser?.id) {
const dmResult = await client.conversations.open({ users: exactUser.id });
if (dmResult.channel?.id)
return dmResult.channel.id;
}
// Fuzzy match with a STRICT confidence floor. Fuse score is 0 (perfect) → 1 (worst);
// require score <= 0.2 (>=80% similarity) before we DM a human.
const fuse = new Fuse(activeUsers, {
keys: ['name', 'real_name', 'profile.display_name'],
threshold: 0.2,
includeScore: true
});
const fuzzyResults = fuse.search(cleanName);
const STRICT = 0.2;
if (fuzzyResults.length > 0 && fuzzyResults[0].score <= STRICT) {
const user = fuzzyResults[0].item;
if (user.id) {
// Open a DM channel with the user
const dmResult = await client.conversations.open({ users: user.id });
if (dmResult.channel?.id)
return dmResult.channel.id;
}
}
// No high-confidence match. Do NOT silently send to the wrong place.
const channelCandidates = channels
.map(ch => ch.name)
.filter((n) => !!n && n.toLowerCase().includes(cleanName.toLowerCase().slice(0, 3)))
.slice(0, 5);
const userCandidates = fuzzyResults.slice(0, 5).map(r => r.item.name).filter(Boolean);
const candidates = [...channelCandidates, ...userCandidates];
throw new Error(`Could not confidently resolve recipient "${channelOrUser}". ` +
`No matching channel and no high-confidence user match. ` +
(candidates.length
? `Did you mean one of: ${candidates.join(', ')}? `
: '') +
`Pass an exact channel ID (C...), user ID (U...), or exact #channel-name.`);
}
async slackSendMessage(args) {
const validatedArgs = SendMessageArgsSchema.parse(args);
const client = await this.getActiveClient();
// Step 1: Resolve the recipient (channel or user) with fuzzy matching
const channelId = await this.resolveRecipient(validatedArgs.channel, client);
// Step 2: Format the message (markdown conversion)
let formattedText = this.convertMarkdownToSlackMrkdwn(validatedArgs.text);
// Step 3: ONLY process explicit @mentions (not context patterns)
formattedText = await this.processExplicitMentions(formattedText, client);
// Step 4: Send the message (with optional session identity override)
const result = await client.chat.postMessage({
channel: channelId,
text: formattedText,
thread_ts: validatedArgs.thread_ts,
mrkdwn: true,
...this.identityOverrides(args)
});
return {
content: [
{
type: "text",
text: `Message sent successfully! Channel: ${result.channel}, Timestamp: ${result.ts}`
}
]
};
}
// Lightweight text-table alternative to the PNG slack_send_diagram path. Renders
// an aligned ASCII table inside a code block so it stays cheap (text, not an image)
// and readable. Use this for status/comparison grids that don't need to be pretty.
async slackSendTable(args) {
const validatedArgs = SendTableArgsSchema.parse(args);
const client = await this.getActiveClient();
const channelId = await this.resolveRecipient(validatedArgs.channel, client);
const asCodeBlock = validatedArgs.as_code_block !== false;
const table = renderTable(validatedArgs.rows);
let text = "";
if (validatedArgs.title)
text += `*${validatedArgs.title}*\n`;
text += asCodeBlock ? "```\n" + table + "\n```" : table;
const result = await client.chat.postMessage({
channel: channelId,
text,
mrkdwn: true
});
return {
content: [
{
type: "text",
text: `Table posted. Channel: ${result.channel}, Timestamp: ${result.ts}`
}
]
};
}
// =====================================================================================
// BLOCK KIT CARDS + THREADED TASK LIFECYCLE (Bundle 2 / 3 / 4)
// =====================================================================================
taskKey(channel, ts) {
return `${channel}:${ts}`;
}
// Build chat.postMessage identity overrides (Bundle 4). Per-call username/icon args
// win, otherwise fall back to SLACK_SESSION_USERNAME / SLACK_SESSION_ICON env vars.
// Requires the chat:write.customize bot scope; Slack ignores/errors gracefully if
// absent. icon_emoji (e.g. ":robot_face:") and icon_url are both supported.
identityOverrides(args) {
const a = (args ?? {});
const username = a.username || process.env.SLACK_SESSION_USERNAME;
const icon = a.icon_emoji || process.env.SLACK_SESSION_ICON;
const iconUrl = a.icon_url;
const out = {};
if (username)
out.username = username;
if (iconUrl)
out.icon_url = iconUrl;
else if (icon) {
// Accept either ":emoji:" or a full URL in the icon var.
if (/^https?:\/\//.test(icon))
out.icon_url = icon;
else
out.icon_emoji = icon.startsWith(":") ? icon : `:${icon}:`;
}
return out;
}
// Live reaction-status indicator (Bundle 3). Swap a status reaction on a message:
// remove the previously-applied one (if any) and add the new one. Reuses the same
// reactions API as slack_add_reaction. Slack errors (already_reacted / no_reaction)
// are swallowed so a status flip never fails the whole task call.
async swapReaction(client, channel, ts, prev, next) {
if (prev && prev !== next) {
try {
await client.reactions.remove({ channel, timestamp: ts, name: prev });
}
catch { /* no_reaction / not_reactable — ignore */ }
}
try {
await client.reactions.add({ channel, timestamp: ts, name: next });
}
catch { /* already_reacted — ignore */ }
}
async slackSendCard(args) {
const validatedArgs = SendCardArgsSchema.parse(args);
const client = await this.getActiveClient();
const channelId = await this.resolveRecipient(validatedArgs.channel, client);
const card = buildCard({
header: validatedArgs.header,
sections: validatedArgs.sections,
context: validatedArgs.context,
color: validatedArgs.color
});
const result = await client.chat.postMessage({
channel: channelId,
text: validatedArgs.header, // fallback / notification text
blocks: card.blocks.length ? card.blocks : undefined,
attachments: card.attachments,
...this.identityOverrides(args)
});
return {
content: [
{ type: "text", text: `Card posted. Channel: ${result.channel}, Timestamp: ${result.ts}` }
]
};
}
async slackStartTask(args) {
const validatedArgs = StartTaskArgsSchema.parse(args);
const client = await this.getActiveClient();
const channelId = await this.resolveRecipient(validatedArgs.channel, client);
const sections = [{ text: ":hourglass_flowing_sand: *In progress*" }];
if (validatedArgs.total_steps) {
sections.push({ text: renderProgressBar(0) + ` (0/${validatedArgs.total_steps})` });
}
const card = buildCard({
header: `⏳ ${validatedArgs.title}`,
sections,
context: `Started ${new Date().toISOString()}`
});
const result = await client.chat.postMessage({
channel: channelId,
text: `⏳ ${validatedArgs.title}`,
blocks: card.blocks,
...this.identityOverrides(args)
});
const ts = result.ts;
const resolvedChannel = result.channel || channelId;
// Add the hourglass status reaction to the parent (Bundle 3).
await this.swapReaction(client, resolvedChannel, ts, undefined, "hourglass_flowing_sand");
this.taskRegistry.set(this.taskKey(resolvedChannel, ts), {
channel: resolvedChannel,
title: validatedArgs.title,
totalSteps: validatedArgs.total_steps,
reaction: "hourglass_flowing_sand"
});
return {
content: [
{ type: "text", text: JSON.stringify({ task_ts: ts, channel: resolvedChannel }) }
]
};
}
async slackUpdateTask(args) {
const validatedArgs = UpdateTaskArgsSchema.parse(args);
const client = await this.getActiveClient();
const channelId = await this.resolveRecipient(validatedArgs.channel, client);
const entry = this.taskRegistry.get(this.taskKey(channelId, validatedArgs.task_ts));
const totalSteps = validatedArgs.total_steps ?? entry?.totalSteps;
// Threaded reply with the progress message. NEVER @-mentions (routine update).
await client.chat.postMessage({
channel: channelId,
thread_ts: validatedArgs.task_ts,
text: validatedArgs.message,
...this.identityOverrides(args)