@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
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 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) {