From d16e89df1ecfe0f92741dfcb565b9a30ed2f5e0a Mon Sep 17 00:00:00 2001 From: olaservo Date: Tue, 29 Apr 2025 21:32:21 -0700 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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 fa236ea2c7bf519ab40f6dcef8a7e9fdb4bbe665 Mon Sep 17 00:00:00 2001 From: Shane Date: Tue, 6 May 2025 15:36:18 -0700 Subject: [PATCH 08/21] 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 09/21] 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 10/21] 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 a61a1b0df2b557653f55933f98e9781b14ec98c2 Mon Sep 17 00:00:00 2001 From: Shane Date: Tue, 20 May 2025 09:25:47 -0700 Subject: [PATCH 11/21] 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 80377bc914bb6f05a3a695cf3d2d99439e582b4d Mon Sep 17 00:00:00 2001 From: Shane Date: Tue, 20 May 2025 22:28:26 -0700 Subject: [PATCH 12/21] 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 13/21] 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 7b1299c730d34594b85c44bd6eb42ff22f04701c Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 27 May 2025 11:36:19 +0100 Subject: [PATCH 14/21] [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 | 52 +++++++++++------ client/src/components/OAuthFlowProgress.tsx | 57 +++++++++++++++---- .../__tests__/AuthDebugger.test.tsx | 8 +-- 3 files changed, 81 insertions(+), 36 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"); - } - const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); - serverAuthProvider.saveServerMetadata(parsedMetadata); + // 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, + }; - await auth(serverAuthProvider, { serverUrl: serverUrl }); + 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; + } + } + + // 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 692c863f5beaeeecd6bcba56a05d362bb46ec980 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Wed, 28 May 2025 12:56:55 -0400 Subject: [PATCH 15/21] 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 de84c472ce2862abc77b7e9e98377b6583785adb Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Tue, 27 May 2025 16:39:39 -0400 Subject: [PATCH 16/21] 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:

+ +
+
+ +
+
+ )}
-
+
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 19/21] 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 20/21] 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 d8a2821344d1d06225c01a12bf13d91863fe20b3 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Wed, 28 May 2025 22:18:19 -0400 Subject: [PATCH 21/21] 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"