SDK v2 migration (behavior-neutral, versionNegotiation: 'legacy') (#1624)#1688
SDK v2 migration (behavior-neutral, versionNegotiation: 'legacy') (#1624)#1688cliffhall wants to merge 6 commits into
Conversation
) Migrate all three clients + core + test-servers from @modelcontextprotocol/sdk@1.29 to SDK v2 (2.0.0-beta.4), keeping behavior identical against legacy (2025-11-25) servers. Foundation for the SDK V2 + New Spec stack. Packages - Split @modelcontextprotocol/sdk -> @modelcontextprotocol/client (Client, transports, OAuth, error taxonomy, protocol types) + core (Zod schemas); stdio -> client/stdio, Ajv -> client/validators/ajv. Test-servers -> @modelcontextprotocol/server (+ server-legacy/sse). Deterministic import rewrite (schemas -> core, everything else -> client); the codemod misroutes client code to /server so it was not used for path routing. - new Client(..., { versionNegotiation: { mode: 'legacy' } }) — nothing 2026-era goes on the wire. Runtime (core/mcp/inspectorClient.ts) - Error taxonomy: McpError -> ProtocolError, ErrorCode -> ProtocolErrorCode; RequestTimeout -> SdkError/SdkErrorCode. - Handlers take method strings; task methods (tasks/*, notifications/tasks/*) use the 3-arg custom form (excluded from v2's spec-method set). - Legacy tasks kept working (per project requirement): requestor tasks re- implemented over raw client.request + explicit deprecated schemas; experimental.tasks.callToolStream replaced by a local pollTaskToolCall async generator (with _progressHandlers revival so task-execution progress still reaches the caller). Receiver (server-initiated) tasks: the v2 Client validates a sampling/elicit handler result and rejects the legacy { task } response with -32602 — installReceiverTaskResponseBypass swaps the wrapped entry in the private _requestHandlers map for the task-augmented branch so { task } reaches the wire (fixes server-initiated sampling-via-task). - Pagination: list verbs use raw client.request({method}, List*ResultSchema) for single-page behavior (v2's client.listX() auto-aggregate all pages). Web / auth / apps - Rename local JsonSchemaType -> InspectorFormSchema + add toFormSchema() structural narrowing at the SDK-schema -> form boundary. - ext-apps still peers on SDK v1: one compat boundary with documented casts (remove when ext-apps#702 lands a v2 peer). - OAuth provider saveClientInformation reconciled with v2's OAuthClientInformationContext; InvalidClientMetadataError re-homed as a local class (server-legacy/auth is not browser-safe). - vitest.shared.mts dedupes @modelcontextprotocol/client+core to one copy so vi.mock intercepts the SUT's import. Accepted v2 behavior changes (per #1624 / spec §9.2): unknown-tool call rejects with -32602; cancellation is stream-abort (no guaranteed notifications/cancelled frame); output-schema strictness restored via the Inspector's own Ajv check on the default path. npm run ci green: validate (web 3087 / cli 212 / tui 274 / launcher 5), coverage (>=90 per-file, all clients), smoke (launcher/cli/web), storybook (410). Closes #1624 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@claude review |
|
Claude finished @cliffhall's task in 3m 0s —— View job Review: SDK v2 migration (#1688)I read the diff against 1.
|
…document progress heuristic - Run Prettier on the 60 core/ files this PR touched — the deterministic import rewrite had collapsed multi-line imports and left throws unwrapped; core/ is in no format gate so it slipped past CI. Now Prettier-clean. - Default-path output-schema violation now throws ProtocolError(ProtocolErrorCode.InvalidParams, …) instead of a bare Error, matching the prior v1 contract (SDK threw a typed McpError with a code) so downstream `instanceof`/`.code` branching keeps working. - Document the "assumes serial construction" invariant on pollTaskToolCall's progress-subscription revival (the `_progressHandlers` new-key heuristic). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks for the review — all three addressed in f9f79ff:
Nits acknowledged (no change needed). |
|
@claude review |
|
Claude finished @cliffhall's task in 2m 15s —— View job Re-review: SDK v2 migration — after
|
…dler API to v2 An MCP App (e.g. NightRider's drum-pad) failed to load with "'[object Object]' is not a spec notification method; pass schemas as the second argument to setNotificationHandler()". `@modelcontextprotocol/ext-apps` still peers on SDK v1 and, during the AppBridge connect handshake, subscribes to tools/resources/prompts list_changed with the v1 schema-first API `setNotificationHandler(NotificationSchema, handler)`. SDK v2 requires a method STRING and throws on a schema object — the migration's compat shim covered the types but not this runtime API difference. Fix in the app-renderer client Proxy (`getAppRendererClient`): translate a schema-first `setNotificationHandler` call to v2's method-string form by reading the method literal off the schema's `shape.method.value`; native string-first calls pass through, and an unrecognized first arg falls through to the SDK's clear error rather than being swallowed. Only `setNotificationHandler` needed this — AppBridge's `request(req, schema, …)` calls work as-is (the v1 SDK schemas expose `~standard`, so v2 accepts them). Remove when ext-apps#702 ships a v2 peer. Added an integration test covering the schema-first translation end-to-end (handler fires on a real tools/list_changed), plus the string-passthrough and unrecognized-schema branches. Coverage ≥90 holds; typecheck + App tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n-1624 # Conflicts: # clients/web/src/components/groups/ResourceLink/ResourceLink.tsx # clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx # clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx
cliffhall
left a comment
There was a problem hiding this comment.
Human review of all changes. Thee are some requests, primarily "as unknown as" - justify or refactor.
Per human review on #1688: for each in the PR, either refactor or justify with a comment naming the SDK gap (so it can inform SDK-side type improvements). - Sampling/elicit receiver-task returns: route through a typed `CreateTaskResult` so the object shape IS checked; the remaining cast to CreateMessageResult/ ElicitResult is the honest SDK gap (the 2-arg setRequestHandler overload types the handler return as the spec result only, not the task-augmented variant). - `_requestHandlers` / `_progressHandlers` private-field casts: comments now name the exact missing public API (opt a handler out of result validation; keep a progress subscription alive across a resolved request). - test-server `_requestHandlers` cast: same SDK gap on the server side, noted. - serverList object->Record casts (pre-existing): documented as plain structural widening (no index signature -> TS requires the `unknown` step), not an SDK workaround. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per human review on #1688: for each `as unknown as` in the PR, either refactor or justify with a comment naming the SDK gap (so it can inform SDK-side type improvements). - Sampling/elicit receiver-task returns: route through a typed `CreateTaskResult` so the object shape IS checked; the remaining cast to CreateMessageResult / ElicitResult is the honest SDK gap (the 2-arg setRequestHandler overload types the handler return as the spec result only, not the task-augmented variant). - `_requestHandlers` / `_progressHandlers` private-field casts: comments now name the exact missing public API (opt a handler out of result validation; keep a progress subscription alive across a resolved request). - test-server `_requestHandlers` cast: same SDK gap on the server side, noted. - serverList object->Record casts (pre-existing): documented as plain structural widening (no index signature -> TS requires the `unknown` step), not an SDK workaround. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9054a8d to
dbb8c40
Compare
|
Addressed your review (commit
"Did X schema disappear?" — no, none did. typecheck + receiver-task/e2e tests still green. |
SDK v2 added RFC 9207 §2.4 authorization-response issuer validation
(`validateAuthorizationResponseIssuer` / `IssuerMismatchError`) as a mix-up
attack defense. It is strict: when AS metadata advertises
`authorization_response_iss_parameter_supported: true` and no `iss` reaches
`exchangeAuthorization`/`auth()`, the exchange throws.
v1.29.0 had no `iss` parameter and no RFC 9207 validation at all, so dropping
the callback `iss` was harmless. The migration swapped in the stricter SDK
without feeding it the new input, breaking the authorization-code exchange
against any RFC 9207 authorization server (Okta/xaa.dev advertise it):
Issuer mismatch in authorization response (RFC 9207):
expected "https://idp.xaa.dev", received undefined
Found by smoke-testing EMA against live xaa.dev staging; not caught by CI
because the mock IdP never advertised the parameter, so the SDK took its
lenient path (a missing `iss` is only fatal when advertised).
`iss` was dropped in three places, with the plumbing half-built —
`oauthManager`/`inspectorClient` already accepted `iss`, but nothing supplied
or forwarded it:
- `parseOAuthCallbackParams` never extracted `iss` from the callback query
- `mcpAuth` never forwarded `options.iss` to SDK `auth()` — its own comment
("forwarded on v2 upgrade; ignored by v1 SDK auth()") predicted this exact
gap, so standard OAuth was broken identically, not just EMA
- EMA leg 1 (`completeIdpOidcAuthorization`) never passed `iss` to
`exchangeAuthorization`
Thread `iss` end to end: callback query -> CallbackParams -> resumeAfterOAuth
-> completeOAuthFlow -> {mcpAuth | EMA leg 1} -> SDK. The Node callback server
and runner carry it for CLI/TUI.
Tests: `ema-mock-servers.ts` now advertises
`authorization_response_iss_parameter_supported: true` so the mock is at least
as strict as real IdPs — with it absent the SDK silently takes the lenient path
and a dropped `iss` goes unnoticed. Flipping it made three existing tests
reproduce the failure. Adds regression coverage for forwarding and for
rejecting a mismatched `iss` before any network call.
Verified end to end against live xaa.dev staging: EMA legs 1-3 (IdP SSO ->
ID-JAG mint -> resource token) now complete and the MCP session initializes.
web 3099, cli 212, tui 274 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjMemXZohSR2EndBQYif2b
Closes #1624
Migrates all three clients +
core+test-serversfrom@modelcontextprotocol/sdk@1.29to SDK v2 (2.0.0-beta.4), keeping behavior identical against legacy (2025-11-25) servers withversionNegotiation: { mode: 'legacy' }. This is the behavior-neutral foundation ("1 of 10") for the SDK V2 + New Spec stack.Packages
@modelcontextprotocol/sdk→@modelcontextprotocol/client(Client, transports, OAuth, error taxonomy, all protocol types) +@modelcontextprotocol/core(Zod*Schemavalues). stdio →client/stdio; Ajv →client/validators/ajv. Test-servers →@modelcontextprotocol/server(+server-legacy/sse).@modelcontextprotocol/server.new Client(..., { versionNegotiation: { mode: 'legacy' } }).Runtime (
core/mcp/inspectorClient.ts)McpError→ProtocolError,ErrorCode→ProtocolErrorCode; timeouts →SdkError/SdkErrorCode.tasks/*+notifications/tasks/*use the 3-arg custom form (v2 excludes them from the spec-method set).client.request+ explicit deprecated schemas;experimental.tasks.callToolStream→ a localpollTaskToolCallasync generator, with_progressHandlersrevival so task-execution progress still reaches the caller.sampling/elicitationhandler result and rejects the legacy{ task }response with-32602.installReceiverTaskResponseBypassswaps the wrapped entry in the private_requestHandlersmap for the task-augmented branch so{ task }reaches the wire. Fixes server-initiated sampling-via-task (verified against@modelcontextprotocol/server-everything+ e2e).client.request({method}, List*ResultSchema)for single-page behavior (v2'sclient.listX()auto-aggregate all pages).Web / auth / apps
JsonSchemaType→InspectorFormSchema+ atoFormSchema()structural narrow at the SDK-schema→form boundary.saveClientInformationreconciled with v2'sOAuthClientInformationContext;InvalidClientMetadataErrorre-homed as a local class (server-legacy/authisn't browser-safe).vitest.shared.mtsdedupesclient+coreto one copy sovi.mockintercepts the SUT's import.Accepted v2 behavior changes (per #1624 / spec §9.2)
Unknown-tool call rejects with
-32602; cancellation is stream-abort (no guaranteednotifications/cancelledframe); output-schema strictness restored via the Inspector's own Ajv check on the default path.Verification —
npm run cigreenGET /→ 200)RFC 9207
iss— a real v2 regression found by live smoke testingSmoke-testing EMA against live xaa.dev staging (Okta's public XAA sandbox) surfaced a genuine regression this PR introduced. It is now fixed; recording it here because it is the one place the migration was not behavior-neutral, and because the CI gap that hid it is worth knowing about.
Symptom
Real IdP login succeeded, then the leg 1 code exchange threw:
Cause
SDK v2 added RFC 9207 §2.4 authorization-response issuer validation (
validateAuthorizationResponseIssuer/IssuerMismatchError) as a mix-up-attack defense, gained anissparameter, and made it strict:SDK v1.29.0 had no
issparameter and no RFC 9207 validation at all (verified against the installed v1 tree —exchangeAuthorization(url, { metadata, clientInformation, authorizationCode, codeVerifier, redirectUri, resource, addClientAuthentication, fetchFn })). So dropping the callbackisswas harmless under v1 and instantly fatal under v2 against any AS advertisingauthorization_response_iss_parameter_supported: true— which real IdPs do.The migration swapped in a stricter SDK without feeding it the new input it requires. Behavior-neutral against the SDK surface; not against real authorization servers.
Scope — this was not EMA-only
isswas dropped in three places, with the plumbing half-built (oauthManager.completeOAuthFlowandinspectorClient.completeOAuthFlowalready acceptediss, but nothing supplied or forwarded it):core/auth/utils.tsparseOAuthCallbackParamsnever extractedissfrom the callback querycore/auth/mcpAuth.tsoptions.issto SDKauth()— standard OAuth broke identicallycore/auth/ema/idpOidc.tsisstoexchangeAuthorization(EMA leg 1)mcpAuth.tscarried this comment onorigin/v2/main, which predicted the exact gap:This is the v2 upgrade; the forwarding was never wired. Any RFC 9207 server would have broken standard OAuth too.
Fix
Thread
issend to end: callback query →CallbackParams→resumeAfterOAuth→completeOAuthFlow→ {mcpAuth| EMA leg 1} → SDK. The Node callback server + runner carry it so CLI/TUI match web.Why CI did not catch it
ema-mock-servers.tsnever advertisedauthorization_response_iss_parameter_supported— the string appeared nowhere in the repo. The mock IdP was therefore more permissive than any real IdP, so the SDK took its lenient branch and all EMA tests passed over a broken flow.The mock now advertises it, making it at least as strict as production. Flipping that one flag made three existing tests immediately reproduce the live failure. Added regression coverage for
issforwarding and for rejecting a mismatchedissbefore any network call.Verification
Manually verified end to end against live xaa.dev staging: EMA legs 1–3 (IdP SSO → ID-JAG mint → resource token) complete and the MCP session initializes against a local
protected-resourcetest server (test-servers/configs/xaa-ema-http.json).Suites after the fix: web 3099 (was 3087), cli 212, tui 274 — green.
🤖 Generated with Claude Code