Skip to content
This repository was archived by the owner on Aug 3, 2026. It is now read-only.

chore: migrate to MCP TypeScript SDK v2 - #58

Closed
olaservo wants to merge 2 commits into
mainfrom
chore/migrate-mcp-sdk-v2
Closed

olaservo wants to merge 2 commits into
mainfrom
chore/migrate-mcp-sdk-v2

Conversation

@olaservo

@olaservo olaservo commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Migrates this server from MCP TypeScript SDK v1 to the split v2 packages.

Package swap

Before After
@modelcontextprotocol/sdk ^1.26.0 @modelcontextprotocol/server ^2.0.0
@modelcontextprotocol/node ^2.0.0 (Streamable HTTP over express)
@modelcontextprotocol/server-legacy ^2.0.0 (frozen SSE transport)
zod ^3.23.8 zod ^4.2.0
engines.node >=18.0.0 >=20.0.0

@modelcontextprotocol/core is not declared — the only symbol that would have needed it (ElicitResultSchema) went away when the elicitation call moved to ctx.mcpReq.elicitInput() (see below).

Mechanical rewrites (codemod)

npx @modelcontextprotocol/codemod@2.0.0 v1-to-v2 . — 54 changes across 29 files.

What Where
sdk/server/mcp.js → @modelcontextprotocol/server (McpServer, ResourceTemplate) all 29 SDK-importing files
sdk/types.js → @modelcontextprotocol/server (CallToolResult, Resource) tools/*, resources/staticResources.ts
sdk/server/stdio.js → @modelcontextprotocol/server/stdio stdio.ts
StreamableHTTPServerTransport → NodeStreamableHTTPServerTransport from @modelcontextprotocol/node streamableHttp.ts
SSEServerTransport → @modelcontextprotocol/server-legacy/sse sse.ts
setRequestHandler(ListResourcesRequestSchema, …) → setRequestHandler('resources/list', …) resources/index.ts
setRequestHandler(Subscribe/UnsubscribeRequestSchema, …) → 'resources/subscribe' / 'resources/unsubscribe' resources/subscriptions.ts
extra → ctx; extra._meta → ctx.mcpReq._meta; extra.requestId → ctx.mcpReq.id tools/longRunningTask.ts
extra.sendRequest → ctx.mcpReq.send tools/elicitationAllOptional.ts
Raw shapes wrapped in z.object() (argsSchema, inputSchema) prompts/complexPrompt.ts, prompts/resourcePrompt.ts, tools/elicitationAllOptional.ts
package.json dependency rewrite package.json

The registerTool / registerResource / registerPrompt call sites needed no shape change — this repo was already on the non-variadic register* API, and registerResource already passed a metadata argument everywhere.

Manual changes, and why each was needed

1. Vendored InMemoryEventStore — inMemoryEventStore.ts (new)

streamableHttp.ts imported @modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js. That was never a public export and has no v2 equivalent, so the codemod could only flag it. The implementation is now vendored from the SDK's examples/shared/src/inMemoryEventStore.ts, importing EventStore / JSONRPCMessage from @modelcontextprotocol/server.

One deliberate deviation: the SDK example uses Array.prototype.toSorted() (ES2023). This package targets ES2022, so the vendored copy uses [...this.events.entries()].sort(...).

2. zod ^3.23.8 → ^4.2.0

v2 requires zod ≥ 4.2.0 (it self-converts schemas via ~standard.jsonSchema). This is the failure mode that typechecking does not catch: a zod-3 range installs cleanly, compiles cleanly, the server starts and connects normally, and then the first tools/list answers with an error pointing at fromJsonSchema(). Caught only by the runtime smoke test.

No zod-3-only type API (z.ZodTypeDef, the old z.ZodType generics) was in use, so there was no consumer-side zod-4 fallout to fix.

3. ctx.mcpReq.send(…, ElicitResultSchema, opts) → ctx.mcpReq.elicitInput(params, opts) — tools/elicitationAllOptional.ts

The codemod mapped extra.sendRequest → ctx.mcpReq.send but leaves the result-schema argument alone (it only drops it from client.request() / client.callTool()). For spec methods v2 resolves the result type from the method name, so the schema argument has to go. elicitInput is the purpose-built handler-context helper for elicitation/create, so the call now reads as the params object directly. This removed the file's last @modelcontextprotocol/core import.

4. .meta({ id: "Person" }) on jsonRefTest's PersonSchema

This one is a behavior restoration, please review it. jsonRefTest exists specifically to exercise $ref in an advertised inputSchema. Under v1 + zod 3 it emitted:

"husband": { "type": "object", "…": "…" },
"wife":    { "$ref": "#/properties/husband" }

zod 4 defaults to reused: "inline", so after the bump both branches were inlined and the fixture stopped testing $ref at all. Tagging the shared subschema with an id makes zod hoist it, which restores $ref in the cleaner $defs form:

"husband": { "$ref": "#/$defs/Person" },
"wife":    { "$ref": "#/$defs/Person" },
"$defs":   { "Person": { "…": "…" } }

If you'd rather the fixture reflect stock zod-4 output, drop the .meta() call — everything else still passes.

5. engines.node >=18.0.0 → >=20.0.0

All three v2 packages declare "engines": { "node": ">=20" }. Leaving >=18 would advertise support this package no longer has.

6. tsconfig module/moduleResolution: ESNext/node → NodeNext/NodeNext

Correcting an assumption: I expected v2's export maps to fail under the legacy node (node10) resolver. They do not — the v2 packages ship a typesVersions fallback ({"*":{"sse":["dist/sse/index.d.mts"], "…": "…"}}) precisely so node10 still finds subpath types. I verified moduleResolution: node10 typechecks this repo cleanly.

So this change is not required by the migration. I made it anyway because node10 is deprecated in TS 5.x, and NodeNext models what Node actually does at runtime for a "type": "module" package instead of relying on a compat shim. It's an isolated two-line change if you'd prefer a more minimal diff. Emit is unchanged (dist/*.js + dist/*.d.ts, matching the published main/types).

7. Cleared 13 @mcp-codemod-error markers

The codemod could not statically prove that inputSchema: referenced a Standard Schema object in 13 tool files (each passes a module-level const). I inspected all 13 — every one is a real z.object(...) — and removed the markers. No marker remains anywhere in the tree.

8. Reindented two wrapped argsSchema blocks

The codemod does not reformat, and this repo has no prettier/eslint config, so this was done by hand and touches only the two blocks it wrapped.

Behavioral changes reviewers should know about

Advertised tool schemas change shape. Expected and spec-conformant, but this repo exists to be a fixture server, so the diffs are worth knowing. Measured by building v1 and v2 side by side and diffing real tools/list output:

  • $schema: http://json-schema.org/draft-07/schema# → https://json-schema.org/draft/2020-12/schema.
  • additionalProperties: false is no longer emitted on any object.
  • userFilterTest.is_active went from "type": ["boolean","null"] to an anyOf of boolean / null. (Note: this tool's description claims "$ref to $defs", but v1 never emitted a $ref here either — pre-existing, not a regression.)
  • strictTypeValidation gained richer constraints from zod 4: format: "email" plus a pattern, minimum/maximum bounds on integerField, exclusiveMinimum: 0 for .positive().
  • jsonRefTest moved from a positional $ref (#/properties/husband) to a named one (#/$defs/Person) — see manual change 4.

Anything pinning this server's advertised tool list needs re-baselining.

Unknown / unregistered tool calls now reject. v2 answers ProtocolError(-32602 InvalidParams) where v1 resolved CallToolResult{ isError: true }. Visible here via elicitationAllOptional, which only registers when the client advertises the elicitation capability — a client without it now gets a JSON-RPC error rather than an isError result.

Capabilities are advertised with listChanged: true. v2's McpServer eagerly installs capability handlers, so initialize now returns {"resources":{"subscribe":true,"listChanged":true},"tools":{"listChanged":true},"prompts":{"listChanged":true}}.

SSE stays on a frozen copy. SSEServerTransport is removed from v2. Rather than delete a working entry point, sse.ts and npm run start:sse now use @modelcontextprotocol/server-legacy/sse — a frozen v1 copy, and a supported interim choice. It installs with a deprecation notice from npm and will not receive new features; the long-term path is Streamable HTTP, which this repo already exposes via start:streamableHttp.

This is a published library (mcp-maintainer-toolkit@0.1.5) that exports createServer. The returned McpServer is now a v2 type, and v1/v2 objects do not interoperate (instanceof and nominal types do not cross the boundary). Any consumer importing createServer must migrate to v2 in the same step, so this warrants a semver-major release.

Verification

Everything below was actually run against this branch.

Check Command Result
Typecheck npx tsc --noEmit clean (no typecheck script in repo)
Build rm -rf dist && npm run build clean
Tests — not run: this repo has no test suite and no test script
Straggler sweep grep -rn "@modelcontextprotocol/sdk" . --exclude-dir=node_modules --exclude-dir=dist --exclude-dir=.git only a historical comment in the vendored inMemoryEventStore.ts
Marker sweep grep -rn '@mcp-codemod-error' . none
Dep tree npm ls --depth=0, npm ls hono clean, no unmet peers

stdio — 31 JSON-RPC frames piped into node dist/index.js:

  • initialize, tools/list, resources/list, resources/templates/list (4), prompts/list (3) — all OK.
  • tools/list returned all 13 tools, every one with a non-empty inputSchema — this is the check that proves the zod bump landed; a missed bump surfaces here as a fromJsonSchema error.
  • tools/call against all 13 tools succeeded, including every adversarial fixture (complexOrder with nested address/items, strictTypeValidation, unionTypeTest, enumDropdownTest, jsonRefTest, userFilterTest, longDescriptionTest).
  • A deliberately invalid strictTypeValidation call was rejected with isError: true and an input-validation message, confirming the 2020-12 validator is wired up.
  • longRunningTask emitted both notifications/progress frames, confirming ctx.mcpReq._meta.progressToken + relatedRequestId: ctx.mcpReq.id.
  • resources/read on all 5 shapes (static text, static blob, test://users{?…}, test://api/v1/posts/{id}{?…}, test://search{?q}); resources/list with cursor returned page 2, confirming the resources/list override.
  • resources/subscribe + resources/unsubscribe both returned {}.
  • prompts/get on all 3 prompts, including the image and embedded-resource content blocks.

stdio with elicitation advertised — a driver client that declares the capability and answers the server's request: 14 tools listed (the conditional tool registers), full elicitation/create round trip, submitted values echoed back correctly.

Streamable HTTP — PORT=3102 node dist/streamableHttp.js, driven with curl (Content-Type: application/json, Accept: application/json, text/event-stream): initialize issued a session id and SSE frames carried event ids (confirming the vendored InMemoryEventStore is being written to), then notifications/initialized → 202, tools/list → 13 tools, tools/call add → 42, resources/list → 10 + cursor, prompts/get complex_prompt, and DELETE /mcp → 200.

SSE — PORT=3101 node dist/sse.js, full round trip over the frozen transport: GET /sse returned the endpoint event, then initialize, tools/list (13), and tools/call echo all resolved over the stream.

🤖 Generated with Claude Code

olaservo and others added 2 commits August 2, 2026 18:53
Replaces the single `@modelcontextprotocol/sdk@^1.26.0` dependency with the
split v2 packages (`@modelcontextprotocol/{server,node,server-legacy}@^2.0.0`)
and bumps zod to `^4.2.0`, which v2 requires.

Applied by `npx @modelcontextprotocol/codemod@2.0.0 v1-to-v2 .`
(54 changes across 29 files):

- Rewrote every `@modelcontextprotocol/sdk/...` import to its v2 package.
- `StreamableHTTPServerTransport` -> `NodeStreamableHTTPServerTransport`
  from `@modelcontextprotocol/node` (this is a Node/express host).
- `SSEServerTransport` -> `@modelcontextprotocol/server-legacy/sse`.
- Schema-first `setRequestHandler(XRequestSchema, ...)` -> method strings
  (`resources/list`, `resources/subscribe`, `resources/unsubscribe`).
- `extra` -> `ctx`: `extra._meta` -> `ctx.mcpReq._meta`,
  `extra.requestId` -> `ctx.mcpReq.id`, `extra.sendRequest` -> `ctx.mcpReq.send`.
- Wrapped the raw `argsSchema` / `inputSchema` shapes in `z.object()`
  (complexPrompt, resourcePrompt, elicitationAllOptional).
- Rewrote package.json dependencies.

Done manually:

- Vendored `InMemoryEventStore` into `inMemoryEventStore.ts`. streamableHttp.ts
  imported it from `@modelcontextprotocol/sdk/examples/shared/...`, which was
  never a public export and does not exist in v2. Uses `[...].sort()` rather
  than the SDK example's ES2023 `.toSorted()`, since this package targets ES2022.
- Bumped zod `^3.23.8` -> `^4.2.0`. The codemod warns that a zod-3 range cannot
  satisfy v2's floor; it installs and typechecks fine and then fails at runtime
  on the first `tools/list`. No zod-3-only type API (`z.ZodTypeDef`, old
  `z.ZodType` generics) was in use, so no schema fallout.
- Switched `ctx.mcpReq.send(..., ElicitResultSchema, opts)` to
  `ctx.mcpReq.elicitInput(params, opts)` in elicitationAllOptional.ts. v2
  resolves spec-method result types from the method name, so the schema argument
  is dropped; `elicitInput` is the intended handler-context helper. This removed
  the last `@modelcontextprotocol/core` import, so that package is not declared.
- Added `.meta({ id: "Person" })` to jsonRefTest's PersonSchema. zod 4 defaults
  to `reused: "inline"`, so this fixture -- whose entire purpose is exercising
  `$ref` -- stopped emitting one. It now emits `$defs`/`$ref` again.
- Bumped `engines.node` `>=18` -> `>=20`; all v2 packages declare `node >=20`.
- tsconfig `module`/`moduleResolution` `ESNext`/`node` -> `NodeNext`/`NodeNext`.
  Not strictly required (the v2 packages ship a `typesVersions` node10 fallback,
  so legacy resolution still typechecks), but node10 is deprecated and NodeNext
  models what Node actually does for this `"type": "module"` package.
- Cleared the 13 `@mcp-codemod-error` "could not verify inputSchema is a schema
  object" markers after confirming each inputSchema is a real `z.object()`.
- Reindented the two `argsSchema` blocks the codemod wrapped (it does not format).

Verified:

- `npx tsc --noEmit` — clean (repo has no typecheck script).
- `npm run build` — clean from a removed dist/.
- No test suite exists in this repo, so none was run.
- stdio smoke test over `dist/index.js`: initialize, tools/list (13 tools, all
  with non-empty inputSchema), resources/list (+ cursor page 2),
  resources/templates/list, prompts/list, all 13 tools/call, 5 resources/read,
  subscribe/unsubscribe, all 3 prompts/get. longRunningTask emitted both
  notifications/progress frames. An intentionally invalid strictTypeValidation
  call was rejected as expected.
- stdio run with a client advertising `elicitation`: 14 tools listed and a full
  elicitation/create round trip returned the submitted values.
- Streamable HTTP on :3102 via curl: initialize (session id issued, eventStore
  event ids present), tools/list, tools/call, resources/list, prompts/get,
  DELETE session -> 200.
- SSE on :3101 via the frozen server-legacy transport: endpoint event,
  initialize, tools/list (13), tools/call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both are fallout from zod 4, not from the SDK v2 API surface, and both were
invisible to typecheck -- they only show up by diffing the advertised tool
schemas against the v1 baseline.

formatData: `data` became required
  zod 3 treated a bare `z.any()` as optional; zod 4 treats it as required. The
  advertised schema gained `"required": ["data"]` and a call that succeeded on v1
  (`{"format":"json"}` -> "Data formatted as json:\n\nundefined") started coming
  back as isError. Restored with an explicit `.optional()`.

add / complexOrder: reused subschemas lost their $refs
  zod 4's default `reused: "inline"` policy inlines a subschema at every use site
  instead of hoisting it. `add.b` was `{"$ref":"#/properties/a"}` and became an
  inlined copy; `complexOrder.shippingAddress` was `{"$ref":"#/properties/billingAddress"}`
  and became a duplicated ~20-line literal. `jsonRefTest` already got the
  `.meta({ id })` treatment during the migration; `add` and `complexOrder` use the
  same reuse pattern and were missed. In a fixture server whose purpose is
  exercising $ref rendering, silently losing $refs defeats the fixture.

Verified against live tools/list on the built output: formatData has no `required`
array and the no-data call succeeds with the v1 text; add ($defs.AddOperand),
complexOrder ($defs.Address) and jsonRefTest ($defs.Person) all emit $ref again.
tsc --noEmit and npm run build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@olaservo

olaservo commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

Follow-up commit: two zod-4 regressions found in review, now fixed

An independent review caught two behavior changes that typecheck could not see — both zod 3 → 4 fallout rather than SDK v2 API changes. Fixed in the follow-up commit on this branch:

formatData had become required-only. zod 3 treated a bare z.any() as optional; zod 4 treats it as required. The advertised schema gained "required": ["data"] and {"format":"json"} — which succeeded on v1 with Data formatted as json:\n\nundefined — started returning isError. Restored with an explicit .optional().

add and complexOrder had silently lost their $refs. zod 4's default reused: "inline" policy inlines a shared subschema at each use site instead of hoisting it. add.b was {"$ref":"#/properties/a"} and became an inlined copy; complexOrder.shippingAddress was {"$ref":"#/properties/billingAddress"} and became a duplicated ~20-line literal. jsonRefTest got the .meta({ id }) fix during the migration; these two use the same reuse pattern and were missed. In a fixture server that exists to exercise $ref rendering, that defeats the fixture.

Verified on live tools/list from the built output: formatData has no required array and the no-data call returns the v1 text; add ($defs.AddOperand), complexOrder ($defs.Address) and jsonRefTest ($defs.Person) all emit $ref again.

Not fixed — needs a decision

Concurrent Streamable HTTP sessions now silently serve the wrong tool list. v2's McpServer accepts repeated connect() where v1 threw Already connected, so the single module-scope server (streamableHttp.ts:11) re-runs oninitialized for every session. The duplicate registerTool throw is swallowed, and elicitationAllOptional leaks to clients that never advertised the elicitation capability — verified with three sessions, where B and C got 14 tools including elicitationAllOptional despite sending capabilities: {}.

On v1 this failed loudly (500 for every session after the first). On v2 it fails silently, which is worse. The structural fix is v2's createMcpHandler / McpServerFactory, giving each session its own server instance — a redesign that deserves its own PR rather than being folded into a migration.

Other disclosures

  • execution: {"taskSupport":"forbidden"} disappeared from every tool descriptor. For a server used to exercise Inspector, that is exactly the kind of thing worth knowing.
  • The description claims strictTypeValidation "gains exclusiveMinimum for .positive()" — v1 already emitted it.
  • package-lock.json was not regenerated after the engines bump; its root entry still records node >=18.0.0 while package.json says >=20.0.0.
  • hono is an undeclared peer pulled in by @modelcontextprotocol/node. npm auto-installs it; pnpm and yarn PnP will report an unmet peer.

@olaservo

olaservo commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

Addendum: the multi-session issue is worse than described above

A second review pass sharpened the shared-McpServer finding. My earlier comment said capability-gated tools leak to sessions that never advertised the capability. That is true, but it is the milder half.

Server._clientCapabilities is a single field on the one module-scope server, overwritten by whichever session initialized most recently. So it is not just that elicitationAllOptional becomes visible — a session that did advertise elicitation can have that capability overwritten by a later session that did not, and a subsequent elicitationAllOptional call then blocks on an elicitation round-trip that will never come. That hangs the tool call for the full request timeout (10 minutes by default) rather than failing fast.

Same root cause, same fix (createMcpHandler / McpServerFactory, one server instance per session), still out of scope for a migration PR. Recording it because "leaks a tool" and "hangs a call for ten minutes" warrant different priorities on the follow-up.

@olaservo

olaservo commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

Closing unmerged — this repository is being archived rather than migrated to MCP TypeScript SDK v2.

The work itself is complete and was reviewed twice (final verdict: merge-with-nits, no blocking findings, no type-safety erosion). It is preserved on the chore/migrate-mcp-sdk-v2 branch if anyone forks this and wants to pick it up:

  • SDK v1 → v2 across 29 files: registerTool/registerPrompt/registerResource, the three setRequestHandler calls, extra.* → ctx.*
  • SSEServerTransport repointed to @modelcontextprotocol/server-legacy/sse
  • InMemoryEventStore vendored locally (the sdk/examples/ path has no v2 equivalent)
  • zod ^3.23.8 → ^4.2.0, plus two zod-4 regressions found in review and fixed: z.any() becoming required in formatData, and add/complexOrder losing their $refs to zod 4's inline-on-reuse default

One pre-existing defect was documented here but never fixed, and it outlives the migration — worth knowing if this code is revived: the single module-scope McpServer shared across Streamable HTTP sessions. Server._clientCapabilities is one field overwritten by the most recent session, so a session that advertised elicitation can have it overwritten by one that did not, hanging a later elicitationAllOptional call for the full 10-minute timeout. The fix is v2's createMcpHandler / McpServerFactory.

@olaservo olaservo closed this Aug 3, 2026
olaservo added a commit that referenced this pull request Aug 3, 2026
Targets MCP TypeScript SDK v1, which is superseded by the v2 packages. The
completed v1 -> v2 migration is preserved on chore/migrate-mcp-sdk-v2 and in
PR #58 (closed unmerged) for anyone forking this.

Also records the unfixed shared-McpServer defect: one module-scope server across
all Streamable HTTP sessions, so _clientCapabilities is overwritten per session
and an elicitation-gated tool call can hang for the full request timeout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant