diff --git a/cli/package.json b/cli/package.json index 53f2aa6b..55f12e15 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.12.0", + "version": "0.13.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.0", + "@modelcontextprotocol/sdk": "^1.11.5", "commander": "^13.1.0", "spawn-rx": "^5.1.2" } diff --git a/client/package.json b/client/package.json index 2bcf2b03..ee54df3d 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.12.0", + "version": "0.13.0", "description": "Client-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -23,8 +23,9 @@ "test:watch": "jest --config jest.config.cjs --watch" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.0", + "@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/App.tsx b/client/src/App.tsx index c32f4840..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, @@ -234,7 +235,6 @@ const App = () => { const onOAuthConnect = useCallback( (serverUrl: string) => { setSseUrl(serverUrl); - setTransportType("sse"); setIsAuthDebuggerVisible(false); void connectMcpServer(); }, @@ -474,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) => { @@ -760,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/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/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/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 aa67bfcf..8c72bd2a 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -9,15 +9,15 @@ import DynamicJsonForm from "./DynamicJsonForm"; import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils"; import { generateDefaultValue } 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"; +import ToolResults from "./ToolResults"; const ToolsTab = ({ tools, @@ -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,75 +53,6 @@ const ToolsTab = ({ setParams(Object.fromEntries(params)); }, [selectedTool]); - 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; - - return ( - <> -

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

- {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 (
@@ -262,6 +194,42 @@ const ToolsTab = ({ ); }, )} + {selectedTool.outputSchema && ( +
+
+

Output Schema:

+ +
+
+ +
+
+ )} - {toolResult && renderToolResult()} +
) : ( 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", }), ); }); 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", }, }, }, diff --git a/client/src/components/__tests__/ToolsTab.test.tsx b/client/src/components/__tests__/ToolsTab.test.tsx index c46a32fc..c9f9b315 100644 --- a/client/src/components/__tests__/ToolsTab.test.tsx +++ b/client/src/components/__tests__/ToolsTab.test.tsx @@ -1,11 +1,17 @@ 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 { cacheToolOutputSchemas } from "@/utils/schemaUtils"; describe("ToolsTab", () => { + beforeEach(() => { + // Clear the output schema cache before each test + cacheToolOutputSchemas([]); + }); + const mockTools: Tool[] = [ { name: "tool1", @@ -141,4 +147,217 @@ describe("ToolsTab", () => { expect(submitButton.getAttribute("disabled")).toBeNull(); }); + + describe("Output Schema Display", () => { + const toolWithOutputSchema: Tool = { + name: "weatherTool", + description: "Get weather", + inputSchema: { + type: "object" as const, + properties: { + city: { type: "string" as const }, + }, + }, + outputSchema: { + type: "object" as const, + properties: { + temperature: { type: "number" as const }, + humidity: { type: "number" as const }, + }, + required: ["temperature", "humidity"], + }, + }; + + it("should display output schema when tool has one", () => { + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + }); + + expect(screen.getByText("Output Schema:")).toBeInTheDocument(); + // Check for expand/collapse button + expect( + screen.getByRole("button", { name: /expand/i }), + ).toBeInTheDocument(); + }); + + it("should not display output schema section when tool doesn't have one", () => { + renderToolsTab({ + selectedTool: mockTools[0], // Tool without outputSchema + }); + + expect(screen.queryByText("Output Schema:")).not.toBeInTheDocument(); + }); + + it("should toggle output schema expansion", () => { + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + }); + + const toggleButton = screen.getByRole("button", { name: /expand/i }); + + // Click to expand + fireEvent.click(toggleButton); + expect( + screen.getByRole("button", { name: /collapse/i }), + ).toBeInTheDocument(); + + // Click to collapse + fireEvent.click(toggleButton); + expect( + screen.getByRole("button", { name: /expand/i }), + ).toBeInTheDocument(); + }); + }); + + describe("Structured Output Results", () => { + const toolWithOutputSchema: Tool = { + name: "weatherTool", + description: "Get weather", + inputSchema: { + type: "object" as const, + properties: {}, + }, + outputSchema: { + type: "object" as const, + properties: { + temperature: { type: "number" as const }, + }, + required: ["temperature"], + }, + }; + + it("should display structured content when present", () => { + // Cache the tool's output schema so hasOutputSchema returns true + cacheToolOutputSchemas([toolWithOutputSchema]); + + const structuredResult = { + content: [], + structuredContent: { + temperature: 25, + }, + }; + + renderToolsTab({ + selectedTool: toolWithOutputSchema, + toolResult: structuredResult, + }); + + expect(screen.getByText("Structured Content:")).toBeInTheDocument(); + expect( + screen.getByText(/Valid according to output schema/), + ).toBeInTheDocument(); + }); + + it("should show validation error for invalid structured content", () => { + cacheToolOutputSchemas([toolWithOutputSchema]); + + const invalidResult = { + content: [], + structuredContent: { + temperature: "25", // String instead of number + }, + }; + + renderToolsTab({ + selectedTool: toolWithOutputSchema, + toolResult: invalidResult, + }); + + expect(screen.getByText(/Validation Error:/)).toBeInTheDocument(); + }); + + it("should show error when tool with output schema doesn't return structured content", () => { + cacheToolOutputSchemas([toolWithOutputSchema]); + + const resultWithoutStructured = { + content: [{ type: "text", text: "some result" }], + // No structuredContent + }; + + renderToolsTab({ + selectedTool: toolWithOutputSchema, + toolResult: resultWithoutStructured, + }); + + expect( + screen.getByText( + /Tool has an output schema but did not return structured content/, + ), + ).toBeInTheDocument(); + }); + + it("should show unstructured content title when both structured and unstructured exist", () => { + cacheToolOutputSchemas([toolWithOutputSchema]); + + const resultWithBoth = { + content: [{ type: "text", text: '{"temperature": 25}' }], + structuredContent: { temperature: 25 }, + }; + + renderToolsTab({ + selectedTool: toolWithOutputSchema, + toolResult: resultWithBoth, + }); + + expect(screen.getByText("Structured Content:")).toBeInTheDocument(); + expect(screen.getByText("Unstructured Content:")).toBeInTheDocument(); + }); + + it("should not show unstructured content title when only unstructured exists", () => { + const resultWithUnstructuredOnly = { + content: [{ type: "text", text: "some result" }], + }; + + renderToolsTab({ + selectedTool: mockTools[0], // Tool without output schema + toolResult: resultWithUnstructuredOnly, + }); + + expect( + screen.queryByText("Unstructured Content:"), + ).not.toBeInTheDocument(); + }); + + it("should show compatibility check when tool has output schema", () => { + cacheToolOutputSchemas([toolWithOutputSchema]); + + const compatibleResult = { + content: [{ type: "text", text: '{"temperature": 25}' }], + structuredContent: { temperature: 25 }, + }; + + renderToolsTab({ + selectedTool: toolWithOutputSchema, + toolResult: compatibleResult, + }); + + // Should show compatibility result + expect( + screen.getByText( + /matches structured content|not a single text block|not valid JSON|does not match/, + ), + ).toBeInTheDocument(); + }); + + it("should not show compatibility check when tool has no output schema", () => { + const resultWithBoth = { + content: [{ type: "text", text: '{"data": "value"}' }], + structuredContent: { different: "data" }, + }; + + renderToolsTab({ + selectedTool: mockTools[0], // Tool without output schema + toolResult: resultWithBoth, + }); + + // Should not show any compatibility messages + expect( + screen.queryByText( + /matches structured content|not a single text block|not valid JSON|does not match/, + ), + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index f1c29b2d..fae904da 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -249,9 +249,16 @@ export function useConnection({ } }; + const is401Error = (error: unknown): boolean => { + return ( + (error instanceof SseError && error.code === 401) || + (error instanceof Error && error.message.includes("401")) || + (error instanceof Error && error.message.includes("Unauthorized")) + ); + }; + const handleAuthError = async (error: unknown) => { - if (error instanceof SseError && error.code === 401) { - // Create a new auth provider with the current server URL + if (is401Error(error)) { const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); const result = await auth(serverAuthProvider, { serverUrl: sseUrl }); @@ -337,7 +344,6 @@ export function useConnection({ mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`); mcpProxyServerUrl.searchParams.append("url", sseUrl); transportOptions = { - authProvider: serverAuthProvider, eventSourceInit: { fetch: ( url: string | URL | globalThis.Request, @@ -354,7 +360,6 @@ export function useConnection({ mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`); mcpProxyServerUrl.searchParams.append("url", sseUrl); transportOptions = { - authProvider: serverAuthProvider, eventSourceInit: { fetch: ( url: string | URL | globalThis.Request, @@ -432,13 +437,14 @@ export function useConnection({ `Failed to connect to MCP Server via the MCP Inspector Proxy: ${mcpProxyServerUrl}:`, error, ); + const shouldRetry = await handleAuthError(error); if (shouldRetry) { return connect(undefined, retryCount + 1); } - - if (error instanceof SseError && error.code === 401) { + if (is401Error(error)) { // Don't set error state if we're about to redirect for auth + return; } throw error; diff --git a/client/src/utils/__tests__/schemaUtils.test.ts b/client/src/utils/__tests__/schemaUtils.test.ts index 94e428ac..ca6b65ca 100644 --- a/client/src/utils/__tests__/schemaUtils.test.ts +++ b/client/src/utils/__tests__/schemaUtils.test.ts @@ -1,5 +1,13 @@ -import { generateDefaultValue, formatFieldLabel } from "../schemaUtils"; +import { + generateDefaultValue, + formatFieldLabel, + cacheToolOutputSchemas, + getToolOutputValidator, + validateToolOutput, + hasOutputSchema, +} from "../schemaUtils"; import type { JsonSchemaType } from "../jsonUtils"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; describe("generateDefaultValue", () => { test("generates default string", () => { @@ -137,3 +145,220 @@ describe("formatFieldLabel", () => { expect(formatFieldLabel("")).toBe(""); }); }); + +describe("Output Schema Validation", () => { + const mockTools: Tool[] = [ + { + name: "weatherTool", + description: "Get weather information", + inputSchema: { + type: "object", + properties: { + city: { type: "string" }, + }, + }, + outputSchema: { + type: "object", + properties: { + temperature: { type: "number" }, + humidity: { type: "number" }, + }, + required: ["temperature", "humidity"], + }, + }, + { + name: "noOutputSchema", + description: "Tool without output schema", + inputSchema: { + type: "object", + properties: {}, + }, + }, + { + name: "complexOutputSchema", + description: "Tool with complex output schema", + inputSchema: { + type: "object", + properties: {}, + }, + outputSchema: { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + }, + tags: { + type: "array", + items: { type: "string" }, + }, + }, + required: ["user"], + }, + }, + ]; + + beforeEach(() => { + // Clear cache before each test + cacheToolOutputSchemas([]); + }); + + describe("cacheToolOutputSchemas", () => { + test("caches validators for tools with output schemas", () => { + cacheToolOutputSchemas(mockTools); + + expect(hasOutputSchema("weatherTool")).toBe(true); + expect(hasOutputSchema("complexOutputSchema")).toBe(true); + expect(hasOutputSchema("noOutputSchema")).toBe(false); + }); + + test("clears existing cache when called", () => { + cacheToolOutputSchemas(mockTools); + expect(hasOutputSchema("weatherTool")).toBe(true); + + cacheToolOutputSchemas([]); + expect(hasOutputSchema("weatherTool")).toBe(false); + }); + + test("handles invalid output schemas gracefully", () => { + const toolsWithInvalidSchema: Tool[] = [ + { + name: "invalidSchemaTool", + description: "Tool with invalid schema", + inputSchema: { type: "object", properties: {} }, + outputSchema: { + // @ts-expect-error Testing with invalid type + type: "invalid-type", + }, + }, + ]; + + // Should not throw + expect(() => + cacheToolOutputSchemas(toolsWithInvalidSchema), + ).not.toThrow(); + expect(hasOutputSchema("invalidSchemaTool")).toBe(false); + }); + }); + + describe("validateToolOutput", () => { + beforeEach(() => { + cacheToolOutputSchemas(mockTools); + }); + + test("validates correct structured content", () => { + const result = validateToolOutput("weatherTool", { + temperature: 25.5, + humidity: 60, + }); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + test("rejects invalid structured content", () => { + const result = validateToolOutput("weatherTool", { + temperature: "25.5", // Should be number + humidity: 60, + }); + + expect(result.isValid).toBe(false); + expect(result.error).toContain("should be number"); + }); + + test("rejects missing required fields", () => { + const result = validateToolOutput("weatherTool", { + temperature: 25.5, + // Missing humidity + }); + + expect(result.isValid).toBe(false); + expect(result.error).toContain("required"); + }); + + test("validates complex nested structures", () => { + const validResult = validateToolOutput("complexOutputSchema", { + user: { + name: "John", + age: 30, + }, + tags: ["tag1", "tag2"], + }); + + expect(validResult.isValid).toBe(true); + + const invalidResult = validateToolOutput("complexOutputSchema", { + user: { + // Missing required 'name' + age: 30, + }, + }); + + expect(invalidResult.isValid).toBe(false); + }); + + test("returns valid for tools without validators", () => { + const result = validateToolOutput("nonExistentTool", { any: "data" }); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + test("validates additional properties restriction", () => { + const result = validateToolOutput("weatherTool", { + temperature: 25.5, + humidity: 60, + extraField: "should not be here", + }); + + // This depends on whether additionalProperties is set to false in the schema + // If it is, this should fail + expect(result.isValid).toBe(true); // By default, additional properties are allowed + }); + }); + + describe("getToolOutputValidator", () => { + beforeEach(() => { + cacheToolOutputSchemas(mockTools); + }); + + test("returns validator for cached tool", () => { + const validator = getToolOutputValidator("weatherTool"); + expect(validator).toBeDefined(); + expect(typeof validator).toBe("function"); + }); + + test("returns undefined for tool without output schema", () => { + const validator = getToolOutputValidator("noOutputSchema"); + expect(validator).toBeUndefined(); + }); + + test("returns undefined for non-existent tool", () => { + const validator = getToolOutputValidator("nonExistentTool"); + expect(validator).toBeUndefined(); + }); + }); + + describe("hasOutputSchema", () => { + beforeEach(() => { + cacheToolOutputSchemas(mockTools); + }); + + test("returns true for tools with output schemas", () => { + expect(hasOutputSchema("weatherTool")).toBe(true); + expect(hasOutputSchema("complexOutputSchema")).toBe(true); + }); + + test("returns false for tools without output schemas", () => { + expect(hasOutputSchema("noOutputSchema")).toBe(false); + }); + + test("returns false for non-existent tools", () => { + expect(hasOutputSchema("nonExistentTool")).toBe(false); + }); + }); +}); diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts index 520b7908..30210532 100644 --- a/client/src/utils/schemaUtils.ts +++ b/client/src/utils/schemaUtils.ts @@ -1,4 +1,82 @@ import type { JsonValue, JsonSchemaType, JsonObject } from "./jsonUtils"; +import Ajv from "ajv"; +import type { ValidateFunction } from "ajv"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; + +const ajv = new Ajv(); + +// Cache for compiled validators +const toolOutputValidators = new Map(); + +/** + * Compiles and caches output schema validators for a list of tools + * Following the same pattern as SDK's Client.cacheToolOutputSchemas + * @param tools Array of tools that may have output schemas + */ +export function cacheToolOutputSchemas(tools: Tool[]): void { + toolOutputValidators.clear(); + for (const tool of tools) { + if (tool.outputSchema) { + try { + 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, + ); + } + } + } +} + +/** + * Gets the cached output schema validator for a tool + * Following the same pattern as SDK's Client.getToolOutputValidator + * @param toolName Name of the tool + * @returns The compiled validator function, or undefined if not found + */ +export function getToolOutputValidator( + toolName: string, +): ValidateFunction | undefined { + return toolOutputValidators.get(toolName); +} + +/** + * Validates structured content against a tool's output schema + * Returns validation result with detailed error messages + * @param toolName Name of the tool + * @param structuredContent The structured content to validate + * @returns An object with isValid boolean and optional error message + */ +export function validateToolOutput( + toolName: string, + structuredContent: unknown, +): { isValid: boolean; error?: string } { + const validator = getToolOutputValidator(toolName); + if (!validator) { + return { isValid: true }; // No validator means no schema to validate against + } + + const isValid = validator(structuredContent); + if (!isValid) { + return { + isValid: false, + error: ajv.errorsText(validator.errors), + }; + } + + return { isValid: true }; +} + +/** + * Checks if a tool has an output schema + * @param toolName Name of the tool + * @returns true if the tool has an output schema + */ +export function hasOutputSchema(toolName: string): boolean { + return toolOutputValidators.has(toolName); +} /** * Generates a default value based on a JSON schema type diff --git a/package-lock.json b/package-lock.json index 92e89f74..2920ca59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.12.0", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "workspaces": [ "client", @@ -14,10 +14,10 @@ "cli" ], "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.12.0", - "@modelcontextprotocol/inspector-client": "^0.12.0", - "@modelcontextprotocol/inspector-server": "^0.12.0", - "@modelcontextprotocol/sdk": "^1.11.2", + "@modelcontextprotocol/inspector-cli": "^0.13.0", + "@modelcontextprotocol/inspector-client": "^0.13.0", + "@modelcontextprotocol/inspector-server": "^0.13.0", + "@modelcontextprotocol/sdk": "^1.11.5", "concurrently": "^9.0.1", "open": "^10.1.0", "shell-quote": "^1.8.2", @@ -40,10 +40,10 @@ }, "cli": { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.0", + "@modelcontextprotocol/sdk": "^1.11.5", "commander": "^13.1.0", "spawn-rx": "^5.1.2" }, @@ -63,10 +63,10 @@ }, "client": { "name": "@modelcontextprotocol/inspector-client", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.0", + "@modelcontextprotocol/sdk": "^1.11.5", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", @@ -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", @@ -2004,13 +2005,15 @@ "link": true }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.2.tgz", - "integrity": "sha512-H9vwztj5OAqHg9GockCQC06k1natgcxWQSRpQcPJf6i5+MWBzfKkRtxGbjQf0X2ihii0ffLZCRGbYV2f2bjNCQ==", + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.5.tgz", + "integrity": "sha512-gS7Q7IHpKxjVaNLMUZyTtatZ63ca3h418zPPntAhu/MvG5yfz/8HMcDAOpvpQfx3V3dsw9QQxk8RuFNrQhLlgA==", + "license": "MIT", "dependencies": { + "ajv": "^8.17.1", "content-type": "^1.0.5", "cors": "^2.8.5", - "cross-spawn": "^7.0.3", + "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", @@ -2023,6 +2026,28 @@ "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", @@ -3978,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", @@ -5652,7 +5676,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -5687,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": { @@ -5697,6 +5719,22 @@ "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", @@ -7701,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": { @@ -8407,15 +8444,6 @@ "node": "20 || >=22" } }, - "node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", - "license": "MIT", - "engines": { - "node": ">=16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8782,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" @@ -9037,6 +9064,15 @@ "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", @@ -10449,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" @@ -10933,10 +10968,10 @@ }, "server": { "name": "@modelcontextprotocol/inspector-server", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.0", + "@modelcontextprotocol/sdk": "^1.11.5", "cors": "^2.8.5", "express": "^5.1.0", "ws": "^8.18.0", diff --git a/package.json b/package.json index dcbd3bf1..d46b3ef8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.12.0", + "version": "0.13.0", "description": "Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -40,10 +40,10 @@ "publish-all": "npm publish --workspaces --access public && npm publish --access public" }, "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.12.0", - "@modelcontextprotocol/inspector-client": "^0.12.0", - "@modelcontextprotocol/inspector-server": "^0.12.0", - "@modelcontextprotocol/sdk": "^1.11.2", + "@modelcontextprotocol/inspector-cli": "^0.13.0", + "@modelcontextprotocol/inspector-client": "^0.13.0", + "@modelcontextprotocol/inspector-server": "^0.13.0", + "@modelcontextprotocol/sdk": "^1.11.5", "concurrently": "^9.0.1", "open": "^10.1.0", "shell-quote": "^1.8.2", diff --git a/server/package.json b/server/package.json index 72723d3f..a3333da0 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-server", - "version": "0.12.0", + "version": "0.13.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.0", + "@modelcontextprotocol/sdk": "^1.11.5", "cors": "^2.8.5", "express": "^5.1.0", "ws": "^8.18.0", diff --git a/server/src/mcpProxy.ts b/server/src/mcpProxy.ts index b93c0cdf..12660504 100644 --- a/server/src/mcpProxy.ts +++ b/server/src/mcpProxy.ts @@ -1,4 +1,5 @@ import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js"; function onClientError(error: Error) { console.error("Error from inspector client:", error); @@ -19,7 +20,21 @@ export default function mcpProxy({ let transportToServerClosed = false; transportToClient.onmessage = (message) => { - transportToServer.send(message).catch(onServerError); + transportToServer.send(message).catch((error) => { + // Send error response back to client if it was a request (has id) and connection is still open + if (isJSONRPCRequest(message) && !transportToClientClosed) { + const errorResponse = { + jsonrpc: "2.0" as const, + id: message.id, + error: { + code: -32001, + message: error.message, + data: error, + }, + }; + transportToClient.send(errorResponse).catch(onClientError); + } + }); }; transportToServer.onmessage = (message) => {