diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e292dae3..8efb6773 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -58,8 +58,6 @@ jobs: # - run: npm ci - run: npm install --no-package-lock - - run: npm run build - # TODO: Add --provenance once the repo is public - run: npm run publish-all env: diff --git a/README.md b/README.md index b312f71c..d7c34b97 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,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. @@ -54,12 +122,13 @@ The MCP Inspector includes a proxy server that can run and communicate with loca The MCP Inspector supports the following configuration settings. To change them, click on the `Configuration` button in the MCP Inspector UI: -| Setting | Description | Default | -| --------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------- | -| `MCP_SERVER_REQUEST_TIMEOUT` | Timeout for requests to the MCP server (ms) | 10000 | -| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | Reset timeout on progress notifications | true | -| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications) | 60000 | -| `MCP_PROXY_FULL_ADDRESS` | Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577 | "" | +| Setting | Description | Default | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------- | +| `MCP_SERVER_REQUEST_TIMEOUT` | Timeout for requests to the MCP server (ms) | 10000 | +| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | Reset timeout on progress notifications | true | +| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications) | 60000 | +| `MCP_PROXY_FULL_ADDRESS` | Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577 | "" | +| `MCP_AUTO_OPEN_ENABLED` | Enable automatic browser opening when inspector starts. Only as environment var, not configurable in browser. | true | These settings can be adjusted in real-time through the UI and will persist across sessions. @@ -93,6 +162,24 @@ 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: + +``` +http://localhost:6274/?transport=sse&serverUrl=http://localhost:8787/sse +http://localhost:6274/?transport=streamable-http&serverUrl=http://localhost:8787/mcp +http://localhost:6274/?transport=stdio&serverCommand=npx&serverArgs=arg1%20arg2 +``` + +You can also set initial config settings via query params, for example: + +``` +http://localhost:6274/?MCP_SERVER_REQUEST_TIMEOUT=10000&MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS=false&MCP_PROXY_FULL_ADDRESS=http://10.1.1.22:5577 +``` + +Note that if both the query param and the corresponding localStorage item are set, the query param will take precedence. + ### From this repository If you're working on the inspector itself: diff --git a/cli/package.json b/cli/package.json index ea746881..53f2aa6b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.10.2", + "version": "0.12.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.10.0", + "@modelcontextprotocol/sdk": "^1.11.0", "commander": "^13.1.0", "spawn-rx": "^5.1.2" } diff --git a/client/bin/client.js b/client/bin/client.js index d30cc702..7179e19e 100755 --- a/client/bin/client.js +++ b/client/bin/client.js @@ -9,10 +9,34 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const distPath = join(__dirname, "../dist"); const server = http.createServer((request, response) => { - return handler(request, response, { + const handlerOptions = { public: distPath, rewrites: [{ source: "/**", destination: "/index.html" }], - }); + headers: [ + { + // Ensure index.html is never cached + source: "index.html", + headers: [ + { + key: "Cache-Control", + value: "no-cache, no-store, max-age=0", + }, + ], + }, + { + // Allow long-term caching for hashed assets + source: "assets/**", + headers: [ + { + key: "Cache-Control", + value: "public, max-age=31536000, immutable", + }, + ], + }, + ], + }; + + return handler(request, response, handlerOptions); }); const port = process.env.PORT || 6274; diff --git a/client/bin/start.js b/client/bin/start.js index e7a2495f..8e383b38 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -1,5 +1,6 @@ #!/usr/bin/env node +import open from "open"; import { resolve, dirname } from "path"; import { spawnPromise } from "spawn-rx"; import { fileURLToPath } from "url"; @@ -99,6 +100,9 @@ async function main() { if (serverOk) { try { + if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { + open(`http://127.0.0.1:${CLIENT_PORT}`); + } await spawnPromise("node", [inspectorClientPath], { env: { ...process.env, PORT: CLIENT_PORT }, signal: abort.signal, diff --git a/client/package.json b/client/package.json index 54b3dda4..2bcf2b03 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.10.2", + "version": "0.12.0", "description": "Client-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -23,7 +23,7 @@ "test:watch": "jest --config jest.config.cjs --watch" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.10.0", + "@modelcontextprotocol/sdk": "^1.11.0", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", diff --git a/client/src/App.tsx b/client/src/App.tsx index 7880b2a5..c32f4840 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -17,6 +17,9 @@ 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 React, { Suspense, useCallback, @@ -28,18 +31,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"; @@ -49,9 +55,15 @@ import RootsTab from "./components/RootsTab"; import SamplingTab, { PendingRequest } from "./components/SamplingTab"; import Sidebar from "./components/Sidebar"; import ToolsTab from "./components/ToolsTab"; -import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants"; import { InspectorConfig } from "./lib/configurationTypes"; -import { getMCPProxyAddress } from "./utils/configUtils"; +import { + getMCPProxyAddress, + getInitialSseUrl, + getInitialTransportType, + getInitialCommand, + getInitialArgs, + initializeInspectorConfig, +} from "./utils/configUtils"; const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; @@ -71,26 +83,13 @@ const App = () => { prompts: null, tools: null, }); - const [command, setCommand] = useState(() => { - return localStorage.getItem("lastCommand") || "mcp-server-everything"; - }); - const [args, setArgs] = useState(() => { - return localStorage.getItem("lastArgs") || ""; - }); + const [command, setCommand] = useState(getInitialCommand); + const [args, setArgs] = useState(getInitialArgs); - const [sseUrl, setSseUrl] = useState(() => { - return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse"; - }); + const [sseUrl, setSseUrl] = useState(getInitialSseUrl); const [transportType, setTransportType] = useState< "stdio" | "sse" | "streamable-http" - >(() => { - return ( - (localStorage.getItem("lastTransportType") as - | "stdio" - | "sse" - | "streamable-http") || "stdio" - ); - }); + >(getInitialTransportType); const [logLevel, setLogLevel] = useState("debug"); const [notifications, setNotifications] = useState([]); const [stdErrNotifications, setStdErrNotifications] = useState< @@ -99,27 +98,9 @@ const App = () => { const [roots, setRoots] = useState([]); const [env, setEnv] = useState>({}); - const [config, setConfig] = useState(() => { - const savedConfig = localStorage.getItem(CONFIG_LOCAL_STORAGE_KEY); - if (savedConfig) { - // merge default config with saved config - const mergedConfig = { - ...DEFAULT_INSPECTOR_CONFIG, - ...JSON.parse(savedConfig), - } as InspectorConfig; - - // update description of keys to match the new description (in case of any updates to the default config description) - Object.entries(mergedConfig).forEach(([key, value]) => { - mergedConfig[key as keyof InspectorConfig] = { - ...value, - label: DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].label, - }; - }); - - return mergedConfig; - } - return DEFAULT_INSPECTOR_CONFIG; - }); + const [config, setConfig] = useState(() => + initializeInspectorConfig(CONFIG_LOCAL_STORAGE_KEY), + ); const [bearerToken, setBearerToken] = useState(() => { return localStorage.getItem("lastBearerToken") || ""; }); @@ -136,6 +117,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([]); @@ -233,11 +235,64 @@ const App = () => { (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()) @@ -471,6 +526,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"), @@ -482,6 +550,17 @@ const App = () => { ); } + if (window.location.pathname === "/oauth/callback/debug") { + const OAuthDebugCallback = React.lazy( + () => import("./components/OAuthDebugCallback"), + ); + return ( + Loading...}> + + + ); + } + return (
{ Roots + + + Auth +
{!serverCapabilities?.resources && !serverCapabilities?.prompts && !serverCapabilities?.tools ? ( -
-

- The connected server does not support any MCP capabilities -

-
+ <> +
+

+ The connected server does not support any MCP + capabilities +

+
+ { + void sendMCPRequest( + { + method: "ping" as const, + }, + EmptyResultSchema, + ); + }} + /> + ) : ( <> { setRoots={setRoots} onRootsChange={handleRootsChange} /> + )}
+ ) : isAuthDebuggerVisible ? ( + (window.location.hash = value)} + > + + ) : ( -
+

Connect to an MCP server to start inspecting

+
+

+ Need to configure authentication? +

+ +
)}
diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx new file mode 100644 index 00000000..a2f20e26 --- /dev/null +++ b/client/src/components/AuthDebugger.tsx @@ -0,0 +1,272 @@ +import { useCallback, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { DebugInspectorOAuthClientProvider } from "../lib/auth"; +import { + auth, + discoverOAuthMetadata, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { AlertCircle } from "lucide-react"; +import { AuthDebuggerState } from "../lib/auth-types"; +import { OAuthFlowProgress } from "./OAuthFlowProgress"; +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 { + const serverAuthProvider = new DebugInspectorOAuthClientProvider( + serverUrl, + ); + // First discover OAuth metadata separately so we can save it + const metadata = await discoverOAuthMetadata(serverUrl); + if (!metadata) { + throw new Error("Failed to discover OAuth metadata"); + } + const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); + serverAuthProvider.saveServerMetadata(parsedMetadata); + + await auth(serverAuthProvider, { serverUrl: serverUrl }); + updateAuthState({ + statusMessage: { + type: "info", + message: "Starting OAuth authentication process...", + }, + }); + } 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]); + + 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/JsonView.tsx b/client/src/components/JsonView.tsx index bd7ef647..e9ef0d2d 100644 --- a/client/src/components/JsonView.tsx +++ b/client/src/components/JsonView.tsx @@ -3,7 +3,7 @@ import type { JsonValue } from "@/utils/jsonUtils"; import clsx from "clsx"; import { Copy, CheckCheck } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { useToast } from "@/hooks/use-toast"; +import { useToast } from "@/lib/hooks/useToast"; import { getDataType, tryParseJson } from "@/utils/jsonUtils"; interface JsonViewProps { diff --git a/client/src/components/OAuthCallback.tsx b/client/src/components/OAuthCallback.tsx index 6bfa8a3b..ccfd6d92 100644 --- a/client/src/components/OAuthCallback.tsx +++ b/client/src/components/OAuthCallback.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from "react"; import { InspectorOAuthClientProvider } from "../lib/auth"; import { SESSION_KEYS } from "../lib/constants"; import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; -import { useToast } from "@/hooks/use-toast.ts"; +import { useToast } from "@/lib/hooks/useToast"; import { generateOAuthErrorDescription, parseOAuthCallbackParams, 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..f604fc73 --- /dev/null +++ b/client/src/components/OAuthFlowProgress.tsx @@ -0,0 +1,259 @@ +import { AuthDebuggerState, OAuthStep } from "@/lib/auth-types"; +import { CheckCircle2, Circle, ExternalLink } from "lucide-react"; +import { Button } from "./ui/button"; +import { DebugInspectorOAuthClientProvider } from "@/lib/auth"; + +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; +} + +export const OAuthFlowProgress = ({ + serverUrl, + authState, + updateAuthState, + proceedToNextStep, +}: OAuthFlowProgressProps) => { + const provider = new DebugInspectorOAuthClientProvider(serverUrl); + + const steps: Array = [ + "metadata_discovery", + "client_registration", + "authorization_redirect", + "authorization_code", + "token_request", + "complete", + ]; + const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep); + + // 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)}
+              
+
+ )} +
+ + + {authState.oauthClientInfo && ( +
+ + Registered Client Information + +
+                {JSON.stringify(authState.oauthClientInfo, 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/SamplingRequest.tsx b/client/src/components/SamplingRequest.tsx index 2fc22d9e..0e6f365e 100644 --- a/client/src/components/SamplingRequest.tsx +++ b/client/src/components/SamplingRequest.tsx @@ -7,7 +7,7 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import { PendingRequest } from "./SamplingTab"; import DynamicJsonForm from "./DynamicJsonForm"; -import { useToast } from "@/hooks/use-toast"; +import { useToast } from "@/lib/hooks/useToast"; import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils"; export type SamplingRequestProps = { diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index bc6af52f..a7668d42 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"; @@ -29,13 +31,14 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import { InspectorConfig } from "@/lib/configurationTypes"; import { ConnectionStatus } from "@/lib/constants"; -import useTheme from "../lib/useTheme"; +import useTheme from "../lib/hooks/useTheme"; import { version } from "../../../package.json"; import { Tooltip, TooltipTrigger, TooltipContent, } from "@/components/ui/tooltip"; +import { useToast } from "../lib/hooks/useToast"; interface SidebarProps { connectionStatus: ConnectionStatus; @@ -95,6 +98,120 @@ 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 Client", + }; + } + if (transportType === "streamable-http") { + return { + type: "streamable-http", + url: sseUrl, + note: "For Streamable HTTP connections, add this URL directly in 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 in Cursor directly.", + }); + + 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 (
@@ -223,6 +340,7 @@ const Sidebar = ({
)} + {transportType === "stdio" && (
+ + Copy Server Entry + + + + + + Copy Servers File + +
+ {/* Configuration */}