🤖 AI Tools
· 5 min read
Last updated on

Build a Slack MCP Server — Step-by-Step Tutorial


Build an MCP server that connects AI assistants to your Slack workspace. Once connected, you can ask Claude Code or Cursor to send messages, read channel history, and search conversations — all without leaving your terminal.

What you’ll build

By the end of this tutorial, your MCP server will expose three tools:

  • send_message — Post a message to any channel
  • read_channel — Fetch recent messages from a channel
  • search_messages — Search across your workspace’s message history

The server runs locally via stdio, so your Slack token never leaves your machine.

Prerequisites

  • Node.js 18+
  • A Slack workspace where you can create apps
  • Claude Code, Cursor, or another MCP-compatible client

Step 1: Create a Slack App and get your bot token

  1. Go to api.slack.com/apps and click Create New AppFrom scratch
  2. Name it something like “MCP Bot” and select your workspace
  3. Navigate to OAuth & Permissions in the sidebar
  4. Under Bot Token Scopes, add these scopes:
    • channels:read — List public channels
    • channels:history — Read messages in public channels
    • chat:write — Send messages
    • search:read — Search messages (requires a user token, see note below)
    • groups:read — List private channels (optional)
    • groups:history — Read private channel messages (optional)
  5. Click Install to Workspace and authorize
  6. Copy the Bot User OAuth Token (starts with xoxb-)

Note on search: The search.messages API requires a user token (xoxp-), not a bot token. If you only have a bot token, you can skip the search tool or add a User Token Scope for search:read and use that token for search operations.

Step 2: Project setup

mkdir slack-mcp && cd slack-mcp
npm init -y
npm install @modelcontextprotocol/sdk @slack/web-api zod

Create a tsconfig.json if you’re using TypeScript:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "strict": true
  },
  "include": ["src/**/*"]
}

Step 3: Build the server

Create src/index.ts:

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 slack = new WebClient(process.env.SLACK_TOKEN);
const server = new McpServer({ name: 'slack', version: '1.0.0' });

// Tool 1: Send a message to a channel
server.tool('send_message', {
  channel: z.string().describe('Channel name (without #) or channel ID'),
  text: z.string().describe('Message text (supports Slack markdown)')
}, async ({ channel, text }) => {
  const result = await slack.chat.postMessage({ channel, text });
  return {
    content: [{
      type: 'text',
      text: `Message sent to #${channel} (ts: ${result.ts})`
    }]
  };
});

// Tool 2: Read recent messages from a channel
server.tool('read_channel', {
  channel: z.string().describe('Channel name or ID'),
  limit: z.number().default(10).describe('Number of messages to fetch (max 100)')
}, async ({ channel, limit }) => {
  // Resolve channel name to ID if needed
  let channelId = channel;
  if (!channel.startsWith('C')) {
    const list = await slack.conversations.list({ limit: 200 });
    const found = list.channels?.find(c => c.name === channel);
    if (!found) {
      return { content: [{ type: 'text', text: `Channel "${channel}" not found` }] };
    }
    channelId = found.id!;
  }

  const history = await slack.conversations.history({
    channel: channelId,
    limit: Math.min(limit, 100)
  });

  const messages = history.messages
    ?.map(m => `[${new Date(Number(m.ts) * 1000).toISOString().slice(0, 16)}] ${m.user}: ${m.text}`)
    .join('\n') || 'No messages found';

  return { content: [{ type: 'text', text: messages }] };
});

// Tool 3: Search messages across the workspace
server.tool('search_messages', {
  query: z.string().describe('Search query (supports Slack search syntax)'),
  count: z.number().default(5).describe('Number of results')
}, async ({ query, count }) => {
  const result = await slack.search.messages({ query, count });
  const messages = result.messages?.matches
    ?.map(m => `[#${m.channel?.name}] ${m.username}: ${m.text}`)
    .join('\n\n') || 'No results';
  return { content: [{ type: 'text', text: messages }] };
});

// Start the server
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('Slack MCP server running on stdio');

Step 4: Connect to your AI client

Claude Code

claude mcp add slack -- node /path/to/slack-mcp/src/index.ts \
  -e SLACK_TOKEN=xoxb-your-token-here

Cursor

Add to your .cursor/mcp.json:

{
  "mcpServers": {
    "slack": {
      "command": "node",
      "args": ["/path/to/slack-mcp/src/index.ts"],
      "env": {
        "SLACK_TOKEN": "xoxb-your-token-here"
      }
    }
  }
}

For more MCP client setups, see MCP + Claude Code setup and MCP + Cursor setup.

Step 5: Test it

Once connected, try these prompts:

  • “Read the last 5 messages from #engineering”
  • “Search Slack for messages about the database migration”
  • “Send a message to #general saying the deploy is complete”

Adding more tools

You can extend the server with additional Slack capabilities:

// List channels the bot is in
server.tool('list_channels', {}, async () => {
  const result = await slack.conversations.list({
    types: 'public_channel,private_channel',
    limit: 50
  });
  const channels = result.channels
    ?.map(c => `#${c.name} (${c.num_members} members)`)
    .join('\n') || 'No channels';
  return { content: [{ type: 'text', text: channels }] };
});

// React to a message
server.tool('add_reaction', {
  channel: z.string(),
  timestamp: z.string().describe('Message timestamp (ts)'),
  emoji: z.string().describe('Emoji name without colons')
}, async ({ channel, timestamp, emoji }) => {
  await slack.reactions.add({ channel, timestamp, name: emoji });
  return { content: [{ type: 'text', text: `Added :${emoji}: reaction` }] };
});

Security best practices

  1. Principle of least privilege — Only add the scopes your tools actually need. If you don’t need chat:write, don’t add it.
  2. Keep tokens local — The stdio transport means your token stays on your machine. Never commit tokens to git.
  3. Use environment variables — Pass SLACK_TOKEN via env, not hardcoded in the source.
  4. Audit channel access — The bot can only read channels it’s been invited to (for private channels) or any public channel.
  5. Rate limits — Slack’s API has rate limits (roughly 1 request/second for most methods). The @slack/web-api client handles retries automatically.

For a deeper dive on securing MCP servers, see our MCP Security Checklist and MCP Security Risks.

Troubleshooting

“not_in_channel” error when reading messages: The bot needs to be invited to the channel. Either invite it manually (/invite @MCP Bot) or add the channels:join scope and call conversations.join before reading.

“missing_scope” error: You added a scope but didn’t reinstall the app. Go back to OAuth & Permissions and click Reinstall to Workspace.

Search returns empty results: The search.messages API requires a user token (xoxp-), not a bot token. Add a User Token Scope for search:read and use that token specifically for search.

FAQ

Can I use this with private channels?

Yes. Add the groups:read and groups:history scopes, and make sure the bot is invited to the private channel.

Does this work with Slack Enterprise Grid?

Yes, but you may need an admin to approve the app installation. The scopes and API calls are the same.

Can I send messages as myself instead of the bot?

That requires a user token (xoxp-) with chat:write scope. The message will appear as coming from your account rather than the bot.

How do I handle Slack’s rate limits?

The @slack/web-api package automatically retries rate-limited requests with exponential backoff. For heavy usage, add a delay between tool calls or batch operations.