
This tutorial is part of Build an MCP Server, within The MCP Guide. Want it built for you? See MCP development.
To build an MCP server for a Slack bot, you create a Slack app with a bot token, then wrap two Slack Web API methods in MCP tools: one that reads recent messages and one that posts. The server limits both tools to channels you list. The AI then acts as the bot, with the bot's narrow permissions, instead of as you.
Slack also runs an official MCP server. It acts with each user's own token and covers search, messages, canvases and more. Build your own bot server when you want a shared bot identity, a fixed channel list, or tools shaped around one workflow, such as posting daily reports.
Checked against: the Python SDK docs, Slack's Python SDK Web API page and method reference pages on 2 November 2026, with mcp 2.3.0 and slack_sdk 3.46.0. The server ran locally on 2 November 2026 with an invalid test token, so Slack calls were checked up to Slack's invalid_auth reply. It was not run against a real workspace.
Key Takeaways
- Use a bot token (
xoxb-) with the fewest scopes:channels:historyto read public channels andchat:writeto post. - Enforce a channel allow list in your server. Slack scopes cover every channel the bot is in.
chat.postMessagegenerally allows about one message per second per channel, and reading history has its own limits.- Since 29 May 2025, commercially distributed apps not approved for the Slack Marketplace are limited to one
conversations.historyrequest per minute. Internal apps are not affected. - With
AsyncWebClient, use the async retry handler. The sync one fails at runtime.
Your own bot server or Slack's MCP server?
| Slack's MCP server | Your bot server (this guide) | |
|---|---|---|
| Acts as | Each signed-in user | One bot |
| Hosting | Slack, at https://mcp.slack.com/mcp | You |
| Scope of access | Whatever the user can see | Channels you allow-list |
| Best for | Personal search and messaging | Shared, repeatable bot workflows |
Our Slack MCP server profile covers Slack's hosted server, and connecting Claude to Slack shows the setup. The old reference Slack server sits in the archived servers repository, which says no security guarantees are provided.
Prerequisites
- Python 3.11 or newer.
- A Slack workspace where you can create an app.
- A Slack app with a bot user, installed to the workspace, and its bot token.
- The bot invited to each channel it should use.
- Node.js 22.19+ for the MCP Inspector.
Step 1: Create the Slack app and pick scopes
Create an app in Slack's app settings, add a bot user, and request bot token scopes:
| Scope | Why | Source |
|---|---|---|
channels:history | Read messages in public channels the bot is in | conversations.history |
chat:write | Post messages | chat.postMessage |
Add groups:history only if the bot must read private channels. Skip chat:write.public. Slack's reference says new apps cannot post to every public channel by default, and that scope removes that limit. Without it, the bot can post only where it was invited, which is a useful safety boundary.
Install the app and copy the bot token. It starts with xoxb-.
Step 2: Install
uv init slack-mcp --python 3.12
cd slack-mcp
uv add "mcp[cli]" slack_sdk aiohttp
Slack's docs say AsyncWebClient relies on the AIOHTTP library, which is an optional dependency, so install aiohttp too.
Step 3: Write the minimal server
Create server.py:
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Annotated, Any
from pydantic import Field
from slack_sdk.errors import SlackApiError
from slack_sdk.http_retry.builtin_async_handlers import AsyncRateLimitErrorRetryHandler
from slack_sdk.web.async_client import AsyncWebClient
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
ALLOWED_CHANNELS = set(filter(None, os.environ.get("SLACK_ALLOWED_CHANNELS", "").split(",")))
@dataclass
class AppContext:
slack: AsyncWebClient
@asynccontextmanager
async def lifespan(server: MCPServer) -> AsyncIterator[AppContext]:
client = AsyncWebClient(token=os.environ["SLACK_BOT_TOKEN"])
client.retry_handlers.append(AsyncRateLimitErrorRetryHandler(max_retry_count=1))
yield AppContext(slack=client)
mcp = MCPServer("slack-bot", lifespan=lifespan)
def check_channel(channel_id: str) -> None:
if channel_id not in ALLOWED_CHANNELS:
raise ToolError(f"Channel {channel_id} is not on this bot's allow list. "
f"Allowed: {', '.join(sorted(ALLOWED_CHANNELS))}")
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
async def read_recent_messages(
channel_id: Annotated[str, Field(description="Slack channel ID, e.g. C0123456789.")],
ctx: Context[AppContext],
limit: Annotated[int, Field(ge=1, le=15)] = 10,
) -> list[dict[str, Any]]:
"""Read the most recent messages in an allowed channel (newest first)."""
check_channel(channel_id)
slack = ctx.request_context.lifespan_context.slack
try:
resp = await slack.conversations_history(channel=channel_id, limit=limit)
except SlackApiError as exc:
raise ToolError(f"Slack error: {exc.response['error']}") from exc
return [{"ts": m["ts"], "user": m.get("user"), "text": m.get("text", "")} for m in resp["messages"]]
if __name__ == "__main__":
mcp.run()
What matters:
- One client in the lifespan. The SDK's lifespan guide says what the lifespan yields is shared by every tool call.
- The retry handler. Slack's docs show
RateLimitErrorRetryHandlerfor the sync client and say "For asyncio apps,Asyncprefixed corresponding modules are available." WithAsyncWebClient, the sync handler failed on 2 November 2026 withAttributeError: 'RateLimitErrorRetryHandler' object has no attribute 'can_retry_async'. The async version,AsyncRateLimitErrorRetryHandler, worked. - The allow list is checked before any Slack call, and the error lists the allowed channels so the model can correct itself.
limitcaps at 15. That matches the lowest limit Slack applies to non-Marketplace apps, covered below.SlackApiErroris what Slack's SDK raises when a response has"ok": false. Theerrorfield holds codes such asinvalid_authorchannel_not_found.- Trimmed output. Each message returns only timestamp, user ID and text.
Step 4: Add a posting tool
@mcp.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=False))
async def post_message(
channel_id: str,
text: Annotated[str, Field(min_length=1, max_length=3000)],
ctx: Context[AppContext],
thread_ts: Annotated[str | None, Field(description="Reply in this thread instead of the channel.")] = None,
) -> str:
"""Post a message as the bot to an allowed channel. Show the user the text first."""
check_channel(channel_id)
slack = ctx.request_context.lifespan_context.slack
try:
resp = await slack.chat_postMessage(channel=channel_id, text=text, thread_ts=thread_ts)
except SlackApiError as exc:
raise ToolError(f"Slack error: {exc.response['error']}") from exc
return f"Posted to {channel_id} at ts {resp['ts']}."
The annotations mark this tool as a non-idempotent write. Posting twice sends two messages. Hosts that respect annotations will ask before running it. The docstring also tells the model to show the text first.
If the bot was not invited to a channel, Slack's chat.postMessage reference lists a not_in_channel error. Your tool returns it as Slack error: not_in_channel, which tells the user exactly what to fix.
Step 5: Test it
In memory with the SDK Client, using a fake token and one allowed channel, the run on 2 November 2026 returned:
read_recent_messages dict_keys(['channel_id', 'limit'])
post_message dict_keys(['channel_id', 'text', 'thread_ts'])
True Error executing tool post_message: Channel C9999999999 is not on this bot's allow list. Allowed: C0123456789
True Error executing tool read_recent_messages: 1 validation error for read_recent_messagesArguments
True Error executing tool read_recent_messages: Slack error: invalid_auth
The blocked channel never reached Slack. A limit of 50 failed validation. With an allowed channel, the call reached Slack and came back with invalid_auth, as expected for a fake token. With a real token, the same call returns messages.
Then test with the MCP Inspector:
npx @modelcontextprotocol/inspector --cli \
-e SLACK_BOT_TOKEN=xoxb-your-token -e SLACK_ALLOWED_CHANNELS=C0123456789 \
uv --directory /ABSOLUTE/PATH/slack-mcp run server.py \
--method tools/call --tool-name read_recent_messages \
--tool-args-json '{"channel_id":"C0123456789","limit":5}' --format json
Our Inspector lesson covers the other flags and exit codes.
Rate limits you will hit
- Posting. Slack's chat.postMessage page says it "will generally allow an app to post 1 message per second to a specific channel."
- Reading. conversations.history is listed as Tier 3, "50+ per minute."
- Non-Marketplace apps. Slack's 29 May 2025 changelog says commercially distributed apps not approved for the Marketplace are "limited to 1 request per minute" on conversations.history and conversations.replies, with the
limitmaximum cut to 15. It also says "Internal customer-built applications are not impacted." - 429 responses. Slack's rate limits page says a limited call gets
HTTP 429 Too Many Requestswith aRetry-Afterheader in seconds. The retry handler waits for it.
If your bot is internal to your company, the strict limit does not apply. If you plan to distribute it to other workspaces, design for one history read per minute or plan for Marketplace review.
Connect it to a host
claude mcp add -e SLACK_BOT_TOKEN=xoxb-your-token -e SLACK_ALLOWED_CHANNELS=C0123456789 \
--transport stdio slack-bot -- uv run --directory /ABSOLUTE/PATH/slack-mcp server.py
For Claude Desktop, Cursor or VS Code, build the JSON with our MCP config generator, then restart the host. Try: "Summarize today's messages in #support and draft a reply for me to approve."
Deploy notes
- Keep the token out of shared configs. A bot token posts as the bot in every channel it is in. Store it in a secret manager for a remote deployment.
- One bot per job. A reporting bot and a support bot should be separate apps with separate scopes.
- Remote use needs sign-in. If you host this server for a team, add OAuth in front so you know who asked the bot to post. See adding OAuth to a remote MCP server.
- Treat messages as untrusted. Slack messages can contain instructions aimed at the model. Our page on prompt injection explains the risk.
Pitfalls
- Using your user token. A user token reads everything you can see. Use a bot token with minimal scopes.
- Channel names instead of IDs. The allow list uses IDs such as
C0123456789. Names can change; IDs do not. - Sync retry handler on the async client. It fails at runtime. Import from
slack_sdk.http_retry.builtin_async_handlers. - v1 MCP imports.
from mcp.server.fastmcp import FastMCPfails on SDK v2. Usefrom mcp.server import MCPServer. - Printing to stdout. It corrupts a stdio server. Use
logging.
Next step
Learn the full build path in the free Learn MCP course. If you want a Slack bot that pulls from your CRM or reports and posts with approvals, eSEOspace MCP development can help design it.
More in the MCP Guide
Free tools: server.json Validator · MCP Error Code Lookup
Latest research: State of MCP: October 2026
Frequently asked questions
Can the bot read private channels or DMs?
Only with extra scopes such as groups:history or im:history, and only where it is a member. Add them only if a workflow truly needs them.
How do I find a channel ID?
Copy the channel's link in Slack. The last part of the link, starting with C for public channels, is the ID. Slack's conversations.list method also returns IDs.
Should I use Slack's official MCP server instead?
If people want to search and message as themselves, yes. Slack's server uses each person's own permissions. Build a bot server for shared, scripted jobs with a fixed scope.
Can the bot react to new messages automatically?
MCP tools run when the AI app calls them. For real-time triggers, use Slack's Events API in a separate app, and use MCP for on-demand work.
Why limit reads to 15 messages?
It keeps responses small and matches the lowest limit Slack applies to non-Marketplace apps. Raise it for internal apps if you need more context.











