Building a Slack MCP Server with OAuth: A Complete Tutorial

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 see
  • list_channels — list channels with optional filtering
  • get_channel_history — fetch recent messages from a specific channel
  • post_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:

  1. Go to https://api.slack.com/apps and click Create New AppFrom scratch.
  2. 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.
  3. Install the app to your workspace.
  4. 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:

  1. The description explicitly tells the LLM to confirm with the user before posting. The LLM tends to follow this guidance.
  2. 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 command
  • list_mentions — your unread @you mentions across all channels
  • search_files — find shared files by name or type
  • pin_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:

  1. Friendly inputs over strict IDs — let users pass channel names or display names instead of forcing them to remember Slack's internal IDs.
  2. 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:

YouWhat was decided about the payment retry policy last week? I think it was discussed in #engineering or #payments.
Claude · used 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.

Leave a Comment

Your email address will not be published. Required fields are marked *