mcp-slack

A Slack MCP server for the 2026-07-28 era, built on marianoguerra/mcp-server. Runs against an in-process mock workspace with no Slack account, or against real Slack with a token.

mcp
model-context-protocol
slack
server
moon add marianoguerra/mcp-slack@0.7.0
Download zip
Version
0.7.0
License
MIT
Last updated
2 days ago
Downloads
5
README

#marianoguerra/mcp-slack

A Slack MCP server for the 2026-07-28 protocol era, built on marianoguerra/mcp-server and marianoguerra/slack.

It runs with no Slack account, no token and no network. The default backend is an in-process mock workspace, so the whole thing is demoable and testable offline — and pointed at a real workspace by one flag.

moon install marianoguerra/mcp-slack

$ slack-mcp # stdio, mock workspace, built-in demo data $ slack-mcp --data live --days 90 # mock workspace, real Feeling-of-Computing history $ slack-mcp --backend real --token xoxb-… # a real Slack workspace $ slack-mcp --http 127.0.0.1:3300 # streamable HTTP instead of stdio

#The eighteen tools

ToolSlack API
Rslack_whoamiauth.test
Rslack_list_channelsconversations.list
Rslack_search_channelsconversations.list + filter
Rslack_channel_infoconversations.info
Rslack_read_channelconversations.history
Rslack_read_threadconversations.replies
Rslack_search_messagesconversations.history + filter
Rslack_list_channel_membersconversations.members
Rslack_user_infousers.info / users.lookupByEmail
Rslack_search_usersusers.list + filter
Rslack_list_files / slack_file_infofiles.list / files.info
Rslack_permalinkchat.getPermalink
Wslack_send_messagechat.postMessage
Wslack_reply_in_threadchat.postMessage + thread_ts
Wslack_add_reactionreactions.add
Wslack_create_channelconversations.create
Wslack_open_conversationconversations.open

Plus resources slack://channels, slack://users and the template slack://channel/{id}, and prompts catch-up and find-decision.

Write tools are annotated readOnlyHint: false so a client can put them behind a confirmation. Per the spec that is a hint — clients MUST treat annotations as untrusted unless the server is trusted — so the real protection is that reaching a real workspace takes --backend real and a token, and nothing is read from the environment.

Mapped from the official Slack MCP server. slack_search_messages does not call Slack's search.messages, which needs a user token with search:read and exists in neither the mock nor the client: it reads and filters locally, within stated bounds, and every answer says how many conversations it covered and which cap stopped it. That is slower than the real endpoint and honest about being so; a search tool that worked against one backend of three would be worse than none, because a model has no way to tell which case it is in.

Not implemented, with reasons: search files and search emoji (search.files and emoji.list exist in neither the Slack client nor its mock, so they would be dead on the demo path), canvases (no API in the package at all), draft messages (not a Web API method — it is a compose affordance inside the AI client), and file contents (the package implements no binary transfer in either direction, so files.info metadata ships instead of pretending).

#Inspecting it

This repo ships both ends, so its own client is the fastest way to see what the server offers. Start the server on HTTP:

$ just slack-mcp --http 127.0.0.1:3300 --data live --days 90 slack-mcp: loaded 838 messages from 90 people across 11 channels, with 453 reactions

and point mcp-inspector-cli at it from another shell:

$ just cli http://127.0.0.1:3300/mcp --method initialize { "serverInfo": { "name": "slack", "version": "0.5.0" }, "protocolVersion": "2026-07-28", "capabilities": { "tools": {…}, "resources": {…}, "prompts": {…} }, "instructions": "This server is connected to a mock workspace seeded from the last 90 days…" }

There is no initialize on the wire in this era — the CLI synthesizes it from server/discover, so a reply at all means discovery is spec-shaped.

For stdio instead, just slack-cli spawns the server and talks to it in one command:

$ just slack-cli --method tools/list --format json | jq -r '.result.tools[].name' $ just slack-cli --method tools/call --tool-name slack_whoami

#A tour

$ just cli $U --method tools/list --format json \ | jq -r '.result.tools[] | "\(.annotations.readOnlyHint // false | if . then "R" else "W" end) \(.name)"' R slack_whoami R slack_list_channels … W slack_send_message

$ just cli $U --method tools/call --tool-name slack_search_channels --tool-arg query=ai #of-ai (C3MN5TLNTCFA2F) · 13 members $ just cli $U --method tools/call --tool-name slack_read_thread \ --tool-args-json '{"channel":"#thinking-together","ts":"1785350135.438899","limit":4}' [1785350135.438899] UNWGM0C: Open mic thread: what do you like find yourself collecting? (31 replies) [:🤍: 1] ↳ [1785350285.536719] UNWGM0C: I'll go first. I like collecting: ↳ [1785351131.045419] U4YW9FO: paychecks and money [:😆: 1] ↳ [1785351201.521999] UYA6ABK: Notes too. Over 5+ different apps. [:❤️: 1] (more results: pass cursor="m:4" to see the next page)

Resources and prompts work the same way. Note resources/read on the template, which is resolved through the same name-or-id logic as the tools:

$ just cli $U --method resources/list $ just cli $U --method resources/read --uri slack://channel/C3MN5TLNTCFA2F $ just cli $U --method prompts/get --prompt-name catch-up --prompt-args channel='#of-ai'

#Seeing the error model

The two channels are easiest to tell apart by trying both. An unknown channel is a tool error — recoverable, and it names what does exist:

$ just cli $U --method tools/call --tool-name slack_read_channel --tool-arg channel='#nope' No channel matches '#nope'. Available: #test-01, #administrivia, #announcements, … isError: true exit 5

An unknown tool is a protocol error, because no choice of arguments fixes it:

$ just cli $U --method tools/call --tool-name slack_frobnicate {"error":{"code":"tool_not_found","message":"Tool 'slack_frobnicate' not found on server."}}

And the HTTP mechanics of the era are visible with curl alone — 405 for the verbs this revision removed, 404 plus a JSON-RPC -32601 for an unimplemented method, 400 for a malformed _meta envelope:

$ curl -s -o /dev/null -w '%{http_code}\n' -X GET http://127.0.0.1:3300/mcp 405

#What it does that a thin API wrapper would not

It accepts what a model actually writes. Slack's own API is asymmetric about channel names: chat.postMessage takes #general, while conversations.history answers channel_not_found for the same string. A model will mix #general, general and C0123ABCD across three consecutive calls, so every tool resolves names first — and every schema says so, because a description mentioning only ids trains the model to fetch one it never needed. Same for users: an id, @alice, a display name or an email all work.

It accepts a timestamp sent as a number, and puts it back into Slack's form. Slack models a ts as an opaque string, but clients send numbers — this repo's own CLI coerces one, so it is the first thing anyone hits. Accepting is safe: at Slack-timestamp magnitude the gap between neighbouring doubles is about 0.24µs, finer than the microsecond granularity a ts carries, so no value is lost. What is lost is the formatting — every shortest-round-trip printer renders 1700000005.000000 as 1700000005, which Slack will not match, and trailing-zero microseconds are common. So the number is canonicalised back to six decimal places rather than passed along in whatever shape it arrived.

It splits the two error channels by whose problem it is. Slack refusing a call becomes isError: true content carrying needed and provided, so missing_scope reaches the model as "needs chat:write, the token has identify" — something it can act on. A response we cannot parse becomes a JSON-RPC -32603 instead, because no amount of retrying will fix a shape mismatch.

#Three data sources

--backend decides where the calls go. --data decides what the mock contains. They are orthogonal and both explicit — nothing is inferred from the environment.

--dataSourceOfflineDeterministic
demo (default)the slack package's built-in workspaceyesyes
snapshot FILEa recorded fetchyesyes
livethe Feeling-of-Computing atproto bridgenono

--data live fetches real history from the at-foc atproto/Colibri mirror and replays it into the mock — around 840 messages from 90 people across 11 channels, with their real timestamps and thread structure:

$ just foc-snapshot foc.json 90 slack-mcp: loaded 838 messages from 90 people across 11 channels, with 453 reactions $ slack-mcp --data snapshot foc.json

listRecords has no time filter, so the ninety-day window is expressed as a synthesized cursor: record keys are TIDs, which sort lexicographically in timestamp order, so a key minted for "ninety days ago" bounds the scan exactly even though no record has it. That is about nine requests instead of paging the whole repo.

The mirror is a projection of Slack and the loader owns the losses rather than hiding them: there is no author field (the speaker is a @Name: prefix inside the text, stripped back out), no user records (ids are hashed from display names, deterministically), and no reactor identity (counts are real, attributed to a visibly synthetic foc-reactions account).

The same repo also publishes com.feelingofcomputing.bridge.slackRaw, which would be a much better structural fit — real Slack ids, reactor identity, full profiles. It is deliberately not used: those payloads publicly contain member email addresses and phone numbers, and the Slack app's verification token in cleartext. Seeding from it would pull that into memory, into any recorded snapshot, and into any transcript a model sees.

Snapshots are gitignored. The bridge already publishes this data, but a copy of a real community's conversations does not belong in a repository about protocol codecs.

#Layering

src/tools/ the eighteen tools, resources and prompts — all four backends src/atproto/ TID codec + listRecords paging (pure) — all four backends the HTTPS fetch loop — native src/foc/ bridge records -> mock workspace — all four backends src/ the binary — native

src/tools imports nothing async, so the domain layer of this server checks on native, js, wasm and wasm-gcjust backends proves it alongside mcp and mcp-server. Calling @typed.Api's async methods needs no runtime; async is a language feature and the library is only the scheduler. Only the binary and the fetch loop are native, and only because stdio and sockets are.

That portability is the point being demonstrated: an MCP server's domain layer is as portable as the protocol core underneath it.

#Testing

The catalog — tool names, schemas, annotations, resources, prompts — is checked by calling build() and Server::route, both pure, with no transport and no async runtime. The TID codec is checked against real bridge record keys paired with the createdAt their own records carry. The loader is checked against hand-written records in the shapes the bridge actually publishes, including the stale-community channel and the dangling reaction target that exist in the real data.

scripts/e2e.sh drives the built binary over stdio with this repo's own MCP client. Because the Slack mock is in-process, that leg needs no network and no reference server.

#
Options

type Options

#
WhichBackend

type WhichBackend derive(Eq)

Which Slack the calls reach.

#
WhichData

type WhichData derive(Eq)

What the mock workspace is filled with.

Demo is synthetic, offline and byte-stable — the default, and the only one CI uses. Live fetches real Feeling-of-Computing history from the atproto bridge at startup. Snapshot replays a recorded fetch, which is how a live load becomes reproducible.

Source Files