All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog 1.1.1, and this project adheres to Semantic Versioning.
0.5.0 - 2026-05-22
- converter: lists emitted by an LLM with a non-ASCII bullet marker
(e.g.
•,◦,▪,★,▶,→) are now recognized as real lists instead of collapsing into a single inline paragraph (every item run together on one line in Slack). Two complementary layers:- normalizer C11 (pre-parse, always on): rewrites a curated set of
unambiguous line-leading bullet glyphs (
• ‣ ⁃ ⁌ ⁍ ∙ ◦ ▪ ▫ ● ○ ■ □ ◆ ◇) to the canonical-marker and surfaces aC11warning, so the repaired list gets goldmark's native nesting/continuation handling. - goldmark paragraph transformer (
internal/converter/unicode_bullet_transformer.go): a structural backstop that detects any other bullet-like marker by Unicode category (Po/So/Sm, non-ASCII) and rewrites the paragraph into a native list. Requires peer evidence (≥2 consecutive same-marker lines) so a lone symbol-led line stays prose; dashes (en/em, category Pd) are excluded so attributions ("— Einstein") are never mistaken for a list. Seedocs/llm-input-recovery.md("Two-layer bullet handling").
- normalizer C11 (pre-parse, always on): rewrites a curated set of
unambiguous line-leading bullet glyphs (
0.4.0 - 2026-05-20
-
normalizer: new
internal/converter/normalizerpackage that repairs 13 evidenced LLM-emission patterns before goldmark parses the input. Always-on repairs:- V1 unclosed emphasis (
*italic,**bold) — appends a matching closer when there is a single left-flanking opener with word content after it. - V2 unclosed inline code (
`code) — appends a closer of the unmatched run's length, respecting CommonMark code-span nesting (a `bis correctly recognized as balanced). - V3 unclosed fenced code block (
```or~~~) — appends a closing fence at EOF to prevent the rest of the document from being swallowed. - V4 fence-with-language-no-newline
(
go fmt.Println("hi")) — splits onto canonical three lines; conservative known-languages whitelist. - V5 ATX header without space (
#Title→# Title). - V7 Unicode look-alikes inside URL paths (en-dash, em-dash, curly quotes, ellipsis) → ASCII equivalents; em-dashes in prose untouched.
- V8 link split across lines (
[label]\n(url)→[label](url)). - C1 stray tilde between word characters escaped (
20~25→20\~25); preserves authored~~strikethrough. - C3 bullet marker without space (
-item→- item). - C4 numbered marker without space (
1.item→1. item). - C5/C9 trailing whitespace stripped (CommonMark hard-break marker preserved).
- C6 borderless GFM tables get leading/trailing pipes.
- C7 table data rows shorter than the header padded with empty
cells (layer-B repair in
internal/converter/tables.go). - R8
<br>tags converted to newlines outside table cells and spaces inside table cells.
Two opt-in repairs:
- V11 whitelisted HTML entity decode (
& < > " '+ numeric refs) gated byOptions.DecodeHTMLEntities. - V6 asterisk-pair balancer (
**italic*→*italic*) gated byOptions.RepairMismatchedEmphasis.
Every fired repair surfaces in the response's
warningsfield as a single combined string (normalized input (LLM-mistake repairs fired: V8, C3)). Codes are semver-stable; the full catalog with evidence and examples lives atdocs/llm-input-recovery.md.Hardening: 95%+ unit-test coverage on the normalizer package, five property tests (idempotence, length bound, no-broadcast- smuggling, code-block preservation), a
FuzzNormalizetarget run for 60s+ across multiple commits with zero failures. - V1 unclosed emphasis (
-
converter:
Options.PreferRichText(defaultfalse) — opt-in bias towardrich_textdecomposition over the single Slackmarkdownblock.rich_textrenders identically on push notifications, search results, screen readers, and the email digest, where themarkdownblock's fallback rendering can show literal##/**/[label](url)characters. Surfaced asprefer_rich_texton the MCP convert tool and--prefer-rich-texton the CLI. Re-exported viablock_kit/. -
converter:
Options.DecodeHTMLEntities(defaultfalse) — see V11 above. Surfaced asdecode_html_entities/--decode-html-entities. -
converter:
MarkdownBlockFallbackSurfacesWarningconstant. Auto-mode emits this advisory whenever it picks a singlemarkdownblock, naming the fallback surfaces where rendering degrades. Re-exported viablock_kit/. -
converter:
DeriveTextFallback([]slack.Block) stringreturns a 150-char plain-text summary suitable forchat.postMessage(text=). Strips Slack mrkdwn, CommonMark links, ATX hashes, and blockquote prefixes; header blocks dominate. Re-exported viablock_kit/together with theTextFallbackMaxCharsconstant. -
server:
ConvertOutput.text_fallbackfield carries the derived summary in the MCP convert-tool response.
- prompts: the
format_for_slackMCP prompt body now instructs callers to pass the returnedblocksaschat.postMessage(blocks=), the returnedtext_fallbackaschat.postMessage(text=), to surfacewarningsto the user (especially normalization repair codes and the fallback-surface advisory), and to setprefer_rich_text=truefor accessibility-sensitive channels.
- The default of
Options.PreferRichTextwill flip fromfalsetotruein the next major release. When the picker was first written, the Slackmarkdownblock was the only block type that supported headers, tables, task-lists, dividers, and code-with-language — so biasing auto-mode toward it was the right call. Slack's March 6, 2026 expansion ofrich_textremoved that advantage;rich_textnow renders identically on every surface, themarkdownblock does not. To pin the current behavior across the flip, setPreferRichText: falseexplicitly.
- normalizer: C5 trailing-whitespace repair now skips fenced and indented code blocks (CommonMark §4.5 literal content) and preserves the §6.7 hard line break in any 2+-space form in genuine prose context (not list items, not table delimiter rows — those still strip).
- normalizer: C3 bullet repair skips lines whose marker
character repeats (emphasis spans like
**bold**/*italic*between two bullet items are no longer misrewritten into* *bold**). - normalizer: C4 numbered repair regex and peer-check now
require non-digit content (decimal pairs like
1.5 GB free\n2.3 GB usedand version triples no longer mutually validate as a numbered list). - normalizer: R8 (
<br>) and V7 (URL Unicode) honor a new inline-code-span mask so content inside backticks (CommonMark §6.1) survives unchanged. Examples that previously corrupted:Use `<br>` for HTML breaks(R8) and`array[1](https://x.com/v2—doc)`(V7). - normalizer: V4's one-line fence split no longer leaves
trailing
LineFenceContenttags that broke idempotence between passes. Theclassify()walker now treats a fence-opener whose info string already contains a matching closing run asLineProse(spec-aligns with CommonMark §4.5 — info strings cannot contain the fence character). - normalizer: V11 HTML entity decoder is now actually wired
into the pipeline (was previously a dead Options field). V11
decodes the five whitelisted XML entities + numeric refs;
results re-escape through
sanitizeBroadcastsso broadcast tokens cannot round-trip through<!channel>→ live<!channel>. The matchingOptions.DecodeHTMLEntities,decode_html_entitiesMCP field, and--decode-html-entitiesCLI flag now have visible effect. - normalizer: V6 asterisk balancer is now reachable from the
public API. New
Options.RepairMismatchedEmphasis,repair_mismatched_emphasisMCP field, and--repair-mismatched-emphasisCLI flag thread the existing internal flag through every layer. - converter (tables):
emptyTableCellnow emits a non-nullelementsarray (mirrorsrenderRowCells' empty-cell fallback shape). Was dead code today because goldmark pre-pads short rows; future-proofs against any wiring change.
- New
docs/llm-input-recovery.mdcatalog with evidence for every normalizer pattern (issue links, blog references, spec citations). Now also documents the inline-code-span guard, the broadcast- safety round-trip for V11, and the MCP/CLI knob names for the two opt-in repairs. internal/server/cheatsheet.md(theblock-kit-cheatsheetMCP resource) gained a "Best-effort posting recipe" section and a "Troubleshooting: literal##/**/[label](url)appear in Slack" section walking through the three diagnostic causes.README.mdgained an "LLM input repairs" section listing every normalizer code, a footnote on the modes table explaining theauto-mode fallback caveat, and a "Troubleshooting" section matching the cheatsheet's diagnostic walk.
0.3.0 - 2026-05-15
- converter: h2–h6 headings — and any heading routed to the
section.mrkdwnfallback (a long h1, or an h1 containing a link/image/inline code) — now entity-escape<,>,&in the heading text. The fallback previously escaped only the emphasis markers* _ ~ \``, so a heading such as## <!channel>inrich_textmode (orautowhen the input routes to rich_text decomposition) emitted a live<!channel>broadcast. ThehandleFallbackand blockquote unknown-child paths, which also build text elements directly, were hardened the same way.allow_broadcastsandpreserve_mention_tokens` are still honored.
- server: new
block_kit_to_markdownMCP tool — the inverse ofconvert_markdown_to_block_kit. Best-effort and lossy; constructs with no Markdown equivalent (buttons, accessories, colors) are approximated and reported inwarnings. - server: all six tools now advertise MCP tool annotations
(
readOnlyHint,openWorldHint, and a human-readable title). - server: a
block-kit-cheatsheetMCP resource documenting the conversion modes, supported/unsupported Markdown, Slack's documented limits, and the mention-safety model. - server: a
format_for_slackMCP prompt. - validator: per-element rules for buttons (text/value/url/action_id lengths), context blocks (≤10 elements), table blocks (≤100 rows, ≤20 columns, ≤20 column_settings), image-block titles (≤2000 chars), and section accessories.
- validator: surface-aware validation —
ValidateForSurfaceplus asurfaceinput onvalidate_block_kit/lint_block_kitraise the block ceiling from 50 (messages) to 100 (modals, App Home tabs). - converter:
Options.MaxNestingDepth(default 100) rejects pathologically deep input with the newErrInputTooDeeplyNestedsentinel —MaxInputBytesbounds bytes but not structural depth. - block_kit: re-exports
BlockKitToMarkdown, theSurfacetype andSurfaceMessage/SurfaceModal/SurfaceHomeTabconstants,ErrInputTooDeeplyNested, andDefaultMaxNestingDepth.
- server:
convert_markdown_to_block_kit'sreturn_preview_urlis now a genuine opt-out — passfalseto skip preview-URL generation (the field is a nullable bool, so omitting it still defaults to true). - server:
convert_markdown_to_block_kitrejects an unknownsplitvalue with a clear error instead of silently ignoring it. - server: the HTTP and SSE transports log a warning when bound to a non-loopback address with no bearer token configured.
- deps:
slack-go/slackv0.23.0 → v0.23.1.
- splitter:
SplitTextno longer cuts inside a multi-byte UTF-8 rune when a single un-breakable token (a long CJK run, a non-ASCII URL) exceeds the limit — the hard-cut now steps back to the nearest rune boundary. The fuzz target gained multibyte seeds and autf8.ValidStringinvariant. - converter: the long-heading
section.mrkdwnfallback truncation is now rune-safe and strips a dangling backslash that could otherwise escape the closing*and leave the bold run unterminated.
0.2.1 - 2026-05-13
- converter: link conversion in
markdown_blockandautomodes. The previous emitter ranentityEscapeover the raw input, which destroyed CommonMark autolink syntax (<https://example.com>rendered as literal<https://example.com>text in Slack) and never recognized Slack's mrkdwn<URL|label>URL-form emitted by Slack tool results (so the user's real-world<https://…|Refa UGC v3 shared-drive>rendered with visible angle brackets and pipe). Replaced with an AST walker that re-emits Slack-supported CommonMark text:- CommonMark URL autolinks (
<url>), email autolinks (<email>), and Linkify-detected bare URLs are promoted to[url](url)/[email](mailto:email)— the only link form Slack'smarkdownblock documents as supported. - Slack's
<URL|label>mrkdwn URL-form is rewritten to CommonMark[label](URL)before goldmark parses, fixing the failure for bothrich_textandmarkdown_blockmodes. - Text content still entity-escapes for broadcast safety; URLs and code-block contents pass through verbatim.
- CommonMark URL autolinks (
- converter: rich_text mode now recognizes Slack mrkdwn URL-form
input (
<URL|label>) and produces a properrich_text_section_linkelement. Previously the construct fell through to the text path and was entity-escaped.
- converter: maintainer-facing
TestLinks_PrintBuilderURLstest that prints Block Kit Builder URLs for every link shape × mode so visual QA in a Slack workspace is a copy-paste away. Run withgo test -v -run TestLinks_PrintBuilderURLs ./internal/converter/.
0.2.0 - 2026-05-11
- converter: new
Options.PreserveMentionTokensflag. When enabled, already-typed Slack mention tokens (<@U…>/<@W…>users,<#C…>channels,<!subteam^S…>usergroups,<!date^…|fallback>dates) pass through as typed rich_text elements instead of being entity-escaped. Catastrophic broadcasts (<!channel>/<!here>/<!everyone>) and URL-form tokens still escape — the new flag is strictly additive to the safety contract. Surfaced on theconvert_markdown_to_block_kitMCP tool aspreserve_mention_tokensand on theconvertCLI subcommand as--preserve-mention-tokens. - server: streamable-HTTP transport (MCP spec 2025-03) via
--http-addron theserversubcommand. Implements graceful shutdown, per-session cleanup, body-size cap, slowloris/idle-timeout hardening, and DNS-rebinding protection (SDK default). - server: legacy SSE transport (MCP spec 2024-11) via
--sse-addrfor older MCP clients.--http-addrand--sse-addrare mutually exclusive. - server: optional bearer-token authentication via
--http-tokenflag orMCPSBK_HTTP_TOKENenvironment variable. Applies to both HTTP and SSE transports. Constant-time comparison; returns 401 withWWW-Authenticate: Beareron mismatch. - block_kit: public
Servertype alias plusNewServer,RunStdio,RunHTTP,RunSSE, andHTTPOptionsre-exports so external Go consumers can embed the MCP server in their own binary without importinginternal/. - repo:
.mailmapto canonicalize maintainer commit identities across the gmail / GitHub-noreply forms forgit logand the GitHub web UI.
0.1.0 - 2026-05-09
First public release. A single static binary that exposes a Model Context Protocol server and a CLI for converting AI-generated markdown into valid Slack Block Kit JSON — credential-free, zero external runtime dependencies, supply-chain hardened.
- Five MCP tools on top of
modelcontextprotocol/go-sdkv1.6.0 (convert_markdown_to_block_kit,validate_block_kit,preview_block_kit,lint_block_kit,split_blocks) — your AI assistant generates a Slack message and one tool call returns the Block Kit JSON, validated against documented Slack constraints, with a one-click Block Kit Builder URL for visual QA. - Auto-mode picker chooses between Slack's new (Feb 2025)
markdownblock and full deterministic decomposition (rich_text/section/header/image/divider/table) based on input characteristics. Five non-representable nesting patterns (code-in-quote / code-in-list / table-in-quote / table-in-list / list-in-quote) are detected and routed to predictable rich_text decomposition withOffset-based ordered-list numbering continuation across splits. - Mention-sanitization is mandatory by default. Every text run
emitted into a Slack
textfield is HTML-entity-escaped, so AI-generated content containing literal<!channel>,<!here>,<@U…>,<#C…>, or<!subteam^…>cannot broadcast or ping the workspace. Opt-in passthrough viaOptions.AllowBroadcasts: true. - Supply-chain hardened release: cosign keyless signing of every
artifact via GitHub OIDC, CycloneDX SBOMs generated by syft, all
GitHub Actions pinned to commit SHAs, OSSF Scorecard 7.1/10
with all of
Pinned-Dependencies,Token-Permissions,SAST,Fuzzing,Vulnerabilities,License,Binary-Artifacts,Dangerous-Workflow,Packaging, andDependency-Update-Toolat 10/10. - 293 tests, ≥80% statement coverage on every shipped package, with
stdlib fuzz tests on the splitter (
FuzzSplitText) and aTestNested_PrintBuilderURLsfixture that emits Block Kit Builder URLs for manual visual verification of every nesting pattern × mode combination.
- MCP server (stdio):
mcp-slack-block-kit(default subcommandserver) exposes the five tools above. Compatible with Claude Desktop, Cursor, Continue.dev, Zed, Cline, and any MCP client that speaks the stdio transport. - CLI:
mcp-slack-block-kit convert— pipe markdown on stdin, get Block Kit JSON on stdout, optional Block Kit Builder URL on stderr. Flags:--mode={auto|rich_text|markdown_block|section_mrkdwn},--allow-broadcasts,--block-id-prefix,--max-input-bytes,--pretty,--preview. - Public Go library at
github.com/hishamkaram/mcp-slack-block-kit/block_kitre-exports the converter, validator, splitter, and preview engines for embedded use without the MCP server dependencies.Renderer.ConvertWithWarnings()returns blocks plus fallback notes (e.g. when auto mode routes away from the markdown block because the input contains code-in-blockquote). - Markdown coverage: paragraphs, headings (H1 short →
header; H1 long / links/images/code → boldsection; H2–H6 always boldsection), thematic breaks →divider, blockquotes →rich_text_quote, ordered + unordered lists with sibling-with- incrementing-indentnesting (Slack's required pattern), fenced and indented code blocks →rich_text_preformattedwith language tag preserved, GFM tables →slack.TableBlockwith row/col truncation and header replication on overflow, inline emphasis with style stack- OR-merge for
***bold-italic***,:emoji:shortcode resolution with goldmark-fragmentation post-processing,@handle→<@U…>resolution viaOptions.MentionMap.
- OR-merge for
- Validator (
internal/validator): six cross-block rules (50-block-per-message ceiling, uniqueblock_id, multiple-tables detection, markdown-block 12k cumulative cap, per-block validators for section/header/image/actions, deprecated-pattern flagging viaValidateStrict). - Splitter (
internal/splitter):SplitText— whitespace-aware splitter preferring paragraph > sentence > word boundaries, fuzz-tested for byte-for-byte round-trip preservation.ChunkBlocks— enforces the 50-block-per-message ceiling ANDonly_one_table_allowed(a secondTableBlockalways opens a new chunk). - Preview (
internal/preview):BuilderURL(blocks)produceshttps://app.slack.com/block-kit-builder/#<URL_ENCODED_JSON>URLs, with truncation flagging above ~8 KiB. - GoReleaser pipeline: multi-arch builds for linux/darwin/windows ×
amd64/arm64, Homebrew tap auto-published to
hishamkaram/homebrew-tap, cosign keyless signing ofchecksums.txt, CycloneDX SBOMs per archive.
- Mention sanitization is the documented security-critical path. See
SECURITY.md and
.claude/rules/security.md. The conformance suiteTestSanitization_BroadcastForms_AllEscapedByDefaultcovers six broadcast forms (!channel,!here,!everyone, user mention, channel reference, subteam) plus nested angle brackets and bare ampersand. Options.MaxInputBytes(default 256 KiB) caps markdown input before goldmark allocates an AST. Prevents trivial memory exhaustion on a public-facing MCP server.- Block Kit Builder URLs use
url.QueryEscapeover the entire{"blocks":[...]}payload — no fragment escape via the JSON encoder alone. CodeQL security-and-qualityquery suite (the strictest) runs on every push and PR.govulncheckruns in CI and lefthook pre-push.gosec(G304 excluded; we don't read user-controlled file paths) runs in CI viagolangci-lint.
# Homebrew (after this release lands the formula in homebrew-tap)
brew install hishamkaram/tap/mcp-slack-block-kit
# Go install
go install github.com/hishamkaram/mcp-slack-block-kit/cmd/mcp-slack-block-kit@v0.1.0
# Or grab a prebuilt + cosign-verifiable binary from
# https://github.com/hishamkaram/mcp-slack-block-kit/releases/tag/v0.1.0Verify a release:
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.txt- Three claims in the rich_text decomposition design are SCHEMA-ONLY or
UNVERIFIED: sibling rich_text decomposition rendering (quote-bar
doesn't visually wrap embedded preformatted), cross-block
ordered-list numbering continuation via
Offset, andmarkdownblock rendering of code-in-quote / list-in-quote / code-in-list. The picker conservatively routes around the third one. TheTestNested_PrintBuilderURLsfixture exists for manual visual verification by anyone with a Slack workspace. slack-go/slackv0.23.0'sRichTextPreformatted.Languagefield serializes into JSON but Slack itself does not syntax-highlight. We preserve the tag for tooling.- Slack Block Kit Builder URLs above ~8 KiB get unreliable in
browsers/Slack — the preview tool flags those as
Truncated: true.