diff --git a/README.md b/README.md index 805a7d3f..9d5017dc 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,31 @@ The MCP inspector is a developer tool for testing and debugging MCP servers. ![MCP Inspector Screenshot](https://raw.githubusercontent.com/modelcontextprotocol/inspector/main/mcp-inspector.png) +## Architecture Overview + +The MCP Inspector consists of two main components that work together: + +- **MCP Inspector Client (MCPI)**: A React-based web UI that provides an interactive interface for testing and debugging MCP servers +- **MCP Proxy (MCPP)**: A Node.js server that acts as a protocol bridge, connecting the web UI to MCP servers via various transport methods (stdio, SSE, streamable-http) + +Note that the proxy is not a network proxy for intercepting traffic. Instead, it functions as both an MCP client (connecting to your MCP server) and an HTTP server (serving the web UI), enabling browser-based interaction with MCP servers that use different transport protocols. + ## Running the Inspector ### Requirements - Node.js: ^22.7.5 +### Quick Start (UI mode) + +To get up and running right away with the UI, just execute the following: + +```bash +npx @modelcontextprotocol/inspector +``` + +The server will start up and the UI will be accessible at `http://localhost:6274`. + ### From an MCP server repository To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`: @@ -42,6 +61,74 @@ CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging). +### Servers File Export + +The MCP Inspector provides convenient buttons to export server launch configurations for use in clients such as Cursor, Claude Code, or the Inspector's CLI. The file is usually called `mcp.json`. + +- **Server Entry** - Copies a single server configuration entry to your clipboard. This can be added to your `mcp.json` file inside the `mcpServers` object with your preferred server name. + + **STDIO transport example:** + + ```json + { + "command": "node", + "args": ["build/index.js", "--debug"], + "env": { + "API_KEY": "your-api-key", + "DEBUG": "true" + } + } + ``` + + **SSE transport example:** + + ```json + { + "type": "sse", + "url": "http://localhost:3000/events", + "note": "For SSE connections, add this URL directly in Client" + } + ``` + +- **Servers File** - Copies a complete MCP configuration file structure to your clipboard, with your current server configuration added as `default-server`. This can be saved directly as `mcp.json`. + + **STDIO transport example:** + + ```json + { + "mcpServers": { + "default-server": { + "command": "node", + "args": ["build/index.js", "--debug"], + "env": { + "API_KEY": "your-api-key", + "DEBUG": "true" + } + } + } + } + ``` + + **SSE transport example:** + + ```json + { + "mcpServers": { + "default-server": { + "type": "sse", + "url": "http://localhost:3000/events", + "note": "For SSE connections, add this URL directly in Client" + } + } + } + ``` + +These buttons appear in the Inspector UI after you've configured your server settings, making it easy to save and reuse your configurations. + +For SSE transport connections, the Inspector provides similar functionality for both buttons. The "Server Entry" button copies the SSE URL configuration that can be added to your existing configuration file, while the "Servers File" button creates a complete configuration file containing the SSE URL for direct use in clients. + +You can paste the Server Entry into your existing `mcp.json` file under your chosen server name, or use the complete Servers File payload to create a new configuration file. + ### Authentication The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header. You can override the header name using the input field in the sidebar. @@ -94,6 +181,8 @@ Example server configuration file: } ``` +> **Tip:** You can easily generate this configuration format using the **Server Entry** and **Servers File** buttons in the Inspector UI, as described in the Servers File Export section above. + You can also set the initial `transport` type, `serverUrl`, `serverCommand`, and `serverArgs` via query params, for example: ``` 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 32bdcf35..396f5051 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -17,6 +17,10 @@ import { Tool, LoggingLevel, } from "@modelcontextprotocol/sdk/types.js"; +import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants"; +import { AuthDebuggerState } from "./lib/auth-types"; +import { cacheToolOutputSchemas } from "./utils/schemaUtils"; import React, { Suspense, useCallback, @@ -28,18 +32,21 @@ import { useConnection } from "./lib/hooks/useConnection"; import { useDraggablePane } from "./lib/hooks/useDraggablePane"; import { StdErrNotification } from "./lib/notificationTypes"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; import { Bell, Files, FolderTree, Hammer, Hash, + Key, MessageSquare, } from "lucide-react"; import { z } from "zod"; import "./App.css"; +import AuthDebugger from "./components/AuthDebugger"; import ConsoleTab from "./components/ConsoleTab"; import HistoryAndNotifications from "./components/History"; import PingTab from "./components/PingTab"; @@ -111,6 +118,27 @@ const App = () => { } > >([]); + const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false); + + // Auth debugger state + const [authState, setAuthState] = useState({ + isInitiatingAuth: false, + oauthTokens: null, + loading: true, + oauthStep: "metadata_discovery", + oauthMetadata: null, + oauthClientInfo: null, + authorizationUrl: null, + authorizationCode: "", + latestError: null, + statusMessage: null, + validationError: null, + }); + + // Helper function to update specific auth state properties + const updateAuthState = (updates: Partial) => { + setAuthState((prev) => ({ ...prev, ...updates })); + }; const nextRequestId = useRef(0); const rootsRef = useRef([]); @@ -207,12 +235,64 @@ const App = () => { const onOAuthConnect = useCallback( (serverUrl: string) => { setSseUrl(serverUrl); - setTransportType("sse"); + setIsAuthDebuggerVisible(false); void connectMcpServer(); }, [connectMcpServer], ); + // Update OAuth debug state during debug callback + const onOAuthDebugConnect = useCallback( + ({ + authorizationCode, + errorMsg, + }: { + authorizationCode?: string; + errorMsg?: string; + }) => { + setIsAuthDebuggerVisible(true); + if (authorizationCode) { + updateAuthState({ + authorizationCode, + oauthStep: "token_request", + }); + } + if (errorMsg) { + updateAuthState({ + latestError: new Error(errorMsg), + }); + } + }, + [], + ); + + // Load OAuth tokens when sseUrl changes + useEffect(() => { + const loadOAuthTokens = async () => { + try { + if (sseUrl) { + const key = getServerSpecificKey(SESSION_KEYS.TOKENS, sseUrl); + const tokens = sessionStorage.getItem(key); + if (tokens) { + const parsedTokens = await OAuthTokensSchema.parseAsync( + JSON.parse(tokens), + ); + updateAuthState({ + oauthTokens: parsedTokens, + oauthStep: "complete", + }); + } + } + } catch (error) { + console.error("Error loading OAuth tokens:", error); + } finally { + updateAuthState({ loading: false }); + } + }; + + loadOAuthTokens(); + }, [sseUrl]); + useEffect(() => { fetch(`${getMCPProxyAddress(config)}/config`) .then((response) => response.json()) @@ -394,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) => { @@ -446,6 +528,19 @@ const App = () => { setStdErrNotifications([]); }; + // Helper component for rendering the AuthDebugger + const AuthDebuggerWrapper = () => ( + + setIsAuthDebuggerVisible(false)} + authState={authState} + updateAuthState={updateAuthState} + /> + + ); + + // Helper function to render OAuth callback components if (window.location.pathname === "/oauth/callback") { const OAuthCallback = React.lazy( () => import("./components/OAuthCallback"), @@ -457,6 +552,17 @@ const App = () => { ); } + if (window.location.pathname === "/oauth/callback/debug") { + const OAuthDebugCallback = React.lazy( + () => import("./components/OAuthDebugCallback"), + ); + return ( + Loading...}> + + + ); + } + return (
{ className="w-full p-4" onValueChange={(value) => (window.location.hash = value)} > - + { Roots + + + Auth +
@@ -552,7 +662,7 @@ const App = () => { !serverCapabilities?.tools ? ( <>
-

+

The connected server does not support any MCP capabilities

@@ -652,6 +762,8 @@ const App = () => { clearTools={() => { setTools([]); setNextToolCursor(undefined); + // Clear cached output schemas + cacheToolOutputSchemas([]); }} callTool={async (name, params) => { clearError("tools"); @@ -689,15 +801,36 @@ const App = () => { setRoots={setRoots} onRootsChange={handleRootsChange} /> + )}
+ ) : isAuthDebuggerVisible ? ( + (window.location.hash = value)} + > + + ) : ( -
-

+

+

Connect to an MCP server to start inspecting

+
+

+ Need to configure authentication? +

+ +
)}
@@ -708,7 +841,7 @@ const App = () => { }} >
diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx new file mode 100644 index 00000000..d0b0432d --- /dev/null +++ b/client/src/components/AuthDebugger.tsx @@ -0,0 +1,288 @@ +import { useCallback, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { DebugInspectorOAuthClientProvider } from "../lib/auth"; +import { AlertCircle } from "lucide-react"; +import { AuthDebuggerState } from "../lib/auth-types"; +import { OAuthFlowProgress } from "./OAuthFlowProgress"; +import { OAuthStateMachine } from "../lib/oauth-state-machine"; + +export interface AuthDebuggerProps { + serverUrl: string; + onBack: () => void; + authState: AuthDebuggerState; + updateAuthState: (updates: Partial) => void; +} + +interface StatusMessageProps { + message: { type: "error" | "success" | "info"; message: string }; +} + +const StatusMessage = ({ message }: StatusMessageProps) => { + let bgColor: string; + let textColor: string; + let borderColor: string; + + switch (message.type) { + case "error": + bgColor = "bg-red-50"; + textColor = "text-red-700"; + borderColor = "border-red-200"; + break; + case "success": + bgColor = "bg-green-50"; + textColor = "text-green-700"; + borderColor = "border-green-200"; + break; + case "info": + default: + bgColor = "bg-blue-50"; + textColor = "text-blue-700"; + borderColor = "border-blue-200"; + break; + } + + return ( +
+
+ +

{message.message}

+
+
+ ); +}; + +const AuthDebugger = ({ + serverUrl: serverUrl, + onBack, + authState, + updateAuthState, +}: AuthDebuggerProps) => { + const startOAuthFlow = useCallback(() => { + if (!serverUrl) { + updateAuthState({ + statusMessage: { + type: "error", + message: + "Please enter a server URL in the sidebar before authenticating", + }, + }); + return; + } + + updateAuthState({ + oauthStep: "metadata_discovery", + authorizationUrl: null, + statusMessage: null, + latestError: null, + }); + }, [serverUrl, updateAuthState]); + + const stateMachine = useMemo( + () => new OAuthStateMachine(serverUrl, updateAuthState), + [serverUrl, updateAuthState], + ); + + const proceedToNextStep = useCallback(async () => { + if (!serverUrl) return; + + try { + updateAuthState({ + isInitiatingAuth: true, + statusMessage: null, + latestError: null, + }); + + await stateMachine.executeStep(authState); + } catch (error) { + console.error("OAuth flow error:", error); + updateAuthState({ + latestError: error instanceof Error ? error : new Error(String(error)), + }); + } finally { + updateAuthState({ isInitiatingAuth: false }); + } + }, [serverUrl, authState, updateAuthState, stateMachine]); + + const handleQuickOAuth = useCallback(async () => { + if (!serverUrl) { + updateAuthState({ + statusMessage: { + type: "error", + message: + "Please enter a server URL in the sidebar before authenticating", + }, + }); + return; + } + + updateAuthState({ isInitiatingAuth: true, statusMessage: null }); + try { + // Step through the OAuth flow using the state machine instead of the auth() function + let currentState: AuthDebuggerState = { + ...authState, + oauthStep: "metadata_discovery", + authorizationUrl: null, + latestError: null, + }; + + const oauthMachine = new OAuthStateMachine(serverUrl, (updates) => { + // Update our temporary state during the process + currentState = { ...currentState, ...updates }; + // But don't call updateAuthState yet + }); + + // Manually step through each stage of the OAuth flow + while (currentState.oauthStep !== "complete") { + await oauthMachine.executeStep(currentState); + // In quick mode, we'll just redirect to the authorization URL + if ( + currentState.oauthStep === "authorization_code" && + currentState.authorizationUrl + ) { + // Open the authorization URL automatically + window.location.href = currentState.authorizationUrl; + break; + } + } + + // After the flow completes or reaches a user-input step, update the app state + updateAuthState({ + ...currentState, + statusMessage: { + type: "info", + message: + currentState.oauthStep === "complete" + ? "Authentication completed successfully" + : "Please complete authentication in the opened window and enter the code", + }, + }); + } catch (error) { + console.error("OAuth initialization error:", error); + updateAuthState({ + statusMessage: { + type: "error", + message: `Failed to start OAuth flow: ${error instanceof Error ? error.message : String(error)}`, + }, + }); + } finally { + updateAuthState({ isInitiatingAuth: false }); + } + }, [serverUrl, updateAuthState, authState]); + + const handleClearOAuth = useCallback(() => { + if (serverUrl) { + const serverAuthProvider = new DebugInspectorOAuthClientProvider( + serverUrl, + ); + serverAuthProvider.clear(); + updateAuthState({ + oauthTokens: null, + oauthStep: "metadata_discovery", + latestError: null, + oauthClientInfo: null, + authorizationCode: "", + validationError: null, + oauthMetadata: null, + statusMessage: { + type: "success", + message: "OAuth tokens cleared successfully", + }, + }); + + // Clear success message after 3 seconds + setTimeout(() => { + updateAuthState({ statusMessage: null }); + }, 3000); + } + }, [serverUrl, updateAuthState]); + + return ( +
+
+

Authentication Settings

+ +
+ +
+
+
+

+ Configure authentication settings for your MCP server connection. +

+ +
+

OAuth Authentication

+

+ Use OAuth to securely authenticate with the MCP server. +

+ + {authState.statusMessage && ( + + )} + + {authState.loading ? ( +

Loading authentication status...

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

Access Token:

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

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

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

History

{requestHistory.length === 0 ? ( -

No history yet

+

+ No history yet +

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

    {serverNotifications.length === 0 ? ( -

    No notifications yet

    +

    + No notifications yet +

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

      {title}

      @@ -46,7 +46,7 @@ const ListPane = ({ {items.map((item, index) => (
      setSelectedItem(item)} > {renderItem(item)} diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx new file mode 100644 index 00000000..88d931c0 --- /dev/null +++ b/client/src/components/OAuthDebugCallback.tsx @@ -0,0 +1,92 @@ +import { useEffect } from "react"; +import { SESSION_KEYS } from "../lib/constants"; +import { + generateOAuthErrorDescription, + parseOAuthCallbackParams, +} from "@/utils/oauthUtils.ts"; + +interface OAuthCallbackProps { + onConnect: ({ + authorizationCode, + errorMsg, + }: { + authorizationCode?: string; + errorMsg?: string; + }) => void; +} + +const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { + useEffect(() => { + let isProcessed = false; + + const handleCallback = async () => { + // Skip if we've already processed this callback + if (isProcessed) { + return; + } + isProcessed = true; + + const params = parseOAuthCallbackParams(window.location.search); + if (!params.successful) { + const errorMsg = generateOAuthErrorDescription(params); + onConnect({ errorMsg }); + return; + } + + const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL); + + // ServerURL isn't set, this can happen if we've opened the + // authentication request in a new tab, so we don't have the same + // session storage + if (!serverUrl) { + // If there's no server URL, we're likely in a new tab + // Just display the code for manual copying + return; + } + + if (!params.code) { + onConnect({ errorMsg: "Missing authorization code" }); + return; + } + + // Instead of storing in sessionStorage, pass the code directly + // to the auth state manager through onConnect + onConnect({ authorizationCode: params.code }); + }; + + handleCallback().finally(() => { + // Only redirect if we have the URL set, otherwise assume this was + // in a new tab + if (sessionStorage.getItem(SESSION_KEYS.SERVER_URL)) { + window.history.replaceState({}, document.title, "/"); + } + }); + + return () => { + isProcessed = true; + }; + }, [onConnect]); + + const callbackParams = parseOAuthCallbackParams(window.location.search); + + return ( +
      +
      +

      + Please copy this authorization code and return to the Auth Debugger: +

      + + {callbackParams.successful && "code" in callbackParams + ? callbackParams.code + : `No code found: ${callbackParams.error}, ${callbackParams.error_description}`} + +

      + Close this tab and paste the code in the OAuth flow to complete + authentication. +

      +
      +
      + ); +}; + +export default OAuthDebugCallback; diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx new file mode 100644 index 00000000..396af142 --- /dev/null +++ b/client/src/components/OAuthFlowProgress.tsx @@ -0,0 +1,294 @@ +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; + isComplete: boolean; + isCurrent: boolean; + error?: Error | null; + children?: React.ReactNode; +} + +const OAuthStepDetails = ({ + label, + isComplete, + isCurrent, + error, + children, +}: OAuthStepProps) => { + return ( +
      +
      + {isComplete ? ( + + ) : ( + + )} + {label} +
      + + {/* Show children if current step or complete and children exist */} + {(isCurrent || isComplete) && children && ( +
      {children}
      + )} + + {/* Display error if current step and an error exists */} + {isCurrent && error && ( +
      +

      Error:

      +

      {error.message}

      +
      + )} +
      + ); +}; + +interface OAuthFlowProgressProps { + serverUrl: string; + authState: AuthDebuggerState; + updateAuthState: (updates: Partial) => void; + 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 = useMemo( + () => new DebugInspectorOAuthClientProvider(serverUrl), + [serverUrl], + ); + const [clientInfo, setClientInfo] = useState( + null, + ); + + 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: + currentStepIdx > steps.indexOf(stepName) || + currentStepIdx === steps.length - 1, // last step is "complete" + isCurrent: authState.oauthStep === stepName, + error: authState.oauthStep === stepName ? authState.latestError : null, + }); + + return ( +
      +

      OAuth Flow Progress

      +

      + Follow these steps to complete OAuth authentication with the server. +

      + +
      + + {provider.getServerMetadata() && ( +
      + + Retrieved OAuth Metadata from {serverUrl} + /.well-known/oauth-authorization-server + +
      +                {JSON.stringify(provider.getServerMetadata(), null, 2)}
      +              
      +
      + )} +
      + + + {clientInfo && ( +
      + + Registered Client Information + +
      +                {JSON.stringify(clientInfo, null, 2)}
      +              
      +
      + )} +
      + + + {authState.authorizationUrl && ( +
      +

      Authorization URL:

      +
      +

      + {authState.authorizationUrl} +

      + + + +
      +

      + Click the link to authorize in your browser. After + authorization, you'll be redirected back to continue the flow. +

      +
      + )} +
      + + +
      + +
      + { + updateAuthState({ + authorizationCode: e.target.value, + validationError: null, + }); + }} + placeholder="Enter the code from the authorization server" + className={`flex h-9 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${ + authState.validationError ? "border-red-500" : "border-input" + }`} + /> +
      + {authState.validationError && ( +

      + {authState.validationError} +

      + )} +

      + Once you've completed authorization in the link, paste the code + here. +

      +
      +
      + + + {authState.oauthMetadata && ( +
      + + Token Request Details + +
      +

      Token Endpoint:

      + + {authState.oauthMetadata.token_endpoint} + +
      +
      + )} +
      + + + {authState.oauthTokens && ( +
      + + Access Tokens + +

      + Authentication successful! You can now use the authenticated + connection. These tokens will be used automatically for server + requests. +

      +
      +                {JSON.stringify(authState.oauthTokens, null, 2)}
      +              
      +
      + )} +
      +
      + +
      + {authState.oauthStep !== "complete" && ( + <> + + + )} + + {authState.oauthStep === "authorization_redirect" && + authState.authorizationUrl && ( + + )} +
      +
      + ); +}; diff --git a/client/src/components/PromptsTab.tsx b/client/src/components/PromptsTab.tsx index 5fdad823..66c15d8f 100644 --- a/client/src/components/PromptsTab.tsx +++ b/client/src/components/PromptsTab.tsx @@ -110,8 +110,8 @@ const PromptsTab = ({ isButtonDisabled={!nextCursor && prompts.length > 0} /> -
      -
      +
      +

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

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

      +

      {selectedPrompt.description}

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

      ) : selectedTemplate ? (
      -

      +

      {selectedTemplate.description}

      {selectedTemplate.uriTemplate diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 6c95daa8..eee8eeaa 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useCallback } from "react"; import { Play, ChevronDown, @@ -12,6 +12,8 @@ import { Settings, HelpCircle, RefreshCwOff, + Copy, + CheckCheck, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -36,6 +38,7 @@ import { TooltipTrigger, TooltipContent, } from "@/components/ui/tooltip"; +import { useToast } from "../lib/hooks/useToast"; interface SidebarProps { connectionStatus: ConnectionStatus; @@ -95,10 +98,124 @@ const Sidebar = ({ const [showBearerToken, setShowBearerToken] = useState(false); const [showConfig, setShowConfig] = useState(false); const [shownEnvVars, setShownEnvVars] = useState>(new Set()); + const [copiedServerEntry, setCopiedServerEntry] = useState(false); + const [copiedServerFile, setCopiedServerFile] = useState(false); + const { toast } = useToast(); + + // Reusable error reporter for copy actions + const reportError = useCallback( + (error: unknown) => { + toast({ + title: "Error", + description: `Failed to copy config: ${error instanceof Error ? error.message : String(error)}`, + variant: "destructive", + }); + }, + [toast], + ); + + // Shared utility function to generate server config + const generateServerConfig = useCallback(() => { + if (transportType === "stdio") { + return { + command, + args: args.trim() ? args.split(/\s+/) : [], + env: { ...env }, + }; + } + if (transportType === "sse") { + return { + type: "sse", + url: sseUrl, + 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 your MCP Client", + }; + } + return {}; + }, [transportType, command, args, env, sseUrl]); + + // Memoized config entry generator + const generateMCPServerEntry = useCallback(() => { + return JSON.stringify(generateServerConfig(), null, 4); + }, [generateServerConfig]); + + // Memoized config file generator + const generateMCPServerFile = useCallback(() => { + return JSON.stringify( + { + mcpServers: { + "default-server": generateServerConfig(), + }, + }, + null, + 4, + ); + }, [generateServerConfig]); + + // Memoized copy handlers + const handleCopyServerEntry = useCallback(() => { + try { + const configJson = generateMCPServerEntry(); + navigator.clipboard + .writeText(configJson) + .then(() => { + setCopiedServerEntry(true); + + toast({ + title: "Config entry copied", + 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 directly in your MCP Client.", + }); + + setTimeout(() => { + setCopiedServerEntry(false); + }, 2000); + }) + .catch((error) => { + reportError(error); + }); + } catch (error) { + reportError(error); + } + }, [generateMCPServerEntry, transportType, toast, reportError]); + + const handleCopyServerFile = useCallback(() => { + try { + const configJson = generateMCPServerFile(); + navigator.clipboard + .writeText(configJson) + .then(() => { + setCopiedServerFile(true); + + toast({ + title: "Servers file copied", + description: + "Servers configuration has been copied to clipboard. Add this to your mcp.json file. Current testing server will be added as 'default-server'", + }); + + setTimeout(() => { + setCopiedServerFile(false); + }, 2000); + }) + .catch((error) => { + reportError(error); + }); + } catch (error) { + reportError(error); + } + }, [generateMCPServerFile, toast, reportError]); return (
      -
      +

      MCP Inspector v{version} @@ -223,6 +340,7 @@ const Sidebar = ({

      )} + {transportType === "stdio" && (
      + + Copy Server Entry + + + + + + Copy Servers File + +
      + {/* Configuration */}
      +
      +
      + +
      +
      + )} - {toolResult && renderToolResult()} +
      ) : ( diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx new file mode 100644 index 00000000..7c539661 --- /dev/null +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -0,0 +1,435 @@ +import { + render, + screen, + fireEvent, + waitFor, + act, +} from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { describe, it, beforeEach, jest } from "@jest/globals"; +import AuthDebugger, { AuthDebuggerProps } from "../AuthDebugger"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { SESSION_KEYS } from "@/lib/constants"; + +const mockOAuthTokens = { + access_token: "test_access_token", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "test_refresh_token", + scope: "test_scope", +}; + +const mockOAuthMetadata = { + issuer: "https://oauth.example.com", + authorization_endpoint: "https://oauth.example.com/authorize", + token_endpoint: "https://oauth.example.com/token", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], +}; + +const mockOAuthClientInfo = { + client_id: "test_client_id", + client_secret: "test_client_secret", + redirect_uris: ["http://localhost:3000/oauth/callback/debug"], +}; + +// Mock MCP SDK functions - must be before imports +jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ + auth: jest.fn(), + discoverOAuthMetadata: jest.fn(), + registerClient: jest.fn(), + startAuthorization: jest.fn(), + exchangeAuthorization: jest.fn(), +})); + +// Import the functions to get their types +import { + discoverOAuthMetadata, + registerClient, + startAuthorization, + exchangeAuthorization, + auth, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; + +// Type the mocked functions properly +const mockDiscoverOAuthMetadata = discoverOAuthMetadata as jest.MockedFunction< + typeof discoverOAuthMetadata +>; +const mockRegisterClient = registerClient as jest.MockedFunction< + typeof registerClient +>; +const mockStartAuthorization = startAuthorization as jest.MockedFunction< + typeof startAuthorization +>; +const mockExchangeAuthorization = exchangeAuthorization as jest.MockedFunction< + typeof exchangeAuthorization +>; +const mockAuth = auth as jest.MockedFunction; + +const sessionStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), +}; +Object.defineProperty(window, "sessionStorage", { + value: sessionStorageMock, +}); + +Object.defineProperty(window, "location", { + value: { + origin: "http://localhost:3000", + }, +}); + +describe("AuthDebugger", () => { + const defaultAuthState = { + isInitiatingAuth: false, + oauthTokens: null, + loading: false, + oauthStep: "metadata_discovery" as const, + oauthMetadata: null, + oauthClientInfo: null, + authorizationUrl: null, + authorizationCode: "", + latestError: null, + statusMessage: null, + validationError: null, + }; + + const defaultProps = { + serverUrl: "https://example.com", + onBack: jest.fn(), + authState: defaultAuthState, + updateAuthState: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + sessionStorageMock.getItem.mockReturnValue(null); + + mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); + mockRegisterClient.mockResolvedValue(mockOAuthClientInfo); + mockStartAuthorization.mockImplementation(async (_sseUrl, options) => { + const authUrl = new URL("https://oauth.example.com/authorize"); + + if (options.scope) { + authUrl.searchParams.set("scope", options.scope); + } + + return { + authorizationUrl: authUrl, + codeVerifier: "test_verifier", + }; + }); + mockExchangeAuthorization.mockResolvedValue(mockOAuthTokens); + }); + + const renderAuthDebugger = (props: Partial = {}) => { + const mergedProps = { + ...defaultProps, + ...props, + authState: { ...defaultAuthState, ...(props.authState || {}) }, + }; + return render( + + + , + ); + }; + + describe("Initial Rendering", () => { + it("should render the component with correct title", async () => { + await act(async () => { + renderAuthDebugger(); + }); + expect(screen.getByText("Authentication Settings")).toBeInTheDocument(); + }); + + it("should call onBack when Back button is clicked", async () => { + const onBack = jest.fn(); + await act(async () => { + renderAuthDebugger({ onBack }); + }); + fireEvent.click(screen.getByText("Back to Connect")); + expect(onBack).toHaveBeenCalled(); + }); + }); + + describe("OAuth Flow", () => { + it("should start OAuth flow when 'Guided OAuth Flow' is clicked", async () => { + await act(async () => { + renderAuthDebugger(); + }); + + await act(async () => { + fireEvent.click(screen.getByText("Guided OAuth Flow")); + }); + + expect(screen.getByText("OAuth Flow Progress")).toBeInTheDocument(); + }); + + it("should show error when OAuth flow is started without sseUrl", async () => { + const updateAuthState = jest.fn(); + await act(async () => { + renderAuthDebugger({ serverUrl: "", updateAuthState }); + }); + + await act(async () => { + fireEvent.click(screen.getByText("Guided OAuth Flow")); + }); + + expect(updateAuthState).toHaveBeenCalledWith({ + statusMessage: { + type: "error", + message: + "Please enter a server URL in the sidebar before authenticating", + }, + }); + }); + + it("should start quick OAuth flow and properly fetch and save metadata", async () => { + // Setup the auth mock + mockAuth.mockResolvedValue("AUTHORIZED"); + + const updateAuthState = jest.fn(); + await act(async () => { + renderAuthDebugger({ updateAuthState }); + }); + + await act(async () => { + fireEvent.click(screen.getByText("Quick OAuth Flow")); + }); + + // Should first discover and save OAuth metadata + expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith( + "https://example.com", + ); + + // Check that updateAuthState was called with the right info message + expect(updateAuthState).toHaveBeenCalledWith( + expect.objectContaining({ + oauthStep: "authorization_code", + }), + ); + }); + + it("should show error when quick OAuth flow fails to discover metadata", async () => { + mockDiscoverOAuthMetadata.mockRejectedValue( + new Error("Metadata discovery failed"), + ); + + const updateAuthState = jest.fn(); + await act(async () => { + renderAuthDebugger({ updateAuthState }); + }); + + await act(async () => { + fireEvent.click(screen.getByText("Quick OAuth Flow")); + }); + + // Check that updateAuthState was called with an error message + expect(updateAuthState).toHaveBeenCalledWith( + expect.objectContaining({ + statusMessage: { + type: "error", + message: expect.stringContaining("Failed to start OAuth flow"), + }, + }), + ); + }); + }); + + describe("Session Storage Integration", () => { + it("should load OAuth tokens from session storage", async () => { + // Mock the specific key for tokens with server URL + sessionStorageMock.getItem.mockImplementation((key) => { + if (key === "[https://example.com] mcp_tokens") { + return JSON.stringify(mockOAuthTokens); + } + return null; + }); + + await act(async () => { + renderAuthDebugger({ + authState: { + ...defaultAuthState, + oauthTokens: mockOAuthTokens, + }, + }); + }); + + await waitFor(() => { + expect(screen.getByText(/Access Token:/)).toBeInTheDocument(); + }); + }); + + it("should handle errors loading OAuth tokens from session storage", async () => { + // Mock console to avoid cluttering test output + const originalError = console.error; + console.error = jest.fn(); + + // Mock getItem to return invalid JSON for tokens + sessionStorageMock.getItem.mockImplementation((key) => { + if (key === "[https://example.com] mcp_tokens") { + return "invalid json"; + } + return null; + }); + + await act(async () => { + renderAuthDebugger(); + }); + + // Component should still render despite the error + expect(screen.getByText("Authentication Settings")).toBeInTheDocument(); + + // Restore console.error + console.error = originalError; + }); + }); + + describe("OAuth State Management", () => { + it("should clear OAuth state when Clear button is clicked", async () => { + const updateAuthState = jest.fn(); + // Mock the session storage to return tokens for the specific key + sessionStorageMock.getItem.mockImplementation((key) => { + if (key === "[https://example.com] mcp_tokens") { + return JSON.stringify(mockOAuthTokens); + } + return null; + }); + + await act(async () => { + renderAuthDebugger({ + authState: { + ...defaultAuthState, + oauthTokens: mockOAuthTokens, + }, + updateAuthState, + }); + }); + + await act(async () => { + fireEvent.click(screen.getByText("Clear OAuth State")); + }); + + expect(updateAuthState).toHaveBeenCalledWith({ + oauthTokens: null, + oauthStep: "metadata_discovery", + latestError: null, + oauthClientInfo: null, + oauthMetadata: null, + authorizationCode: "", + validationError: null, + statusMessage: { + type: "success", + message: "OAuth tokens cleared successfully", + }, + }); + + // Verify session storage was cleared + expect(sessionStorageMock.removeItem).toHaveBeenCalled(); + }); + }); + + describe("OAuth Flow Steps", () => { + it("should handle OAuth flow step progression", async () => { + const updateAuthState = jest.fn(); + await act(async () => { + renderAuthDebugger({ + updateAuthState, + authState: { + ...defaultAuthState, + isInitiatingAuth: false, // Changed to false so button is enabled + oauthStep: "metadata_discovery", + }, + }); + }); + + // Verify metadata discovery step + expect(screen.getByText("Metadata Discovery")).toBeInTheDocument(); + + // Click Continue - this should trigger metadata discovery + await act(async () => { + fireEvent.click(screen.getByText("Continue")); + }); + + expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith( + "https://example.com", + ); + }); + + // Setup helper for OAuth authorization tests + const setupAuthorizationUrlTest = async (metadata: OAuthMetadata) => { + const updateAuthState = jest.fn(); + + // Mock the session storage to return metadata + sessionStorageMock.getItem.mockImplementation((key) => { + if (key === `[https://example.com] ${SESSION_KEYS.SERVER_METADATA}`) { + return JSON.stringify(metadata); + } + if ( + key === `[https://example.com] ${SESSION_KEYS.CLIENT_INFORMATION}` + ) { + return JSON.stringify(mockOAuthClientInfo); + } + return null; + }); + + await act(async () => { + renderAuthDebugger({ + updateAuthState, + authState: { + ...defaultAuthState, + isInitiatingAuth: false, + oauthStep: "authorization_redirect", + oauthMetadata: metadata, + oauthClientInfo: mockOAuthClientInfo, + }, + }); + }); + + // Click Continue to trigger authorization + await act(async () => { + fireEvent.click(screen.getByText("Continue")); + }); + + return updateAuthState; + }; + + it("should include scope in authorization URL when scopes_supported is present", async () => { + const metadataWithScopes = { + ...mockOAuthMetadata, + scopes_supported: ["read", "write", "admin"], + }; + + const updateAuthState = + await setupAuthorizationUrlTest(metadataWithScopes); + + // Wait for the updateAuthState to be called + await waitFor(() => { + expect(updateAuthState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUrl: expect.stringContaining("scope="), + }), + ); + }); + }); + + it("should not include scope in authorization URL when scopes_supported is not present", async () => { + const updateAuthState = + await setupAuthorizationUrlTest(mockOAuthMetadata); + + // Wait for the updateAuthState to be called + await waitFor(() => { + expect(updateAuthState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUrl: expect.not.stringContaining("scope="), + }), + ); + }); + }); + }); +}); diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index df4ed64f..ba03d180 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, act } from "@testing-library/react"; import "@testing-library/jest-dom"; import { describe, it, beforeEach, jest } from "@jest/globals"; import Sidebar from "../Sidebar"; @@ -12,6 +12,25 @@ jest.mock("../../lib/hooks/useTheme", () => ({ default: () => ["light", jest.fn()], })); +// Mock toast hook +const mockToast = jest.fn(); +jest.mock("@/lib/hooks/useToast", () => ({ + useToast: () => ({ + toast: mockToast, + }), +})); + +// Mock navigator clipboard +const mockClipboardWrite = jest.fn(() => Promise.resolve()); +Object.defineProperty(navigator, "clipboard", { + value: { + writeText: mockClipboardWrite, + }, +}); + +// Setup fake timers +jest.useFakeTimers(); + describe("Sidebar Environment Variables", () => { const defaultProps = { connectionStatus: "disconnected" as const, @@ -53,6 +72,7 @@ describe("Sidebar Environment Variables", () => { beforeEach(() => { jest.clearAllMocks(); + jest.clearAllTimers(); }); describe("Basic Operations", () => { @@ -622,4 +642,231 @@ describe("Sidebar Environment Variables", () => { ); }); }); + + describe("Copy Configuration Features", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + }); + + const getCopyButtons = () => { + return { + serverEntry: screen.getByRole("button", { name: /server entry/i }), + serversFile: screen.getByRole("button", { name: /servers file/i }), + }; + }; + + it("should render both copy buttons for all transport types", () => { + ["stdio", "sse", "streamable-http"].forEach((transportType) => { + renderSidebar({ transportType }); + // There should be exactly one Server Entry and one Servers File button per render + const serverEntryButtons = screen.getAllByRole("button", { + name: /server entry/i, + }); + const serversFileButtons = screen.getAllByRole("button", { + name: /servers file/i, + }); + expect(serverEntryButtons).toHaveLength(1); + expect(serversFileButtons).toHaveLength(1); + // Clean up DOM for next iteration + // (Testing Library's render does not auto-unmount in a loop) + document.body.innerHTML = ""; + }); + }); + + it("should copy server entry configuration to clipboard for STDIO transport", async () => { + const command = "node"; + const args = "--inspect server.js"; + const env = { API_KEY: "test-key", DEBUG: "true" }; + + renderSidebar({ + transportType: "stdio", + command, + args, + env, + }); + + await act(async () => { + const { serverEntry } = getCopyButtons(); + fireEvent.click(serverEntry); + jest.runAllTimers(); + }); + + expect(mockClipboardWrite).toHaveBeenCalledTimes(1); + const expectedConfig = JSON.stringify( + { + command, + args: ["--inspect", "server.js"], + env, + }, + null, + 4, + ); + expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig); + }); + + it("should copy servers file configuration to clipboard for STDIO transport", async () => { + const command = "node"; + const args = "--inspect server.js"; + const env = { API_KEY: "test-key", DEBUG: "true" }; + + renderSidebar({ + transportType: "stdio", + command, + args, + env, + }); + + await act(async () => { + const { serversFile } = getCopyButtons(); + fireEvent.click(serversFile); + jest.runAllTimers(); + }); + + expect(mockClipboardWrite).toHaveBeenCalledTimes(1); + const expectedConfig = JSON.stringify( + { + mcpServers: { + "default-server": { + command, + args: ["--inspect", "server.js"], + env, + }, + }, + }, + null, + 4, + ); + expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig); + }); + + it("should copy server entry configuration to clipboard for SSE transport", async () => { + const sseUrl = "http://localhost:3000/events"; + renderSidebar({ transportType: "sse", sseUrl }); + + await act(async () => { + const { serverEntry } = getCopyButtons(); + fireEvent.click(serverEntry); + jest.runAllTimers(); + }); + + expect(mockClipboardWrite).toHaveBeenCalledTimes(1); + const expectedConfig = JSON.stringify( + { + type: "sse", + url: sseUrl, + note: "For SSE connections, add this URL directly in your MCP Client", + }, + null, + 4, + ); + expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig); + }); + + it("should copy servers file configuration to clipboard for SSE transport", async () => { + const sseUrl = "http://localhost:3000/events"; + renderSidebar({ transportType: "sse", sseUrl }); + + await act(async () => { + const { serversFile } = getCopyButtons(); + fireEvent.click(serversFile); + jest.runAllTimers(); + }); + + expect(mockClipboardWrite).toHaveBeenCalledTimes(1); + const expectedConfig = JSON.stringify( + { + mcpServers: { + "default-server": { + type: "sse", + url: sseUrl, + note: "For SSE connections, add this URL directly in your MCP Client", + }, + }, + }, + null, + 4, + ); + expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig); + }); + + it("should copy server entry configuration to clipboard for streamable-http transport", async () => { + const sseUrl = "http://localhost:3001/sse"; + renderSidebar({ transportType: "streamable-http", sseUrl }); + + await act(async () => { + const { serverEntry } = getCopyButtons(); + fireEvent.click(serverEntry); + jest.runAllTimers(); + }); + + expect(mockClipboardWrite).toHaveBeenCalledTimes(1); + const expectedConfig = JSON.stringify( + { + type: "streamable-http", + url: sseUrl, + note: "For Streamable HTTP connections, add this URL directly in your MCP Client", + }, + null, + 4, + ); + expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig); + }); + + it("should copy servers file configuration to clipboard for streamable-http transport", async () => { + const sseUrl = "http://localhost:3001/sse"; + renderSidebar({ transportType: "streamable-http", sseUrl }); + + await act(async () => { + const { serversFile } = getCopyButtons(); + fireEvent.click(serversFile); + jest.runAllTimers(); + }); + + expect(mockClipboardWrite).toHaveBeenCalledTimes(1); + const expectedConfig = JSON.stringify( + { + mcpServers: { + "default-server": { + type: "streamable-http", + url: sseUrl, + note: "For Streamable HTTP connections, add this URL directly in your MCP Client", + }, + }, + }, + null, + 4, + ); + expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig); + }); + + it("should handle empty args in STDIO transport", async () => { + const command = "python"; + const args = ""; + + renderSidebar({ + transportType: "stdio", + command, + args, + }); + + await act(async () => { + const { serverEntry } = getCopyButtons(); + fireEvent.click(serverEntry); + jest.runAllTimers(); + }); + + expect(mockClipboardWrite).toHaveBeenCalledTimes(1); + const expectedConfig = JSON.stringify( + { + command, + args: [], + env: {}, + }, + null, + 4, + ); + expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig); + }); + }); }); 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/index.css b/client/src/index.css index 11c6f23d..858f9e7f 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -93,8 +93,8 @@ h1 { --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; + --border: 217.2 24% 24%; + --input: 217.2 24% 24%; --ring: 212.7 26.8% 83.9%; --chart-1: 220 70% 50%; --chart-2: 160 60% 45%; diff --git a/client/src/lib/auth-types.ts b/client/src/lib/auth-types.ts new file mode 100644 index 00000000..ef32601a --- /dev/null +++ b/client/src/lib/auth-types.ts @@ -0,0 +1,38 @@ +import { + OAuthMetadata, + OAuthClientInformationFull, + OAuthClientInformation, + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js"; + +// OAuth flow steps +export type OAuthStep = + | "metadata_discovery" + | "client_registration" + | "authorization_redirect" + | "authorization_code" + | "token_request" + | "complete"; + +// Message types for inline feedback +export type MessageType = "success" | "error" | "info"; + +export interface StatusMessage { + type: MessageType; + message: string; +} + +// Single state interface for OAuth state +export interface AuthDebuggerState { + isInitiatingAuth: boolean; + oauthTokens: OAuthTokens | null; + loading: boolean; + oauthStep: OAuthStep; + oauthMetadata: OAuthMetadata | null; + oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null; + authorizationUrl: string | null; + authorizationCode: string; + latestError: Error | null; + statusMessage: StatusMessage | null; + validationError: string | null; +} diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index 7ef31822..3e3516e0 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -4,11 +4,13 @@ import { OAuthClientInformation, OAuthTokens, OAuthTokensSchema, + OAuthClientMetadata, + OAuthMetadata, } from "@modelcontextprotocol/sdk/shared/auth.js"; import { SESSION_KEYS, getServerSpecificKey } from "./constants"; export class InspectorOAuthClientProvider implements OAuthClientProvider { - constructor(private serverUrl: string) { + constructor(public serverUrl: string) { // Save the server URL to session storage sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl); } @@ -17,7 +19,7 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { return window.location.origin + "/oauth/callback"; } - get clientMetadata() { + get clientMetadata(): OAuthClientMetadata { return { redirect_uris: [this.redirectUrl], token_endpoint_auth_method: "none", @@ -101,3 +103,38 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { ); } } + +// Overrides debug URL and allows saving server OAuth metadata to +// display in debug UI. +export class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider { + get redirectUrl(): string { + return `${window.location.origin}/oauth/callback/debug`; + } + + saveServerMetadata(metadata: OAuthMetadata) { + const key = getServerSpecificKey( + SESSION_KEYS.SERVER_METADATA, + this.serverUrl, + ); + sessionStorage.setItem(key, JSON.stringify(metadata)); + } + + getServerMetadata(): OAuthMetadata | null { + const key = getServerSpecificKey( + SESSION_KEYS.SERVER_METADATA, + this.serverUrl, + ); + const metadata = sessionStorage.getItem(key); + if (!metadata) { + return null; + } + return JSON.parse(metadata); + } + + clear() { + super.clear(); + sessionStorage.removeItem( + getServerSpecificKey(SESSION_KEYS.SERVER_METADATA, this.serverUrl), + ); + } +} diff --git a/client/src/lib/constants.ts b/client/src/lib/constants.ts index a03239ae..4c3e27aa 100644 --- a/client/src/lib/constants.ts +++ b/client/src/lib/constants.ts @@ -6,6 +6,7 @@ export const SESSION_KEYS = { SERVER_URL: "mcp_server_url", TOKENS: "mcp_tokens", CLIENT_INFORMATION: "mcp_client_information", + SERVER_METADATA: "mcp_server_metadata", } as const; // Generate server-specific session storage keys diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index c6700dcf..6e5748d7 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,92 @@ 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", + ); + }); + }); }); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 4ced90e4..e4f1bc50 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 }); @@ -297,7 +304,14 @@ export function useConnection({ bearerToken || (await serverAuthProvider.tokens())?.access_token; if (token) { const authHeaderName = headerName || "Authorization"; - headers[authHeaderName] = `Bearer ${token}`; + + // Add custom header name as a special request header to let the server know which header to pass through + if (authHeaderName.toLowerCase() !== "authorization") { + headers[authHeaderName] = token; + headers["x-custom-auth-header"] = authHeaderName; + } else { + headers[authHeaderName] = `Bearer ${token}`; + } } // Create appropriate transport @@ -330,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, @@ -347,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, @@ -425,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/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts new file mode 100644 index 00000000..0678229c --- /dev/null +++ b/client/src/lib/oauth-state-machine.ts @@ -0,0 +1,181 @@ +import { OAuthStep, AuthDebuggerState } from "./auth-types"; +import { DebugInspectorOAuthClientProvider } from "./auth"; +import { + discoverOAuthMetadata, + registerClient, + startAuthorization, + exchangeAuthorization, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; + +export interface StateMachineContext { + state: AuthDebuggerState; + serverUrl: string; + provider: DebugInspectorOAuthClientProvider; + updateState: (updates: Partial) => void; +} + +export interface StateTransition { + canTransition: (context: StateMachineContext) => Promise; + execute: (context: StateMachineContext) => Promise; + nextStep: OAuthStep; +} + +// State machine transitions +export const oauthTransitions: Record = { + metadata_discovery: { + canTransition: async () => true, + execute: async (context) => { + const metadata = await discoverOAuthMetadata(context.serverUrl); + if (!metadata) { + throw new Error("Failed to discover OAuth metadata"); + } + const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); + context.provider.saveServerMetadata(parsedMetadata); + context.updateState({ + oauthMetadata: parsedMetadata, + oauthStep: "client_registration", + }); + }, + nextStep: "client_registration", + }, + + client_registration: { + canTransition: async (context) => !!context.state.oauthMetadata, + execute: async (context) => { + const metadata = context.state.oauthMetadata!; + const clientMetadata = context.provider.clientMetadata; + + // Add all supported scopes to client registration + if (metadata.scopes_supported) { + clientMetadata.scope = metadata.scopes_supported.join(" "); + } + + const fullInformation = await registerClient(context.serverUrl, { + metadata, + clientMetadata, + }); + + context.provider.saveClientInformation(fullInformation); + context.updateState({ + oauthClientInfo: fullInformation, + oauthStep: "authorization_redirect", + }); + }, + nextStep: "authorization_redirect", + }, + + authorization_redirect: { + canTransition: async (context) => + !!context.state.oauthMetadata && !!context.state.oauthClientInfo, + execute: async (context) => { + const metadata = context.state.oauthMetadata!; + const clientInformation = context.state.oauthClientInfo!; + + let scope: string | undefined = undefined; + if (metadata.scopes_supported) { + scope = metadata.scopes_supported.join(" "); + } + + const { authorizationUrl, codeVerifier } = await startAuthorization( + context.serverUrl, + { + metadata, + clientInformation, + redirectUrl: context.provider.redirectUrl, + scope, + }, + ); + + context.provider.saveCodeVerifier(codeVerifier); + context.updateState({ + authorizationUrl: authorizationUrl.toString(), + oauthStep: "authorization_code", + }); + }, + nextStep: "authorization_code", + }, + + authorization_code: { + canTransition: async () => true, + execute: async (context) => { + if ( + !context.state.authorizationCode || + context.state.authorizationCode.trim() === "" + ) { + context.updateState({ + validationError: "You need to provide an authorization code", + }); + // Don't advance if no code + throw new Error("Authorization code required"); + } + context.updateState({ + validationError: null, + oauthStep: "token_request", + }); + }, + nextStep: "token_request", + }, + + token_request: { + canTransition: async (context) => { + return ( + !!context.state.authorizationCode && + !!context.provider.getServerMetadata() && + !!(await context.provider.clientInformation()) + ); + }, + execute: async (context) => { + const codeVerifier = context.provider.codeVerifier(); + const metadata = context.provider.getServerMetadata()!; + const clientInformation = (await context.provider.clientInformation())!; + + const tokens = await exchangeAuthorization(context.serverUrl, { + metadata, + clientInformation, + authorizationCode: context.state.authorizationCode, + codeVerifier, + redirectUri: context.provider.redirectUrl, + }); + + context.provider.saveTokens(tokens); + context.updateState({ + oauthTokens: tokens, + oauthStep: "complete", + }); + }, + nextStep: "complete", + }, + + complete: { + canTransition: async () => false, + execute: async () => { + // No-op for complete state + }, + nextStep: "complete", + }, +}; + +export class OAuthStateMachine { + constructor( + private serverUrl: string, + private updateState: (updates: Partial) => void, + ) {} + + async executeStep(state: AuthDebuggerState): Promise { + const provider = new DebugInspectorOAuthClientProvider(this.serverUrl); + const context: StateMachineContext = { + state, + serverUrl: this.serverUrl, + provider, + updateState: this.updateState, + }; + + const transition = oauthTransitions[state.oauthStep]; + if (!(await transition.canTransition(context))) { + throw new Error(`Cannot transition from ${state.oauthStep}`); + } + + await transition.execute(context); + } +} 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/mcp-inspector.png b/mcp-inspector.png index 7ca1112f..4217cf14 100644 Binary files a/mcp-inspector.png and b/mcp-inspector.png differ diff --git a/package-lock.json b/package-lock.json index e0e23c9f..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.0", + "@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.10.2", + "@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.10.2", + "@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,14 +2005,15 @@ "link": true }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz", - "integrity": "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==", + "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", @@ -2024,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", @@ -3979,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", @@ -5653,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": { @@ -5688,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": { @@ -5698,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", @@ -7702,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": { @@ -8408,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", @@ -8783,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" @@ -9038,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", @@ -10450,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" @@ -10934,10 +10968,10 @@ }, "server": { "name": "@modelcontextprotocol/inspector-server", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.10.2", + "@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 01beeac4..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.0", + "@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/index.ts b/server/src/index.ts index c967b60c..ef3061d8 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -41,6 +41,44 @@ const { values } = parseArgs({ }, }); +// Function to get HTTP headers. +// Supports only "sse" and "streamable-http" transport types. +const getHttpHeaders = ( + req: express.Request, + transportType: string, +): HeadersInit => { + const headers: HeadersInit = { + Accept: + transportType === "sse" + ? "text/event-stream" + : "text/event-stream, application/json", + }; + const defaultHeaders = + transportType === "sse" + ? SSE_HEADERS_PASSTHROUGH + : STREAMABLE_HTTP_HEADERS_PASSTHROUGH; + + for (const key of defaultHeaders) { + if (req.headers[key] === undefined) { + continue; + } + + const value = req.headers[key]; + headers[key] = Array.isArray(value) ? value[value.length - 1] : value; + } + + // If the header "x-custom-auth-header" is present, use its value as the custom header name. + if (req.headers["x-custom-auth-header"] !== undefined) { + const customHeaderName = req.headers["x-custom-auth-header"] as string; + const lowerCaseHeaderName = customHeaderName.toLowerCase(); + if (req.headers[lowerCaseHeaderName] !== undefined) { + const value = req.headers[lowerCaseHeaderName]; + headers[customHeaderName] = value as string; + } + } + return headers; +}; + const app = express(); app.use(cors()); app.use((req, res, next) => { @@ -48,7 +86,8 @@ app.use((req, res, next) => { next(); }); -const webAppTransports: Map = new Map(); // Transports by sessionId +const webAppTransports: Map = new Map(); // Web app transports by web app sessionId +const serverTransports: Map = new Map(); // Server Transports by web app sessionId const createTransport = async (req: express.Request): Promise => { const query = req.query; @@ -79,18 +118,8 @@ const createTransport = async (req: express.Request): Promise => { return transport; } else if (transportType === "sse") { const url = query.url as string; - const headers: HeadersInit = { - Accept: "text/event-stream", - }; - for (const key of SSE_HEADERS_PASSTHROUGH) { - if (req.headers[key] === undefined) { - continue; - } - - const value = req.headers[key]; - headers[key] = Array.isArray(value) ? value[value.length - 1] : value; - } + const headers = getHttpHeaders(req, transportType); console.log(`SSE transport: url=${url}, headers=${Object.keys(headers)}`); @@ -107,18 +136,7 @@ const createTransport = async (req: express.Request): Promise => { console.log("Connected to SSE transport"); return transport; } else if (transportType === "streamable-http") { - const headers: HeadersInit = { - Accept: "text/event-stream, application/json", - }; - - for (const key of STREAMABLE_HTTP_HEADERS_PASSTHROUGH) { - if (req.headers[key] === undefined) { - continue; - } - - const value = req.headers[key]; - headers[key] = Array.isArray(value) ? value[value.length - 1] : value; - } + const headers = getHttpHeaders(req, transportType); const transport = new StreamableHTTPClientTransport( new URL(query.url as string), @@ -137,8 +155,6 @@ const createTransport = async (req: express.Request): Promise => { } }; -let backingServerTransport: Transport | undefined; - app.get("/mcp", async (req, res) => { const sessionId = req.headers["mcp-session-id"] as string; console.log(`Received GET message for sessionId ${sessionId}`); @@ -161,12 +177,12 @@ app.get("/mcp", async (req, res) => { app.post("/mcp", async (req, res) => { const sessionId = req.headers["mcp-session-id"] as string | undefined; console.log(`Received POST message for sessionId ${sessionId}`); + let serverTransport: Transport | undefined; if (!sessionId) { try { console.log("New streamable-http connection"); try { - await backingServerTransport?.close(); - backingServerTransport = await createTransport(req); + serverTransport = await createTransport(req); } catch (error) { if (error instanceof SseError && error.code === 401) { console.error( @@ -180,12 +196,13 @@ app.post("/mcp", async (req, res) => { throw error; } - console.log("Connected MCP client to backing server transport"); + console.log("Connected MCP client to server transport"); const webAppTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: randomUUID, onsessioninitialized: (sessionId) => { webAppTransports.set(sessionId, webAppTransport); + serverTransports.set(sessionId, serverTransport!); console.log("Created streamable web app transport " + sessionId); }, }); @@ -194,7 +211,7 @@ app.post("/mcp", async (req, res) => { mcpProxy({ transportToClient: webAppTransport, - transportToServer: backingServerTransport, + transportToServer: serverTransport, }); await (webAppTransport as StreamableHTTPServerTransport).handleRequest( @@ -229,10 +246,9 @@ app.post("/mcp", async (req, res) => { app.get("/stdio", async (req, res) => { try { console.log("New connection"); - + let serverTransport: Transport | undefined; try { - await backingServerTransport?.close(); - backingServerTransport = await createTransport(req); + serverTransport = await createTransport(req); } catch (error) { if (error instanceof SseError && error.code === 401) { console.error( @@ -250,26 +266,24 @@ app.get("/stdio", async (req, res) => { const webAppTransport = new SSEServerTransport("/message", res); webAppTransports.set(webAppTransport.sessionId, webAppTransport); - - console.log("Created web app transport"); + serverTransports.set(webAppTransport.sessionId, serverTransport); + console.log("Created client/server transports"); await webAppTransport.start(); - (backingServerTransport as StdioClientTransport).stderr!.on( - "data", - (chunk) => { - webAppTransport.send({ - jsonrpc: "2.0", - method: "notifications/stderr", - params: { - content: chunk.toString(), - }, - }); - }, - ); + + (serverTransport as StdioClientTransport).stderr!.on("data", (chunk) => { + webAppTransport.send({ + jsonrpc: "2.0", + method: "notifications/stderr", + params: { + content: chunk.toString(), + }, + }); + }); mcpProxy({ transportToClient: webAppTransport, - transportToServer: backingServerTransport, + transportToServer: serverTransport, }); console.log("Set up MCP proxy"); @@ -284,10 +298,9 @@ app.get("/sse", async (req, res) => { console.log( "New SSE connection. NOTE: The sse transport is deprecated and has been replaced by streamable-http", ); - + let serverTransport: Transport | undefined; try { - await backingServerTransport?.close(); - backingServerTransport = await createTransport(req); + serverTransport = await createTransport(req); } catch (error) { if (error instanceof SseError && error.code === 401) { console.error( @@ -305,13 +318,15 @@ app.get("/sse", async (req, res) => { const webAppTransport = new SSEServerTransport("/message", res); webAppTransports.set(webAppTransport.sessionId, webAppTransport); - console.log("Created web app transport"); + console.log("Created client transport"); + serverTransports.set(webAppTransport.sessionId, serverTransport); + console.log("Created server transport"); await webAppTransport.start(); mcpProxy({ transportToClient: webAppTransport, - transportToServer: backingServerTransport, + transportToServer: serverTransport, }); console.log("Set up MCP proxy"); 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) => {