From d16e89df1ecfe0f92741dfcb565b9a30ed2f5e0a Mon Sep 17 00:00:00 2001 From: olaservo Date: Tue, 29 Apr 2025 21:32:21 -0700 Subject: [PATCH 01/65] Preserve original URL --- cli/src/transport.ts | 4 ++++ client/src/lib/hooks/useConnection.ts | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/cli/src/transport.ts b/cli/src/transport.ts index e693f246..5df44f30 100644 --- a/cli/src/transport.ts +++ b/cli/src/transport.ts @@ -15,7 +15,11 @@ export type TransportOptions = { function createSSETransport(options: TransportOptions): Transport { const baseUrl = new URL(options.url ?? ""); + // Create new URL while preserving the port from baseUrl const sseUrl = new URL("/sse", baseUrl); + if (baseUrl.port) { + sseUrl.port = baseUrl.port; + } return new SSEClientTransport(sseUrl); } diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 27bc11a2..98ae0447 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -280,6 +280,8 @@ export function useConnection({ return; } let mcpProxyServerUrl; + const originalUrl = new URL(sseUrl); + switch (transportType) { case "stdio": mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`); @@ -290,12 +292,12 @@ export function useConnection({ case "sse": mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`); - mcpProxyServerUrl.searchParams.append("url", sseUrl); + mcpProxyServerUrl.searchParams.append("url", originalUrl.toString()); break; case "streamable-http": mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`); - mcpProxyServerUrl.searchParams.append("url", sseUrl); + mcpProxyServerUrl.searchParams.append("url", originalUrl.toString()); break; } (mcpProxyServerUrl as URL).searchParams.append( From 0838ffde38098ecd84699d94dfd767caa6af3766 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 30 Apr 2025 05:03:00 -0700 Subject: [PATCH 02/65] Update tests --- .../hooks/__tests__/useConnection.test.tsx | 88 ++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index e191d6c0..debab46d 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -28,10 +28,18 @@ jest.mock("@modelcontextprotocol/sdk/client/index.js", () => ({ })); jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({ - SSEClientTransport: jest.fn(), + SSEClientTransport: jest.fn((url) => ({ + toString: () => url, + })), SseError: jest.fn(), })); +jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ + StreamableHTTPClientTransport: jest.fn((url) => ({ + toString: () => url, + })), +})); + jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ auth: jest.fn().mockResolvedValue("AUTHORIZED"), })); @@ -163,4 +171,82 @@ describe("useConnection", () => { result.current.makeRequest(mockRequest, mockSchema), ).rejects.toThrow("MCP client not connected"); }); + + describe("URL Port Handling", () => { + const SSEClientTransport = jest.requireMock("@modelcontextprotocol/sdk/client/sse.js").SSEClientTransport; + const StreamableHTTPClientTransport = jest.requireMock("@modelcontextprotocol/sdk/client/streamableHttp.js").StreamableHTTPClientTransport; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("preserves HTTPS port number when connecting", async () => { + const props = { + ...defaultProps, + sseUrl: "https://example.com:8443/api", + transportType: "sse" as const, + }; + + const { result } = renderHook(() => useConnection(props)); + + await act(async () => { + await result.current.connect(); + }); + + const call = SSEClientTransport.mock.calls[0][0]; + expect(call.toString()).toContain("url=https%3A%2F%2Fexample.com%3A8443%2Fapi"); + }); + + test("preserves HTTP port number when connecting", async () => { + const props = { + ...defaultProps, + sseUrl: "http://localhost:3000/api", + transportType: "sse" as const, + }; + + const { result } = renderHook(() => useConnection(props)); + + await act(async () => { + await result.current.connect(); + }); + + const call = SSEClientTransport.mock.calls[0][0]; + expect(call.toString()).toContain("url=http%3A%2F%2Flocalhost%3A3000%2Fapi"); + }); + + test("uses default port for HTTPS when not specified", async () => { + const props = { + ...defaultProps, + sseUrl: "https://example.com/api", + transportType: "sse" as const, + }; + + const { result } = renderHook(() => useConnection(props)); + + await act(async () => { + await result.current.connect(); + }); + + const call = SSEClientTransport.mock.calls[0][0]; + expect(call.toString()).toContain("url=https%3A%2F%2Fexample.com%2Fapi"); + expect(call.toString()).not.toContain("%3A443"); + }); + + test("preserves port number in streamable-http transport", async () => { + const props = { + ...defaultProps, + sseUrl: "https://example.com:8443/api", + transportType: "streamable-http" as const, + }; + + const { result } = renderHook(() => useConnection(props)); + + await act(async () => { + await result.current.connect(); + }); + + const call = StreamableHTTPClientTransport.mock.calls[0][0]; + expect(call.toString()).toContain("url=https%3A%2F%2Fexample.com%3A8443%2Fapi"); + }); + }); }); From 7bf363cca9ac425a7b301580962bbbefada1b8f3 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 30 Apr 2025 05:05:23 -0700 Subject: [PATCH 03/65] Remove unnecessary comment --- cli/src/transport.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/cli/src/transport.ts b/cli/src/transport.ts index 5df44f30..f3a1bec9 100644 --- a/cli/src/transport.ts +++ b/cli/src/transport.ts @@ -15,7 +15,6 @@ export type TransportOptions = { function createSSETransport(options: TransportOptions): Transport { const baseUrl = new URL(options.url ?? ""); - // Create new URL while preserving the port from baseUrl const sseUrl = new URL("/sse", baseUrl); if (baseUrl.port) { sseUrl.port = baseUrl.port; From fe827426ae748b3c5cc816d5c28bd3309e72a1a4 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 30 Apr 2025 05:16:00 -0700 Subject: [PATCH 04/65] Revert re-parsing url --- client/src/lib/hooks/useConnection.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 98ae0447..34388b8e 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -280,7 +280,6 @@ export function useConnection({ return; } let mcpProxyServerUrl; - const originalUrl = new URL(sseUrl); switch (transportType) { case "stdio": @@ -292,12 +291,12 @@ export function useConnection({ case "sse": mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`); - mcpProxyServerUrl.searchParams.append("url", originalUrl.toString()); + mcpProxyServerUrl.searchParams.append("url", sseUrl); break; case "streamable-http": mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`); - mcpProxyServerUrl.searchParams.append("url", originalUrl.toString()); + mcpProxyServerUrl.searchParams.append("url", sseUrl); break; } (mcpProxyServerUrl as URL).searchParams.append( From f641aa047099eae4cd1d5b7d6c77b36dbffedf0a Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 30 Apr 2025 05:16:42 -0700 Subject: [PATCH 05/65] Revert re-parsing url --- client/src/lib/hooks/useConnection.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 34388b8e..27bc11a2 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -280,7 +280,6 @@ export function useConnection({ return; } let mcpProxyServerUrl; - switch (transportType) { case "stdio": mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`); From 24e1aaefd170572031bb6b4c23c840395e8d27cb Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 30 Apr 2025 06:09:35 -0700 Subject: [PATCH 06/65] Remove unnecessary explicit port setting --- cli/src/transport.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/cli/src/transport.ts b/cli/src/transport.ts index f3a1bec9..e693f246 100644 --- a/cli/src/transport.ts +++ b/cli/src/transport.ts @@ -16,9 +16,6 @@ export type TransportOptions = { function createSSETransport(options: TransportOptions): Transport { const baseUrl = new URL(options.url ?? ""); const sseUrl = new URL("/sse", baseUrl); - if (baseUrl.port) { - sseUrl.port = baseUrl.port; - } return new SSEClientTransport(sseUrl); } From 7611227fd4be86e9fb78342e8640740d17b5e009 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 30 Apr 2025 06:19:44 -0700 Subject: [PATCH 07/65] Fix formatting --- .../hooks/__tests__/useConnection.test.tsx | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index debab46d..d1dae99d 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -173,8 +173,12 @@ describe("useConnection", () => { }); describe("URL Port Handling", () => { - const SSEClientTransport = jest.requireMock("@modelcontextprotocol/sdk/client/sse.js").SSEClientTransport; - const StreamableHTTPClientTransport = jest.requireMock("@modelcontextprotocol/sdk/client/streamableHttp.js").StreamableHTTPClientTransport; + const SSEClientTransport = jest.requireMock( + "@modelcontextprotocol/sdk/client/sse.js", + ).SSEClientTransport; + const StreamableHTTPClientTransport = jest.requireMock( + "@modelcontextprotocol/sdk/client/streamableHttp.js", + ).StreamableHTTPClientTransport; beforeEach(() => { jest.clearAllMocks(); @@ -194,7 +198,9 @@ describe("useConnection", () => { }); const call = SSEClientTransport.mock.calls[0][0]; - expect(call.toString()).toContain("url=https%3A%2F%2Fexample.com%3A8443%2Fapi"); + expect(call.toString()).toContain( + "url=https%3A%2F%2Fexample.com%3A8443%2Fapi", + ); }); test("preserves HTTP port number when connecting", async () => { @@ -211,7 +217,9 @@ describe("useConnection", () => { }); const call = SSEClientTransport.mock.calls[0][0]; - expect(call.toString()).toContain("url=http%3A%2F%2Flocalhost%3A3000%2Fapi"); + expect(call.toString()).toContain( + "url=http%3A%2F%2Flocalhost%3A3000%2Fapi", + ); }); test("uses default port for HTTPS when not specified", async () => { @@ -246,7 +254,9 @@ describe("useConnection", () => { }); const call = StreamableHTTPClientTransport.mock.calls[0][0]; - expect(call.toString()).toContain("url=https%3A%2F%2Fexample.com%3A8443%2Fapi"); + expect(call.toString()).toContain( + "url=https%3A%2F%2Fexample.com%3A8443%2Fapi", + ); }); }); }); From 89ab90df81c4886c02cf3e853aa540760c6fa473 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Apr 2025 17:58:43 +0000 Subject: [PATCH 08/65] Bump vite in the npm_and_yarn group across 1 directory Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). Updates `vite` from 6.3.0 to 6.3.4 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v6.3.4/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 6.3.4 dependency-type: direct:development dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- package-lock.json | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7a10da69..57863e1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7768,13 +7768,13 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz", - "integrity": "sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==", + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.3", + "fdir": "^6.4.4", "picomatch": "^4.0.2" }, "engines": { @@ -7785,9 +7785,9 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.3.tgz", - "integrity": "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==", + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -8225,18 +8225,18 @@ } }, "node_modules/vite": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.0.tgz", - "integrity": "sha512-9aC0n4pr6hIbvi1YOpFjwQ+QOTGssvbJKoeYkuHHGWwlXfdxQlI8L2qNMo9awEEcCPSiS+5mJZk5jH1PAqoDeQ==", + "version": "6.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.4.tgz", + "integrity": "sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.3", + "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", - "tinyglobby": "^0.2.12" + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" @@ -8300,9 +8300,9 @@ } }, "node_modules/vite/node_modules/fdir": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.3.tgz", - "integrity": "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==", + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", "dev": true, "license": "MIT", "peerDependencies": { From fa236ea2c7bf519ab40f6dcef8a7e9fdb4bbe665 Mon Sep 17 00:00:00 2001 From: Shane Date: Tue, 6 May 2025 15:36:18 -0700 Subject: [PATCH 09/65] handle the case that client disconnects so that the server does not crash --- server/src/index.ts | 62 ++++++++++++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index c967b60c..587911bb 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -141,7 +141,7 @@ let backingServerTransport: Transport | undefined; app.get("/mcp", async (req, res) => { const sessionId = req.headers["mcp-session-id"] as string; - console.log(`Received GET message for sessionId ${sessionId}`); + console.log(`GET /mcp for sessionId ${sessionId}`); try { const transport = webAppTransports.get( sessionId, @@ -160,7 +160,7 @@ app.get("/mcp", async (req, res) => { app.post("/mcp", async (req, res) => { const sessionId = req.headers["mcp-session-id"] as string | undefined; - console.log(`Received POST message for sessionId ${sessionId}`); + console.log(`POST /mcp for sessionId ${sessionId}`); if (!sessionId) { try { console.log("New streamable-http connection"); @@ -228,7 +228,7 @@ app.post("/mcp", async (req, res) => { app.get("/stdio", async (req, res) => { try { - console.log("New connection"); + console.log("GET /stdio"); try { await backingServerTransport?.close(); @@ -254,18 +254,44 @@ app.get("/stdio", async (req, res) => { console.log("Created web app transport"); await webAppTransport.start(); - (backingServerTransport as StdioClientTransport).stderr!.on( - "data", - (chunk) => { - webAppTransport.send({ - jsonrpc: "2.0", - method: "notifications/stderr", - params: { - content: chunk.toString(), - }, - }); - }, - ); + + // Handle client disconnection + res.on('close', () => { + console.log(`Client disconnected from session ${webAppTransport.sessionId}`); + // Clean up the transport map + webAppTransports.delete(webAppTransport.sessionId); + }); + + // Create a stderr handler that checks connection state + const stderrHandler = (chunk: Buffer) => { + try { + // Only send if the transport exists in our map (meaning it's still active) + if (webAppTransports.has(webAppTransport.sessionId)) { + webAppTransport.send({ + jsonrpc: "2.0", + method: "notifications/stderr", + params: { + content: chunk.toString(), + }, + }); + } + } catch (error: any) { + console.log(`Error sending stderr data to client: ${error.message}`); + // If we hit an error sending, clean up the transport + webAppTransports.delete(webAppTransport.sessionId); + } + }; + + if ((backingServerTransport as StdioClientTransport).stderr) { + (backingServerTransport as StdioClientTransport).stderr!.on("data", stderrHandler); + + // Store the handler reference so we can remove it when client disconnects + res.on('close', () => { + if ((backingServerTransport as StdioClientTransport).stderr) { + (backingServerTransport as StdioClientTransport).stderr!.removeListener("data", stderrHandler); + } + }); + } mcpProxy({ transportToClient: webAppTransport, @@ -282,7 +308,7 @@ app.get("/stdio", async (req, res) => { app.get("/sse", async (req, res) => { try { console.log( - "New SSE connection. NOTE: The sse transport is deprecated and has been replaced by streamable-http", + "GET /sse (NOTE: The sse transport is deprecated and has been replaced by streamable-http)", ); try { @@ -324,7 +350,7 @@ app.get("/sse", async (req, res) => { app.post("/message", async (req, res) => { try { const sessionId = req.query.sessionId; - console.log(`Received message for sessionId ${sessionId}`); + console.log(`POST /message for sessionId ${sessionId}`); const transport = webAppTransports.get( sessionId as string, @@ -341,6 +367,7 @@ app.post("/message", async (req, res) => { }); app.get("/health", (req, res) => { + console.log("GET /health"); res.json({ status: "ok", }); @@ -348,6 +375,7 @@ app.get("/health", (req, res) => { app.get("/config", (req, res) => { try { + console.log("GET /config"); res.json({ defaultEnvironment, defaultCommand: values.env, From b9b3682350bf3ae45636a9704ff501fdcfcab870 Mon Sep 17 00:00:00 2001 From: Shane Date: Tue, 6 May 2025 15:44:58 -0700 Subject: [PATCH 10/65] format fix --- server/src/index.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 587911bb..afd30243 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -254,14 +254,16 @@ app.get("/stdio", async (req, res) => { console.log("Created web app transport"); await webAppTransport.start(); - + // Handle client disconnection - res.on('close', () => { - console.log(`Client disconnected from session ${webAppTransport.sessionId}`); + res.on("close", () => { + console.log( + `Client disconnected from session ${webAppTransport.sessionId}`, + ); // Clean up the transport map webAppTransports.delete(webAppTransport.sessionId); }); - + // Create a stderr handler that checks connection state const stderrHandler = (chunk: Buffer) => { try { @@ -281,14 +283,19 @@ app.get("/stdio", async (req, res) => { webAppTransports.delete(webAppTransport.sessionId); } }; - + if ((backingServerTransport as StdioClientTransport).stderr) { - (backingServerTransport as StdioClientTransport).stderr!.on("data", stderrHandler); + (backingServerTransport as StdioClientTransport).stderr!.on( + "data", + stderrHandler, + ); // Store the handler reference so we can remove it when client disconnects - res.on('close', () => { + res.on("close", () => { if ((backingServerTransport as StdioClientTransport).stderr) { - (backingServerTransport as StdioClientTransport).stderr!.removeListener("data", stderrHandler); + ( + backingServerTransport as StdioClientTransport + ).stderr!.removeListener("data", stderrHandler); } }); } From 7df1936b3c7125600420808fc92fead1265f53c6 Mon Sep 17 00:00:00 2001 From: Shane Date: Sun, 18 May 2025 23:33:19 -0700 Subject: [PATCH 11/65] fixed the error handling in async call --- server/src/index.ts | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index afd30243..3ea88f44 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -266,21 +266,20 @@ app.get("/stdio", async (req, res) => { // Create a stderr handler that checks connection state const stderrHandler = (chunk: Buffer) => { - try { - // Only send if the transport exists in our map (meaning it's still active) - if (webAppTransports.has(webAppTransport.sessionId)) { - webAppTransport.send({ - jsonrpc: "2.0", - method: "notifications/stderr", - params: { - content: chunk.toString(), - }, - }); - } - } catch (error: any) { - console.log(`Error sending stderr data to client: ${error.message}`); - // If we hit an error sending, clean up the transport - webAppTransports.delete(webAppTransport.sessionId); + // Only send if the transport exists in our map (meaning it's still active) + if (webAppTransports.has(webAppTransport.sessionId)) { + webAppTransport.send({ + jsonrpc: "2.0", + method: "notifications/stderr", + params: { + content: chunk.toString(), + }, + }) + .catch((error: any) => { + console.error(`Error sending stderr data to client: ${error.message}`); + // If we hit an error sending, clean up the transport + webAppTransports.delete(webAppTransport.sessionId); + }); } }; From 618f24a9acb25222b01dec19e773362bb36c7abb Mon Sep 17 00:00:00 2001 From: xiaoli Date: Tue, 13 May 2025 23:38:19 +0800 Subject: [PATCH 12/65] feat: support custom authorization headers, fix #395 --- client/src/lib/hooks/useConnection.ts | 7 ++++++- server/src/index.ts | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 4ced90e4..501c8359 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -297,7 +297,12 @@ export function useConnection({ bearerToken || (await serverAuthProvider.tokens())?.access_token; if (token) { const authHeaderName = headerName || "Authorization"; - headers[authHeaderName] = `Bearer ${token}`; + headers[authHeaderName] = token; + + // Add custom header name as a special request header to let the server know which header to pass through + if (headerName && headerName.toLowerCase() !== "authorization") { + headers["x-custom-auth-header"] = headerName; + } } // Create appropriate transport diff --git a/server/src/index.ts b/server/src/index.ts index c967b60c..766d2df4 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -91,6 +91,15 @@ const createTransport = async (req: express.Request): Promise => { const value = req.headers[key]; headers[key] = Array.isArray(value) ? value[value.length - 1] : value; } + + // If the header "x-custom-auth-header" is present, use its value as the custom header name. + if (req.headers["x-custom-auth-header"] !== undefined) { + const customHeaderName = req.headers["x-custom-auth-header"] as string; + if (req.headers[customHeaderName.toLowerCase()] !== undefined) { + const value = req.headers[customHeaderName.toLowerCase()]; + headers[customHeaderName] = Array.isArray(value) ? value[value.length - 1] : value as string; + } + } console.log(`SSE transport: url=${url}, headers=${Object.keys(headers)}`); @@ -119,6 +128,15 @@ const createTransport = async (req: express.Request): Promise => { const value = req.headers[key]; headers[key] = Array.isArray(value) ? value[value.length - 1] : value; } + + // If the header "x-custom-auth-header" is present, use its value as the custom header name. + if (req.headers["x-custom-auth-header"] !== undefined) { + const customHeaderName = req.headers["x-custom-auth-header"] as string; + if (req.headers[customHeaderName.toLowerCase()] !== undefined) { + const value = req.headers[customHeaderName.toLowerCase()]; + headers[customHeaderName] = Array.isArray(value) ? value[value.length - 1] : value as string; + } + } const transport = new StreamableHTTPClientTransport( new URL(query.url as string), From 13f736033a98b4502af2b7d47cf630fde1e72b3a Mon Sep 17 00:00:00 2001 From: xiaoli Date: Tue, 20 May 2025 13:10:55 +0800 Subject: [PATCH 13/65] fix: fix review comments - Avoid calling .lowerCase twice - Keep 'Bearer ' prefix when header equals 'Authorization' - Remove unnecessary Array type checks --- client/src/lib/hooks/useConnection.ts | 10 +++++++--- server/src/index.ts | 12 ++++++------ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 501c8359..f1f7ab9e 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -297,11 +297,15 @@ export function useConnection({ bearerToken || (await serverAuthProvider.tokens())?.access_token; if (token) { const authHeaderName = headerName || "Authorization"; - headers[authHeaderName] = token; // Add custom header name as a special request header to let the server know which header to pass through - if (headerName && headerName.toLowerCase() !== "authorization") { - headers["x-custom-auth-header"] = headerName; + if (authHeaderName.toLowerCase() !== "authorization") { + headers[authHeaderName] = token; + if (headerName) { + headers["x-custom-auth-header"] = headerName; + } + } else { + headers[authHeaderName] = `Bearer ${token}`; } } diff --git a/server/src/index.ts b/server/src/index.ts index 766d2df4..fd28a807 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -95,9 +95,9 @@ const createTransport = async (req: express.Request): Promise => { // If the header "x-custom-auth-header" is present, use its value as the custom header name. if (req.headers["x-custom-auth-header"] !== undefined) { const customHeaderName = req.headers["x-custom-auth-header"] as string; - if (req.headers[customHeaderName.toLowerCase()] !== undefined) { - const value = req.headers[customHeaderName.toLowerCase()]; - headers[customHeaderName] = Array.isArray(value) ? value[value.length - 1] : value as string; + if (req.headers[customHeaderName] !== undefined) { + const value = req.headers[customHeaderName]; + headers[customHeaderName] = value as string; } } @@ -132,9 +132,9 @@ const createTransport = async (req: express.Request): Promise => { // If the header "x-custom-auth-header" is present, use its value as the custom header name. if (req.headers["x-custom-auth-header"] !== undefined) { const customHeaderName = req.headers["x-custom-auth-header"] as string; - if (req.headers[customHeaderName.toLowerCase()] !== undefined) { - const value = req.headers[customHeaderName.toLowerCase()]; - headers[customHeaderName] = Array.isArray(value) ? value[value.length - 1] : value as string; + if (req.headers[customHeaderName] !== undefined) { + const value = req.headers[customHeaderName]; + headers[customHeaderName] = value as string; } } From a61a1b0df2b557653f55933f98e9781b14ec98c2 Mon Sep 17 00:00:00 2001 From: Shane Date: Tue, 20 May 2025 09:25:47 -0700 Subject: [PATCH 14/65] format update with prettier --- server/src/index.ts | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 3ea88f44..955d0267 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -268,18 +268,21 @@ app.get("/stdio", async (req, res) => { const stderrHandler = (chunk: Buffer) => { // Only send if the transport exists in our map (meaning it's still active) if (webAppTransports.has(webAppTransport.sessionId)) { - webAppTransport.send({ - jsonrpc: "2.0", - method: "notifications/stderr", - params: { - content: chunk.toString(), - }, - }) - .catch((error: any) => { - console.error(`Error sending stderr data to client: ${error.message}`); - // If we hit an error sending, clean up the transport - webAppTransports.delete(webAppTransport.sessionId); - }); + webAppTransport + .send({ + jsonrpc: "2.0", + method: "notifications/stderr", + params: { + content: chunk.toString(), + }, + }) + .catch((error: any) => { + console.error( + `Error sending stderr data to client: ${error.message}`, + ); + // If we hit an error sending, clean up the transport + webAppTransports.delete(webAppTransport.sessionId); + }); } }; From df9ea1e8a4127a4ed4ac0dd405bcf30db8c4c288 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 20 May 2025 19:51:48 +0100 Subject: [PATCH 15/65] rework naming --- client/src/components/Sidebar.tsx | 6 +++--- client/src/components/__tests__/Sidebar.test.tsx | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index a7668d42..516f48d5 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -127,14 +127,14 @@ const Sidebar = ({ return { type: "sse", url: sseUrl, - note: "For SSE connections, add this URL directly in Client", + note: "For SSE connections, add this URL directly in your MCP Client", }; } if (transportType === "streamable-http") { return { type: "streamable-http", url: sseUrl, - note: "For Streamable HTTP connections, add this URL directly in Client", + note: "For Streamable HTTP connections, add this URL directly in your MCP Client", }; } return {}; @@ -172,7 +172,7 @@ const Sidebar = ({ description: transportType === "stdio" ? "Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name." - : "SSE URL has been copied. Use this URL in Cursor directly.", + : "SSE URL has been copied. Use this URL directly in your MCP Client.", }); setTimeout(() => { diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index 47c19b17..ba03d180 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -755,7 +755,7 @@ describe("Sidebar Environment Variables", () => { { type: "sse", url: sseUrl, - note: "For SSE connections, add this URL directly in Client", + note: "For SSE connections, add this URL directly in your MCP Client", }, null, 4, @@ -780,7 +780,7 @@ describe("Sidebar Environment Variables", () => { "default-server": { type: "sse", url: sseUrl, - note: "For SSE connections, add this URL directly in Client", + note: "For SSE connections, add this URL directly in your MCP Client", }, }, }, @@ -805,7 +805,7 @@ describe("Sidebar Environment Variables", () => { { type: "streamable-http", url: sseUrl, - note: "For Streamable HTTP connections, add this URL directly in Client", + note: "For Streamable HTTP connections, add this URL directly in your MCP Client", }, null, 4, @@ -830,7 +830,7 @@ describe("Sidebar Environment Variables", () => { "default-server": { type: "streamable-http", url: sseUrl, - note: "For Streamable HTTP connections, add this URL directly in Client", + note: "For Streamable HTTP connections, add this URL directly in your MCP Client", }, }, }, From 80377bc914bb6f05a3a695cf3d2d99439e582b4d Mon Sep 17 00:00:00 2001 From: Shane Date: Tue, 20 May 2025 22:28:26 -0700 Subject: [PATCH 16/65] fix the PR build error --- client/src/components/ToolsTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index aa67bfcf..8b625567 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -82,7 +82,7 @@ const ToolsTab = ({ Success )} - {structuredResult.content.map((item, index) => ( + {structuredResult.content?.map((item, index) => (
{item.type === "text" && ( From 3232c0e11eab10b50b01238a0b26b476f56fae1b Mon Sep 17 00:00:00 2001 From: Shane Date: Wed, 21 May 2025 10:21:39 -0700 Subject: [PATCH 17/65] Revert "fix the PR build error" This reverts commit 80377bc914bb6f05a3a695cf3d2d99439e582b4d. --- client/src/components/ToolsTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 8b625567..aa67bfcf 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -82,7 +82,7 @@ const ToolsTab = ({ Success )} - {structuredResult.content?.map((item, index) => ( + {structuredResult.content.map((item, index) => (
{item.type === "text" && ( From f04470ace7080f53233d1387e2dbb4d232bd5f7d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 21 May 2025 17:45:19 -0400 Subject: [PATCH 18/65] Allow multiple client connections to the MCP server, by making a unique server tranpsort for each web app transport. * In server/src/index.ts - add serverTransports map - remove backingServerTransport var - in /mcp POST handler, when a new connection is being made - create a server transport - map the server transport using the session id from the web app connection - pass serverTransport to the mcpProxy instead of backingServerTransport - in /stdio GET handler, when a new connection is being made - create a server transport - map the server transport using the session id from the web app connection - pass serverTransport to the mcpProxy instead of backingServerTransport - in /sse GET handler, when a new connection is being made - create a server transport - map the server transport using the session id from the web app connection - pass serverTransport to the mcpProxy instead of backingServerTransport --- server/src/index.ts | 57 +++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index c967b60c..8bc8a53c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -48,7 +48,8 @@ app.use((req, res, next) => { next(); }); -const webAppTransports: Map = new Map(); // Transports by sessionId +const webAppTransports: Map = new Map(); // Web app transports by web app sessionId +const serverTransports: Map = new Map(); // Server Transports by web app sessionId const createTransport = async (req: express.Request): Promise => { const query = req.query; @@ -137,8 +138,6 @@ const createTransport = async (req: express.Request): Promise => { } }; -let backingServerTransport: Transport | undefined; - app.get("/mcp", async (req, res) => { const sessionId = req.headers["mcp-session-id"] as string; console.log(`Received GET message for sessionId ${sessionId}`); @@ -161,12 +160,12 @@ app.get("/mcp", async (req, res) => { app.post("/mcp", async (req, res) => { const sessionId = req.headers["mcp-session-id"] as string | undefined; console.log(`Received POST message for sessionId ${sessionId}`); + let serverTransport: Transport | undefined; if (!sessionId) { try { console.log("New streamable-http connection"); try { - await backingServerTransport?.close(); - backingServerTransport = await createTransport(req); + serverTransport = await createTransport(req); } catch (error) { if (error instanceof SseError && error.code === 401) { console.error( @@ -180,12 +179,13 @@ app.post("/mcp", async (req, res) => { throw error; } - console.log("Connected MCP client to backing server transport"); + console.log("Connected MCP client to server transport"); const webAppTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: randomUUID, onsessioninitialized: (sessionId) => { webAppTransports.set(sessionId, webAppTransport); + serverTransports.set(sessionId, serverTransport!); console.log("Created streamable web app transport " + sessionId); }, }); @@ -194,7 +194,7 @@ app.post("/mcp", async (req, res) => { mcpProxy({ transportToClient: webAppTransport, - transportToServer: backingServerTransport, + transportToServer: serverTransport, }); await (webAppTransport as StreamableHTTPServerTransport).handleRequest( @@ -229,10 +229,9 @@ app.post("/mcp", async (req, res) => { app.get("/stdio", async (req, res) => { try { console.log("New connection"); - + let serverTransport: Transport | undefined; try { - await backingServerTransport?.close(); - backingServerTransport = await createTransport(req); + serverTransport = await createTransport(req); } catch (error) { if (error instanceof SseError && error.code === 401) { console.error( @@ -250,26 +249,24 @@ app.get("/stdio", async (req, res) => { const webAppTransport = new SSEServerTransport("/message", res); webAppTransports.set(webAppTransport.sessionId, webAppTransport); - - console.log("Created web app transport"); + serverTransports.set(webAppTransport.sessionId, serverTransport); + console.log("Created client/server transports"); await webAppTransport.start(); - (backingServerTransport as StdioClientTransport).stderr!.on( - "data", - (chunk) => { - webAppTransport.send({ - jsonrpc: "2.0", - method: "notifications/stderr", - params: { - content: chunk.toString(), - }, - }); - }, - ); + + (serverTransport as StdioClientTransport).stderr!.on("data", (chunk) => { + webAppTransport.send({ + jsonrpc: "2.0", + method: "notifications/stderr", + params: { + content: chunk.toString(), + }, + }); + }); mcpProxy({ transportToClient: webAppTransport, - transportToServer: backingServerTransport, + transportToServer: serverTransport, }); console.log("Set up MCP proxy"); @@ -284,10 +281,9 @@ app.get("/sse", async (req, res) => { console.log( "New SSE connection. NOTE: The sse transport is deprecated and has been replaced by streamable-http", ); - + let serverTransport: SSEServerTransport | undefined; try { - await backingServerTransport?.close(); - backingServerTransport = await createTransport(req); + serverTransport = (await createTransport(req)) as SSEServerTransport; } catch (error) { if (error instanceof SseError && error.code === 401) { console.error( @@ -305,13 +301,14 @@ app.get("/sse", async (req, res) => { const webAppTransport = new SSEServerTransport("/message", res); webAppTransports.set(webAppTransport.sessionId, webAppTransport); - console.log("Created web app transport"); + serverTransports.set(webAppTransport.sessionId, serverTransport); + console.log("Created client/server transports"); await webAppTransport.start(); mcpProxy({ transportToClient: webAppTransport, - transportToServer: backingServerTransport, + transportToServer: serverTransport, }); console.log("Set up MCP proxy"); From 2870441fea8206625142f9d6b15ffe9904b82377 Mon Sep 17 00:00:00 2001 From: xiaoli Date: Sun, 25 May 2025 11:17:37 +0800 Subject: [PATCH 19/65] =?UTF-8?q?fix:=20fix=20review=20comments,=20again?= =?UTF-8?q?=F0=9F=98=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unnecessary auth header name check - Extract operations on headers into a function --- client/src/lib/hooks/useConnection.ts | 3 - server/src/index.ts | 80 +++++++++++++-------------- 2 files changed, 39 insertions(+), 44 deletions(-) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index f1f7ab9e..84956942 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -301,9 +301,6 @@ export function useConnection({ // Add custom header name as a special request header to let the server know which header to pass through if (authHeaderName.toLowerCase() !== "authorization") { headers[authHeaderName] = token; - if (headerName) { - headers["x-custom-auth-header"] = headerName; - } } else { headers[authHeaderName] = `Bearer ${token}`; } diff --git a/server/src/index.ts b/server/src/index.ts index fd28a807..de14e286 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -41,6 +41,43 @@ const { values } = parseArgs({ }, }); +// Function to get HTTP headers. +// Supports only "sse" and "streamable-http" transport types. +const getHttpHeaders = ( + req: express.Request, + transportType: string, +): HeadersInit => { + const headers: HeadersInit = { + Accept: + transportType === "sse" + ? "text/event-stream" + : "text/event-stream, application/json", + }; + const defaultHeaders = + transportType === "sse" + ? SSE_HEADERS_PASSTHROUGH + : STREAMABLE_HTTP_HEADERS_PASSTHROUGH; + + for (const key of defaultHeaders) { + if (req.headers[key] === undefined) { + continue; + } + + const value = req.headers[key]; + headers[key] = Array.isArray(value) ? value[value.length - 1] : value; + } + + // If the header "x-custom-auth-header" is present, use its value as the custom header name. + if (req.headers["x-custom-auth-header"] !== undefined) { + const customHeaderName = req.headers["x-custom-auth-header"] as string; + if (req.headers[customHeaderName] !== undefined) { + const value = req.headers[customHeaderName]; + headers[customHeaderName] = value as string; + } + } + return headers; +}; + const app = express(); app.use(cors()); app.use((req, res, next) => { @@ -79,27 +116,8 @@ const createTransport = async (req: express.Request): Promise => { return transport; } else if (transportType === "sse") { const url = query.url as string; - const headers: HeadersInit = { - Accept: "text/event-stream", - }; - for (const key of SSE_HEADERS_PASSTHROUGH) { - if (req.headers[key] === undefined) { - continue; - } - - const value = req.headers[key]; - headers[key] = Array.isArray(value) ? value[value.length - 1] : value; - } - - // If the header "x-custom-auth-header" is present, use its value as the custom header name. - if (req.headers["x-custom-auth-header"] !== undefined) { - const customHeaderName = req.headers["x-custom-auth-header"] as string; - if (req.headers[customHeaderName] !== undefined) { - const value = req.headers[customHeaderName]; - headers[customHeaderName] = value as string; - } - } + const headers = getHttpHeaders(req, transportType); console.log(`SSE transport: url=${url}, headers=${Object.keys(headers)}`); @@ -116,27 +134,7 @@ const createTransport = async (req: express.Request): Promise => { console.log("Connected to SSE transport"); return transport; } else if (transportType === "streamable-http") { - const headers: HeadersInit = { - Accept: "text/event-stream, application/json", - }; - - for (const key of STREAMABLE_HTTP_HEADERS_PASSTHROUGH) { - if (req.headers[key] === undefined) { - continue; - } - - const value = req.headers[key]; - headers[key] = Array.isArray(value) ? value[value.length - 1] : value; - } - - // If the header "x-custom-auth-header" is present, use its value as the custom header name. - if (req.headers["x-custom-auth-header"] !== undefined) { - const customHeaderName = req.headers["x-custom-auth-header"] as string; - if (req.headers[customHeaderName] !== undefined) { - const value = req.headers[customHeaderName]; - headers[customHeaderName] = value as string; - } - } + const headers = getHttpHeaders(req, transportType); const transport = new StreamableHTTPClientTransport( new URL(query.url as string), From 21833f91022539ce6093a112cbe5b204f67ccd9a Mon Sep 17 00:00:00 2001 From: Balaj Marius Date: Tue, 27 May 2025 11:48:03 +0300 Subject: [PATCH 20/65] feat: improve color contrast and readability in dark mode --- client/src/App.tsx | 8 ++++---- client/src/components/History.tsx | 12 ++++++++---- client/src/components/ListPane.tsx | 6 +++--- client/src/components/PromptsTab.tsx | 6 +++--- client/src/components/ResourcesTab.tsx | 6 +++--- client/src/components/Sidebar.tsx | 4 ++-- client/src/components/ToolsTab.tsx | 8 ++++---- client/src/index.css | 4 ++-- 8 files changed, 29 insertions(+), 25 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 68065e24..be4347ca 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -608,7 +608,7 @@ const App = () => { className="w-full p-4" onValueChange={(value) => (window.location.hash = value)} > - + { !serverCapabilities?.tools ? ( <>
-

+

The connected server does not support any MCP capabilities

@@ -811,7 +811,7 @@ const App = () => { ) : (
-

+

Connect to an MCP server to start inspecting

@@ -836,7 +836,7 @@ const App = () => { }} >
diff --git a/client/src/components/History.tsx b/client/src/components/History.tsx index 0b05b55d..78394dea 100644 --- a/client/src/components/History.tsx +++ b/client/src/components/History.tsx @@ -29,7 +29,9 @@ const HistoryAndNotifications = ({

History

{requestHistory.length === 0 ? ( -

No history yet

+

+ No history yet +

) : (
    {requestHistory @@ -38,7 +40,7 @@ const HistoryAndNotifications = ({ .map((request, index) => (
  • Server Notifications

    {serverNotifications.length === 0 ? ( -

    No notifications yet

    +

    + No notifications yet +

    ) : (
      {serverNotifications @@ -102,7 +106,7 @@ const HistoryAndNotifications = ({ .map((notification, index) => (
    • ({ buttonText, isButtonDisabled, }: ListPaneProps) => ( -
      -
      +
      +

      {title}

      @@ -46,7 +46,7 @@ const ListPane = ({ {items.map((item, index) => (
      setSelectedItem(item)} > {renderItem(item)} diff --git a/client/src/components/PromptsTab.tsx b/client/src/components/PromptsTab.tsx index 5fdad823..66c15d8f 100644 --- a/client/src/components/PromptsTab.tsx +++ b/client/src/components/PromptsTab.tsx @@ -110,8 +110,8 @@ const PromptsTab = ({ isButtonDisabled={!nextCursor && prompts.length > 0} /> -
      -
      +
      +

      {selectedPrompt ? selectedPrompt.name : "Select a prompt"}

      @@ -126,7 +126,7 @@ const PromptsTab = ({ ) : selectedPrompt ? (
      {selectedPrompt.description && ( -

      +

      {selectedPrompt.description}

      )} diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index c0f8203f..35742799 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -173,8 +173,8 @@ const ResourcesTab = ({ isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0} /> -
      -
      +
      +

      ) : selectedTemplate ? (
      -

      +

      {selectedTemplate.description}

      {selectedTemplate.uriTemplate diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 516f48d5..eee8eeaa 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -215,7 +215,7 @@ const Sidebar = ({ return (
      -
      +

      MCP Inspector v{version} @@ -646,7 +646,7 @@ const Sidebar = ({ } })()}`} /> - + {(() => { switch (connectionStatus) { case "connected": diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index aa67bfcf..16d53848 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -145,8 +145,8 @@ const ToolsTab = ({ isButtonDisabled={!nextCursor && tools.length > 0} /> -
      -
      +
      +

      {selectedTool ? selectedTool.name : "Select a tool"}

      @@ -154,7 +154,7 @@ const ToolsTab = ({
      {selectedTool ? (
      -

      +

      {selectedTool.description}

      {Object.entries(selectedTool.inputSchema.properties ?? []).map( @@ -164,7 +164,7 @@ const ToolsTab = ({
      diff --git a/client/src/index.css b/client/src/index.css index 11c6f23d..c3f30b97 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -93,8 +93,8 @@ h1 { --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; + --border: 217.2, 24%, 24%; + --input: 217.2 24% 24%; --ring: 212.7 26.8% 83.9%; --chart-1: 220 70% 50%; --chart-2: 160 60% 45%; From 7b1299c730d34594b85c44bd6eb42ff22f04701c Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 27 May 2025 11:36:19 +0100 Subject: [PATCH 21/65] [auth debugger] Make the quick flow use the same state machine as the step-by-step flow (#426) * use state machine in quick flow * clean up test * rm some comments --- client/src/components/AuthDebugger.tsx | 50 ++++++++++------ client/src/components/OAuthFlowProgress.tsx | 57 +++++++++++++++---- .../__tests__/AuthDebugger.test.tsx | 8 +-- 3 files changed, 80 insertions(+), 35 deletions(-) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index a2f20e26..d0b0432d 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -1,11 +1,6 @@ import { useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { DebugInspectorOAuthClientProvider } from "../lib/auth"; -import { - auth, - discoverOAuthMetadata, -} from "@modelcontextprotocol/sdk/client/auth.js"; -import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; import { AlertCircle } from "lucide-react"; import { AuthDebuggerState } from "../lib/auth-types"; import { OAuthFlowProgress } from "./OAuthFlowProgress"; @@ -124,22 +119,43 @@ const AuthDebugger = ({ updateAuthState({ isInitiatingAuth: true, statusMessage: null }); try { - const serverAuthProvider = new DebugInspectorOAuthClientProvider( - serverUrl, - ); - // First discover OAuth metadata separately so we can save it - const metadata = await discoverOAuthMetadata(serverUrl); - if (!metadata) { - throw new Error("Failed to discover OAuth metadata"); + // Step through the OAuth flow using the state machine instead of the auth() function + let currentState: AuthDebuggerState = { + ...authState, + oauthStep: "metadata_discovery", + authorizationUrl: null, + latestError: null, + }; + + const oauthMachine = new OAuthStateMachine(serverUrl, (updates) => { + // Update our temporary state during the process + currentState = { ...currentState, ...updates }; + // But don't call updateAuthState yet + }); + + // Manually step through each stage of the OAuth flow + while (currentState.oauthStep !== "complete") { + await oauthMachine.executeStep(currentState); + // In quick mode, we'll just redirect to the authorization URL + if ( + currentState.oauthStep === "authorization_code" && + currentState.authorizationUrl + ) { + // Open the authorization URL automatically + window.location.href = currentState.authorizationUrl; + break; + } } - const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); - serverAuthProvider.saveServerMetadata(parsedMetadata); - await auth(serverAuthProvider, { serverUrl: serverUrl }); + // After the flow completes or reaches a user-input step, update the app state updateAuthState({ + ...currentState, statusMessage: { type: "info", - message: "Starting OAuth authentication process...", + message: + currentState.oauthStep === "complete" + ? "Authentication completed successfully" + : "Please complete authentication in the opened window and enter the code", }, }); } catch (error) { @@ -153,7 +169,7 @@ const AuthDebugger = ({ } finally { updateAuthState({ isInitiatingAuth: false }); } - }, [serverUrl, updateAuthState]); + }, [serverUrl, updateAuthState, authState]); const handleClearOAuth = useCallback(() => { if (serverUrl) { diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index f604fc73..396af142 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -2,6 +2,8 @@ import { AuthDebuggerState, OAuthStep } from "@/lib/auth-types"; import { CheckCircle2, Circle, ExternalLink } from "lucide-react"; import { Button } from "./ui/button"; import { DebugInspectorOAuthClientProvider } from "@/lib/auth"; +import { useEffect, useMemo, useState } from "react"; +import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js"; interface OAuthStepProps { label: string; @@ -54,24 +56,57 @@ interface OAuthFlowProgressProps { proceedToNextStep: () => Promise; } +const steps: Array = [ + "metadata_discovery", + "client_registration", + "authorization_redirect", + "authorization_code", + "token_request", + "complete", +]; + export const OAuthFlowProgress = ({ serverUrl, authState, updateAuthState, proceedToNextStep, }: OAuthFlowProgressProps) => { - const provider = new DebugInspectorOAuthClientProvider(serverUrl); + const provider = useMemo( + () => new DebugInspectorOAuthClientProvider(serverUrl), + [serverUrl], + ); + const [clientInfo, setClientInfo] = useState( + null, + ); - const steps: Array = [ - "metadata_discovery", - "client_registration", - "authorization_redirect", - "authorization_code", - "token_request", - "complete", - ]; const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep); + useEffect(() => { + const fetchClientInfo = async () => { + if (authState.oauthClientInfo) { + setClientInfo(authState.oauthClientInfo); + } else { + try { + const info = await provider.clientInformation(); + if (info) { + setClientInfo(info); + } + } catch (error) { + console.error("Failed to fetch client information:", error); + } + } + }; + + if (currentStepIdx > steps.indexOf("client_registration")) { + fetchClientInfo(); + } + }, [ + provider, + authState.oauthStep, + authState.oauthClientInfo, + currentStepIdx, + ]); + // Helper to get step props const getStepProps = (stepName: OAuthStep) => ({ isComplete: @@ -110,13 +145,13 @@ export const OAuthFlowProgress = ({ label="Client Registration" {...getStepProps("client_registration")} > - {authState.oauthClientInfo && ( + {clientInfo && (
      Registered Client Information
      -                {JSON.stringify(authState.oauthClientInfo, null, 2)}
      +                {JSON.stringify(clientInfo, null, 2)}
                     
      )} diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index aa206ecc..7c539661 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -207,16 +207,10 @@ describe("AuthDebugger", () => { "https://example.com", ); - // Then should call auth with the server provider - expect(mockAuth).toHaveBeenCalled(); - // Check that updateAuthState was called with the right info message expect(updateAuthState).toHaveBeenCalledWith( expect.objectContaining({ - statusMessage: { - type: "info", - message: "Starting OAuth authentication process...", - }, + oauthStep: "authorization_code", }), ); }); From 66bb9c896b0f3961d9edc88f845ccdd6b9d39cfb Mon Sep 17 00:00:00 2001 From: David Sanftenberg Date: Wed, 28 May 2025 12:22:22 +0100 Subject: [PATCH 22/65] Add quick start example --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index d7c34b97..8d0de6f8 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,16 @@ The MCP inspector is a developer tool for testing and debugging MCP servers. - Node.js: ^22.7.5 +### Quick Start (UI mode) + +To get up and running right away with the UI, just execute the following: + +```bash +npx @modelcontextprotocol/inspector +``` + +The server will start up and the UI will be accessible at `http://localhost:6274`. + ### From an MCP server repository To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`: From 692c863f5beaeeecd6bcba56a05d362bb46ec980 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Wed, 28 May 2025 12:56:55 -0400 Subject: [PATCH 23/65] Revert "fix: handle the client disconnect so that the server does not crash." --- server/src/index.ts | 71 +++++++++++---------------------------------- 1 file changed, 17 insertions(+), 54 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 955d0267..c967b60c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -141,7 +141,7 @@ let backingServerTransport: Transport | undefined; app.get("/mcp", async (req, res) => { const sessionId = req.headers["mcp-session-id"] as string; - console.log(`GET /mcp for sessionId ${sessionId}`); + console.log(`Received GET message for sessionId ${sessionId}`); try { const transport = webAppTransports.get( sessionId, @@ -160,7 +160,7 @@ app.get("/mcp", async (req, res) => { app.post("/mcp", async (req, res) => { const sessionId = req.headers["mcp-session-id"] as string | undefined; - console.log(`POST /mcp for sessionId ${sessionId}`); + console.log(`Received POST message for sessionId ${sessionId}`); if (!sessionId) { try { console.log("New streamable-http connection"); @@ -228,7 +228,7 @@ app.post("/mcp", async (req, res) => { app.get("/stdio", async (req, res) => { try { - console.log("GET /stdio"); + console.log("New connection"); try { await backingServerTransport?.close(); @@ -254,53 +254,18 @@ app.get("/stdio", async (req, res) => { console.log("Created web app transport"); await webAppTransport.start(); - - // Handle client disconnection - res.on("close", () => { - console.log( - `Client disconnected from session ${webAppTransport.sessionId}`, - ); - // Clean up the transport map - webAppTransports.delete(webAppTransport.sessionId); - }); - - // Create a stderr handler that checks connection state - const stderrHandler = (chunk: Buffer) => { - // Only send if the transport exists in our map (meaning it's still active) - if (webAppTransports.has(webAppTransport.sessionId)) { - webAppTransport - .send({ - jsonrpc: "2.0", - method: "notifications/stderr", - params: { - content: chunk.toString(), - }, - }) - .catch((error: any) => { - console.error( - `Error sending stderr data to client: ${error.message}`, - ); - // If we hit an error sending, clean up the transport - webAppTransports.delete(webAppTransport.sessionId); - }); - } - }; - - if ((backingServerTransport as StdioClientTransport).stderr) { - (backingServerTransport as StdioClientTransport).stderr!.on( - "data", - stderrHandler, - ); - - // Store the handler reference so we can remove it when client disconnects - res.on("close", () => { - if ((backingServerTransport as StdioClientTransport).stderr) { - ( - backingServerTransport as StdioClientTransport - ).stderr!.removeListener("data", stderrHandler); - } - }); - } + (backingServerTransport as StdioClientTransport).stderr!.on( + "data", + (chunk) => { + webAppTransport.send({ + jsonrpc: "2.0", + method: "notifications/stderr", + params: { + content: chunk.toString(), + }, + }); + }, + ); mcpProxy({ transportToClient: webAppTransport, @@ -317,7 +282,7 @@ app.get("/stdio", async (req, res) => { app.get("/sse", async (req, res) => { try { console.log( - "GET /sse (NOTE: The sse transport is deprecated and has been replaced by streamable-http)", + "New SSE connection. NOTE: The sse transport is deprecated and has been replaced by streamable-http", ); try { @@ -359,7 +324,7 @@ app.get("/sse", async (req, res) => { app.post("/message", async (req, res) => { try { const sessionId = req.query.sessionId; - console.log(`POST /message for sessionId ${sessionId}`); + console.log(`Received message for sessionId ${sessionId}`); const transport = webAppTransports.get( sessionId as string, @@ -376,7 +341,6 @@ app.post("/message", async (req, res) => { }); app.get("/health", (req, res) => { - console.log("GET /health"); res.json({ status: "ok", }); @@ -384,7 +348,6 @@ app.get("/health", (req, res) => { app.get("/config", (req, res) => { try { - console.log("GET /config"); res.json({ defaultEnvironment, defaultCommand: values.env, From bc90f11dc88ae15de0adc059164ea35ed7f77d48 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 28 May 2025 13:44:55 -0400 Subject: [PATCH 24/65] Address [comment](https://github.com/modelcontextprotocol/inspector/pull/428/files/f04470ace7080f53233d1387e2dbb4d232bd5f7d#r2109509222) by @olaservo --- server/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 8bc8a53c..c4345ae6 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -281,9 +281,9 @@ app.get("/sse", async (req, res) => { console.log( "New SSE connection. NOTE: The sse transport is deprecated and has been replaced by streamable-http", ); - let serverTransport: SSEServerTransport | undefined; + let serverTransport: Transport | undefined; try { - serverTransport = (await createTransport(req)) as SSEServerTransport; + serverTransport = (await createTransport(req)); } catch (error) { if (error instanceof SseError && error.code === 401) { console.error( From 9bc6bd725ee5e974047d2949644355dac5615828 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 28 May 2025 13:46:58 -0400 Subject: [PATCH 25/65] Address [comment](https://github.com/modelcontextprotocol/inspector/pull/428/files/f04470ace7080f53233d1387e2dbb4d232bd5f7d#r2109509222) [by](https://github.com/modelcontextprotocol/inspector/pull/428/files/f04470ace7080f53233d1387e2dbb4d232bd5f7d#r2108137150) @olaservo --- server/src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/index.ts b/server/src/index.ts index c4345ae6..0b2aeec9 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -301,8 +301,9 @@ app.get("/sse", async (req, res) => { const webAppTransport = new SSEServerTransport("/message", res); webAppTransports.set(webAppTransport.sessionId, webAppTransport); + console.log("Created client transport"); serverTransports.set(webAppTransport.sessionId, serverTransport); - console.log("Created client/server transports"); + console.log("Created server transport"); await webAppTransport.start(); From de84c472ce2862abc77b7e9e98377b6583785adb Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Tue, 27 May 2025 16:39:39 -0400 Subject: [PATCH 26/65] feat: Add structured output support to browser UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds comprehensive support for structured output validation in tool calls, following the MCP specification for tools with output schemas. UI Changes: 1. Output Schema Display: - Added collapsible output schema section in ToolsTab - Shows output schemas after input fields, before the Run Tool button - Default view shows 8 lines with scrolling, expandable to full view - Expand/Collapse button with chevron icons for better UX 2. Structured Content Display: - New "Structured Content" section when tools return structuredContent - Shows structured data in a formatted JSON view - Validation status indicator (green checkmark for valid, red X for errors) - Detailed validation error messages when content doesn't match schema 3. Unstructured Content Labeling: - Added "Unstructured Content" heading when both structured and unstructured content exist - Only shows label when structured content is also present - Maintains clean UI when only unstructured content exists 4. Compatibility Checking: - Checks if unstructured content matches structured content (when output schema exists) - Shows compatibility status with blue (compatible) or yellow (incompatible) indicators - Detailed messages explain why content doesn't match - Only runs compatibility check for tools with output schemas 5. Validation Error Handling: - Shows error when tool with output schema doesn't return structured content - Clear error messages for schema validation failures - Maintains proper error state display Technical Implementation: - Added schema validation utilities using Ajv (same as SDK) - Caches compiled validators for performance - Validates on tool result display, not during the call - Follows SDK's Client.callTool validation pattern 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- client/src/App.tsx | 5 + client/src/components/ToolsTab.tsx | 170 +++++++++++++++++++++++++++-- client/src/utils/schemaUtils.ts | 74 +++++++++++++ 3 files changed, 242 insertions(+), 7 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 68065e24..cb411452 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -20,6 +20,7 @@ import { import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants"; import { AuthDebuggerState } from "./lib/auth-types"; +import { cacheToolOutputSchemas } from "./utils/schemaUtils"; import React, { Suspense, useCallback, @@ -473,6 +474,8 @@ const App = () => { ); setTools(response.tools); setNextToolCursor(response.nextCursor); + // Cache output schemas for validation + cacheToolOutputSchemas(response.tools); }; const callTool = async (name: string, params: Record) => { @@ -759,6 +762,8 @@ const App = () => { clearTools={() => { setTools([]); setNextToolCursor(undefined); + // Clear cached output schemas + cacheToolOutputSchemas([]); }} callTool={async (name, params) => { clearError("tools"); diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index aa67bfcf..4f4e235f 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -7,14 +7,14 @@ import { TabsContent } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import DynamicJsonForm from "./DynamicJsonForm"; import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils"; -import { generateDefaultValue } from "@/utils/schemaUtils"; +import { generateDefaultValue, validateToolOutput, hasOutputSchema } from "@/utils/schemaUtils"; import { CallToolResultSchema, CompatibilityCallToolResult, ListToolsResult, Tool, } from "@modelcontextprotocol/sdk/types.js"; -import { Loader2, Send } from "lucide-react"; +import { Loader2, Send, ChevronDown, ChevronUp } from "lucide-react"; import { useEffect, useState } from "react"; import ListPane from "./ListPane"; import JsonView from "./JsonView"; @@ -41,6 +41,7 @@ const ToolsTab = ({ }) => { const [params, setParams] = useState>({}); const [isToolRunning, setIsToolRunning] = useState(false); + const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false); useEffect(() => { const params = Object.entries( @@ -52,6 +53,53 @@ const ToolsTab = ({ setParams(Object.fromEntries(params)); }, [selectedTool]); + // Check compatibility between structured and unstructured content + const checkContentCompatibility = ( + structuredContent: unknown, + unstructuredContent: Array<{ type: string; text?: string; [key: string]: unknown }> + ): { isCompatible: boolean; message: string } => { + // Check if unstructured content is a single text block + if (unstructuredContent.length !== 1 || unstructuredContent[0].type !== "text") { + return { + isCompatible: false, + message: "Unstructured content is not a single text block" + }; + } + + const textContent = unstructuredContent[0].text; + if (!textContent) { + return { + isCompatible: false, + message: "Text content is empty" + }; + } + + try { + // Try to parse the text as JSON + const parsedContent = JSON.parse(textContent); + + // Deep equality check + const isEqual = JSON.stringify(parsedContent) === JSON.stringify(structuredContent); + + if (isEqual) { + return { + isCompatible: true, + message: "Unstructured content matches structured content" + }; + } else { + return { + isCompatible: false, + message: "Parsed JSON does not match structured content" + }; + } + } catch (e) { + return { + isCompatible: false, + message: "Unstructured content is not valid JSON" + }; + } + }; + const renderToolResult = () => { if (!toolResult) return null; @@ -72,6 +120,36 @@ const ToolsTab = ({ const structuredResult = parsedResult.data; const isError = structuredResult.isError ?? false; + // Validate structured content if present and tool has output schema + let validationResult = null; + const toolHasOutputSchema = selectedTool && hasOutputSchema(selectedTool.name); + + if (toolHasOutputSchema) { + if (!structuredResult.structuredContent && !isError) { + // Tool has output schema but didn't return structured content (and it's not an error) + validationResult = { + isValid: false, + error: "Tool has an output schema but did not return structured content" + }; + } else if (structuredResult.structuredContent) { + // Validate the structured content + validationResult = validateToolOutput(selectedTool.name, structuredResult.structuredContent); + } + } + + // Check compatibility if both structured and unstructured content exist + // AND the tool has an output schema + let compatibilityResult = null; + if (structuredResult.structuredContent && + structuredResult.content.length > 0 && + selectedTool && + hasOutputSchema(selectedTool.name)) { + compatibilityResult = checkContentCompatibility( + structuredResult.structuredContent, + structuredResult.content + ); + } + return ( <>

      @@ -82,11 +160,57 @@ const ToolsTab = ({ Success )}

      - {structuredResult.content.map((item, index) => ( -
      - {item.type === "text" && ( - + {structuredResult.structuredContent && ( +
      +
      Structured Content:
      +
      + + {validationResult && ( +
      + {validationResult.isValid ? ( + "✓ Valid according to output schema" + ) : ( + <> + ✗ Validation Error: {validationResult.error} + + )} +
      + )} +
      +
      + )} + {!structuredResult.structuredContent && validationResult && !validationResult.isValid && ( +
      +
      + ✗ Validation Error: {validationResult.error} +
      +
      + )} + {structuredResult.content.length > 0 && ( +
      + {structuredResult.structuredContent && ( + <> +
      Unstructured Content:
      + {compatibilityResult && ( +
      + {compatibilityResult.isCompatible ? "✓" : "⚠"} {compatibilityResult.message} +
      + )} + )} + {structuredResult.content.map((item, index) => ( +
      + {item.type === "text" && ( + + )} {item.type === "image" && ( ))} +
      + ))}
      - ))} + )} ); } else if ("toolResult" in toolResult) { @@ -262,6 +388,36 @@ const ToolsTab = ({ ); }, )} + {selectedTool.outputSchema && ( +
      +
      +

      Output Schema:

      + +
      +
      + +
      +
      + )}

      {structuredResult.structuredContent && (
      -
      Structured Content:
      +
      + Structured Content: +
      {validationResult && ( -
      +
      {validationResult.isValid ? ( "✓ Valid according to output schema" ) : ( - <> - ✗ Validation Error: {validationResult.error} - + <>✗ Validation Error: {validationResult.error} )}
      )}
      )} - {!structuredResult.structuredContent && validationResult && !validationResult.isValid && ( -
      -
      - ✗ Validation Error: {validationResult.error} + {!structuredResult.structuredContent && + validationResult && + !validationResult.isValid && ( +
      +
      + ✗ Validation Error: {validationResult.error} +
      -
      - )} + )} {structuredResult.content.length > 0 && (
      {structuredResult.structuredContent && ( <> -
      Unstructured Content:
      +
      + Unstructured Content: +
      {compatibilityResult && ( -
      - {compatibilityResult.isCompatible ? "✓" : "⚠"} {compatibilityResult.message} +
      + {compatibilityResult.isCompatible ? "✓" : "⚠"}{" "} + {compatibilityResult.message}
      )} @@ -211,25 +239,25 @@ const ToolsTab = ({ {item.type === "text" && ( )} - {item.type === "image" && ( - Tool result image - )} - {item.type === "resource" && - (item.resource?.mimeType?.startsWith("audio/") ? ( - - ) : ( - - ))} + {item.type === "image" && ( + Tool result image + )} + {item.type === "resource" && + (item.resource?.mimeType?.startsWith("audio/") ? ( + + ) : ( + + ))}
      ))}
      @@ -395,7 +423,9 @@ const ToolsTab = ({
      -
      +
      diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts index db620e28..d2440875 100644 --- a/client/src/utils/schemaUtils.ts +++ b/client/src/utils/schemaUtils.ts @@ -22,7 +22,10 @@ export function cacheToolOutputSchemas(tools: Tool[]): void { const validator = ajv.compile(tool.outputSchema); toolOutputValidators.set(tool.name, validator); } catch (error) { - console.warn(`Failed to compile output schema for tool ${tool.name}:`, error); + console.warn( + `Failed to compile output schema for tool ${tool.name}:`, + error, + ); } } } @@ -34,7 +37,9 @@ export function cacheToolOutputSchemas(tools: Tool[]): void { * @param toolName Name of the tool * @returns The compiled validator function, or undefined if not found */ -export function getToolOutputValidator(toolName: string): ValidateFunction | undefined { +export function getToolOutputValidator( + toolName: string, +): ValidateFunction | undefined { return toolOutputValidators.get(toolName); } @@ -47,7 +52,7 @@ export function getToolOutputValidator(toolName: string): ValidateFunction | und */ export function validateToolOutput( toolName: string, - structuredContent: unknown + structuredContent: unknown, ): { isValid: boolean; error?: string } { const validator = getToolOutputValidator(toolName); if (!validator) { From e7e47454641535b4c936b5b15543d554f0119b0f Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Tue, 27 May 2025 19:19:40 -0400 Subject: [PATCH 29/65] fix: Fix TypeScript build errors in ToolsTab tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous test implementation caused build failures due to Jest hoisting issues with mock variables. This commit fixes the build by removing mocking in favor of using real schema validation. - Remove jest.mock() of schemaUtils module that caused initialization errors - Add beforeEach to clear schema cache between tests - Use cacheToolOutputSchemas() to set up test scenarios - Let real validation logic run for more accurate tests - All tests pass and build succeeds 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../components/__tests__/ToolsTab.test.tsx | 50 ++++--------------- 1 file changed, 10 insertions(+), 40 deletions(-) diff --git a/client/src/components/__tests__/ToolsTab.test.tsx b/client/src/components/__tests__/ToolsTab.test.tsx index 828808bb..c9f9b315 100644 --- a/client/src/components/__tests__/ToolsTab.test.tsx +++ b/client/src/components/__tests__/ToolsTab.test.tsx @@ -1,36 +1,15 @@ import { render, screen, fireEvent, act } from "@testing-library/react"; -import { describe, it, expect, jest } from "@jest/globals"; import "@testing-library/jest-dom"; +import { describe, it, jest, beforeEach } from "@jest/globals"; import ToolsTab from "../ToolsTab"; import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { Tabs } from "@/components/ui/tabs"; -import * as schemaUtils from "@/utils/schemaUtils"; - -// Mock the schemaUtils module -// Note: hasOutputSchema checks if a tool's output schema validator has been compiled and cached -// by cacheToolOutputSchemas. In these tests, we mock it to avoid needing to call -// cacheToolOutputSchemas for every test that uses tools with output schemas. -// This keeps the tests focused on the component's behavior rather than schema compilation. -jest.mock("@/utils/schemaUtils", () => ({ - ...jest.requireActual("@/utils/schemaUtils"), - hasOutputSchema: jest.fn(), - validateToolOutput: jest.fn(), -})); +import { cacheToolOutputSchemas } from "@/utils/schemaUtils"; describe("ToolsTab", () => { beforeEach(() => { - jest.clearAllMocks(); - // Reset to default behavior - (schemaUtils.hasOutputSchema as jest.Mock).mockImplementation( - (toolName) => { - // Only tools with outputSchema property should return true - return false; - }, - ); - (schemaUtils.validateToolOutput as jest.Mock).mockReturnValue({ - isValid: true, - error: null, - }); + // Clear the output schema cache before each test + cacheToolOutputSchemas([]); }); const mockTools: Tool[] = [ @@ -250,8 +229,8 @@ describe("ToolsTab", () => { }; it("should display structured content when present", () => { - // Mock hasOutputSchema to return true for this tool - (schemaUtils.hasOutputSchema as jest.Mock).mockReturnValue(true); + // Cache the tool's output schema so hasOutputSchema returns true + cacheToolOutputSchemas([toolWithOutputSchema]); const structuredResult = { content: [], @@ -272,13 +251,7 @@ describe("ToolsTab", () => { }); it("should show validation error for invalid structured content", () => { - // Mock hasOutputSchema to return true for this tool - (schemaUtils.hasOutputSchema as jest.Mock).mockReturnValue(true); - // Mock the validation to fail - (schemaUtils.validateToolOutput as jest.Mock).mockReturnValue({ - isValid: false, - error: "temperature must be number", - }); + cacheToolOutputSchemas([toolWithOutputSchema]); const invalidResult = { content: [], @@ -296,8 +269,7 @@ describe("ToolsTab", () => { }); it("should show error when tool with output schema doesn't return structured content", () => { - // Mock hasOutputSchema to return true for this tool - (schemaUtils.hasOutputSchema as jest.Mock).mockReturnValue(true); + cacheToolOutputSchemas([toolWithOutputSchema]); const resultWithoutStructured = { content: [{ type: "text", text: "some result" }], @@ -317,8 +289,7 @@ describe("ToolsTab", () => { }); it("should show unstructured content title when both structured and unstructured exist", () => { - // Mock hasOutputSchema to return true for this tool - (schemaUtils.hasOutputSchema as jest.Mock).mockReturnValue(true); + cacheToolOutputSchemas([toolWithOutputSchema]); const resultWithBoth = { content: [{ type: "text", text: '{"temperature": 25}' }], @@ -350,8 +321,7 @@ describe("ToolsTab", () => { }); it("should show compatibility check when tool has output schema", () => { - // Mock hasOutputSchema to return true for this tool - (schemaUtils.hasOutputSchema as jest.Mock).mockReturnValue(true); + cacheToolOutputSchemas([toolWithOutputSchema]); const compatibleResult = { content: [{ type: "text", text: '{"temperature": 25}' }], From 5a628c7a00e7efcedd8b9351b907f8a2f81cbd48 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Tue, 27 May 2025 19:35:43 -0400 Subject: [PATCH 30/65] fix: Fix lint errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused catch parameter in ToolsTab.tsx - Replace 'as any' with @ts-expect-error in test for invalid schema 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- client/src/components/ToolsTab.tsx | 2 +- client/src/utils/__tests__/schemaUtils.test.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index ecadc8b9..01ea25f7 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -104,7 +104,7 @@ const ToolsTab = ({ message: "Parsed JSON does not match structured content", }; } - } catch (e) { + } catch { return { isCompatible: false, message: "Unstructured content is not valid JSON", diff --git a/client/src/utils/__tests__/schemaUtils.test.ts b/client/src/utils/__tests__/schemaUtils.test.ts index f908bf51..ca6b65ca 100644 --- a/client/src/utils/__tests__/schemaUtils.test.ts +++ b/client/src/utils/__tests__/schemaUtils.test.ts @@ -231,7 +231,8 @@ describe("Output Schema Validation", () => { description: "Tool with invalid schema", inputSchema: { type: "object", properties: {} }, outputSchema: { - type: "invalid-type" as any, + // @ts-expect-error Testing with invalid type + type: "invalid-type", }, }, ]; From 0c1962c2d06e11f7dab395a38c7104a9c0121640 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 28 May 2025 15:46:07 -0400 Subject: [PATCH 31/65] prettier --- server/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/index.ts b/server/src/index.ts index 0b2aeec9..73288f67 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -283,7 +283,7 @@ app.get("/sse", async (req, res) => { ); let serverTransport: Transport | undefined; try { - serverTransport = (await createTransport(req)); + serverTransport = await createTransport(req); } catch (error) { if (error instanceof SseError && error.code === 401) { console.error( From d8a2821344d1d06225c01a12bf13d91863fe20b3 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Wed, 28 May 2025 22:18:19 -0400 Subject: [PATCH 32/65] refactor: Address PR comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract tool results rendering into separate ToolResults component - Hoist checkContentCompatibility function to module level following codebase conventions - Add ajv@6.12.6 as direct dependency to client package.json - All tests pass and linting is clean 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- client/package.json | 1 + client/src/components/ToolResults.tsx | 221 ++++++++++++++++++++++++ client/src/components/ToolsTab.tsx | 231 +------------------------- client/src/utils/schemaUtils.ts | 1 - package-lock.json | 6 +- 5 files changed, 229 insertions(+), 231 deletions(-) create mode 100644 client/src/components/ToolResults.tsx diff --git a/client/package.json b/client/package.json index 5b5ec19a..ee54df3d 100644 --- a/client/package.json +++ b/client/package.json @@ -25,6 +25,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.11.5", "@radix-ui/react-checkbox": "^1.1.4", + "ajv": "^6.12.6", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", diff --git a/client/src/components/ToolResults.tsx b/client/src/components/ToolResults.tsx new file mode 100644 index 00000000..c6d90700 --- /dev/null +++ b/client/src/components/ToolResults.tsx @@ -0,0 +1,221 @@ +import JsonView from "./JsonView"; +import { + CallToolResultSchema, + CompatibilityCallToolResult, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import { validateToolOutput, hasOutputSchema } from "@/utils/schemaUtils"; + +interface ToolResultsProps { + toolResult: CompatibilityCallToolResult | null; + selectedTool: Tool | null; +} + +const checkContentCompatibility = ( + structuredContent: unknown, + unstructuredContent: Array<{ + type: string; + text?: string; + [key: string]: unknown; + }>, +): { isCompatible: boolean; message: string } => { + if ( + unstructuredContent.length !== 1 || + unstructuredContent[0].type !== "text" + ) { + return { + isCompatible: false, + message: "Unstructured content is not a single text block", + }; + } + + const textContent = unstructuredContent[0].text; + if (!textContent) { + return { + isCompatible: false, + message: "Text content is empty", + }; + } + + try { + const parsedContent = JSON.parse(textContent); + const isEqual = + JSON.stringify(parsedContent) === JSON.stringify(structuredContent); + + if (isEqual) { + return { + isCompatible: true, + message: "Unstructured content matches structured content", + }; + } else { + return { + isCompatible: false, + message: "Parsed JSON does not match structured content", + }; + } + } catch { + return { + isCompatible: false, + message: "Unstructured content is not valid JSON", + }; + } +}; + +const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => { + if (!toolResult) return null; + + if ("content" in toolResult) { + const parsedResult = CallToolResultSchema.safeParse(toolResult); + if (!parsedResult.success) { + return ( + <> +

      Invalid Tool Result:

      + +

      Errors:

      + {parsedResult.error.errors.map((error, idx) => ( + + ))} + + ); + } + const structuredResult = parsedResult.data; + const isError = structuredResult.isError ?? false; + + let validationResult = null; + const toolHasOutputSchema = + selectedTool && hasOutputSchema(selectedTool.name); + + if (toolHasOutputSchema) { + if (!structuredResult.structuredContent && !isError) { + validationResult = { + isValid: false, + error: + "Tool has an output schema but did not return structured content", + }; + } else if (structuredResult.structuredContent) { + validationResult = validateToolOutput( + selectedTool.name, + structuredResult.structuredContent, + ); + } + } + + let compatibilityResult = null; + if ( + structuredResult.structuredContent && + structuredResult.content.length > 0 && + selectedTool && + hasOutputSchema(selectedTool.name) + ) { + compatibilityResult = checkContentCompatibility( + structuredResult.structuredContent, + structuredResult.content, + ); + } + + return ( + <> +

      + Tool Result:{" "} + {isError ? ( + Error + ) : ( + Success + )} +

      + {structuredResult.structuredContent && ( +
      +
      Structured Content:
      +
      + + {validationResult && ( +
      + {validationResult.isValid ? ( + "✓ Valid according to output schema" + ) : ( + <>✗ Validation Error: {validationResult.error} + )} +
      + )} +
      +
      + )} + {!structuredResult.structuredContent && + validationResult && + !validationResult.isValid && ( +
      +
      + ✗ Validation Error: {validationResult.error} +
      +
      + )} + {structuredResult.content.length > 0 && ( +
      + {structuredResult.structuredContent && ( + <> +
      + Unstructured Content: +
      + {compatibilityResult && ( +
      + {compatibilityResult.isCompatible ? "✓" : "⚠"}{" "} + {compatibilityResult.message} +
      + )} + + )} + {structuredResult.content.map((item, index) => ( +
      + {item.type === "text" && ( + + )} + {item.type === "image" && ( + Tool result image + )} + {item.type === "resource" && + (item.resource?.mimeType?.startsWith("audio/") ? ( + + ) : ( + + ))} +
      + ))} +
      + )} + + ); + } else if ("toolResult" in toolResult) { + return ( + <> +

      Tool Result (Legacy):

      + + + ); + } + + return null; +}; + +export default ToolResults; diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 01ea25f7..8c72bd2a 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -7,13 +7,8 @@ import { TabsContent } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import DynamicJsonForm from "./DynamicJsonForm"; import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils"; +import { generateDefaultValue } from "@/utils/schemaUtils"; import { - generateDefaultValue, - validateToolOutput, - hasOutputSchema, -} from "@/utils/schemaUtils"; -import { - CallToolResultSchema, CompatibilityCallToolResult, ListToolsResult, Tool, @@ -22,6 +17,7 @@ import { Loader2, Send, ChevronDown, ChevronUp } from "lucide-react"; import { useEffect, useState } from "react"; import ListPane from "./ListPane"; import JsonView from "./JsonView"; +import ToolResults from "./ToolResults"; const ToolsTab = ({ tools, @@ -57,224 +53,6 @@ const ToolsTab = ({ setParams(Object.fromEntries(params)); }, [selectedTool]); - // Check compatibility between structured and unstructured content - const checkContentCompatibility = ( - structuredContent: unknown, - unstructuredContent: Array<{ - type: string; - text?: string; - [key: string]: unknown; - }>, - ): { isCompatible: boolean; message: string } => { - // Check if unstructured content is a single text block - if ( - unstructuredContent.length !== 1 || - unstructuredContent[0].type !== "text" - ) { - return { - isCompatible: false, - message: "Unstructured content is not a single text block", - }; - } - - const textContent = unstructuredContent[0].text; - if (!textContent) { - return { - isCompatible: false, - message: "Text content is empty", - }; - } - - try { - // Try to parse the text as JSON - const parsedContent = JSON.parse(textContent); - - // Deep equality check - const isEqual = - JSON.stringify(parsedContent) === JSON.stringify(structuredContent); - - if (isEqual) { - return { - isCompatible: true, - message: "Unstructured content matches structured content", - }; - } else { - return { - isCompatible: false, - message: "Parsed JSON does not match structured content", - }; - } - } catch { - return { - isCompatible: false, - message: "Unstructured content is not valid JSON", - }; - } - }; - - const renderToolResult = () => { - if (!toolResult) return null; - - if ("content" in toolResult) { - const parsedResult = CallToolResultSchema.safeParse(toolResult); - if (!parsedResult.success) { - return ( - <> -

      Invalid Tool Result:

      - -

      Errors:

      - {parsedResult.error.errors.map((error, idx) => ( - - ))} - - ); - } - const structuredResult = parsedResult.data; - const isError = structuredResult.isError ?? false; - - // Validate structured content if present and tool has output schema - let validationResult = null; - const toolHasOutputSchema = - selectedTool && hasOutputSchema(selectedTool.name); - - if (toolHasOutputSchema) { - if (!structuredResult.structuredContent && !isError) { - // Tool has output schema but didn't return structured content (and it's not an error) - validationResult = { - isValid: false, - error: - "Tool has an output schema but did not return structured content", - }; - } else if (structuredResult.structuredContent) { - // Validate the structured content - validationResult = validateToolOutput( - selectedTool.name, - structuredResult.structuredContent, - ); - } - } - - // Check compatibility if both structured and unstructured content exist - // AND the tool has an output schema - let compatibilityResult = null; - if ( - structuredResult.structuredContent && - structuredResult.content.length > 0 && - selectedTool && - hasOutputSchema(selectedTool.name) - ) { - compatibilityResult = checkContentCompatibility( - structuredResult.structuredContent, - structuredResult.content, - ); - } - - return ( - <> -

      - Tool Result:{" "} - {isError ? ( - Error - ) : ( - Success - )} -

      - {structuredResult.structuredContent && ( -
      -
      - Structured Content: -
      -
      - - {validationResult && ( -
      - {validationResult.isValid ? ( - "✓ Valid according to output schema" - ) : ( - <>✗ Validation Error: {validationResult.error} - )} -
      - )} -
      -
      - )} - {!structuredResult.structuredContent && - validationResult && - !validationResult.isValid && ( -
      -
      - ✗ Validation Error: {validationResult.error} -
      -
      - )} - {structuredResult.content.length > 0 && ( -
      - {structuredResult.structuredContent && ( - <> -
      - Unstructured Content: -
      - {compatibilityResult && ( -
      - {compatibilityResult.isCompatible ? "✓" : "⚠"}{" "} - {compatibilityResult.message} -
      - )} - - )} - {structuredResult.content.map((item, index) => ( -
      - {item.type === "text" && ( - - )} - {item.type === "image" && ( - Tool result image - )} - {item.type === "resource" && - (item.resource?.mimeType?.startsWith("audio/") ? ( - - ) : ( - - ))} -
      - ))} -
      - )} - - ); - } else if ("toolResult" in toolResult) { - return ( - <> -

      Tool Result (Legacy):

      - - - - ); - } - }; - return (
      @@ -475,7 +253,10 @@ const ToolsTab = ({ )} - {toolResult && renderToolResult()} +
      ) : ( diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts index d2440875..30210532 100644 --- a/client/src/utils/schemaUtils.ts +++ b/client/src/utils/schemaUtils.ts @@ -3,7 +3,6 @@ import Ajv from "ajv"; import type { ValidateFunction } from "ajv"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; -// Create a single Ajv instance following the SDK pattern const ajv = new Ajv(); // Cache for compiled validators diff --git a/package-lock.json b/package-lock.json index 0274b038..2920ca59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,6 +77,7 @@ "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", + "ajv": "^6.12.6", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.4", @@ -4002,7 +4003,6 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -5710,7 +5710,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -7740,7 +7739,6 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -8812,7 +8810,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -10488,7 +10485,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" From 10e3202832c51add7e0fdb8ab16cecde2646c47e Mon Sep 17 00:00:00 2001 From: xiaoli Date: Thu, 29 May 2025 11:49:57 +0800 Subject: [PATCH 33/65] fix: Fix the issue of accidentally losing 'x-custom-auth-header' --- client/src/lib/hooks/useConnection.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 84956942..f1c29b2d 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -301,6 +301,7 @@ export function useConnection({ // Add custom header name as a special request header to let the server know which header to pass through if (authHeaderName.toLowerCase() !== "authorization") { headers[authHeaderName] = token; + headers["x-custom-auth-header"] = authHeaderName; } else { headers[authHeaderName] = `Bearer ${token}`; } From 46d3d844f8884f2099a5e3fb078b5bf337ab2a9f Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 16 May 2025 19:20:40 +0100 Subject: [PATCH 34/65] reset authorization url --- client/src/components/AuthDebugger.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index d0b0432d..98d58e23 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -183,6 +183,7 @@ const AuthDebugger = ({ latestError: null, oauthClientInfo: null, authorizationCode: "", + authorizationUrl: "", validationError: null, oauthMetadata: null, statusMessage: { From befadc0042ac31ad649dca02f31097cb387541cd Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 16 May 2025 20:53:21 +0100 Subject: [PATCH 35/65] very wip draft auth spec support --- client/src/App.tsx | 3 +- client/src/components/AuthDebugger.tsx | 2 +- client/src/components/OAuthFlowProgress.tsx | 25 +++++++++++++++++ client/src/lib/auth-types.ts | 3 ++ client/src/lib/oauth-state-machine.ts | 31 +++++++++++++++------ 5 files changed, 53 insertions(+), 11 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index cb411452..a30dcc9c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -125,8 +125,9 @@ const App = () => { isInitiatingAuth: false, oauthTokens: null, loading: true, - oauthStep: "metadata_discovery", + oauthStep: "resource_metadata_discovery", oauthMetadata: null, + resourceMetadata: null, oauthClientInfo: null, authorizationUrl: null, authorizationCode: "", diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 98d58e23..083fc39b 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -179,7 +179,7 @@ const AuthDebugger = ({ serverAuthProvider.clear(); updateAuthState({ oauthTokens: null, - oauthStep: "metadata_discovery", + oauthStep: "resource_metadata_discovery", latestError: null, oauthClientInfo: null, authorizationCode: "", diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index 396af142..8167afa6 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -79,6 +79,16 @@ export const OAuthFlowProgress = ({ null, ); + const steps: Array = [ + "resource_metadata_discovery", + "metadata_discovery", + "client_registration", + "authorization_redirect", + "authorization_code", + "token_request", + "complete", + ]; + const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep); useEffect(() => { @@ -124,6 +134,21 @@ export const OAuthFlowProgress = ({

      + + {authState.resourceMetadata && ( +
      + + Retrieved OAuth Resource Metadata from {(new URL('/.well-known/oauth-protected-resource', serverUrl)).href} + +
      +                {JSON.stringify(authState.resourceMetadata, null, 2)}
      +              
      +
      + )} +
      Promise; execute: (context: StateMachineContext) => Promise; - nextStep: OAuthStep; } // State machine transitions export const oauthTransitions: Record = { - metadata_discovery: { + resource_metadata_discovery: { canTransition: async () => true, execute: async (context) => { - const metadata = await discoverOAuthMetadata(context.serverUrl); + // TODO: use sdk + const url = new URL("/.well-known/oauth-protected-resource", context.serverUrl); + const response = await fetch(url); + + const resourceMetadata = await response.json(); + context.updateState({ + resourceMetadata: resourceMetadata, + oauthStep: "metadata_discovery", + }); + }, + }, + + metadata_discovery: { + canTransition: async (context) => !!context.state.resourceMetadata, + execute: async (context) => { + // TODO: use sdk + let authServerUrl = context.serverUrl; + if (context.state.resourceMetadata?.authorization_servers?.[0]) { + authServerUrl = context.state.resourceMetadata.authorization_servers[0]; + } + const metadata = await discoverOAuthMetadata(authServerUrl); if (!metadata) { throw new Error("Failed to discover OAuth metadata"); } @@ -37,7 +56,6 @@ export const oauthTransitions: Record = { oauthStep: "client_registration", }); }, - nextStep: "client_registration", }, client_registration: { @@ -62,7 +80,6 @@ export const oauthTransitions: Record = { oauthStep: "authorization_redirect", }); }, - nextStep: "authorization_redirect", }, authorization_redirect: { @@ -93,7 +110,6 @@ export const oauthTransitions: Record = { oauthStep: "authorization_code", }); }, - nextStep: "authorization_code", }, authorization_code: { @@ -114,7 +130,6 @@ export const oauthTransitions: Record = { oauthStep: "token_request", }); }, - nextStep: "token_request", }, token_request: { @@ -144,7 +159,6 @@ export const oauthTransitions: Record = { oauthStep: "complete", }); }, - nextStep: "complete", }, complete: { @@ -152,7 +166,6 @@ export const oauthTransitions: Record = { execute: async () => { // No-op for complete state }, - nextStep: "complete", }, }; From 00893afc0be1577748cac65fc3f95cae2713051c Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 22 May 2025 18:36:48 +0100 Subject: [PATCH 36/65] wip new metadata --- client/src/App.tsx | 2 +- client/src/components/AuthDebugger.tsx | 2 +- client/src/components/OAuthFlowProgress.tsx | 20 ++----------- client/src/lib/auth-types.ts | 1 - client/src/lib/oauth-state-machine.ts | 32 +++++++++------------ 5 files changed, 19 insertions(+), 38 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index a30dcc9c..4708f08e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -125,7 +125,7 @@ const App = () => { isInitiatingAuth: false, oauthTokens: null, loading: true, - oauthStep: "resource_metadata_discovery", + oauthStep: "metadata_discovery", oauthMetadata: null, resourceMetadata: null, oauthClientInfo: null, diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 083fc39b..98d58e23 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -179,7 +179,7 @@ const AuthDebugger = ({ serverAuthProvider.clear(); updateAuthState({ oauthTokens: null, - oauthStep: "resource_metadata_discovery", + oauthStep: "metadata_discovery", latestError: null, oauthClientInfo: null, authorizationCode: "", diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index 8167afa6..9f079dde 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -57,6 +57,7 @@ interface OAuthFlowProgressProps { } const steps: Array = [ + "resource_metadata_discovery", "metadata_discovery", "client_registration", "authorization_redirect", @@ -79,16 +80,6 @@ export const OAuthFlowProgress = ({ null, ); - const steps: Array = [ - "resource_metadata_discovery", - "metadata_discovery", - "client_registration", - "authorization_redirect", - "authorization_code", - "token_request", - "complete", - ]; - const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep); useEffect(() => { @@ -135,8 +126,8 @@ export const OAuthFlowProgress = ({
      {authState.resourceMetadata && (
      @@ -148,11 +139,6 @@ export const OAuthFlowProgress = ({
      )} -
      - {provider.getServerMetadata() && (
      diff --git a/client/src/lib/auth-types.ts b/client/src/lib/auth-types.ts index 87e8f8bc..2a9171f0 100644 --- a/client/src/lib/auth-types.ts +++ b/client/src/lib/auth-types.ts @@ -7,7 +7,6 @@ import { // OAuth flow steps export type OAuthStep = - | "resource_metadata_discovery" | "metadata_discovery" | "client_registration" | "authorization_redirect" diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index bef728d7..a48b32e1 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -20,31 +20,26 @@ export interface StateTransition { execute: (context: StateMachineContext) => Promise; } +const fetchProtectedResourceMetadata = async (serverUrl: string): Promise => { + // TODO: use sdk + const url = new URL("/.well-known/oauth-protected-resource", serverUrl); + const response = await fetch(url); + const resourceMetadata = await response.json(); + + return resourceMetadata; +} + // State machine transitions export const oauthTransitions: Record = { - resource_metadata_discovery: { + metadata_discovery: { canTransition: async () => true, execute: async (context) => { - // TODO: use sdk - const url = new URL("/.well-known/oauth-protected-resource", context.serverUrl); - const response = await fetch(url); - const resourceMetadata = await response.json(); - context.updateState({ - resourceMetadata: resourceMetadata, - oauthStep: "metadata_discovery", - }); - }, - }, + try { - metadata_discovery: { - canTransition: async (context) => !!context.state.resourceMetadata, - execute: async (context) => { - // TODO: use sdk - let authServerUrl = context.serverUrl; - if (context.state.resourceMetadata?.authorization_servers?.[0]) { - authServerUrl = context.state.resourceMetadata.authorization_servers[0]; } + const resourceMetadata = fetchProtectedResourceMetadata(serverUrl) + const metadata = await discoverOAuthMetadata(authServerUrl); if (!metadata) { throw new Error("Failed to discover OAuth metadata"); @@ -52,6 +47,7 @@ export const oauthTransitions: Record = { const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); context.provider.saveServerMetadata(parsedMetadata); context.updateState({ + resourceMetadata, oauthMetadata: parsedMetadata, oauthStep: "client_registration", }); From c6ddcf4c90951319f9b6bce72c1d3b3ca72c9d9b Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 22 May 2025 18:40:27 +0100 Subject: [PATCH 37/65] fix --- client/src/lib/oauth-state-machine.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index a48b32e1..ca473b49 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -34,11 +34,15 @@ export const oauthTransitions: Record = { metadata_discovery: { canTransition: async () => true, execute: async (context) => { - - try { - - } - const resourceMetadata = fetchProtectedResourceMetadata(serverUrl) + const authServerUrl = context.serverUrl; + // try { + // const resourceMetadata = await fetchProtectedResourceMetadata(context.serverUrl); + // if (resourceMetadata && resourceMetadata) { + // authServerUrl = resourceMetadata + // } + // } catch (_error) { + // // pass + // } const metadata = await discoverOAuthMetadata(authServerUrl); if (!metadata) { From b2fa99668efae6b1b0e82d3df854b6546755c765 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 May 2025 10:19:39 +0100 Subject: [PATCH 38/65] bump typescript sdk --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d46b3ef8..a5e09c6a 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@modelcontextprotocol/inspector-cli": "^0.13.0", "@modelcontextprotocol/inspector-client": "^0.13.0", "@modelcontextprotocol/inspector-server": "^0.13.0", - "@modelcontextprotocol/sdk": "^1.11.5", + "@modelcontextprotocol/sdk": "^1.12.0", "concurrently": "^9.0.1", "open": "^10.1.0", "shell-quote": "^1.8.2", From 73fde03aa4e7d9f5b91bcc5c92d4fd9872b0158d Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 May 2025 10:56:09 +0100 Subject: [PATCH 39/65] working metadata fetch --- client/src/App.tsx | 17 +----- client/src/components/AuthDebugger.tsx | 11 +--- client/src/components/OAuthFlowProgress.tsx | 14 ++++- .../__tests__/AuthDebugger.test.tsx | 15 +---- client/src/lib/auth-types.ts | 21 ++++++- client/src/lib/oauth-state-machine.ts | 39 +++++++------ package-lock.json | 58 ++----------------- 7 files changed, 63 insertions(+), 112 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 4708f08e..55a4541e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -19,7 +19,7 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants"; -import { AuthDebuggerState } from "./lib/auth-types"; +import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types"; import { cacheToolOutputSchemas } from "./utils/schemaUtils"; import React, { Suspense, @@ -121,20 +121,7 @@ const App = () => { const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false); // Auth debugger state - const [authState, setAuthState] = useState({ - isInitiatingAuth: false, - oauthTokens: null, - loading: true, - oauthStep: "metadata_discovery", - oauthMetadata: null, - resourceMetadata: null, - oauthClientInfo: null, - authorizationUrl: null, - authorizationCode: "", - latestError: null, - statusMessage: null, - validationError: null, - }); + const [authState, setAuthState] = useState(EMPTY_DEBUGGER_STATE); // Helper function to update specific auth state properties const updateAuthState = (updates: Partial) => { diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 98d58e23..5565a77a 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { DebugInspectorOAuthClientProvider } from "../lib/auth"; import { AlertCircle } from "lucide-react"; -import { AuthDebuggerState } from "../lib/auth-types"; +import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "../lib/auth-types"; import { OAuthFlowProgress } from "./OAuthFlowProgress"; import { OAuthStateMachine } from "../lib/oauth-state-machine"; @@ -178,14 +178,7 @@ const AuthDebugger = ({ ); serverAuthProvider.clear(); updateAuthState({ - oauthTokens: null, - oauthStep: "metadata_discovery", - latestError: null, - oauthClientInfo: null, - authorizationCode: "", - authorizationUrl: "", - validationError: null, - oauthMetadata: null, + ...EMPTY_DEBUGGER_STATE, statusMessage: { type: "success", message: "OAuth tokens cleared successfully", diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index 9f079dde..6bd50bcb 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -57,7 +57,6 @@ interface OAuthFlowProgressProps { } const steps: Array = [ - "resource_metadata_discovery", "metadata_discovery", "client_registration", "authorization_redirect", @@ -139,6 +138,19 @@ export const OAuthFlowProgress = ({ )} + {authState.resourceMetadataError && ( +
      +

      + Failed to retrieve resource metadata, falling back to /.well-known/oauth-authorization-server: +

      +

      + {authState.resourceMetadataError.message} + {authState.resourceMetadataError instanceof TypeError + ? " (This could indicate the endpoint doesn't exist or does not have CORS configured)" + : authState.resourceMetadataError['status'] && ` (${authState.resourceMetadataError['status']})`} +

      +
      + )} {provider.getServerMetadata() && (
      diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 7c539661..42a289ff 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -51,6 +51,7 @@ import { auth, } from "@modelcontextprotocol/sdk/client/auth.js"; import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types"; // Type the mocked functions properly const mockDiscoverOAuthMetadata = discoverOAuthMetadata as jest.MockedFunction< @@ -84,19 +85,7 @@ Object.defineProperty(window, "location", { }); describe("AuthDebugger", () => { - const defaultAuthState = { - isInitiatingAuth: false, - oauthTokens: null, - loading: false, - oauthStep: "metadata_discovery" as const, - oauthMetadata: null, - oauthClientInfo: null, - authorizationUrl: null, - authorizationCode: "", - latestError: null, - statusMessage: null, - validationError: null, - }; + const defaultAuthState = EMPTY_DEBUGGER_STATE; const defaultProps = { serverUrl: "https://example.com", diff --git a/client/src/lib/auth-types.ts b/client/src/lib/auth-types.ts index 2a9171f0..40262601 100644 --- a/client/src/lib/auth-types.ts +++ b/client/src/lib/auth-types.ts @@ -3,6 +3,7 @@ import { OAuthClientInformationFull, OAuthClientInformation, OAuthTokens, + OAuthProtectedResourceMetadata, } from "@modelcontextprotocol/sdk/shared/auth.js"; // OAuth flow steps @@ -28,8 +29,8 @@ export interface AuthDebuggerState { oauthTokens: OAuthTokens | null; loading: boolean; oauthStep: OAuthStep; - // TODO: use sdk type - resourceMetadata: object | null; + resourceMetadata: OAuthProtectedResourceMetadata | null; + resourceMetadataError: Error | { status: number; statusText: string; message: string } | null; oauthMetadata: OAuthMetadata | null; oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null; authorizationUrl: string | null; @@ -38,3 +39,19 @@ export interface AuthDebuggerState { statusMessage: StatusMessage | null; validationError: string | null; } + +export const EMPTY_DEBUGGER_STATE: AuthDebuggerState = { + isInitiatingAuth: false, + oauthTokens: null, + loading: true, + oauthStep: "metadata_discovery", + oauthMetadata: null, + resourceMetadata: null, + resourceMetadataError: null, + oauthClientInfo: null, + authorizationUrl: null, + authorizationCode: "", + latestError: null, + statusMessage: null, + validationError: null, +} diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index ca473b49..5b2925ac 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -5,8 +5,9 @@ import { registerClient, startAuthorization, exchangeAuthorization, + discoverOAuthProtectedResourceMetadata, } from "@modelcontextprotocol/sdk/client/auth.js"; -import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { OAuthMetadataSchema, OAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; export interface StateMachineContext { state: AuthDebuggerState; @@ -20,29 +21,28 @@ export interface StateTransition { execute: (context: StateMachineContext) => Promise; } -const fetchProtectedResourceMetadata = async (serverUrl: string): Promise => { - // TODO: use sdk - const url = new URL("/.well-known/oauth-protected-resource", serverUrl); - const response = await fetch(url); - const resourceMetadata = await response.json(); - - return resourceMetadata; -} - // State machine transitions export const oauthTransitions: Record = { metadata_discovery: { canTransition: async () => true, execute: async (context) => { - const authServerUrl = context.serverUrl; - // try { - // const resourceMetadata = await fetchProtectedResourceMetadata(context.serverUrl); - // if (resourceMetadata && resourceMetadata) { - // authServerUrl = resourceMetadata - // } - // } catch (_error) { - // // pass - // } + let authServerUrl = context.serverUrl; + let resourceMetadata: OAuthProtectedResourceMetadata | null = null; + let resourceMetadataError: Error | null = null; + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata(context.serverUrl); + if (resourceMetadata && resourceMetadata.authorization_servers?.length) { + authServerUrl = resourceMetadata.authorization_servers[0]; + } + } catch (e) { + console.info(`Failed to find protected resource metadata: ${e}`); + console.log(e); + if (e instanceof Error) { + resourceMetadataError = e; + } else { + resourceMetadataError = new Error(String(e)); + } + } const metadata = await discoverOAuthMetadata(authServerUrl); if (!metadata) { @@ -52,6 +52,7 @@ export const oauthTransitions: Record = { context.provider.saveServerMetadata(parsedMetadata); context.updateState({ resourceMetadata, + resourceMetadataError, oauthMetadata: parsedMetadata, oauthStep: "client_registration", }); diff --git a/package-lock.json b/package-lock.json index 2920ca59..6e5d73f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@modelcontextprotocol/inspector-cli": "^0.13.0", "@modelcontextprotocol/inspector-client": "^0.13.0", "@modelcontextprotocol/inspector-server": "^0.13.0", - "@modelcontextprotocol/sdk": "^1.11.5", + "@modelcontextprotocol/sdk": "^1.12.0", "concurrently": "^9.0.1", "open": "^10.1.0", "shell-quote": "^1.8.2", @@ -2005,12 +2005,11 @@ "link": true }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.5.tgz", - "integrity": "sha512-gS7Q7IHpKxjVaNLMUZyTtatZ63ca3h418zPPntAhu/MvG5yfz/8HMcDAOpvpQfx3V3dsw9QQxk8RuFNrQhLlgA==", - "license": "MIT", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.12.0.tgz", + "integrity": "sha512-m//7RlINx1F3sz3KqwY1WWzVgTcYX52HYk4bJ1hkBXV3zccAEth+jRvG8DBRrdaQuRsPAJOx2MH3zaHNCKL7Zg==", "dependencies": { - "ajv": "^8.17.1", + "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", @@ -2026,28 +2025,6 @@ "node": ">=18" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -5719,22 +5696,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -9064,15 +9025,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", From 82858bed30fd5f93192f65c9dc7ea6e6deadbfdf Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 May 2025 11:17:21 +0100 Subject: [PATCH 40/65] show proper authserverurl --- client/src/components/OAuthFlowProgress.tsx | 72 +++++++++++++-------- client/src/lib/auth-types.ts | 2 + client/src/lib/oauth-state-machine.ts | 5 +- 3 files changed, 49 insertions(+), 30 deletions(-) diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index 6bd50bcb..2e74bf68 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -128,38 +128,54 @@ export const OAuthFlowProgress = ({ label="Metadata Discovery" {...getStepProps("metadata_discovery")} > - {authState.resourceMetadata && ( -
      - - Retrieved OAuth Resource Metadata from {(new URL('/.well-known/oauth-protected-resource', serverUrl)).href} - -
      -                {JSON.stringify(authState.resourceMetadata, null, 2)}
      -              
      -
      - )} - {authState.resourceMetadataError && ( -
      -

      - Failed to retrieve resource metadata, falling back to /.well-known/oauth-authorization-server: -

      -

      - {authState.resourceMetadataError.message} - {authState.resourceMetadataError instanceof TypeError - ? " (This could indicate the endpoint doesn't exist or does not have CORS configured)" - : authState.resourceMetadataError['status'] && ` (${authState.resourceMetadataError['status']})`} -

      -
      - )} {provider.getServerMetadata() && (
      - Retrieved OAuth Metadata from {serverUrl} - /.well-known/oauth-authorization-server + OAuth Metadata Sources + {!authState.resourceMetadata && " ℹ️"} -
      -                {JSON.stringify(provider.getServerMetadata(), null, 2)}
      -              
      + + {authState.resourceMetadata && ( +
      +

      Resource Metadata:

      +

      + From {new URL('/.well-known/oauth-protected-resource', serverUrl).href} +

      +
      +                    {JSON.stringify(authState.resourceMetadata, null, 2)}
      +                  
      +
      + )} + + {authState.resourceMetadataError && ( +
      +

      + ℹ️ No resource metadata available from {' '} + + {new URL('/.well-known/oauth-protected-resource', serverUrl).href} + +

      +

      + Resource metadata was added in the 2025-DRAFT-v2 specification update +
      + {authState.resourceMetadataError.message} + {authState.resourceMetadataError instanceof TypeError + && " (This could indicate the endpoint doesn't exist or does not have CORS configured)"} +

      +
      + )} + + {provider.getServerMetadata() && ( +
      +

      Authorization Server Metadata:

      + {authState.authServerUrl &&

      + From {new URL('/.well-known/oauth-authorization-server', authState.authServerUrl).href} +

      } +
      +                    {JSON.stringify(provider.getServerMetadata(), null, 2)}
      +                  
      +
      + )}
      )} diff --git a/client/src/lib/auth-types.ts b/client/src/lib/auth-types.ts index 40262601..2541e822 100644 --- a/client/src/lib/auth-types.ts +++ b/client/src/lib/auth-types.ts @@ -31,6 +31,7 @@ export interface AuthDebuggerState { oauthStep: OAuthStep; resourceMetadata: OAuthProtectedResourceMetadata | null; resourceMetadataError: Error | { status: number; statusText: string; message: string } | null; + authServerUrl: URL | null; oauthMetadata: OAuthMetadata | null; oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null; authorizationUrl: string | null; @@ -48,6 +49,7 @@ export const EMPTY_DEBUGGER_STATE: AuthDebuggerState = { oauthMetadata: null, resourceMetadata: null, resourceMetadataError: null, + authServerUrl: null, oauthClientInfo: null, authorizationUrl: null, authorizationCode: "", diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index 5b2925ac..939e5d6c 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -26,13 +26,13 @@ export const oauthTransitions: Record = { metadata_discovery: { canTransition: async () => true, execute: async (context) => { - let authServerUrl = context.serverUrl; + let authServerUrl = new URL(context.serverUrl); let resourceMetadata: OAuthProtectedResourceMetadata | null = null; let resourceMetadataError: Error | null = null; try { resourceMetadata = await discoverOAuthProtectedResourceMetadata(context.serverUrl); if (resourceMetadata && resourceMetadata.authorization_servers?.length) { - authServerUrl = resourceMetadata.authorization_servers[0]; + authServerUrl = new URL(resourceMetadata.authorization_servers[0]); } } catch (e) { console.info(`Failed to find protected resource metadata: ${e}`); @@ -53,6 +53,7 @@ export const oauthTransitions: Record = { context.updateState({ resourceMetadata, resourceMetadataError, + authServerUrl, oauthMetadata: parsedMetadata, oauthStep: "client_registration", }); From 0dbf75a66427f1f430095d567595e373fcaf185b Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 May 2025 11:50:09 +0100 Subject: [PATCH 41/65] store state before redirect --- client/src/App.tsx | 31 +++++++++++++++----- client/src/components/AuthDebugger.tsx | 6 ++++ client/src/components/OAuthDebugCallback.tsx | 19 ++++++++++-- client/src/lib/constants.ts | 1 + 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 55a4541e..8be23a5b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -234,21 +234,36 @@ const App = () => { ({ authorizationCode, errorMsg, + restoredState, }: { authorizationCode?: string; errorMsg?: string; + restoredState?: AuthDebuggerState; }) => { setIsAuthDebuggerVisible(true); - if (authorizationCode) { + + if (restoredState) { + // Restore the previous auth state updateAuthState({ - authorizationCode, - oauthStep: "token_request", - }); - } - if (errorMsg) { - updateAuthState({ - latestError: new Error(errorMsg), + ...restoredState, + // Update with the new authorization code if provided + authorizationCode: authorizationCode || restoredState.authorizationCode, + oauthStep: authorizationCode ? "token_request" : restoredState.oauthStep, + latestError: errorMsg ? new Error(errorMsg) : restoredState.latestError, }); + } else { + // Fallback to the original behavior if no state was restored + if (authorizationCode) { + updateAuthState({ + authorizationCode, + oauthStep: "token_request", + }); + } + if (errorMsg) { + updateAuthState({ + latestError: new Error(errorMsg), + }); + } } }, [], diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 5565a77a..0ef8e70b 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -5,6 +5,7 @@ import { AlertCircle } from "lucide-react"; import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "../lib/auth-types"; import { OAuthFlowProgress } from "./OAuthFlowProgress"; import { OAuthStateMachine } from "../lib/oauth-state-machine"; +import { SESSION_KEYS } from "../lib/constants"; export interface AuthDebuggerProps { serverUrl: string; @@ -141,6 +142,11 @@ const AuthDebugger = ({ currentState.oauthStep === "authorization_code" && currentState.authorizationUrl ) { + // Store the current auth state before redirecting + sessionStorage.setItem( + SESSION_KEYS.AUTH_DEBUGGER_STATE, + JSON.stringify(currentState) + ); // Open the authorization URL automatically window.location.href = currentState.authorizationUrl; break; diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx index 88d931c0..af9944ac 100644 --- a/client/src/components/OAuthDebugCallback.tsx +++ b/client/src/components/OAuthDebugCallback.tsx @@ -9,9 +9,11 @@ interface OAuthCallbackProps { onConnect: ({ authorizationCode, errorMsg, + restoredState, }: { authorizationCode?: string; errorMsg?: string; + restoredState?: any; }) => void; } @@ -34,6 +36,19 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { } const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL); + + // Try to restore the auth state + const storedState = sessionStorage.getItem(SESSION_KEYS.AUTH_DEBUGGER_STATE); + let restoredState = null; + if (storedState) { + try { + restoredState = JSON.parse(storedState); + // Clean up the stored state + sessionStorage.removeItem(SESSION_KEYS.AUTH_DEBUGGER_STATE); + } catch (e) { + console.error("Failed to parse stored auth state:", e); + } + } // ServerURL isn't set, this can happen if we've opened the // authentication request in a new tab, so we don't have the same @@ -50,8 +65,8 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { } // Instead of storing in sessionStorage, pass the code directly - // to the auth state manager through onConnect - onConnect({ authorizationCode: params.code }); + // to the auth state manager through onConnect, along with restored state + onConnect({ authorizationCode: params.code, restoredState }); }; handleCallback().finally(() => { diff --git a/client/src/lib/constants.ts b/client/src/lib/constants.ts index 4c3e27aa..3acbda91 100644 --- a/client/src/lib/constants.ts +++ b/client/src/lib/constants.ts @@ -7,6 +7,7 @@ export const SESSION_KEYS = { TOKENS: "mcp_tokens", CLIENT_INFORMATION: "mcp_client_information", SERVER_METADATA: "mcp_server_metadata", + AUTH_DEBUGGER_STATE: "mcp_auth_debugger_state", } as const; // Generate server-specific session storage keys From 20e961ce5c2286de5eee0503a85cc8e0005547ff Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 May 2025 12:09:52 +0100 Subject: [PATCH 42/65] resumption working --- client/src/App.tsx | 72 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 54 insertions(+), 18 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 8be23a5b..f3f1180e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -20,6 +20,7 @@ import { import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants"; import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types"; +import { OAuthStateMachine } from "./lib/oauth-state-machine"; import { cacheToolOutputSchemas } from "./utils/schemaUtils"; import React, { Suspense, @@ -231,7 +232,7 @@ const App = () => { // Update OAuth debug state during debug callback const onOAuthDebugConnect = useCallback( - ({ + async ({ authorizationCode, errorMsg, restoredState, @@ -241,29 +242,64 @@ const App = () => { restoredState?: AuthDebuggerState; }) => { setIsAuthDebuggerVisible(true); - - if (restoredState) { - // Restore the previous auth state + + if (errorMsg) { updateAuthState({ - ...restoredState, - // Update with the new authorization code if provided - authorizationCode: authorizationCode || restoredState.authorizationCode, - oauthStep: authorizationCode ? "token_request" : restoredState.oauthStep, - latestError: errorMsg ? new Error(errorMsg) : restoredState.latestError, + latestError: new Error(errorMsg), }); - } else { - // Fallback to the original behavior if no state was restored - if (authorizationCode) { - updateAuthState({ - authorizationCode, - oauthStep: "token_request", + return; + } + + if (restoredState && authorizationCode) { + // Restore the previous auth state and continue the OAuth flow + let currentState: AuthDebuggerState = { + ...restoredState, + authorizationCode, + oauthStep: "token_request", + isInitiatingAuth: true, + statusMessage: null, + latestError: null, + }; + + try { + // Create a new state machine instance to continue the flow + const stateMachine = new OAuthStateMachine(sseUrl, (updates) => { + currentState = { ...currentState, ...updates }; }); - } - if (errorMsg) { + + // Continue stepping through the OAuth flow from where we left off + while (currentState.oauthStep !== "complete" && currentState.oauthStep !== "authorization_code") { + await stateMachine.executeStep(currentState); + } + + if (currentState.oauthStep === "complete") { + // After the flow completes or reaches a user-input step, update the app state + updateAuthState({ + ...currentState, + statusMessage: { + type: "success", + message: "Authentication completed successfully", + }, + isInitiatingAuth: false, + }); + } + } catch (error) { + console.error("OAuth continuation error:", error); updateAuthState({ - latestError: new Error(errorMsg), + latestError: error instanceof Error ? error : new Error(String(error)), + statusMessage: { + type: "error", + message: `Failed to complete OAuth flow: ${error instanceof Error ? error.message : String(error)}`, + }, + isInitiatingAuth: false, }); } + } else if (authorizationCode) { + // Fallback to the original behavior if no state was restored + updateAuthState({ + authorizationCode, + oauthStep: "token_request", + }); } }, [], From 35eab580efa9010c88ff43dd91b483e61bcc43a1 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 May 2025 12:26:53 +0100 Subject: [PATCH 43/65] switch to metadata from state --- client/src/components/AuthDebugger.tsx | 19 ++++++++++++++++++- client/src/components/OAuthFlowProgress.tsx | 6 +++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 0ef8e70b..a90b1061 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { DebugInspectorOAuthClientProvider } from "../lib/auth"; import { AlertCircle } from "lucide-react"; @@ -60,6 +60,23 @@ const AuthDebugger = ({ authState, updateAuthState, }: AuthDebuggerProps) => { + // Initialize loading state + useEffect(() => { + if (authState.loading && serverUrl) { + // Check if we have existing tokens + const checkTokens = async () => { + const provider = new DebugInspectorOAuthClientProvider(serverUrl); + const existingTokens = await provider.tokens(); + + updateAuthState({ + loading: false, + oauthTokens: existingTokens || null, + }); + }; + + checkTokens(); + } + }, [serverUrl, authState.loading, updateAuthState]); const startOAuthFlow = useCallback(() => { if (!serverUrl) { updateAuthState({ diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index 2e74bf68..f86ceec8 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -128,7 +128,7 @@ export const OAuthFlowProgress = ({ label="Metadata Discovery" {...getStepProps("metadata_discovery")} > - {provider.getServerMetadata() && ( + {authState.oauthMetadata && (
      OAuth Metadata Sources @@ -165,14 +165,14 @@ export const OAuthFlowProgress = ({ )} - {provider.getServerMetadata() && ( + {authState.oauthMetadata && (

      Authorization Server Metadata:

      {authState.authServerUrl &&

      From {new URL('/.well-known/oauth-authorization-server', authState.authServerUrl).href}

      }
      -                    {JSON.stringify(provider.getServerMetadata(), null, 2)}
      +                    {JSON.stringify(authState.oauthMetadata, null, 2)}
                         
      )} From cc49358f5ab7c037fd7349031512b4caac345795 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 May 2025 13:42:54 +0100 Subject: [PATCH 44/65] add test + prettier --- client/src/App.tsx | 11 +++- client/src/components/AuthDebugger.tsx | 6 +- client/src/components/OAuthDebugCallback.tsx | 6 +- client/src/components/OAuthFlowProgress.tsx | 52 ++++++++++++--- .../__tests__/AuthDebugger.test.tsx | 66 +++++++++++++++++++ client/src/lib/auth-types.ts | 7 +- client/src/lib/oauth-state-machine.ts | 14 +++- 7 files changed, 139 insertions(+), 23 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index f3f1180e..74cdca19 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -122,7 +122,8 @@ const App = () => { const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false); // Auth debugger state - const [authState, setAuthState] = useState(EMPTY_DEBUGGER_STATE); + const [authState, setAuthState] = + useState(EMPTY_DEBUGGER_STATE); // Helper function to update specific auth state properties const updateAuthState = (updates: Partial) => { @@ -268,7 +269,10 @@ const App = () => { }); // Continue stepping through the OAuth flow from where we left off - while (currentState.oauthStep !== "complete" && currentState.oauthStep !== "authorization_code") { + while ( + currentState.oauthStep !== "complete" && + currentState.oauthStep !== "authorization_code" + ) { await stateMachine.executeStep(currentState); } @@ -286,7 +290,8 @@ const App = () => { } catch (error) { console.error("OAuth continuation error:", error); updateAuthState({ - latestError: error instanceof Error ? error : new Error(String(error)), + latestError: + error instanceof Error ? error : new Error(String(error)), statusMessage: { type: "error", message: `Failed to complete OAuth flow: ${error instanceof Error ? error.message : String(error)}`, diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index a90b1061..b88758d4 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -67,13 +67,13 @@ const AuthDebugger = ({ const checkTokens = async () => { const provider = new DebugInspectorOAuthClientProvider(serverUrl); const existingTokens = await provider.tokens(); - + updateAuthState({ loading: false, oauthTokens: existingTokens || null, }); }; - + checkTokens(); } }, [serverUrl, authState.loading, updateAuthState]); @@ -162,7 +162,7 @@ const AuthDebugger = ({ // Store the current auth state before redirecting sessionStorage.setItem( SESSION_KEYS.AUTH_DEBUGGER_STATE, - JSON.stringify(currentState) + JSON.stringify(currentState), ); // Open the authorization URL automatically window.location.href = currentState.authorizationUrl; diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx index af9944ac..17705abb 100644 --- a/client/src/components/OAuthDebugCallback.tsx +++ b/client/src/components/OAuthDebugCallback.tsx @@ -36,9 +36,11 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { } const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL); - + // Try to restore the auth state - const storedState = sessionStorage.getItem(SESSION_KEYS.AUTH_DEBUGGER_STATE); + const storedState = sessionStorage.getItem( + SESSION_KEYS.AUTH_DEBUGGER_STATE, + ); let restoredState = null; if (storedState) { try { diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index f86ceec8..b9b67c6c 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -139,7 +139,13 @@ export const OAuthFlowProgress = ({

      Resource Metadata:

      - From {new URL('/.well-known/oauth-protected-resource', serverUrl).href} + From{" "} + { + new URL( + "/.well-known/oauth-protected-resource", + serverUrl, + ).href + }

                           {JSON.stringify(authState.resourceMetadata, null, 2)}
      @@ -150,17 +156,35 @@ export const OAuthFlowProgress = ({
                     {authState.resourceMetadataError && (
                       

      - ℹ️ No resource metadata available from {' '} - - {new URL('/.well-known/oauth-protected-resource', serverUrl).href} + ℹ️ No resource metadata available from{" "} + + { + new URL( + "/.well-known/oauth-protected-resource", + serverUrl, + ).href + }

      - Resource metadata was added in the 2025-DRAFT-v2 specification update + Resource metadata was added in the{" "} + + 2025-DRAFT-v2 specification update +
      {authState.resourceMetadataError.message} - {authState.resourceMetadataError instanceof TypeError - && " (This could indicate the endpoint doesn't exist or does not have CORS configured)"} + {authState.resourceMetadataError instanceof TypeError && + " (This could indicate the endpoint doesn't exist or does not have CORS configured)"}

      )} @@ -168,9 +192,17 @@ export const OAuthFlowProgress = ({ {authState.oauthMetadata && (

      Authorization Server Metadata:

      - {authState.authServerUrl &&

      - From {new URL('/.well-known/oauth-authorization-server', authState.authServerUrl).href} -

      } + {authState.authServerUrl && ( +

      + From{" "} + { + new URL( + "/.well-known/oauth-authorization-server", + authState.authServerUrl, + ).href + } +

      + )}
                           {JSON.stringify(authState.oauthMetadata, null, 2)}
                         
      diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 42a289ff..fe5a94f9 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -40,6 +40,7 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ registerClient: jest.fn(), startAuthorization: jest.fn(), exchangeAuthorization: jest.fn(), + discoverOAuthProtectedResourceMetadata: jest.fn(), })); // Import the functions to get their types @@ -49,6 +50,7 @@ import { startAuthorization, exchangeAuthorization, auth, + discoverOAuthProtectedResourceMetadata, } from "@modelcontextprotocol/sdk/client/auth.js"; import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types"; @@ -67,6 +69,10 @@ const mockExchangeAuthorization = exchangeAuthorization as jest.MockedFunction< typeof exchangeAuthorization >; const mockAuth = auth as jest.MockedFunction; +const mockDiscoverOAuthProtectedResourceMetadata = + discoverOAuthProtectedResourceMetadata as jest.MockedFunction< + typeof discoverOAuthProtectedResourceMetadata + >; const sessionStorageMock = { getItem: jest.fn(), @@ -100,6 +106,7 @@ describe("AuthDebugger", () => { mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); mockRegisterClient.mockResolvedValue(mockOAuthClientInfo); + mockDiscoverOAuthProtectedResourceMetadata.mockResolvedValue(null); mockStartAuthorization.mockImplementation(async (_sseUrl, options) => { const authUrl = new URL("https://oauth.example.com/authorize"); @@ -421,4 +428,63 @@ describe("AuthDebugger", () => { }); }); }); + + describe("OAuth State Persistence", () => { + it("should store auth state to sessionStorage before redirect in Quick OAuth Flow", async () => { + const updateAuthState = jest.fn(); + + // Mock window.location.href setter + delete (window as any).location; + window.location = { href: "" } as any; + + // Setup mocks for OAuth flow + mockStartAuthorization.mockResolvedValue({ + authorizationUrl: new URL( + "https://oauth.example.com/authorize?client_id=test_client_id&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Foauth%2Fcallback%2Fdebug", + ), + codeVerifier: "test_verifier", + }); + + await act(async () => { + renderAuthDebugger({ + updateAuthState, + authState: { ...defaultAuthState, loading: false }, + }); + }); + + // Click Quick OAuth Flow + await act(async () => { + fireEvent.click(screen.getByText("Quick OAuth Flow")); + }); + + // Wait for the flow to reach the authorization step + await waitFor(() => { + expect(sessionStorage.setItem).toHaveBeenCalledWith( + SESSION_KEYS.AUTH_DEBUGGER_STATE, + expect.stringContaining('"oauthStep":"authorization_code"'), + ); + }); + + // Verify the stored state includes all the accumulated data + const storedStateCall = ( + sessionStorage.setItem as jest.Mock + ).mock.calls.find((call) => call[0] === SESSION_KEYS.AUTH_DEBUGGER_STATE); + + expect(storedStateCall).toBeDefined(); + const storedState = JSON.parse(storedStateCall![1]); + + expect(storedState).toMatchObject({ + oauthStep: "authorization_code", + authorizationUrl: expect.stringMatching( + /^https:\/\/oauth\.example\.com\/authorize/, + ), + oauthMetadata: expect.objectContaining({ + token_endpoint: "https://oauth.example.com/token", + }), + oauthClientInfo: expect.objectContaining({ + client_id: "test_client_id", + }), + }); + }); + }); }); diff --git a/client/src/lib/auth-types.ts b/client/src/lib/auth-types.ts index 2541e822..e69dcd12 100644 --- a/client/src/lib/auth-types.ts +++ b/client/src/lib/auth-types.ts @@ -30,7 +30,10 @@ export interface AuthDebuggerState { loading: boolean; oauthStep: OAuthStep; resourceMetadata: OAuthProtectedResourceMetadata | null; - resourceMetadataError: Error | { status: number; statusText: string; message: string } | null; + resourceMetadataError: + | Error + | { status: number; statusText: string; message: string } + | null; authServerUrl: URL | null; oauthMetadata: OAuthMetadata | null; oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null; @@ -56,4 +59,4 @@ export const EMPTY_DEBUGGER_STATE: AuthDebuggerState = { latestError: null, statusMessage: null, validationError: null, -} +}; diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index 939e5d6c..d05369c4 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -7,7 +7,10 @@ import { exchangeAuthorization, discoverOAuthProtectedResourceMetadata, } from "@modelcontextprotocol/sdk/client/auth.js"; -import { OAuthMetadataSchema, OAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { + OAuthMetadataSchema, + OAuthProtectedResourceMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; export interface StateMachineContext { state: AuthDebuggerState; @@ -30,8 +33,13 @@ export const oauthTransitions: Record = { let resourceMetadata: OAuthProtectedResourceMetadata | null = null; let resourceMetadataError: Error | null = null; try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata(context.serverUrl); - if (resourceMetadata && resourceMetadata.authorization_servers?.length) { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + context.serverUrl, + ); + if ( + resourceMetadata && + resourceMetadata.authorization_servers?.length + ) { authServerUrl = new URL(resourceMetadata.authorization_servers[0]); } } catch (e) { From 03bb303bf9f1c07fba565c7771c5ad5d0c1ae70e Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 May 2025 14:10:32 +0100 Subject: [PATCH 45/65] rm loading, prm test --- client/src/App.tsx | 2 - client/src/components/AuthDebugger.tsx | 26 ++-- .../__tests__/AuthDebugger.test.tsx | 123 +++++++++++++++++- client/src/lib/auth-types.ts | 2 - 4 files changed, 131 insertions(+), 22 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 74cdca19..a332f9aa 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -329,8 +329,6 @@ const App = () => { } } catch (error) { console.error("Error loading OAuth tokens:", error); - } finally { - updateAuthState({ loading: false }); } }; diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index b88758d4..9757af95 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -60,23 +60,23 @@ const AuthDebugger = ({ authState, updateAuthState, }: AuthDebuggerProps) => { - // Initialize loading state + // Check for existing tokens on mount useEffect(() => { - if (authState.loading && serverUrl) { - // Check if we have existing tokens + if (serverUrl && !authState.oauthTokens) { const checkTokens = async () => { const provider = new DebugInspectorOAuthClientProvider(serverUrl); const existingTokens = await provider.tokens(); - - updateAuthState({ - loading: false, - oauthTokens: existingTokens || null, - }); + if (existingTokens) { + updateAuthState({ + oauthTokens: existingTokens, + oauthStep: "complete", + }); + } }; - checkTokens(); } - }, [serverUrl, authState.loading, updateAuthState]); + }, [serverUrl]); // Only run when serverUrl changes + const startOAuthFlow = useCallback(() => { if (!serverUrl) { updateAuthState({ @@ -241,10 +241,7 @@ const AuthDebugger = ({ )} - {authState.loading ? ( -

      Loading authentication status...

      - ) : ( -
      +
      {authState.oauthTokens && (

      Access Token:

      @@ -286,7 +283,6 @@ const AuthDebugger = ({ the standard automatic flow.

      - )}
      { mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); mockRegisterClient.mockResolvedValue(mockOAuthClientInfo); - mockDiscoverOAuthProtectedResourceMetadata.mockResolvedValue(null); + mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue( + new Error("No protected resource metadata found") + ); mockStartAuthorization.mockImplementation(async (_sseUrl, options) => { const authUrl = new URL("https://oauth.example.com/authorize"); @@ -448,7 +450,7 @@ describe("AuthDebugger", () => { await act(async () => { renderAuthDebugger({ updateAuthState, - authState: { ...defaultAuthState, loading: false }, + authState: { ...defaultAuthState }, }); }); @@ -471,7 +473,7 @@ describe("AuthDebugger", () => { ).mock.calls.find((call) => call[0] === SESSION_KEYS.AUTH_DEBUGGER_STATE); expect(storedStateCall).toBeDefined(); - const storedState = JSON.parse(storedStateCall![1]); + const storedState = JSON.parse(storedStateCall![1] as string); expect(storedState).toMatchObject({ oauthStep: "authorization_code", @@ -487,4 +489,119 @@ describe("AuthDebugger", () => { }); }); }); + + describe("OAuth Protected Resource Metadata", () => { + it("should successfully fetch and display protected resource metadata", async () => { + const updateAuthState = jest.fn(); + const mockResourceMetadata = { + resource: "https://example.com/api", + authorization_servers: ["https://custom-auth.example.com"], + bearer_methods_supported: ["header", "body"], + resource_documentation: "https://example.com/api/docs", + resource_policy_uri: "https://example.com/api/policy", + }; + + // Mock successful metadata discovery + mockDiscoverOAuthProtectedResourceMetadata.mockResolvedValue( + mockResourceMetadata + ); + mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); + + await act(async () => { + renderAuthDebugger({ + updateAuthState, + authState: { ...defaultAuthState }, + }); + }); + + // Click Guided OAuth Flow to start the process + await act(async () => { + fireEvent.click(screen.getByText("Guided OAuth Flow")); + }); + + // Verify that the flow started with metadata discovery + expect(updateAuthState).toHaveBeenCalledWith({ + oauthStep: "metadata_discovery", + authorizationUrl: null, + statusMessage: null, + latestError: null, + }); + + // Click Continue to trigger metadata discovery + const continueButton = await screen.findByText("Continue"); + await act(async () => { + fireEvent.click(continueButton); + }); + + // Wait for the metadata to be fetched + await waitFor(() => { + expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( + "https://example.com" + ); + }); + + // Verify the state was updated with the resource metadata + await waitFor(() => { + expect(updateAuthState).toHaveBeenCalledWith( + expect.objectContaining({ + resourceMetadata: mockResourceMetadata, + authServerUrl: new URL("https://custom-auth.example.com"), + oauthStep: "client_registration", + }) + ); + }); + }); + + it("should handle protected resource metadata fetch failure gracefully", async () => { + const updateAuthState = jest.fn(); + const mockError = new Error("Failed to fetch resource metadata"); + + // Mock failed metadata discovery + mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(mockError); + // But OAuth metadata should still work with the original URL + mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); + + await act(async () => { + renderAuthDebugger({ + updateAuthState, + authState: { ...defaultAuthState }, + }); + }); + + // Click Guided OAuth Flow + await act(async () => { + fireEvent.click(screen.getByText("Guided OAuth Flow")); + }); + + // Click Continue to trigger metadata discovery + const continueButton = await screen.findByText("Continue"); + await act(async () => { + fireEvent.click(continueButton); + }); + + // Wait for the metadata fetch to fail + await waitFor(() => { + expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( + "https://example.com" + ); + }); + + // Verify the flow continues despite the error + await waitFor(() => { + expect(updateAuthState).toHaveBeenCalledWith( + expect.objectContaining({ + resourceMetadataError: mockError, + // Should use the original server URL as fallback + authServerUrl: new URL("https://example.com"), + oauthStep: "client_registration", + }) + ); + }); + + // Verify that regular OAuth metadata discovery was still called + expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith( + new URL("https://example.com") + ); + }); + }); }); diff --git a/client/src/lib/auth-types.ts b/client/src/lib/auth-types.ts index e69dcd12..daf1bc15 100644 --- a/client/src/lib/auth-types.ts +++ b/client/src/lib/auth-types.ts @@ -27,7 +27,6 @@ export interface StatusMessage { export interface AuthDebuggerState { isInitiatingAuth: boolean; oauthTokens: OAuthTokens | null; - loading: boolean; oauthStep: OAuthStep; resourceMetadata: OAuthProtectedResourceMetadata | null; resourceMetadataError: @@ -47,7 +46,6 @@ export interface AuthDebuggerState { export const EMPTY_DEBUGGER_STATE: AuthDebuggerState = { isInitiatingAuth: false, oauthTokens: null, - loading: true, oauthStep: "metadata_discovery", oauthMetadata: null, resourceMetadata: null, From 11bc04364bfc012b2170be97ce530af1c118e547 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 May 2025 14:11:01 +0100 Subject: [PATCH 46/65] whitespace --- client/src/components/AuthDebugger.tsx | 72 +++++++++---------- .../__tests__/AuthDebugger.test.tsx | 14 ++-- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 9757af95..48c9cb00 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -242,47 +242,47 @@ const AuthDebugger = ({ )}
      - {authState.oauthTokens && ( -
      -

      Access Token:

      -
      - {authState.oauthTokens.access_token.substring(0, 25)}... -
      + {authState.oauthTokens && ( +
      +

      Access Token:

      +
      + {authState.oauthTokens.access_token.substring(0, 25)}...
      - )} - -
      - +
      + )} - +
      + - -
      + -

      - Choose "Guided" for step-by-step instructions or "Quick" for - the standard automatic flow. -

      +
      + +

      + Choose "Guided" for step-by-step instructions or "Quick" for + the standard automatic flow. +

      +
      { mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); mockRegisterClient.mockResolvedValue(mockOAuthClientInfo); mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue( - new Error("No protected resource metadata found") + new Error("No protected resource metadata found"), ); mockStartAuthorization.mockImplementation(async (_sseUrl, options) => { const authUrl = new URL("https://oauth.example.com/authorize"); @@ -503,7 +503,7 @@ describe("AuthDebugger", () => { // Mock successful metadata discovery mockDiscoverOAuthProtectedResourceMetadata.mockResolvedValue( - mockResourceMetadata + mockResourceMetadata, ); mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); @@ -536,7 +536,7 @@ describe("AuthDebugger", () => { // Wait for the metadata to be fetched await waitFor(() => { expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( - "https://example.com" + "https://example.com", ); }); @@ -547,7 +547,7 @@ describe("AuthDebugger", () => { resourceMetadata: mockResourceMetadata, authServerUrl: new URL("https://custom-auth.example.com"), oauthStep: "client_registration", - }) + }), ); }); }); @@ -582,7 +582,7 @@ describe("AuthDebugger", () => { // Wait for the metadata fetch to fail await waitFor(() => { expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( - "https://example.com" + "https://example.com", ); }); @@ -594,13 +594,13 @@ describe("AuthDebugger", () => { // Should use the original server URL as fallback authServerUrl: new URL("https://example.com"), oauthStep: "client_registration", - }) + }), ); }); // Verify that regular OAuth metadata discovery was still called expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith( - new URL("https://example.com") + new URL("https://example.com"), ); }); }); From e74f80db8591c288c394d7912b57e18bf93e4b78 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 May 2025 14:26:29 +0100 Subject: [PATCH 47/65] fix tests --- client/src/components/AuthDebugger.tsx | 20 +++++++++++-------- .../__tests__/AuthDebugger.test.tsx | 9 +++++++-- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 48c9cb00..ec09963e 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -64,18 +64,22 @@ const AuthDebugger = ({ useEffect(() => { if (serverUrl && !authState.oauthTokens) { const checkTokens = async () => { - const provider = new DebugInspectorOAuthClientProvider(serverUrl); - const existingTokens = await provider.tokens(); - if (existingTokens) { - updateAuthState({ - oauthTokens: existingTokens, - oauthStep: "complete", - }); + try { + const provider = new DebugInspectorOAuthClientProvider(serverUrl); + const existingTokens = await provider.tokens(); + if (existingTokens) { + updateAuthState({ + oauthTokens: existingTokens, + oauthStep: "complete", + }); + } + } catch (error) { + console.error("Failed to load existing OAuth tokens:", error); } }; checkTokens(); } - }, [serverUrl]); // Only run when serverUrl changes + }, [serverUrl, updateAuthState, authState.oauthTokens]); const startOAuthFlow = useCallback(() => { if (!serverUrl) { diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index b0df65eb..b2fa4c61 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -202,7 +202,7 @@ describe("AuthDebugger", () => { // Should first discover and save OAuth metadata expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith( - "https://example.com", + new URL("https://example.com"), ); // Check that updateAuthState was called with the right info message @@ -314,6 +314,11 @@ describe("AuthDebugger", () => { }); expect(updateAuthState).toHaveBeenCalledWith({ + authServerUrl: null, + authorizationUrl: null, + isInitiatingAuth: false, + resourceMetadata: null, + resourceMetadataError: null, oauthTokens: null, oauthStep: "metadata_discovery", latestError: null, @@ -355,7 +360,7 @@ describe("AuthDebugger", () => { }); expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith( - "https://example.com", + new URL("https://example.com"), ); }); From 2e5cc87659ea77bb26db342af2097abb7d95d20b Mon Sep 17 00:00:00 2001 From: Balaj Marius Date: Thu, 29 May 2025 18:30:13 +0300 Subject: [PATCH 48/65] Update index.css Co-authored-by: Ola Hungerford --- client/src/index.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/index.css b/client/src/index.css index c3f30b97..858f9e7f 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -93,7 +93,7 @@ h1 { --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; - --border: 217.2, 24%, 24%; + --border: 217.2 24% 24%; --input: 217.2 24% 24%; --ring: 212.7 26.8% 83.9%; --chart-1: 220 70% 50%; From 02aacb46a2851cf6c5066659f1bc3b56a44d3c76 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 29 May 2025 19:54:36 -0400 Subject: [PATCH 49/65] Send HTTP/DELETE when disconnecting client * In server/src/index.ts - add delete handler for /mcp endpoint - gets the server transport for the sessionId - calls terminateSession on it - removes the webapp and server transports for the sessionId from the maps - returns status 200 to the client. * In client/src/lib/hooks/useConnection.ts - import Transport - add useState for clientTransport, type Transport or null initialized to null - in connect() function - move creation of client transport down a ways, just before calling client.connect with it - immendiately efter calling client.connect, call setClientTransport with the connected transport (has to happen after, so that what's saved has the abort controller, etc. otherwise it doesn't work to save it prior. - in disconnect() function - immediately call clientTransport.terminateSession if transportType is "streamable-http" - setClientTransport to null --- client/src/lib/hooks/useConnection.ts | 32 +++++++++++++++++++-------- server/src/index.ts | 27 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 9c825318..39da55cb 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -45,6 +45,7 @@ import { } from "@/utils/configUtils"; import { getMCPServerRequestTimeout } from "@/utils/configUtils"; import { InspectorConfig } from "../configurationTypes"; +import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; interface UseConnectionOptions { transportType: "stdio" | "sse" | "streamable-http"; @@ -83,6 +84,9 @@ export function useConnection({ const [serverCapabilities, setServerCapabilities] = useState(null); const [mcpClient, setMcpClient] = useState(null); + const [clientTransport, setClientTransport] = useState( + null, + ); const [requestHistory, setRequestHistory] = useState< { request: string; response?: string }[] >([]); @@ -377,14 +381,6 @@ export function useConnection({ transportType, ); - const clientTransport = - transportType === "streamable-http" - ? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, { - sessionId: undefined, - ...transportOptions, - }) - : new SSEClientTransport(mcpProxyServerUrl as URL, transportOptions); - if (onNotification) { [ CancelledNotificationSchema, @@ -414,7 +410,20 @@ export function useConnection({ let capabilities; try { - await client.connect(clientTransport); + const transport = + transportType === "streamable-http" + ? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, { + sessionId: undefined, + ...transportOptions, + }) + : new SSEClientTransport( + mcpProxyServerUrl as URL, + transportOptions, + ); + + await client.connect(transport as Transport); + + setClientTransport(transport); capabilities = client.getServerCapabilities(); const initializeRequest = { @@ -468,10 +477,15 @@ export function useConnection({ }; const disconnect = async () => { + if (transportType === "streamable-http") + await ( + clientTransport as StreamableHTTPClientTransport + ).terminateSession(); await mcpClient?.close(); const authProvider = new InspectorOAuthClientProvider(sseUrl); authProvider.clear(); setMcpClient(null); + setClientTransport(null); setConnectionStatus("disconnected"); setCompletionsSupported(false); setServerCapabilities(null); diff --git a/server/src/index.ts b/server/src/index.ts index 73288f67..1586d0a0 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -226,6 +226,33 @@ app.post("/mcp", async (req, res) => { } }); +app.delete("/mcp", async (req, res) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + console.log(`Received DELETE message for sessionId ${sessionId}`); + let serverTransport: Transport | undefined; + if (sessionId) { + try { + serverTransport = serverTransports.get( + sessionId, + ) as StreamableHTTPClientTransport; + if (!serverTransport) { + res.status(404).end("Transport not found for sessionId " + sessionId); + } else { + await ( + serverTransport as StreamableHTTPClientTransport + ).terminateSession(); + webAppTransports.delete(sessionId); + serverTransports.delete(sessionId); + console.log(`Transports removed for sessionId ${sessionId}`); + } + res.status(200).end(); + } catch (error) { + console.error("Error in /mcp route:", error); + res.status(500).json(error); + } + } +}); + app.get("/stdio", async (req, res) => { try { console.log("New connection"); From b51ee625cddcf76608873c381c069a07195ace73 Mon Sep 17 00:00:00 2001 From: xiaoli Date: Fri, 30 May 2025 10:14:03 +0800 Subject: [PATCH 50/65] style: fix code style to pass CI checks --- client/src/lib/hooks/useConnection.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index fae904da..e4f1bc50 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -304,8 +304,8 @@ export function useConnection({ bearerToken || (await serverAuthProvider.tokens())?.access_token; if (token) { const authHeaderName = headerName || "Authorization"; - - // Add custom header name as a special request header to let the server know which header to pass through + + // Add custom header name as a special request header to let the server know which header to pass through if (authHeaderName.toLowerCase() !== "authorization") { headers[authHeaderName] = token; headers["x-custom-auth-header"] = authHeaderName; From 3510e1a56128a094638290531fec59fc820ffa2e Mon Sep 17 00:00:00 2001 From: xiaoli Date: Fri, 30 May 2025 14:43:09 +0800 Subject: [PATCH 51/65] fix: preserve capitalization in custom headers Express converts all header names to lowercase, which prevents custom headers with uppercase characters from being properly passed to the target service. This fix ensures that custom headers preserve their original capitalization when forwarded through the proxy. --- server/src/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index de14e286..962c90e8 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -70,8 +70,9 @@ const getHttpHeaders = ( // If the header "x-custom-auth-header" is present, use its value as the custom header name. if (req.headers["x-custom-auth-header"] !== undefined) { const customHeaderName = req.headers["x-custom-auth-header"] as string; - if (req.headers[customHeaderName] !== undefined) { - const value = req.headers[customHeaderName]; + const lowerCaseHeaderName = customHeaderName.toLowerCase(); + if (req.headers[lowerCaseHeaderName] !== undefined) { + const value = req.headers[lowerCaseHeaderName]; headers[customHeaderName] = value as string; } } From 11757ef6154283a0e5bacf04b61dfeda5d9e4cfe Mon Sep 17 00:00:00 2001 From: olaservo Date: Fri, 30 May 2025 07:46:28 -0700 Subject: [PATCH 52/65] Add simple architecture overview --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index d7c34b97..99356536 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,15 @@ The MCP inspector is a developer tool for testing and debugging MCP servers. ![MCP Inspector Screenshot](https://raw.githubusercontent.com/modelcontextprotocol/inspector/main/mcp-inspector.png) +## Architecture Overview + +The MCP Inspector consists of two main components that work together: + +- **MCP Inspector Client (MCPI)**: A React-based web UI that provides an interactive interface for testing and debugging MCP servers +- **MCP Proxy (MCPP)**: A Node.js server that acts as a protocol bridge, connecting the web UI to MCP servers via various transport methods (stdio, SSE, streamable-http) + +Note that the proxy is not a network proxy for intercepting traffic. Instead, it functions as both an MCP client (connecting to your MCP server) and an HTTP server (serving the web UI), enabling browser-based interaction with MCP servers that use different transport protocols. + ## Running the Inspector ### Requirements From 2d7936d10b7abeed521091cb3c3173230847abe7 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 2 Jun 2025 16:20:16 +0100 Subject: [PATCH 53/65] fix type errors --- client/src/components/OAuthDebugCallback.tsx | 3 ++- .../src/components/__tests__/AuthDebugger.test.tsx | 12 ++++++++++-- client/src/lib/auth-types.ts | 5 +---- package.json | 1 + 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx index 17705abb..99774297 100644 --- a/client/src/components/OAuthDebugCallback.tsx +++ b/client/src/components/OAuthDebugCallback.tsx @@ -4,6 +4,7 @@ import { generateOAuthErrorDescription, parseOAuthCallbackParams, } from "@/utils/oauthUtils.ts"; +import { AuthDebuggerState } from "@/lib/auth-types"; interface OAuthCallbackProps { onConnect: ({ @@ -13,7 +14,7 @@ interface OAuthCallbackProps { }: { authorizationCode?: string; errorMsg?: string; - restoredState?: any; + restoredState?: AuthDebuggerState; }) => void; } diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index b2fa4c61..772b3d43 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -441,8 +441,16 @@ describe("AuthDebugger", () => { const updateAuthState = jest.fn(); // Mock window.location.href setter - delete (window as any).location; - window.location = { href: "" } as any; + const originalLocation = window.location; + const locationMock = { + ...originalLocation, + href: "", + origin: "http://localhost:3000" + }; + Object.defineProperty(window, 'location', { + writable: true, + value: locationMock + }); // Setup mocks for OAuth flow mockStartAuthorization.mockResolvedValue({ diff --git a/client/src/lib/auth-types.ts b/client/src/lib/auth-types.ts index daf1bc15..5e8113ef 100644 --- a/client/src/lib/auth-types.ts +++ b/client/src/lib/auth-types.ts @@ -29,10 +29,7 @@ export interface AuthDebuggerState { oauthTokens: OAuthTokens | null; oauthStep: OAuthStep; resourceMetadata: OAuthProtectedResourceMetadata | null; - resourceMetadataError: - | Error - | { status: number; statusText: string; message: string } - | null; + resourceMetadataError: Error | null; authServerUrl: URL | null; oauthMetadata: OAuthMetadata | null; oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null; diff --git a/package.json b/package.json index a5e09c6a..6ee5719e 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "test-cli": "cd cli && npm run test", "prettier-fix": "prettier --write .", "prettier-check": "prettier --check .", + "lint": "prettier --check . && cd client && npm run lint", "prepare": "npm run build", "publish-all": "npm publish --workspaces --access public && npm publish --access public" }, From 98668507c798ce7010e209557ff35be2ebcad92e Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 2 Jun 2025 16:20:36 +0100 Subject: [PATCH 54/65] version bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6ee5719e..c0f7cf73 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@modelcontextprotocol/inspector-cli": "^0.13.0", "@modelcontextprotocol/inspector-client": "^0.13.0", "@modelcontextprotocol/inspector-server": "^0.13.0", - "@modelcontextprotocol/sdk": "^1.12.0", + "@modelcontextprotocol/sdk": "^1.12.1", "concurrently": "^9.0.1", "open": "^10.1.0", "shell-quote": "^1.8.2", From fa01fafa59e34285bd5e5580201dc4b3ec13ce18 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 2 Jun 2025 16:21:55 +0100 Subject: [PATCH 55/65] version bump + lint --- client/src/components/__tests__/AuthDebugger.test.tsx | 6 +++--- package-lock.json | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 772b3d43..3727128a 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -445,11 +445,11 @@ describe("AuthDebugger", () => { const locationMock = { ...originalLocation, href: "", - origin: "http://localhost:3000" + origin: "http://localhost:3000", }; - Object.defineProperty(window, 'location', { + Object.defineProperty(window, "location", { writable: true, - value: locationMock + value: locationMock, }); // Setup mocks for OAuth flow diff --git a/package-lock.json b/package-lock.json index 6e5d73f0..36588354 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@modelcontextprotocol/inspector-cli": "^0.13.0", "@modelcontextprotocol/inspector-client": "^0.13.0", "@modelcontextprotocol/inspector-server": "^0.13.0", - "@modelcontextprotocol/sdk": "^1.12.0", + "@modelcontextprotocol/sdk": "^1.12.1", "concurrently": "^9.0.1", "open": "^10.1.0", "shell-quote": "^1.8.2", @@ -2005,9 +2005,9 @@ "link": true }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.12.0.tgz", - "integrity": "sha512-m//7RlINx1F3sz3KqwY1WWzVgTcYX52HYk4bJ1hkBXV3zccAEth+jRvG8DBRrdaQuRsPAJOx2MH3zaHNCKL7Zg==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.12.1.tgz", + "integrity": "sha512-KG1CZhZfWg+u8pxeM/mByJDScJSrjjxLc8fwQqbsS8xCjBmQfMNEBTotYdNanKekepnfRI85GtgQlctLFpcYPw==", "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", From 9371006838de8224046eb8c0d3656b7eb9355af4 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 3 Jun 2025 11:02:34 +0100 Subject: [PATCH 56/65] less noisy errors --- client/src/components/__tests__/AuthDebugger.test.tsx | 7 +++++++ client/src/lib/oauth-state-machine.ts | 2 -- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 3727128a..2130e68b 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -104,6 +104,9 @@ describe("AuthDebugger", () => { jest.clearAllMocks(); sessionStorageMock.getItem.mockReturnValue(null); + // Supress + jest.spyOn(console, "error").mockImplementation(() => {}); + mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); mockRegisterClient.mockResolvedValue(mockOAuthClientInfo); mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue( @@ -124,6 +127,10 @@ describe("AuthDebugger", () => { mockExchangeAuthorization.mockResolvedValue(mockOAuthTokens); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + const renderAuthDebugger = (props: Partial = {}) => { const mergedProps = { ...defaultProps, diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index d05369c4..101a33cf 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -43,8 +43,6 @@ export const oauthTransitions: Record = { authServerUrl = new URL(resourceMetadata.authorization_servers[0]); } } catch (e) { - console.info(`Failed to find protected resource metadata: ${e}`); - console.log(e); if (e instanceof Error) { resourceMetadataError = e; } else { From e2d3989bb219cd34d249cb1d2cc4c5169e85ae30 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 3 Jun 2025 11:05:10 +0100 Subject: [PATCH 57/65] use scopes from PRM if available --- client/src/lib/oauth-state-machine.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index 101a33cf..5f10a783 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -72,9 +72,13 @@ export const oauthTransitions: Record = { const metadata = context.state.oauthMetadata!; const clientMetadata = context.provider.clientMetadata; + // Prefer scopes from resource metadata if available + const scopesSupported = + context.state.resourceMetadata?.scopes_supported || + metadata.scopes_supported; // Add all supported scopes to client registration - if (metadata.scopes_supported) { - clientMetadata.scope = metadata.scopes_supported.join(" "); + if (scopesSupported) { + clientMetadata.scope = scopesSupported.join(" "); } const fullInformation = await registerClient(context.serverUrl, { From a387c6ad7e5f006a3138aff5235e42d187cbb8e9 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 3 Jun 2025 17:29:22 +0100 Subject: [PATCH 58/65] hook warning --- client/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 00d3bb48..2fbc7494 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -307,7 +307,7 @@ const App = () => { }); } }, - [], + [sseUrl], ); // Load OAuth tokens when sseUrl changes From 2f50a4ba0eecc2774b4ac6544b10149452b96835 Mon Sep 17 00:00:00 2001 From: dr3s Date: Tue, 3 Jun 2025 13:58:33 -0400 Subject: [PATCH 59/65] fix: Resource template query parameters not appended to request URI #450 --- client/src/components/ResourcesTab.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index 35742799..1293a0f8 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -16,6 +16,7 @@ import ListPane from "./ListPane"; import { useEffect, useState } from "react"; import { useCompletionState } from "@/lib/hooks/useCompletionState"; import JsonView from "./JsonView"; +import { UriTemplate } from "@modelcontextprotocol/sdk/shared/uriTemplate.js"; const ResourcesTab = ({ resources, @@ -79,10 +80,10 @@ const ResourcesTab = ({ template: string, values: Record, ): string => { - return template.replace( - /{([^}]+)}/g, - (_, key) => values[key] || `{${key}}`, - ); + console.log("Expanding template:", template, "with values:", values); + const result = new UriTemplate(template).expand(values); + console.log("Filled template:", result); + return result; }; const handleTemplateValueChange = async (key: string, value: string) => { @@ -240,7 +241,8 @@ const ResourcesTab = ({ {selectedTemplate.uriTemplate .match(/{([^}]+)}/g) ?.map((param) => { - const key = param.slice(1, -1); + // Remove leading operator characters (?, &, /, #, ;, +, .) from variable name + const key = param.slice(1, -1).replace(/^[?&/#;+.]/, ""); return (
      From f1650d06e3d3a835ecdafe65c8e7ce19f129c317 Mon Sep 17 00:00:00 2001 From: dr3s Date: Tue, 3 Jun 2025 13:58:33 -0400 Subject: [PATCH 60/65] use UriTemplate variable names --- client/src/components/ResourcesTab.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index 1293a0f8..dd40d1b8 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -238,11 +238,8 @@ const ResourcesTab = ({

      {selectedTemplate.description}

      - {selectedTemplate.uriTemplate - .match(/{([^}]+)}/g) - ?.map((param) => { - // Remove leading operator characters (?, &, /, #, ;, +, .) from variable name - const key = param.slice(1, -1).replace(/^[?&/#;+.]/, ""); + {new UriTemplate(selectedTemplate.uriTemplate).variableNames + ?.map((key) => { return (
      From 07f3e4593b8bb2ffc9cee99fb7813a08ca85f27b Mon Sep 17 00:00:00 2001 From: dr3s Date: Tue, 3 Jun 2025 13:58:33 -0400 Subject: [PATCH 61/65] remove logs --- client/src/components/ResourcesTab.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index dd40d1b8..8795e46b 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -80,10 +80,7 @@ const ResourcesTab = ({ template: string, values: Record, ): string => { - console.log("Expanding template:", template, "with values:", values); - const result = new UriTemplate(template).expand(values); - console.log("Filled template:", result); - return result; + return new UriTemplate(template).expand(values); }; const handleTemplateValueChange = async (key: string, value: string) => { From 26f744866aefde5acaec7a2da6314b8c915ec9be Mon Sep 17 00:00:00 2001 From: dr3s Date: Tue, 3 Jun 2025 13:58:33 -0400 Subject: [PATCH 62/65] formatting --- client/src/components/ResourcesTab.tsx | 41 +++++++++++++------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index 8795e46b..8ec3f91b 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -235,26 +235,27 @@ const ResourcesTab = ({

      {selectedTemplate.description}

      - {new UriTemplate(selectedTemplate.uriTemplate).variableNames - ?.map((key) => { - return ( -
      - - - handleTemplateValueChange(key, value) - } - onInputChange={(value) => - handleTemplateValueChange(key, value) - } - options={completions[key] || []} - /> -
      - ); - })} + {new UriTemplate( + selectedTemplate.uriTemplate, + ).variableNames?.map((key) => { + return ( +
      + + + handleTemplateValueChange(key, value) + } + onInputChange={(value) => + handleTemplateValueChange(key, value) + } + options={completions[key] || []} + /> +
      + ); + })} + ), +})); + +describe("ResourcesTab - Template Query Parameters", () => { + const mockListResources = jest.fn(); + const mockClearResources = jest.fn(); + const mockListResourceTemplates = jest.fn(); + const mockClearResourceTemplates = jest.fn(); + const mockReadResource = jest.fn(); + const mockSetSelectedResource = jest.fn(); + const mockHandleCompletion = jest.fn(); + const mockSubscribeToResource = jest.fn(); + const mockUnsubscribeFromResource = jest.fn(); + + const mockResourceTemplate: ResourceTemplate = { + name: "Users API", + uriTemplate: "test://users{?name,limit,offset}", + description: "Fetch users with optional filtering and pagination", + }; + + const mockResource: Resource = { + uri: "test://users?name=john&limit=10&offset=0", + name: "Users Resource", + description: "Expanded users resource", + }; + + const defaultProps = { + resources: [], + resourceTemplates: [mockResourceTemplate], + listResources: mockListResources, + clearResources: mockClearResources, + listResourceTemplates: mockListResourceTemplates, + clearResourceTemplates: mockClearResourceTemplates, + readResource: mockReadResource, + selectedResource: null, + setSelectedResource: mockSetSelectedResource, + handleCompletion: mockHandleCompletion, + completionsSupported: true, + resourceContent: "", + nextCursor: undefined, + nextTemplateCursor: undefined, + error: null, + resourceSubscriptionsSupported: false, + resourceSubscriptions: new Set(), + subscribeToResource: mockSubscribeToResource, + unsubscribeFromResource: mockUnsubscribeFromResource, + }; + + const renderResourcesTab = (props = {}) => + render( + + + , + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should parse and display template variables from URI template", () => { + renderResourcesTab(); + + // Click on the resource template to select it + fireEvent.click(screen.getByText("Users API")); + + // Check that input fields are rendered for each template variable + expect(screen.getByTestId("combobox-name")).toBeInTheDocument(); + expect(screen.getByTestId("combobox-limit")).toBeInTheDocument(); + expect(screen.getByTestId("combobox-offset")).toBeInTheDocument(); + }); + + it("should display template description when template is selected", () => { + renderResourcesTab(); + + // Click on the resource template to select it + fireEvent.click(screen.getByText("Users API")); + + expect(screen.getByText("Fetch users with optional filtering and pagination")).toBeInTheDocument(); + }); + + it("should handle template value changes", () => { + renderResourcesTab(); + + // Click on the resource template to select it + fireEvent.click(screen.getByText("Users API")); + + // Find and fill template value inputs + const nameInput = screen.getByTestId("combobox-name"); + const limitInput = screen.getByTestId("combobox-limit"); + const offsetInput = screen.getByTestId("combobox-offset"); + + fireEvent.change(nameInput, { target: { value: "john" } }); + fireEvent.change(limitInput, { target: { value: "10" } }); + fireEvent.change(offsetInput, { target: { value: "0" } }); + + expect(nameInput).toHaveValue("john"); + expect(limitInput).toHaveValue("10"); + expect(offsetInput).toHaveValue("0"); + }); + + it("should expand template and read resource when Read Resource button is clicked", async () => { + renderResourcesTab(); + + // Click on the resource template to select it + fireEvent.click(screen.getByText("Users API")); + + // Fill template values + const nameInput = screen.getByTestId("combobox-name"); + const limitInput = screen.getByTestId("combobox-limit"); + const offsetInput = screen.getByTestId("combobox-offset"); + + fireEvent.change(nameInput, { target: { value: "john" } }); + fireEvent.change(limitInput, { target: { value: "10" } }); + fireEvent.change(offsetInput, { target: { value: "0" } }); + + // Click Read Resource button + const readResourceButton = screen.getByText("Read Resource"); + expect(readResourceButton).not.toBeDisabled(); + + fireEvent.click(readResourceButton); + + // Verify that readResource was called with the expanded URI + expect(mockReadResource).toHaveBeenCalledWith("test://users?name=john&limit=10&offset=0"); + + // Verify that setSelectedResource was called with the expanded resource + expect(mockSetSelectedResource).toHaveBeenCalledWith({ + uri: "test://users?name=john&limit=10&offset=0", + name: "test://users?name=john&limit=10&offset=0" + }); + }); + + it("should disable Read Resource button when no template values are provided", () => { + renderResourcesTab(); + + // Click on the resource template to select it + fireEvent.click(screen.getByText("Users API")); + + // Read Resource button should be disabled when no values are provided + const readResourceButton = screen.getByText("Read Resource"); + expect(readResourceButton).toBeDisabled(); + }); + + it("should handle partial template values correctly", () => { + renderResourcesTab(); + + // Click on the resource template to select it + fireEvent.click(screen.getByText("Users API")); + + // Fill only some template values + const nameInput = screen.getByTestId("combobox-name"); + fireEvent.change(nameInput, { target: { value: "john" } }); + + // Read Resource button should be enabled with partial values + const readResourceButton = screen.getByText("Read Resource"); + expect(readResourceButton).not.toBeDisabled(); + + fireEvent.click(readResourceButton); + + // Should expand with only the provided values + expect(mockReadResource).toHaveBeenCalledWith("test://users?name=john"); + }); + + it("should handle special characters in template values", () => { + renderResourcesTab(); + + // Click on the resource template to select it + fireEvent.click(screen.getByText("Users API")); + + // Fill template values with special characters + const nameInput = screen.getByTestId("combobox-name"); + fireEvent.change(nameInput, { target: { value: "john doe" } }); + + fireEvent.click(screen.getByText("Read Resource")); + + // Should properly encode special characters + expect(mockReadResource).toHaveBeenCalledWith("test://users?name=john%20doe"); + }); + + it("should clear template values when switching between templates", () => { + const anotherTemplate: ResourceTemplate = { + name: "Posts API", + uriTemplate: "test://posts{?author,category}", + description: "Fetch posts by author and category", + }; + + renderResourcesTab({ + resourceTemplates: [mockResourceTemplate, anotherTemplate] + }); + + // Select first template and fill values + fireEvent.click(screen.getByText("Users API")); + const nameInput = screen.getByTestId("combobox-name"); + fireEvent.change(nameInput, { target: { value: "john" } }); + + // Switch to second template + fireEvent.click(screen.getByText("Posts API")); + + // Should show new template fields and clear previous values + expect(screen.getByTestId("combobox-author")).toBeInTheDocument(); + expect(screen.getByTestId("combobox-category")).toBeInTheDocument(); + expect(screen.queryByTestId("combobox-name")).not.toBeInTheDocument(); + }); + + it("should display resource content when a resource is selected", () => { + const resourceContent = '{"users": [{"id": 1, "name": "John"}]}'; + + renderResourcesTab({ + selectedResource: mockResource, + resourceContent: resourceContent + }); + + expect(screen.getByTestId("json-view")).toBeInTheDocument(); + expect(screen.getByText(resourceContent)).toBeInTheDocument(); + }); + + it("should show alert when no resource or template is selected", () => { + renderResourcesTab(); + + expect(screen.getByText("Select a resource or template from the list to view its contents")).toBeInTheDocument(); + }); +}); From 9508cc46271b5965accda35e98c3a93f5cc4de96 Mon Sep 17 00:00:00 2001 From: dr3s Date: Wed, 4 Jun 2025 14:44:15 -0400 Subject: [PATCH 64/65] pretty --- .../__tests__/ResourcesTab.test.tsx | 51 ++++++++++++++----- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/client/src/components/__tests__/ResourcesTab.test.tsx b/client/src/components/__tests__/ResourcesTab.test.tsx index dd12cced..d564b02c 100644 --- a/client/src/components/__tests__/ResourcesTab.test.tsx +++ b/client/src/components/__tests__/ResourcesTab.test.tsx @@ -20,7 +20,12 @@ jest.mock("../JsonView", () => { }); jest.mock("@/components/ui/combobox", () => ({ - Combobox: ({ id, value, onChange, placeholder }: { + Combobox: ({ + id, + value, + onChange, + placeholder, + }: { id: string; value: string; onChange: (value: string) => void; @@ -37,7 +42,10 @@ jest.mock("@/components/ui/combobox", () => ({ })); jest.mock("@/components/ui/label", () => ({ - Label: ({ htmlFor, children }: { + Label: ({ + htmlFor, + children, + }: { htmlFor: string; children: React.ReactNode; }) => ( @@ -48,7 +56,12 @@ jest.mock("@/components/ui/label", () => ({ })); jest.mock("@/components/ui/button", () => ({ - Button: ({ children, onClick, disabled, ...props }: { + Button: ({ + children, + onClick, + disabled, + ...props + }: { children: React.ReactNode; onClick?: () => void; disabled?: boolean; @@ -139,7 +152,9 @@ describe("ResourcesTab - Template Query Parameters", () => { // Click on the resource template to select it fireEvent.click(screen.getByText("Users API")); - expect(screen.getByText("Fetch users with optional filtering and pagination")).toBeInTheDocument(); + expect( + screen.getByText("Fetch users with optional filtering and pagination"), + ).toBeInTheDocument(); }); it("should handle template value changes", () => { @@ -180,16 +195,18 @@ describe("ResourcesTab - Template Query Parameters", () => { // Click Read Resource button const readResourceButton = screen.getByText("Read Resource"); expect(readResourceButton).not.toBeDisabled(); - + fireEvent.click(readResourceButton); // Verify that readResource was called with the expanded URI - expect(mockReadResource).toHaveBeenCalledWith("test://users?name=john&limit=10&offset=0"); - + expect(mockReadResource).toHaveBeenCalledWith( + "test://users?name=john&limit=10&offset=0", + ); + // Verify that setSelectedResource was called with the expanded resource expect(mockSetSelectedResource).toHaveBeenCalledWith({ uri: "test://users?name=john&limit=10&offset=0", - name: "test://users?name=john&limit=10&offset=0" + name: "test://users?name=john&limit=10&offset=0", }); }); @@ -217,7 +234,7 @@ describe("ResourcesTab - Template Query Parameters", () => { // Read Resource button should be enabled with partial values const readResourceButton = screen.getByText("Read Resource"); expect(readResourceButton).not.toBeDisabled(); - + fireEvent.click(readResourceButton); // Should expand with only the provided values @@ -237,7 +254,9 @@ describe("ResourcesTab - Template Query Parameters", () => { fireEvent.click(screen.getByText("Read Resource")); // Should properly encode special characters - expect(mockReadResource).toHaveBeenCalledWith("test://users?name=john%20doe"); + expect(mockReadResource).toHaveBeenCalledWith( + "test://users?name=john%20doe", + ); }); it("should clear template values when switching between templates", () => { @@ -248,7 +267,7 @@ describe("ResourcesTab - Template Query Parameters", () => { }; renderResourcesTab({ - resourceTemplates: [mockResourceTemplate, anotherTemplate] + resourceTemplates: [mockResourceTemplate, anotherTemplate], }); // Select first template and fill values @@ -267,10 +286,10 @@ describe("ResourcesTab - Template Query Parameters", () => { it("should display resource content when a resource is selected", () => { const resourceContent = '{"users": [{"id": 1, "name": "John"}]}'; - + renderResourcesTab({ selectedResource: mockResource, - resourceContent: resourceContent + resourceContent: resourceContent, }); expect(screen.getByTestId("json-view")).toBeInTheDocument(); @@ -280,6 +299,10 @@ describe("ResourcesTab - Template Query Parameters", () => { it("should show alert when no resource or template is selected", () => { renderResourcesTab(); - expect(screen.getByText("Select a resource or template from the list to view its contents")).toBeInTheDocument(); + expect( + screen.getByText( + "Select a resource or template from the list to view its contents", + ), + ).toBeInTheDocument(); }); }); From 8ef91e359ecd5d51af8aa0dd315080b47389e19e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 4 Jun 2025 16:13:35 -0400 Subject: [PATCH 65/65] Bump Inspector packages to 0.14.0, and sdk to 1.12.1 --- cli/package.json | 4 ++-- client/package.json | 4 ++-- package-lock.json | 22 +++++++++++----------- package.json | 8 ++++---- server/package.json | 4 ++-- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/cli/package.json b/cli/package.json index 55f12e15..bd027bfe 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.13.0", + "version": "0.14.0", "description": "CLI for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -21,7 +21,7 @@ }, "devDependencies": {}, "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.5", + "@modelcontextprotocol/sdk": "^1.12.1", "commander": "^13.1.0", "spawn-rx": "^5.1.2" } diff --git a/client/package.json b/client/package.json index ee54df3d..daf549fc 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.13.0", + "version": "0.14.0", "description": "Client-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -23,7 +23,7 @@ "test:watch": "jest --config jest.config.cjs --watch" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.5", + "@modelcontextprotocol/sdk": "^1.12.1", "@radix-ui/react-checkbox": "^1.1.4", "ajv": "^6.12.6", "@radix-ui/react-dialog": "^1.1.3", diff --git a/package-lock.json b/package-lock.json index 36588354..846d2eaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.13.0", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "0.13.0", + "version": "0.14.0", "license": "MIT", "workspaces": [ "client", @@ -14,9 +14,9 @@ "cli" ], "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.13.0", - "@modelcontextprotocol/inspector-client": "^0.13.0", - "@modelcontextprotocol/inspector-server": "^0.13.0", + "@modelcontextprotocol/inspector-cli": "^0.14.0", + "@modelcontextprotocol/inspector-client": "^0.14.0", + "@modelcontextprotocol/inspector-server": "^0.14.0", "@modelcontextprotocol/sdk": "^1.12.1", "concurrently": "^9.0.1", "open": "^10.1.0", @@ -40,10 +40,10 @@ }, "cli": { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.13.0", + "version": "0.14.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.5", + "@modelcontextprotocol/sdk": "^1.12.1", "commander": "^13.1.0", "spawn-rx": "^5.1.2" }, @@ -63,10 +63,10 @@ }, "client": { "name": "@modelcontextprotocol/inspector-client", - "version": "0.13.0", + "version": "0.14.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.5", + "@modelcontextprotocol/sdk": "^1.12.1", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", @@ -10920,10 +10920,10 @@ }, "server": { "name": "@modelcontextprotocol/inspector-server", - "version": "0.13.0", + "version": "0.14.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.5", + "@modelcontextprotocol/sdk": "^1.12.1", "cors": "^2.8.5", "express": "^5.1.0", "ws": "^8.18.0", diff --git a/package.json b/package.json index c0f7cf73..8742cc8b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.13.0", + "version": "0.14.0", "description": "Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -41,9 +41,9 @@ "publish-all": "npm publish --workspaces --access public && npm publish --access public" }, "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.13.0", - "@modelcontextprotocol/inspector-client": "^0.13.0", - "@modelcontextprotocol/inspector-server": "^0.13.0", + "@modelcontextprotocol/inspector-cli": "^0.14.0", + "@modelcontextprotocol/inspector-client": "^0.14.0", + "@modelcontextprotocol/inspector-server": "^0.14.0", "@modelcontextprotocol/sdk": "^1.12.1", "concurrently": "^9.0.1", "open": "^10.1.0", diff --git a/server/package.json b/server/package.json index a3333da0..50d74cfc 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-server", - "version": "0.13.0", + "version": "0.14.0", "description": "Server-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -27,7 +27,7 @@ "typescript": "^5.6.2" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.5", + "@modelcontextprotocol/sdk": "^1.12.1", "cors": "^2.8.5", "express": "^5.1.0", "ws": "^8.18.0",