@frmwrkd/snag-mcp
v0.1.3
Published
The Snag MCP server for Claude Code: five tools over .snag/.
Readme
@frmwrkd/snag-mcp
stdio MCP server exposing the Snag queue to Claude Code. Five tools, four resources.
Licence: Apache-2.0 · Spec: docs/02-BACKEND-SPEC.md §9. Locked decisions: docs/00-MASTER-BUILD-PLAN.md §7 — do not relitigate them.
The number that shapes this package
Claude Code warns at 10,000 tokens of MCP tool output and hard-caps at
25,000 (MAX_MCP_OUTPUT_TOKENS, doc 03 §4.3). Chrome DevTools MCP ships
~17,000 tokens of tool definitions before a single call, and pays that on
every turn.
So: .snag/ files carry the bulk, and MCP carries actions and filtered
queries. Measured on this server:
| | tokens |
|---|---|
| Tool definitions, every turn | ~2,900 |
| snag_list on a 20-task queue | ~600 |
| snag_get on one task | ~500 |
| A screenshot, if you ask for one | ~1,568 visual |
The regression guards live in test/tools.test.ts; they fail if a sixth tool
appears or the definitions cross 4,000 tokens.
Install
@frmwrkd/snag-mcp is not published yet, so the server runs from a local build:
npx [email protected] build
node scripts/install-claude-code.mjs "C:\code\my-app"The installer writes .mcp.json with an absolute node command, plus the
skill, hooks and settings — see integrations/claude-code/README.md:
// .mcp.json, project-scoped — machine-specific: do not commit it yet
{ "mcpServers": { "snag": { "type": "stdio", "command": "node", "args": ["L:/snag/apps/mcp/dist/index.js"] } } }That absolute path exists only on the machine that ran the installer, which
warns about it every run. Until @frmwrkd/snag-mcp is published and the entry can be
npx -y @frmwrkd/snag-mcp, keep .mcp.json out of commits.
The server is spawned with the project directory as its cwd and finds .snag/
by walking up from there — skipping ~/.snag/, which is snagd's token
directory, not a project. It only talks to a snagd whose /health root is
this project — or, when no such daemon answers, one serving another git
worktree of the same repository (same git rev-parse --git-common-dir). A
snagd for some other project is never used, and a write then fails naming it:
snagd on :7654 serves L:\code\other-app, not L:\code\my-app — open Claude Code in L:\code\other-app, or start snagd with --root L:\code\my-app
Before every status change and verify request the cached daemon's /health is
asked again, so a port another project's snagd took over after a restart is
dropped and discovery runs again.
snag_resolve first asks POST /verify/:taskId with dry_run: true, before it
moves the task at all. no_capture_url (an authored task has no page to
re-open) leaves the task in_progress and says a human must resolve it;
verify_disabled resolves it directly with verified: false where §5.3
allows. Otherwise it moves the task to verifying and gets 202 back from the
real POST at once; Verify runs in Chrome afterwards, so the tool tells the
agent to snag_get the task in ~30 s, where verify carries the last
outcome and reason from GET /verify/:taskId. When no extension is
connected (503 verify_unavailable) the daemon's message is passed through
word for word and the task stays verifying — the only refusal that says to
call snag_resolve again.
Tools
| Tool | Signature | Returns |
|---|---|---|
| snag_list | (status?, wave?, limit=20) | Compact list: id, title, severity, kind, wave, status, first suspect file + line range. Non-terminal tasks by default. |
| snag_get | (id) | The full task via structuredContent, plus resource_links to screenshots on disk, a next_actions list, and — once Verify is involved — verify: { running, outcome, reason, last_at }. |
| snag_claim | (id) | open → claimed. Returns the full task. |
| snag_resolve | (id, commit?, note?) | Verify preflight, then → verifying and queues a verify run. Never resolved while Verify is enabled. |
| snag_screenshot | (id, index=0) | The image inline. Only when explicitly called. |
id is SNAG-<n> or tsk_<ulid>; both work everywhere.
Three invariants
- Screenshots are never inlined by
snag_get(locked decision D13). They go out asresource_links withfile://paths — Claude Code canReada local PNG directly, which sidesteps the output cap entirely. snag_resolvenever setsresolved. doc 02 §5.3: "resolvedis only ever set by Verify, never by the agent directly." The single documented exception isplan.verify_on_resolve: falsein snagd's.snag/config.json— reported by the verify preflight asverify_disabled— in which case the task goes straight toresolvedwithverified: false, and the daemon still gates that edge on the config value. Verify itself re-opens the page, re-resolves the anchor and checks the captured console errors; it does not compare pixels yet, and a capture with no console error goes toneeds_review.next_actionsis Jam'sgetDetailspattern. One cheap call tells the agent which expensive calls are worth making: it suggestssnag_screenshotwhenkindisux/a11y/layout, and explicitly tells the agent not to bother otherwise.
Resources
snag://queue · snag://plan · snag://task/{id} · snag://map
Surfaced in Claude Code as @snag:snag://… autocompletes.
snag://task/{id} returns the bytes of .snag/tasks/SNAG-<n> - <title>.md
unchanged, so reading a task through MCP and reading it with Read produce
character-identical text. The frontmatter carries edit anchors; they must not
be re-rendered.
Every resource is capped: if a payload would exceed the client's warning threshold it projects itself down to a summary and names the file on disk. Losing detail loudly beats a client truncating mid-JSON.
How it gets its data
snag_list / snag_get / resources → snagd HTTP → falls back to .snag/ files
snag_claim / snag_resolve → /health re-checked → snagd HTTP → hard error if absent
snag_screenshot → .snag/shots/*.png on diskReads prefer the daemon because the SQLite index carries live status;
.snag/plan.json is a snapshot whose statuses go stale the moment a task is
claimed. When the daemon is unreachable the fallback still answers, and every
response is labelled source: "daemon" | "files" so a stale status is a known
stale status.
Writes require the daemon. Editing .snag/tasks/*.md from here would race the
daemon's file watcher and desynchronise the index, so snag_claim and
snag_resolve fail with an error that names the fix:
snagd is not running (probed http://127.0.0.1:7654-7664 and /repo/.snag/.port). Start it with
npx snagdin /repo. Until then the queue is still readable without me — Read/Grep .snag/tasks/*.md and .snag/plan.json.
Port discovery reads .snag/.port first, then probes 7654–7664 for a
GET /health that identifies itself as snagd (doc 02 §7.3 — nothing
hard-codes 7654). Auth is the token from ~/.snag/token, sent as
Authorization: Bearer.
Implementation notes
Why the low-level Server and not McpServer. McpServer.registerTool
generates each tool's wire schema itself, at draft-7. The MCP 2026-07-28
spec says tool schemas are JSON Schema 2020-12, and @frmwrkd/snag-core's
toInlineJsonSchema emits exactly that. Going through the low-level Server
keeps @frmwrkd/snag-core the single source of truth and makes the emitted bytes
inspectable, which is what lets the tests assert their token cost.
One interop wrinkle, found by running it. The SDK's own client validates
structuredContent against the declared outputSchema with an Ajv instance
built from ajv's draft-07 entry point. Draft-07 has no prefixItems, so
the spec-correct 2020-12 tuple for Task.suspect_files[].lines
(prefixItems + items: false) was rejected with
MCP error -32602: … data/tasks/0/suspect_lines/0 boolean schema is false
openTupleTail in src/tools.ts drops the items: false when maxItems
already pins the arity — a no-op under 2020-12, and the difference between
working and not under draft-07. test/schema-interop.test.ts runs the SDK's
real validator over the real schemas so it cannot regress.
Development
pnpm --filter @frmwrkd/snag-mcp build
pnpm --filter @frmwrkd/snag-mcp test # vitest, 160 tests
pnpm --filter @frmwrkd/snag-mcp typecheck