Skip to content
Conduit

mcp server

slk-mcp

Slack MCP server for Claude Code, GitHub Copilot, Cursor, JetBrains, and any MCP-compatible client. Morning recaps, decision detection, multi-channel digests.

velesnitski/slk-mcpcommunitylistedunreviewed

Set up

Runs on your computer. Pick your agent and operating system.

This project does not publish an install method we can turn into a config. Follow the README instead.

README

synced Oct 27, 2026

slk-mcp

Tests Go License: MIT Release Tools Stars

Slack MCP server for Claude Code, GitHub Copilot, Cursor, JetBrains IDEs, and any MCP-compatible client.

What it does: morning recaps across channels, smart unread summaries, mentions tracking, decision detection, message search, thread reading, and posting.

Written in Go — single ~10 MB binary, ~15 MB Docker image, no runtime, no zombie processes.

Quick start

1. Create a Slack App

Go to api.slack.com/apps → Create New App → From scratch. Pick one of the setups below depending on how you want slk-mcp to act.

Setup A — user token only (recommended for personal use)

Use this if you want slk-mcp to act as you: posts appear under your name, DMs and private channels work out of the box, full unread/mentions support. You don't need to invite anything to channels.

Under OAuth & Permissions → User Token Scopes, add:

  • channels:history, channels:read
  • groups:history, groups:read
  • im:history, im:read, mpim:history, mpim:read
  • users:read
  • users:read.email (optional — email column in list_users with_email / email_domain)
  • search:read
  • chat:write (optional — for post_message)
  • reactions:write (optional — for add_reaction)
  • reactions:read (optional — completes long reactor lists in get_message reactors=true)
  • canvases:write (optional — for edit_canvas)

Install to Workspace, copy the User OAuth Token (xoxp-...) → SLACK_USER_TOKEN.

Setup B — bot token (shared team use)

Use this if you want slk-mcp to act as a bot: posts appear as the bot, the bot must be invited to channels. Unread/mentions tools stay hidden unless you also add a user token.

Under OAuth & Permissions → Bot Token Scopes, add:

  • channels:history, channels:read
  • groups:history, groups:read
  • users:read
  • chat:write (optional — for post_message)
  • reactions:write (optional — for add_reaction)
  • reactions:read (optional — completes long reactor lists in get_message reactors=true)

Optional — add User Token Scopes to unlock get_unread_summary, get_mentions, mark_read:

  • channels:history, groups:history, im:history, mpim:history
  • users.profile:read, search:read

Install to Workspace, copy:

  • xoxb-... — Bot User OAuth Token → SLACK_TOKEN
  • xoxp-... — User OAuth Token → SLACK_USER_TOKEN (optional)

Invite the bot to channels: /invite @your-bot.

2. Install in Claude Code

Setup A (user token only — recommended):

claude mcp add slack \
  -e SLACK_USER_TOKEN=xoxp-your-user-token \
  -- docker run --rm -i \
     -e SLACK_USER_TOKEN \
     velesnitski/slk-mcp -transport stdio

Or via config file (~/.claude.json):

{
  "mcpServers": {
    "slack": {
      "command": "docker",
      "args": ["run", "--rm", "-i",
               "-e", "SLACK_USER_TOKEN",
               "velesnitski/slk-mcp",
               "-transport", "stdio"],
      "env": {
        "SLACK_USER_TOKEN": "xoxp-your-user-token"
      }
    }
  }
}

Setup B (bot token): add -e SLACK_TOKEN=xoxb-... instead of (or alongside) the user token. Bot must be invited to each channel; unread/mentions/DM tools stay hidden unless a user token is also set.

3. Try it

  • "Summarise my unread messages"
  • "What happened in #general overnight?"
  • "Morning recap"
  • "Any mentions of me in the last 24 hours?"
  • "What decisions were made this week across my channels?"
  • "Search for messages about database migration"
  • "What did alex say about the deployment?"

Tools

Without user token (bot-token only)

ToolDescription
list_channelsList channels the bot can see, ordered by member count
get_channel_infoTopic, purpose, member count, created date
get_channel_digestCompact digest of one channel — or a DM, addressed as @handle or a bare U… user id; with_replies=true inlines thread discussions
read_canvasRead canvases in a channel/DM (tab + shared); date/match select per-meeting notes, list_only lists them
get_multi_channel_digestDigest across multiple channels
get_morning_recapDecisions + activity across channels
search_messagesWorkspace search (Slack syntax)
find_decisionsMessages that look like decisions (keywords + reactions)
get_threadFull thread replies
get_messageOne message verbatim with metadata; reactors=true names who reacted, per emoji and as one roster
get_user_messagesRecent messages from a specific user
post_messagePost a message (disabled in SLACK_READ_ONLY)
add_reactionAdd an emoji reaction (disabled in SLACK_READ_ONLY)
delete_messageDelete a message by permalink or channel+ts — only ones this token posted (disabled in SLACK_READ_ONLY)
set_statusSet/clear your custom status (text + emoji + auto-expiry) and optional presence; you-global by default (disabled in SLACK_READ_ONLY, needs user token)
set_presenceFlip presence away/auto WITHOUT touching your status; you-global by default (disabled in SLACK_READ_ONLY, needs user token)
set_dndPause/resume notifications — Do Not Disturb snooze (actually silences, unlike a status); you-global by default (disabled in SLACK_READ_ONLY, needs user token + dnd:write)
download_audioDownload audio attachments (voice messages) to local temp files for transcription
view_imageFetch image attachments (screenshots, photos, cards) and return them inline so the model can see them
read_documentRead text attachments (HTML, Markdown, TXT, CSV, JSON, source) inline; HTML is flattened to plain text, keeping embedded JSON/Markdown payloads. PDFs are text-extracted, with pages that are only pictures named so they can be read visually; keep_file returns any attachment as a raw local file
transcribe_audioVoice message, audio/video clip, or recorded huddle → text via a local whisper.cpp install; degrades to download_audio behaviour when the toolchain is missing
analyze_audio_toneVocal tone of a voice message — loudness range (LRA) + native pitch (f0 mean/variability) — to gauge calm vs agitated/shouting; needs only ffmpeg

With user token (SLACK_USER_TOKEN)

ToolDescription
get_unread_summaryUnread messages across all joined channels, sorted by volume
get_mentionsMessages that mention you; summary=true returns load stats (by sender / channel, DM split) instead of the list
mark_readMark a channel read through a given timestamp
edit_canvasEdit a canvas as you: append/prepend, insert before/after, replace or delete a section found by its text, replace the whole canvas, rename; dry_run previews (disabled in SLACK_READ_ONLY, needs canvases:write)
list_scheduled_messagesYour pending scheduled (queued-to-send) messages with send time + channel; you-global by default

How-to: transcribe voice messages

Slack voice notes are audio/mp4 (.m4a) attachments, fetched with the server's own token (the token never leaves the server process; requires the files:read scope, or Slack answers with its sign-in page and the tool reports a scope error).

A permalink to a thread's root also reaches a voice note posted as a reply: if the linked message carries no audio, the thread is scanned for the newest matching attachment — so you can copy the link on the parent message and still transcribe the reply.

Scopes per resolution mode: a permalink or file URL needs only files:read; latest-mode (channel with no timestamp) also needs im:history to read the DM, plus users:read and im:write when the channel is an @handle. On a missing scope the tool now names the workspace and the exact scope to add — so in a multi-workspace setup you can tell which app to reinstall.

One call — transcribe_audio runs the whole pipeline under the hood when whisper.cpp is installed:

# one-time setup (same as below); then:
# transcribe_audio { "permalink": "https://<workspace>.slack.com/archives/D.../p...", "language": "ru" }
# → transcript text. Missing toolchain? The tool falls back to
#   download_audio behaviour: local file path + a setup hint.
#
# No permalink and no ts? Point at the conversation and get its newest
# voice note (latest-mode) — ideal for empty-text memos search can't find:
# transcribe_audio { "channel": "@teammate", "from": "me", "language": "ru" }
#   channel: a DM as @handle, or a #name / C·G·D id. from: @handle or "me".

Optional overrides: SLACK_FFMPEG_BIN, SLACK_WHISPER_BIN (defaults: PATH lookup of ffmpeg / whisper-cli) and SLACK_WHISPER_MODEL (default: ~/.cache/whisper/ggml-small.bin).

Manual — download_audio just fetches the file; pair it with any local speech-to-text engine:

# one-time setup
brew install whisper-cpp ffmpeg
mkdir -p ~/.cache/whisper
curl -L -o ~/.cache/whisper/ggml-small.bin \
  "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin"
# 1. download the voice note (MCP call — returns a local temp path)
#    download_audio { "permalink": "https://<workspace>.slack.com/archives/D.../p..." }

# 2. convert to 16 kHz mono WAV and transcribe (pick your language via -l)
ffmpeg -y -i /tmp/slk-audio-<id>-<name>.m4a -ar 16000 -ac 1 /tmp/voice.wav
whisper-cli -m ~/.cache/whisper/ggml-small.bin -l ru -np /tmp/voice.wav

Model sizes trade accuracy for speed: ggml-base.bin (142 MB, fast), ggml-small.bin (466 MB, good multilingual balance), ggml-medium.bin (1.5 GB), ggml-large-v3.bin (3 GB, best). For typical voice notes, small is enough. An MCP client (e.g. Claude Code) can run the whole chain from a single message permalink.

Environment variables

VariableRequiredDescription
SLACK_TOKENone ofBot User OAuth Token (xoxb-...).
SLACK_USER_TOKENone ofUser OAuth Token (xoxp-...). Required for unread/mentions. At least one of SLACK_TOKEN / SLACK_USER_TOKEN must be set.
SLACK_WORKSPACE_NAMENoCosmetic label for the primary workspace, shown in merged digests (default: primary).
SLACK_WORKSPACESNoJSON array of additional workspaces. Each entry: {"name","bot_token","user_token","channels","priority_channels"} (all but at least one token optional). Tokens are workspace-scoped, so each extra Slack space needs its own token pair. Labels live in the JSON values — no per-workspace env keys. See Multiple workspaces.
SLACK_CHANNELSNoDefault channels for digest/recap (comma-separated). If unset, tools auto-discover the channels you've joined.
SLACK_PRIORITY_CHANNELSNoChannels (names or IDs, comma-separated) that get_unread_summary always shows for its window, even when you have already read them — first in the output, marked ★, with · read when nothing in them is new to you. Window: priority_hours (default 24) or everything after the after cursor. Channels it cannot find are named in the output. Per extra workspace: priority_channels. ADR 112.
SLACK_AUTODISCOVER_LIMITNoCap on auto-discovered channel count when SLACK_CHANNELS is unset (default: 50)
SLACK_READ_ONLYNotrue to disable post_message, add_reaction, delete_message, mark_read, edit_canvas
SLACK_FFMPEG_BINNoffmpeg binary for transcribe_audio (default: ffmpeg from PATH)
SLACK_WHISPER_BINNowhisper.cpp binary for transcribe_audio (default: whisper-cli from PATH)
SLACK_WHISPER_MODELNoggml model file for transcribe_audio (default: ~/.cache/whisper/ggml-small.bin)
SLACK_DIGEST_HOURSNoDefault digest lookback (default: 24)
SLACK_MAX_MESSAGESNoCap on messages fetched per channel per call (default: 200)
SLACK_SWEEP_TIMEOUTNoSeconds get_unread_summary / get_mentions may spend fetching before returning what they have, naming what they skipped (default: 100; 0 = no limit). A workspace that did not finish keeps its delta cursor
SLACK_SWEEP_EXCLUDENoComma-separated workspace labels that get_unread_summary / get_mentions skip unless asked by name; the output names them and keeps their delta cursor
SLACK_LOG_FILENoAlso append logs to this file (created 0600, ~/ expanded, rotated to .1 past 10 MB at startup). Per-phase sweep timings land here, since MCP hosts don't keep a server's stderr
SLACK_COMPACTNofalse to disable compact output (default: true)
SLACK_LOG_LEVELNodebug, info, warn, error (default: info)
DISABLED_TOOLSNoComma-separated list of tool names to hide

Multiple workspaces

A single Slack token only sees the workspace it was minted in. To read more than one workspace, keep your existing SLACK_TOKEN / SLACK_USER_TOKEN as the primary and add the rest through SLACK_WORKSPACES, a JSON array:

SLACK_WORKSPACES=[
  { "name": "secondary", "user_token": "xoxp-...", "bot_token": "xoxb-..." }
]
  • name is a label only — it appears as a [name] heading in merged output and is never used as a credential key. Workspace names live in the JSON values, so adding a workspace never introduces a new env var.
  • Each entry needs at least one token (user_token for unread/mentions). channels is an optional comma-separated allow-list, same as SLACK_CHANNELS; priority_channels is the per-workspace SLACK_PRIORITY_CHANNELS.

With more than one workspace configured, get_unread_summary and get_mentions merge every workspace automatically, each under its own ## [name] heading. Pass workspace: "<name>" to either tool to scope the call to one workspace. Drill-in tools (get_channel_digest, post_message, mark_read, …) operate on the primary workspace.

Docker

docker run -d --name slack-mcp \
  -e SLACK_USER_TOKEN=xoxp-your-user-token \
  -p 8001:8000 \
  velesnitski/slk-mcp

Add -e SLACK_TOKEN=xoxb-... alongside if you also want a bot token (Setup B).

Connect Claude Code over the network:

{
  "mcpServers": {
    "slack": {
      "type": "url",
      "url": "http://localhost:8001/sse"
    }
  }
}

Architecture

slk-mcp/
├── main.go                          # Entry, flags, transport, graceful shutdown
├── internal/
│   ├── config/                      # Env-driven configuration + validation
│   ├── logger/                      # slog JSON logger (stderr)
│   ├── format/                      # Compact LLM-friendly output
│   ├── slack/
│   │   ├── client.go                # Composition root
│   │   ├── channels.go              # ChannelService — list, resolve, info
│   │   ├── messages.go              # MessageService — history, threads, post
│   │   ├── users.go                 # UserService — cached name resolution
│   │   ├── search.go                # SearchService — workspace search
│   │   ├── unread.go                # UnreadService — user-token-only
│   │   └── ratelimit/               # 429 retry with Retry-After
│   └── tools/                       # MCP tool handlers (one file per domain)
└── Dockerfile                       # Multi-stage, non-root, Alpine

Services are composed on slack.Client; each tool handler depends only on what it needs. Rate-limiting wraps every API call. context.Context propagates from tool handlers to the Slack SDK.

Decision detection

find_decisions and get_morning_recap detect decisions by:

Keywords in text: "decided", "approved", "let's go with", "agreed", "confirmed", "moving forward", "final answer"

Reactions on messages: :white_check_mark:, :heavy_check_mark:, :eyes:, :thumbsup:

Security

  • Tokens passed via environment variables — never hardcoded.
  • Stdio transport has zero network exposure.
  • Read-only mode available (SLACK_READ_ONLY=true).
  • Bot only sees channels it's invited to.
  • User token only needed for personal workflow features (unread/mentions) — bot token covers everything else.
  • No message content is logged, only tool names, channel names, and timings.

Development

go build -o slk-mcp .
go test ./...
go vet ./...

A Makefile wraps the common flows: make build (version-stamped), make test, and make install (build + sync the /mcp dialog label to slack v<version>). The /mcp listing shows each server by its config key in ~/.claude.json (read at session start), not the server-reported name — make sync-label keeps that key current so the dialog never displays a stale version. After make install, restart Claude Code (a /mcp reconnect isn't enough). ADR 024.

Beyond go test: make fuzz (FUZZTIME per target, default 30s) explores the fuzz targets for every parser of untrusted input, and make mutate runs mutation testing with gremlins and fails if any mutant survives. ADR 118.

Compatibility (1.0)

SemVer applies to the machine surface: tool names, argument names/types/defaults, call semantics, and environment-variable names. A breaking change to any of these bumps the major version.

The rendered output text is not contractual — digests, summaries, headers, and section labels target an LLM and will keep changing for density in minor/patch releases. Do not regex the human-readable body; integrate against the typed arguments and the structured tokens the tools emit (permalinks, the cursor: timestamp, issue IDs under include_refs). See ADR 037.

License

MIT

↑↓ navigate openesc close