Skip to content

Commit 2981544

Browse files
authored
feat: v0.3.0 — broadcast-safety fix, reverse tool, validator & MCP hardening (#7)
Security: - converter: h2-h6 headings (and any heading routed to the section.mrkdwn fallback) now entity-escape < > & in the heading text. The fallback previously escaped only emphasis markers, so `## <!channel>` in rich_text mode emitted a live broadcast. handleFallback and the blockquote unknown-child path were hardened the same way. Added: - server: block_kit_to_markdown tool (inverse conversion; lossy, warns) - server: MCP tool annotations on all six tools - server: block-kit-cheatsheet MCP resource + format_for_slack prompt - validator: button/context/table/image-title/section-accessory rules - validator: surface-aware validation (modal/home 100-block ceiling) - converter: MaxNestingDepth guard (ErrInputTooDeeplyNested) - internal/reverse package; block_kit re-exports the new surface Changed: - server: return_preview_url is a real opt-out (*bool) - server: convert rejects unknown split values; non-loopback bind warning - deps: slack-go/slack v0.23.0 -> v0.23.1 Fixed: - splitter: SplitText no longer cuts inside a multi-byte UTF-8 rune - converter: long-heading fallback truncation is rune-safe, no dangling \ ci: coverage gate now enforces >=80% per package, not just overall.
1 parent 1ff3a69 commit 2981544

32 files changed

Lines changed: 2229 additions & 90 deletions

.github/workflows/ci.yml

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,31 +31,48 @@ jobs:
3131
- name: Coverage report
3232
run: go tool cover -func=cover.out
3333

34-
- name: Coverage gate (≥80% on listed packages)
35-
# Per research.md §8 mandate: block_kit + the four conversion-engine
36-
# packages must hit at least 80% statement coverage. Other packages
37-
# are advisory.
34+
- name: Coverage gate (≥80% per package + overall)
35+
# block_kit + the conversion-engine packages must each hit at least
36+
# 80% statement coverage, and so must the overall figure. Per-package
37+
# coverage is computed directly from the merged cover profile
38+
# (count covered vs total statements, grouped by package dir) — the
39+
# `go tool cover -func` output has no per-package total line.
3840
run: |
3941
set -euo pipefail
42+
awk 'NR==1{next} {
43+
split($1,a,":"); file=a[1];
44+
n=split(file,p,"/"); dir=p[1];
45+
for (i=2;i<n;i++) dir=dir"/"p[i];
46+
tot[dir]+=$2; if ($3+0>0) cov[dir]+=$2
47+
} END { for (d in tot) printf "%s %.1f\n", d, 100*cov[d]/tot[d] }' cover.out \
48+
| sort > pkgcov.txt
49+
cat pkgcov.txt
50+
fail=0
4051
for pkg in \
4152
github.com/hishamkaram/mcp-slack-block-kit/block_kit \
4253
github.com/hishamkaram/mcp-slack-block-kit/internal/converter \
54+
github.com/hishamkaram/mcp-slack-block-kit/internal/reverse \
4355
github.com/hishamkaram/mcp-slack-block-kit/internal/validator \
4456
github.com/hishamkaram/mcp-slack-block-kit/internal/splitter \
4557
github.com/hishamkaram/mcp-slack-block-kit/internal/preview \
4658
; do
47-
line=$(go tool cover -func=cover.out | awk -v p="$pkg/" '$1 ~ p && $1 ~ /total:|\.go/' | grep -E "^${pkg}/[a-z_]+\.go" | awk '{ sum += $NF; n++ } END { if (n) printf "%.1f", sum/n; else print "0" }') || line=0
48-
# Fallback: use the per-file coverage average. This is approximate;
49-
# the per-package "total" line from go tool cover does not exist.
50-
echo "$pkg average per-file coverage: ${line}%"
59+
pct=$(awk -v p="$pkg" '$1==p {print $2}' pkgcov.txt)
60+
pct=${pct:-0}
61+
if awk -v t="$pct" 'BEGIN { exit (t+0 >= 80 ? 0 : 1) }'; then
62+
echo "OK: $pkg ${pct}%"
63+
else
64+
echo "FAIL: $pkg coverage ${pct}% is below the 80% gate"
65+
fail=1
66+
fi
5167
done
52-
# Hard total gate from the overall percentage.
5368
total=$(go tool cover -func=cover.out | tail -1 | awk '{print $NF}' | tr -d '%')
54-
echo "overall coverage: ${total}%"
55-
awk -v t="$total" 'BEGIN { exit (t+0 >= 80 ? 0 : 1) }' || {
69+
if awk -v t="$total" 'BEGIN { exit (t+0 >= 80 ? 0 : 1) }'; then
70+
echo "OK: overall ${total}%"
71+
else
5672
echo "FAIL: overall coverage ${total}% is below the 80% gate"
57-
exit 1
58-
}
73+
fail=1
74+
fi
75+
exit $fail
5976
6077
lint:
6178
runs-on: ubuntu-latest

CHANGELOG.md

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
1515

1616
---
1717

18+
## [0.3.0] - 2026-05-15
19+
20+
### Security
21+
- **converter**: h2–h6 headings — and any heading routed to the
22+
`section.mrkdwn` fallback (a long h1, or an h1 containing a
23+
link/image/inline code) — now entity-escape `<`, `>`, `&` in the
24+
heading text. The fallback previously escaped only the emphasis
25+
markers `* _ ~ \``, so a heading such as `## <!channel>` in
26+
`rich_text` mode (or `auto` when the input routes to rich_text
27+
decomposition) emitted a live `<!channel>` broadcast. The
28+
`handleFallback` and blockquote unknown-child paths, which also build
29+
text elements directly, were hardened the same way.
30+
`allow_broadcasts` and `preserve_mention_tokens` are still honored.
31+
32+
### Added
33+
- **server**: new `block_kit_to_markdown` MCP tool — the inverse of
34+
`convert_markdown_to_block_kit`. Best-effort and lossy; constructs
35+
with no Markdown equivalent (buttons, accessories, colors) are
36+
approximated and reported in `warnings`.
37+
- **server**: all six tools now advertise MCP tool annotations
38+
(`readOnlyHint`, `openWorldHint`, and a human-readable title).
39+
- **server**: a `block-kit-cheatsheet` MCP resource documenting the
40+
conversion modes, supported/unsupported Markdown, Slack's documented
41+
limits, and the mention-safety model.
42+
- **server**: a `format_for_slack` MCP prompt.
43+
- **validator**: per-element rules for buttons (text/value/url/action_id
44+
lengths), context blocks (≤10 elements), table blocks (≤100 rows, ≤20
45+
columns, ≤20 column_settings), image-block titles (≤2000 chars), and
46+
section accessories.
47+
- **validator**: surface-aware validation — `ValidateForSurface` plus a
48+
`surface` input on `validate_block_kit` / `lint_block_kit` raise the
49+
block ceiling from 50 (messages) to 100 (modals, App Home tabs).
50+
- **converter**: `Options.MaxNestingDepth` (default 100) rejects
51+
pathologically deep input with the new `ErrInputTooDeeplyNested`
52+
sentinel — `MaxInputBytes` bounds bytes but not structural depth.
53+
- **block_kit**: re-exports `BlockKitToMarkdown`, the `Surface` type and
54+
`SurfaceMessage`/`SurfaceModal`/`SurfaceHomeTab` constants,
55+
`ErrInputTooDeeplyNested`, and `DefaultMaxNestingDepth`.
56+
57+
### Changed
58+
- **server**: `convert_markdown_to_block_kit`'s `return_preview_url` is
59+
now a genuine opt-out — pass `false` to skip preview-URL generation
60+
(the field is a nullable bool, so omitting it still defaults to true).
61+
- **server**: `convert_markdown_to_block_kit` rejects an unknown `split`
62+
value with a clear error instead of silently ignoring it.
63+
- **server**: the HTTP and SSE transports log a warning when bound to a
64+
non-loopback address with no bearer token configured.
65+
- **deps**: `slack-go/slack` v0.23.0 → v0.23.1.
66+
67+
### Fixed
68+
- **splitter**: `SplitText` no longer cuts inside a multi-byte UTF-8
69+
rune when a single un-breakable token (a long CJK run, a non-ASCII
70+
URL) exceeds the limit — the hard-cut now steps back to the nearest
71+
rune boundary. The fuzz target gained multibyte seeds and a
72+
`utf8.ValidString` invariant.
73+
- **converter**: the long-heading `section.mrkdwn` fallback truncation
74+
is now rune-safe and strips a dangling backslash that could otherwise
75+
escape the closing `*` and leave the bold run unterminated.
76+
77+
---
78+
1879
## [0.2.1] - 2026-05-13
1980

2081
### Fixed
@@ -230,7 +291,8 @@ cosign verify-blob \
230291
- Slack Block Kit Builder URLs above ~8 KiB get unreliable in
231292
browsers/Slack — the preview tool flags those as `Truncated: true`.
232293

233-
[Unreleased]: https://github.com/hishamkaram/mcp-slack-block-kit/compare/v0.2.1...HEAD
294+
[Unreleased]: https://github.com/hishamkaram/mcp-slack-block-kit/compare/v0.3.0...HEAD
295+
[0.3.0]: https://github.com/hishamkaram/mcp-slack-block-kit/releases/tag/v0.3.0
234296
[0.2.1]: https://github.com/hishamkaram/mcp-slack-block-kit/releases/tag/v0.2.1
235297
[0.2.0]: https://github.com/hishamkaram/mcp-slack-block-kit/releases/tag/v0.2.0
236298
[0.1.0]: https://github.com/hishamkaram/mcp-slack-block-kit/releases/tag/v0.1.0

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,11 @@ CI mirrors all of the above. Coverage gate: **≥80% overall, enforced in
7171
```
7272
cmd/mcp-slack-block-kit/ cobra entry point (server default + convert subcommand)
7373
internal/converter/ goldmark renderer + emoji/mentions/markdown_block
74+
internal/reverse/ Block Kit → markdown (inverse of converter; lossy)
7475
internal/validator/ slack constraint suite + structured Violations
7576
internal/splitter/ SplitText + ChunkBlocks (50-block + table-isolation)
7677
internal/preview/ Block Kit Builder URL encoder
77-
internal/server/ MCP wiring (5 tools on top of go-sdk v1.6.0)
78+
internal/server/ MCP wiring (6 tools + cheatsheet resource + prompt)
7879
block_kit/ Public Go library re-exports (semver-stable surface)
7980
docs/ Public docs (CLAUDE-architecture.md, etc.)
8081
docs/internal/ Gitignored design notes (research.md lives here)

README.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,22 @@
2222

2323
## What it does
2424

25-
Five MCP tools your AI assistant can call:
25+
Six MCP tools your AI assistant can call:
2626

2727
| Tool | What it does |
2828
|---|---|
29-
| **`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`. |
30-
| **`validate_block_kit`** | Validates a payload against the documented Slack constraints (per-block char limits, count limits, XOR rules, `only_one_table_allowed`, the 12k-char `markdown_block` cap, etc.) with structured violations + fix hints. |
29+
| **`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`. |
30+
| **`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`. |
31+
| **`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. |
3132
| **`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. |
3233
| **`lint_block_kit`** | Warns on near-limit content, deprecated patterns, and accessibility gaps (e.g. missing image `alt_text`). Always advisory. |
3334
| **`split_blocks`** | Splits an oversized payload into multiple Slack-API-compliant chunks on the >50-block axis, with `only_one_table_allowed` enforcement. |
3435

36+
The server also exposes an MCP **resource** (`block-kit-cheatsheet` — the
37+
conversion modes, supported Markdown, Slack limits, and mention-safety
38+
model) and a **prompt** (`format_for_slack`) so MCP clients can discover
39+
how to use the tools.
40+
3541
Plus a **`convert` CLI** for offline testing without an MCP client.
3642

3743
### Conversion modes
@@ -43,6 +49,18 @@ Plus a **`convert` CLI** for offline testing without an MCP client.
4349
| **`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. |
4450
| **`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). |
4551
| **`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. |
52+
| **`section_mrkdwn`** | `section` blocks with `mrkdwn` text. | Downstream consumers that need the older `section`-based shape. |
53+
54+
### Supported Markdown
55+
56+
Headings, bold, italic, strikethrough, inline code, fenced code blocks,
57+
ordered/unordered lists (including nesting), block quotes, thematic
58+
breaks, links, images, GFM tables, task lists, and `:emoji:` shortcodes;
59+
bare URLs are auto-linked. **Not** supported: footnotes and definition
60+
lists (emitted as plain text); raw HTML is entity-escaped to literal
61+
text. In `rich_text` mode, Markdown link *titles* are dropped — Slack's
62+
rich-text link element has no title field (they survive in
63+
`markdown_block` mode).
4664

4765
### Nested elements
4866

block_kit/block_kit.go

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import (
5555

5656
"github.com/hishamkaram/mcp-slack-block-kit/internal/converter"
5757
"github.com/hishamkaram/mcp-slack-block-kit/internal/preview"
58+
"github.com/hishamkaram/mcp-slack-block-kit/internal/reverse"
5859
"github.com/hishamkaram/mcp-slack-block-kit/internal/splitter"
5960
"github.com/hishamkaram/mcp-slack-block-kit/internal/validator"
6061
)
@@ -85,17 +86,22 @@ const (
8586
// Slack-defined ceilings, exposed for callers that want to reference the
8687
// same constants as the converter.
8788
const (
88-
MaxBlocksPerMessage = converter.MaxBlocksPerMessage
89-
MaxSectionTextChars = converter.MaxSectionTextChars
90-
MaxHeaderTextChars = converter.MaxHeaderTextChars
91-
MaxMarkdownBlockSum = converter.MaxMarkdownBlockSum
92-
MaxBlockIDChars = converter.MaxBlockIDChars
93-
DefaultMaxInputBytes = converter.DefaultMaxInputBytes
89+
MaxBlocksPerMessage = converter.MaxBlocksPerMessage
90+
MaxSectionTextChars = converter.MaxSectionTextChars
91+
MaxHeaderTextChars = converter.MaxHeaderTextChars
92+
MaxMarkdownBlockSum = converter.MaxMarkdownBlockSum
93+
MaxBlockIDChars = converter.MaxBlockIDChars
94+
DefaultMaxInputBytes = converter.DefaultMaxInputBytes
95+
DefaultMaxNestingDepth = converter.DefaultMaxNestingDepth
9496
)
9597

9698
// ErrInputTooLarge is returned when input exceeds Options.MaxInputBytes.
9799
var ErrInputTooLarge = converter.ErrInputTooLarge
98100

101+
// ErrInputTooDeeplyNested is returned when the parsed markdown AST nests
102+
// deeper than Options.MaxNestingDepth.
103+
var ErrInputTooDeeplyNested = converter.ErrInputTooDeeplyNested
104+
99105
// ErrMarkdownBlockTooLarge is returned when input is requested to be
100106
// emitted as a single Slack markdown block but exceeds the 12,000-char cap.
101107
var ErrMarkdownBlockTooLarge = converter.ErrMarkdownBlockTooLarge
@@ -133,6 +139,17 @@ const (
133139
SeverityWarning = validator.SeverityWarning
134140
)
135141

142+
// Surface identifies the Slack surface a payload targets. It sets the
143+
// block-count ceiling: messages allow 50 blocks, modals and App Home tabs
144+
// allow 100. Pass one to Validator.ValidateForSurface.
145+
type Surface = validator.Surface
146+
147+
const (
148+
SurfaceMessage = validator.SurfaceMessage
149+
SurfaceModal = validator.SurfaceModal
150+
SurfaceHomeTab = validator.SurfaceHomeTab
151+
)
152+
136153
// NewValidator returns a Validator that reports only Slack-published
137154
// constraint violations as errors.
138155
func NewValidator() *Validator { return validator.New() }
@@ -177,3 +194,14 @@ func PreviewURLString(blocks []slack.Block) (string, error) {
177194

178195
// BuilderHost is the canonical Block Kit Builder URL prefix.
179196
const BuilderHost = preview.BuilderHost
197+
198+
// --- Reverse (Block Kit → Markdown) -----------------------------------------
199+
200+
// BlockKitToMarkdown converts a Slack Block Kit block list back into
201+
// Markdown — the inverse of Converter.Convert. The conversion is
202+
// best-effort and lossy: Block Kit can express styling and interactive
203+
// elements with no Markdown equivalent. The returned warnings slice names
204+
// every construct that could not be represented faithfully.
205+
func BlockKitToMarkdown(blocks []slack.Block) (markdown string, warnings []string, err error) {
206+
return reverse.ToMarkdown(blocks)
207+
}

0 commit comments

Comments
 (0)