Absorbing a @modelcontextprotocol/server release into Figwright, end to end: audit → upgrade → prove
the wire contract is unchanged.
Do not reason about this the way figma-typings-audit reasons about plugin typings. That package
is types-only, so tsc is a real gate. This one is a runtime dependency: it serializes every
tool result, generates the JSON Schema for every tool, and negotiates the protocol version. A
release can leave every type identical and still change what clients see. pnpm typecheck will stay
green through it.
Unlike every other dependency here, this one has a dedicated gate — use it.
packages/mcp/test/e2e/mcp-wire.test.ts spawns the built dist over real stdio, speaks raw
JSON-RPC at it, and asserts the advertised contract against what the specs declare. It runs in
pnpm test. That gate answers did anything break; it does not answer what moved, which is what
an audit is for — Stage 5 covers the difference.
Know the one thing that gate cannot see. Its schema check compares the SDK's output against
test/tool-schema.ts's derivation, and both call the same z.toJSONSchema. That is independent of
the SDK — it catches an SDK that stops asking Zod the same question — but not of Zod: when Zod
changes what it answers, both sides move together and the equality still holds. An SDK bump that
also moves the resolved zod version (its range is ^4.2.0, so it shares the repo's copy) can
therefore reshape every client's schema with this gate green.
packages/mcp/test/json-schema-generation.test.ts covers that half by pinning each construct's
rendering by hand — if a bump moves the Zod version, read its diff too, and let probe.mjs name
which tools changed.
Target version: whatever the user named, otherwise the latest @modelcontextprotocol/server on npm.
Stage 0 — Resolve versions
grep '@modelcontextprotocol' packages/mcp/package.json # declared range
grep -m1 '@modelcontextprotocol/server@' pnpm-lock.yaml # what is installed
npm view @modelcontextprotocol/server version dist-tags --json # latest
gh api repos/modelcontextprotocol/typescript-sdk/releases --jq '.[0:15][] | "\(.tag_name)\t\(.published_at)"'
Figwright is on v2 — the package family (@modelcontextprotocol/server + its transitive
/core, with /client, /node, /express, /hono, /fastify, /server-legacy, /codemod
alongside). It depends on exactly one of them.
Two things the releases list will show that are not upgrades for us:
- Bare
1.30.0-style tags are the v1 legacy line (@modelcontextprotocol/sdk, source on the long-livedv1.xbranch). Figwright left it; a v1 tag is not our concern. @modelcontextprotocol/{client,express,fastify,hono,node,server-legacy,codemod}@Xtags ship on the same version number asserverbut are packages we do not install.
If installed and target are equal, say so and stop.
Stage 1 — Read the release notes as an index, not as an answer
The GitHub Releases body is an auto-generated list of PR titles, written for SDK contributors: it says what was changed, never who is affected. "stdio buffer limit" reads like it belongs to whoever runs stdio; the useful question is which package and which directory it landed in.
So use the notes only to get the PR numbers, then ask each PR what it touched:
gh api repos/modelcontextprotocol/typescript-sdk/releases --jq '.[] | select(.tag_name=="@modelcontextprotocol/server@<target>") | .body'
# then, per PR number in that body:
gh pr view <n> --repo modelcontextprotocol/typescript-sdk --json title,files \
--jq '"\(.title)\n " + (.files | map(.path) | join("\n "))'
⚠️ Read the whole file list, never just the first entry. v2 splits one concern across packages
by design — a wire change routinely lands in packages/core-internal/src/wire/,
packages/server/src/server/, and packages/client/src/ at once. Judging a PR by its first file
is how the hunk that mattered gets missed.
Stage 2 — Sort by file path
Figwright imports exactly two entry points (packages/mcp/src/index.ts, src/prompts/*,
test/tool-schema.ts, test/e2e/mcp-wire.test.ts): @modelcontextprotocol/server and
@modelcontextprotocol/server/stdio. That narrow slice is what makes this audit cheap — most of any
given release is about parts of the family this server never loads.
| SDK path | Bearing on Figwright |
|---|---|
packages/server/src/server/mcp.ts, server.ts |
Load-bearing. registerTool / registerPrompt / result serialization |
packages/server/src/server/serveStdio.ts, stdio.ts |
Load-bearing. The entry point and the only transport this server runs |
packages/server/src/fromJsonSchema.ts, validators/ |
Load-bearing and invisible. Turns each spec's Zod object into the JSON Schema every client reads |
packages/core-internal/src/wire/, types/ |
Load-bearing. Protocol version constants, per-era codecs, CallToolResult, ToolAnnotations |
packages/core/src/ |
Schema constants + types. We import types only through server's re-export; watch renames |
packages/client/** |
Not ours — but it is what Claude Code / Cursor run against us, so a client-side limit still bites |
packages/server/src/server/streamableHttp.ts, perRequestTransport.ts, createMcpHandler.ts |
Unused today (stdio only). Relevant only to a future HTTP transport — note, don't act |
packages/{express,fastify,hono,node,server-legacy}/**, **/auth/**, examples/** |
Ignore |
The packages/client/** row is the subtle one: a limit added to the client's read path applies to
Figwright's responses, since the client is what reads them. get_screenshot returns inline
base64 and get_design_context returns large JSON, so client-side ceilings are a real exposure even
though Figwright ships no client.
Stage 3 — Cross-check against the published dist
The PR list is a claim about the release; the tarball is the release. Confirm they agree — and catch anything that reached the build without a listed PR:
cd "$SCRATCH" && mkdir -p mcp-sdk-audit && cd mcp-sdk-audit
npm pack @modelcontextprotocol/server@<installed> @modelcontextprotocol/server@<target> \
@modelcontextprotocol/core@<installed> @modelcontextprotocol/core@<target> \
--pack-destination .
for p in server core; do for v in <installed> <target>; do
mkdir -p "$p-$v" && tar -xzf "modelcontextprotocol-$p-$v.tgz" -C "$p-$v" --strip-components=1
done; done
diff -ru --exclude='*.map' server-<installed>/dist server-<target>/dist > server.diff
grep '^diff ' server.diff # the file list — compare against Stage 2
v2 emits .mjs/.cjs siblings in a flat dist/ with content-hashed chunk names
(dist/mcp-DXXb3Vv3.mjs), so a chunk filename changing between versions is noise, not signal — diff
by content, and expect the whole-file rename churn. dist/**/*.d.mts hunks are the type-level
surface (what tsc would catch); .mjs hunks with no .d.mts counterpart are the dangerous kind —
behavior changed, signature didn't.
Also diff package.json between the two versions: engines.node, dependencies (the pinned
@modelcontextprotocol/core version and zod's supported range) all move without appearing in the
source diff. v2 declares zod as a real dependency, not a peer — a range bump there can nest a
second zod copy beside the repo's own.
Stage 4 — Classify
- Type-level — a changed export in
dist/**/*.d.mtsthat Figwright names.tsccovers these; confirm in Stage 6 rather than reasoning about them. - ★ Wire behavior — the recurring shapes:
- the negotiated protocol version (
LATEST_PROTOCOL_VERSION,SUPPORTED_PROTOCOL_VERSIONS,FIRST_MODERN_PROTOCOL_VERSION) — moving it changes what every connecting client sees, and dropping an old entry can cut off an older client outright; - the JSON Schema generated per tool — one for every tool Figwright registers, they are the LLM's
entire spec, and a
$ref/allOf/additionalPropertiesshift has broken third-party clients before (seeproject_moonshot_ref_immunity); - era selection in
serveStdio— which revision a given opening exchange lands on; - stdio framing and buffering — message size ceilings, chunk handling, error-on-overflow;
- error and result serialization — what a tool-call rejection looks like to the model.
- the negotiated protocol version (
- Dependency / supply chain —
engines.nodevs the repo's^20.19.0 || >=22.12.0, the zod range againstzod@^4, transitive advisories. Note whatpnpm installflags.
Bucket 2 is the whole reason this skill exists. Never claim a bucket-2 item is safe from the diff alone — Stage 5 settles it.
Stage 5 — Run the gate, then diff the delta
Two different questions, two tools. Run both.
The gate — did anything break?
pnpm build && pnpm vitest run packages/mcp/test/e2e/mcp-wire.test.ts
It asserts the advertised tool set, each tool's JSON Schema against a derivation that is independent
of the SDK (but not of Zod — see above), the dialect, annotations, prompts, a live tools/call, the
bad-argument path, and that a 2024-11-05 client is still served. Red here means the release moved
something that matters. If the bump also moved zod, run
pnpm vitest run packages/mcp/test/json-schema-generation.test.ts as a second gate.
The probe — what moved? A gate is a boolean; an audit has to name the change. probe.mjs boots
the same built server, snapshots everything a client can observe, and diffs two snapshots.
REPO=$(git rev-parse --show-toplevel)
pnpm build # the probe reads dist, not src
node "$REPO/.claude/skills/mcp-sdk-audit/probe.mjs" "$REPO" "$SCRATCH/mcp-sdk-audit/base.json"
# ...upgrade (Stage 6), pnpm build again, then:
node "$REPO/.claude/skills/mcp-sdk-audit/probe.mjs" "$REPO" "$SCRATCH/mcp-sdk-audit/after.json"
node "$REPO/.claude/skills/mcp-sdk-audit/probe.mjs" --diff "$SCRATCH/mcp-sdk-audit/base.json" "$SCRATCH/mcp-sdk-audit/after.json"
Notes that matter:
pnpm buildfirst, every time. The MCP server runs the builtdist; probing a stale bundle reports the previous SDK. (A staledistleft behind by an abandoned upgrade is also unrunnable — it imports a package the currentnode_modulesno longer has.)- Both the gate and the probe run the server on a random high port via
FIGWRIGHT_PORT, so neither contends for3055or steals a connected plugin. Neither needs a plugin —pinganswers without one. - A baseline captured after upgrading is worthless. If the bump already happened (a Renovate
branch), take the baseline from
mainin agit worktree,pnpm install, build, probe, then come back.probe.mjsreads the installed version out of the lockfile, so a v1 baseline needs that one regex pointed at@modelcontextprotocol/sdk@.
Stage 6 — Upgrade and run the gates
pnpm -C packages/mcp add @modelcontextprotocol/server@^<target>
pnpm typecheck && pnpm lint && pnpm format:check && pnpm knip && pnpm build && pnpm test
This and the lockfile are the only writes to the repo; everything before was read-only. All six gates green and an empty probe diff is the pass condition — neither alone is one, though the six now include the wire gate, which is what made them worth trusting.
Stage 7 — Report in 繁體中文台灣用語
Ordered by consequence:
- 線上契約有沒有動 — the probe diff: protocol version, tool count, per-tool schema hashes, annotations, prompts. Name the tools that changed, or state plainly that none did.
- 會壞掉的 — bucket 1, with the gate verdict.
- 要知道但不用動的 — bucket 2/3 items that exist in the release but cannot reach this server (a client-side limit, an HTTP transport fix). Say why it cannot reach us; that reasoning is the deliverable, and it is what makes the next audit cheap.
- 新能力 — anything the SDK now exposes that Figwright could use, one line each.
Report only what the diff and the probe showed. Don't pad with plausible-sounding changes, and never claim a verification you didn't run.
Then AskUserQuestion over the items from 3 and 4 that would need work.
Stage 8 — Hand off the live verification
The gate and the probe prove the server speaks correctly to a test harness. They do not prove real clients are happy:
- The MCP server runs the built
dist→pnpm build, then the user reconnects the MCP connection (.mcp.jsonlaunchespackages/mcp/dist/index.mjs). - Ask the user to run one read tool (
ping, then something with a real payload likeget_design_context) through the reconnected server with the plugin open. - If the release touched protocol-version constants or schema generation, that check should ideally happen in more than one client — Claude Code, Cursor, Codex ship different SDK versions (many still on v1), and the failure mode is exactly a version mismatch between the two ends.
Say what was actually run. If the live step didn't happen, say that.