Single-binary MCP server + CLI that converts AI-generated markdown into Slack Block Kit JSON. Go 1.25+. Zero external runtime dependencies.
Long-form architecture and design rationale: @docs/CLAUDE-architecture.md
| Action | Command |
|---|---|
| Build local binary | make build (output: bin/mcp-slack-block-kit) |
| Run all tests | make test |
| Race tests + coverage | make test-race |
| HTML coverage report | make cover |
| Lint (golangci-lint v2) | make lint |
| Format | gofumpt -w . |
| Vet | make vet |
| Vulnerability scan | make vuln |
| Fuzz the splitter (30s) | make fuzz |
| GoReleaser snapshot | make snapshot |
| Install lefthook hooks | make setup (one-time after clone) |
CI mirrors all of the above. Coverage gate: ≥80% overall, enforced in
.github/workflows/ci.yml. Per-package targets are documented in
CONTRIBUTING.md.
- Format:
gofumpt(stricter superset ofgofmt). Tabs for indentation. - Lint: golangci-lint v2 with the config in
.golangci.yml. v2 bundlesstaticcheck,errcheck,govet,gosec,gocritic. Don't add a standalonegosechook — it duplicates. - Comments: comment the why, not the what. No autogenerated docstrings. Don't restate function signatures.
- Tests: table-driven (
[]struct{name, in, want}), onet.Runper row. Usecmp.Difffor nested-struct asserts;require/assertare not in this repo's dep tree. - Errors: wrap with
fmt.Errorf("...: %w", err). Sentinel errors viaerrors.Is/As. At MCP boundaries, returnIsError: trueonCallToolResultfor tool-level failures (per spec); reserve JSON-RPC transport errors for protocol-level problems. - Logging:
log/slogJSON handler to stderr only. The MCP stdio transport reserves stdout for protocol bytes — anything else corrupts the channel.
- Mention sanitization is mandatory. Every text run emitted into a
Slack
textfield must HTML-entity-escape&<>unlessOptions.AllowBroadcasts == true. Without this, AI-generated content containing literal<!channel>/<!here>/<@U…>would broadcast or ping the workspace. Path-scoped rule: see.claude/rules/security.md.Options.PreserveMentionTokensis the narrower escape hatch (typed Slack tokens pass through; catastrophic broadcasts still escape) — the safer alternative toAllowBroadcastswhen the markdown comes from a trusted Slack tool result. - Bound input.
Options.MaxInputBytes(default 256 KiB) keeps goldmark from allocating gigabytes on hostile input. Don't bypass. - HTTP/SSE transports default to localhost. When wiring a new HTTP
feature, keep the SDK's DNS-rebinding protection enabled; don't set
WriteTimeouton the wrappinghttp.Server(kills SSE GETs); usecrypto/subtle.ConstantTimeComparefor any token comparison. - No secrets in this repo. The server holds none. Don't log
os.Environ(), request bodies at INFO, or anything that might contain user PII. The bearer-token middleware logs request lines but never the token value.
cmd/mcp-slack-block-kit/ cobra entry point (server default + convert subcommand)
internal/converter/ goldmark renderer + emoji/mentions/markdown_block
internal/reverse/ Block Kit → markdown (inverse of converter; lossy)
internal/validator/ slack constraint suite + structured Violations
internal/splitter/ SplitText + ChunkBlocks (50-block + table-isolation)
internal/preview/ Block Kit Builder URL encoder
internal/server/ MCP wiring (6 tools + cheatsheet resource + prompt)
block_kit/ Public Go library re-exports (semver-stable surface)
docs/ Public docs (CLAUDE-architecture.md, etc.)
docs/internal/ Gitignored design notes (research.md lives here)
- Branches:
feat/<slug>,fix/<slug>,chore/<slug>,docs/<slug>. - Commits: Conventional Commits 1.0 (enforced by the lefthook
commit-msghook). Examples:feat(converter): add task-list checkbox,fix(server): handle nil mention map. - PRs: every change has at least one test. The lefthook
pre-pushhook runs the race suite +govulncheckbefore the push lands. - Releases: tag-driven via GoReleaser. See
.claude/skills/release/(invoke with/release) for the bump-tag-push flow.
slack-go/slackv0.23.0'sRichTextPreformatted.Languagefield is emitted into JSON, but Slack itself does not syntax-highlight. We preserve the tag for tooling and explicitly document that Slack ignores it.- The MCP SDK's
jsonschema-goinfersjson.RawMessageas integer-array (because it's[]byte). Useanyfor fields that carry block payloads; seeinternal/server/convert_tool.gofor the pattern. - The auto-mode picker chooses the new
markdownblock (Feb 2025) for short LLM outputs. It will skip that path if it sees*ast.Imageor a table over Slack's row/col limits — verify by readinginternal/converter/markdown_block.go::shouldUseMarkdownBlock. - Do not pair
parallel: truewith multiplestage_fixed: truecommands inlefthook.yml— they race on the git index. Our config groups them inpiped: trueorder.
- Don't add a co-author trailer for Claude in commits (your global
~/.claude/CLAUDE.mdenforces this). - Don't fork or vendor competitor libraries (
navidemad/md2slack,takara2314/slack-go-util). Read them as references; reimplement here. See feedback memory for the why. - Don't introduce new dependencies without checking
govulncheckand considering supply-chain weight. The current dep tree is intentionally minimal.