MCP Server for Slack: Letting Your Agent Read and Post Messages
Why Slack Is the First Integration Every Agent Builder Reaches For
Every team building an AI agent hits the same wall within the first week: the agent is genuinely useful, but it lives in a terminal or a playground, disconnected from where the actual work happens. For most companies, that place is Slack. Incident channels, support escalations, standup threads, deployment alerts — it's all sitting in Slack, and an agent that can't read or write to it is an agent that can't participate in the team's actual workflow.
This is exactly the gap the Model Context Protocol (MCP) was built to close. Instead of writing bespoke Slack API glue code for every agent you build, you stand up one MCP server that speaks Slack, and any MCP-compatible agent — Claude, a custom LangChain pipeline, an internal tool — can connect to it and immediately gain the ability to search channels, read threads, and post messages. You write the integration once. You reuse it everywhere.
In this article we'll build a working MCP server for Slack from scratch, wire it into an agent, and walk through the permission and safety model you need before you let an LLM post on your team's behalf. By the end you'll understand not just how to connect Slack to an agent, but why the "read" and "post" halves of this integration deserve very different levels of caution.
What MCP Actually Gives You Over a Raw Slack API Call
If you've used the Slack Web API before, you know it's not hard to call chat.postMessage from a script. So why wrap it in MCP at all?
The answer is standardization at the boundary between "tools that do things" and "models that decide what to do." A raw API call is just a function. An MCP server is a function *plus* a self-describing contract: a name, a JSON schema for its inputs, a human-readable description, and a discovery mechanism that lets any compliant client ask "what can you do?" at runtime.
Concretely, this buys you three things:
- Portability. The same Slack MCP server works whether the client is Claude Desktop, Claude Code, a custom agent runtime, or a teammate's experiment. You don't rewrite the integration per client.
- Discoverability. The agent doesn't need a hardcoded prompt listing every Slack function. It calls
tools/list, gets backlist_channels,read_messages,post_message,search_messages, and decides at runtime which one solves the task in front of it. - Isolation. Your Slack bot token lives inside the MCP server process, not inside the agent's context window or prompt. The model never sees the credential — it only sees tool names and structured results.
That last point matters more than it sounds. Once you start letting agents post to Slack, credential handling and permission scoping stop being a nice-to-have and become the whole ballgame.
Setting Up the Slack App and Bot Token
Before writing any server code, you need a real Slack app with the right scopes. This part is unglamorous but it's where most integrations go wrong — either too many scopes granted (a security problem) or too few (a "why isn't this working" debugging session).
Go to api.slack.com/apps, create a new app "from scratch," and attach it to your workspace. Under OAuth & Permissions, add these Bot Token Scopes to start:
channels:read— list public channelschannels:history— read messages in public channelsgroups:readandgroups:history— same, for private channels the bot is invited tochat:write— post messages as the botsearch:read— search across messages (requires a user token in most workspaces, worth noting separately)users:read— resolve user IDs to display names, useful when rendering threads for the model
Install the app to your workspace, grab the Bot User OAuth Token (starts with xoxb-), and invite the bot into whichever channels you want it to see with /invite @your-bot-name. This last step trips people up constantly — a bot token with channels:history still can't read a channel it hasn't been invited into. Scopes control *what kind* of data the bot can touch; channel membership controls *which* channels.
It's worth pausing on why this two-layer model exists at all. Slack could have made scopes the only gate — grant channels:history and the bot reads everything, full stop. Instead, scopes are a ceiling and channel membership is the actual door. This gives you a cheap, Slack-enforced way to sandbox an experimental agent: build and test your MCP server against a single low-stakes #agent-sandbox channel, and only widen its reach once you trust its behavior. Resist the temptation to invite the bot everywhere during development just to save a step — the whole point of this integration is that the blast radius should be a deliberate decision, not a side effect of convenience.
Store the token as an environment variable, never in code:
export SLACK_BOT_TOKEN="xoxb-your-token-here"
export SLACK_SIGNING_SECRET="your-signing-secret"Building the MCP Server: Core Structure
An MCP server, at minimum, exposes a set of tools over stdio or HTTP/SSE transport. We'll use the official MCP Python SDK and the slack_sdk package, since this pairing keeps the code short enough to reason about in one sitting.
import os
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
from mcp.server import Server
from mcp.types import Tool, TextContent
import mcp.server.stdio
slack_client = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
server = Server("slack-mcp-server")
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="list_channels",
description="List public channels the bot has access to",
inputSchema={
"type": "object",
"properties": {
"limit": {"type": "integer", "default": 50}
},
},
),
Tool(
name="read_messages",
description="Read the most recent messages in a channel",
inputSchema={
"type": "object",
"properties": {
"channel_id": {"type": "string"},
"limit": {"type": "integer", "default": 20},
},
"required": ["channel_id"],
},
),
Tool(
name="post_message",
description="Post a message to a Slack channel as the bot",
inputSchema={
"type": "object",
"properties": {
"channel_id": {"type": "string"},
"text": {"type": "string"},
"thread_ts": {"type": "string"},
},
"required": ["channel_id", "text"],
},
),
]Notice the shape of this: every tool declares its own schema. The agent never has to guess whether channel_id is required or what thread_ts means — it's all in the contract. This is the part of MCP that replaces pages of prompt engineering with a few lines of JSON Schema.
Implementing the Tool Handlers
Declaring tools is only half the server. The other half is the dispatcher that actually calls the Slack API when a tool is invoked, and — just as importantly — turns Slack's API errors into something the model can reason about instead of a stack trace.
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
try:
if name == "list_channels":
resp = slack_client.conversations_list(
limit=arguments.get("limit", 50),
types="public_channel",
)
channels = [
{"id": c["id"], "name": c["name"], "members": c["num_members"]}
for c in resp["channels"]
]
return [TextContent(type="text", text=str(channels))]
elif name == "read_messages":
resp = slack_client.conversations_history(
channel=arguments["channel_id"],
limit=arguments.get("limit", 20),
)
messages = [
{"user": m.get("user"), "text": m.get("text"), "ts": m.get("ts")}
for m in resp["messages"]
]
return [TextContent(type="text", text=str(messages))]
elif name == "post_message":
resp = slack_client.chat_postMessage(
channel=arguments["channel_id"],
text=arguments["text"],
thread_ts=arguments.get("thread_ts"),
)
return [TextContent(
type="text",
text=f"Message posted successfully. ts={resp['ts']}"
)]
else:
return [TextContent(type="text", text=f"Unknown tool: {name}")]
except SlackApiError as e:
return [TextContent(
type="text",
text=f"Slack API error: {e.response['error']}"
)]
async def main():
async with mcp.server.stdio.stdio_server() as (read, write):
await server.run(read, write, server.create_initialization_options())
if __name__ == "__main__":
import asyncio
asyncio.run(main())A few details worth calling out because they're easy to get wrong the first time:
- Errors return as text, not exceptions. If
conversations_historyfails because the bot isn't in the channel, the model needs to see"error": "not_in_channel"as a readable string so it can adapt — maybe by asking the user to invite the bot, rather than the whole agent session crashing. - Pagination is your problem, not the model's. Slack's
conversations_historyreturns anext_cursor. For a first version, cappinglimitand ignoring pagination is fine. Production servers should loop through cursors when the model asks for "all messages since X." - Timestamps double as message IDs. Slack's
tsfield is both a timestamp and the unique identifier you'd use to reply in a thread (thread_ts) or add a reaction. This one field does a lot of work, and it's worth explaining it in your tool descriptions so the model uses it correctly.
Connecting the Server to Your Agent and Locking Down Posting
With the server written, the next step is registering it with whatever MCP client you're using. If you're working inside Claude Code or Claude Desktop, this is a config entry, not code:
{
"mcpServers": {
"slack": {
"command": "python",
"args": ["/path/to/slack_mcp_server.py"],
"env": {
"SLACK_BOT_TOKEN": "xoxb-your-token-here"
}
}
}
}Once registered, restart the client and the three tools — list_channels, read_messages, post_message — show up automatically in the agent's tool list. There's no additional prompting required to teach the model what Slack is; the tool descriptions do that job.
If you're building a custom agent runtime instead, the pattern is the same conceptually: your agent's tool-calling loop connects to the MCP server over stdio (or SSE for a remote server), calls tools/list once at startup, and forwards any Slack-shaped requests as tools/call invocations with the tool name and arguments. The agent code doesn't need to know anything about Slack's REST API, pagination, or auth — that's all encapsulated in the server.
Here's where most Slack MCP integrations either stay useful or become a liability, though. Reading Slack is low risk — worst case, the agent surfaces information out of context. Posting to Slack is a different category of risk entirely: an autonomous agent that can post messages can also spam a channel, leak information into the wrong channel, or post something embarrassing under the company's name, and unlike a read, a post can't be silently undone.
A few patterns worth adopting from day one rather than retrofitting later:
- Scope channels explicitly. Don't invite the bot into every channel "just in case." Invite it only into the channels the agent is actually meant to operate in. This is a much stronger control than anything in your prompt, because it's enforced by Slack itself, not by the model's judgment.
- Add a confirmation step for posts. Many teams wire
post_messagebehind a human-in-the-loop check — the agent drafts the message and a person approves it before the tool actually fires. This is trivial to add: have the tool handler write to a pending-approval queue instead of callingchat_postMessagedirectly, and expose a separate approval mechanism. - Rate-limit at the server, not the model. Don't trust the agent's own restraint. Add a simple counter in the MCP server that refuses more than N posts per minute regardless of what the model asks for.
- Log every call. Every
call_toolinvocation should be logged with the tool name, arguments, and result before it's returned to the model. When something goes wrong — and eventually something will — you want a clean audit trail of exactly what the agent asked Slack to do.
import time
_post_timestamps: list[float] = []
MAX_POSTS_PER_MINUTE = 5
def rate_limit_ok() -> bool:
now = time.time()
_post_timestamps[:] = [t for t in _post_timestamps if now - t < 60]
if len(_post_timestamps) >= MAX_POSTS_PER_MINUTE:
return False
_post_timestamps.append(now)
return TrueDrop this check at the top of the post_message branch and reject with a clear error message when it trips. It costs four lines and prevents an entire category of "the agent got into a loop and spammed the channel" incidents.
Handling Threads, Mentions, and Search Properly
Reading a channel's flat history is the easy 80%. The remaining 20% — and the part that makes an agent genuinely useful in Slack rather than just a novelty — is handling threads and search correctly.
Threads in Slack are not a separate object; a threaded reply is just a message whose thread_ts points at the parent message's ts. If your read_messages tool only calls conversations_history, it will return top-level messages and miss all the replies buried in threads. You need a second tool, read_thread_replies, that calls conversations_replies with a given ts:
elif name == "read_thread_replies":
resp = slack_client.conversations_replies(
channel=arguments["channel_id"],
ts=arguments["thread_ts"],
)
replies = [
{"user": m.get("user"), "text": m.get("text"), "ts": m.get("ts")}
for m in resp["messages"]
]
return [TextContent(type="text", text=str(replies))]Search deserves its own tool too, because "find the message where someone mentioned the outage" is a completely different query shape than "read the last 20 messages." Slack's search.messages endpoint requires a user token (not a bot token) in most workspace configurations, which is a common surprise — plan for it during setup rather than debugging it under deadline pressure. If your use case genuinely needs full-text search across the workspace, budget time to set up a Slack app with user-token scopes (search:read) and a proper OAuth flow, since bot tokens alone won't get you there.
There's a second, subtler issue with mentions and user IDs that catches teams off guard the first time they read agent output back into a channel. Slack messages don't store @username as text — they store a raw user ID like <@U012AB3CD>, and the display name is resolved client-side. If your read_messages tool hands the model raw message text, the model will see <@U012AB3CD> can you look at this instead of @sarah can you look at this, and it will have no idea who that is unless you resolve it. The fix is a small lookup layer: cache the workspace's user list with users_list on server startup, build a user_id -> display_name map, and run every message's text through a resolver before returning it to the model.
_user_cache: dict[str, str] = {}
def resolve_users(text: str) -> str:
import re
for user_id in re.findall(r"<@(\w+)>", text):
if user_id not in _user_cache:
try:
info = slack_client.users_info(user=user_id)
_user_cache[user_id] = info["user"]["real_name"]
except SlackApiError:
_user_cache[user_id] = user_id
text = text.replace(f"<@{user_id}>", f"@{_user_cache[user_id]}")
return textRun every message body through resolve_users before it goes into a TextContent block, and the model will reason about "Sarah asked about the deploy" instead of a meaningless ID string. It's a small addition, but it's the difference between an agent that reads Slack usefully and one that technically reads Slack.
Testing the Server Before You Trust It With an Agent, and Failure Modes to Expect
Don't hand this server to an autonomous agent as your first test. Test it directly first, the same way you'd test any other backend service.
echo '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}' | python slack_mcp_server.pyThis should return your three (or more) tool definitions as JSON. Once that works, test a real call:
echo '{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "list_channels", "arguments": {"limit": 5}}}' | python slack_mcp_server.pyIf this returns real channel data, you know the server, the token, and the scopes are all correctly wired before an LLM ever gets near it. This matters because debugging "the agent isn't posting to Slack" is much harder when you don't know whether the failure is in the model's tool call, the MCP transport, or the Slack API itself. Isolate each layer first.
Once the server checks out on its own, connect it to your agent and run a handful of manual scenarios: ask it to summarize the last 20 messages in a channel, ask it to find a specific message from last week, and — with the rate limiter and channel scoping in place — ask it to post a test message. Watch what tool calls it actually makes, not just the final answer, since that's where you'll catch it calling read_messages with the wrong channel ID or forgetting to pass thread_ts when replying.
A handful of failures show up so consistently across Slack MCP integrations that it's worth naming them before you hit them yourself, too.
- `not_in_channel` errors on a bot that clearly has the right scopes. This is almost always the membership issue from earlier — the app was reinstalled or a scope was added after the bot was already invited, and Slack's permission cache didn't refresh the way you'd expect. Re-inviting the bot usually clears it.
- `missing_scope` on `chat_postMessage` even though `chat:write` is listed. Check whether you're posting into a private channel — that requires the bot to also hold
groups:writeor to be a channel member withchat:writescoped correctly for private conversations. Public and private channels are governed by slightly different scope combinations. - The model hallucinates a channel ID. If your
list_channelstool truncates results at a lowlimitand the model needs a channel that didn't make the cut, it will sometimes guess an ID that looks plausible rather than callinglist_channelsagain with a higher limit. Guard against this by havingpost_messageandread_messagesvalidate the channel ID against a freshconversations_infocall before acting, and return a clear "channel not found" error rather than letting Slack's API error bubble up as a generic failure. - Rate limiting from Slack itself, not just your own limiter. Slack's Web API enforces its own per-method rate limits (roughly one request per second per method on standard tiers), and a busy agent doing rapid
read_messagescalls across many channels can trip a429response with aRetry-Afterheader. Your error handler should read that header and either back off automatically or surface a clear "Slack is rate-limiting us, try again in N seconds" message rather than treating it as a generic failure.
None of these are exotic problems — they're the ordinary friction of talking to a real, stateful API from inside a probabilistic system. Naming them up front just means you spend your first week building the integration instead of debugging it blind.
Where This Goes From Here
A Slack MCP server with three tools is a solid starting point, but it's also a template. The same pattern — declare a tool, validate its schema, wrap the underlying API call, return readable errors, rate-limit anything that writes — applies whether you're connecting an agent to Jira, GitHub, a CRM, or an internal database. Slack is simply one of the highest-leverage integrations to build first, because it's where most teams' actual communication already lives, and giving your agent eyes and a careful voice inside it turns a standalone assistant into a genuine team member.
If you want to go deeper into this pattern — building custom MCP servers from scratch, handling auth flows properly, designing tool schemas that models use correctly on the first try, and wiring multiple MCP servers into a single production agent — that's exactly what we cover in Building & Integrating MCP Servers on TeachYou.ai. It walks through everything in this article in more depth, plus additional integrations beyond Slack, so you can apply the same architecture to whatever tool your team lives in next.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.