Skip to content

feat: MCP v2 (draft) - #2843

Open
KKonstantinov wants to merge 14 commits into
upstash:masterfrom
KKonstantinov:feature/mcp-v2-migration-draft
Open

feat: MCP v2 (draft)#2843
KKonstantinov wants to merge 14 commits into
upstash:masterfrom
KKonstantinov:feature/mcp-v2-migration-draft

Conversation

@KKonstantinov

Copy link
Copy Markdown

Migrate @upstash/context7-mcp to MCP TypeScript SDK v2

Summary

This PR migrates the packages/mcp server (@upstash/context7-mcp) from the MCP
TypeScript SDK v1 (@modelcontextprotocol/sdk) to v2
(@modelcontextprotocol/server + @modelcontextprotocol/node).

It is a mechanical, behavior-preserving migration — no tool logic, prompts, transport
wiring, or runtime behavior changed. The bulk of it was produced by the official
@modelcontextprotocol/codemod and then reviewed by hand.

Why v2

v2 replaces the single monolithic @modelcontextprotocol/sdk package with a set of
focused, ESM-only packages:

  • @modelcontextprotocol/server — the runtime-neutral server API (McpServer,
    stdio transport, shared types).
  • @modelcontextprotocol/node — the Node.js HTTP transport
    (NodeStreamableHTTPServerTransport).

The result is a smaller dependency surface, a cleaner public API, and first-class
support for non-Node runtimes. v1 is being superseded, so new work should target v2.

What changed (high level)

Area Before (v1) After (v2)
Dependencies @modelcontextprotocol/sdk @modelcontextprotocol/server + @modelcontextprotocol/node
Imports deep paths under @modelcontextprotocol/sdk/... the two new packages
Tool input schemas raw Zod shape { query: z.string() } wrapped z.object({ query: z.string() })
Low-level handlers setRequestHandler(ListPromptsRequestSchema, …) setRequestHandler("prompts/list", …)
HTTP transport StreamableHTTPServerTransport NodeStreamableHTTPServerTransport

Two concrete illustrations:

// Tool schemas now use Standard Schema — raw shapes are wrapped in z.object()
inputSchema: z.object({
  query: z.string().describe("…"),
  libraryName: z.string().describe("…"),
}),

// Low-level handlers take a method string instead of a Zod request schema
server.server.setRequestHandler("prompts/list", async () => ({ prompts: [] }));
server.server.setRequestHandler("resources/list", async () => ({ resources: [] }));
server.server.setRequestHandler("resources/templates/list", async () => ({ resourceTemplates: [] }));

What did NOT change

  • Tool names, descriptions, annotations, and arguments.
  • The Express/Streamable-HTTP and stdio serving logic.
  • Auth, JWT handling, session store, and API-call code.
  • The zod dependency (already ^4.4.3; v2's Standard Schema requirement is satisfied
    by Zod v4).

How this was produced

Generated with the official MCP v1→v2 codemod and reviewed by hand:

npx @modelcontextprotocol/codemod packages/mcp/src

Verification

All package checks pass after the migration:

  • typecheck (tsc --noEmit)
  • build (tsc)
  • test (vitest run) — 12/12 passing
  • lint (eslint .)

Before merging

  • v2 is currently pre-release (2.0.0-alpha.3). The dependency pins reflect that.
    Recommend merging once v2 reaches a stable release and re-pinning to the GA version.
  • The minimumReleaseAgeExclude: ['@modelcontextprotocol/*'] entry added to
    pnpm-workspace.yaml exempts the SDK packages from this repo's 7-day
    minimumReleaseAge gate so the pre-release versions can be installed. It can be
    removed once the depended-on v2 release is older than that window.

Roadmap

  • Initial alpha draft
  • Update to beta (legacy era MCP on modern interfaces)
  • Update to stable - backwards compatible with legacy era MCP clients; enabling Modern stateless MCP will need context7 maintainers support

@enesgules

Copy link
Copy Markdown
Collaborator

We do use a stateful implementation with Redis currently which we should get rid of with this PR right?

… to 2.0.0-beta.4

- Bump @modelcontextprotocol/server and /node from 2.0.0-alpha.3 to 2.0.0-beta.4
- HTTP: replace hand-wired per-request transport + Redis session store with
  createMcpHandler (stateless legacy fallback serves 2025-era clients,
  modern 2026-07-28 era served natively); responseMode 'sse' preserves the
  immediate-header-flush behavior for long-running tools
- stdio: replace server.connect(transport) with serveStdio(factory)
- Tool-arg alias rewriting moves from transport onmessage hook to the parsed
  HTTP body / a wrapped stdio onmessage
- Delete sessionStore.ts, redis.ts and the @upstash/redis dependency
@enesgules

Copy link
Copy Markdown
Collaborator

Pushed an update that moves the migration to the SDK's stateless serving model (per the 2026-07-28 migration guide):

  • Bumped @modelcontextprotocol/server / @modelcontextprotocol/node to 2.0.0-beta.4 (roadmap's beta step).
  • HTTP: replaced the hand-wired per-request transport + Redis session store with createMcpHandler + toNodeHandler. 2025-era clients are served through the built-in stateless legacy fallback; the 2026-07-28 era is served natively. No more Mcp-Session-Id, no session state anywhere. responseMode: 'sse' preserves the immediate-header-flush behavior long-running tools rely on.
  • stdio: server.connect(transport)serveStdio(factory).
  • Tool-arg alias rewriting now happens on the parsed HTTP body / a wrapped stdio onmessage instead of a transport hook.
  • Deleted sessionStore.ts, redis.ts, and the @upstash/redis dependency.

Net −133 lines. Verified: typecheck, build, lint, 46/46 tests, plus end-to-end smoke tests of both transports (legacy initialize handshake, sessionless tools/list, tools/call with hallucinated arg aliases, GET → 405).

Replaces the two transport-specific interception sites (HTTP body rewrite,
stdio onmessage wrap) with z.preprocess on each tool's input schema. The SDK
runs the preprocess during validation regardless of transport or protocol
era, so the aliasing no longer depends on express.json() ordering or on
serveStdio assigning transport.onmessage synchronously. Emitted JSON schemas
are unchanged (verified via tools/list).

Also: hoist static tool schemas/annotations out of the per-request
createMcpServer(), precompute the WWW-Authenticate header, drop the dead
Access-Control-Expose-Headers: MCP-Session-Id, and remove a redundant
TRANSPORT_TYPE local alias.
Reverts the module-level hoisting for readability — the schema reads where
the tool is registered. The z.preprocess alias rewriting stays in the schema
(transport-agnostic), just written inline.
@modelcontextprotocol/server and /node 2.0.0-beta.4 declare engines.node
>=20, so align the README requirements and Dockerfile example, the mcpb
manifest compatibility block, the troubleshooting docs, and declare
engines in package.json so installers see the floor on the top-level
package instead of an EBADENGINE warning naming a transitive dep.
- Capture client info for modern-era (2026-07-28) stdio clients from the
  per-request _meta envelope (CLIENT_INFO_META_KEY); oninitialized only
  fires for the legacy initialize handshake, so modern clients lost the
  X-Context7-Client-IDE/Version headers and IDE-specific auth command.
- Pass onerror to toNodeHandler: adapter-level failures (request
  conversion / handler.fetch throws) were answered with a bare 500 and
  logged nowhere.
- Declare prompts/resources capabilities via the McpServer constructor
  instead of hand-wiring empty list handlers; the SDK installs the same
  empty-collection handlers plus proper error handling for prompts/get
  and resources/read.
@fahreddinozcan

Copy link
Copy Markdown
Contributor

P1 (functional regression): the auth sign-in nudge is dead on the 2026-07-28 era

maybeElicitAuthSignIn uses push-style server.server.elicitInput(...). The 2026-07-28 revision removes the server-to-client request channel, so on a modern-era request the SDK throws MethodNotSupportedByProtocolVersion locally before anything reaches the wire. The .catch(() => {}) swallows it, so there is no crash, but modern stdio clients silently stop getting the nudge.

Note: on the HTTP path this nudge was already inert in both old and new code. getClientCapabilities() is initialize-scoped, and stateless HTTP serves each tools/call on a fresh McpServer that never ran initialize, so the getClientCapabilities()?.elicitation guard never passes there. In practice this feature only ever worked on 2025-era stdio.

  • Reference: support-2026-07-28.md > Multi-round-trip requests and the appendix behavior matrix row "Server to client requests".
  • Options: rewrite as an in-band return inputRequired({ inputRequests: { id: inputRequired.elicit({...}) } }) (one code path serves both eras via the legacy shim), or accept it as 2025-only and document it. The inputRequired form is blocking rather than fire-and-forget, so a soft nudge does not map cleanly, worth a design decision rather than a mechanical swap.

@fahreddinozcan

Copy link
Copy Markdown
Contributor

P2 (fragility, not a bug): modern stdio identity relies on an untyped beta surface

The stdio factory sets server.server.oninitialized = () => server.server.getClientVersion(). On 2026-pinned connections oninitialized never fires and getClientVersion() returns undefined (no initialize runs there), so that branch is dead code on the modern era. It is correctly compensated by the new envelopeClientInfo(toolCtx) path (I confirmed the envelope populates clientInfo on modern requests).

The concern: ctx.mcpReq.envelope is typed Partial<RequestMetaEnvelope> where RequestMetaEnvelope is {} in beta, so the as Record<...> cast is load-bearing. A beta bump could change the envelope shape with no compile error and no test catching it.

@fahreddinozcan

Copy link
Copy Markdown
Contributor

P4 (functional gap): SEP-2243 standard headers are not allow-listed for CORS

Enabling modern-era serving means 2026-07-28 Streamable HTTP clients send the SEP-2243 standard headers Mcp-Method and Mcp-Name on every request, and createMcpHandler validates them against the body (I hit exactly this in testing: the request was rejected 400 until both headers were present). The CORS Access-Control-Allow-Headers list allows MCP-Protocol-Version but not Mcp-Method / Mcp-Name, so a browser-based modern client making a credentialed cross-origin call fails the preflight. Non-browser clients (Node, native IDEs) do not preflight and are unaffected. This is new surface: 2025-era clients never sent these headers.

… test, nudge docs)

- Allow Mcp-Method / Mcp-Name in CORS preflight so browser-based
  2026-07-28 clients pass credentialed cross-origin calls (P4)
- Move envelopeClientInfo into lib/utils and pin the untyped beta
  envelope shape with a unit test so an SDK bump fails loudly (P2)
- Document the auth sign-in nudge as 2025-era-only by design: the
  modern inputRequired replacement would gate doc delivery behind the
  nudge, which is the wrong trade for a soft hint (P1)

Claude-Session: https://claude.ai/code/session_01UdKfobSMnSFbdW6Lx78SSp
… http and stdio

Spawn the real built binary for both transports and drive it with the v2
SDK client in both protocol eras (2026-07-28 pinned and the 2025 legacy
handshake). The Context7 API is stubbed via CONTEXT7_API_URL with a local
server that records requests, so era negotiation, tool listing (JSON
Schema derivation through the z.preprocess alias wrapper), tool calls,
hallucinated-arg aliasing, and client-info propagation (envelope,
initialize handshake, and User-Agent fallback) are all asserted at the
wire.

Claude-Session: https://claude.ai/code/session_01UuNKMhXx5T1pHuj1fwG8UK
The v2 SDK packages went GA on 2026-07-27. Move node/server/client off
2.0.0-beta.4 to 2.0.0, which also picks up the beta.5 wire alignment
(serverInfo moves to result _meta, envelope clientInfo optional) plus
the GA fixes for SSE keep-alive frames, JSON Schema draft-07/06 dialect
handling, and 401/403 on the negotiation probe.

No source changes required.

Claude-Session: https://claude.ai/code/session_01RRBT2REbb8sT7Sfab6b4xw
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.

3 participants