import { WebClient, WebAPICallResult } from '@slack/web-api';
import {
ConversationsHistorySchema,
ConversationsRepliesSchema,
ConversationsAddMessageSchema,
ChannelsListSchema,
ConversationsHistoryInput,
ConversationsRepliesInput,
ConversationsAddMessageInput,
ChannelsListInput
} from './schemas.js';
/**
* Standard response format for all tools
*/
interface ToolResponse {
success: boolean;
data?: any;
error?: string;
}
/**
* Parse limit parameter (can be time range like "7d" or count like 100)
*/
function parseLimit(limit?: string | number): { oldest?: string; limit?: number } {
if (!limit) {
return { limit: 100 }; // Default
}
if (typeof limit === 'number') {
return { limit };
}
// Parse time ranges: 1d, 7d, 1m, 90d
const timeMatch = limit.match(/^(\d+)(d|m)$/);
if (timeMatch) {
const value = parseInt(timeMatch[1]);
const unit = timeMatch[2];
const now = Date.now();
let seconds = 0;
if (unit === 'd') {
seconds = value * 24 * 60 * 60;
} else if (unit === 'm') {
seconds = value * 30 * 24 * 60 * 60;
}
const oldest = ((now - seconds * 1000) / 1000).toString();
return { oldest };
}
// If not a time range, treat as count
const count = parseInt(limit);
if (!isNaN(count)) {
return { limit: count };
}
return { limit: 100 }; // Default fallback
}
/**
* Resolve channel name or @username to channel ID
*/
async function resolveChannelId(client: WebClient, channelInput: string): Promise<string> {
// If already an ID (starts with C, D, or G), return as-is
if (/^[CDG][A-Z0-9]+$/.test(channelInput)) {
return channelInput;
}
// Handle #channel format
if (channelInput.startsWith('#')) {
const channelName = channelInput.slice(1);
const result = await client.conversations.list({ types: 'public_channel,private_channel' });
const channel = result.channels?.find((ch: any) => ch.name === channelName);
if (channel) {
return channel.id!;
}
throw new Error(`Channel not found: ${channelInput}`);
}
// Handle @username format for DMs
if (channelInput.startsWith('@')) {
const username = channelInput.slice(1);
const usersResult = await client.users.list({});
const user = usersResult.members?.find((u: any) => u.name === username);
if (user) {
// Open or get existing DM
const dmResult = await client.conversations.open({ users: user.id! });
return dmResult.channel!.id!;
}
throw new Error(`User not found: ${channelInput}`);
}
return channelInput; // Return as-is if no special format
}
/**
* Handle Slack API errors with specific messages
*/
function handleSlackError(error: any): ToolResponse {
console.error('Slack API error:', error.data?.error || error.message);
if (error.data?.error) {
switch (error.data.error) {
case 'channel_not_found':
return { success: false, error: 'Channel not found with the provided ID' };
case 'invalid_auth':
case 'token_revoked':
case 'token_expired':
return { success: false, error: 'Invalid or expired authentication credentials' };
case 'not_in_channel':
return { success: false, error: 'Bot is not a member of this channel' };
case 'missing_scope':
return { success: false, error: 'Token is missing required Slack permissions/scopes' };
case 'rate_limited':
return { success: false, error: 'Rate limited by Slack API - please try again later' };
case 'thread_not_found':
return { success: false, error: 'Thread not found with the provided timestamp' };
case 'user_not_found':
return { success: false, error: 'User not found with the provided identifier' };
case 'no_permission':
return { success: false, error: 'Insufficient permissions to perform this action' };
default:
return { success: false, error: `Slack API error: ${error.data.error}` };
}
}
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
}
/**
* Tool: conversations_history
* Retrieves message history from a Slack channel, DM, or thread
*/
export async function conversationsHistory(args: any): Promise<ToolResponse> {
try {
const validated = ConversationsHistorySchema.parse(args);
const client = new WebClient(validated.accessToken);
console.log('Fetching conversation history for channel:', validated.channel_id);
// Resolve channel name/username to ID if needed
const channelId = await resolveChannelId(client, validated.channel_id);
// Parse limit parameter
const limitParams = parseLimit(validated.limit);
// Fetch conversation history
const result = await client.conversations.history({
channel: channelId,
cursor: validated.cursor,
...limitParams
});
// Filter out activity messages if not requested
let messages = result.messages || [];
if (!validated.include_activity_messages) {
messages = messages.filter((msg: any) => !msg.subtype || !['channel_join', 'channel_leave'].includes(msg.subtype));
}
return {
success: true,
data: {
messages,
has_more: result.has_more,
cursor: result.response_metadata?.next_cursor,
channel_id: channelId
}
};
} catch (error: any) {
if (error.name === 'ZodError') {
return { success: false, error: `Validation error: ${error.errors.map((e: any) => e.message).join(', ')}` };
}
return handleSlackError(error);
}
}
/**
* Tool: conversations_replies
* Fetches thread messages by channel and thread timestamp
*/
export async function conversationsReplies(args: any): Promise<ToolResponse> {
try {
const validated = ConversationsRepliesSchema.parse(args);
const client = new WebClient(validated.accessToken);
console.log('Fetching thread replies for channel:', validated.channel_id, 'thread:', validated.thread_ts);
// Resolve channel name/username to ID if needed
const channelId = await resolveChannelId(client, validated.channel_id);
// Parse limit parameter
const limitParams = parseLimit(validated.limit);
// Fetch thread replies
const result = await client.conversations.replies({
channel: channelId,
ts: validated.thread_ts,
cursor: validated.cursor,
...limitParams
});
// Filter out activity messages if not requested
let messages = result.messages || [];
if (!validated.include_activity_messages) {
messages = messages.filter((msg: any) => !msg.subtype || !['channel_join', 'channel_leave'].includes(msg.subtype));
}
return {
success: true,
data: {
messages,
has_more: result.has_more,
cursor: result.response_metadata?.next_cursor,
channel_id: channelId,
thread_ts: validated.thread_ts
}
};
} catch (error: any) {
if (error.name === 'ZodError') {
return { success: false, error: `Validation error: ${error.errors.map((e: any) => e.message).join(', ')}` };
}
return handleSlackError(error);
}
}
/**
* Tool: conversations_add_message
* Posts messages to channels, threads, or DMs
* NOTE: Disabled by default for safety - requires environment variable to enable
*/
export async function conversationsAddMessage(args: any): Promise<ToolResponse> {
try {
// Safety check - require explicit enable
const enabledChannels = process.env.SLACK_MCP_ADD_MESSAGE_TOOL;
if (!enabledChannels) {
return {
success: false,
error: 'Message posting is disabled by default for safety. Set SLACK_MCP_ADD_MESSAGE_TOOL environment variable to enable.'
};
}
const validated = ConversationsAddMessageSchema.parse(args);
const client = new WebClient(validated.accessToken);
console.log('Posting message to channel:', validated.channel_id);
// Resolve channel name/username to ID if needed
const channelId = await resolveChannelId(client, validated.channel_id);
// Check if channel is in enabled list (if not '*')
if (enabledChannels !== '*') {
const allowedChannels = enabledChannels.split(',').map(c => c.trim());
if (!allowedChannels.includes(channelId) && !allowedChannels.includes(validated.channel_id)) {
return {
success: false,
error: `Message posting not enabled for channel: ${validated.channel_id}`
};
}
}
// Post message
const result = await client.chat.postMessage({
channel: channelId,
text: validated.payload,
thread_ts: validated.thread_ts,
mrkdwn: validated.content_type === 'text/markdown'
});
return {
success: true,
data: {
ok: result.ok,
channel: result.channel,
ts: result.ts,
message: result.message
}
};
} catch (error: any) {
if (error.name === 'ZodError') {
return { success: false, error: `Validation error: ${error.errors.map((e: any) => e.message).join(', ')}` };
}
return handleSlackError(error);
}
}
/**
* Tool: channels_list
* Lists workspace channels with optional sorting
*/
export async function channelsList(args: any): Promise<ToolResponse> {
try {
const validated = ChannelsListSchema.parse(args);
const client = new WebClient(validated.accessToken);
console.log('Listing channels of types:', validated.channel_types);
// Fetch channels
const result = await client.conversations.list({
types: validated.channel_types,
limit: validated.limit || 100,
cursor: validated.cursor
});
let channels = result.channels || [];
// Sort by popularity (member count) if requested
if (validated.sort === 'popularity') {
channels = channels.sort((a: any, b: any) => {
const aCount = a.num_members || 0;
const bCount = b.num_members || 0;
return bCount - aCount;
});
}
return {
success: true,
data: {
channels: channels.map((ch: any) => ({
id: ch.id,
name: ch.name,
topic: ch.topic?.value,
purpose: ch.purpose?.value,
member_count: ch.num_members,
is_private: ch.is_private,
is_channel: ch.is_channel,
is_im: ch.is_im,
is_mpim: ch.is_mpim
})),
has_more: Boolean(result.response_metadata?.next_cursor),
cursor: result.response_metadata?.next_cursor
}
};
} catch (error: any) {
if (error.name === 'ZodError') {
return { success: false, error: `Validation error: ${error.errors.map((e: any) => e.message).join(', ')}` };
}
return handleSlackError(error);
}
}