Slack is where most engineering decisions get made, where incidents get triaged, and where context lives. Wiring Slack into your AI assistant via Model Context Protocol (MCP) means Claude (or any other MCP-aware client) can search past discussions, summarise channels, and post replies — all without leaving the chat where you are working.
This tutorial walks through building a custom Slack MCP server in Node.js with proper OAuth, real Slack Web API calls, and the patterns you need to keep tokens safe. By the end you will have a working server that exposes a useful subset of Slack to any MCP client.
What we will build
A Slack MCP server with five tools:
search_messages— find messages matching a query across channels you can seelist_channels— list channels with optional filteringget_channel_history— fetch recent messages from a specific channelpost_message— send a message to a channel (write — guarded)get_user_info— look up a user by ID, email, or display name
That covers the bulk of real-world use: searching, reading, and the occasional reply.
Authentication: pick your model
Slack has two practical auth options for an MCP server:
Option A: User OAuth (recommended for personal use)
A Slack app that you install into your own workspace, with user-level scopes. The server acts as you — searches see what you can see, posts come from your account.
Setup steps:
- Go to https://api.slack.com/apps and click Create New App → From scratch.
- Add User Token Scopes under OAuth & Permissions. For the five tools above you need:
search:read,channels:history,groups:history,im:history,mpim:history,chat:write,users:read,users:read.email. - Install the app to your workspace.
- Copy the User OAuth Token (starts with
xoxp-).
Option B: Bot Token (for shared servers)
A bot user in your workspace. Cleaner separation, but the bot needs to be invited to every channel it reads. Use this if multiple people will share the server.
For this tutorial we use Option A (user OAuth) — simpler and more flexible for personal-use MCP servers.
Setup
mkdir slack-mcp
cd slack-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod @slack/web-api
Add "type": "module" to package.json. The @slack/web-api package is Slack's official Node SDK — handles auth, pagination, retries, rate limits.
The server skeleton
Create server.js:
#!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { WebClient } from '@slack/web-api';
import { z } from 'zod';
const token = process.env.SLACK_TOKEN;
if (!token) {
console.error('Set SLACK_TOKEN environment variable to your Slack User OAuth token (xoxp-...).');
process.exit(1);
}
const slack = new WebClient(token);
const server = new McpServer({ name: 'slack-helper', version: '1.0.0' });
// Tools go here
await server.connect(new StdioServerTransport());
A strict token check at startup saves a lot of confused debugging later — failing loud is better than silently making unauthenticated API calls.
Tool 1: search_messages
Slack's search API supports the same syntax you use in the search bar — from:@username, in:#channel, has:link, etc.
server.tool(
'search_messages',
'Search Slack messages using Slack search syntax. Examples: "from:@alice in:#engineering payment", "has:link before:2026-05-01".',
{
query: z.string().describe('Slack search query'),
limit: z.number().int().min(1).max(50).default(20),
},
async ({ query, limit }) => {
const result = await slack.search.messages({ query, count: limit, sort: 'timestamp' });
const matches = result.messages?.matches || [];
if (!matches.length) return { content: [{ type: 'text', text: `No matches for "${query}".` }] };
const lines = matches.map(m =>
`[${new Date(parseFloat(m.ts) * 1000).toISOString().slice(0, 16)}] #${m.channel?.name || '?'} · @${m.username || 'unknown'}\n ${m.text.slice(0, 200).replace(/\n/g, ' ')}\n ${m.permalink}`
);
return {
content: [{ type: 'text', text: `${result.messages?.total || matches.length} total matches, showing ${matches.length}:\n\n${lines.join('\n\n')}` }],
};
}
);
Notice the description includes example queries — that single change makes the LLM construct vastly better searches.
Tool 2: list_channels
server.tool(
'list_channels',
'List channels in the workspace, optionally filtered by name pattern.',
{
name_contains: z.string().optional().describe('Substring to filter channel names'),
limit: z.number().int().min(1).max(200).default(50),
},
async ({ name_contains, limit }) => {
const result = await slack.conversations.list({ limit: 200, exclude_archived: true, types: 'public_channel,private_channel' });
let channels = result.channels || [];
if (name_contains) {
const q = name_contains.toLowerCase();
channels = channels.filter(c => c.name.toLowerCase().includes(q));
}
channels = channels.slice(0, limit);
const lines = channels.map(c => `• #${c.name}${c.is_private ? ' 🔒' : ''}${c.purpose?.value ? ` — ${c.purpose.value}` : ''}`);
return { content: [{ type: 'text', text: lines.join('\n') || 'No channels match.' }] };
}
);
Pagination note: Slack's conversations.list caps at 200 per page. For workspaces with more than 200 channels, you would loop with cursor. The 200-channel filter keeps this tutorial simple.
Tool 3: get_channel_history
server.tool(
'get_channel_history',
'Fetch the most recent messages from a channel (by name or ID).',
{
channel: z.string().describe('Channel name like "engineering" (with or without #) or ID like "C012345"'),
limit: z.number().int().min(1).max(100).default(20),
},
async ({ channel, limit }) => {
let channelId = channel;
if (!channel.startsWith('C') && !channel.startsWith('G')) {
const list = await slack.conversations.list({ limit: 1000 });
const cleaned = channel.replace(/^#/, '').toLowerCase();
const match = list.channels?.find(c => c.name === cleaned);
if (!match) {
return { content: [{ type: 'text', text: `Channel "${channel}" not found.` }], isError: true };
}
channelId = match.id;
}
const result = await slack.conversations.history({ channel: channelId, limit });
const messages = result.messages || [];
if (!messages.length) return { content: [{ type: 'text', text: 'No recent messages.' }] };
const lines = await Promise.all(messages.reverse().map(async m => {
const userInfo = m.user ? await slack.users.info({ user: m.user }) : null;
const author = userInfo?.user?.real_name || m.username || m.user || 'unknown';
const time = new Date(parseFloat(m.ts) * 1000).toISOString().slice(11, 16);
return `[${time}] ${author}: ${m.text?.slice(0, 280) || '(no text)'}`;
}));
return { content: [{ type: 'text', text: lines.join('\n') }] };
}
);
The lookup-by-name pattern is friendlier than forcing the LLM to remember channel IDs. The trade-off: one extra API call per invocation to resolve the name.
Tool 4: post_message (write — guarded)
server.tool(
'post_message',
'Post a message to a Slack channel. Use sparingly — confirm with the user before sending anything that will be visible to others.',
{
channel: z.string().describe('Channel name or ID'),
text: z.string().min(1).max(4000),
thread_ts: z.string().optional().describe('Reply to a thread by passing the parent message timestamp'),
},
async ({ channel, text, thread_ts }) => {
let channelId = channel;
if (!channel.startsWith('C') && !channel.startsWith('G') && !channel.startsWith('D')) {
const list = await slack.conversations.list({ limit: 1000 });
const cleaned = channel.replace(/^#/, '').toLowerCase();
const match = list.channels?.find(c => c.name === cleaned);
if (!match) return { content: [{ type: 'text', text: `Channel "${channel}" not found.` }], isError: true };
channelId = match.id;
}
const result = await slack.chat.postMessage({ channel: channelId, text, thread_ts });
return {
content: [{
type: 'text',
text: `✓ Posted to ${channel} (ts ${result.ts}). ${result.message?.permalink || ''}`,
}],
};
}
);
Two safety patterns at work:
- The description explicitly tells the LLM to confirm with the user before posting. The LLM tends to follow this guidance.
- The
max(4000)length limit prevents accidentally pasting a 50,000-character debug log into a channel.
For higher-stakes deployments, you can add a confirmation-token pattern (preview-then-confirm) like the one in the Postgres tutorial — overkill for personal use, essential for shared servers.
Tool 5: get_user_info
server.tool(
'get_user_info',
'Look up a Slack user by ID, email, or display name.',
{
identifier: z.string().describe('User ID (U0...), email address, or display/real name'),
},
async ({ identifier }) => {
let user;
if (identifier.startsWith('U') || identifier.startsWith('W')) {
const r = await slack.users.info({ user: identifier });
user = r.user;
} else if (identifier.includes('@')) {
const r = await slack.users.lookupByEmail({ email: identifier });
user = r.user;
} else {
const r = await slack.users.list({ limit: 1000 });
const q = identifier.toLowerCase();
user = r.members?.find(u =>
(u.real_name || '').toLowerCase().includes(q) ||
(u.profile?.display_name || '').toLowerCase().includes(q)
);
}
if (!user) return { content: [{ type: 'text', text: 'User not found.' }], isError: true };
const info = [
`Name: ${user.real_name || user.name}`,
`Display: @${user.profile?.display_name || user.name}`,
`ID: ${user.id}`,
`Email: ${user.profile?.email || '(hidden)'}`,
`Title: ${user.profile?.title || '(none)'}`,
`Timezone: ${user.tz || '(unknown)'}`,
];
return { content: [{ type: 'text', text: info.join('\n') }] };
}
);
Three lookup modes in one tool — the LLM picks based on what the user provides. This is good UX: instead of three tools (get_user_by_id, get_user_by_email, find_user_by_name), one tool with branching logic.
Wiring it into Claude Desktop
With the server saved at /Users/you/code/slack-mcp/server.js:
{
"mcpServers": {
"slack": {
"command": "node",
"args": ["/Users/you/code/slack-mcp/server.js"],
"env": { "SLACK_TOKEN": "xoxp-yourTokenHere" }
}
}
}
Fully quit and relaunch Claude Desktop. Try "summarise yesterday's discussion in #engineering." The agent uses list_channels to confirm the channel exists, then get_channel_history to fetch the messages, then writes the summary.
Rate limits
Slack rate-limits aggressively — different tiers per method, all documented at api.slack.com/docs/rate-limits. The @slack/web-api client returns useful error metadata on 429s. For a personal-use MCP server you will almost never hit a limit; for shared servers, add @slack/web-api's retry plugin:
import { WebClient, retryPolicies } from '@slack/web-api';
const slack = new WebClient(token, {
retryConfig: retryPolicies.fiveRetriesInFiveMinutes,
});
The library handles exponential backoff on rate-limited calls automatically.
What to add next
Natural extensions of the five-tool base:
react_to_message— add an emoji reaction (low-risk write tool)set_status— update your Slack status from a chat commandlist_mentions— your unread@youmentions across all channelssearch_files— find shared files by name or typepin_message/unpin_message— manage pinned content programmatically
Each is the same shape: Slack SDK call, format the response, return text. The hard parts (auth, error handling, channel name resolution) are already done.
Conclusion
A personal Slack MCP server is one of the most useful things you can build in an afternoon. The five tools above give Claude (or Cursor, or any MCP client) genuinely productive access to your workspace — search, read, occasional reply — without any of the per-tool integration code you would otherwise write.
The two patterns to internalise:
- Friendly inputs over strict IDs — let users pass channel names or display names instead of forcing them to remember Slack's internal IDs.
- Hard limits on writes — short max-length, explicit confirmation language in descriptions, never expose a generic "do anything" write tool.
With those two principles, your Slack MCP server stays safe and feels native.
Try it yourself
Once the server is wired into Claude Desktop, you can run multi-channel investigations as one-shot prompts:
search_messagesThe decision was reached on 2026-05-08 in #payments. Summary:
• Retry failed payments up to 3 times with exponential backoff (5s → 30s → 5min)
• After the third failure, mark the order as payment_failed and notify the customer
• No retries for declined-by-issuer errors (4xx from gateway)
The thread starter was @amol, and @leah confirmed implementation by EOW. Want me to pull the full thread or post a recap in #engineering?
One natural-language question, one tool call, a clean summary — and an offer to follow up. The LLM never had to guess at channel IDs or message timestamps because the tool's description told it how to search.