feat(mcp): Add the @frontsail/qr-mcp stdio server - #14
Merged
Conversation
Records the design for packages/mcp before it is built: the fixed render pipeline, why the library's own canvas path is off-limits, how parity with the web app is claimed, and what the 80 MB dependency tree buys. The pipeline is not a preference. Two experiments on 2026-07-31 tried several combinations, and the ones that failed mostly failed by producing a plausible file rather than by throwing — logos silently dropped, backgrounds silently opaque, a solid square where a QR should be. Writing down which combination survived, and which traps produced silent wrongness, is the point of the doc. Assumption 7 (npm @frontsail scope ownership) is the one that did not validate, which is why publishing stays out of scope. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8brJnertGtttZrNke8L1C
Agents can now generate the web app's exact QR codes, and its share links, without a browser. packages/mcp exposes two MCP tools — generate_qr_code and create_share_link — over stdio, built to run as `npx -y @frontsail/qr-mcp`. Nothing is published in this commit. The rendering runs jsdom + @napi-rs/canvas for the DOM and image handling, and rasterizes PNGs with resvg from a sanitized copy of the SVG. The library's own canvas/PNG path is deliberately unused: it drops logos silently on node-canvas 2 and paints solid squares with the napi canvas. `nodeCanvas` is always supplied and every render is wrapped in a timeout, because without a canvas implementation the logo path returns a promise that never settles — a hung tool call, which an agent cannot report or retry the way it can report an error. Sanitizing happens on the PNG path only. The rewrite resvg needs changes bytes, so applying it to returned SVG would break byte-parity with the web app; tests/parity.test.ts guards that parity against SVGs captured from the live site through Playwright. Core is bundled rather than depended on. It exports raw TypeScript with extensionless imports and pulls in UMD lz-string, neither loadable by plain Node, so `vp pack` inlines both into one 31 kB ESM file and the published tarball carries no reference to a private workspace package. 62 tests: the experiment matrix as pixel-level regressions, the parity guard, schema and logo validation, and a stdio integration test that drives the built artifact with the SDK's own client. apps/web is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8brJnertGtttZrNke8L1C
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Flip the blueprint to implemented and record the four deviations plus the SDK findings (registerTool over deprecated tool(), the ~24 MB SDK share of cold start, and the deferred ~12 MB slimming path) so the doc stays reconciled with what landed in PR #14. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
korya
marked this pull request as ready for review
July 31, 2026 17:33
korya
added a commit
that referenced
this pull request
Aug 1, 2026
The npm scope question is settled: the owned org is frontsail-ai, with the hyphen, and @frontsail-ai/qr-mcp is verified free. PR #14 shipped the package as @frontsail/qr-mcp, which was always marked tentative pending exactly this. The `qr-mcp` bin name is unchanged, so the npx invocation users type is the only thing that shifts. The private @frontsail/qr-core keeps its name: it is bundled into the published artifact and never published itself, so its scope is invisible outside the repo and renaming it would churn apps/web for no external benefit. The blueprint's body keeps the old name as written; only its Outcome section records the resolution, so the plan stays a record of what was planned rather than a retcon. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8brJnertGtttZrNke8L1C
korya
added a commit
that referenced
this pull request
Aug 1, 2026
`npx @frontsail-ai/qr-mcp` would have done nothing. npm rejects a `bin` path written as "./dist/index.mjs" and, rather than failing, strips the entry and publishes the package without it: "bin[qr-mcp] script name dist/index.mjs was invalid and removed". It surfaces only as a warning, and only on `npm publish` — installing the packed tarball links the bin correctly either way, which is why PR #14's end-to-end check did not catch it. The `./` prefix was not hand-written. `vp pack` with `pack.exports: true` rewrites package.json's `exports` and `bin` on every build, and the `bin` value it generates is the rejected form — so correcting package.json alone would have been undone by the next build, including the build that the publish flow runs. Dropping `exports: true` and declaring the entry explicitly stops the build touching the manifest at all. `exports` keeps its "./" prefix, which that field requires; only `bin` takes the bare path. The two fields having opposite rules is what makes this easy to get wrong. tests/manifest.test.ts pins both forms, plus the file list, public access, and the invariant that core stays a devDependency and node-canvas never becomes a dependency. Also clarifies two tool descriptions that a dogfooding agent flagged as guess-inducing: that `extra-rounded` is the value the UI labels "Rounded", and that the two linear gradient names are rotated relative to what they render (verified: `linear-bl-tr` emits a top-left to bottom-right gradient). The underlying rotation bug lives in packages/core and predates this package; fixing it would change the web app's output and re-render existing share links, so it is left alone and documented instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8brJnertGtttZrNke8L1C
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
An agent asked to make a QR code cannot produce the one our own site produces — it has to drive a browser or reach for a different library.
A different library means different defaults, different presets, and output that drifts from qr-code-gen.frontsail.app. PR #13 extracted the framework-free logic into
@frontsail/qr-coreso a second consumer could exist without forking it. This is that consumer.Solution
Add
packages/mcp— a stdio MCP server exposing two tools,generate_qr_codeandcreate_share_link, sharing core's logic rather than reimplementing it.{ "mcpServers": { "qr-code-generator": { "command": "npx", "args": ["-y", "@frontsail/qr-mcp"] } } }generate_qr_codetakes a content type and its fields plus the same customization options the web UI offers — colors including thetransparentbackground sentinel, gradients, dot and corner styles, and a logo as a data URI or absolute path. It returns SVG text (280px), a base64 PNG (560px), or writes to an absoluteoutput_path.create_share_linkreturns a link that reopens the design in the web editor, with logos excluded exactly as the web app excludes them.The pipeline is a finding, not a preference
Rendering runs jsdom +
@napi-rs/canvas+@resvg/resvg-json a sanitized SVG. Two experiments on 2026-07-31 tried the alternatives, and what makes this worth spelling out is how they failed — almost never by throwing:url('#id')nodeCanvasat all, logo presentThree of those four produce a plausible file rather than a failure. So
nodeCanvasis always supplied, every render carries a hard timeout, and the tests assert on decoded pixels rather than on "a buffer came back".The hang is the one that matters most for an MCP server: a tool call that never returns is strictly worse for an agent than one that errors, because there is nothing to report or retry. A test doctors the render to omit
nodeCanvasand asserts it rejects — it takes 1.5 s of its 1.5 s timeout, which is the evidence the hang is real and the backstop works.Parity is claimed precisely
tests/parity.test.tsagainst four SVGs captured from the live site through Playwright — the goldens are the browser's own files, not this package's output, so the test fails if the two ever diverge. The only tolerated difference is qr-code-styling's per-process instance counter in element ids, which has no rendered effect.This is also why sanitizing runs on the PNG path only: the
url('#id')→url(#id)rewrite resvg needs changes bytes, and applying it to returned SVG would break the parity above.Core is bundled, not depended on
@frontsail/qr-coresits indevDependencieson purpose. Core exports raw TypeScript with extensionless imports and pulls in UMDlz-string— neither loadable by plain Node — sovp packinlines both into a single 31 kB ESM file. The published tarball is 2 files and 8.7 kB, with no reference to a private workspace package.Verified by packing the tarball, installing it into a clean directory, and driving the linked
qr-mcpbinary over stdio with the SDK's own client: both tools answered, and the share link it produced decodes back to the original design.Dependency weight and npx cold start
@napi-rs/canvasjsdom@modelcontextprotocol/sdk@resvg/resvg-jsqr-code-stylingnpx -y @frontsail/qr-mcp80 MB is real, paid once per npx cache generation, and not meaningfully reducible here: jsdom is what makes the library run, the napi canvas is what stops the logo path hanging, and the SDK's own tree (express, hono, jose, ajv) is not ours to trim. Both native deps ship napi prebuilds for every major platform and run no install scripts — deliberately avoiding
canvas/node-canvas, whoseprebuild-install || node-gyppostinstall npm ≥ 11.16 is moving to block. The lockfile carries all platform variants, so CI on linux-x64 resolves without a rebuild.No visual change —
apps/webis untouched.Other Changes
AGENTS.mdgains apackages/mcpsection recording the invariants that are easy to unknowingly break — bundled core, the forbidden canvas path, sanitizer and timeout, and the real-PNG fixture requirement (resvg decodes node-canvas-2-encoded PNGs as black).@frontsail/qr-mcpis free on npm, but scope ownership is still unverifiable —npm whoamireturns 401, the same blocker as PR refactor(workspace): Split into apps/web and packages/core #13's open question 2. Nothing publishes here, and the name is trivially changeable before a first release.Related:
🤖 Generated with Claude Code