UNPKG

@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
#!/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)