Skip to content

feat(mcp): Add the @frontsail/qr-mcp stdio server - #14

Merged
korya merged 3 commits into
masterfrom
korya-feat-mcp-server
Aug 1, 2026
Merged

feat(mcp): Add the @frontsail/qr-mcp stdio server#14
korya merged 3 commits into
masterfrom
korya-feat-mcp-server

Conversation

@korya

@korya korya commented Jul 31, 2026

Copy link
Copy Markdown
Member

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-core so 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_code and create_share_link, sharing core's logic rather than reimplementing it.

{
  "mcpServers": {
    "qr-code-generator": {
      "command": "npx",
      "args": ["-y", "@frontsail/qr-mcp"]
    }
  }
}

generate_qr_code takes a content type and its fields plus the same customization options the web UI offers — colors including the transparent background 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 absolute output_path. create_share_link returns 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-js on 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:

Approach Failure
Library's own canvas/PNG path (node-canvas 2) Emits a valid, scannable PNG with the logo silently missing
Library's own canvas/PNG path (napi canvas) Emits a solid square
resvg on unsanitized SVG Emits a solid square — resvg won't resolve url('#id')
No nodeCanvas at all, logo present Hangs forever — the promise never settles, no error

Three of those four produce a plausible file rather than a failure. So nodeCanvas is 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 nodeCanvas and 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

  • SVG without a logo is byte-identical to the web app's download. Guarded by tests/parity.test.ts against 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.
  • PNGs and logo SVGs are the same design, not the same bytes. resvg is not Chromium, and the library re-encodes logo images through a canvas. Claiming byte-equality there would be a promise the package cannot keep.

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-core sits in devDependencies on purpose. Core exports raw TypeScript with extensionless imports and pulls in UMD lz-string — neither loadable by plain Node — so vp pack inlines 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-mcp binary 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

Package Installed Role
@napi-rs/canvas 25.9 MB canvas for the library
jsdom 25.8 MB DOM for the library
@modelcontextprotocol/sdk 23.9 MB protocol + stdio transport
@resvg/resvg-js 3.4 MB SVG → PNG
qr-code-styling 0.8 MB the renderer
Combined 80 MB first npx -y @frontsail/qr-mcp

80 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, whose prebuild-install || node-gyp postinstall npm ≥ 11.16 is moving to block. The lockfile carries all platform variants, so CI on linux-x64 resolves without a rebuild.

No visual changeapps/web is untouched.

Other Changes

  • The blueprint doc lands first, in its own commit, mirroring PR refactor(workspace): Split into apps/web and packages/core #13's shape.
  • 62 new tests: the experiment matrix as pixel-level regressions (zero-alpha counts for transparency, logo-colored pixel counts to prove the logo rasterized), the SVG parity guard, schema and logo-path validation, and a stdio integration test driving the built artifact. Repo total is now 146 (37 core + 62 mcp + 47 e2e).
  • AGENTS.md gains a packages/mcp section 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).
  • Tool enums are derived from core's own option lists rather than retyped, so the MCP surface cannot drift from what the web app offers.
  • Package name is tentative. @frontsail/qr-mcp is free on npm, but scope ownership is still unverifiable — npm whoami returns 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

korya and others added 2 commits July 31, 2026 13:13
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
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
qr-code-generator Ready Ready Preview Jul 31, 2026 5:31pm

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
korya marked this pull request as ready for review July 31, 2026 17:33
@korya
korya merged commit bc43174 into master Aug 1, 2026
3 checks passed
@korya
korya deleted the korya-feat-mcp-server branch August 1, 2026 02:39
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant