diff --git a/README.md b/README.md index 98b5704..3ffeae9 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The MCP inspector is a developer tool for testing and debugging MCP servers. 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`: ```bash -npx @modelcontextprotocol/inspector build/index.js +npx @modelcontextprotocol/inspector node build/index.js ``` You can pass both arguments and environment variables to your MCP server. Arguments are passed directly to your server, while environment variables can be set using the `-e` flag: @@ -21,19 +21,19 @@ You can pass both arguments and environment variables to your MCP server. Argume npx @modelcontextprotocol/inspector build/index.js arg1 arg2 # Pass environment variables only -npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 build/index.js +npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js # Pass both environment variables and arguments -npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 build/index.js arg1 arg2 +npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js arg1 arg2 # Use -- to separate inspector flags from server arguments -npx @modelcontextprotocol/inspector -e KEY=$VALUE -- build/index.js -e server-flag +npx @modelcontextprotocol/inspector -e KEY=$VALUE -- node build/index.js -e server-flag ``` The inspector runs both a client UI (default port 5173) and an MCP proxy server (default port 3000). Open the client UI in your browser to use the inspector. You can customize the ports if needed: ```bash -CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector build/index.js +CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js ``` 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). diff --git a/client/package.json b/client/package.json index 69021c8..e3445da 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.4.0", + "version": "0.4.1", "description": "Client-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/client/src/components/OAuthCallback.tsx b/client/src/components/OAuthCallback.tsx index a7439df..869eef1 100644 --- a/client/src/components/OAuthCallback.tsx +++ b/client/src/components/OAuthCallback.tsx @@ -24,9 +24,15 @@ const OAuthCallback = () => { } try { - const accessToken = await handleOAuthCallback(serverUrl, code); - // Store the access token for future use - sessionStorage.setItem(SESSION_KEYS.ACCESS_TOKEN, accessToken); + const tokens = await handleOAuthCallback(serverUrl, code); + // Store both access and refresh tokens + sessionStorage.setItem(SESSION_KEYS.ACCESS_TOKEN, tokens.access_token); + if (tokens.refresh_token) { + sessionStorage.setItem( + SESSION_KEYS.REFRESH_TOKEN, + tokens.refresh_token, + ); + } // Redirect back to the main app with server URL to trigger auto-connect window.location.href = `/?serverUrl=${encodeURIComponent(serverUrl)}`; } catch (error) { diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 6b64c01..a3a7ff2 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -87,11 +87,20 @@ const ToolsTab = ({ className="max-w-full h-auto" /> )} - {item.type === "resource" && ( -
-                  {JSON.stringify(item.resource, null, 2)}
-                
- )} + {item.type === "resource" && + (item.resource?.mimeType?.startsWith("audio/") ? ( + + ) : ( +
+                    {JSON.stringify(item.resource, null, 2)}
+                  
+ ))} ))} diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index 0417731..592dc17 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -1,10 +1,21 @@ import pkceChallenge from "pkce-challenge"; import { SESSION_KEYS } from "./constants"; +import { z } from "zod"; -export interface OAuthMetadata { - authorization_endpoint: string; - token_endpoint: string; -} +export const OAuthMetadataSchema = z.object({ + authorization_endpoint: z.string(), + token_endpoint: z.string(), +}); + +export type OAuthMetadata = z.infer; + +export const OAuthTokensSchema = z.object({ + access_token: z.string(), + refresh_token: z.string().optional(), + expires_in: z.number().optional(), +}); + +export type OAuthTokens = z.infer; export async function discoverOAuthMetadata( serverUrl: string, @@ -15,10 +26,11 @@ export async function discoverOAuthMetadata( if (response.ok) { const metadata = await response.json(); - return { + const validatedMetadata = OAuthMetadataSchema.parse({ authorization_endpoint: metadata.authorization_endpoint, token_endpoint: metadata.token_endpoint, - }; + }); + return validatedMetadata; } } catch (error) { console.warn("OAuth metadata discovery failed:", error); @@ -26,10 +38,11 @@ export async function discoverOAuthMetadata( // Fall back to default endpoints const baseUrl = new URL(serverUrl); - return { + const defaultMetadata = { authorization_endpoint: new URL("/authorize", baseUrl).toString(), token_endpoint: new URL("/token", baseUrl).toString(), }; + return OAuthMetadataSchema.parse(defaultMetadata); } export async function startOAuthFlow(serverUrl: string): Promise { @@ -60,7 +73,7 @@ export async function startOAuthFlow(serverUrl: string): Promise { export async function handleOAuthCallback( serverUrl: string, code: string, -): Promise { +): Promise { // Get stored code verifier const codeVerifier = sessionStorage.getItem(SESSION_KEYS.CODE_VERIFIER); if (!codeVerifier) { @@ -69,7 +82,6 @@ export async function handleOAuthCallback( // Discover OAuth endpoints const metadata = await discoverOAuthMetadata(serverUrl); - // Exchange code for tokens const response = await fetch(metadata.token_endpoint, { method: "POST", @@ -88,6 +100,35 @@ export async function handleOAuthCallback( throw new Error("Token exchange failed"); } - const data = await response.json(); - return data.access_token; + const tokens = await response.json(); + return OAuthTokensSchema.parse(tokens); +} + +export async function refreshAccessToken( + serverUrl: string, +): Promise { + const refreshToken = sessionStorage.getItem(SESSION_KEYS.REFRESH_TOKEN); + if (!refreshToken) { + throw new Error("No refresh token available"); + } + + const metadata = await discoverOAuthMetadata(serverUrl); + + const response = await fetch(metadata.token_endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: refreshToken, + }), + }); + + if (!response.ok) { + throw new Error("Token refresh failed"); + } + + const tokens = await response.json(); + return OAuthTokensSchema.parse(tokens); } diff --git a/client/src/lib/constants.ts b/client/src/lib/constants.ts index e302b52..13a2370 100644 --- a/client/src/lib/constants.ts +++ b/client/src/lib/constants.ts @@ -3,4 +3,5 @@ export const SESSION_KEYS = { CODE_VERIFIER: "mcp_code_verifier", SERVER_URL: "mcp_server_url", ACCESS_TOKEN: "mcp_access_token", + REFRESH_TOKEN: "mcp_refresh_token", } as const; diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index de2d29e..6c42c3f 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -16,7 +16,7 @@ import { import { useState } from "react"; import { toast } from "react-toastify"; import { z } from "zod"; -import { startOAuthFlow } from "../auth"; +import { startOAuthFlow, refreshAccessToken } from "../auth"; import { SESSION_KEYS } from "../constants"; import { Notification, StdErrNotificationSchema } from "../notificationTypes"; @@ -121,7 +121,49 @@ export function useConnection({ } }; - const connect = async () => { + const initiateOAuthFlow = async () => { + sessionStorage.removeItem(SESSION_KEYS.ACCESS_TOKEN); + sessionStorage.removeItem(SESSION_KEYS.REFRESH_TOKEN); + sessionStorage.setItem(SESSION_KEYS.SERVER_URL, sseUrl); + const redirectUrl = await startOAuthFlow(sseUrl); + window.location.href = redirectUrl; + }; + + const handleTokenRefresh = async () => { + try { + const tokens = await refreshAccessToken(sseUrl); + sessionStorage.setItem(SESSION_KEYS.ACCESS_TOKEN, tokens.access_token); + if (tokens.refresh_token) { + sessionStorage.setItem( + SESSION_KEYS.REFRESH_TOKEN, + tokens.refresh_token, + ); + } + return tokens.access_token; + } catch (error) { + console.error("Token refresh failed:", error); + await initiateOAuthFlow(); + throw error; + } + }; + + const handleAuthError = async (error: unknown) => { + if (error instanceof SseError && error.code === 401) { + if (sessionStorage.getItem(SESSION_KEYS.REFRESH_TOKEN)) { + try { + await handleTokenRefresh(); + return true; + } catch (error) { + console.error("Token refresh failed:", error); + } + } else { + await initiateOAuthFlow(); + } + } + return false; + }; + + const connect = async (_e?: unknown, retryCount: number = 0) => { try { const client = new Client( { @@ -182,14 +224,15 @@ export function useConnection({ await client.connect(clientTransport); } catch (error) { console.error("Failed to connect to MCP server:", error); - if (error instanceof SseError && error.code === 401) { - // Store the server URL for the callback handler - sessionStorage.setItem(SESSION_KEYS.SERVER_URL, sseUrl); - const redirectUrl = await startOAuthFlow(sseUrl); - window.location.href = redirectUrl; - return; + const shouldRetry = await handleAuthError(error); + if (shouldRetry) { + return connect(undefined, retryCount + 1); } + if (error instanceof SseError && error.code === 401) { + // Don't set error state if we're about to redirect for auth + return; + } throw error; } diff --git a/package.json b/package.json index cf06f70..0989aa9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.4.0", + "version": "0.4.1", "description": "Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -33,8 +33,8 @@ "publish-all": "npm publish --workspaces --access public && npm publish --access public" }, "dependencies": { - "@modelcontextprotocol/inspector-client": "0.3.0", - "@modelcontextprotocol/inspector-server": "0.3.0", + "@modelcontextprotocol/inspector-client": "0.4.1", + "@modelcontextprotocol/inspector-server": "0.4.1", "concurrently": "^9.0.1", "shell-quote": "^1.8.2", "spawn-rx": "^5.1.0", diff --git a/server/package.json b/server/package.json index d1cea0a..05de0d1 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-server", - "version": "0.4.0", + "version": "0.4.1", "description": "Server-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/server/src/index.ts b/server/src/index.ts index 4d8ac42..2874b45 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -181,4 +181,16 @@ app.get("/config", (req, res) => { }); const PORT = process.env.PORT || 3000; -app.listen(PORT, () => {}); + +try { + const server = app.listen(PORT); + + server.on("listening", () => { + const addr = server.address(); + const port = typeof addr === "string" ? addr : addr?.port; + console.log(`Proxy server listening on port ${port}`); + }); +} catch (error) { + console.error("Failed to start server:", error); + process.exit(1); +}