Skip to content

Move MCP server to SDK v2 (legacy sessions only) - #174

Merged
jpr5 merged 5 commits into
mainfrom
feat/mcp-sdk-v2-legacy
Oct 2, 2026
Merged

jpr5 merged 5 commits into
mainfrom
feat/mcp-sdk-v2-legacy

Conversation

@jpr5

@jpr5 jpr5 commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

What

This moves the Pathfinder MCP server from @modelcontextprotocol/sdk 1.28 to the split v2 packages: @modelcontextprotocol/server 2.2.0, @modelcontextprotocol/node 2.1.0 and @modelcontextprotocol/server-legacy/sse 2.2.0. Tool schemas move to zod 4 through the zod-v4 alias and are registered with registerTool and z.object. All traffic stays on the legacy sessionful protocol. The P1 unknown-session 404 and the P2 analytics behaviour are unchanged. This is phase P3 of the stateless-MCP migration plan.

Changes

  • 1356c19 Add MCP SDK v2 server, node, server-legacy and the zod v4 alias next to v1
  • 7bae503 Move the MCP server, transports and tools to SDK v2 (legacy sessions only)
  • 0b7a71a Remove MCP SDK v1
  • 980932c Pin the inputSchema that tools/list advertises
  • a6f5038 Pin that tools/call rejects arguments outside the advertised inputSchema

Expected wire differences

  • E1, tools/list schemas: inputSchema.$schema changes from draft-07 to https://json-schema.org/draft/2020-12/schema, and additionalProperties: false is no longer advertised. Unknown keys are still stripped at runtime exactly as on v1.
  • E1 with decision 1, tools/list: execution.taskSupport: "forbidden" is no longer sent. The 2025-11-25 spec schema says absent taskSupport defaults to "forbidden", so clients see the same value.
  • E2 with decision 4, unknown tool: tools/call for a tool that does not exist now returns a JSON-RPC error -32602 "Tool no-such-tool not found" instead of an isError: true result. HTTP status stays 200.
  • Decision 2, invalid arguments: the error text now uses zod 4 wording without the "MCP error -32602:" prefix, and a call with no arguments is treated as {}.
  • Decision 3, GET /mcp stream: the SDK v2 default keepalive frames are sent on the GET stream, with the X-Accel-Buffering: no header.
  • E3, serverInfo: no change ({"name":"pathfinder-docs","version":"1.4.0"} in both).

Proof (red → green on the real surface)

RED = base 443892e (SDK v1 1.28.0). GREEN = this branch. Both runs used the same captures against the same local Postgres:

  • curl against /mcp and against /sse
  • SDK v1 Streamable HTTP (anonymous and bearer) and SSE clients, and the SDK v2 legacy client
  • Claude Code 2.1.287
  • MCP Inspector 2.9.0

Mechanical wire diff of RED against GREEN: VERDICT PASS. 34 hunks, all expected, 0 unexpected.

  • curl /mcp: 5 hunks (4 tools-list E1, 1 unknown-tool E2)
  • curl /sse: 5 hunks (same split)
  • Inspector: 4 hunks (tools-list E1)
  • SDK clients: 20 hunks (16 tools-list E1 across 4 tools and 4 clients, 4 unknown-tool E2)
  • Claude Code transcript and summary: no diff
  • Scoped analytics: no diff

Unchanged on the wire: the P1 404 with -32001 "Session not found" for a fake session id on POST, GET and DELETE (POST echoes the id), a fresh session on initialize with a stale header, the /sse unknown-session 404, the 400 for the modern server/discover probe, the 405 for GET /mcp without a session, and the [mcp] 404 unknown-session-id log line.

Analytics continuity: the scoped query_log rows are identical in RED and GREEN across 8 sources and 28 rows. Every row has the handshake fields populated (transport, protocol era legacy, protocol version 2025-11-25, client name), with 0 rows missing handshake data. auth_client_id is populated on all 4 bearer-client rows. The /analytics summary endpoint matches SQL for every P2 count.

Tests

  • New tools-list-input-schema.contract.test.ts pins the advertised inputSchema for all 4 tools. Mutation proved it is not vacuous.
  • New tools-call-input-validation.contract.test.ts covers 31 invalid-argument cases plus positive controls. Mutation proved it is not vacuous.
  • Full suite: 4238 passed, 1 skipped.
  • Every commit builds from a clean npm ci.
  • The Docker production image builds and boots.

Review

Tier-3 code review, 5 full rounds. The SDK swap introduced no behaviour defect. Every behaviour finding was checked against v1 with side-by-side probes and was identical there. The remaining pre-existing issues are tracked as follow-ups below and are not part of this PR.

Follow-ups (not in this PR)

  • Init-path comment and dead-code cleanup: stale eager-ensureSession/503 comments and dead if (!accepted) in server.ts, the impossible-race comments in sse-handlers.ts, and the 429 mislabel on a session-id collision.
  • A failed initialize (406, 415 or 400) keeps its session and IP slot until the reaper runs, and logs a false "New session".
  • Transport onerror is never wired, so SDK errors are dropped.
  • limit has no .int() in the search and knowledge tools.
  • sessionStateManager is not passed to createSseHandlers, so SSE session state is never cleaned up.
  • scripts/integration-test.ts counts an isError result as a pass.
  • Consolidate zod: the root still uses zod v3 while v4 is pulled in through the alias; move types.ts and the remaining schemas to zod 4.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EDxYQLKhDxwoYe8GV2noDY

jpr5 and others added 5 commits October 2, 2026 10:49
…only)

Swap every @modelcontextprotocol/sdk (v1) import in src/ and scripts/ to the
v2 packages: McpServer and isInitializeRequest from @modelcontextprotocol/server,
NodeStreamableHTTPServerTransport from @modelcontextprotocol/node, and
SSEServerTransport from @modelcontextprotocol/server-legacy/sse (not the package
root, which augments Express req.auth). Tools now use registerTool with zod-v4
input schemas wrapped in z.object. Tests use Client and InMemoryTransport from
@modelcontextprotocol/client, and the fake servers in collect.test.ts and
knowledge-tool.test.ts now match the zod-v4 and registerTool shapes. Comments
in server.ts, sse-handlers.ts and the tests describe only what the code does
and which SDK behaviour it relies on.

Expected wire differences (the only allowed changes):
- E1: tools/list inputSchema uses the v2 JSON Schema 2020-12 shape, without the
  default additionalProperties: false or the execution field.
- E2: a call to an unknown tool returns a JSON-RPC -32602 error instead of a
  CallToolResult with isError: true.
- E3: serverInfo and SDK version strings. serverInfo name and version come from
  config, so this may be empty.

Unknown-session 404 (-32001) and the modern-request 400 are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDxYQLKhDxwoYe8GV2noDY
Drop the @modelcontextprotocol/sdk dependency now that all server and client
code runs on the v2 packages. No transitive consumer remains in the tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDxYQLKhDxwoYe8GV2noDY
Add a contract test that builds the real server through createMcpServer with
a search, knowledge, bash and collect tool, calls tools/list over the v2
Client and InMemoryTransport, and compares each inputSchema to an exact
expected object: properties, types, descriptions, bounds, defaults, enums,
required, $schema, and the absence of additionalProperties.

The expected values come from the SDK v2 tools/list capture. Apart from
$schema (draft-07 to 2020-12) and additionalProperties (no longer sent),
every field matches the SDK v1 capture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDxYQLKhDxwoYe8GV2noDY
Add a contract test that sends invalid arguments through a real tools/call
for all 4 tools (search, knowledge, bash, collect). The cases come from a
fixed copy of the pinned schemas: missing required fields, wrong types,
values below the minimum and above the maximum, and a value not in the enum.
Each case checks that the result is an isError "Input validation error"
naming the field, and that no embedding, DB, or bash exec double was called.
A valid call per tool proves that the doubles are reached.

Also make the collect "rejects invalid input" test check the error text,
the field it names, and that insertCollectedData was not called.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDxYQLKhDxwoYe8GV2noDY
@jpr5
jpr5 merged commit a9d1ab7 into main Oct 2, 2026
7 checks passed
@jpr5
jpr5 deleted the feat/mcp-sdk-v2-legacy branch October 2, 2026 18:41
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