UNPKG

@chinchillaenterprises/mcp-slack

Version:

Multi-account MCP server for Slack with persistent credential storage and runtime workspace switching

1,149 lines (1,148 loc) 208 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 path from "path"; import os from "os"; import crypto from "crypto"; import Fuse from "fuse.js"; // ===================================================================================== // CREDENTIAL MANAGER CLASS // ===================================================================================== class CredentialManager { SERVICE_NAME = 'mcp-slack'; CONFIG_DIR = path.join(os.homedir(), '.mcp-slack'); CONFIG_FILE = path.join(this.CONFIG_DIR, 'accounts.json'); ENCRYPTION_KEY_FILE = path.join(this.CONFIG_DIR, '.key'); keytar = null; isKeytarAvailable = false; initPromise; encryptionKey = null; constructor() { this.initPromise = this.initialize(); } async initialize() { // Initialize keytar try { this.keytar = await import('keytar'); this.isKeytarAvailable = true; console.error('[CredentialManager] Keytar initialized successfully'); } catch (error) { console.error('[CredentialManager] Keytar not available, using file-based storage:', error); this.isKeytarAvailable = false; } // Ensure config directory exists try { await fs.mkdir(this.CONFIG_DIR, { recursive: true }); // Set directory permissions to be readable only by the user await fs.chmod(this.CONFIG_DIR, 0o700); } catch (error) { console.error('[CredentialManager] Failed to create config directory:', error); } // Initialize or load encryption key for file-based storage await this.initializeEncryptionKey(); } async initializeEncryptionKey() { try { // Try to read existing key this.encryptionKey = await fs.readFile(this.ENCRYPTION_KEY_FILE); } catch (error) { // Generate new key if doesn't exist this.encryptionKey = crypto.randomBytes(32); try { await fs.writeFile(this.ENCRYPTION_KEY_FILE, this.encryptionKey); await fs.chmod(this.ENCRYPTION_KEY_FILE, 0o600); // Read/write for owner only } catch (writeError) { console.error('[CredentialManager] Failed to save encryption key:', writeError); } } } encrypt(text) { if (!this.encryptionKey) return text; // Fallback to plain text if no key const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv('aes-256-cbc', this.encryptionKey, iv); let encrypted = cipher.update(text, 'utf8', 'hex'); encrypted += cipher.final('hex'); return iv.toString('hex') + ':' + encrypted; } decrypt(encryptedText) { if (!this.encryptionKey || !encryptedText.includes(':')) return encryptedText; try { const [ivHex, encrypted] = encryptedText.split(':'); const iv = Buffer.from(ivHex, 'hex'); const decipher = crypto.createDecipheriv('aes-256-cbc', this.encryptionKey, iv); let decrypted = decipher.update(encrypted, 'hex', 'utf8'); decrypted += decipher.final('utf8'); return decrypted; } catch (error) { console.error('[CredentialManager] Failed to decrypt:', error); return encryptedText; // Return as-is if decryption fails } } async ensureInitialized() { await this.initPromise; } async saveAccount(account) { await this.ensureInitialized(); const persistedAccount = { ...account, addedAt: new Date().toISOString(), lastUsed: new Date().toISOString() }; // Try keytar first if (this.isKeytarAvailable && this.keytar) { try { await this.keytar.setPassword(this.SERVICE_NAME, `slack-${account.id}`, JSON.stringify(persistedAccount)); console.error(`[CredentialManager] Account ${account.id} saved to keychain`); return; // Success with keytar } catch (error) { console.error('[CredentialManager] Keytar save failed, falling back to file:', error); } } // Fallback to file-based storage await this.saveToFile(persistedAccount); } async saveToFile(account) { try { // Read existing accounts const accounts = await this.loadFromFile(); // Encrypt sensitive data const encryptedAccount = { ...account, botToken: this.encrypt(account.botToken), userToken: account.userToken ? this.encrypt(account.userToken) : undefined }; // Update or add account accounts[account.id] = encryptedAccount; // Save back to file await fs.writeFile(this.CONFIG_FILE, JSON.stringify(accounts, null, 2), 'utf8'); await fs.chmod(this.CONFIG_FILE, 0o600); // Read/write for owner only console.error(`[CredentialManager] Account ${account.id} saved to file`); } catch (error) { console.error('[CredentialManager] Failed to save account to file:', error); throw error; } } async loadFromFile() { try { const data = await fs.readFile(this.CONFIG_FILE, 'utf8'); return JSON.parse(data); } catch (error) { // File doesn't exist or is invalid return {}; } } async updateAccount(account) { await this.ensureInitialized(); // Get existing to preserve addedAt const existing = await this.getAccount(account.id); const persistedAccount = { ...account, addedAt: existing?.addedAt || new Date().toISOString(), lastUsed: new Date().toISOString() }; // Try keytar first if (this.isKeytarAvailable && this.keytar) { try { await this.keytar.setPassword(this.SERVICE_NAME, `slack-${account.id}`, JSON.stringify(persistedAccount)); console.error(`[CredentialManager] Account ${account.id} updated in keychain`); return; // Success with keytar } catch (error) { console.error('[CredentialManager] Keytar update failed, falling back to file:', error); } } // Fallback to file-based storage await this.saveToFile(persistedAccount); } async loadAllAccounts() { await this.ensureInitialized(); const accounts = []; // Try keytar first if (this.isKeytarAvailable && this.keytar) { try { const credentials = await this.keytar.findCredentials(this.SERVICE_NAME); for (const cred of credentials) { try { const account = JSON.parse(cred.password); accounts.push(account); } catch (error) { console.error(`[CredentialManager] Failed to parse keytar account ${cred.account}:`, error); } } if (accounts.length > 0) { console.error(`[CredentialManager] Loaded ${accounts.length} accounts from keychain`); return accounts; } } catch (error) { console.error('[CredentialManager] Failed to load from keychain:', error); } } // Fallback to file-based storage try { const fileAccounts = await this.loadFromFile(); for (const [id, encryptedAccount] of Object.entries(fileAccounts)) { try { // Decrypt sensitive data const account = { ...encryptedAccount, botToken: this.decrypt(encryptedAccount.botToken), userToken: encryptedAccount.userToken ? this.decrypt(encryptedAccount.userToken) : undefined }; accounts.push(account); } catch (error) { console.error(`[CredentialManager] Failed to process file account ${id}:`, error); } } console.error(`[CredentialManager] Loaded ${accounts.length} accounts from file`); } catch (error) { console.error('[CredentialManager] Failed to load from file:', error); } return accounts; } async getAccount(accountId) { await this.ensureInitialized(); // Try keytar first if (this.isKeytarAvailable && this.keytar) { try { const password = await this.keytar.getPassword(this.SERVICE_NAME, `slack-${accountId}`); if (password) { return JSON.parse(password); } } catch (error) { console.error(`[CredentialManager] Failed to get account ${accountId} from keychain:`, error); } } // Fallback to file-based storage try { const accounts = await this.loadFromFile(); const encryptedAccount = accounts[accountId]; if (encryptedAccount) { // Decrypt sensitive data const account = { ...encryptedAccount, botToken: this.decrypt(encryptedAccount.botToken), userToken: encryptedAccount.userToken ? this.decrypt(encryptedAccount.userToken) : undefined }; return account; } } catch (error) { console.error(`[CredentialManager] Failed to get account ${accountId} from file:`, error); } return null; } async deleteAccount(accountId) { await this.ensureInitialized(); // Try keytar first if (this.isKeytarAvailable && this.keytar) { try { await this.keytar.deletePassword(this.SERVICE_NAME, `slack-${accountId}`); console.error(`[CredentialManager] Account ${accountId} deleted from keychain`); } catch (error) { console.error('[CredentialManager] Failed to delete from keychain:', error); } } // Also delete from file-based storage try { const accounts = await this.loadFromFile(); delete accounts[accountId]; await fs.writeFile(this.CONFIG_FILE, JSON.stringify(accounts, null, 2), 'utf8'); console.error(`[CredentialManager] Account ${accountId} deleted from file`); } catch (error) { console.error('[CredentialManager] Failed to delete from file:', error); } } async isAvailable() { await this.ensureInitialized(); return this.isKeytarAvailable; } } // ===================================================================================== // VALIDATION SCHEMAS - Account Management Tools // ===================================================================================== const ListAccountsArgsSchema = z.object({}); const SwitchAccountArgsSchema = z.object({ account_id: z.string().describe("Account ID to switch to") }); const AddAccountArgsSchema = z.object({ name: z.string().describe("Human-friendly name for this account"), bot_token: z.string().describe("Bot User OAuth Token (starts with xoxb-)"), user_token: z.string().optional().describe("User OAuth Token (starts with xoxp-) - optional"), team_id: z.string().describe("Slack team ID") }); const RemoveAccountArgsSchema = z.object({ account_id: z.string().describe("Account ID to remove") }); const GetActiveAccountArgsSchema = z.object({}); const SetDefaultAccountArgsSchema = z.object({ account_id: z.string().describe("Account ID to set as default") }); const UpdateAccountArgsSchema = z.object({ account_id: z.string().describe("Account ID to update"), bot_token: z.string().optional().describe("New Bot User OAuth Token (starts with xoxb-)"), user_token: z.string().optional().describe("New User OAuth Token (starts with xoxp-)") }); // ===================================================================================== // EXISTING TOOL SCHEMAS (from original mcp-slacker) // ===================================================================================== const ListChannelsArgsSchema = z.object({ types: z.string().optional().describe("Comma-separated list of channel types: public_channel, private_channel, mpim, im"), limit: z.number().optional().describe("Maximum number of channels to return (default: 100)") }); 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") }); 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)") }); 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)") }); const ListUsersArgsSchema = z.object({ limit: z.number().optional().describe("Maximum number of users to return (default: 100)") }); const GetUserByNameArgsSchema = z.object({ name: z.string().describe("Name or display name to search for") }); 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)") }); 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)") }); 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)") }); 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 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({}); const SendFormattedMessageArgsSchema = z.object({ channel: z.string().describe("Channel ID or name"), blocks: z.array(z.any()).describe("Slack Block Kit blocks for rich formatting"), text: z.string().optional().describe("Fallback text") }); 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)") }); // ===================================================================================== // MAIN SERVER CLASS // ===================================================================================== class SlackerV3Server { server; accountState; credentialManager; constructor() { this.server = new Server({ name: "mcp-slack", version: "3.0.2", }, { capabilities: { tools: {}, }, }); // Initialize account state this.accountState = { accounts: new Map(), activeAccountId: null, clients: new Map(), userClients: new Map() }; // Initialize credential manager this.credentialManager = new CredentialManager(); // Initialize with default account if environment variables are provided this.initializeDefaultAccount(); // Setup handlers immediately so the server can start this.setupHandlers(); } // ===================================================================================== // ACCOUNT MANAGEMENT METHODS // ===================================================================================== async loadPersistedAccounts() { try { const startTime = Date.now(); console.error('[SlackServer] Loading persisted accounts...'); const persistedAccounts = await this.credentialManager.loadAllAccounts(); console.error(`[SlackServer] Found ${persistedAccounts.length} persisted accounts`); for (const account of persistedAccounts) { // Don't overwrite if already loaded from env if (!this.accountState.accounts.has(account.id)) { this.accountState.accounts.set(account.id, account); this.initializeClient(account); // Set first loaded account as active if none set if (!this.accountState.activeAccountId) { this.accountState.activeAccountId = account.id; console.error(`[SlackServer] Set ${account.id} as active account`); } } } const loadTime = Date.now() - startTime; console.error(`[SlackServer] Account loading completed in ${loadTime}ms`); console.error(`[SlackServer] Active accounts: ${Array.from(this.accountState.accounts.keys()).join(', ')}`); } catch (error) { console.error('[SlackServer] Failed to load persisted accounts:', error); } } initializeDefaultAccount() { const defaultBotToken = process.env.DEFAULT_BOT_TOKEN; const defaultUserToken = process.env.DEFAULT_USER_TOKEN; const defaultTeamId = process.env.DEFAULT_TEAM_ID; const defaultAccountName = process.env.DEFAULT_ACCOUNT_NAME; console.error("[SlackServer] Environment variables:", { DEFAULT_BOT_TOKEN: defaultBotToken ? "Set" : "Not set", DEFAULT_USER_TOKEN: defaultUserToken ? "Set" : "Not set", DEFAULT_TEAM_ID: defaultTeamId, DEFAULT_ACCOUNT_NAME: defaultAccountName }); if (defaultBotToken && defaultTeamId) { const defaultAccount = { id: 'default', name: defaultAccountName || 'Default Workspace', workspace: 'Unknown', // Will be fetched on first use teamId: defaultTeamId, botToken: defaultBotToken, userToken: defaultUserToken, isDefault: true }; this.accountState.accounts.set(defaultAccount.id, defaultAccount); this.accountState.activeAccountId = defaultAccount.id; this.initializeClient(defaultAccount); // Save env-based account to keychain for next time this.credentialManager.saveAccount(defaultAccount).catch(error => { console.error('Failed to persist env-based account:', error); }); } } initializeClient(account) { // Initialize bot client const botClient = new WebClient(account.botToken); this.accountState.clients.set(account.id, botClient); // Initialize user client if user token is provided if (account.userToken) { const userClient = new WebClient(account.userToken); this.accountState.userClients.set(account.id, userClient); } } validateTokenFormat(token, type) { if (type === 'bot') { return token.startsWith('xoxb-'); } else { return token.startsWith('xoxp-'); } } generateAccountId(name) { // Generate a safe account ID from the name const baseId = name.toLowerCase() .replace(/[^a-z0-9]/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); let id = baseId; let counter = 1; // Ensure uniqueness while (this.accountState.accounts.has(id)) { id = `${baseId}-${counter}`; counter++; } return id; } async getActiveClient() { if (!this.accountState.activeAccountId) { throw new Error("No active Slack account. Use 'list_accounts' to see available accounts, or 'add_account' to add one."); } const client = this.accountState.clients.get(this.accountState.activeAccountId); if (!client) { throw new Error("Active account client not found. This shouldn't happen - please try switching accounts."); } // Debug: Log token being used const activeAccount = this.accountState.accounts.get(this.accountState.activeAccountId); console.error("Using bot token:", activeAccount?.botToken); return client; } async getActiveUserClient() { if (!this.accountState.activeAccountId) { throw new Error("No active Slack account. Use 'switch_account' first."); } // Try to get user client first, fall back to bot client let client = this.accountState.userClients.get(this.accountState.activeAccountId); if (!client) { client = this.accountState.clients.get(this.accountState.activeAccountId); } if (!client) { throw new Error("Active account client not found."); } return client; } maskToken(token) { if (token.length <= 8) return "***"; return token.substring(0, 8) + "***" + token.substring(token.length - 4); } // ===================================================================================== // ACCOUNT MANAGEMENT TOOL IMPLEMENTATIONS // ===================================================================================== async listAccounts(args) { ListAccountsArgsSchema.parse(args); const accounts = Array.from(this.accountState.accounts.values()).map(account => ({ id: account.id, name: account.name, workspace: account.workspace, team_id: account.teamId, is_default: account.isDefault || false, is_active: account.id === this.accountState.activeAccountId, has_user_token: !!account.userToken, bot_token: this.maskToken(account.botToken), user_token: account.userToken ? this.maskToken(account.userToken) : null })); return { content: [ { type: "text", text: JSON.stringify({ total_accounts: accounts.length, active_account_id: this.accountState.activeAccountId, accounts: accounts }, null, 2) } ] }; } async switchAccount(args) { const validatedArgs = SwitchAccountArgsSchema.parse(args); const account = this.accountState.accounts.get(validatedArgs.account_id); if (!account) { throw new Error(`Account '${validatedArgs.account_id}' not found. Use 'list_accounts' to see available accounts.`); } // Initialize client if not exists if (!this.accountState.clients.has(account.id)) { this.initializeClient(account); } // Switch active account this.accountState.activeAccountId = account.id; // Fetch workspace info if not cached if (account.workspace === 'Unknown') { try { const client = this.accountState.clients.get(account.id); const teamInfo = await client.team.info(); account.workspace = teamInfo.team?.name || 'Unknown'; } catch (error) { // If we can't fetch workspace info, that's ok - we'll use 'Unknown' console.error(`Could not fetch workspace info for ${account.id}:`, error); } } return { content: [ { type: "text", text: JSON.stringify({ success: true, message: `Switched to ${account.name} (${account.workspace})`, account: { id: account.id, name: account.name, workspace: account.workspace, team_id: account.teamId } }, null, 2) } ] }; } async addAccount(args) { const validatedArgs = AddAccountArgsSchema.parse(args); // Validate token formats if (!this.validateTokenFormat(validatedArgs.bot_token, 'bot')) { throw new Error("Invalid bot token format. Bot tokens must start with 'xoxb-'"); } if (validatedArgs.user_token && !this.validateTokenFormat(validatedArgs.user_token, 'user')) { throw new Error("Invalid user token format. User tokens must start with 'xoxp-'"); } // Generate unique account ID const accountId = this.generateAccountId(validatedArgs.name); // Create new account const newAccount = { id: accountId, name: validatedArgs.name, workspace: 'Unknown', // Will be fetched on first use teamId: validatedArgs.team_id, botToken: validatedArgs.bot_token, userToken: validatedArgs.user_token, isDefault: false }; // Test the bot token by trying to get team info try { const testClient = new WebClient(validatedArgs.bot_token); const teamInfo = await testClient.team.info(); newAccount.workspace = teamInfo.team?.name || 'Unknown'; // Verify team ID matches if (teamInfo.team?.id && teamInfo.team.id !== validatedArgs.team_id) { throw new Error(`Team ID mismatch. Token belongs to team ${teamInfo.team.id}, but ${validatedArgs.team_id} was provided.`); } } catch (error) { throw new Error(`Failed to verify bot token: ${error instanceof Error ? error.message : String(error)}`); } // Add to accounts this.accountState.accounts.set(accountId, newAccount); this.initializeClient(newAccount); // If this is the first account, make it active if (!this.accountState.activeAccountId) { this.accountState.activeAccountId = accountId; } // Persist to keychain try { await this.credentialManager.saveAccount(newAccount); return { content: [ { type: "text", text: JSON.stringify({ success: true, message: `Account '${validatedArgs.name}' added and saved to secure storage`, account: { id: accountId, name: newAccount.name, workspace: newAccount.workspace, team_id: newAccount.teamId, is_active: accountId === this.accountState.activeAccountId } }, null, 2) } ] }; } catch (error) { // Still works, just not persisted return { content: [ { type: "text", text: JSON.stringify({ success: true, message: `Account '${validatedArgs.name}' added (temporary - secure storage unavailable)`, account: { id: accountId, name: newAccount.name, workspace: newAccount.workspace, team_id: newAccount.teamId, is_active: accountId === this.accountState.activeAccountId } }, null, 2) } ] }; } } async removeAccount(args) { const validatedArgs = RemoveAccountArgsSchema.parse(args); const account = this.accountState.accounts.get(validatedArgs.account_id); if (!account) { throw new Error(`Account '${validatedArgs.account_id}' not found.`); } // Check if this is the active account const wasActive = this.accountState.activeAccountId === validatedArgs.account_id; // Remove from all maps this.accountState.accounts.delete(validatedArgs.account_id); this.accountState.clients.delete(validatedArgs.account_id); this.accountState.userClients.delete(validatedArgs.account_id); // If this was the active account, switch to another one or clear active if (wasActive) { const remainingAccounts = Array.from(this.accountState.accounts.keys()); this.accountState.activeAccountId = remainingAccounts.length > 0 ? remainingAccounts[0] : null; } // Remove from keychain try { await this.credentialManager.deleteAccount(validatedArgs.account_id); return { content: [ { type: "text", text: JSON.stringify({ success: true, message: `Account '${account.name}' removed from memory and secure storage`, new_active_account: this.accountState.activeAccountId, remaining_accounts: this.accountState.accounts.size }, null, 2) } ] }; } catch (error) { return { content: [ { type: "text", text: JSON.stringify({ success: true, message: `Account '${account.name}' removed from memory (secure storage removal failed)`, new_active_account: this.accountState.activeAccountId, remaining_accounts: this.accountState.accounts.size }, null, 2) } ] }; } } async getActiveAccount(args) { GetActiveAccountArgsSchema.parse(args); if (!this.accountState.activeAccountId) { return { content: [ { type: "text", text: JSON.stringify({ active_account: null, message: "No active account. Use 'list_accounts' to see available accounts, or 'add_account' to add one." }, null, 2) } ] }; } const account = this.accountState.accounts.get(this.accountState.activeAccountId); if (!account) { return { content: [ { type: "text", text: JSON.stringify({ active_account: null, error: "Active account not found in registry" }, null, 2) } ] }; } return { content: [ { type: "text", text: JSON.stringify({ active_account: { id: account.id, name: account.name, workspace: account.workspace, team_id: account.teamId, is_default: account.isDefault || false, has_user_token: !!account.userToken } }, null, 2) } ] }; } async setDefaultAccount(args) { const validatedArgs = SetDefaultAccountArgsSchema.parse(args); const account = this.accountState.accounts.get(validatedArgs.account_id); if (!account) { throw new Error(`Account '${validatedArgs.account_id}' not found.`); } // Clear default flag from all accounts for (const acc of this.accountState.accounts.values()) { acc.isDefault = false; } // Set new default account.isDefault = true; return { content: [ { type: "text", text: JSON.stringify({ success: true, message: `Account '${account.name}' set as default`, default_account: { id: account.id, name: account.name, workspace: account.workspace } }, null, 2) } ] }; } async updateAccount(args) { const validatedArgs = UpdateAccountArgsSchema.parse(args); const account = this.accountState.accounts.get(validatedArgs.account_id); if (!account) { throw new Error(`Account '${validatedArgs.account_id}' not found.`); } // Validate new tokens if provided if (validatedArgs.bot_token && !this.validateTokenFormat(validatedArgs.bot_token, 'bot')) { throw new Error("Invalid bot token format. Bot tokens must start with 'xoxb-'"); } if (validatedArgs.user_token && !this.validateTokenFormat(validatedArgs.user_token, 'user')) { throw new Error("Invalid user token format. User tokens must start with 'xoxp-'"); } // Update tokens in memory let tokensUpdated = false; if (validatedArgs.bot_token && validatedArgs.bot_token !== account.botToken) { account.botToken = validatedArgs.bot_token; tokensUpdated = true; } if (validatedArgs.user_token !== undefined && validatedArgs.user_token !== account.userToken) { account.userToken = validatedArgs.user_token; tokensUpdated = true; } if (!tokensUpdated) { return { content: [ { type: "text", text: JSON.stringify({ success: false, message: "No token changes provided" }, null, 2) } ] }; } // Re-initialize clients with new tokens this.initializeClient(account); // Update workspace info if bot token changed if (validatedArgs.bot_token) { try { const client = this.accountState.clients.get(account.id); const teamInfo = await client.team.info(); account.workspace = teamInfo.team?.name || account.workspace; } catch (error) { console.error('Failed to update workspace info:', error); } } // Persist updated account try { await this.credentialManager.updateAccount(account); return { content: [ { type: "text", text: JSON.stringify({ success: true, message: `Updated tokens for '${account.name}' and saved to secure storage`, account: { id: account.id, name: account.name, workspace: account.workspace, team_id: account.teamId, has_user_token: !!account.userToken } }, null, 2) } ] }; } catch (error) { return { content: [ { type: "text", text: JSON.stringify({ success: true, message: `Updated tokens for '${account.name}' (temporary - secure storage unavailable)`, account: { id: account.id, name: account.name, workspace: account.workspace, team_id: account.teamId, has_user_token: !!account.userToken } }, null, 2) } ] }; } } // ===================================================================================== // EXISTING SLACK TOOL IMPLEMENTATIONS (Modified for Multi-Account) // ===================================================================================== 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({ types: validatedArgs.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); 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) } ] }; } 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.accounts.get(this.accountState.activeAccountId); 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 findAndReplaceMentions(text, client) { // Get all users once const result = await client.users.list({}); const activeUsers = result.members?.filter(u => !u.deleted && !u.is_bot) || []; // First, handle @mentions that are already in the text let processedText = 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() || u.profile?.first_name?.toLowerCase() === username.toLowerCase()); // If no exact match, use fuzzy matching if (!user && username.length > 2) { const searchableUsers = activeUsers.map(u => ({ ...u, searchableNames: [ u.name, u.real_name, u.profile?.display_name, u.profile?.first_name, u.profile?.last_name ].filter(Boolean).join(' ') })); const fuse = new Fuse(searchableUsers, { keys: ['searchableNames'], threshold: 0.4, includeScore: true }); const fuzzyResults = fuse.search(username); if (fuzzyResults.length > 0) { user = fuzzyResults[0].item; } } return user && user.id ? `<@${user.id}>` : match; }); // Then handle other patterns like "tell X", "notify Y" const contextPatterns = [ /\b(?:to|tell|notify|cc|ping)\s+(\w+)/gi, /^(\w+)[,:](?:\s|$)/gm, /\b(?:hi|hello|hey)\s+(\w+)/gi // "Hi Ricardo", "Hello Johnny" ]; for (const pattern of contextPatterns) { processedText = processedText.replace(pattern, (match, username) => { // Skip if it's already a mention if (match.includes('<@')) return match; // Try exact match let user = activeUsers.find(u => u.name?.toLowerCase() === username.toLowerCase() || u.real_name?.toLowerCase() === username.toLowerCase() || u.profile?.display_name?.toLowerCase() === username.toLowerCase() || u.profile?.first_name?.toLowerCase() === username.toLowerCase()); // Fuzzy match if needed if (!user && username.length > 2) { const searchableUsers = activeUsers.map(u => ({ ...u, searchableNames: [ u.name, u.real_name, u.profile?.display_name, u.profile?.first_name, u.profile?.last_name ].filter(Boolean).join(' ') })); const fuse = new Fuse(searchableUsers, { keys: ['searchableNames'], threshold: 0.4, includeScore: true }); const fuzzyResults = fuse.search(username); if (fuzzyResults.length > 0) { user = fuzzyResults[0].item; } } if (user && user.id) { // Replace just the username part with the mention return match.replace(username, `<@${user.id}>`); } return match; }); } return processedText; } async slackSendMessage(args) { const validatedArgs = SendMessageArgsSchema.parse(args); const client = await this.getActiveClient(); // Handle channel names that start with # let channelId = validatedArgs.channel; if (channelId.startsWith('#')) { // Try to find the channel by name const channelName = channelId.substring(1); const result = await client.conversations.list({ types: "public_channel,private_channel", limit: 1000 }); const channel = result.channels?.find(ch => ch.name === channelName); if (channel) { channelId = channel.id; } else { throw new Error(`Channel #${channelName} not found`); } } // Convert markdown to Slack mrkdwn format let formattedText = this.convertMarkdownToSlackMrkdwn(validatedArgs.text); // Find and replace user mentions formattedText = await this.findAndReplaceMentions(formattedText, client); const result = await client.chat.postMessage({ channel: channelId, text: formattedText, thread_ts: validatedArgs.thread_ts, mrkdwn: true }); return { content: [ { type: "text", text: `Message sent successfully! Channel: ${result.channel}, Timestamp: ${result.ts}` } ] }; } async slackGetChannelHistory(args) {