diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ced1745..623ae01c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Restored the MCP SDK v1 client for compatibility with deployed MCP servers and OAuth providers. This rollback temporarily removes SDK v2-only protocol negotiation while retaining OAuth issuer binding and callback issuer validation. See issue #236. + ## [2.15.0] - 2026-07-25 ### Added diff --git a/OAUTH.md b/OAUTH.md index 96ca6975..08e0fe75 100644 --- a/OAUTH.md +++ b/OAUTH.md @@ -300,15 +300,15 @@ The OAuth implementation uses the following modules: ## SDK Integration -The implementation uses these MCP SDK v2 exports: +The implementation uses the MCP SDK v1 client and OAuth APIs: ```typescript +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { auth, - StreamableHTTPClientTransport, UnauthorizedError, type OAuthClientProvider, -} from "@modelcontextprotocol/client" +} from "@modelcontextprotocol/sdk/client/auth.js" ``` The `McpOAuthProvider` class implements `OAuthClientProvider` and is passed to `StreamableHTTPClientTransport`: diff --git a/__tests__/abort-signal.test.ts b/__tests__/abort-signal.test.ts index 0e5d5568..8082063a 100644 --- a/__tests__/abort-signal.test.ts +++ b/__tests__/abort-signal.test.ts @@ -71,6 +71,7 @@ describe("AbortSignal propagation", () => { expect(result.details.error).toBe("aborted"); expect(callTool).toHaveBeenCalledWith( { name: "slow", arguments: {}, _meta: undefined }, + undefined, { signal: controller.signal }, ); expect(state.manager.decrementInFlight).toHaveBeenCalledWith("demo"); @@ -90,6 +91,7 @@ describe("AbortSignal propagation", () => { expect(result.details.error).toBe("aborted"); expect(callTool).toHaveBeenCalledWith( { name: "slow", arguments: {}, _meta: undefined }, + undefined, { signal: controller.signal }, ); expect(state.manager.decrementInFlight).toHaveBeenCalledWith("demo"); diff --git a/__tests__/direct-tools-auto-auth.test.ts b/__tests__/direct-tools-auto-auth.test.ts index ebd6b7de..30b387ad 100644 --- a/__tests__/direct-tools-auto-auth.test.ts +++ b/__tests__/direct-tools-auto-auth.test.ts @@ -110,6 +110,7 @@ describe("direct tools auto auth", () => { arguments: { q: "hello" }, _meta: undefined, }, + undefined, { timeout: 4321 }, ); expect(result.content[0].text).toContain("ok"); @@ -156,6 +157,7 @@ describe("direct tools auto auth", () => { expect(state.manager.getRequestOptions).toHaveBeenCalledWith("demo", controller.signal); expect(connection.client.callTool).toHaveBeenCalledWith( { name: "search", arguments: {}, _meta: undefined }, + undefined, requestOptions, ); expect(result.details).toMatchObject({ error: "aborted", server: "demo" }); @@ -244,7 +246,7 @@ describe("direct tools auto auth", () => { }); it("runs URL elicitations returned by a URL-required tool error", async () => { - const { UrlElicitationRequiredError } = await import("@modelcontextprotocol/client"); + const { UrlElicitationRequiredError } = await import("@modelcontextprotocol/sdk/types.js"); const { createDirectToolExecutor } = await import("../direct-tools.ts"); const error = new UrlElicitationRequiredError([{ mode: "url", diff --git a/__tests__/elicitation-handler.test.ts b/__tests__/elicitation-handler.test.ts index eb8a5fd0..aaa60edc 100644 --- a/__tests__/elicitation-handler.test.ts +++ b/__tests__/elicitation-handler.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { ElicitRequest } from "@modelcontextprotocol/client"; +import type { ElicitRequest } from "@modelcontextprotocol/sdk/types.js"; const mocks = vi.hoisted(() => ({ open: vi.fn(async () => undefined), diff --git a/__tests__/fixtures/delayed-mcp-server.mjs b/__tests__/fixtures/delayed-mcp-server.mjs index a28af6a9..f7136d5a 100644 --- a/__tests__/fixtures/delayed-mcp-server.mjs +++ b/__tests__/fixtures/delayed-mcp-server.mjs @@ -1,7 +1,8 @@ import { rename, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { Server } from "@modelcontextprotocol/server"; -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { ListResourcesRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; const pidPath = process.env.MCP_RELOAD_PID_DIR ? join(process.env.MCP_RELOAD_PID_DIR, `${process.pid}.json`) : undefined; const identity = { pid: process.pid, toolName: "reload_identity" }; @@ -18,11 +19,11 @@ const server = new Server( { name: "delayed-reload-fixture", version: "1.0.0" }, { capabilities: { tools: {}, resources: {} } }, ); -server.setRequestHandler("tools/list", async () => { +server.setRequestHandler(ListToolsRequestSchema, async () => { await new Promise(resolve => setTimeout(resolve, 100)); return { tools: [{ name: identity.toolName, description: "reload identity", inputSchema: { type: "object", properties: {} } }], }; }); -server.setRequestHandler("resources/list", async () => ({ resources: [] })); +server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] })); await server.connect(new StdioServerTransport()); diff --git a/__tests__/fixtures/elicitation-server.mjs b/__tests__/fixtures/elicitation-server.mjs index d786cff7..9f28e2cb 100644 --- a/__tests__/fixtures/elicitation-server.mjs +++ b/__tests__/fixtures/elicitation-server.mjs @@ -1,5 +1,13 @@ -import { Server, UrlElicitationRequiredError } from "@modelcontextprotocol/server"; -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ElicitResultSchema, + ListResourcesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, + UrlElicitationRequiredError, +} from "@modelcontextprotocol/sdk/types.js"; const server = new Server( { name: "elicitation-integration-server", version: "1.0.0" }, @@ -15,7 +23,7 @@ function urlRequiredError() { }]); } -server.setRequestHandler("tools/list", async () => ({ +server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "capabilities", inputSchema: { type: "object", properties: {} } }, { name: "form", inputSchema: { type: "object", properties: {} } }, @@ -24,21 +32,21 @@ server.setRequestHandler("tools/list", async () => ({ ], })); -server.setRequestHandler("resources/list", async () => ({ +server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [ { name: "URL-required resource", uri: "test://url-required" }, { name: "URL-required UI resource", uri: "ui://url-required" }, ], })); -server.setRequestHandler("resources/read", async request => { +server.setRequestHandler(ReadResourceRequestSchema, async request => { if (request.params.uri === "test://url-required" || request.params.uri === "ui://url-required") { throw urlRequiredError(); } return { contents: [] }; }); -server.setRequestHandler("tools/call", async request => { +server.setRequestHandler(CallToolRequestSchema, async request => { if (request.params.name === "capabilities") { return { content: [{ type: "text", text: JSON.stringify(server.getClientCapabilities()?.elicitation ?? null) }], @@ -48,25 +56,31 @@ server.setRequestHandler("tools/call", async request => { if (request.params.name === "url-required") throw urlRequiredError(); if (request.params.name === "form") { - const result = await server.elicitInput({ - mode: "form", - message: "Provide a name", - requestedSchema: { - type: "object", - properties: { name: { type: "string", minLength: 1 } }, - required: ["name"], + const result = await server.request({ + method: "elicitation/create", + params: { + mode: "form", + message: "Provide a name", + requestedSchema: { + type: "object", + properties: { name: { type: "string", minLength: 1 } }, + required: ["name"], + }, }, - }); + }, ElicitResultSchema); return { content: [{ type: "text", text: JSON.stringify(result) }] }; } if (request.params.name === "url") { - const result = await server.elicitInput({ - mode: "url", - message: "Connect your account", - elicitationId: "requested-1", - url: "https://example.com/authorize", - }); + const result = await server.request({ + method: "elicitation/create", + params: { + mode: "url", + message: "Connect your account", + elicitationId: "requested-1", + url: "https://example.com/authorize", + }, + }, ElicitResultSchema); if (result.action === "accept") { for (const elicitationId of ["unknown", "requested-1", "requested-1"]) { await server.notification({ diff --git a/__tests__/fixtures/legacy-no-discover-server.mjs b/__tests__/fixtures/legacy-no-discover-server.mjs new file mode 100644 index 00000000..9c3fedd6 --- /dev/null +++ b/__tests__/fixtures/legacy-no-discover-server.mjs @@ -0,0 +1,44 @@ +import readline from "node:readline"; + +const lines = readline.createInterface({ input: process.stdin }); + +function respond(id, result) { + process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`); +} + +function reject(id, message) { + process.stdout.write(`${JSON.stringify({ + jsonrpc: "2.0", + id, + error: { code: -32601, message }, + })}\n`); +} + +lines.on("line", line => { + const request = JSON.parse(line); + if (request.method === "server/discover") { + reject(request.id, "server/discover is not supported"); + return; + } + if (request.method === "initialize") { + respond(request.id, { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "legacy-no-discover", version: "1.0.0" }, + }); + return; + } + if (request.method === "tools/list") { + respond(request.id, { + tools: [{ + name: "classic_initialize_reached", + description: "Classic initialize completed", + inputSchema: { type: "object", properties: {} }, + }], + }); + return; + } + if (request.id !== undefined) { + reject(request.id, "Method not found"); + } +}); diff --git a/__tests__/fixtures/output-schema-server.mjs b/__tests__/fixtures/output-schema-server.mjs index d810badf..066488d0 100644 --- a/__tests__/fixtures/output-schema-server.mjs +++ b/__tests__/fixtures/output-schema-server.mjs @@ -1,5 +1,6 @@ -import { Server } from "@modelcontextprotocol/server"; -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; const server = new Server( { name: "output-schema-integration-server", version: "1.0.0" }, @@ -36,7 +37,7 @@ const draft2020Tuple = { }; const draft2020Schema = { $schema: "https://json-schema.org/draft/2020-12/schema", ...draft2020Tuple }; -server.setRequestHandler("tools/list", async () => ({ +server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "draft07-valid", inputSchema: { type: "object" }, outputSchema: draft07Schema }, { name: "draft07-invalid", inputSchema: { type: "object" }, outputSchema: draft07InvalidSchema }, @@ -45,7 +46,7 @@ server.setRequestHandler("tools/list", async () => ({ ], })); -server.setRequestHandler("tools/call", async request => { +server.setRequestHandler(CallToolRequestSchema, async request => { const name = request.params.name; if (name === "draft07-valid" || name === "draft2020-valid") { return { structuredContent: { values: ["ok", 1] }, content: [{ type: "text", text: name }] }; diff --git a/__tests__/fixtures/prompts-server.mjs b/__tests__/fixtures/prompts-server.mjs index aa3a6e79..65284e70 100644 --- a/__tests__/fixtures/prompts-server.mjs +++ b/__tests__/fixtures/prompts-server.mjs @@ -1,5 +1,12 @@ -import { Server } from "@modelcontextprotocol/server"; -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; // A minimal MCP server that advertises the `prompts` capability and returns // deterministic content, used by prompts-sdk-integration.test.ts to exercise @@ -9,17 +16,17 @@ const server = new Server( { capabilities: { tools: {}, resources: {}, prompts: { listChanged: false } } }, ); -server.setRequestHandler("tools/list", async () => ({ +server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [{ name: "noop", inputSchema: { type: "object", properties: {} } }], })); -server.setRequestHandler("resources/list", async () => ({ resources: [] })); +server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] })); -server.setRequestHandler("tools/call", async () => ({ +server.setRequestHandler(CallToolRequestSchema, async () => ({ content: [{ type: "text", text: "ok" }], })); -server.setRequestHandler("prompts/list", async () => ({ +server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [ { name: "brief", @@ -37,7 +44,7 @@ server.setRequestHandler("prompts/list", async () => ({ ], })); -server.setRequestHandler("prompts/get", async (request) => { +server.setRequestHandler(GetPromptRequestSchema, async (request) => { const { name, arguments: args = {} } = request.params; if (name === "brief") { const topic = typeof args.topic === "string" ? args.topic : "(missing)"; diff --git a/__tests__/fixtures/tools-only-server.mjs b/__tests__/fixtures/tools-only-server.mjs index c4822ed8..68107d90 100644 --- a/__tests__/fixtures/tools-only-server.mjs +++ b/__tests__/fixtures/tools-only-server.mjs @@ -1,5 +1,6 @@ -import { Server } from "@modelcontextprotocol/server"; -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; // A minimal MCP server that advertises tools only — no `resources`, no // `prompts`. Used by resources-capability.test.ts to check that the adapter @@ -9,11 +10,11 @@ const server = new Server( { capabilities: { tools: {} } }, ); -server.setRequestHandler("tools/list", async () => ({ +server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [{ name: "noop", inputSchema: { type: "object", properties: {} } }], })); -server.setRequestHandler("tools/call", async () => ({ +server.setRequestHandler(CallToolRequestSchema, async () => ({ content: [{ type: "text", text: "ok" }], })); diff --git a/__tests__/json-schema-validator.test.ts b/__tests__/json-schema-validator.test.ts index 9c1e2730..cc8fc459 100644 --- a/__tests__/json-schema-validator.test.ts +++ b/__tests__/json-schema-validator.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { JsonSchemaType } from "@modelcontextprotocol/client"; +import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types.js"; import { createJsonSchemaValidator } from "../json-schema-validator.ts"; const draft07 = "http://json-schema.org/draft-07/schema#"; @@ -64,16 +64,32 @@ describe("createJsonSchemaValidator", () => { required: ["values"], }; - expect(validate(schema, { values: ["ok", 1] }).valid).toBe(true); - expect(validate(schema, { values: ["ok", 1, true] }).valid).toBe(false); - expect(validate({ $schema: "https://json-schema.org/draft/2020-12/schema", ...schema }, { values: ["ok", 1] }).valid).toBe(true); + for (const candidate of [schema, { $schema: "https://json-schema.org/draft/2020-12/schema", ...schema }]) { + expect(validate(candidate, { values: ["ok", 1] }).valid).toBe(true); + expect(validate(candidate, { values: ["ok", 1, true] }).valid).toBe(false); + } + }); + + it("accepts unstamped schemas that use draft-07-compatible keywords", () => { + const schema = { + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + }, + required: ["name"], + additionalProperties: false, + }; + + expect(validate(schema, { name: "ok" }).valid).toBe(true); + expect(validate(schema, { name: "" }).valid).toBe(false); + expect(validate(schema, { name: "ok", extra: true }).valid).toBe(false); }); it("does not downgrade an unsupported explicit dialect", () => { expect(() => validate({ $schema: "https://example.com/custom-schema", type: "object", - }, {})).toThrow(/unsupported dialect|2020-12/i); + }, {})).toThrow(/unsupported.*dialect|2020-12/i); }); it("creates isolated validator providers", () => { diff --git a/__tests__/mcp-auth-flow-client-credentials.test.ts b/__tests__/mcp-auth-flow-client-credentials.test.ts index 30a9e031..1f837cd2 100644 --- a/__tests__/mcp-auth-flow-client-credentials.test.ts +++ b/__tests__/mcp-auth-flow-client-credentials.test.ts @@ -17,7 +17,7 @@ const mocks = vi.hoisted(() => ({ class MockUnauthorizedError extends Error {} -vi.mock("@modelcontextprotocol/client", async (importOriginal) => ({ +vi.mock("@modelcontextprotocol/sdk/client/auth.js", async (importOriginal) => ({ ...(await importOriginal>()), auth: mocks.sdkAuth, extractWWWAuthenticateParams: (response: Response) => { @@ -107,6 +107,95 @@ describe("mcp-auth-flow explicit auth", () => { )).toThrow("state mismatch"); }); + it("keeps a pending flow when an advertised RFC 9207 issuer is missing", async () => { + let oauthState = ""; + mocks.sdkAuth.mockImplementation(async (provider, options) => { + if (options.authorizationCode) return "AUTHORIZED"; + oauthState = await provider.state(); + await provider.saveDiscoveryState({ + authorizationServerUrl: "https://auth.example.com", + authorizationServerMetadata: { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + authorization_response_iss_parameter_supported: true, + }, + }); + await provider.redirectToAuthorization(new URL("https://auth.example.com/authorize")); + return "REDIRECT"; + }); + const { completeAuthFromInput, hasPendingAuth, startAuth } = await import("../mcp-auth-flow.ts"); + + await startAuth("rfc9207-missing", "https://api.example.com/mcp", { auth: "oauth" }); + await expect(completeAuthFromInput( + "rfc9207-missing", + `code=auth-code&state=${oauthState}`, + )).rejects.toThrow('requires the RFC 9207 "iss" parameter'); + + expect(mocks.sdkAuth).toHaveBeenCalledTimes(1); + expect(hasPendingAuth("rfc9207-missing")).toBe(true); + + await expect(completeAuthFromInput( + "rfc9207-missing", + `code=auth-code&state=${oauthState}&iss=${encodeURIComponent("https://auth.example.com")}`, + )).resolves.toBe("authenticated"); + expect(mocks.sdkAuth).toHaveBeenCalledTimes(2); + expect(hasPendingAuth("rfc9207-missing")).toBe(false); + }); + + it("rejects a mismatched RFC 9207 issuer before token exchange", async () => { + let oauthState = ""; + mocks.sdkAuth.mockImplementation(async (provider, options) => { + if (options.authorizationCode) return "AUTHORIZED"; + oauthState = await provider.state(); + await provider.saveDiscoveryState({ + authorizationServerUrl: "https://auth.example.com", + authorizationServerMetadata: { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + authorization_response_iss_parameter_supported: true, + }, + }); + await provider.redirectToAuthorization(new URL("https://auth.example.com/authorize")); + return "REDIRECT"; + }); + const { completeAuthFromInput, hasPendingAuth, startAuth } = await import("../mcp-auth-flow.ts"); + + await startAuth("rfc9207-mismatch", "https://api.example.com/mcp", { auth: "oauth" }); + await expect(completeAuthFromInput( + "rfc9207-mismatch", + `code=auth-code&state=${oauthState}&iss=${encodeURIComponent("https://attacker.example.com")}`, + )).rejects.toThrow("does not match the discovered issuer"); + + expect(mocks.sdkAuth).toHaveBeenCalledTimes(1); + expect(hasPendingAuth("rfc9207-mismatch")).toBe(false); + }); + + it("rejects a callback issuer mismatch when metadata is unavailable", async () => { + let oauthState = ""; + mocks.sdkAuth.mockImplementation(async (provider, options) => { + if (options.authorizationCode) return "AUTHORIZED"; + oauthState = await provider.state(); + await provider.saveDiscoveryState({ + authorizationServerUrl: "https://auth.example.com", + }); + await provider.redirectToAuthorization(new URL("https://auth.example.com/authorize")); + return "REDIRECT"; + }); + const { completeAuthFromInput, startAuth } = await import("../mcp-auth-flow.ts"); + + await startAuth("issuer-without-metadata", "https://api.example.com/mcp", { auth: "oauth" }); + await expect(completeAuthFromInput( + "issuer-without-metadata", + `code=auth-code&state=${oauthState}&iss=${encodeURIComponent("https://attacker.example.com")}`, + )).rejects.toThrow("does not match the discovered issuer"); + + expect(mocks.sdkAuth).toHaveBeenCalledTimes(1); + }); + it("does not start the callback server during OAuth initialization", async () => { const { initializeOAuth } = await import("../mcp-auth-flow.ts"); diff --git a/__tests__/mcp-oauth-provider.test.ts b/__tests__/mcp-oauth-provider.test.ts index 891b22c1..f5c21f94 100644 --- a/__tests__/mcp-oauth-provider.test.ts +++ b/__tests__/mcp-oauth-provider.test.ts @@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { UnauthorizedError } from "@modelcontextprotocol/client"; +import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; import { McpOAuthProvider } from "../mcp-oauth-provider.ts"; -import { saveAuthEntry } from "../mcp-auth.ts"; +import { getAuthForUrl, saveAuthEntry } from "../mcp-auth.ts"; describe("McpOAuthProvider clientMetadata scope", () => { it("includes configured scope in authorization_code client metadata", () => { @@ -197,6 +197,200 @@ describe("McpOAuthProvider discovery state", () => { } }); + it("back-stamps legacy client information and tokens with the discovered issuer", async () => { + saveAuthEntry("legacy-binding", { + clientInfo: { + clientId: "legacy-client", + clientSecret: "legacy-secret", + redirectUris: ["http://localhost:19876/callback"], + }, + tokens: { + accessToken: "legacy-access", + refreshToken: "legacy-refresh", + }, + serverUrl, + }, serverUrl); + const provider = new McpOAuthProvider( + "legacy-binding", + serverUrl, + {}, + { onRedirect: async () => {} }, + ); + + await provider.saveDiscoveryState({ + authorizationServerUrl: "https://auth.example.com", + authorizationServerMetadata: { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + }, + }); + + expect(await provider.clientInformation()).toMatchObject({ + client_id: "legacy-client", + issuer: "https://auth.example.com", + }); + expect(await provider.tokens()).toMatchObject({ + access_token: "legacy-access", + issuer: "https://auth.example.com", + }); + expect(getAuthForUrl("legacy-binding", serverUrl)).toMatchObject({ + clientInfo: { issuer: "https://auth.example.com" }, + tokens: { issuer: "https://auth.example.com" }, + }); + }); + + it("rejects stored credentials when the issuer changes before refresh", async () => { + saveAuthEntry("changed-issuer", { + clientInfo: { + clientId: "bound-client", + clientSecret: "bound-secret", + redirectUris: ["http://localhost:19876/callback"], + issuer: "https://old-auth.example.com", + }, + tokens: { + accessToken: "bound-access", + refreshToken: "bound-refresh", + issuer: "https://old-auth.example.com", + }, + serverUrl, + }, serverUrl); + const provider = new McpOAuthProvider( + "changed-issuer", + serverUrl, + {}, + { onRedirect: async () => {} }, + ); + + await provider.saveDiscoveryState({ + authorizationServerUrl: "https://new-auth.example.com", + authorizationServerMetadata: { + issuer: "https://new-auth.example.com", + authorization_endpoint: "https://new-auth.example.com/authorize", + token_endpoint: "https://new-auth.example.com/token", + response_types_supported: ["code"], + }, + }); + + await expect(provider.clientInformation()).rejects.toThrow( + "clear credentials before authenticating again", + ); + await expect(provider.tokens()).rejects.toThrow( + "clear credentials before authenticating again", + ); + }); + + it("does not stamp unbound tokens when client information has a different issuer", async () => { + saveAuthEntry("partial-client-binding", { + clientInfo: { + clientId: "bound-client", + clientSecret: "bound-secret", + redirectUris: ["http://localhost:19876/callback"], + issuer: "https://old-auth.example.com", + }, + tokens: { accessToken: "legacy-access", refreshToken: "legacy-refresh" }, + serverUrl, + }, serverUrl); + const provider = new McpOAuthProvider( + "partial-client-binding", + serverUrl, + {}, + { onRedirect: async () => {} }, + ); + await provider.saveDiscoveryState({ authorizationServerUrl: "https://new-auth.example.com" }); + + await expect(provider.tokens()).rejects.toThrow("clear credentials before authenticating again"); + expect(getAuthForUrl("partial-client-binding", serverUrl)?.tokens?.issuer).toBeUndefined(); + }); + + it("does not stamp unbound client information when tokens have a different issuer", async () => { + saveAuthEntry("partial-token-binding", { + clientInfo: { + clientId: "legacy-client", + clientSecret: "legacy-secret", + redirectUris: ["http://localhost:19876/callback"], + }, + tokens: { + accessToken: "bound-access", + refreshToken: "bound-refresh", + issuer: "https://old-auth.example.com", + }, + serverUrl, + }, serverUrl); + const provider = new McpOAuthProvider( + "partial-token-binding", + serverUrl, + {}, + { onRedirect: async () => {} }, + ); + await provider.saveDiscoveryState({ authorizationServerUrl: "https://new-auth.example.com" }); + + await expect(provider.clientInformation()).rejects.toThrow("clear credentials before authenticating again"); + expect(getAuthForUrl("partial-token-binding", serverUrl)?.clientInfo?.issuer).toBeUndefined(); + }); + + it("persists a pre-registered issuer binding without the config secret", async () => { + const provider = new McpOAuthProvider( + "pre-registered-binding", + serverUrl, + { clientId: "config-client", clientSecret: "config-secret" }, + { onRedirect: async () => {} }, + ); + await provider.saveDiscoveryState({ + authorizationServerUrl: "https://auth.example.com", + authorizationServerMetadata: { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + }, + }); + + expect(await provider.clientInformation()).toMatchObject({ + client_id: "config-client", + client_secret: "config-secret", + issuer: "https://auth.example.com", + }); + expect(getAuthForUrl("pre-registered-binding", serverUrl)?.clientInfo).toEqual({ + clientId: "config-client", + issuer: "https://auth.example.com", + configPreRegistered: true, + }); + }); + + it("fails closed when a pre-registered client issuer changes", async () => { + saveAuthEntry("pre-registered-issuer-change", { + clientInfo: { + clientId: "config-client", + issuer: "https://old-auth.example.com", + configPreRegistered: true, + }, + serverUrl, + }, serverUrl); + const provider = new McpOAuthProvider( + "pre-registered-issuer-change", + serverUrl, + { clientId: "config-client", clientSecret: "config-secret" }, + { onRedirect: async () => {} }, + ); + await provider.saveDiscoveryState({ + authorizationServerUrl: "https://new-auth.example.com", + authorizationServerMetadata: { + issuer: "https://new-auth.example.com", + authorization_endpoint: "https://new-auth.example.com/authorize", + token_endpoint: "https://new-auth.example.com/token", + response_types_supported: ["code"], + }, + }); + + await expect(provider.clientInformation()).rejects.toThrow( + "clear credentials before authenticating again", + ); + expect(getAuthForUrl("pre-registered-issuer-change", serverUrl)?.clientInfo?.issuer) + .toBe("https://old-auth.example.com"); + }); + it("round-trips callback-leg discovery state and invalidates it independently", async () => { const provider = new McpOAuthProvider( "discovery-state", diff --git a/__tests__/package-manifest.test.ts b/__tests__/package-manifest.test.ts index 4ff32a8b..98181888 100644 --- a/__tests__/package-manifest.test.ts +++ b/__tests__/package-manifest.test.ts @@ -61,8 +61,10 @@ describe("package.json dependency policy", () => { } }); - it("declares the SDK v1 peer needed by ext-apps under Pi managed installs", () => { + it("uses the SDK v1 dependency without the SDK v2 beta packages", () => { expect(packageJson.dependencies?.["@modelcontextprotocol/ext-apps"]).toBeDefined(); expect(packageJson.dependencies?.["@modelcontextprotocol/sdk"]).toBe("^1.29.0"); + expect(packageJson.dependencies?.["@modelcontextprotocol/client"]).toBeUndefined(); + expect(packageJson.devDependencies?.["@modelcontextprotocol/server"]).toBeUndefined(); }); }); diff --git a/__tests__/prompts.test.ts b/__tests__/prompts.test.ts index 14de7421..c424383f 100644 --- a/__tests__/prompts.test.ts +++ b/__tests__/prompts.test.ts @@ -1,6 +1,6 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; -import type { GetPromptResult } from "@modelcontextprotocol/client"; +import type { GetPromptResult } from "@modelcontextprotocol/sdk/types.js"; import { createPromptCommand, formatPromptResult, diff --git a/__tests__/proxy-modes-auto-auth.test.ts b/__tests__/proxy-modes-auto-auth.test.ts index 4c60d7ea..09e9fa2e 100644 --- a/__tests__/proxy-modes-auto-auth.test.ts +++ b/__tests__/proxy-modes-auto-auth.test.ts @@ -37,7 +37,7 @@ vi.mock("../init.ts", () => ({ recordFailure: mocks.recordFailure, })); -vi.mock("@modelcontextprotocol/client", async (importOriginal) => ({ +vi.mock("@modelcontextprotocol/sdk/client/index.js", async (importOriginal) => ({ ...(await importOriginal>()), Client: vi.fn().mockImplementation(function (this: any, info: unknown, options: unknown) { this.info = info; @@ -64,7 +64,7 @@ vi.mock("@modelcontextprotocol/client", async (importOriginal) => ({ SSEClientTransport: vi.fn(), })); -vi.mock("@modelcontextprotocol/client/stdio", () => ({ +vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({ StdioClientTransport: vi.fn().mockImplementation(function (this: any, options: unknown) { this.options = options; this.close = vi.fn(async () => undefined); @@ -260,7 +260,7 @@ describe("proxy auto auth", () => { }); it("runs URL elicitations returned by proxy tool calls", async () => { - const { UrlElicitationRequiredError } = await import("@modelcontextprotocol/client"); + const { UrlElicitationRequiredError } = await import("@modelcontextprotocol/sdk/types.js"); const { executeCall } = await import("../proxy-modes.ts"); const error = new UrlElicitationRequiredError([{ mode: "url", @@ -374,6 +374,7 @@ describe("proxy auto auth", () => { arguments: { q: "hello" }, _meta: undefined, }, + undefined, { timeout: 1234 }, ); expect(result.content[0].text).toContain("ok"); @@ -451,6 +452,7 @@ describe("proxy auto auth", () => { expect(manager.getRequestOptions).toHaveBeenCalledWith("demo", controller.signal); expect(connection.client.callTool).toHaveBeenCalledWith( { name: "search", arguments: {}, _meta: undefined }, + undefined, requestOptions, ); expect(result.details).toMatchObject({ error: "aborted", message: "request aborted" }); @@ -554,11 +556,13 @@ describe("proxy auto auth", () => { expect(client.callTool).toHaveBeenNthCalledWith( 1, { name: "search", arguments: { q: "one" }, _meta: undefined }, + undefined, { timeout: 5000 }, ); expect(client.callTool).toHaveBeenNthCalledWith( 2, { name: "search", arguments: { q: "two" }, _meta: undefined }, + undefined, { timeout: 5000 }, ); expect(first.content[0].text).toContain("ok"); diff --git a/__tests__/sampling-handler.test.ts b/__tests__/sampling-handler.test.ts index 3c4b0e43..c820f2b2 100644 --- a/__tests__/sampling-handler.test.ts +++ b/__tests__/sampling-handler.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Api, Model } from "@earendil-works/pi-ai"; -import type { CreateMessageRequest, ModelPreferences } from "@modelcontextprotocol/client"; +import type { CreateMessageRequest, ModelPreferences } from "@modelcontextprotocol/sdk/types.js"; import type { SamplingHandlerOptions } from "../sampling-handler.ts"; const mocks = vi.hoisted(() => ({ diff --git a/__tests__/server-manager-http-auth.test.ts b/__tests__/server-manager-http-auth.test.ts index 96b7970c..622279e9 100644 --- a/__tests__/server-manager-http-auth.test.ts +++ b/__tests__/server-manager-http-auth.test.ts @@ -9,9 +9,7 @@ type OAuthProviderLike = { }; }; -type ClientOptions = { - versionNegotiation?: { mode?: string }; -}; +type ClientOptions = Record; type TransportOptions = { requestInit?: { @@ -31,7 +29,7 @@ const mocks = vi.hoisted(() => ({ httpTransports: [] as HttpTransportMock[], })); -vi.mock("@modelcontextprotocol/client", async (importOriginal) => ({ +vi.mock("@modelcontextprotocol/sdk/client/index.js", async (importOriginal) => ({ ...(await importOriginal>()), Client: vi.fn().mockImplementation((info: unknown, options: ClientOptions) => { const client = { @@ -47,15 +45,20 @@ vi.mock("@modelcontextprotocol/client", async (importOriginal) => ({ mocks.clients.push(client); return client; }), +})); + +vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", async (importOriginal) => ({ + ...(await importOriginal>()), StreamableHTTPClientTransport: vi.fn().mockImplementation((url: URL, options: TransportOptions) => { const transport = { url, options, close: vi.fn(async () => undefined) }; mocks.httpTransports.push(transport); return transport; }), - SSEClientTransport: vi.fn(), })); -vi.mock("@modelcontextprotocol/client/stdio", () => ({ +vi.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({ SSEClientTransport: vi.fn() })); + +vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({ StdioClientTransport: vi.fn(), })); @@ -85,18 +88,7 @@ describe("McpServerManager HTTP bearer auth", () => { } }); - it("enables automatic v2 protocol negotiation for both HTTP probe and live clients", async () => { - const { McpServerManager } = await import("../server-manager.ts"); - const manager = new McpServerManager(); - await manager.connect("remote", { url: "https://example.test/mcp" }); - - expect(mocks.clients).toHaveLength(2); - expect(mocks.clients.map(client => client.options)).toEqual([ - expect.objectContaining({ versionNegotiation: { mode: "auto" } }), - expect.objectContaining({ versionNegotiation: { mode: "auto" } }), - ]); - }); it("interpolates ${VAR} URL placeholders", async () => { const { McpServerManager } = await import("../server-manager.ts"); diff --git a/__tests__/server-manager-instructions.test.ts b/__tests__/server-manager-instructions.test.ts index ff07b5c3..c513a06c 100644 --- a/__tests__/server-manager-instructions.test.ts +++ b/__tests__/server-manager-instructions.test.ts @@ -5,7 +5,7 @@ const mocks = vi.hoisted(() => ({ instructions: undefined as string | undefined, })); -vi.mock("@modelcontextprotocol/client", async (importOriginal) => ({ +vi.mock("@modelcontextprotocol/sdk/client/index.js", async (importOriginal) => ({ ...(await importOriginal>()), Client: vi.fn().mockImplementation(function (this: any, info: unknown, options: unknown) { this.info = info; @@ -23,7 +23,7 @@ vi.mock("@modelcontextprotocol/client", async (importOriginal) => ({ SSEClientTransport: vi.fn(), })); -vi.mock("@modelcontextprotocol/client/stdio", () => ({ +vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({ StdioClientTransport: vi.fn().mockImplementation(function (this: any, options: unknown) { this.options = options; this.close = vi.fn(async () => undefined); @@ -59,14 +59,5 @@ describe("McpServerManager instructions", () => { expect(connection.instructions).toBeUndefined(); }); - it("enables automatic v2 protocol negotiation for stdio clients", async () => { - const { McpServerManager } = await import("../server-manager.ts"); - const manager = new McpServerManager(); - await manager.connect("demo", { command: "node", args: ["server.js"] }); - - expect(mocks.clients[0]?.options).toMatchObject({ - versionNegotiation: { mode: "auto" }, - }); - }); }); diff --git a/__tests__/server-manager-legacy-handshake.test.ts b/__tests__/server-manager-legacy-handshake.test.ts new file mode 100644 index 00000000..950b6f00 --- /dev/null +++ b/__tests__/server-manager-legacy-handshake.test.ts @@ -0,0 +1,26 @@ +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { McpServerManager } from "../server-manager.ts"; + +const managers: McpServerManager[] = []; + +afterEach(async () => { + await Promise.all(managers.map(manager => manager.closeAll())); + managers.length = 0; +}); + +describe("McpServerManager legacy handshake", () => { + it("reaches classic initialize when the server rejects server/discover", async () => { + const manager = new McpServerManager(); + managers.push(manager); + manager.setDefaultRequestTimeoutMs(1_000); + + const connection = await manager.connect("legacy", { + command: process.execPath, + args: [fileURLToPath(new URL("./fixtures/legacy-no-discover-server.mjs", import.meta.url))], + }); + + expect(connection.status).toBe("connected"); + expect(connection.tools.map(tool => tool.name)).toEqual(["classic_initialize_reached"]); + }, 5_000); +}); diff --git a/__tests__/server-manager-reconnect.test.ts b/__tests__/server-manager-reconnect.test.ts index a8c49d4d..f81f46be 100644 --- a/__tests__/server-manager-reconnect.test.ts +++ b/__tests__/server-manager-reconnect.test.ts @@ -15,7 +15,7 @@ const mocks = vi.hoisted(() => ({ httpTransports: [] as HttpTransportMock[], })); -vi.mock("@modelcontextprotocol/client", async (importOriginal) => ({ +vi.mock("@modelcontextprotocol/sdk/client/index.js", async (importOriginal) => ({ ...(await importOriginal>()), Client: vi.fn().mockImplementation((info: unknown, options: unknown) => { const client: any = { @@ -32,15 +32,20 @@ vi.mock("@modelcontextprotocol/client", async (importOriginal) => ({ mocks.clients.push(client); return client; }), +})); + +vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", async (importOriginal) => ({ + ...(await importOriginal>()), StreamableHTTPClientTransport: vi.fn().mockImplementation((url: URL, options: TransportOptions) => { const transport = { url, options, close: vi.fn(async () => undefined) }; mocks.httpTransports.push(transport); return transport; }), - SSEClientTransport: vi.fn(), })); -vi.mock("@modelcontextprotocol/client/stdio", () => ({ +vi.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({ SSEClientTransport: vi.fn() })); + +vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({ StdioClientTransport: vi.fn(), })); diff --git a/__tests__/server-manager-runtime-owner.test.ts b/__tests__/server-manager-runtime-owner.test.ts index 75a5dd6c..3ca6a0bd 100644 --- a/__tests__/server-manager-runtime-owner.test.ts +++ b/__tests__/server-manager-runtime-owner.test.ts @@ -14,7 +14,7 @@ function gate() { return { promise, resolve }; } -vi.mock("@modelcontextprotocol/client", async (importOriginal) => ({ +vi.mock("@modelcontextprotocol/sdk/client/index.js", async (importOriginal) => ({ ...(await importOriginal>()), Client: vi.fn().mockImplementation(function (this: any) { this.setRequestHandler = vi.fn(); @@ -31,7 +31,7 @@ vi.mock("@modelcontextprotocol/client", async (importOriginal) => ({ StreamableHTTPClientTransport: vi.fn(), SSEClientTransport: vi.fn(), })); -vi.mock("@modelcontextprotocol/client/stdio", () => ({ +vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({ StdioClientTransport: vi.fn().mockImplementation(function (this: any) { this.close = vi.fn(async () => undefined); mocks.transports.push(this); diff --git a/__tests__/server-manager-sampling.test.ts b/__tests__/server-manager-sampling.test.ts index 47803766..3205d517 100644 --- a/__tests__/server-manager-sampling.test.ts +++ b/__tests__/server-manager-sampling.test.ts @@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({ vi.mock("open", () => ({ default: mocks.open })); -vi.mock("@modelcontextprotocol/client", async (importOriginal) => ({ +vi.mock("@modelcontextprotocol/sdk/client/index.js", async (importOriginal) => ({ ...(await importOriginal>()), Client: vi.fn().mockImplementation(function (this: any, info: unknown, options: unknown) { this.info = info; @@ -28,7 +28,7 @@ vi.mock("@modelcontextprotocol/client", async (importOriginal) => ({ SSEClientTransport: vi.fn(), })); -vi.mock("@modelcontextprotocol/client/stdio", () => ({ +vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({ StdioClientTransport: vi.fn().mockImplementation(function (this: any, options: unknown) { this.options = options; this.close = vi.fn(async () => undefined); @@ -72,8 +72,6 @@ describe("McpServerManager sampling", () => { const client = mocks.clients[0]; expect(client.options).toMatchObject({ capabilities: { sampling: {} }, - versionNegotiation: { mode: "auto" }, - inputRequired: { autoFulfill: true }, }); expect(client.setRequestHandler).toHaveBeenCalledTimes(1); expect(client.setRequestHandler.mock.invocationCallOrder[0]).toBeLessThan( @@ -99,8 +97,6 @@ describe("McpServerManager sampling", () => { url: {}, }, }, - versionNegotiation: { mode: "auto" }, - inputRequired: { autoFulfill: true }, }); expect(client.setRequestHandler).toHaveBeenCalledTimes(1); expect(client.setRequestHandler.mock.invocationCallOrder[0]).toBeLessThan( @@ -117,8 +113,6 @@ describe("McpServerManager sampling", () => { expect(mocks.clients[0].options).toMatchObject({ capabilities: { elicitation: { form: {} } }, - versionNegotiation: { mode: "auto" }, - inputRequired: { autoFulfill: true }, }); }); @@ -158,7 +152,7 @@ describe("McpServerManager sampling", () => { }); it("handles every URL in a URL-required error", async () => { - const { UrlElicitationRequiredError } = await import("@modelcontextprotocol/client"); + const { UrlElicitationRequiredError } = await import("@modelcontextprotocol/sdk/types.js"); const { McpServerManager } = await import("../server-manager.ts"); const ui = { select: vi.fn().mockResolvedValue("Open"), @@ -201,8 +195,6 @@ describe("McpServerManager sampling", () => { url: {}, }, }, - versionNegotiation: { mode: "auto" }, - inputRequired: { autoFulfill: true }, }); expect(mocks.clients[0].setRequestHandler).toHaveBeenCalledTimes(2); }); @@ -214,10 +206,7 @@ describe("McpServerManager sampling", () => { await manager.connect("demo", { command: "node", args: ["server.js"] }); const client = mocks.clients[0]; - expect(client.options).toMatchObject({ - versionNegotiation: { mode: "auto" }, - inputRequired: { autoFulfill: true }, - }); + expect(client.options).not.toHaveProperty("capabilities"); expect(client.options.listChanged.tools.onChanged).toBeTypeOf("function"); expect(client.options.listChanged.resources.onChanged).toBeTypeOf("function"); expect(client.setRequestHandler).not.toHaveBeenCalled(); diff --git a/__tests__/server-manager-stderr-capture.test.ts b/__tests__/server-manager-stderr-capture.test.ts index cd2073c6..90efce0e 100644 --- a/__tests__/server-manager-stderr-capture.test.ts +++ b/__tests__/server-manager-stderr-capture.test.ts @@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({ connectImpl: null as null | ((transport: any) => Promise), })); -vi.mock("@modelcontextprotocol/client", async (importOriginal) => ({ +vi.mock("@modelcontextprotocol/sdk/client/index.js", async (importOriginal) => ({ ...(await importOriginal>()), Client: vi.fn().mockImplementation(function (this: any) { this.setRequestHandler = vi.fn(); @@ -28,7 +28,7 @@ vi.mock("@modelcontextprotocol/client", async (importOriginal) => ({ }), })); -vi.mock("@modelcontextprotocol/client/stdio", () => ({ +vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({ StdioClientTransport: vi.fn().mockImplementation(function (this: any, options: any) { this.options = options; this.stderr = options?.stderr === "pipe" ? new PassThrough() : null; diff --git a/__tests__/server-manager-streamable-http.test.ts b/__tests__/server-manager-streamable-http.test.ts index 9ac9118c..e0a4c79d 100644 --- a/__tests__/server-manager-streamable-http.test.ts +++ b/__tests__/server-manager-streamable-http.test.ts @@ -200,7 +200,7 @@ describe("McpServerManager StreamableHTTP transport", () => { expect(connection.tools).toEqual([]); expect(connection.resources).toEqual([]); expect(requests).toContain("GET /mcp"); - // SDK v2 probes the optional GET stream once, then keeps the successful + // The SDK probes the optional GET stream once, then keeps the successful // POST-based session without an SSE fallback or retry storm. expect(requests.filter(request => request === "GET /mcp")).toHaveLength(1); diff --git a/__tests__/server-manager-unix-socket.test.ts b/__tests__/server-manager-unix-socket.test.ts index d1925d47..6600f65e 100644 --- a/__tests__/server-manager-unix-socket.test.ts +++ b/__tests__/server-manager-unix-socket.test.ts @@ -2,7 +2,8 @@ import { createServer, type Server } from "node:net"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { ReadBuffer, serializeMessage, type JSONRPCMessage } from "@modelcontextprotocol/client"; +import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"; +import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; import { afterEach, describe, expect, it } from "vitest"; import { McpServerManager } from "../server-manager.ts"; diff --git a/__tests__/session-recovery-integration.test.ts b/__tests__/session-recovery-integration.test.ts index 66751625..638ac0db 100644 --- a/__tests__/session-recovery-integration.test.ts +++ b/__tests__/session-recovery-integration.test.ts @@ -129,13 +129,13 @@ describe("session recovery — Streamable HTTP wire path", () => { describe("session recovery — proxy path (proxy-modes.ts executeCall)", () => { it("recovers a terminated Streamable HTTP session transparently mid tool-call", async () => { const { executeCall } = await import("../proxy-modes.ts"); - const { SdkHttpError, SdkErrorCode } = await import("@modelcontextprotocol/client"); + const { StreamableHTTPError } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js"); const stale = { status: "connected" as const, transport: { sessionId: "session-1" }, client: { - callTool: vi.fn().mockRejectedValueOnce(new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Session not found", { status: 404 })), + callTool: vi.fn().mockRejectedValueOnce(new StreamableHTTPError(404, "Session not found")), }, }; const fresh = { @@ -176,13 +176,13 @@ describe("session recovery — proxy path (proxy-modes.ts executeCall)", () => { it("recovers a server-not-initialized MCP error transparently mid tool-call", async () => { const { executeCall } = await import("../proxy-modes.ts"); - const { ProtocolError } = await import("@modelcontextprotocol/client"); + const { McpError } = await import("@modelcontextprotocol/sdk/types.js"); const stale = { status: "connected" as const, transport: { sessionId: "session-1" }, client: { - callTool: vi.fn().mockRejectedValueOnce(new ProtocolError(-32000, "Server not initialized")), + callTool: vi.fn().mockRejectedValueOnce(new McpError(-32000, "Server not initialized")), }, }; const fresh = { @@ -223,17 +223,17 @@ describe("session recovery — proxy path (proxy-modes.ts executeCall)", () => { it("gives up after one reconnect attempt: a second server-not-initialized MCP error propagates as call_failed", async () => { const { executeCall } = await import("../proxy-modes.ts"); - const { ProtocolError } = await import("@modelcontextprotocol/client"); + const { McpError } = await import("@modelcontextprotocol/sdk/types.js"); const stale = { status: "connected" as const, transport: { sessionId: "session-1" }, - client: { callTool: vi.fn().mockRejectedValue(new ProtocolError(-32000, "Server not initialized")) }, + client: { callTool: vi.fn().mockRejectedValue(new McpError(-32000, "Server not initialized")) }, }; const fresh = { status: "connected" as const, transport: { sessionId: "session-2" }, - client: { callTool: vi.fn().mockRejectedValue(new ProtocolError(-32000, "Server not initialized")) }, + client: { callTool: vi.fn().mockRejectedValue(new McpError(-32000, "Server not initialized")) }, }; const manager = { @@ -265,17 +265,17 @@ describe("session recovery — proxy path (proxy-modes.ts executeCall)", () => { it("gives up after one reconnect attempt: a second terminated session propagates as call_failed", async () => { const { executeCall } = await import("../proxy-modes.ts"); - const { SdkHttpError, SdkErrorCode } = await import("@modelcontextprotocol/client"); + const { StreamableHTTPError } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js"); const stale = { status: "connected" as const, transport: { sessionId: "session-1" }, - client: { callTool: vi.fn().mockRejectedValue(new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Session not found", { status: 404 })) }, + client: { callTool: vi.fn().mockRejectedValue(new StreamableHTTPError(404, "Session not found")) }, }; const fresh = { status: "connected" as const, transport: { sessionId: "session-2" }, - client: { callTool: vi.fn().mockRejectedValue(new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Session not found", { status: 404 })) }, + client: { callTool: vi.fn().mockRejectedValue(new StreamableHTTPError(404, "Session not found")) }, }; const manager = { @@ -314,13 +314,13 @@ describe("session recovery — direct-tools path (direct-tools.ts createDirectTo it("recovers a terminated Streamable HTTP session transparently for a direct tool call", async () => { const { createDirectToolExecutor } = await import("../direct-tools.ts"); - const { SdkHttpError, SdkErrorCode } = await import("@modelcontextprotocol/client"); + const { StreamableHTTPError } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js"); const stale = { status: "connected" as const, transport: { sessionId: "session-1" }, client: { - callTool: vi.fn().mockRejectedValueOnce(new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Session not found", { status: 404 })), + callTool: vi.fn().mockRejectedValueOnce(new StreamableHTTPError(404, "Session not found")), }, }; const fresh = { @@ -365,13 +365,13 @@ describe("session recovery — direct-tools path (direct-tools.ts createDirectTo it("recovers a server-not-initialized MCP error transparently for a direct tool call", async () => { const { createDirectToolExecutor } = await import("../direct-tools.ts"); - const { ProtocolError } = await import("@modelcontextprotocol/client"); + const { McpError } = await import("@modelcontextprotocol/sdk/types.js"); const stale = { status: "connected" as const, transport: { sessionId: "session-1" }, client: { - callTool: vi.fn().mockRejectedValueOnce(new ProtocolError(-32000, "Server not initialized")), + callTool: vi.fn().mockRejectedValueOnce(new McpError(-32000, "Server not initialized")), }, }; const fresh = { @@ -416,17 +416,17 @@ describe("session recovery — direct-tools path (direct-tools.ts createDirectTo it("gives up after one reconnect attempt: a second terminated session propagates as call_failed", async () => { const { createDirectToolExecutor } = await import("../direct-tools.ts"); - const { SdkHttpError, SdkErrorCode } = await import("@modelcontextprotocol/client"); + const { StreamableHTTPError } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js"); const stale = { status: "connected" as const, transport: { sessionId: "session-1" }, - client: { callTool: vi.fn().mockRejectedValue(new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Session not found", { status: 404 })) }, + client: { callTool: vi.fn().mockRejectedValue(new StreamableHTTPError(404, "Session not found")) }, }; const fresh = { status: "connected" as const, transport: { sessionId: "session-2" }, - client: { callTool: vi.fn().mockRejectedValue(new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Session not found", { status: 404 })) }, + client: { callTool: vi.fn().mockRejectedValue(new StreamableHTTPError(404, "Session not found")) }, }; const manager = { diff --git a/__tests__/session-recovery.test.ts b/__tests__/session-recovery.test.ts index f4fbb9a9..aaf00488 100644 --- a/__tests__/session-recovery.test.ts +++ b/__tests__/session-recovery.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { SdkHttpError, SdkErrorCode } from "@modelcontextprotocol/client"; -import { ProtocolError } from "@modelcontextprotocol/client"; +import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { McpError } from "@modelcontextprotocol/sdk/types.js"; import { SessionRecoveryAuthRequiredError, isTerminatedSession, withSessionRecovery } from "../session-recovery.ts"; import type { ServerConnection } from "../server-manager.ts"; import type { McpConfig } from "../types.ts"; @@ -20,42 +20,42 @@ function makeConnection(sessionId: string | undefined): ServerConnection { describe("isTerminatedSession", () => { it("is true for a 404 StreamableHTTPError carrying a session id", () => { - const err = new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Session not found", { status: 404 }); + const err = new StreamableHTTPError(404, "Session not found"); expect(isTerminatedSession(err, true)).toBe(true); }); it("is false for a 404 with no session id (never initialized / wrong URL)", () => { - const err = new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Not found", { status: 404 }); + const err = new StreamableHTTPError(404, "Not found"); expect(isTerminatedSession(err, false)).toBe(false); }); it("is true for a server-not-initialized MCP error carrying a session id", () => { - const err = new ProtocolError(-32000, "Server not initialized"); + const err = new McpError(-32000, "Server not initialized"); expect(isTerminatedSession(err, true)).toBe(true); }); it("is true for the SDK's bad-request server-not-initialized MCP error", () => { - const err = new ProtocolError(-32000, "Bad Request: Server not initialized"); + const err = new McpError(-32000, "Bad Request: Server not initialized"); expect(isTerminatedSession(err, true)).toBe(true); }); it("is true for the SDK's bad-request server-not-initialized HTTP error body", () => { - const err = new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Server not initialized"},"id":null}', { status: 400 }); + const err = new StreamableHTTPError(400, 'Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Server not initialized"},"id":null}'); expect(isTerminatedSession(err, true)).toBe(true); }); it("is false for other -32000 HTTP 400 error bodies", () => { - const err = new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Mcp-Session-Id header is required"},"id":null}', { status: 400 }); + const err = new StreamableHTTPError(400, 'Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Mcp-Session-Id header is required"},"id":null}'); expect(isTerminatedSession(err, true)).toBe(false); }); it("is false for server-not-initialized without a session id", () => { - const err = new ProtocolError(-32000, "Server not initialized"); + const err = new McpError(-32000, "Server not initialized"); expect(isTerminatedSession(err, false)).toBe(false); }); it("is false for other -32000 MCP errors, even with a session id", () => { - const err = new ProtocolError(-32000, "Connection closed"); + const err = new McpError(-32000, "Connection closed"); expect(isTerminatedSession(err, true)).toBe(false); }); @@ -65,12 +65,12 @@ describe("isTerminatedSession", () => { }); it("is false for the right message with the wrong MCP error code", () => { - const err = new ProtocolError(-32603, "Server not initialized"); + const err = new McpError(-32603, "Server not initialized"); expect(isTerminatedSession(err, true)).toBe(false); }); it("is false for 400, even with a session id — ambiguous, never treated as expiry", () => { - const err = new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Bad request", { status: 400 }); + const err = new StreamableHTTPError(400, "Bad request"); expect(isTerminatedSession(err, true)).toBe(false); }); @@ -115,7 +115,7 @@ describe("withSessionRecovery", () => { const fn = vi.fn(async (conn: ServerConnection) => { if (conn === stale) { - throw new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Session not found", { status: 404 }); + throw new StreamableHTTPError(404, "Session not found"); } return "ok"; }); @@ -140,7 +140,7 @@ describe("withSessionRecovery", () => { const fn = vi.fn(async (conn: ServerConnection) => { if (conn === stale) { - throw new ProtocolError(-32000, "Server not initialized"); + throw new McpError(-32000, "Server not initialized"); } return "ok"; }); @@ -158,7 +158,7 @@ describe("withSessionRecovery", () => { it("does not recover unrelated -32000 MCP errors", async () => { const connection = makeConnection("session-1"); const manager = makeManager({ getConnection: () => connection, reconnect: async () => connection }); - const err = new ProtocolError(-32000, "Connection closed"); + const err = new McpError(-32000, "Connection closed"); const fn = vi.fn().mockRejectedValue(err); await expect(withSessionRecovery({ manager: manager as any, config }, "demo", fn)).rejects.toBe(err); @@ -178,7 +178,7 @@ describe("withSessionRecovery", () => { const fn = vi.fn(async (conn: ServerConnection) => { if (conn === stale) { - throw new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Session not found", { status: 404 }); + throw new StreamableHTTPError(404, "Session not found"); } return conn === authed ? "ok" : "wrong-connection"; }); @@ -198,7 +198,7 @@ describe("withSessionRecovery", () => { reconnect: async () => needsAuth, }); const fn = vi.fn(async () => { - throw new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Session not found", { status: 404 }); + throw new StreamableHTTPError(404, "Session not found"); }); await expect(withSessionRecovery({ manager: manager as any, config }, "demo", fn)) @@ -215,7 +215,7 @@ describe("withSessionRecovery", () => { }); const fn = vi.fn(async (conn: ServerConnection) => { if (conn === stale) { - throw new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Session not found", { status: 404 }); + throw new StreamableHTTPError(404, "Session not found"); } return "ok"; }); @@ -235,7 +235,7 @@ describe("withSessionRecovery", () => { }); const fn = vi.fn(async () => { controller.abort(reason); - throw new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Session not found", { status: 404 }); + throw new StreamableHTTPError(404, "Session not found"); }); await expect(withSessionRecovery({ manager: manager as any, config, signal: controller.signal }, "demo", fn)) @@ -251,8 +251,8 @@ describe("withSessionRecovery", () => { reconnect: async () => fresh, }); - const err1 = new ProtocolError(-32000, "Server not initialized"); - const err2 = new ProtocolError(-32000, "Server not initialized"); + const err1 = new McpError(-32000, "Server not initialized"); + const err2 = new McpError(-32000, "Server not initialized"); const fn = vi.fn().mockRejectedValueOnce(err1).mockRejectedValueOnce(err2); await expect(withSessionRecovery({ manager: manager as any, config }, "demo", fn)).rejects.toBe(err2); @@ -269,8 +269,8 @@ describe("withSessionRecovery", () => { reconnect: async () => fresh, }); - const err1 = new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Session not found", { status: 404 }); - const err2 = new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Session not found", { status: 404 }); + const err1 = new StreamableHTTPError(404, "Session not found"); + const err2 = new StreamableHTTPError(404, "Session not found"); const fn = vi.fn().mockRejectedValueOnce(err1).mockRejectedValueOnce(err2); await expect(withSessionRecovery({ manager: manager as any, config }, "demo", fn)).rejects.toBe(err2); @@ -294,7 +294,7 @@ describe("withSessionRecovery", () => { it("does not recover a 404 without a session id (never initialized / wrong URL)", async () => { const connection = makeConnection(undefined); const manager = makeManager({ getConnection: () => connection, reconnect: async () => connection }); - const err = new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Not found", { status: 404 }); + const err = new StreamableHTTPError(404, "Not found"); const fn = vi.fn().mockRejectedValue(err); await expect(withSessionRecovery({ manager: manager as any, config }, "demo", fn)).rejects.toBe(err); @@ -316,7 +316,7 @@ describe("withSessionRecovery", () => { it("does not recover a 400 (ambiguous status, never matched)", async () => { const connection = makeConnection("session-1"); const manager = makeManager({ getConnection: () => connection, reconnect: async () => connection }); - const err = new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Bad request", { status: 400 }); + const err = new StreamableHTTPError(400, "Bad request"); const fn = vi.fn().mockRejectedValue(err); await expect(withSessionRecovery({ manager: manager as any, config }, "demo", fn)).rejects.toBe(err); @@ -327,7 +327,7 @@ describe("withSessionRecovery", () => { it("rethrows the original error when the server was removed from config before reconnecting", async () => { const connection = makeConnection("session-1"); const manager = makeManager({ getConnection: () => connection, reconnect: async () => connection }); - const err = new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Session not found", { status: 404 }); + const err = new StreamableHTTPError(404, "Session not found"); const fn = vi.fn().mockRejectedValue(err); const emptyConfig: McpConfig = { mcpServers: {} }; @@ -351,7 +351,7 @@ describe("withSessionRecovery", () => { const fn = vi.fn(async (conn: ServerConnection) => { if (conn === stale) { - throw new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Session not found", { status: 404 }); + throw new StreamableHTTPError(404, "Session not found"); } return conn === fresh ? "ok" : "unexpected"; }); diff --git a/__tests__/ui-resource-handler.test.ts b/__tests__/ui-resource-handler.test.ts index 9e155e6d..35462739 100644 --- a/__tests__/ui-resource-handler.test.ts +++ b/__tests__/ui-resource-handler.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { UiResourceHandler } from "../ui-resource-handler.ts"; import { buildCspMetaContent } from "../host-html-template.ts"; -import { UrlElicitationRequiredError } from "@modelcontextprotocol/client"; +import { UrlElicitationRequiredError } from "@modelcontextprotocol/sdk/types.js"; import type { McpServerManager } from "../server-manager.ts"; // Mock the manager @@ -38,12 +38,12 @@ describe("UiResourceHandler", () => { }); it("recovers a terminated HTTP session while loading a UI resource", async () => { - const { SdkHttpError, SdkErrorCode } = await import("@modelcontextprotocol/client"); + const { StreamableHTTPError } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js"); const stale = { status: "connected" as const, transport: { sessionId: "session-1" }, client: { - readResource: vi.fn().mockRejectedValueOnce(new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Session not found", { status: 404 })), + readResource: vi.fn().mockRejectedValueOnce(new StreamableHTTPError(404, "Session not found")), }, resources: [], }; diff --git a/__tests__/ui-server-session-recovery.test.ts b/__tests__/ui-server-session-recovery.test.ts index cb8bcd05..0611d0ad 100644 --- a/__tests__/ui-server-session-recovery.test.ts +++ b/__tests__/ui-server-session-recovery.test.ts @@ -70,12 +70,12 @@ describe("UiServer /proxy/tools/call session recovery", () => { }); it("recovers a terminated Streamable HTTP session transparently, when config is supplied", async () => { - const { SdkHttpError, SdkErrorCode } = await import("@modelcontextprotocol/client"); + const { StreamableHTTPError } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js"); const stale = { status: "connected", transport: { sessionId: "session-1" }, - client: { callTool: vi.fn().mockRejectedValueOnce(new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Session not found", { status: 404 })) }, + client: { callTool: vi.fn().mockRejectedValueOnce(new StreamableHTTPError(404, "Session not found")) }, } as unknown as ServerConnection; const fresh = { status: "connected", @@ -119,12 +119,12 @@ describe("UiServer /proxy/tools/call session recovery", () => { }); it("returns auth guidance when recovery reconnect needs auth and no callback can refresh it", async () => { - const { SdkHttpError, SdkErrorCode } = await import("@modelcontextprotocol/client"); + const { StreamableHTTPError } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js"); const stale = { status: "connected", transport: { sessionId: "session-1" }, - client: { callTool: vi.fn().mockRejectedValueOnce(new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Session not found", { status: 404 })) }, + client: { callTool: vi.fn().mockRejectedValueOnce(new StreamableHTTPError(404, "Session not found")) }, } as unknown as ServerConnection; const needsAuth = { status: "needs-auth", @@ -164,12 +164,12 @@ describe("UiServer /proxy/tools/call session recovery", () => { }); it("uses the supplied auth callback when recovery reconnect returns needs-auth", async () => { - const { SdkHttpError, SdkErrorCode } = await import("@modelcontextprotocol/client"); + const { StreamableHTTPError } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js"); const stale = { status: "connected", transport: { sessionId: "session-1" }, - client: { callTool: vi.fn().mockRejectedValueOnce(new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Session not found", { status: 404 })) }, + client: { callTool: vi.fn().mockRejectedValueOnce(new StreamableHTTPError(404, "Session not found")) }, } as unknown as ServerConnection; const needsAuth = { status: "needs-auth", @@ -215,12 +215,12 @@ describe("UiServer /proxy/tools/call session recovery", () => { }); it("runs without recovery (unchanged pre-existing behavior) when config is not supplied", async () => { - const { SdkHttpError, SdkErrorCode } = await import("@modelcontextprotocol/client"); + const { StreamableHTTPError } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js"); const stale = { status: "connected", transport: { sessionId: "session-1" }, - client: { callTool: vi.fn().mockRejectedValue(new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Session not found", { status: 404 })) }, + client: { callTool: vi.fn().mockRejectedValue(new StreamableHTTPError(404, "Session not found")) }, } as unknown as ServerConnection; const manager = { diff --git a/__tests__/ui-server.test.ts b/__tests__/ui-server.test.ts index 4d722727..59d1baef 100644 --- a/__tests__/ui-server.test.ts +++ b/__tests__/ui-server.test.ts @@ -554,6 +554,7 @@ describe("UiServer", () => { name: "some_tool", arguments: { arg1: "value1" }, }, + undefined, requestOptions, ); }); diff --git a/__tests__/ui-streaming.test.ts b/__tests__/ui-streaming.test.ts index c756135e..2dff328d 100644 --- a/__tests__/ui-streaming.test.ts +++ b/__tests__/ui-streaming.test.ts @@ -198,9 +198,11 @@ describe("UI Streaming", () => { attachAdapterNotificationHandlers: (serverName: string, client: { setNotificationHandler: typeof client.setNotificationHandler }) => void; }).attachAdapterNotificationHandlers(serverName, client); expect(client.setNotificationHandler).toHaveBeenCalledOnce(); - const handler = client.setNotificationHandler.mock.calls[0][2] as (params: { - streamToken: string; - result: { content?: unknown[]; structuredContent?: Record }; + const handler = client.setNotificationHandler.mock.calls[0][1] as (notification: { + params: { + streamToken: string; + result: { content?: unknown[]; structuredContent?: Record }; + }; }) => void; return (notification: { method: string; @@ -208,7 +210,7 @@ describe("UI Streaming", () => { streamToken: string; result: { content?: unknown[]; structuredContent?: Record }; }; - }) => handler(notification.params); + }) => handler(notification); } it("routes notifications to the matching listener", () => { diff --git a/conformance/README.md b/conformance/README.md index 9a5d4853..ec360cea 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -44,7 +44,6 @@ Current gaps: | --- | --- | | `auth/basic-cimd` | The adapter uses dynamic client registration rather than an HTTPS Client ID Metadata Document. | | `auth/scope-step-up` | Pi's user-gated OAuth flow cannot yet resume the SDK's in-call 403 scope challenge with the widened scope. | -| `auth/2025-03-26-oauth-metadata-backcompat` | SDK beta.5 rejects the referee's legacy metadata issuer mismatch under RFC 8414 validation. | | `auth/client-credentials-jwt` | Private-key JWT client authentication is not configured by the adapter. | | `auth/cross-app-access-complete-flow` | The adapter does not implement SEP-990 token exchange and JWT bearer grants. | diff --git a/conformance/baseline-client.yml b/conformance/baseline-client.yml index eb79f45e..17290cd9 100644 --- a/conformance/baseline-client.yml +++ b/conformance/baseline-client.yml @@ -11,11 +11,6 @@ client: # challenge scope and requests only the original PRM scope. - auth/scope-step-up - # beta.5 applies RFC 8414 issuer-echo validation to the 2025-03-26 fallback. - # The referee serves metadata at the MCP origin while advertising an /oauth - # issuer, so the secure default rejects it before registration. - - auth/2025-03-26-oauth-metadata-backcompat - # The adapter supports client_secret_basic client credentials, but its config # has no private-key JWT credentials or assertion signing hook. - auth/client-credentials-jwt diff --git a/conformance/driver.sh b/conformance/driver.sh index 814d30a5..9baf7743 100755 --- a/conformance/driver.sh +++ b/conformance/driver.sh @@ -12,6 +12,7 @@ cleanup() { trap cleanup EXIT HUP INT TERM export MCP_OAUTH_DIR="$AUTH_DIR" +export PI_MCP_ADAPTER_TEST_AUTH_STORE=memory # Pre-registered browser clients require an exact callback port. Allocate one # per process. The full runner is sequential, avoiding the race between probing diff --git a/conformance/driver.ts b/conformance/driver.ts index 39ee3039..b9c1756a 100644 --- a/conformance/driver.ts +++ b/conformance/driver.ts @@ -22,7 +22,7 @@ */ import { rmSync } from "node:fs" -import { UnauthorizedError } from "@modelcontextprotocol/client" +import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent" import { McpServerManager } from "../server-manager.ts" import { @@ -210,6 +210,7 @@ async function callTool(toolName: string, args: Record) { try { const result = await connection.client.callTool( { name: toolName, arguments: args }, + undefined, manager.getRequestOptions(SERVER_NAME), ) if (result.isError) { diff --git a/direct-tools.ts b/direct-tools.ts index edae8787..7a377cf5 100644 --- a/direct-tools.ts +++ b/direct-tools.ts @@ -1,5 +1,5 @@ import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext } from "@earendil-works/pi-coding-agent"; -import { UrlElicitationRequiredError } from "@modelcontextprotocol/client"; +import { UrlElicitationRequiredError } from "@modelcontextprotocol/sdk/types.js"; import type { McpExtensionState } from "./state.ts"; import type { DirectToolSpec, McpConfig, McpContent, ToolPrefix } from "./types.ts"; import type { MetadataCache } from "./metadata-cache.ts"; @@ -433,9 +433,9 @@ export function createDirectToolExecutor( name: spec.originalName, arguments: params ?? {}, _meta: uiSession?.requestMeta, - }, requestOptions), ownedSignal), + }, undefined, requestOptions), ownedSignal), ); - uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/client").CallToolResult); + uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/sdk/types.js").CallToolResult); if (result.isError) { const mcpContent = (result.content ?? []) as McpContent[]; diff --git a/elicitation-handler.ts b/elicitation-handler.ts index b5fa8371..d6ff0cc7 100644 --- a/elicitation-handler.ts +++ b/elicitation-handler.ts @@ -1,15 +1,16 @@ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { - ProtocolError, - ProtocolErrorCode, - type Client, + ElicitRequestSchema, + ErrorCode, + McpError, type ElicitRequest, type ElicitRequestFormParams, type ElicitRequestURLParams, type ElicitResult, - type JsonSchemaType, -} from "@modelcontextprotocol/client"; -import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv"; +} from "@modelcontextprotocol/sdk/types.js"; +import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv"; +import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types.js"; import open from "open"; export type ElicitationValue = string | number | boolean | string[] | undefined; @@ -27,7 +28,7 @@ export interface ElicitationHandlerOptions { export type ServerElicitationConfig = Omit; export function registerElicitationHandler(client: Client, options: ElicitationHandlerOptions): void { - client.setRequestHandler("elicitation/create", (request) => + client.setRequestHandler(ElicitRequestSchema, request => handleElicitationRequest(options, request)); } @@ -304,16 +305,16 @@ export async function handleUrlElicitation( options: ElicitationHandlerOptions, params: ElicitRequestURLParams, ): Promise { - if (!options.allowUrl) throw new ProtocolError(ProtocolErrorCode.InvalidParams, "URL elicitation is not supported"); + if (!options.allowUrl) throw new McpError(ErrorCode.InvalidParams, "URL elicitation is not supported"); let parsed: URL; try { parsed = new URL(params.url); } catch { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, "URL elicitation supplied an invalid URL"); + throw new McpError(ErrorCode.InvalidParams, "URL elicitation supplied an invalid URL"); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, "URL elicitation only supports HTTP and HTTPS URLs"); + throw new McpError(ErrorCode.InvalidParams, "URL elicitation only supports HTTP and HTTPS URLs"); } const decision = await options.ui.select([ diff --git a/examples/interactive-visualizer/package.json b/examples/interactive-visualizer/package.json index 76ea35da..0a03ad84 100644 --- a/examples/interactive-visualizer/package.json +++ b/examples/interactive-visualizer/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@modelcontextprotocol/ext-apps": "^1.2.0", - "@modelcontextprotocol/server": "2.0.0-beta.5", + "@modelcontextprotocol/sdk": "^1.29.0", "chart.js": "^4.5.1", "mermaid": "^11.12.0", "zod": "^4.1.0" diff --git a/examples/interactive-visualizer/src/server.ts b/examples/interactive-visualizer/src/server.ts index dd90f954..d33d5915 100644 --- a/examples/interactive-visualizer/src/server.ts +++ b/examples/interactive-visualizer/src/server.ts @@ -1,5 +1,5 @@ -import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server"; -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -59,7 +59,7 @@ server.registerTool( }), _meta: { ui: { resourceUri: "ui://interactive-visualizer/app.html" } }, }, - async (args, ctx) => { + async (args, extra) => { const spec = { type: args.type || "bar", title: args.title || "Chart", @@ -67,10 +67,10 @@ server.registerTool( datasets: JSON.parse(args.datasets || "[]"), }; - const streamToken = ctx.mcpReq._meta?.["pi-mcp-adapter/stream-token"] as string | undefined; + const streamToken = extra._meta?.["pi-mcp-adapter/stream-token"] as string | undefined; if (streamToken) { const sendAdapterNotification = (notification: StreamNotification) => - ctx.mcpReq.notify(notification as never); + extra.sendNotification(notification as never); for (let i = 0; i < spec.datasets.length; i++) { const partial = { ...spec, datasets: spec.datasets.slice(0, i + 1) }; const isLast = i === spec.datasets.length - 1; @@ -185,12 +185,12 @@ server.registerTool( inputSchema: z.object({}), _meta: { ui: { resourceUri: "ui://interactive-visualizer/app.html" } }, }, - async (_args, ctx) => { - const streamToken = ctx.mcpReq._meta?.["pi-mcp-adapter/stream-token"] as string | undefined; + async (_args, extra) => { + const streamToken = extra._meta?.["pi-mcp-adapter/stream-token"] as string | undefined; if (streamToken) { const sendAdapterNotification = (notification: StreamNotification) => - ctx.mcpReq.notify(notification as never); + extra.sendNotification(notification as never); for (let t = 1; t <= STORY.length; t++) { const isLast = t === STORY.length; await sendStreamFrame(streamToken, sendAdapterNotification, { diff --git a/json-schema-validator.ts b/json-schema-validator.ts index 39143f5a..cef41e37 100644 --- a/json-schema-validator.ts +++ b/json-schema-validator.ts @@ -1,13 +1,12 @@ -import { - Ajv, - AjvJsonSchemaValidator, - addFormats, -} from "@modelcontextprotocol/client/validators/ajv"; +import { Ajv } from "ajv"; +import Ajv2020Import from "ajv/dist/2020.js"; +import addFormatsImport from "ajv-formats"; +import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv"; import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator as JsonSchemaValidatorProvider, -} from "@modelcontextprotocol/client"; +} from "@modelcontextprotocol/sdk/validation/types.js"; type SchemaDialect = | { status: "unstamped" } @@ -17,6 +16,9 @@ const DRAFT_07_SCHEMA_URIS: ReadonlySet = new Set([ "http://json-schema.org/draft-07/schema", "https://json-schema.org/draft-07/schema", ]); +const DRAFT_2020_12_SCHEMA_URIS: ReadonlySet = new Set([ + "https://json-schema.org/draft/2020-12/schema", +]); function schemaDialect(schema: JsonSchemaType): SchemaDialect { if (!("$schema" in schema) || typeof schema.$schema !== "string") { @@ -29,14 +31,24 @@ function schemaDialect(schema: JsonSchemaType): SchemaDialect { } export function createJsonSchemaValidator(): JsonSchemaValidatorProvider { - const defaultValidator = new AjvJsonSchemaValidator(); let draft07Validator: AjvJsonSchemaValidator | undefined; + let draft2020Validator: AjvJsonSchemaValidator | undefined; return { getValidator(schema: JsonSchemaType): JsonSchemaValidator { const dialect = schemaDialect(schema); - if (dialect.status !== "stamped" || !DRAFT_07_SCHEMA_URIS.has(dialect.uri)) { - return defaultValidator.getValidator(schema); + if (dialect.status === "unstamped" || DRAFT_2020_12_SCHEMA_URIS.has(dialect.uri)) { + draft2020Validator ??= (() => { + const Ajv2020 = Ajv2020Import as unknown as typeof Ajv; + const ajv = new Ajv2020({ strict: false, allErrors: true }); + const addFormats = addFormatsImport as unknown as (instance: Ajv) => void; + addFormats(ajv); + return new AjvJsonSchemaValidator(ajv); + })(); + return draft2020Validator.getValidator(schema); + } + if (!DRAFT_07_SCHEMA_URIS.has(dialect.uri)) { + throw new Error(`Unsupported JSON Schema dialect: ${dialect.uri}`); } draft07Validator ??= (() => { @@ -46,6 +58,7 @@ export function createJsonSchemaValidator(): JsonSchemaValidatorProvider { validateSchema: false, allErrors: true, }); + const addFormats = addFormatsImport as unknown as (instance: Ajv) => void; addFormats(ajv); return new AjvJsonSchemaValidator(ajv); })(); diff --git a/mcp-auth-flow.ts b/mcp-auth-flow.ts index 096a3e2f..be37ba9f 100644 --- a/mcp-auth-flow.ts +++ b/mcp-auth-flow.ts @@ -7,10 +7,9 @@ import { auth as runSdkAuth, extractWWWAuthenticateParams, - IssuerMismatchError, UnauthorizedError, - LATEST_PROTOCOL_VERSION, -} from "@modelcontextprotocol/client" +} from "@modelcontextprotocol/sdk/client/auth.js" +import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js" import open from "open" import { McpOAuthProvider, type McpOAuthConfig } from "./mcp-oauth-provider.ts" import { @@ -572,10 +571,25 @@ export async function completeAuth( let keepPendingForRetry = false let caughtError: unknown try { + const discoveryState = await pendingAuth.authProvider.discoveryState() + const metadata = discoveryState?.authorizationServerMetadata + const expectedIssuer = metadata?.issuer ?? discoveryState?.authorizationServerUrl + const requiresIssuer = (metadata as { authorization_response_iss_parameter_supported?: unknown } | undefined) + ?.authorization_response_iss_parameter_supported === true + if (expectedIssuer !== undefined && iss === undefined && requiresIssuer) { + keepPendingForRetry = true + throw new Error( + `The authorization server for ${serverName} requires the RFC 9207 "iss" parameter. ` + + "Paste the full redirect URL from the browser address bar (not just the authorization code).", + ) + } + if (expectedIssuer !== undefined && iss !== undefined && iss !== expectedIssuer) { + throw new Error(`The OAuth authorization response issuer does not match the discovered issuer for ${serverName}.`) + } + const result = await abortable(runSdkAuth(pendingAuth.authProvider, { serverUrl: pendingAuth.serverUrl, authorizationCode: code, - ...(iss !== undefined ? { iss } : {}), ...pendingAuth.discovery, }), signal) throwIfAborted(signal) @@ -585,17 +599,6 @@ export async function completeAuth( return "authenticated" } catch (error) { caughtError = error - // RFC 9207: the AS advertises authorization_response_iss_parameter_supported - // but no `iss` accompanied the pasted code (e.g. the user pasted only the - // raw code). Keep the pending flow alive so the user can re-paste the full - // redirect URL instead of restarting authentication from scratch. - if (iss === undefined && error instanceof IssuerMismatchError && error.kind === "authorization_response") { - keepPendingForRetry = true - throw new Error( - `The authorization server for ${serverName} requires the RFC 9207 "iss" parameter. ` + - "Paste the full redirect URL from the browser address bar (not just the authorization code).", - ) - } throw error } finally { if (!keepPendingForRetry) { diff --git a/mcp-auth.ts b/mcp-auth.ts index f517c313..7654db8a 100644 --- a/mcp-auth.ts +++ b/mcp-auth.ts @@ -27,7 +27,7 @@ export interface StoredTokens { refreshToken?: string; expiresAt?: number; // Unix timestamp in seconds scope?: string; - /** SEP-2352 authorization-server issuer stamp from the SDK */ + /** SEP-2352 authorization-server issuer binding */ issuer?: string; } @@ -38,7 +38,7 @@ export interface StoredClientInfo { clientIdIssuedAt?: number; clientSecretExpiresAt?: number; redirectUris?: string[]; - /** SEP-2352 authorization-server issuer stamp from the SDK */ + /** SEP-2352 authorization-server issuer binding */ issuer?: string; /** * True when this entry is a secretless SEP-2352 issuer stub persisted for a diff --git a/mcp-oauth-provider.test.ts b/mcp-oauth-provider.test.ts index 2f85a0b3..1eb86e64 100644 --- a/mcp-oauth-provider.test.ts +++ b/mcp-oauth-provider.test.ts @@ -22,8 +22,8 @@ import { type McpOAuthConfig, } from "./mcp-oauth-provider.ts" import { getAuthForUrl, saveAuthEntry } from "./mcp-auth.ts" -import { UnauthorizedError } from "@modelcontextprotocol/client" -import type { OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/client" +import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" +import type { OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js" describe("McpOAuthProvider", () => { const serverName = "test-server" diff --git a/mcp-oauth-provider.ts b/mcp-oauth-provider.ts index 93f048c6..81d907f2 100644 --- a/mcp-oauth-provider.ts +++ b/mcp-oauth-provider.ts @@ -5,15 +5,17 @@ * Handles OAuth client registration, token storage, and authorization redirection. */ -import { UnauthorizedError } from "@modelcontextprotocol/client" +import { + UnauthorizedError, + type AddClientAuthentication, + type OAuthClientProvider, + type OAuthDiscoveryState, +} from "@modelcontextprotocol/sdk/client/auth.js" import type { - AddClientAuthentication, - OAuthClientProvider, + OAuthClientInformationMixed, OAuthClientMetadata, - OAuthDiscoveryState, - StoredOAuthClientInformation, - StoredOAuthTokens, -} from "@modelcontextprotocol/client" + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js" import { getAuthForUrl, updateTokens, @@ -22,12 +24,22 @@ import { clearClientInfo, clearCodeVerifier, clearTokens, + type AuthEntry, type AuthStorageOptions, type StoredTokens, type StoredClientInfo, } from "./mcp-auth.ts" import { resolveCommandSecret } from "./utils.ts" +type IssuerBoundClientInformation = OAuthClientInformationMixed & { issuer?: string } +type IssuerBoundTokens = OAuthTokens & { issuer?: string } + +function issuersMatch(first: string, second: string): boolean { + return first === second + || (first.endsWith("/") && first.slice(0, -1) === second) + || (second.endsWith("/") && second.slice(0, -1) === first) +} + // Callback server configuration const DEFAULT_OAUTH_CALLBACK_PORT = 19876 const DEFAULT_OAUTH_CALLBACK_PATH = "/callback" @@ -90,6 +102,7 @@ export class McpOAuthProvider implements OAuthClientProvider { private flowClientInfo: StoredClientInfo | undefined private flowCodeVerifier: string | undefined private flowDiscoveryState: OAuthDiscoveryState | undefined + private flowIssuerMismatch = false private flowState: string | undefined constructor( @@ -111,10 +124,33 @@ export class McpOAuthProvider implements OAuthClientProvider { return this.config.grantType === "client_credentials" } + private get discoveredIssuer(): string | undefined { + return this.flowDiscoveryState?.authorizationServerMetadata?.issuer + ?? this.flowDiscoveryState?.authorizationServerUrl + } + deactivate(): void { this.active = false } + private assertStoredIssuerBindings(entry: AuthEntry | undefined, issuer: string | undefined): void { + if (this.flowIssuerMismatch) { + throw new Error( + `OAuth authorization server issuer changed for ${this.serverName}; clear credentials before authenticating again`, + ) + } + if (!entry || !issuer) return + + const storedIssuers = [entry.clientInfo?.issuer, entry.tokens?.issuer] + .filter((storedIssuer): storedIssuer is string => storedIssuer !== undefined) + if (storedIssuers.some(storedIssuer => !issuersMatch(storedIssuer, issuer))) { + this.flowIssuerMismatch = true + throw new Error( + `OAuth authorization server issuer changed for ${this.serverName}; clear credentials before authenticating again`, + ) + } + } + private throwIfInactive(): void { if (!this.active) throw new Error("OAuth flow is no longer active") this.runtimeSignal?.throwIfAborted() @@ -163,13 +199,25 @@ export class McpOAuthProvider implements OAuthClientProvider { * Get client information (for pre-registered or dynamically registered clients). * Returns undefined if no client info exists or if the server URL has changed. */ - async clientInformation(): Promise { - // Check config first (pre-registered client) + async clientInformation(): Promise { + const issuer = this.discoveredIssuer + const stored = await getAuthForUrl(this.serverName, this.serverUrl, this.storageOptions) + this.assertStoredIssuerBindings(stored, issuer) + + // Check config first (pre-registered client). Store only its issuer binding. + // The configured secret stays in config and never enters the credential store. if (this.config.clientId) { - // Reuse a previously stamped issuer (SEP-2352) if the SDK re-saved - // this pre-registered client with one. - const stored = await getAuthForUrl(this.serverName, this.serverUrl, this.storageOptions) - const issuer = stored?.clientInfo?.clientId === this.config.clientId ? stored.clientInfo.issuer : undefined + const storedClient = stored?.clientInfo?.clientId === this.config.clientId + ? stored.clientInfo + : undefined + if (issuer && (storedClient?.issuer !== issuer || storedClient.configPreRegistered !== true)) { + updateClientInfo( + this.serverName, + { clientId: this.config.clientId, issuer, configPreRegistered: true }, + this.serverUrl, + this.storageOptions, + ) + } const clientSecret = this.config.clientSecret?.startsWith("!") ? resolveCommandSecret( this.config.clientSecret, @@ -180,12 +228,12 @@ export class McpOAuthProvider implements OAuthClientProvider { client_id: this.config.clientId, client_secret: clientSecret, ...(issuer !== undefined ? { issuer } : {}), - } + } as IssuerBoundClientInformation } // Keep client registration associated with this in-flight flow even if // another runtime writes the shared persistent entry for the same name. - const clientInfo = this.flowClientInfo ?? (await getAuthForUrl(this.serverName, this.serverUrl, this.storageOptions))?.clientInfo + const clientInfo = this.flowClientInfo ?? stored?.clientInfo if (clientInfo) { // A stored SEP-2352 issuer stub for a config-pre-registered client // (identified by the explicit marker, or by the legacy stub shape of @@ -207,10 +255,16 @@ export class McpOAuthProvider implements OAuthClientProvider { if (clientInfo.clientSecretExpiresAt && clientInfo.clientSecretExpiresAt < Date.now() / 1000) { return undefined } - // Return all stored registration metadata so the SDK's SEP-2352 issuer - // stamp-and-resave (which round-trips clientInformation() back through - // saveClientInformation()) does not drop client_id_issued_at, - // client_secret_expires_at, or the registered redirect_uris. + if (issuer && clientInfo.issuer && !issuersMatch(clientInfo.issuer, issuer)) { + return undefined + } + if (issuer && clientInfo.issuer === undefined) { + clientInfo.issuer = issuer + this.flowClientInfo = clientInfo + updateClientInfo(this.serverName, clientInfo, this.serverUrl, this.storageOptions) + } + // Return all registration metadata and the local issuer extension. + // This keeps the SDK v1 view and the stored issuer binding consistent. return { client_id: clientInfo.clientId, client_secret: clientInfo.clientSecret, @@ -224,7 +278,7 @@ export class McpOAuthProvider implements OAuthClientProvider { ? { redirect_uris: clientInfo.redirectUris } : {}), ...(clientInfo.issuer !== undefined ? { issuer: clientInfo.issuer } : {}), - } + } as IssuerBoundClientInformation } // No client info or URL changed - will trigger dynamic registration @@ -234,16 +288,13 @@ export class McpOAuthProvider implements OAuthClientProvider { /** * Save client information from dynamic registration. */ - async saveClientInformation(info: StoredOAuthClientInformation): Promise { + async saveClientInformation(info: OAuthClientInformationMixed): Promise { this.throwIfInactive() - // Pre-registered client from config: the SDK's SEP-2352 issuer stamp - // re-saves whatever clientInformation() returned. Persist only the - // issuer binding - never copy the config-supplied client secret into - // the on-disk auth store. + const issuer = this.discoveredIssuer ?? (info as IssuerBoundClientInformation).issuer if (this.config.clientId && info.client_id === this.config.clientId) { updateClientInfo( this.serverName, - { clientId: info.client_id, issuer: info.issuer, configPreRegistered: true }, + { clientId: info.client_id, issuer, configPreRegistered: true }, this.serverUrl, this.storageOptions, ) @@ -258,7 +309,7 @@ export class McpOAuthProvider implements OAuthClientProvider { clientIdIssuedAt: info.client_id_issued_at, clientSecretExpiresAt: info.client_secret_expires_at, redirectUris, - issuer: info.issuer, + issuer, } this.flowClientInfo = clientInfo updateClientInfo(this.serverName, clientInfo, this.serverUrl, this.storageOptions) @@ -268,10 +319,16 @@ export class McpOAuthProvider implements OAuthClientProvider { * Get stored OAuth tokens. * Returns undefined if no tokens exist or if the server URL has changed. */ - async tokens(): Promise { - // Use getAuthForUrl to validate tokens are for the current server URL + async tokens(): Promise { + // Use getAuthForUrl to validate tokens are for the current server URL. const entry = await getAuthForUrl(this.serverName, this.serverUrl, this.storageOptions) if (!entry?.tokens) return undefined + const issuer = this.discoveredIssuer + this.assertStoredIssuerBindings(entry, issuer) + if (issuer && entry.tokens.issuer === undefined) { + entry.tokens.issuer = issuer + updateTokens(this.serverName, entry.tokens, this.serverUrl, this.storageOptions) + } return { access_token: entry.tokens.accessToken, @@ -282,13 +339,13 @@ export class McpOAuthProvider implements OAuthClientProvider { : undefined, scope: entry.tokens.scope, ...(entry.tokens.issuer !== undefined ? { issuer: entry.tokens.issuer } : {}), - } + } as IssuerBoundTokens } /** * Save OAuth tokens. */ - async saveTokens(tokens: StoredOAuthTokens): Promise { + async saveTokens(tokens: OAuthTokens): Promise { const storedTokens: StoredTokens = { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, @@ -297,7 +354,7 @@ export class McpOAuthProvider implements OAuthClientProvider { // being persisted as never-expiring. expiresAt: tokens.expires_in !== undefined ? Date.now() / 1000 + tokens.expires_in : undefined, scope: tokens.scope, - issuer: tokens.issuer, + issuer: this.discoveredIssuer ?? (tokens as IssuerBoundTokens).issuer, } this.throwIfInactive() updateTokens(this.serverName, storedTokens, this.serverUrl, this.storageOptions) @@ -356,8 +413,8 @@ export class McpOAuthProvider implements OAuthClientProvider { } /** - * Persist discovery with the same durability as the PKCE verifier. SDK v2 - * reads this on the callback leg to prevent authorization-code mix-up. + * Keep discovery with the in-flight PKCE verifier. The callback leg uses it + * to validate the authorization response issuer before token exchange. */ async saveDiscoveryState(state: OAuthDiscoveryState): Promise { this.throwIfInactive() @@ -405,6 +462,7 @@ export class McpOAuthProvider implements OAuthClientProvider { this.flowClientInfo = undefined this.flowCodeVerifier = undefined this.flowDiscoveryState = undefined + this.flowIssuerMismatch = false this.flowState = undefined clearAllCredentials(this.serverName, this.storageOptions) break diff --git a/oauth-handler.ts b/oauth-handler.ts index e93d056e..37a95f71 100644 --- a/oauth-handler.ts +++ b/oauth-handler.ts @@ -1,5 +1,5 @@ // oauth-handler.ts - OAuth token compatibility helpers for MCP servers -import type { OAuthTokens } from "@modelcontextprotocol/client"; +import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; import { getAuthEntry } from "./mcp-auth.ts"; /** diff --git a/package-lock.json b/package-lock.json index d3ebdb7c..689c279b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,11 @@ "version": "2.15.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/client": "2.0.0-beta.5", "@modelcontextprotocol/ext-apps": "^1.2.2", "@modelcontextprotocol/sdk": "^1.29.0", "@napi-rs/keyring": "^1.3.0", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "cross-spawn": "^7.0.6", "open": "^10.2.0", "recheck": "^4.5.0", @@ -28,7 +29,6 @@ "@earendil-works/pi-coding-agent": "0.79.10", "@earendil-works/pi-tui": "0.74.2", "@modelcontextprotocol/conformance": "0.1.16", - "@modelcontextprotocol/server": "2.0.0-beta.5", "@types/bun": "^1.0.0", "@types/node": "^20.0.0", "@types/open": "^6.2.1", @@ -2953,24 +2953,6 @@ } } }, - "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.5.tgz", - "integrity": "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg==", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "jose": "^6.1.3", - "pkce-challenge": "^5.0.0", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/@modelcontextprotocol/conformance": { "version": "0.1.16", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.1.16.tgz", @@ -2992,18 +2974,6 @@ "conformance": "dist/index.js" } }, - "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.5.tgz", - "integrity": "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew==", - "license": "MIT", - "dependencies": { - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/@modelcontextprotocol/ext-apps": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.4.tgz", @@ -3073,20 +3043,6 @@ } } }, - "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", - "integrity": "sha512-i1E5l75rQKsgY/AKAIspgMBH1vEL7dqiK7tHr0L+raYcb0SWOziqNGJXGIG6NY4AlXDWIKGJQGB7Nqfs3oUi5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/@napi-rs/keyring": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", diff --git a/package.json b/package.json index f4c32a7f..7f2c1d7f 100644 --- a/package.json +++ b/package.json @@ -110,10 +110,11 @@ "LICENSE" ], "dependencies": { - "@modelcontextprotocol/client": "2.0.0-beta.5", "@modelcontextprotocol/ext-apps": "^1.2.2", "@modelcontextprotocol/sdk": "^1.29.0", "@napi-rs/keyring": "^1.3.0", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "cross-spawn": "^7.0.6", "open": "^10.2.0", "recheck": "^4.5.0", @@ -143,7 +144,6 @@ "@earendil-works/pi-coding-agent": "0.79.10", "@earendil-works/pi-tui": "0.74.2", "@modelcontextprotocol/conformance": "0.1.16", - "@modelcontextprotocol/server": "2.0.0-beta.5", "@types/bun": "^1.0.0", "@types/node": "^20.0.0", "@types/open": "^6.2.1", diff --git a/prompts.ts b/prompts.ts index bec607af..f5417551 100644 --- a/prompts.ts +++ b/prompts.ts @@ -2,7 +2,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c import type { GetPromptResult, PromptMessage, -} from "@modelcontextprotocol/client"; +} from "@modelcontextprotocol/sdk/types.js"; import type { McpExtensionState } from "./state.ts"; import { isServerDisabled, type McpConfig, type PromptMetadata } from "./types.ts"; import { formatPromptCommandName } from "./types.ts"; diff --git a/proxy-modes.ts b/proxy-modes.ts index 149b0cd9..a1dd974c 100644 --- a/proxy-modes.ts +++ b/proxy-modes.ts @@ -1,5 +1,5 @@ import type { AgentToolResult, ToolInfo } from "@earendil-works/pi-coding-agent"; -import { UrlElicitationRequiredError } from "@modelcontextprotocol/client"; +import { UrlElicitationRequiredError } from "@modelcontextprotocol/sdk/types.js"; import { createRequire } from "node:module"; import type { McpExtensionState } from "./state.ts"; import type { ToolMetadata, McpContent } from "./types.ts"; @@ -1078,11 +1078,11 @@ export async function executeCall( name: toolMeta.originalName, arguments: args ?? {}, _meta: uiSession?.requestMeta, - }, requestOptions), ownedSignal), + }, undefined, requestOptions), ownedSignal), ); if (toolMeta.uiResourceUri) { - uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/client").CallToolResult); + uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/sdk/types.js").CallToolResult); if (result.isError) { const mcpContent = (result.content ?? []) as McpContent[]; diff --git a/sampling-handler.ts b/sampling-handler.ts index 66bb67c6..340b6ae8 100644 --- a/sampling-handler.ts +++ b/sampling-handler.ts @@ -2,14 +2,15 @@ import { complete, type Api, type AssistantMessage, type Message, type Model, ty import { truncateAtWord } from "./utils.ts"; import { throwIfAborted } from "./abort.ts"; import type { ExtensionUIContext, ModelRegistry } from "@earendil-works/pi-coding-agent"; -import type { - Client, - CreateMessageRequest, - CreateMessageResult, - ModelPreferences, - SamplingMessage, - SamplingMessageContentBlock, -} from "@modelcontextprotocol/client"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { + CreateMessageRequestSchema, + type CreateMessageRequest, + type CreateMessageResult, + type ModelPreferences, + type SamplingMessage, + type SamplingMessageContentBlock, +} from "@modelcontextprotocol/sdk/types.js"; export interface SamplingHandlerOptions { serverName: string; @@ -23,8 +24,8 @@ export interface SamplingHandlerOptions { export type ServerSamplingConfig = Omit; export function registerSamplingHandler(client: Client, options: SamplingHandlerOptions): void { - client.setRequestHandler("sampling/createMessage", (request) => { - return handleSamplingRequest(options, request as CreateMessageRequest); + client.setRequestHandler(CreateMessageRequestSchema, request => { + return handleSamplingRequest(options, request); }); } diff --git a/server-manager.ts b/server-manager.ts index 7c55888f..05c8a1f9 100644 --- a/server-manager.ts +++ b/server-manager.ts @@ -1,14 +1,18 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { - Client, - SSEClientTransport, StreamableHTTPClientTransport, - SdkHttpError, - UnauthorizedError, - type RequestOptions, + StreamableHTTPError, +} from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import { + ElicitationCompleteNotificationSchema, + type GetPromptResult, type ReadResourceResult, type UrlElicitationRequiredError, -} from "@modelcontextprotocol/client"; -import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +} from "@modelcontextprotocol/sdk/types.js"; import { UnixSocketClientTransport } from "./unix-socket-transport.ts"; import { isServerDisabled, @@ -19,8 +23,8 @@ import { type ServerStreamResultPatchNotification, type Transport, type McpTraceSettings, + serverStreamResultPatchNotificationSchema, } from "./types.ts"; -import { SERVER_STREAM_RESULT_PATCH_METHOD, serverStreamResultPatchNotificationSchema } from "./types.ts"; import { resolveNpxBinary } from "./npx-resolver.ts"; import { createJsonSchemaValidator } from "./json-schema-validator.ts"; import { logger } from "./logger.ts"; @@ -53,10 +57,6 @@ import { const MAX_CAPTURED_STDERR_BYTES = 8 * 1024; const MAX_CAPTURED_STDERR_LINES = 3; -const MCP_CLIENT_OPTIONS = { - versionNegotiation: { mode: "auto" as const }, - inputRequired: { autoFulfill: true }, -}; const abortCleanupPromises = new WeakMap>(); type HttpAuthProviderState = @@ -66,7 +66,7 @@ type HttpAuthProviderState = | { status: "implicit-challenged"; provider: McpOAuthProvider }; function isUnauthorizedHttpError(error: unknown): boolean { - return error instanceof UnauthorizedError || (error instanceof SdkHttpError && error.status === 401); + return error instanceof UnauthorizedError || (error instanceof StreamableHTTPError && error.code === 401); } function boundedStderrChunk(chunk: Buffer | string): Buffer { @@ -507,7 +507,6 @@ export class McpServerManager { client = new Client( { name: `pi-mcp-${serverName}`, version: "1.0.0" }, { - ...MCP_CLIENT_OPTIONS, jsonSchemaValidator: createJsonSchemaValidator(), ...(Object.keys(capabilities).length > 0 ? { capabilities } : {}), listChanged: { @@ -539,7 +538,7 @@ export class McpServerManager { onUrlAccepted: elicitationId => this.rememberUrlElicitation(serverName, elicitationId), }); if (this.elicitationConfig.allowUrl) { - client.setNotificationHandler("notifications/elicitation/complete", notification => { + client.setNotificationHandler(ElicitationCompleteNotificationSchema, notification => { if (this.runtimeSignal?.aborted) return; const accepted = this.acceptedUrlElicitations.get(serverName); if (!accepted?.delete(notification.params.elicitationId)) return; @@ -702,10 +701,7 @@ export class McpServerManager { const probeTransport = traceObserver ? wrapTransportWithMcpTrace(streamableTransport, serverName, "streamable-http", traceObserver) : streamableTransport; - const testClient = new Client( - { name: "pi-mcp-probe", version: "2.1.2" }, - MCP_CLIENT_OPTIONS, - ); + const testClient = new Client({ name: "pi-mcp-probe", version: "2.1.2" }); let probeCleanupAttempted = false; try { await this.connectClientWithAbort( @@ -829,15 +825,11 @@ export class McpServerManager { } private attachAdapterNotificationHandlers(serverName: string, client: Client): void { - client.setNotificationHandler( - SERVER_STREAM_RESULT_PATCH_METHOD, - { params: serverStreamResultPatchNotificationSchema.shape.params }, - (params) => { - const listener = this.uiStreamListeners.get(params.streamToken); - if (!listener) return; - listener(serverName, params); - }, - ); + client.setNotificationHandler(serverStreamResultPatchNotificationSchema, notification => { + const listener = this.uiStreamListeners.get(notification.params.streamToken); + if (!listener) return; + listener(serverName, notification.params); + }); } registerUiStreamListener(streamToken: string, listener: UiStreamListener): void { @@ -853,7 +845,7 @@ export class McpServerManager { promptName: string, args?: Record, signal?: AbortSignal, - ): Promise { + ): Promise { const connection = this.connections.get(name); if (!connection || connection.status !== "connected") { throw new Error(`Server "${name}" is not connected`); diff --git a/session-recovery.ts b/session-recovery.ts index 0ac93f2c..ff6695ee 100644 --- a/session-recovery.ts +++ b/session-recovery.ts @@ -20,7 +20,8 @@ // many things other than "your session is gone" // - treat generic -32000/ConnectionClosed errors as session expiry // - treat AbortError/cancellation as a session failure -import { ProtocolError, SdkHttpError } from "@modelcontextprotocol/client"; +import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; import { logger } from "./logger.ts"; import { throwIfAborted } from "./abort.ts"; import { isServerDisabled, type McpConfig } from "./types.ts"; @@ -33,32 +34,25 @@ import type { McpServerManager, ServerConnection } from "./server-manager.ts"; * gate response some servers emit before dispatching to a handler. * * `hadSessionId` must reflect the transport's session id from *before* the - * call that produced `err` was made. The installed SDK (2.0.0-beta.5) only - * clears `transport.sessionId` on an explicit `terminateSession()`, so - * checking it at catch time currently agrees with checking it up front — but - * callers should capture it before the call rather than rely on that - * incidental behavior. + * call that produced `err` was made. The installed SDK (1.29.0) does not + * clear `transport.sessionId` on a 404 response, so callers must capture it + * before the call rather than rely on catch-time transport state. */ -// JSON-RPC "connection closed" code emitted by servers as -32000 (the SDK v1 -// `ErrorCode.ConnectionClosed`; v2's `ProtocolErrorCode` no longer names it, -// but peers still send it on the wire). -const CONNECTION_CLOSED_CODE = -32000; - const SERVER_NOT_INITIALIZED_MCP_MESSAGES = new Set([ - "Server not initialized", - "Bad Request: Server not initialized", + `MCP error ${ErrorCode.ConnectionClosed}: Server not initialized`, + `MCP error ${ErrorCode.ConnectionClosed}: Bad Request: Server not initialized`, ]); export function isTerminatedSession(err: unknown, hadSessionId: boolean): boolean { if (!hadSessionId) return false; - if (err instanceof SdkHttpError) { - return err.status === 404 - || (err.status === 400 + if (err instanceof StreamableHTTPError) { + return err.code === 404 + || (err.code === 400 && /"code"\s*:\s*-32000/.test(err.message) && /"message"\s*:\s*"Bad Request: Server not initialized"/.test(err.message)); } - return err instanceof ProtocolError - && err.code === CONNECTION_CLOSED_CODE + return err instanceof McpError + && err.code === ErrorCode.ConnectionClosed && SERVER_NOT_INITIALIZED_MCP_MESSAGES.has(err.message); } diff --git a/types.ts b/types.ts index c5262833..62477182 100644 --- a/types.ts +++ b/types.ts @@ -1,5 +1,5 @@ // types.ts - Core type definitions -import type { Transport as McpTransport } from "@modelcontextprotocol/client"; +import type { Transport as McpTransport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { TextContent, ImageContent } from "@earendil-works/pi-ai"; import type { UiStreamMode } from "./ui-stream-types.ts"; diff --git a/ui-resource-handler.ts b/ui-resource-handler.ts index 7a327be3..96702ebf 100644 --- a/ui-resource-handler.ts +++ b/ui-resource-handler.ts @@ -1,5 +1,5 @@ import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/app-bridge"; -import { UrlElicitationRequiredError, type ReadResourceResult } from "@modelcontextprotocol/client"; +import { UrlElicitationRequiredError, type ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; import { ResourceFetchError, ResourceParseError } from "./errors.ts"; import { logger } from "./logger.ts"; import { SessionRecoveryAuthRequiredError, withSessionRecovery, type SessionRecoveryDeps } from "./session-recovery.ts"; diff --git a/ui-server.ts b/ui-server.ts index a0370959..12b05943 100644 --- a/ui-server.ts +++ b/ui-server.ts @@ -6,7 +6,7 @@ import { buildAllowAttribute } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { CallToolRequest, CallToolResult, -} from "@modelcontextprotocol/client"; +} from "@modelcontextprotocol/sdk/types.js"; import type { ConsentManager } from "./consent-manager.ts"; import { ServerError, wrapError } from "./errors.ts"; import { formatAuthRequiredMessage } from "./utils.ts"; @@ -369,9 +369,9 @@ export async function startUiServer(options: UiServerOptions): Promise conn.client.callTool(callArgs, options.manager.getRequestOptions?.(options.serverName)), + (conn) => conn.client.callTool(callArgs, undefined, options.manager.getRequestOptions?.(options.serverName)), ) - : await connection.client.callTool(callArgs, options.manager.getRequestOptions?.(options.serverName)); + : await connection.client.callTool(callArgs, undefined, options.manager.getRequestOptions?.(options.serverName)); sendJson(res, 200, { ok: true, result }); } finally { options.manager.decrementInFlight(options.serverName); diff --git a/ui-session.ts b/ui-session.ts index 6bc8460d..768af371 100644 --- a/ui-session.ts +++ b/ui-session.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { UrlElicitationRequiredError, type CallToolResult } from "@modelcontextprotocol/client"; +import { UrlElicitationRequiredError, type CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { McpExtensionState } from "./state.ts"; import { extractUiPromptText, diff --git a/unix-socket-transport.ts b/unix-socket-transport.ts index e178d367..06679c5d 100644 --- a/unix-socket-transport.ts +++ b/unix-socket-transport.ts @@ -1,10 +1,7 @@ import { createConnection, type Socket } from "node:net"; -import { - ReadBuffer, - serializeMessage, - type JSONRPCMessage, - type Transport, -} from "@modelcontextprotocol/client"; +import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; /** MCP JSONL transport for an explicitly configured Unix-domain socket. */ export class UnixSocketClientTransport implements Transport {