A single-binary Model Context Protocol server (and CLI) that converts AI-generated markdown into valid Slack Block Kit JSON. Credential-free, ships as a static Go binary, speaks stdio / streamable HTTP / SSE.
Six MCP tools your AI assistant can call:
| Tool | What it does |
|---|---|
convert_markdown_to_block_kit |
Markdown → Block Kit JSON. Auto mode picks between Slack's new (Feb 2025) markdown block and full deterministic decomposition into rich_text / section / header / image / divider / table. |
block_kit_to_markdown |
The inverse — Block Kit JSON → Markdown. Best-effort and lossy; constructs with no Markdown equivalent (buttons, accessories, colors) are approximated and reported in warnings. |
validate_block_kit |
Validates a payload against the documented Slack constraints (per-block char limits, count limits, button/context/table element limits, XOR rules, only_one_table_allowed, the 12k-char markdown_block cap, etc.) with structured violations + fix hints. Pass surface (message / modal / home) to set the block ceiling. |
preview_block_kit |
Returns a Block Kit Builder URL — one click to a live visual preview in Slack's own builder. No workspace credentials needed. |
lint_block_kit |
Warns on near-limit content, deprecated patterns, and accessibility gaps (e.g. missing image alt_text). Always advisory. |
split_blocks |
Splits an oversized payload into multiple Slack-API-compliant chunks on the >50-block axis, with only_one_table_allowed enforcement. |
The server also exposes an MCP resource (block-kit-cheatsheet — the
conversion modes, supported Markdown, Slack limits, and mention-safety
model) and a prompt (format_for_slack) so MCP clients can discover
how to use the tools.
Plus a convert CLI for offline testing without an MCP client.
convert_markdown_to_block_kit accepts a mode parameter (CLI: --mode):
| Mode | What it produces | When to use |
|---|---|---|
auto † (default) |
One Slack markdown block when the input is short, image-free, and contains no nested-block patterns. Otherwise full rich_text decomposition. |
Most LLM workflows — let the converter pick. |
rich_text |
Always full decomposition into typed rich_text / section / header / image / divider / table blocks. |
When you want explicit, deterministic block shapes (e.g. for downstream styling, validation, or because you don't want to delegate rendering to Slack's markdown parser). |
markdown_block |
Single Slack markdown block — Slack's server-side parser owns the rendering. |
When the input is known-good markdown and you want the smallest possible payload. Errors if input >12,000 chars. |
section_mrkdwn |
section blocks with mrkdwn text. |
Downstream consumers that need the older section-based shape. |
† auto may emit a single Slack markdown block. That block renders
well in the main message pane but its fallback rendering on push
notifications, search results, screen readers, and the email digest
can show literal ## / ** / [label](url) characters. To bias
auto toward rich_text decomposition (which renders identically on
every surface), pass prefer_rich_text: true on the tool call (CLI:
--prefer-rich-text). This default will flip to true in the next
major release; pin it to false explicitly to preserve current
behavior across the flip.
The converter runs a pre-parse normalizer that auto-repairs 14 evidenced LLM-emission patterns before goldmark sees the input:
- V1–V4: unclosed emphasis, inline code, fenced code, and fence-with-language-no-newline.
- V5: ATX header missing space (
#Title→# Title). - V7: Unicode look-alikes inside URL paths (en-dash, em-dash, curly quotes) → ASCII equivalents.
- V8: link split across lines (
[label]\n(url)→[label](url)). - C1: stray
~between word characters escaped (20~25→20\~25). - C3 / C4: bullet / numbered-list markers missing space.
- C5 / C9: trailing whitespace stripped (hard-break markers preserved).
- C6: borderless GFM tables get leading/trailing pipes.
- C7: short table rows padded to match the header column count.
- C11: Unicode bullet markers (
•/◦/▪/●/… instead of-) rewritten so the list parses instead of collapsing to one inline paragraph. A companion goldmark paragraph transformer catches exotic/ambiguous markers (★/▶/→/·) structurally, on peer evidence. See the catalog's "Two-layer bullet handling". - R8:
<br>tags converted to newlines (or spaces inside table cells).
Two opt-in repairs (Options.DecodeHTMLEntities for V11 HTML
entity decode, Options.RepairMismatchedEmphasis for V6's
asterisk balancer) sit behind feature flags. Every fired repair
surfaces in the warnings field of the response (codes are
semver-stable). See
docs/llm-input-recovery.md for the
full catalog with evidence and examples.
Headings, bold, italic, strikethrough, inline code, fenced code blocks,
ordered/unordered lists (including nesting), block quotes, thematic
breaks, links, images, GFM tables, task lists, and :emoji: shortcodes;
bare URLs are auto-linked. Not supported: footnotes and definition
lists (emitted as plain text); raw HTML is entity-escaped to literal
text. In rich_text mode, Markdown link titles are dropped — Slack's
rich-text link element has no title field (they survive in
markdown_block mode).
Slack's rich_text element schema doesn't allow code blocks, lists, or
tables inside a rich_text_quote or rich_text_list (those containers
take inline elements only). When markdown contains one of these
patterns:
- code in a blockquote / list item
- table in a blockquote / list item
- list in a blockquote
…the converter decomposes the construct into adjacent top-level blocks
rather than silently flattening to plain text. Ordered lists set Offset
on the post-split sibling so numbering continues across the gap. In auto
mode this triggers a one-line warning in the response so callers know the
visual rendering won't look exactly like CommonMark embedding — the inner
block is adjacent to the quote, not visually nested inside it.
Every text run is HTML-entity-escaped by default so AI-generated content
can't broadcast <!channel> / <!here> / <@U…> to your workspace.
Two narrowing knobs:
preserve_mention_tokens: true— already-typed Slack mention tokens (<@U…>,<#C…>,<!subteam^S…>,<!date^…|fallback>) pass through as typed elements, while catastrophic broadcasts (<!channel>,<!here>,<!everyone>) still escape. Use this when the markdown comes from an upstream Slack tool result (e.g.get_slack_user_info).allow_broadcasts: true— disables sanitization entirely. Don't use this unless the input is fully trusted.
See SECURITY.md for the full threat model.
If your bot's output ever appears as literal markdown characters in Slack instead of rendered formatting, three causes (in order of likelihood):
- Wrong field on
chat.postMessage. The converted output goes inblocks=[...]. Never pass the markdown source intotext=or asection.mrkdwntext — those parse mrkdwn (which has no##header syntax and no[label](url)link syntax). Pair the returnedblockswith the returnedtext_fallback:chat.postMessage(channel=..., blocks=resp.blocks, text=resp.text_fallback)
- Malformed LLM input. The library auto-repairs 14 common
LLM-emission patterns (see LLM input repairs
above and the catalog doc). The
response's
warningsfield reports which repairs fired. - Fallback surface degradation. The Slack
markdownblock (auto mode's default) renders well in the main channel pane but its fallback rendering on push notifications, search results, screen readers, and the email digest can show literal characters. Setprefer_rich_text: trueon the call (CLI:--prefer-rich-text) —rich_textrenders identically on every surface.
# Go install
go install github.com/hishamkaram/mcp-slack-block-kit/cmd/mcp-slack-block-kit@latest
# Or grab a prebuilt binary from Releases (signed via cosign keyless)
# https://github.com/hishamkaram/mcp-slack-block-kit/releases/latestA Homebrew tap (
brew install hishamkaram/tap/mcp-slack-block-kit) is planned; tap publishing is currently disabled in the GoReleaser config pending tap-repo + publishing-PAT setup.
Verify a release with cosign:
cosign verify-blob \
--certificate-identity-regexp 'https://github\.com/hishamkaram/mcp-slack-block-kit/.+' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
--certificate checksums.txt.pem --signature checksums.txt.sig \
checksums.txtRelease tags are SSH-signed (ed25519); GitHub displays a green "Verified" badge on each tag page (e.g. v0.2.0).
Add to ~/Library/Application Support/Claude/claude_desktop_config.json
(macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):
{
"mcpServers": {
"block_kit": {
"command": "mcp-slack-block-kit",
"args": []
}
}
}The same shape works in Cursor, Continue.dev, Zed, Cline, and any other MCP-compatible client that supports the stdio transport.
The default mcp-slack-block-kit (or mcp-slack-block-kit server)
invocation runs the stdio transport — what Claude Desktop, Cursor,
and Continue.dev launch. For HTTP-based MCP clients (remote runners,
multi-tenant hosts, containerized deployments), two HTTP transports are
also available via flags. Both default to localhost-only binds and the
SDK's DNS-rebinding protection stays on:
# Streamable HTTP (spec 2025-03 — preferred for new MCP clients):
mcp-slack-block-kit server --http-addr 127.0.0.1:7777
# Legacy Server-Sent Events (spec 2024-11 — older clients):
mcp-slack-block-kit server --sse-addr 127.0.0.1:7778
# With a bearer token (applies to both --http-addr and --sse-addr):
mcp-slack-block-kit server --http-addr 127.0.0.1:7777 --http-token s3cret
# The token can also come from the MCPSBK_HTTP_TOKEN environment variable.When auth is enabled the server requires
Authorization: Bearer <token> on every incoming request and replies
with 401 Unauthorized otherwise. --http-addr and --sse-addr are
mutually exclusive. For exposure beyond localhost, run behind a reverse
proxy that terminates TLS and enforces auth — the binary intentionally
ships without built-in TLS.
External Go consumers can import block_kit and embed the MCP server
the same way they embed the converter:
import "github.com/hishamkaram/mcp-slack-block-kit/block_kit"
srv, _ := block_kit.NewServer("v1.2.3")
// Stdio (matches the default binary launch):
_ = block_kit.RunStdio(ctx, srv)
// Or streamable HTTP with optional bearer auth:
_ = block_kit.RunHTTP(ctx, srv, "127.0.0.1:7777",
block_kit.HTTPOptions{Token: "s3cret"})cat <<'EOF' | mcp-slack-block-kit convert --mode rich_text --pretty
# Hello
A paragraph with **bold**, *italic*, `code`, and a [link](https://example.com).
- list item 1
- list item 2 with :wave:
```go
func main() {}
```
EOFStdout receives the Block Kit JSON only — pipe straight into jq or
chat.postMessage. Stderr carries logs and the optional --preview
Block Kit Builder URL:
echo '# title' | mcp-slack-block-kit convert --preview
# stdout: {"blocks":[{"type":"header",...}]}
# stderr: preview: https://app.slack.com/block-kit-builder/#%7B...%7DOther useful flags: --mode={auto|rich_text|markdown_block|section_mrkdwn},
--allow-broadcasts, --preserve-mention-tokens,
--block-id-prefix=<str>, --max-input-bytes=<n>, --pretty. Full
help: mcp-slack-block-kit convert --help.
import "github.com/hishamkaram/mcp-slack-block-kit/block_kit"
r, err := block_kit.NewConverter(block_kit.DefaultOptions())
if err != nil { panic(err) }
// ConvertWithWarnings returns blocks plus any fallback notes (e.g. when
// auto mode routed away from markdown_block because the input contains
// code-in-blockquote). Use Convert() if you want to drop warnings.
blocks, warnings, err := r.ConvertWithWarnings("# Title\n\nbody **bold** text.")
if err != nil { panic(err) }
for _, w := range warnings {
log.Printf("converter warning: %s", w)
}
// Validate before sending:
result := block_kit.NewValidator().Validate(blocks)
if !result.Valid {
for _, e := range result.Errors {
fmt.Println(e.Path, e.Code, e.Message)
}
}
// Visual QA via Block Kit Builder:
pr, _ := block_kit.PreviewURL(blocks)
fmt.Println("preview:", pr.URL)Full API reference: pkg.go.dev.
| Project | What we share | What's different |
|---|---|---|
navidemad/md2slack |
goldmark-based markdown→Block Kit | Library only, no MCP server, no validation/preview/lint, hardcoded block-id prefix. We re-implemented the patterns rather than depending on it. |
takara2314/slack-go-util |
Same shape | Library only; missing tables, hr, images, strike, autolinks. |
tryfabric/mack |
TS markdown → Block Kit | TS, last commit 2022 (stale). Ours: Go static binary, MCP-native. |
| Other Slack MCP servers | Workspace integration | They send messages; we generate them. Pair with one of those for the full pipeline. |
Pull requests welcome. Read CONTRIBUTING.md first —
short version: Conventional Commits, ≥80% test coverage on changed
packages, make setup once after clone to wire the lefthook hooks.
By participating you agree to the Code of Conduct.
MIT © 2026 Hesham Karm. Third-party notices.