From f514857a3401c05826d73cb94854886b4f0f1aa4 Mon Sep 17 00:00:00 2001 From: olaservo Date: Mon, 19 May 2025 05:31:24 -0700 Subject: [PATCH 01/46] Apply process env after defaults --- cli/src/transport.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/transport.ts b/cli/src/transport.ts index e693f246..22dde73b 100644 --- a/cli/src/transport.ts +++ b/cli/src/transport.ts @@ -38,8 +38,8 @@ function createStdioTransport(options: TransportOptions): Transport { const defaultEnv = getDefaultEnvironment(); const env: Record = { - ...processEnv, ...defaultEnv, + ...processEnv, }; const { cmd: actualCommand, args: actualArgs } = findActualExecutable( From f1401b143660aac71ffa3aab577ad88e11f09f89 Mon Sep 17 00:00:00 2001 From: olaservo Date: Mon, 19 May 2025 10:14:26 -0700 Subject: [PATCH 02/46] Apply process env after defaults for UI mode, too --- server/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index c967b60c..97dbe941 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -59,8 +59,8 @@ const createTransport = async (req: express.Request): Promise => { if (transportType === "stdio") { const command = query.command as string; const origArgs = shellParseArgs(query.args as string) as string[]; - const queryEnv = query.env ? JSON.parse(query.env as string) : {}; - const env = { ...process.env, ...defaultEnvironment, ...queryEnv }; + const queryEnv = query.env ? JSON.parse(query.env as string) : {}; + const env = { ...defaultEnvironment, ...process.env, ...queryEnv }; const { cmd, args } = findActualExecutable(command, origArgs); From 9655841399999fa51f4e16a30511901428428984 Mon Sep 17 00:00:00 2001 From: olaservo Date: Tue, 27 May 2025 09:01:29 -0700 Subject: [PATCH 03/46] Fix formatting --- server/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 97dbe941..c113d1ea 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -59,8 +59,8 @@ const createTransport = async (req: express.Request): Promise => { if (transportType === "stdio") { const command = query.command as string; const origArgs = shellParseArgs(query.args as string) as string[]; - const queryEnv = query.env ? JSON.parse(query.env as string) : {}; - const env = { ...defaultEnvironment, ...process.env, ...queryEnv }; + const queryEnv = query.env ? JSON.parse(query.env as string) : {}; + const env = { ...defaultEnvironment, ...process.env, ...queryEnv }; const { cmd, args } = findActualExecutable(command, origArgs); From 0194e5f62a8bc4bb3772e220db1673f1beadfaf4 Mon Sep 17 00:00:00 2001 From: Jonathan Leitschuh Date: Mon, 4 Aug 2025 14:35:33 -0400 Subject: [PATCH 04/46] Add warning for DANGEROUSLY_OMIT_AUTH usage Added warning about disabling authentication and its risks. --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 6a671f1c..6eaa7e4b 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,16 @@ If you need to disable authentication (NOT RECOMMENDED), you can set the `DANGER DANGEROUSLY_OMIT_AUTH=true npm start ``` +--- + +**🚨 WARNING 🚨** + +Disabling authentication with `DANGEROUSLY_OMIT_AUTH` is incredibly dangerous! Disabling auth leaves your machine open to attack not just when exposed to the public internet, but also **via your web browser**. Meaning, visiting a malicious website OR viewing a malicious advertizement could allow an attacker to remotely compromise your computer. Do not disable this feature unless you truly understand the risks. + +Read more about the risks of this vulnerability on Oligo's blog: [Critical RCE Vulnerability in Anthropic MCP Inspector - CVE-2025-49596](https://www.oligo.security/blog/critical-rce-vulnerability-in-anthropic-mcp-inspector-cve-2025-49596) + +--- + You can also set the token via the `MCP_PROXY_AUTH_TOKEN` environment variable when starting the server: ```bash From 85c86de61e46757e96cb290a53c7571f46df18f5 Mon Sep 17 00:00:00 2001 From: Raghunath Prabhakar <82239949+Raghuboi@users.noreply.github.com> Date: Mon, 11 Aug 2025 11:25:23 -0500 Subject: [PATCH 05/46] chore: add jsdoc comments to utils --- client/src/utils/jsonUtils.ts | 10 ++++++++++ client/src/utils/oauthUtils.ts | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/client/src/utils/jsonUtils.ts b/client/src/utils/jsonUtils.ts index 60f5b1d6..08de50ba 100644 --- a/client/src/utils/jsonUtils.ts +++ b/client/src/utils/jsonUtils.ts @@ -63,12 +63,22 @@ export type DataType = | "array" | "null"; +/** + * Determines the specific data type of a JSON value + * @param value The JSON value to analyze + * @returns The specific data type including "array" and "null" as distinct types + */ export function getDataType(value: JsonValue): DataType { if (Array.isArray(value)) return "array"; if (value === null) return "null"; return typeof value; } +/** + * Attempts to parse a string as JSON, only for objects and arrays + * @param str The string to parse + * @returns Object with success boolean and either parsed data or original string + */ export function tryParseJson(str: string): { success: boolean; data: JsonValue; diff --git a/client/src/utils/oauthUtils.ts b/client/src/utils/oauthUtils.ts index 83de080b..9b51179c 100644 --- a/client/src/utils/oauthUtils.ts +++ b/client/src/utils/oauthUtils.ts @@ -24,6 +24,11 @@ type CallbackParams = error_uri: string | null; }; +/** + * Parses OAuth 2.1 callback parameters from a URL search string + * @param location The URL search string (e.g., "?code=abc123" or "?error=access_denied") + * @returns Parsed callback parameters with success/error information + */ export const parseOAuthCallbackParams = (location: string): CallbackParams => { const params = new URLSearchParams(location); @@ -62,6 +67,11 @@ export const generateOAuthState = () => { ); }; +/** + * Generates a human-readable error description from OAuth callback error parameters + * @param params OAuth error callback parameters containing error details + * @returns Formatted multiline error message with error code, description, and optional URI + */ export const generateOAuthErrorDescription = ( params: Extract, ): string => { From fc4aeb392fa942048870ee7fb36f7ea1b435be86 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 13 Aug 2025 14:00:08 -0700 Subject: [PATCH 06/46] Restore AuthDebuggerState.resource as URL Not doing so prevents the resource URI from getting sent to the OAuth server's token endpoint during the "Quick OAuth flow" --- client/src/components/OAuthDebugCallback.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx index 99774297..e7a566f9 100644 --- a/client/src/components/OAuthDebugCallback.tsx +++ b/client/src/components/OAuthDebugCallback.tsx @@ -46,6 +46,9 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { if (storedState) { try { restoredState = JSON.parse(storedState); + if (typeof restoredState.resource === "string") { + restoredState.resource = new URL(restoredState.resource); + } // Clean up the stored state sessionStorage.removeItem(SESSION_KEYS.AUTH_DEBUGGER_STATE); } catch (e) { From 36b9acc97e0f142dab6430c91a71131790523cad Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 14 Aug 2025 17:03:12 +0100 Subject: [PATCH 07/46] fix: filter out client_secret when storing OAuth client information --- client/src/lib/auth.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index 7012c782..1a3db326 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -110,10 +110,17 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { } saveClientInformation(clientInformation: OAuthClientInformation) { + // Remove client_secret before storing (not needed after initial OAuth flow) + const safeInfo = Object.fromEntries( + Object.entries(clientInformation).filter( + ([key]) => key !== "client_secret", + ), + ) as OAuthClientInformation; + // Save the dynamically registered client information to session storage saveClientInformationToSessionStorage({ serverUrl: this.serverUrl, - clientInformation, + clientInformation: safeInfo, isPreregistered: false, }); } From 10b4681e3c6de348388891f5c7f461457ae2f630 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 14 Aug 2025 10:47:15 -0700 Subject: [PATCH 08/46] Also fix authorizationUrl type --- client/src/components/OAuthDebugCallback.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx index e7a566f9..67ddc926 100644 --- a/client/src/components/OAuthDebugCallback.tsx +++ b/client/src/components/OAuthDebugCallback.tsx @@ -46,9 +46,12 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { if (storedState) { try { restoredState = JSON.parse(storedState); - if (typeof restoredState.resource === "string") { + if (restoredState && typeof restoredState.resource === "string") { restoredState.resource = new URL(restoredState.resource); } + if (restoredState && typeof restoredState.authorizationUrl === "string") { + restoredState.authorizationUrl = new URL(restoredState.authorizationUrl); + } // Clean up the stored state sessionStorage.removeItem(SESSION_KEYS.AUTH_DEBUGGER_STATE); } catch (e) { From 74900fca5df7a4c26b8d8dd7dffb335cab160b42 Mon Sep 17 00:00:00 2001 From: Bobby Tiernay Date: Mon, 11 Aug 2025 20:02:37 -0400 Subject: [PATCH 09/46] feat: unify OAuth scope discovery between automatic and manual flows - Add shared discoverScopes function in auth.ts with resource metadata preference - Update automatic flow (handleAuthError) to use dynamic scope discovery - Update manual flow (OAuth state machine) to use shared scope discovery logic - Add comprehensive test suite for discoverScopes function (11 test cases) - Fix empty array handling to return undefined instead of empty string - Maintain backward compatibility with existing oauthScope parameter - All quality gates pass: TypeScript, linting, formatting, and tests Resolves OAuth scope discovery inconsistency where automatic flow used static scopes while manual flow used metadata scopes. Both flows now use identical shared logic with proper resource metadata preference. --- .../__tests__/AuthDebugger.test.tsx | 69 ++++++- client/src/lib/__tests__/auth.test.ts | 154 +++++++++++++++ client/src/lib/auth.ts | 35 ++++ .../hooks/__tests__/useConnection.test.tsx | 181 +++++++++++++++++- client/src/lib/hooks/useConnection.ts | 22 ++- client/src/lib/oauth-state-machine.ts | 10 +- 6 files changed, 451 insertions(+), 20 deletions(-) create mode 100644 client/src/lib/__tests__/auth.test.ts diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 0f7fb413..3dfbaead 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -25,6 +25,7 @@ const mockOAuthMetadata = { token_endpoint: "https://oauth.example.com/token", response_types_supported: ["code"], grant_types_supported: ["authorization_code"], + scopes_supported: ["read", "write"], }; const mockOAuthClientInfo = { @@ -56,6 +57,40 @@ import { import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types"; +// Mock local auth module +jest.mock("@/lib/auth", () => ({ + DebugInspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ + tokens: jest.fn().mockImplementation(() => Promise.resolve(undefined)), + clear: jest.fn().mockImplementation(() => { + // Mock the real clear() behavior which removes items from sessionStorage + sessionStorage.removeItem("[https://example.com/mcp] mcp_tokens"); + sessionStorage.removeItem("[https://example.com/mcp] mcp_client_info"); + sessionStorage.removeItem( + "[https://example.com/mcp] mcp_server_metadata", + ); + }), + redirectUrl: "http://localhost:3000/oauth/callback/debug", + clientMetadata: { + redirect_uris: ["http://localhost:3000/oauth/callback/debug"], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + client_name: "MCP Inspector", + }, + clientInformation: jest.fn(), + saveClientInformation: jest.fn(), + saveTokens: jest.fn(), + redirectToAuthorization: jest.fn(), + saveCodeVerifier: jest.fn(), + codeVerifier: jest.fn(), + saveServerMetadata: jest.fn(), + getServerMetadata: jest.fn(), + })), + discoverScopes: jest.fn().mockResolvedValue("read write" as never), +})); + +import { discoverScopes } from "@/lib/auth"; + // Type the mocked functions properly const mockDiscoverAuthorizationServerMetadata = discoverAuthorizationServerMetadata as jest.MockedFunction< @@ -75,6 +110,9 @@ const mockDiscoverOAuthProtectedResourceMetadata = discoverOAuthProtectedResourceMetadata as jest.MockedFunction< typeof discoverOAuthProtectedResourceMetadata >; +const mockDiscoverScopes = discoverScopes as jest.MockedFunction< + typeof discoverScopes +>; const sessionStorageMock = { getItem: jest.fn(), @@ -103,9 +141,15 @@ describe("AuthDebugger", () => { // Suppress console errors in tests to avoid JSDOM navigation noise jest.spyOn(console, "error").mockImplementation(() => {}); - mockDiscoverAuthorizationServerMetadata.mockResolvedValue( - mockOAuthMetadata, - ); + // Set default mock behaviors with complete OAuth metadata + mockDiscoverAuthorizationServerMetadata.mockResolvedValue({ + 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"], + scopes_supported: ["read", "write"], + }); mockRegisterClient.mockResolvedValue(mockOAuthClientInfo); mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue( new Error("No protected resource metadata found"), @@ -427,7 +471,24 @@ describe("AuthDebugger", () => { }); }); - it("should not include scope in authorization URL when scopes_supported is not present", async () => { + it("should 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.stringContaining("scope="), + }), + ); + }); + }); + + it("should omit scope from authorization URL when discoverScopes returns undefined", async () => { + // Mock discoverScopes to return undefined (no scopes available) + mockDiscoverScopes.mockResolvedValueOnce(undefined); + const updateAuthState = await setupAuthorizationUrlTest(mockOAuthMetadata); diff --git a/client/src/lib/__tests__/auth.test.ts b/client/src/lib/__tests__/auth.test.ts new file mode 100644 index 00000000..b34b3266 --- /dev/null +++ b/client/src/lib/__tests__/auth.test.ts @@ -0,0 +1,154 @@ +import { discoverScopes } from "../auth"; +import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js"; + +jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ + discoverAuthorizationServerMetadata: jest.fn(), +})); + +const mockDiscoverAuth = + discoverAuthorizationServerMetadata as jest.MockedFunction< + typeof discoverAuthorizationServerMetadata + >; + +const baseMetadata = { + issuer: "https://test.com", + authorization_endpoint: "https://test.com/authorize", + token_endpoint: "https://test.com/token", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + scopes_supported: ["read", "write"], +}; + +describe("discoverScopes", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const testCases = [ + { + name: "returns joined scopes from OAuth metadata", + mockResolves: baseMetadata, + serverUrl: "https://example.com", + expected: "read write", + expectedCallUrl: "https://example.com/", + }, + { + name: "prefers resource metadata over OAuth metadata", + mockResolves: baseMetadata, + serverUrl: "https://example.com", + resourceMetadata: { + resource: "https://example.com", + scopes_supported: ["admin", "full"], + }, + expected: "admin full", + }, + { + name: "falls back to OAuth when resource has empty scopes", + mockResolves: baseMetadata, + serverUrl: "https://example.com", + resourceMetadata: { + resource: "https://example.com", + scopes_supported: [], + }, + expected: "read write", + }, + { + name: "normalizes URL with port and path", + mockResolves: baseMetadata, + serverUrl: "https://example.com:8080/some/path", + expected: "read write", + expectedCallUrl: "https://example.com:8080/", + }, + { + name: "normalizes URL with trailing slash", + mockResolves: baseMetadata, + serverUrl: "https://example.com/", + expected: "read write", + expectedCallUrl: "https://example.com/", + }, + { + name: "handles single scope", + mockResolves: { ...baseMetadata, scopes_supported: ["admin"] }, + serverUrl: "https://example.com", + expected: "admin", + }, + { + name: "prefers resource metadata even with fewer scopes", + mockResolves: { + ...baseMetadata, + scopes_supported: ["read", "write", "admin", "full"], + }, + serverUrl: "https://example.com", + resourceMetadata: { + resource: "https://example.com", + scopes_supported: ["read"], + }, + expected: "read", + }, + ]; + + const undefinedCases = [ + { + name: "returns undefined when OAuth discovery fails", + mockRejects: new Error("Discovery failed"), + serverUrl: "https://example.com", + }, + { + name: "returns undefined when OAuth has no scopes", + mockResolves: { ...baseMetadata, scopes_supported: [] }, + serverUrl: "https://example.com", + }, + { + name: "returns undefined when scopes_supported missing", + mockResolves: (() => { + const { scopes_supported, ...rest } = baseMetadata; + return rest; + })(), + serverUrl: "https://example.com", + }, + { + name: "returns undefined with resource metadata but OAuth fails", + mockRejects: new Error("No OAuth metadata"), + serverUrl: "https://example.com", + resourceMetadata: { + resource: "https://example.com", + scopes_supported: ["read", "write"], + }, + }, + ]; + + test.each(testCases)( + "$name", + async ({ + mockResolves, + serverUrl, + resourceMetadata, + expected, + expectedCallUrl, + }) => { + mockDiscoverAuth.mockResolvedValue(mockResolves); + + const result = await discoverScopes(serverUrl, resourceMetadata); + + expect(result).toBe(expected); + if (expectedCallUrl) { + expect(mockDiscoverAuth).toHaveBeenCalledWith(new URL(expectedCallUrl)); + } + }, + ); + + test.each(undefinedCases)( + "$name", + async ({ mockResolves, mockRejects, serverUrl, resourceMetadata }) => { + if (mockRejects) { + mockDiscoverAuth.mockRejectedValue(mockRejects); + } else { + mockDiscoverAuth.mockResolvedValue(mockResolves); + } + + const result = await discoverScopes(serverUrl, resourceMetadata); + + expect(result).toBeUndefined(); + }, + ); +}); diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index 1a3db326..99707387 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -6,10 +6,45 @@ import { OAuthTokensSchema, OAuthClientMetadata, OAuthMetadata, + OAuthProtectedResourceMetadata, } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js"; import { SESSION_KEYS, getServerSpecificKey } from "./constants"; import { generateOAuthState } from "@/utils/oauthUtils"; +/** + * Discovers OAuth scopes from server metadata, with preference for resource metadata scopes + * @param serverUrl - The MCP server URL + * @param resourceMetadata - Optional resource metadata containing preferred scopes + * @returns Promise resolving to space-separated scope string or undefined + */ +export const discoverScopes = async ( + serverUrl: string, + resourceMetadata?: OAuthProtectedResourceMetadata, +): Promise => { + try { + const metadata = await discoverAuthorizationServerMetadata( + new URL("/", serverUrl), + ); + + // Prefer resource metadata scopes, but fall back to OAuth metadata if empty + const resourceScopes = resourceMetadata?.scopes_supported; + const oauthScopes = metadata?.scopes_supported; + + const scopesSupported = + resourceScopes && resourceScopes.length > 0 + ? resourceScopes + : oauthScopes; + + return scopesSupported && scopesSupported.length > 0 + ? scopesSupported.join(" ") + : undefined; + } catch (error) { + console.debug("OAuth scope discovery failed:", error); + return undefined; + } +}; + export const getClientInformationFromSessionStorage = async ({ serverUrl, isPreregistered, diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index c340d774..7518f814 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -3,11 +3,16 @@ import { useConnection } from "../useConnection"; import { z } from "zod"; import { ClientRequest } from "@modelcontextprotocol/sdk/types.js"; import { DEFAULT_INSPECTOR_CONFIG } from "../../constants"; -import { SSEClientTransportOptions } from "@modelcontextprotocol/sdk/client/sse.js"; +import { + SSEClientTransportOptions, + SseError, +} from "@modelcontextprotocol/sdk/client/sse.js"; import { ElicitResult, ElicitRequest, } from "@modelcontextprotocol/sdk/types.js"; +import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; +import { discoverScopes } from "../../auth"; // Mock fetch global.fetch = jest.fn().mockResolvedValue({ @@ -53,14 +58,27 @@ jest.mock("@modelcontextprotocol/sdk/client/index.js", () => ({ Client: jest.fn().mockImplementation(() => mockClient), })); -jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({ - SSEClientTransport: jest.fn((url, options) => { - mockSSETransport.url = url; - mockSSETransport.options = options; - return mockSSETransport; - }), - SseError: jest.fn(), -})); +jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => { + // Minimal mock class that supports instanceof checks + class SseError extends Error { + code: number; + event: ErrorEvent; + constructor(code: number, message: string, event: ErrorEvent) { + super(message); + this.code = code; + this.event = event; + } + } + + return { + SSEClientTransport: jest.fn((url, options) => { + mockSSETransport.url = url; + mockSSETransport.options = options; + return mockSSETransport; + }), + SseError, + }; +}); jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ StreamableHTTPClientTransport: jest.fn((url, options) => { @@ -85,11 +103,18 @@ jest.mock("@/lib/hooks/useToast", () => ({ jest.mock("../../auth", () => ({ InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }), + redirectUrl: "http://localhost:3000/oauth/callback", })), clearClientInformationFromSessionStorage: jest.fn(), saveClientInformationToSessionStorage: jest.fn(), + discoverScopes: jest.fn(), })); +const mockAuth = auth as jest.MockedFunction; +const mockDiscoverScopes = discoverScopes as jest.MockedFunction< + typeof discoverScopes +>; + describe("useConnection", () => { const defaultProps = { transportType: "sse" as const, @@ -714,4 +739,142 @@ describe("useConnection", () => { ).toHaveProperty("X-MCP-Proxy-Auth", "Bearer test-proxy-token"); }); }); + + describe("OAuth Error Handling with Scope Discovery", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockAuth.mockResolvedValue("AUTHORIZED"); + mockDiscoverScopes.mockResolvedValue(undefined); + }); + + const setup401Error = () => { + const mockErrorEvent = new ErrorEvent("error", { + message: "Mock error event", + }); + mockClient.connect.mockRejectedValueOnce( + new SseError(401, "Unauthorized", mockErrorEvent), + ); + }; + + const attemptConnection = async (props = defaultProps) => { + const { result } = renderHook(() => useConnection(props)); + await act(async () => { + try { + await result.current.connect(); + } catch { + // Expected error from auth handling + } + }); + }; + + const testCases = [ + [ + "discovers and includes scopes in auth call", + { + discoveredScope: "read write admin", + oauthScope: undefined, + expectScopeCall: true, + expectedAuthScope: "read write admin", + authResult: "AUTHORIZED", + }, + ], + [ + "handles scope discovery failure gracefully", + { + discoveredScope: undefined, + oauthScope: undefined, + expectScopeCall: true, + expectedAuthScope: undefined, + authResult: "AUTHORIZED", + }, + ], + [ + "uses manual oauthScope override instead of discovered scopes", + { + discoveredScope: "discovered:scope", + oauthScope: "manual:scope", + expectScopeCall: false, + expectedAuthScope: "manual:scope", + authResult: "AUTHORIZED", + }, + ], + [ + "triggers scope discovery when oauthScope is whitespace", + { + discoveredScope: "discovered:scope", + oauthScope: " ", + expectScopeCall: true, + expectedAuthScope: "discovered:scope", + authResult: "AUTHORIZED", + }, + ], + [ + "handles auth failure after scope discovery", + { + discoveredScope: "read write", + oauthScope: undefined, + expectScopeCall: true, + expectedAuthScope: "read write", + authResult: "UNAUTHORIZED", + }, + ], + ] as const; + + test.each(testCases)( + "should %s", + async ( + _, + { + discoveredScope, + oauthScope, + expectScopeCall, + expectedAuthScope, + authResult = "AUTHORIZED", + }, + ) => { + mockDiscoverScopes.mockResolvedValue(discoveredScope); + mockAuth.mockResolvedValue(authResult as never); + setup401Error(); + + const props = + oauthScope !== undefined + ? { ...defaultProps, oauthScope } + : defaultProps; + await attemptConnection(props); + + if (expectScopeCall) { + expect(mockDiscoverScopes).toHaveBeenCalledWith( + defaultProps.sseUrl, + undefined, + ); + } else { + expect(mockDiscoverScopes).not.toHaveBeenCalled(); + } + + expect(mockAuth).toHaveBeenCalledWith(expect.any(Object), { + serverUrl: defaultProps.sseUrl, + scope: expectedAuthScope, + }); + }, + ); + + it("should handle slow scope discovery gracefully", async () => { + mockDiscoverScopes.mockImplementation( + () => + new Promise((resolve) => setTimeout(() => resolve(undefined), 100)), + ); + + setup401Error(); + await attemptConnection(); + + expect(mockDiscoverScopes).toHaveBeenCalledWith( + defaultProps.sseUrl, + undefined, + ); + expect(mockAuth).toHaveBeenCalledWith(expect.any(Object), { + serverUrl: defaultProps.sseUrl, + scope: undefined, + }); + }); + }); }); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 12d8ff3e..e6008197 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -37,11 +37,15 @@ import { useToast } from "@/lib/hooks/useToast"; import { z } from "zod"; import { ConnectionStatus } from "../constants"; import { Notification, StdErrNotificationSchema } from "../notificationTypes"; -import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; +import { + auth, + discoverOAuthProtectedResourceMetadata, +} from "@modelcontextprotocol/sdk/client/auth.js"; import { clearClientInformationFromSessionStorage, InspectorOAuthClientProvider, saveClientInformationToSessionStorage, + discoverScopes, } from "../auth"; import packageJson from "../../../package.json"; import { @@ -317,9 +321,23 @@ export function useConnection({ if (is401Error(error)) { const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); + let scope = oauthScope?.trim(); + if (!scope) { + // Only discover resource metadata when we need to discover scopes + let resourceMetadata; + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + new URL("/", sseUrl), + ); + } catch { + // Resource metadata is optional, continue without it + } + scope = await discoverScopes(sseUrl, resourceMetadata); + } + const result = await auth(serverAuthProvider, { serverUrl: sseUrl, - scope: oauthScope, + scope, }); return result === "AUTHORIZED"; } diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index 24b62084..4a2c0f47 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -1,5 +1,5 @@ import { OAuthStep, AuthDebuggerState } from "./auth-types"; -import { DebugInspectorOAuthClientProvider } from "./auth"; +import { DebugInspectorOAuthClientProvider, discoverScopes } from "./auth"; import { discoverAuthorizationServerMetadata, registerClient, @@ -113,10 +113,10 @@ export const oauthTransitions: Record = { 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 scope = await discoverScopes( + context.serverUrl, + context.state.resourceMetadata ?? undefined, + ); const { authorizationUrl, codeVerifier } = await startAuthorization( context.serverUrl, From 34b39530d1f417e7d35dfa87cbf0d633d3b61804 Mon Sep 17 00:00:00 2001 From: Bobby Tiernay Date: Thu, 14 Aug 2025 14:02:00 -0400 Subject: [PATCH 10/46] fix: failing test from SDK update --- .../__tests__/AuthDebugger.test.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 3dfbaead..b3da23fd 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -77,8 +77,25 @@ jest.mock("@/lib/auth", () => ({ response_types: ["code"], client_name: "MCP Inspector", }, - clientInformation: jest.fn(), - saveClientInformation: jest.fn(), + clientInformation: jest.fn().mockImplementation(async () => { + const serverUrl = "https://example.com/mcp"; + const preregisteredKey = `[${serverUrl}] ${SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION}`; + const preregisteredData = sessionStorage.getItem(preregisteredKey); + if (preregisteredData) { + return JSON.parse(preregisteredData); + } + const dynamicKey = `[${serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`; + const dynamicData = sessionStorage.getItem(dynamicKey); + if (dynamicData) { + return JSON.parse(dynamicData); + } + return undefined; + }), + saveClientInformation: jest.fn().mockImplementation((clientInfo) => { + const serverUrl = "https://example.com/mcp"; + const key = `[${serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`; + sessionStorage.setItem(key, JSON.stringify(clientInfo)); + }), saveTokens: jest.fn(), redirectToAuthorization: jest.fn(), saveCodeVerifier: jest.fn(), From 739e0aaa684e2a6acf37f514cf658d05c0241be8 Mon Sep 17 00:00:00 2001 From: Bobby Tiernay Date: Thu, 14 Aug 2025 15:00:54 -0400 Subject: [PATCH 11/46] fix: lint --- client/src/lib/__tests__/auth.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/lib/__tests__/auth.test.ts b/client/src/lib/__tests__/auth.test.ts index b34b3266..329b7f02 100644 --- a/client/src/lib/__tests__/auth.test.ts +++ b/client/src/lib/__tests__/auth.test.ts @@ -101,6 +101,7 @@ describe("discoverScopes", () => { { name: "returns undefined when scopes_supported missing", mockResolves: (() => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { scopes_supported, ...rest } = baseMetadata; return rest; })(), From 8d8af870ba0a9c46342a0c74f2510a5df59361c5 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Thu, 14 Aug 2025 00:14:30 -0700 Subject: [PATCH 12/46] Trim whitespace from `command` on blur --- client/src/components/Sidebar.tsx | 1 + .../src/components/__tests__/Sidebar.test.tsx | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index a41e4bfc..4691c99c 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -268,6 +268,7 @@ const Sidebar = ({ placeholder="Command" value={command} onChange={(e) => setCommand(e.target.value)} + onBlur={(e) => setCommand(e.target.value.trim())} className="font-mono" /> diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index e892a7f8..b4dcb684 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -878,4 +878,36 @@ describe("Sidebar Environment Variables", () => { expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig); }); }); + + describe("Command and arguments", () => { + it("should trim whitespace from command input on blur", () => { + const setCommand = jest.fn(); + renderSidebar({ command: " node server.js ", setCommand }); + + const commandInput = screen.getByLabelText("Command"); + + fireEvent.blur(commandInput); + expect(setCommand).toHaveBeenLastCalledWith("node server.js"); + }); + + it("should handle whitespace-only command input on blur", () => { + const setCommand = jest.fn(); + renderSidebar({ command: " ", setCommand }); + + const commandInput = screen.getByLabelText("Command"); + + fireEvent.blur(commandInput); + expect(setCommand).toHaveBeenLastCalledWith(""); + }); + + it("should not affect command without surrounding whitespace", () => { + const setCommand = jest.fn(); + renderSidebar({ command: "node", setCommand }); + + const commandInput = screen.getByLabelText("Command"); + + fireEvent.blur(commandInput); + expect(setCommand).toHaveBeenLastCalledWith("node"); + }); + }); }); From 9a1540c79ab376fe9ec6c4a922a37990c3d64022 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Thu, 14 Aug 2025 00:15:19 -0700 Subject: [PATCH 13/46] Trim whitespace from `command` query parameter --- server/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/index.ts b/server/src/index.ts index 34a69414..7e0f151c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -181,7 +181,7 @@ const createTransport = async (req: express.Request): Promise => { const transportType = query.transportType as string; if (transportType === "stdio") { - const command = query.command as string; + const command = (query.command as string).trim(); const origArgs = shellParseArgs(query.args as string) as string[]; const queryEnv = query.env ? JSON.parse(query.env as string) : {}; const env = { ...process.env, ...defaultEnvironment, ...queryEnv }; From 399e16afa4eeee8b7b4ad7a43800f78b9c4976de Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Thu, 14 Aug 2025 10:40:32 -0700 Subject: [PATCH 14/46] Re-organize Sidebar tests - Tests were mistakenly grouped in Environment Variables block - Tests follow UI order, top-to-bottom --- .../src/components/__tests__/Sidebar.test.tsx | 1048 +++++++++-------- 1 file changed, 529 insertions(+), 519 deletions(-) diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index b4dcb684..dc737896 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -69,586 +69,325 @@ describe("Sidebar Environment Variables", () => { ); }; - const openEnvVarsSection = () => { - const button = screen.getByTestId("env-vars-button"); - fireEvent.click(button); - }; - beforeEach(() => { jest.clearAllMocks(); jest.clearAllTimers(); }); - describe("Basic Operations", () => { - it("should add a new environment variable", () => { - const setEnv = jest.fn(); - renderSidebar({ env: {}, setEnv }); + describe("Command and arguments", () => { + it("should trim whitespace from command input on blur", () => { + const setCommand = jest.fn(); + renderSidebar({ command: " node server.js ", setCommand }); - openEnvVarsSection(); + const commandInput = screen.getByLabelText("Command"); - const addButton = screen.getByText("Add Environment Variable"); - fireEvent.click(addButton); - - expect(setEnv).toHaveBeenCalledWith({ "": "" }); + fireEvent.blur(commandInput); + expect(setCommand).toHaveBeenLastCalledWith("node server.js"); }); - it("should remove an environment variable", () => { - const setEnv = jest.fn(); - const initialEnv = { TEST_KEY: "test_value" }; - renderSidebar({ env: initialEnv, setEnv }); + it("should handle whitespace-only command input on blur", () => { + const setCommand = jest.fn(); + renderSidebar({ command: " ", setCommand }); - openEnvVarsSection(); + const commandInput = screen.getByLabelText("Command"); - const removeButton = screen.getByRole("button", { name: "Γ—" }); - fireEvent.click(removeButton); - - expect(setEnv).toHaveBeenCalledWith({}); + fireEvent.blur(commandInput); + expect(setCommand).toHaveBeenLastCalledWith(""); }); - it("should update environment variable value", () => { - const setEnv = jest.fn(); - const initialEnv = { TEST_KEY: "test_value" }; - renderSidebar({ env: initialEnv, setEnv }); + it("should not affect command without surrounding whitespace", () => { + const setCommand = jest.fn(); + renderSidebar({ command: "node", setCommand }); - openEnvVarsSection(); + const commandInput = screen.getByLabelText("Command"); - const valueInput = screen.getByDisplayValue("test_value"); - fireEvent.change(valueInput, { target: { value: "new_value" } }); - - expect(setEnv).toHaveBeenCalledWith({ TEST_KEY: "new_value" }); - }); - - it("should toggle value visibility", () => { - const initialEnv = { TEST_KEY: "test_value" }; - renderSidebar({ env: initialEnv }); - - openEnvVarsSection(); - - const valueInput = screen.getByDisplayValue("test_value"); - expect(valueInput).toHaveProperty("type", "password"); - - const toggleButton = screen.getByRole("button", { name: /show value/i }); - fireEvent.click(toggleButton); - - expect(valueInput).toHaveProperty("type", "text"); + fireEvent.blur(commandInput); + expect(setCommand).toHaveBeenLastCalledWith("node"); }); }); - describe("Authentication", () => { - const openAuthSection = () => { - const button = screen.getByTestId("auth-button"); + describe("Environment Variables", () => { + const openEnvVarsSection = () => { + const button = screen.getByTestId("env-vars-button"); fireEvent.click(button); }; - it("should update bearer token", () => { - const setBearerToken = jest.fn(); - renderSidebar({ - bearerToken: "", - setBearerToken, - transportType: "sse", // Set transport type to SSE + describe("Basic Operations", () => { + it("should add a new environment variable", () => { + const setEnv = jest.fn(); + renderSidebar({ env: {}, setEnv }); + + openEnvVarsSection(); + + const addButton = screen.getByText("Add Environment Variable"); + fireEvent.click(addButton); + + expect(setEnv).toHaveBeenCalledWith({ "": "" }); }); - openAuthSection(); + it("should remove an environment variable", () => { + const setEnv = jest.fn(); + const initialEnv = { TEST_KEY: "test_value" }; + renderSidebar({ env: initialEnv, setEnv }); - const tokenInput = screen.getByTestId("bearer-token-input"); - fireEvent.change(tokenInput, { target: { value: "new_token" } }); + openEnvVarsSection(); - expect(setBearerToken).toHaveBeenCalledWith("new_token"); - }); + const removeButton = screen.getByRole("button", { name: "Γ—" }); + fireEvent.click(removeButton); - it("should update header name", () => { - const setHeaderName = jest.fn(); - renderSidebar({ - headerName: "Authorization", - setHeaderName, - transportType: "sse", + expect(setEnv).toHaveBeenCalledWith({}); }); - openAuthSection(); + it("should update environment variable value", () => { + const setEnv = jest.fn(); + const initialEnv = { TEST_KEY: "test_value" }; + renderSidebar({ env: initialEnv, setEnv }); - const headerInput = screen.getByTestId("header-input"); - fireEvent.change(headerInput, { target: { value: "X-Custom-Auth" } }); + openEnvVarsSection(); - expect(setHeaderName).toHaveBeenCalledWith("X-Custom-Auth"); - }); + const valueInput = screen.getByDisplayValue("test_value"); + fireEvent.change(valueInput, { target: { value: "new_value" } }); - it("should clear bearer token", () => { - const setBearerToken = jest.fn(); - renderSidebar({ - bearerToken: "existing_token", - setBearerToken, - transportType: "sse", // Set transport type to SSE + expect(setEnv).toHaveBeenCalledWith({ TEST_KEY: "new_value" }); }); - openAuthSection(); + it("should toggle value visibility", () => { + const initialEnv = { TEST_KEY: "test_value" }; + renderSidebar({ env: initialEnv }); - const tokenInput = screen.getByTestId("bearer-token-input"); - fireEvent.change(tokenInput, { target: { value: "" } }); + openEnvVarsSection(); - expect(setBearerToken).toHaveBeenCalledWith(""); - }); + const valueInput = screen.getByDisplayValue("test_value"); + expect(valueInput).toHaveProperty("type", "password"); - it("should properly render bearer token input", () => { - const { rerender } = renderSidebar({ - bearerToken: "existing_token", - transportType: "sse", // Set transport type to SSE - }); + const toggleButton = screen.getByRole("button", { + name: /show value/i, + }); + fireEvent.click(toggleButton); - openAuthSection(); - - // Token input should be a password field - const tokenInput = screen.getByTestId("bearer-token-input"); - expect(tokenInput).toHaveProperty("type", "password"); - - // Update the token - fireEvent.change(tokenInput, { target: { value: "new_token" } }); - - // Rerender with updated token - rerender( - - - , - ); - - // Token input should still exist after update - expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument(); - }); - - it("should maintain token visibility state after update", () => { - const { rerender } = renderSidebar({ - bearerToken: "existing_token", - transportType: "sse", // Set transport type to SSE - }); - - openAuthSection(); - - // Token input should be a password field - const tokenInput = screen.getByTestId("bearer-token-input"); - expect(tokenInput).toHaveProperty("type", "password"); - - // Update the token - fireEvent.change(tokenInput, { target: { value: "new_token" } }); - - // Rerender with updated token - rerender( - - - , - ); - - // Token input should still exist after update - expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument(); - }); - - it("should maintain header name when toggling auth section", () => { - renderSidebar({ - headerName: "X-API-Key", - transportType: "sse", - }); - - // Open auth section - openAuthSection(); - - // Verify header name is displayed - const headerInput = screen.getByTestId("header-input"); - expect(headerInput).toHaveValue("X-API-Key"); - - // Close auth section - const authButton = screen.getByTestId("auth-button"); - fireEvent.click(authButton); - - // Reopen auth section - fireEvent.click(authButton); - - // Verify header name is still preserved - expect(screen.getByTestId("header-input")).toHaveValue("X-API-Key"); - }); - - it("should display default header name when not specified", () => { - renderSidebar({ - headerName: undefined, - transportType: "sse", - }); - - openAuthSection(); - - const headerInput = screen.getByTestId("header-input"); - expect(headerInput).toHaveAttribute("placeholder", "Authorization"); - }); - }); - - describe("Key Editing", () => { - it("should maintain order when editing first key", () => { - const setEnv = jest.fn(); - const initialEnv = { - FIRST_KEY: "first_value", - SECOND_KEY: "second_value", - THIRD_KEY: "third_value", - }; - renderSidebar({ env: initialEnv, setEnv }); - - openEnvVarsSection(); - - const firstKeyInput = screen.getByDisplayValue("FIRST_KEY"); - fireEvent.change(firstKeyInput, { target: { value: "NEW_FIRST_KEY" } }); - - expect(setEnv).toHaveBeenCalledWith({ - NEW_FIRST_KEY: "first_value", - SECOND_KEY: "second_value", - THIRD_KEY: "third_value", + expect(valueInput).toHaveProperty("type", "text"); }); }); - it("should maintain order when editing middle key", () => { - const setEnv = jest.fn(); - const initialEnv = { - FIRST_KEY: "first_value", - SECOND_KEY: "second_value", - THIRD_KEY: "third_value", - }; - renderSidebar({ env: initialEnv, setEnv }); + describe("Key Editing", () => { + it("should maintain order when editing first key", () => { + const setEnv = jest.fn(); + const initialEnv = { + FIRST_KEY: "first_value", + SECOND_KEY: "second_value", + THIRD_KEY: "third_value", + }; + renderSidebar({ env: initialEnv, setEnv }); - openEnvVarsSection(); + openEnvVarsSection(); - const middleKeyInput = screen.getByDisplayValue("SECOND_KEY"); - fireEvent.change(middleKeyInput, { target: { value: "NEW_SECOND_KEY" } }); + const firstKeyInput = screen.getByDisplayValue("FIRST_KEY"); + fireEvent.change(firstKeyInput, { target: { value: "NEW_FIRST_KEY" } }); - expect(setEnv).toHaveBeenCalledWith({ - FIRST_KEY: "first_value", - NEW_SECOND_KEY: "second_value", - THIRD_KEY: "third_value", - }); - }); - - it("should maintain order when editing last key", () => { - const setEnv = jest.fn(); - const initialEnv = { - FIRST_KEY: "first_value", - SECOND_KEY: "second_value", - THIRD_KEY: "third_value", - }; - renderSidebar({ env: initialEnv, setEnv }); - - openEnvVarsSection(); - - const lastKeyInput = screen.getByDisplayValue("THIRD_KEY"); - fireEvent.change(lastKeyInput, { target: { value: "NEW_THIRD_KEY" } }); - - expect(setEnv).toHaveBeenCalledWith({ - FIRST_KEY: "first_value", - SECOND_KEY: "second_value", - NEW_THIRD_KEY: "third_value", - }); - }); - - it("should maintain order during key editing", () => { - const setEnv = jest.fn(); - const initialEnv = { - KEY1: "value1", - KEY2: "value2", - }; - renderSidebar({ env: initialEnv, setEnv }); - - openEnvVarsSection(); - - // Type "NEW_" one character at a time - const key1Input = screen.getByDisplayValue("KEY1"); - "NEW_".split("").forEach((char) => { - fireEvent.change(key1Input, { - target: { value: char + "KEY1".slice(1) }, + expect(setEnv).toHaveBeenCalledWith({ + NEW_FIRST_KEY: "first_value", + SECOND_KEY: "second_value", + THIRD_KEY: "third_value", }); }); - // Verify the last setEnv call maintains the order - const lastCall = setEnv.mock.calls[ - setEnv.mock.calls.length - 1 - ][0] as Record; - const entries = Object.entries(lastCall); + it("should maintain order when editing middle key", () => { + const setEnv = jest.fn(); + const initialEnv = { + FIRST_KEY: "first_value", + SECOND_KEY: "second_value", + THIRD_KEY: "third_value", + }; + renderSidebar({ env: initialEnv, setEnv }); - // The values should stay with their original keys - expect(entries[0][1]).toBe("value1"); // First entry should still have value1 - expect(entries[1][1]).toBe("value2"); // Second entry should still have value2 - }); - }); + openEnvVarsSection(); - describe("Multiple Operations", () => { - it("should maintain state after multiple key edits", () => { - const setEnv = jest.fn(); - const initialEnv = { - FIRST_KEY: "first_value", - SECOND_KEY: "second_value", - }; - const { rerender } = renderSidebar({ env: initialEnv, setEnv }); + const middleKeyInput = screen.getByDisplayValue("SECOND_KEY"); + fireEvent.change(middleKeyInput, { + target: { value: "NEW_SECOND_KEY" }, + }); - openEnvVarsSection(); + expect(setEnv).toHaveBeenCalledWith({ + FIRST_KEY: "first_value", + NEW_SECOND_KEY: "second_value", + THIRD_KEY: "third_value", + }); + }); - // First key edit - const firstKeyInput = screen.getByDisplayValue("FIRST_KEY"); - fireEvent.change(firstKeyInput, { target: { value: "NEW_FIRST_KEY" } }); + it("should maintain order when editing last key", () => { + const setEnv = jest.fn(); + const initialEnv = { + FIRST_KEY: "first_value", + SECOND_KEY: "second_value", + THIRD_KEY: "third_value", + }; + renderSidebar({ env: initialEnv, setEnv }); - // Get the updated env from the first setEnv call - const updatedEnv = setEnv.mock.calls[0][0] as Record; + openEnvVarsSection(); - // Rerender with the updated env - rerender( - - - , - ); + const lastKeyInput = screen.getByDisplayValue("THIRD_KEY"); + fireEvent.change(lastKeyInput, { target: { value: "NEW_THIRD_KEY" } }); - // Second key edit - const secondKeyInput = screen.getByDisplayValue("SECOND_KEY"); - fireEvent.change(secondKeyInput, { target: { value: "NEW_SECOND_KEY" } }); + expect(setEnv).toHaveBeenCalledWith({ + FIRST_KEY: "first_value", + SECOND_KEY: "second_value", + NEW_THIRD_KEY: "third_value", + }); + }); - // Verify the final state matches what we expect - expect(setEnv).toHaveBeenLastCalledWith({ - NEW_FIRST_KEY: "first_value", - NEW_SECOND_KEY: "second_value", + it("should maintain order during key editing", () => { + const setEnv = jest.fn(); + const initialEnv = { + KEY1: "value1", + KEY2: "value2", + }; + renderSidebar({ env: initialEnv, setEnv }); + + openEnvVarsSection(); + + // Type "NEW_" one character at a time + const key1Input = screen.getByDisplayValue("KEY1"); + "NEW_".split("").forEach((char) => { + fireEvent.change(key1Input, { + target: { value: char + "KEY1".slice(1) }, + }); + }); + + // Verify the last setEnv call maintains the order + const lastCall = setEnv.mock.calls[ + setEnv.mock.calls.length - 1 + ][0] as Record; + const entries = Object.entries(lastCall); + + // The values should stay with their original keys + expect(entries[0][1]).toBe("value1"); // First entry should still have value1 + expect(entries[1][1]).toBe("value2"); // Second entry should still have value2 }); }); - it("should maintain visibility state after key edit", () => { - const initialEnv = { TEST_KEY: "test_value" }; - const { rerender } = renderSidebar({ env: initialEnv }); + describe("Multiple Operations", () => { + it("should maintain state after multiple key edits", () => { + const setEnv = jest.fn(); + const initialEnv = { + FIRST_KEY: "first_value", + SECOND_KEY: "second_value", + }; + const { rerender } = renderSidebar({ env: initialEnv, setEnv }); - openEnvVarsSection(); + openEnvVarsSection(); - // Show the value - const toggleButton = screen.getByRole("button", { name: /show value/i }); - fireEvent.click(toggleButton); + // First key edit + const firstKeyInput = screen.getByDisplayValue("FIRST_KEY"); + fireEvent.change(firstKeyInput, { target: { value: "NEW_FIRST_KEY" } }); - const valueInput = screen.getByDisplayValue("test_value"); - expect(valueInput).toHaveProperty("type", "text"); + // Get the updated env from the first setEnv call + const updatedEnv = setEnv.mock.calls[0][0] as Record; - // Edit the key - const keyInput = screen.getByDisplayValue("TEST_KEY"); - fireEvent.change(keyInput, { target: { value: "NEW_KEY" } }); + // Rerender with the updated env + rerender( + + + , + ); - // Rerender with updated env - rerender( - - - , - ); + // Second key edit + const secondKeyInput = screen.getByDisplayValue("SECOND_KEY"); + fireEvent.change(secondKeyInput, { + target: { value: "NEW_SECOND_KEY" }, + }); - // Value should still be visible - const updatedValueInput = screen.getByDisplayValue("test_value"); - expect(updatedValueInput).toHaveProperty("type", "text"); - }); - }); - - describe("Edge Cases", () => { - it("should handle empty key", () => { - const setEnv = jest.fn(); - const initialEnv = { TEST_KEY: "test_value" }; - renderSidebar({ env: initialEnv, setEnv }); - - openEnvVarsSection(); - - const keyInput = screen.getByDisplayValue("TEST_KEY"); - fireEvent.change(keyInput, { target: { value: "" } }); - - expect(setEnv).toHaveBeenCalledWith({ "": "test_value" }); - }); - - it("should handle special characters in key", () => { - const setEnv = jest.fn(); - const initialEnv = { TEST_KEY: "test_value" }; - renderSidebar({ env: initialEnv, setEnv }); - - openEnvVarsSection(); - - const keyInput = screen.getByDisplayValue("TEST_KEY"); - fireEvent.change(keyInput, { target: { value: "TEST-KEY@123" } }); - - expect(setEnv).toHaveBeenCalledWith({ "TEST-KEY@123": "test_value" }); - }); - - it("should handle unicode characters", () => { - const setEnv = jest.fn(); - const initialEnv = { TEST_KEY: "test_value" }; - renderSidebar({ env: initialEnv, setEnv }); - - openEnvVarsSection(); - - const keyInput = screen.getByDisplayValue("TEST_KEY"); - fireEvent.change(keyInput, { target: { value: "TEST_πŸ”‘" } }); - - expect(setEnv).toHaveBeenCalledWith({ "TEST_πŸ”‘": "test_value" }); - }); - - it("should handle very long key names", () => { - const setEnv = jest.fn(); - const initialEnv = { TEST_KEY: "test_value" }; - renderSidebar({ env: initialEnv, setEnv }); - - openEnvVarsSection(); - - const keyInput = screen.getByDisplayValue("TEST_KEY"); - const longKey = "A".repeat(100); - fireEvent.change(keyInput, { target: { value: longKey } }); - - expect(setEnv).toHaveBeenCalledWith({ [longKey]: "test_value" }); - }); - }); - - describe("Configuration Operations", () => { - const openConfigSection = () => { - const button = screen.getByTestId("config-button"); - fireEvent.click(button); - }; - - it("should update MCP server request timeout", () => { - const setConfig = jest.fn(); - renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig }); - - openConfigSection(); - - const timeoutInput = screen.getByTestId( - "MCP_SERVER_REQUEST_TIMEOUT-input", - ); - fireEvent.change(timeoutInput, { target: { value: "5000" } }); - - expect(setConfig).toHaveBeenCalledWith( - expect.objectContaining({ - MCP_SERVER_REQUEST_TIMEOUT: { - label: "Request Timeout", - description: "Timeout for requests to the MCP server (ms)", - value: 5000, - is_session_item: false, - }, - }), - ); - }); - - it("should update MCP server proxy address", () => { - const setConfig = jest.fn(); - renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig }); - - openConfigSection(); - - const proxyAddressInput = screen.getByTestId( - "MCP_PROXY_FULL_ADDRESS-input", - ); - fireEvent.change(proxyAddressInput, { - target: { value: "http://localhost:8080" }, + // Verify the final state matches what we expect + expect(setEnv).toHaveBeenLastCalledWith({ + NEW_FIRST_KEY: "first_value", + NEW_SECOND_KEY: "second_value", + }); }); - expect(setConfig).toHaveBeenCalledWith( - expect.objectContaining({ - MCP_PROXY_FULL_ADDRESS: { - label: "Inspector Proxy Address", - description: - "Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577", - value: "http://localhost:8080", - is_session_item: false, - }, - }), - ); + it("should maintain visibility state after key edit", () => { + const initialEnv = { TEST_KEY: "test_value" }; + const { rerender } = renderSidebar({ env: initialEnv }); + + openEnvVarsSection(); + + // Show the value + const toggleButton = screen.getByRole("button", { + name: /show value/i, + }); + fireEvent.click(toggleButton); + + const valueInput = screen.getByDisplayValue("test_value"); + expect(valueInput).toHaveProperty("type", "text"); + + // Edit the key + const keyInput = screen.getByDisplayValue("TEST_KEY"); + fireEvent.change(keyInput, { target: { value: "NEW_KEY" } }); + + // Rerender with updated env + rerender( + + + , + ); + + // Value should still be visible + const updatedValueInput = screen.getByDisplayValue("test_value"); + expect(updatedValueInput).toHaveProperty("type", "text"); + }); }); - it("should update max total timeout", () => { - const setConfig = jest.fn(); - renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig }); + describe("Edge Cases", () => { + it("should handle empty key", () => { + const setEnv = jest.fn(); + const initialEnv = { TEST_KEY: "test_value" }; + renderSidebar({ env: initialEnv, setEnv }); - openConfigSection(); + openEnvVarsSection(); - const maxTotalTimeoutInput = screen.getByTestId( - "MCP_REQUEST_MAX_TOTAL_TIMEOUT-input", - ); - fireEvent.change(maxTotalTimeoutInput, { - target: { value: "10000" }, + const keyInput = screen.getByDisplayValue("TEST_KEY"); + fireEvent.change(keyInput, { target: { value: "" } }); + + expect(setEnv).toHaveBeenCalledWith({ "": "test_value" }); }); - expect(setConfig).toHaveBeenCalledWith( - expect.objectContaining({ - MCP_REQUEST_MAX_TOTAL_TIMEOUT: { - label: "Maximum Total Timeout", - description: - "Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)", - value: 10000, - is_session_item: false, - }, - }), - ); - }); + it("should handle special characters in key", () => { + const setEnv = jest.fn(); + const initialEnv = { TEST_KEY: "test_value" }; + renderSidebar({ env: initialEnv, setEnv }); - it("should handle invalid timeout values entered by user", () => { - const setConfig = jest.fn(); - renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig }); + openEnvVarsSection(); - openConfigSection(); + const keyInput = screen.getByDisplayValue("TEST_KEY"); + fireEvent.change(keyInput, { target: { value: "TEST-KEY@123" } }); - const timeoutInput = screen.getByTestId( - "MCP_SERVER_REQUEST_TIMEOUT-input", - ); - fireEvent.change(timeoutInput, { target: { value: "abc1" } }); - - expect(setConfig).toHaveBeenCalledWith( - expect.objectContaining({ - MCP_SERVER_REQUEST_TIMEOUT: { - label: "Request Timeout", - description: "Timeout for requests to the MCP server (ms)", - value: 0, - is_session_item: false, - }, - }), - ); - }); - - it("should maintain configuration state after multiple updates", () => { - const setConfig = jest.fn(); - const { rerender } = renderSidebar({ - config: DEFAULT_INSPECTOR_CONFIG, - setConfig, + expect(setEnv).toHaveBeenCalledWith({ "TEST-KEY@123": "test_value" }); }); - openConfigSection(); - // First update - const timeoutInput = screen.getByTestId( - "MCP_SERVER_REQUEST_TIMEOUT-input", - ); - fireEvent.change(timeoutInput, { target: { value: "5000" } }); + it("should handle unicode characters", () => { + const setEnv = jest.fn(); + const initialEnv = { TEST_KEY: "test_value" }; + renderSidebar({ env: initialEnv, setEnv }); - // Get the updated config from the first setConfig call - const updatedConfig = setConfig.mock.calls[0][0] as InspectorConfig; + openEnvVarsSection(); - // Rerender with the updated config - rerender( - - - , - ); + const keyInput = screen.getByDisplayValue("TEST_KEY"); + fireEvent.change(keyInput, { target: { value: "TEST_πŸ”‘" } }); - // Second update - const updatedTimeoutInput = screen.getByTestId( - "MCP_SERVER_REQUEST_TIMEOUT-input", - ); - fireEvent.change(updatedTimeoutInput, { target: { value: "3000" } }); + expect(setEnv).toHaveBeenCalledWith({ "TEST_πŸ”‘": "test_value" }); + }); - // Verify the final state matches what we expect - expect(setConfig).toHaveBeenLastCalledWith( - expect.objectContaining({ - MCP_SERVER_REQUEST_TIMEOUT: { - label: "Request Timeout", - description: "Timeout for requests to the MCP server (ms)", - value: 3000, - is_session_item: false, - }, - }), - ); + it("should handle very long key names", () => { + const setEnv = jest.fn(); + const initialEnv = { TEST_KEY: "test_value" }; + renderSidebar({ env: initialEnv, setEnv }); + + openEnvVarsSection(); + + const keyInput = screen.getByDisplayValue("TEST_KEY"); + const longKey = "A".repeat(100); + fireEvent.change(keyInput, { target: { value: longKey } }); + + expect(setEnv).toHaveBeenCalledWith({ [longKey]: "test_value" }); + }); }); }); @@ -879,35 +618,306 @@ describe("Sidebar Environment Variables", () => { }); }); - describe("Command and arguments", () => { - it("should trim whitespace from command input on blur", () => { - const setCommand = jest.fn(); - renderSidebar({ command: " node server.js ", setCommand }); + describe("Authentication", () => { + const openAuthSection = () => { + const button = screen.getByTestId("auth-button"); + fireEvent.click(button); + }; - const commandInput = screen.getByLabelText("Command"); + it("should update bearer token", () => { + const setBearerToken = jest.fn(); + renderSidebar({ + bearerToken: "", + setBearerToken, + transportType: "sse", // Set transport type to SSE + }); - fireEvent.blur(commandInput); - expect(setCommand).toHaveBeenLastCalledWith("node server.js"); + openAuthSection(); + + const tokenInput = screen.getByTestId("bearer-token-input"); + fireEvent.change(tokenInput, { target: { value: "new_token" } }); + + expect(setBearerToken).toHaveBeenCalledWith("new_token"); }); - it("should handle whitespace-only command input on blur", () => { - const setCommand = jest.fn(); - renderSidebar({ command: " ", setCommand }); + it("should update header name", () => { + const setHeaderName = jest.fn(); + renderSidebar({ + headerName: "Authorization", + setHeaderName, + transportType: "sse", + }); - const commandInput = screen.getByLabelText("Command"); + openAuthSection(); - fireEvent.blur(commandInput); - expect(setCommand).toHaveBeenLastCalledWith(""); + const headerInput = screen.getByTestId("header-input"); + fireEvent.change(headerInput, { target: { value: "X-Custom-Auth" } }); + + expect(setHeaderName).toHaveBeenCalledWith("X-Custom-Auth"); }); - it("should not affect command without surrounding whitespace", () => { - const setCommand = jest.fn(); - renderSidebar({ command: "node", setCommand }); + it("should clear bearer token", () => { + const setBearerToken = jest.fn(); + renderSidebar({ + bearerToken: "existing_token", + setBearerToken, + transportType: "sse", // Set transport type to SSE + }); - const commandInput = screen.getByLabelText("Command"); + openAuthSection(); - fireEvent.blur(commandInput); - expect(setCommand).toHaveBeenLastCalledWith("node"); + const tokenInput = screen.getByTestId("bearer-token-input"); + fireEvent.change(tokenInput, { target: { value: "" } }); + + expect(setBearerToken).toHaveBeenCalledWith(""); + }); + + it("should properly render bearer token input", () => { + const { rerender } = renderSidebar({ + bearerToken: "existing_token", + transportType: "sse", // Set transport type to SSE + }); + + openAuthSection(); + + // Token input should be a password field + const tokenInput = screen.getByTestId("bearer-token-input"); + expect(tokenInput).toHaveProperty("type", "password"); + + // Update the token + fireEvent.change(tokenInput, { target: { value: "new_token" } }); + + // Rerender with updated token + rerender( + + + , + ); + + // Token input should still exist after update + expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument(); + }); + + it("should maintain token visibility state after update", () => { + const { rerender } = renderSidebar({ + bearerToken: "existing_token", + transportType: "sse", // Set transport type to SSE + }); + + openAuthSection(); + + // Token input should be a password field + const tokenInput = screen.getByTestId("bearer-token-input"); + expect(tokenInput).toHaveProperty("type", "password"); + + // Update the token + fireEvent.change(tokenInput, { target: { value: "new_token" } }); + + // Rerender with updated token + rerender( + + + , + ); + + // Token input should still exist after update + expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument(); + }); + + it("should maintain header name when toggling auth section", () => { + renderSidebar({ + headerName: "X-API-Key", + transportType: "sse", + }); + + // Open auth section + openAuthSection(); + + // Verify header name is displayed + const headerInput = screen.getByTestId("header-input"); + expect(headerInput).toHaveValue("X-API-Key"); + + // Close auth section + const authButton = screen.getByTestId("auth-button"); + fireEvent.click(authButton); + + // Reopen auth section + fireEvent.click(authButton); + + // Verify header name is still preserved + expect(screen.getByTestId("header-input")).toHaveValue("X-API-Key"); + }); + + it("should display default header name when not specified", () => { + renderSidebar({ + headerName: undefined, + transportType: "sse", + }); + + openAuthSection(); + + const headerInput = screen.getByTestId("header-input"); + expect(headerInput).toHaveAttribute("placeholder", "Authorization"); + }); + }); + + describe("Configuration Operations", () => { + const openConfigSection = () => { + const button = screen.getByTestId("config-button"); + fireEvent.click(button); + }; + + it("should update MCP server request timeout", () => { + const setConfig = jest.fn(); + renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig }); + + openConfigSection(); + + const timeoutInput = screen.getByTestId( + "MCP_SERVER_REQUEST_TIMEOUT-input", + ); + fireEvent.change(timeoutInput, { target: { value: "5000" } }); + + expect(setConfig).toHaveBeenCalledWith( + expect.objectContaining({ + MCP_SERVER_REQUEST_TIMEOUT: { + label: "Request Timeout", + description: "Timeout for requests to the MCP server (ms)", + value: 5000, + is_session_item: false, + }, + }), + ); + }); + + it("should update MCP server proxy address", () => { + const setConfig = jest.fn(); + renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig }); + + openConfigSection(); + + const proxyAddressInput = screen.getByTestId( + "MCP_PROXY_FULL_ADDRESS-input", + ); + fireEvent.change(proxyAddressInput, { + target: { value: "http://localhost:8080" }, + }); + + expect(setConfig).toHaveBeenCalledWith( + expect.objectContaining({ + MCP_PROXY_FULL_ADDRESS: { + label: "Inspector Proxy Address", + description: + "Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577", + value: "http://localhost:8080", + is_session_item: false, + }, + }), + ); + }); + + it("should update max total timeout", () => { + const setConfig = jest.fn(); + renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig }); + + openConfigSection(); + + const maxTotalTimeoutInput = screen.getByTestId( + "MCP_REQUEST_MAX_TOTAL_TIMEOUT-input", + ); + fireEvent.change(maxTotalTimeoutInput, { + target: { value: "10000" }, + }); + + expect(setConfig).toHaveBeenCalledWith( + expect.objectContaining({ + MCP_REQUEST_MAX_TOTAL_TIMEOUT: { + label: "Maximum Total Timeout", + description: + "Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)", + value: 10000, + is_session_item: false, + }, + }), + ); + }); + + it("should handle invalid timeout values entered by user", () => { + const setConfig = jest.fn(); + renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig }); + + openConfigSection(); + + const timeoutInput = screen.getByTestId( + "MCP_SERVER_REQUEST_TIMEOUT-input", + ); + fireEvent.change(timeoutInput, { target: { value: "abc1" } }); + + expect(setConfig).toHaveBeenCalledWith( + expect.objectContaining({ + MCP_SERVER_REQUEST_TIMEOUT: { + label: "Request Timeout", + description: "Timeout for requests to the MCP server (ms)", + value: 0, + is_session_item: false, + }, + }), + ); + }); + + it("should maintain configuration state after multiple updates", () => { + const setConfig = jest.fn(); + const { rerender } = renderSidebar({ + config: DEFAULT_INSPECTOR_CONFIG, + setConfig, + }); + + openConfigSection(); + // First update + const timeoutInput = screen.getByTestId( + "MCP_SERVER_REQUEST_TIMEOUT-input", + ); + fireEvent.change(timeoutInput, { target: { value: "5000" } }); + + // Get the updated config from the first setConfig call + const updatedConfig = setConfig.mock.calls[0][0] as InspectorConfig; + + // Rerender with the updated config + rerender( + + + , + ); + + // Second update + const updatedTimeoutInput = screen.getByTestId( + "MCP_SERVER_REQUEST_TIMEOUT-input", + ); + fireEvent.change(updatedTimeoutInput, { target: { value: "3000" } }); + + // Verify the final state matches what we expect + expect(setConfig).toHaveBeenLastCalledWith( + expect.objectContaining({ + MCP_SERVER_REQUEST_TIMEOUT: { + label: "Request Timeout", + description: "Timeout for requests to the MCP server (ms)", + value: 3000, + is_session_item: false, + }, + }), + ); }); }); }); From 34938b72d30d28bf0e656efa0a10a2f1dfd2537a Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Thu, 14 Aug 2025 10:49:04 -0700 Subject: [PATCH 15/46] Tidy test descriptions and titles --- client/src/components/__tests__/Sidebar.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index dc737896..cb43663a 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -31,7 +31,7 @@ Object.defineProperty(navigator, "clipboard", { // Setup fake timers jest.useFakeTimers(); -describe("Sidebar Environment Variables", () => { +describe("Sidebar", () => { const defaultProps = { connectionStatus: "disconnected" as const, transportType: "stdio" as const, @@ -362,7 +362,7 @@ describe("Sidebar Environment Variables", () => { expect(setEnv).toHaveBeenCalledWith({ "TEST-KEY@123": "test_value" }); }); - it("should handle unicode characters", () => { + it("should handle unicode characters in key", () => { const setEnv = jest.fn(); const initialEnv = { TEST_KEY: "test_value" }; renderSidebar({ env: initialEnv, setEnv }); @@ -375,7 +375,7 @@ describe("Sidebar Environment Variables", () => { expect(setEnv).toHaveBeenCalledWith({ "TEST_πŸ”‘": "test_value" }); }); - it("should handle very long key names", () => { + it("should handle a very long key name", () => { const setEnv = jest.fn(); const initialEnv = { TEST_KEY: "test_value" }; renderSidebar({ env: initialEnv, setEnv }); @@ -391,7 +391,7 @@ describe("Sidebar Environment Variables", () => { }); }); - describe("Copy Configuration Features", () => { + describe("Copy Server Features", () => { beforeEach(() => { jest.clearAllMocks(); jest.clearAllTimers(); @@ -769,7 +769,7 @@ describe("Sidebar Environment Variables", () => { }); }); - describe("Configuration Operations", () => { + describe("Configuration", () => { const openConfigSection = () => { const button = screen.getByTestId("config-button"); fireEvent.click(button); From 410f792d29af58ea77e929fe449db2d90c644f46 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Thu, 14 Aug 2025 10:50:29 -0700 Subject: [PATCH 16/46] Remove redundant mock and timer clear --- client/src/components/__tests__/Sidebar.test.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index cb43663a..8f693717 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -392,11 +392,6 @@ describe("Sidebar", () => { }); describe("Copy Server Features", () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.clearAllTimers(); - }); - const getCopyButtons = () => { return { serverEntry: screen.getByRole("button", { name: /server entry/i }), From 0831e812bb49c7d06f5a243db4b5e80708747332 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 15 Aug 2025 14:44:51 -0400 Subject: [PATCH 17/46] Align Quick OAuth Flow with Guided OAuth Flow behavior - In oauth-state-machine.ts - in token_request transition - ensure resource is URL type, not string - this fixes Quick flow which was sending string, while guided was using proper URL type and working --- client/src/lib/oauth-state-machine.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index 4a2c0f47..a5ae9d40 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -177,7 +177,7 @@ export const oauthTransitions: Record = { authorizationCode: context.state.authorizationCode, codeVerifier, redirectUri: context.provider.redirectUrl, - resource: context.state.resource ?? undefined, + resource: context.state.resource ? (context.state.resource instanceof URL ? context.state.resource : new URL(context.state.resource)) : undefined, }); context.provider.saveTokens(tokens); From 94f44c7b57e5070fdf7fb9f15857685f02be2751 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 15 Aug 2025 17:19:27 -0400 Subject: [PATCH 18/46] Prettier --- client/src/lib/oauth-state-machine.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index a5ae9d40..91465853 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -177,7 +177,11 @@ export const oauthTransitions: Record = { authorizationCode: context.state.authorizationCode, codeVerifier, redirectUri: context.provider.redirectUrl, - resource: context.state.resource ? (context.state.resource instanceof URL ? context.state.resource : new URL(context.state.resource)) : undefined, + resource: context.state.resource + ? context.state.resource instanceof URL + ? context.state.resource + : new URL(context.state.resource) + : undefined, }); context.provider.saveTokens(tokens); From 5323d4d8f4aef1c68745f8491d4dd58e79ba6c7e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 15 Aug 2025 18:40:21 -0400 Subject: [PATCH 19/46] Fix connect button behavior for Oauth by making sure that scope is included in client metadata when client is registered. - In auth.ts - in InspectorOAuthClientProvider constructor - add optional scope param - set instance var this.scope to scope param - in clientMetadata getter - add scope param set to this.scope or "" - in useConnection.ts - in handleAuthError handler - move instantiation of serverAuthProvider until after scope has been determined - pass scope to InspectorOAuthClientProvider constructor --- client/src/lib/auth.ts | 8 +++++++- client/src/lib/hooks/useConnection.ts | 3 +-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index 99707387..78d52a59 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -102,10 +102,15 @@ export const clearClientInformationFromSessionStorage = ({ }; export class InspectorOAuthClientProvider implements OAuthClientProvider { - constructor(protected serverUrl: string) { + constructor( + protected serverUrl: string, + scope?: string, + ) { + this.scope = scope; // Save the server URL to session storage sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl); } + scope: string | undefined; get redirectUrl() { return window.location.origin + "/oauth/callback"; @@ -119,6 +124,7 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { response_types: ["code"], client_name: "MCP Inspector", client_uri: "https://github.com/modelcontextprotocol/inspector", + scope: this.scope ?? "", }; } diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index e6008197..fc3e214f 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -319,8 +319,6 @@ export function useConnection({ const handleAuthError = async (error: unknown) => { if (is401Error(error)) { - const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); - let scope = oauthScope?.trim(); if (!scope) { // Only discover resource metadata when we need to discover scopes @@ -334,6 +332,7 @@ export function useConnection({ } scope = await discoverScopes(sseUrl, resourceMetadata); } + const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl, scope); const result = await auth(serverAuthProvider, { serverUrl: sseUrl, From 3adb1dd5c9790c1092978b813af7dabfa33f6c7f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 15 Aug 2025 18:55:15 -0400 Subject: [PATCH 20/46] Prettier --- client/src/lib/hooks/useConnection.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index fc3e214f..d3690f31 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -332,7 +332,10 @@ export function useConnection({ } scope = await discoverScopes(sseUrl, resourceMetadata); } - const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl, scope); + const serverAuthProvider = new InspectorOAuthClientProvider( + sseUrl, + scope, + ); const result = await auth(serverAuthProvider, { serverUrl: sseUrl, From 5bbbd645556283225cc2e0e20fdb26d7648ecd3e Mon Sep 17 00:00:00 2001 From: olaservo Date: Mon, 18 Aug 2025 06:15:15 -0700 Subject: [PATCH 21/46] Update CC GitHub Action to have same config as reference servers repo action --- .github/workflows/claude.yml | 37 +++++++++++++----------------------- 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index a4fd6c52..da8e7c7a 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -18,10 +18,6 @@ jobs: (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - ) && - ( - github.actor == 'ihrpr' || - github.actor == 'olaservo' ) runs-on: ubuntu-latest permissions: @@ -29,6 +25,7 @@ jobs: pull-requests: read issues: read id-token: write + actions: read steps: - name: Checkout repository uses: actions/checkout@v4 @@ -41,24 +38,16 @@ jobs: with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) - # model: "claude-opus-4-20250514" + # Allow Claude to read CI results on PRs + additional_permissions: | + actions: read - # Optional: Customize the trigger phrase (default: @claude) - # trigger_phrase: "/claude" - - # Optional: Trigger when specific user is assigned to an issue - # assignee_trigger: "claude-bot" - - # Optional: Allow Claude to run specific commands - # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" - - # Optional: Add custom instructions for Claude to customize its behavior for your project - # custom_instructions: | - # Follow our coding standards - # Ensure all new code has tests - # Use TypeScript for new files - - # Optional: Custom environment variables for Claude - # claude_env: | - # NODE_ENV: test + # Trigger when assigned to an issue + assignee_trigger: "claude" + + # Allow Claude to run bash + # This should be safe given the repo is already public + allowed_tools: "Bash" + + custom_instructions: | + If posting a comment to GitHub, give a concise summary of the comment at the top and put all the details in a
block. From 28c0bf176ab65be71aa4dc89c4062b3e1a1e7d2d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 18 Aug 2025 11:13:42 -0400 Subject: [PATCH 22/46] Update Typescript SDK version to latest --- cli/package.json | 2 +- client/package.json | 2 +- package-lock.json | 8 ++++---- package.json | 2 +- server/package.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cli/package.json b/cli/package.json index 9458ef65..7d38186e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -21,7 +21,7 @@ }, "devDependencies": {}, "dependencies": { - "@modelcontextprotocol/sdk": "^1.17.2", + "@modelcontextprotocol/sdk": "^1.17.3", "commander": "^13.1.0", "spawn-rx": "^5.1.2" } diff --git a/client/package.json b/client/package.json index 441d2168..3780a3b0 100644 --- a/client/package.json +++ b/client/package.json @@ -25,7 +25,7 @@ "cleanup:e2e": "node e2e/global-teardown.js" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.17.2", + "@modelcontextprotocol/sdk": "^1.17.3", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", diff --git a/package-lock.json b/package-lock.json index 3a99a46e..a7260f8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@modelcontextprotocol/inspector-cli": "^0.16.4", "@modelcontextprotocol/inspector-client": "^0.16.4", "@modelcontextprotocol/inspector-server": "^0.16.4", - "@modelcontextprotocol/sdk": "^1.17.2", + "@modelcontextprotocol/sdk": "^1.17.3", "concurrently": "^9.2.0", "open": "^10.2.0", "shell-quote": "^1.8.3", @@ -1999,9 +1999,9 @@ "link": true }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.17.2", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.2.tgz", - "integrity": "sha512-EFLRNXR/ixpXQWu6/3Cu30ndDFIFNaqUXcTqsGebujeMan9FzhAaFFswLRiFj61rgygDRr8WO1N+UijjgRxX9g==", + "version": "1.17.3", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.3.tgz", + "integrity": "sha512-JPwUKWSsbzx+DLFznf/QZ32Qa+ptfbUlHhRLrBQBAFu9iI1iYvizM4p+zhhRDceSsPutXp4z+R/HPVphlIiclg==", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index c2a78331..22353c33 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "@modelcontextprotocol/inspector-cli": "^0.16.4", "@modelcontextprotocol/inspector-client": "^0.16.4", "@modelcontextprotocol/inspector-server": "^0.16.4", - "@modelcontextprotocol/sdk": "^1.17.2", + "@modelcontextprotocol/sdk": "^1.17.3", "concurrently": "^9.2.0", "open": "^10.2.0", "shell-quote": "^1.8.3", diff --git a/server/package.json b/server/package.json index 4047e2d8..307a8c27 100644 --- a/server/package.json +++ b/server/package.json @@ -27,7 +27,7 @@ "typescript": "^5.6.2" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.17.2", + "@modelcontextprotocol/sdk": "^1.17.3", "cors": "^2.8.5", "express": "^5.1.0", "ws": "^8.18.0", From 2f93d7036430ab76b3bec7f66b42c8507e2dd742 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 18 Aug 2025 11:37:41 -0400 Subject: [PATCH 23/46] bump version to 0.16.5 --- cli/package.json | 2 +- client/package.json | 2 +- package-lock.json | 22 +++++++++++----------- package.json | 8 ++++---- server/package.json | 2 +- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/cli/package.json b/cli/package.json index 7d38186e..2f0f53b6 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.16.4", + "version": "0.16.5", "description": "CLI for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/client/package.json b/client/package.json index 3780a3b0..5d634ee1 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.16.4", + "version": "0.16.5", "description": "Client-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/package-lock.json b/package-lock.json index a7260f8b..87cbc93c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.16.4", + "version": "0.16.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "0.16.4", + "version": "0.16.5", "license": "MIT", "workspaces": [ "client", @@ -14,9 +14,9 @@ "cli" ], "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.16.4", - "@modelcontextprotocol/inspector-client": "^0.16.4", - "@modelcontextprotocol/inspector-server": "^0.16.4", + "@modelcontextprotocol/inspector-cli": "^0.16.5", + "@modelcontextprotocol/inspector-client": "^0.16.5", + "@modelcontextprotocol/inspector-server": "^0.16.5", "@modelcontextprotocol/sdk": "^1.17.3", "concurrently": "^9.2.0", "open": "^10.2.0", @@ -44,10 +44,10 @@ }, "cli": { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.16.4", + "version": "0.16.5", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.17.2", + "@modelcontextprotocol/sdk": "^1.17.3", "commander": "^13.1.0", "spawn-rx": "^5.1.2" }, @@ -67,10 +67,10 @@ }, "client": { "name": "@modelcontextprotocol/inspector-client", - "version": "0.16.4", + "version": "0.16.5", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.17.2", + "@modelcontextprotocol/sdk": "^1.17.3", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", @@ -11059,10 +11059,10 @@ }, "server": { "name": "@modelcontextprotocol/inspector-server", - "version": "0.16.4", + "version": "0.16.5", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.17.2", + "@modelcontextprotocol/sdk": "^1.17.3", "cors": "^2.8.5", "express": "^5.1.0", "ws": "^8.18.0", diff --git a/package.json b/package.json index 22353c33..33211621 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.16.4", + "version": "0.16.5", "description": "Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -47,9 +47,9 @@ "check-version": "node scripts/check-version-consistency.js" }, "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.16.4", - "@modelcontextprotocol/inspector-client": "^0.16.4", - "@modelcontextprotocol/inspector-server": "^0.16.4", + "@modelcontextprotocol/inspector-cli": "^0.16.5", + "@modelcontextprotocol/inspector-client": "^0.16.5", + "@modelcontextprotocol/inspector-server": "^0.16.5", "@modelcontextprotocol/sdk": "^1.17.3", "concurrently": "^9.2.0", "open": "^10.2.0", diff --git a/server/package.json b/server/package.json index 307a8c27..fb835f63 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-server", - "version": "0.16.4", + "version": "0.16.5", "description": "Server-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From f7b1cd6f43a8e5f256c05a11009fdd1e8961038f Mon Sep 17 00:00:00 2001 From: olaservo Date: Mon, 18 Aug 2025 08:43:09 -0700 Subject: [PATCH 24/46] Setup prettier pre-commit formatting with husky and lint-staged - Install husky and lint-staged dev dependencies - Configure pre-commit hook to run prettier on staged files - Only format changed files to improve performance - Prevents CI formatting failures by formatting locally --- .husky/pre-commit | 1 + package-lock.json | 612 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 9 +- 3 files changed, 614 insertions(+), 8 deletions(-) create mode 100644 .husky/pre-commit diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..2312dc58 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/package-lock.json b/package-lock.json index 3a99a46e..cf7137c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,9 @@ "@types/jest": "^29.5.14", "@types/node": "^22.17.0", "@types/shell-quote": "^1.7.5", + "husky": "^9.1.7", "jest-fixed-jsdom": "^0.0.9", + "lint-staged": "^16.1.5", "prettier": "^3.6.2", "rimraf": "^6.0.1", "typescript": "^5.4.2" @@ -4649,6 +4651,93 @@ "url": "https://polar.sh/cva" } }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -4724,6 +4813,13 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -4962,9 +5058,9 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5245,6 +5341,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -5593,6 +5702,13 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, + "license": "MIT" + }, "node_modules/eventsource": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", @@ -6049,6 +6165,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -6328,6 +6457,22 @@ "node": ">=10.17.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -7857,6 +8002,160 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/lint-staged": { + "version": "16.1.5", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.1.5.tgz", + "integrity": "sha512-uAeQQwByI6dfV7wpt/gVqg+jAPaSp8WwOA8kKC/dv1qw14oGpnpAisY65ibGHUGDUv0rYaZ8CAJZ/1U8hUvC2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.5.0", + "commander": "^14.0.0", + "debug": "^4.4.1", + "lilconfig": "^3.1.3", + "listr2": "^9.0.1", + "micromatch": "^4.0.8", + "nano-spawn": "^1.0.2", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.8.1" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/chalk": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", + "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/commander": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", + "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/listr2": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.1.tgz", + "integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -7893,6 +8192,160 @@ "dev": true, "license": "MIT" }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", + "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", + "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -8070,6 +8523,19 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -8118,6 +8584,19 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nano-spawn": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.2.tgz", + "integrity": "sha512-21t+ozMQDAL/UGgQVBbZ/xXvNO10++ZPuTmKRO8k9V3AClVRht49ahtDjfY8l1q6nSHOrE5ASfthzH3ol6R/hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -8496,6 +8975,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", @@ -9225,6 +9717,52 @@ "node": ">=10" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -9235,6 +9773,13 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rimraf": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", @@ -9711,6 +10256,49 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -9790,6 +10378,16 @@ "node": ">= 0.8" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -10979,15 +11577,15 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", - "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { - "node": ">= 14" + "node": ">= 14.6" } }, "node_modules/yargs": { diff --git a/package.json b/package.json index c2a78331..0e6bb653 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "prettier-fix": "prettier --write .", "prettier-check": "prettier --check .", "lint": "prettier --check . && cd client && npm run lint", - "prepare": "npm run build", + "prepare": "husky", "publish-all": "npm publish --workspaces --access public && npm publish --access public", "update-version": "node scripts/update-version.js", "check-version": "node scripts/check-version-consistency.js" @@ -63,12 +63,19 @@ "@types/jest": "^29.5.14", "@types/node": "^22.17.0", "@types/shell-quote": "^1.7.5", + "husky": "^9.1.7", "jest-fixed-jsdom": "^0.0.9", + "lint-staged": "^16.1.5", "prettier": "^3.6.2", "rimraf": "^6.0.1", "typescript": "^5.4.2" }, "engines": { "node": ">=22.7.5" + }, + "lint-staged": { + "**/*.{js,ts,jsx,tsx,json,md}": [ + "prettier --write" + ] } } From b33dabe8dc1666eb9e9a82999d8c4b1251f48ac3 Mon Sep 17 00:00:00 2001 From: olaservo Date: Mon, 18 Aug 2025 08:50:57 -0700 Subject: [PATCH 25/46] Fix: restore original 'prepare' script that was overwritten by husky init - Restore 'prepare': 'npm run build' for proper package installation - Husky pre-commit hooks remain functional via existing .husky directory --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d14e5d09..a5994d7c 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "prettier-fix": "prettier --write .", "prettier-check": "prettier --check .", "lint": "prettier --check . && cd client && npm run lint", - "prepare": "husky", + "prepare": "npm run build", "publish-all": "npm publish --workspaces --access public && npm publish --access public", "update-version": "node scripts/update-version.js", "check-version": "node scripts/check-version-consistency.js" From de1754633e05a535e12d430b713bce894505087d Mon Sep 17 00:00:00 2001 From: olaservo Date: Mon, 18 Aug 2025 08:52:33 -0700 Subject: [PATCH 26/46] Test pre-commit formatting hook with intentionally bad formatting --- test-formatting.js | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 test-formatting.js diff --git a/test-formatting.js b/test-formatting.js new file mode 100644 index 00000000..32f7e029 --- /dev/null +++ b/test-formatting.js @@ -0,0 +1,10 @@ +// Test file with bad formatting +const badly = { + formatted: "code", + missing: "semicolons", + extra: "spaces", +}; + +function test() { + console.log("test"); +} From 420f9a7b9a6db5354f935194c4fcd30232f7c1ff Mon Sep 17 00:00:00 2001 From: olaservo Date: Mon, 18 Aug 2025 08:53:49 -0700 Subject: [PATCH 27/46] Remove test formatting file --- test-formatting.js | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 test-formatting.js diff --git a/test-formatting.js b/test-formatting.js deleted file mode 100644 index 32f7e029..00000000 --- a/test-formatting.js +++ /dev/null @@ -1,10 +0,0 @@ -// Test file with bad formatting -const badly = { - formatted: "code", - missing: "semicolons", - extra: "spaces", -}; - -function test() { - console.log("test"); -} From 36a30fa3db08d5933a903fc2fee24ebd13b37993 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Mon, 18 Aug 2025 13:01:13 -0700 Subject: [PATCH 28/46] prettier --write client/src/components/OAuthDebugCallback.tsx --- client/src/components/OAuthDebugCallback.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx index 67ddc926..95ccc076 100644 --- a/client/src/components/OAuthDebugCallback.tsx +++ b/client/src/components/OAuthDebugCallback.tsx @@ -49,8 +49,13 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { if (restoredState && typeof restoredState.resource === "string") { restoredState.resource = new URL(restoredState.resource); } - if (restoredState && typeof restoredState.authorizationUrl === "string") { - restoredState.authorizationUrl = new URL(restoredState.authorizationUrl); + if ( + restoredState && + typeof restoredState.authorizationUrl === "string" + ) { + restoredState.authorizationUrl = new URL( + restoredState.authorizationUrl, + ); } // Clean up the stored state sessionStorage.removeItem(SESSION_KEYS.AUTH_DEBUGGER_STATE); From fa9b99b5df6a1c3126e7fff99080a5f988c2da4f Mon Sep 17 00:00:00 2001 From: Ola Hungerford Date: Tue, 19 Aug 2025 06:55:29 -0700 Subject: [PATCH 29/46] Update .husky/pre-commit Co-authored-by: Cliff Hall --- .husky/pre-commit | 1 + 1 file changed, 1 insertion(+) diff --git a/.husky/pre-commit b/.husky/pre-commit index 2312dc58..0a3afdcb 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,2 @@ npx lint-staged +git update-index --again From df6152ee351bb3a30c81e4fbb564f61717bb5e2f Mon Sep 17 00:00:00 2001 From: Ola Hungerford Date: Tue, 19 Aug 2025 06:57:09 -0700 Subject: [PATCH 30/46] Update npm prepare script to include husky --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a5994d7c..879c7b2c 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "prettier-fix": "prettier --write .", "prettier-check": "prettier --check .", "lint": "prettier --check . && cd client && npm run lint", - "prepare": "npm run build", + "prepare": "husky && npm run build", "publish-all": "npm publish --workspaces --access public && npm publish --access public", "update-version": "node scripts/update-version.js", "check-version": "node scripts/check-version-consistency.js" From 650f3090d26344a672026b737d81586595bb1f60 Mon Sep 17 00:00:00 2001 From: Jenn Newton Date: Fri, 15 Aug 2025 13:09:49 -0400 Subject: [PATCH 31/46] fix: add validation for OAuth redirect URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create shared validateRedirectUrl utility to centralize URL validation - Only allow HTTP and HTTPS protocols in OAuth authorization URLs - Fix three vulnerable redirect paths that bypassed validation: - AuthDebugger.tsx: Direct window.location.href redirect - OAuthFlowProgress.tsx: window.open() without validation - OAuthFlowProgress.tsx: Direct anchor href attribute - Add comprehensive test coverage for URL validation - Update existing auth.ts to use shared validation utility This prevents malicious MCP servers from injecting javascript: or data: protocol URLs that would execute XSS attacks when users are redirected to the authorization endpoint. πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- client/src/components/AuthDebugger.tsx | 17 +++ client/src/components/OAuthFlowProgress.tsx | 35 ++++- client/src/lib/auth.ts | 9 +- .../src/utils/__tests__/urlValidation.test.ts | 127 ++++++++++++++++++ client/src/utils/urlValidation.ts | 21 +++ 5 files changed, 197 insertions(+), 12 deletions(-) create mode 100644 client/src/utils/__tests__/urlValidation.test.ts create mode 100644 client/src/utils/urlValidation.ts diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index ec09963e..c8d59c42 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -6,6 +6,7 @@ import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "../lib/auth-types"; import { OAuthFlowProgress } from "./OAuthFlowProgress"; import { OAuthStateMachine } from "../lib/oauth-state-machine"; import { SESSION_KEYS } from "../lib/constants"; +import { validateRedirectUrl } from "@/utils/urlValidation"; export interface AuthDebuggerProps { serverUrl: string; @@ -163,6 +164,22 @@ const AuthDebugger = ({ currentState.oauthStep === "authorization_code" && currentState.authorizationUrl ) { + // Validate the URL before redirecting + try { + validateRedirectUrl(currentState.authorizationUrl); + } catch (error) { + updateAuthState({ + ...currentState, + isInitiatingAuth: false, + latestError: error instanceof Error ? error : new Error(String(error)), + statusMessage: { + type: "error", + message: `Invalid authorization URL: ${error instanceof Error ? error.message : String(error)}`, + }, + }); + return; + } + // Store the current auth state before redirecting sessionStorage.setItem( SESSION_KEYS.AUTH_DEBUGGER_STATE, diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index 0e260556..797d9f4d 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -4,6 +4,8 @@ import { Button } from "./ui/button"; import { DebugInspectorOAuthClientProvider } from "@/lib/auth"; import { useEffect, useMemo, useState } from "react"; import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { validateRedirectUrl } from "@/utils/urlValidation"; +import { useToast } from "@/lib/hooks/useToast"; interface OAuthStepProps { label: string; @@ -71,6 +73,7 @@ export const OAuthFlowProgress = ({ updateAuthState, proceedToNextStep, }: OAuthFlowProgressProps) => { + const { toast } = useToast(); const provider = useMemo( () => new DebugInspectorOAuthClientProvider(serverUrl), [serverUrl], @@ -239,16 +242,25 @@ export const OAuthFlowProgress = ({

{authState.authorizationUrl}

- { + try { + validateRedirectUrl(authState.authorizationUrl!); + window.open(authState.authorizationUrl!, "_blank"); + } catch (error) { + toast({ + title: "Invalid URL", + description: error instanceof Error ? error.message : "The authorization URL is not valid", + variant: "destructive", + }); + } + }} className="flex items-center text-blue-500 hover:text-blue-700" aria-label="Open authorization URL in new tab" title="Open authorization URL" > - +

Click the link to authorize in your browser. After @@ -354,7 +366,18 @@ export const OAuthFlowProgress = ({ authState.authorizationUrl && ( diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index 78d52a59..c1505398 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -11,6 +11,7 @@ import { import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js"; import { SESSION_KEYS, getServerSpecificKey } from "./constants"; import { generateOAuthState } from "@/utils/oauthUtils"; +import { validateRedirectUrl } from "@/utils/urlValidation"; /** * Discovers OAuth scopes from server metadata, with preference for resource metadata scopes @@ -182,12 +183,8 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { } redirectToAuthorization(authorizationUrl: URL) { - if ( - authorizationUrl.protocol !== "http:" && - authorizationUrl.protocol !== "https:" - ) { - throw new Error("Authorization URL must be HTTP or HTTPS"); - } + // Validate the URL using the shared utility + validateRedirectUrl(authorizationUrl.href); window.location.href = authorizationUrl.href; } diff --git a/client/src/utils/__tests__/urlValidation.test.ts b/client/src/utils/__tests__/urlValidation.test.ts new file mode 100644 index 00000000..279197cb --- /dev/null +++ b/client/src/utils/__tests__/urlValidation.test.ts @@ -0,0 +1,127 @@ +import { validateRedirectUrl } from "../urlValidation"; + +describe("validateRedirectUrl", () => { + describe("valid URLs", () => { + it("should allow HTTP URLs", () => { + expect(() => validateRedirectUrl("http://example.com")).not.toThrow(); + }); + + it("should allow HTTPS URLs", () => { + expect(() => validateRedirectUrl("https://example.com")).not.toThrow(); + }); + + it("should allow URLs with ports", () => { + expect(() => validateRedirectUrl("https://example.com:8080")).not.toThrow(); + }); + + it("should allow URLs with paths", () => { + expect(() => validateRedirectUrl("https://example.com/path/to/auth")).not.toThrow(); + }); + + it("should allow URLs with query parameters", () => { + expect(() => validateRedirectUrl("https://example.com?param=value")).not.toThrow(); + }); + }); + + describe("invalid URLs - XSS vectors", () => { + it("should block javascript: protocol", () => { + expect(() => validateRedirectUrl("javascript:alert('XSS')")).toThrow( + "Authorization URL must be HTTP or HTTPS" + ); + }); + + it("should block javascript: with encoded characters", () => { + expect(() => validateRedirectUrl("javascript:alert%28%27XSS%27%29")).toThrow( + "Authorization URL must be HTTP or HTTPS" + ); + }); + + it("should block data: protocol", () => { + expect(() => validateRedirectUrl("data:text/html,")).toThrow( + "Authorization URL must be HTTP or HTTPS" + ); + }); + + it("should block vbscript: protocol", () => { + expect(() => validateRedirectUrl("vbscript:msgbox")).toThrow( + "Authorization URL must be HTTP or HTTPS" + ); + }); + + it("should block file: protocol", () => { + expect(() => validateRedirectUrl("file:///etc/passwd")).toThrow( + "Authorization URL must be HTTP or HTTPS" + ); + }); + + it("should block about: protocol", () => { + expect(() => validateRedirectUrl("about:blank")).toThrow( + "Authorization URL must be HTTP or HTTPS" + ); + }); + + it("should block custom protocols", () => { + expect(() => validateRedirectUrl("custom://example")).toThrow( + "Authorization URL must be HTTP or HTTPS" + ); + }); + }); + + describe("edge cases", () => { + it("should handle malformed URLs", () => { + expect(() => validateRedirectUrl("not a url")).toThrow( + "Invalid URL: not a url" + ); + }); + + it("should handle empty string", () => { + expect(() => validateRedirectUrl("")).toThrow( + "Invalid URL: " + ); + }); + + it("should handle URLs with unicode characters", () => { + expect(() => validateRedirectUrl("https://δΎ‹γˆ.jp")).not.toThrow(); + }); + + it("should handle URLs with case variations", () => { + expect(() => validateRedirectUrl("HTTPS://EXAMPLE.COM")).not.toThrow(); + expect(() => validateRedirectUrl("HtTpS://example.com")).not.toThrow(); + }); + + it("should handle protocol-relative URLs as invalid", () => { + expect(() => validateRedirectUrl("//example.com")).toThrow( + "Invalid URL: //example.com" + ); + }); + + it("should handle URLs with authentication", () => { + expect(() => validateRedirectUrl("https://user:pass@example.com")).not.toThrow(); + }); + }); + + describe("security considerations", () => { + it("should not be fooled by whitespace", () => { + expect(() => validateRedirectUrl(" javascript:alert('XSS')")).toThrow(); + expect(() => validateRedirectUrl("javascript:alert('XSS') ")).toThrow(); + }); + + it("should handle null bytes", () => { + expect(() => validateRedirectUrl("java\x00script:alert('XSS')")).toThrow(); + }); + + it("should handle tab characters", () => { + expect(() => validateRedirectUrl("java\tscript:alert('XSS')")).toThrow(); + }); + + it("should handle newlines", () => { + expect(() => validateRedirectUrl("java\nscript:alert('XSS')")).toThrow(); + }); + + it("should handle mixed case protocols", () => { + expect(() => validateRedirectUrl("JaVaScRiPt:alert('XSS')")).toThrow( + "Authorization URL must be HTTP or HTTPS" + ); + }); + }); +}); \ No newline at end of file diff --git a/client/src/utils/urlValidation.ts b/client/src/utils/urlValidation.ts new file mode 100644 index 00000000..fb0ef76b --- /dev/null +++ b/client/src/utils/urlValidation.ts @@ -0,0 +1,21 @@ +/** + * Validates that a URL is safe for redirection. + * Only allows HTTP and HTTPS protocols to prevent XSS attacks. + * + * @param url - The URL string to validate + * @throws Error if the URL has an unsafe protocol + */ +export function validateRedirectUrl(url: string): void { + try { + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + throw new Error("Authorization URL must be HTTP or HTTPS"); + } + } catch (error) { + if (error instanceof Error && error.message === "Authorization URL must be HTTP or HTTPS") { + throw error; + } + // If URL parsing fails, it's also invalid + throw new Error(`Invalid URL: ${url}`); + } +} \ No newline at end of file From fa2e6b0c4089bafefaa4f6434f0f71e2abc9c92c Mon Sep 17 00:00:00 2001 From: Jenn Newton Date: Thu, 21 Aug 2025 15:31:53 +0000 Subject: [PATCH 32/46] Fix to properly open in new tab for OAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- client/src/components/OAuthFlowProgress.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index 797d9f4d..e50b7df4 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -246,11 +246,18 @@ export const OAuthFlowProgress = ({ onClick={() => { try { validateRedirectUrl(authState.authorizationUrl!); - window.open(authState.authorizationUrl!, "_blank"); + window.open( + authState.authorizationUrl!, + "_blank", + "noopener noreferrer", + ); } catch (error) { toast({ title: "Invalid URL", - description: error instanceof Error ? error.message : "The authorization URL is not valid", + description: + error instanceof Error + ? error.message + : "The authorization URL is not valid", variant: "destructive", }); } @@ -373,7 +380,10 @@ export const OAuthFlowProgress = ({ } catch (error) { toast({ title: "Invalid URL", - description: error instanceof Error ? error.message : "The authorization URL is not valid", + description: + error instanceof Error + ? error.message + : "The authorization URL is not valid", variant: "destructive", }); } From a018a6d7587b04f02d45dd473fa03b7fdf982d97 Mon Sep 17 00:00:00 2001 From: Jenn Newton Date: Thu, 21 Aug 2025 15:41:33 +0000 Subject: [PATCH 33/46] Apply Prettier formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- client/src/components/AuthDebugger.tsx | 5 +- .../src/utils/__tests__/urlValidation.test.ts | 54 +++++++++++-------- client/src/utils/urlValidation.ts | 9 ++-- 3 files changed, 40 insertions(+), 28 deletions(-) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index c8d59c42..e7acbd80 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -171,7 +171,8 @@ const AuthDebugger = ({ updateAuthState({ ...currentState, isInitiatingAuth: false, - latestError: error instanceof Error ? error : new Error(String(error)), + latestError: + error instanceof Error ? error : new Error(String(error)), statusMessage: { type: "error", message: `Invalid authorization URL: ${error instanceof Error ? error.message : String(error)}`, @@ -179,7 +180,7 @@ const AuthDebugger = ({ }); return; } - + // Store the current auth state before redirecting sessionStorage.setItem( SESSION_KEYS.AUTH_DEBUGGER_STATE, diff --git a/client/src/utils/__tests__/urlValidation.test.ts b/client/src/utils/__tests__/urlValidation.test.ts index 279197cb..9ee6a454 100644 --- a/client/src/utils/__tests__/urlValidation.test.ts +++ b/client/src/utils/__tests__/urlValidation.test.ts @@ -11,58 +11,64 @@ describe("validateRedirectUrl", () => { }); it("should allow URLs with ports", () => { - expect(() => validateRedirectUrl("https://example.com:8080")).not.toThrow(); + expect(() => + validateRedirectUrl("https://example.com:8080"), + ).not.toThrow(); }); it("should allow URLs with paths", () => { - expect(() => validateRedirectUrl("https://example.com/path/to/auth")).not.toThrow(); + expect(() => + validateRedirectUrl("https://example.com/path/to/auth"), + ).not.toThrow(); }); it("should allow URLs with query parameters", () => { - expect(() => validateRedirectUrl("https://example.com?param=value")).not.toThrow(); + expect(() => + validateRedirectUrl("https://example.com?param=value"), + ).not.toThrow(); }); }); describe("invalid URLs - XSS vectors", () => { it("should block javascript: protocol", () => { expect(() => validateRedirectUrl("javascript:alert('XSS')")).toThrow( - "Authorization URL must be HTTP or HTTPS" + "Authorization URL must be HTTP or HTTPS", ); }); it("should block javascript: with encoded characters", () => { - expect(() => validateRedirectUrl("javascript:alert%28%27XSS%27%29")).toThrow( - "Authorization URL must be HTTP or HTTPS" - ); + expect(() => + validateRedirectUrl("javascript:alert%28%27XSS%27%29"), + ).toThrow("Authorization URL must be HTTP or HTTPS"); }); it("should block data: protocol", () => { - expect(() => validateRedirectUrl("data:text/html,")).toThrow( - "Authorization URL must be HTTP or HTTPS" - ); + expect(() => + validateRedirectUrl("data:text/html,"), + ).toThrow("Authorization URL must be HTTP or HTTPS"); }); it("should block vbscript: protocol", () => { expect(() => validateRedirectUrl("vbscript:msgbox")).toThrow( - "Authorization URL must be HTTP or HTTPS" + "Authorization URL must be HTTP or HTTPS", ); }); it("should block file: protocol", () => { expect(() => validateRedirectUrl("file:///etc/passwd")).toThrow( - "Authorization URL must be HTTP or HTTPS" + "Authorization URL must be HTTP or HTTPS", ); }); it("should block about: protocol", () => { expect(() => validateRedirectUrl("about:blank")).toThrow( - "Authorization URL must be HTTP or HTTPS" + "Authorization URL must be HTTP or HTTPS", ); }); it("should block custom protocols", () => { expect(() => validateRedirectUrl("custom://example")).toThrow( - "Authorization URL must be HTTP or HTTPS" + "Authorization URL must be HTTP or HTTPS", ); }); }); @@ -70,14 +76,12 @@ describe("validateRedirectUrl", () => { describe("edge cases", () => { it("should handle malformed URLs", () => { expect(() => validateRedirectUrl("not a url")).toThrow( - "Invalid URL: not a url" + "Invalid URL: not a url", ); }); it("should handle empty string", () => { - expect(() => validateRedirectUrl("")).toThrow( - "Invalid URL: " - ); + expect(() => validateRedirectUrl("")).toThrow("Invalid URL: "); }); it("should handle URLs with unicode characters", () => { @@ -91,12 +95,14 @@ describe("validateRedirectUrl", () => { it("should handle protocol-relative URLs as invalid", () => { expect(() => validateRedirectUrl("//example.com")).toThrow( - "Invalid URL: //example.com" + "Invalid URL: //example.com", ); }); it("should handle URLs with authentication", () => { - expect(() => validateRedirectUrl("https://user:pass@example.com")).not.toThrow(); + expect(() => + validateRedirectUrl("https://user:pass@example.com"), + ).not.toThrow(); }); }); @@ -107,7 +113,9 @@ describe("validateRedirectUrl", () => { }); it("should handle null bytes", () => { - expect(() => validateRedirectUrl("java\x00script:alert('XSS')")).toThrow(); + expect(() => + validateRedirectUrl("java\x00script:alert('XSS')"), + ).toThrow(); }); it("should handle tab characters", () => { @@ -120,8 +128,8 @@ describe("validateRedirectUrl", () => { it("should handle mixed case protocols", () => { expect(() => validateRedirectUrl("JaVaScRiPt:alert('XSS')")).toThrow( - "Authorization URL must be HTTP or HTTPS" + "Authorization URL must be HTTP or HTTPS", ); }); }); -}); \ No newline at end of file +}); diff --git a/client/src/utils/urlValidation.ts b/client/src/utils/urlValidation.ts index fb0ef76b..70bc458b 100644 --- a/client/src/utils/urlValidation.ts +++ b/client/src/utils/urlValidation.ts @@ -1,7 +1,7 @@ /** * Validates that a URL is safe for redirection. * Only allows HTTP and HTTPS protocols to prevent XSS attacks. - * + * * @param url - The URL string to validate * @throws Error if the URL has an unsafe protocol */ @@ -12,10 +12,13 @@ export function validateRedirectUrl(url: string): void { throw new Error("Authorization URL must be HTTP or HTTPS"); } } catch (error) { - if (error instanceof Error && error.message === "Authorization URL must be HTTP or HTTPS") { + if ( + error instanceof Error && + error.message === "Authorization URL must be HTTP or HTTPS" + ) { throw error; } // If URL parsing fails, it's also invalid throw new Error(`Invalid URL: ${url}`); } -} \ No newline at end of file +} From 9dbd5cc85bd1d7f309392ab626452a988da6fcc3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 21 Aug 2025 18:01:29 -0400 Subject: [PATCH 34/46] * Remove handling of out-of-spec 'notification/stderr' messages. It's not a thing. See https://github.com/modelcontextprotocol/servers/pull/2469 * Inspect the stderr output of STDIO servers and attempt to assign an appropriate RFC 5424 Syslog Protocol level before sending a leveled logging message to the client --- client/src/App.tsx | 16 --------- client/src/components/Sidebar.tsx | 35 ------------------ client/src/lib/hooks/useConnection.ts | 10 +----- client/src/lib/notificationTypes.ts | 14 ++------ server/src/index.ts | 51 ++++++++++++++++++++++++--- 5 files changed, 49 insertions(+), 77 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index d6680c35..fecd9839 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -34,7 +34,6 @@ import { useDraggablePane, useDraggableSidebar, } from "./lib/hooks/useDraggablePane"; -import { StdErrNotification } from "./lib/notificationTypes"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; @@ -106,9 +105,6 @@ const App = () => { >(getInitialTransportType); const [logLevel, setLogLevel] = useState("debug"); const [notifications, setNotifications] = useState([]); - const [stdErrNotifications, setStdErrNotifications] = useState< - StdErrNotification[] - >([]); const [roots, setRoots] = useState([]); const [env, setEnv] = useState>({}); @@ -224,12 +220,6 @@ const App = () => { onNotification: (notification) => { setNotifications((prev) => [...prev, notification as ServerNotification]); }, - onStdErrNotification: (notification) => { - setStdErrNotifications((prev) => [ - ...prev, - notification as StdErrNotification, - ]); - }, onPendingRequest: (request, resolve, reject) => { setPendingSampleRequests((prev) => [ ...prev, @@ -757,10 +747,6 @@ const App = () => { setLogLevel(level); }; - const clearStdErrNotifications = () => { - setStdErrNotifications([]); - }; - const AuthDebuggerWrapper = () => ( { setOauthScope={setOauthScope} onConnect={connectMcpServer} onDisconnect={disconnectMcpServer} - stdErrNotifications={stdErrNotifications} logLevel={logLevel} sendLogLevelRequest={sendLogLevelRequest} loggingSupported={!!serverCapabilities?.logging || false} - clearStdErrNotifications={clearStdErrNotifications} />

void; onConnect: () => void; onDisconnect: () => void; - stdErrNotifications: StdErrNotification[]; - clearStdErrNotifications: () => void; logLevel: LoggingLevel; sendLogLevelRequest: (level: LoggingLevel) => void; loggingSupported: boolean; @@ -93,8 +90,6 @@ const Sidebar = ({ setOauthScope, onConnect, onDisconnect, - stdErrNotifications, - clearStdErrNotifications, logLevel, sendLogLevelRequest, loggingSupported, @@ -760,36 +755,6 @@ const Sidebar = ({
)} - - {stdErrNotifications.length > 0 && ( - <> -
-
-

- Error output from MCP server -

- -
-
- {stdErrNotifications.map((notification, index) => ( -
- {notification.params.content} -
- ))} -
-
- - )} diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index d3690f31..8c44d51b 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -36,7 +36,7 @@ import { useEffect, useState } from "react"; import { useToast } from "@/lib/hooks/useToast"; import { z } from "zod"; import { ConnectionStatus } from "../constants"; -import { Notification, StdErrNotificationSchema } from "../notificationTypes"; +import { Notification } from "../notificationTypes"; import { auth, discoverOAuthProtectedResourceMetadata, @@ -92,7 +92,6 @@ export function useConnection({ oauthScope, config, onNotification, - onStdErrNotification, onPendingRequest, onElicitationRequest, getRoots, @@ -505,13 +504,6 @@ export function useConnection({ }; } - if (onStdErrNotification) { - client.setNotificationHandler( - StdErrNotificationSchema, - onStdErrNotification, - ); - } - let capabilities; try { const transport = diff --git a/client/src/lib/notificationTypes.ts b/client/src/lib/notificationTypes.ts index 8627ccc6..a956452a 100644 --- a/client/src/lib/notificationTypes.ts +++ b/client/src/lib/notificationTypes.ts @@ -5,18 +5,8 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; -export const StdErrNotificationSchema = BaseNotificationSchema.extend({ - method: z.literal("notifications/stderr"), - params: z.object({ - content: z.string(), - }), -}); - export const NotificationSchema = ClientNotificationSchema.or( - StdErrNotificationSchema, -) - .or(ServerNotificationSchema) - .or(BaseNotificationSchema); + ServerNotificationSchema, +).or(BaseNotificationSchema); -export type StdErrNotification = z.infer; export type Notification = z.infer; diff --git a/server/src/index.ts b/server/src/index.ts index 0a0f7bcc..65746017 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -401,24 +401,65 @@ app.get( (serverTransport as StdioClientTransport).stderr!.on("data", (chunk) => { if (chunk.toString().includes("MODULE_NOT_FOUND")) { + // Server command not found, remove transports + const message = "Command not found, transports removed"; webAppTransport.send({ jsonrpc: "2.0", - method: "notifications/stderr", + method: "notifications/message", params: { - content: "Command not found, transports removed", + level: "alert", + data: { + error: message, + }, }, }); webAppTransport.close(); serverTransport.close(); webAppTransports.delete(webAppTransport.sessionId); serverTransports.delete(webAppTransport.sessionId); - console.error("Command not found, transports removed"); + console.error(message); } else { + // Inspect message and attempt to assign a RFC 5424 Syslog Protocol level + let level; + let message = chunk.toString(); + let ucMsg = chunk.toString().toUpperCase(); + if (ucMsg.includes("DEBUG")) { + level = "debug"; + } else if (ucMsg.includes("INFO")) { + level = "info"; + } else if (ucMsg.includes("NOTICE")) { + level = "notice"; + } else if (ucMsg.includes("WARN")) { + level = "warning"; + } else if (ucMsg.includes("ERROR")) { + level = "error"; + } else if (ucMsg.includes("CRITICAL")) { + level = "critical"; + } else if (ucMsg.includes("ALERT")) { + level = "alert"; + } else if (ucMsg.includes("EMERGENCY")) { + level = "emergency"; + } else if (ucMsg.includes("SIGINT")) { + level = "alert"; + message = "SIGINT received. Server shutdown."; + } else if (ucMsg.includes("SIGHUP")) { + level = "alert"; + message = "SIGHUP received. Server shutdown."; + } else if (ucMsg.includes("SIGTERM")) { + level = "alert"; + message = "SIGTERM received. Server shutdown."; + } else { + level = "info"; + } webAppTransport.send({ jsonrpc: "2.0", - method: "notifications/stderr", + method: "notifications/message", params: { - content: chunk.toString(), + level, + logger: "stdio", + data: { + error: message, + }, }, }); } From ec99372d925e24c46f8470e96b02c1116bef6242 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 21 Aug 2025 18:04:07 -0400 Subject: [PATCH 35/46] * Remove handling of out-of-spec 'notification/stderr' messages. It's not a thing. See https://github.com/modelcontextprotocol/servers/pull/2469 * Inspect the stderr output of STDIO servers and attempt to assign an appropriate RFC 5424 Syslog Protocol level before sending a leveled logging message to the client --- server/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/index.ts b/server/src/index.ts index 65746017..284424e5 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -458,7 +458,7 @@ app.get( level, logger: "stdio", data: { - error: message, + message, }, }, }); From f472327ea0174cf198d365c3e722617bf24290f9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 21 Aug 2025 18:12:38 -0400 Subject: [PATCH 36/46] Trim message before sending to client --- server/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/index.ts b/server/src/index.ts index 284424e5..fc87aa0f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -421,7 +421,7 @@ app.get( } else { // Inspect message and attempt to assign a RFC 5424 Syslog Protocol level let level; - let message = chunk.toString(); + let message = chunk.toString().trim(); let ucMsg = chunk.toString().toUpperCase(); if (ucMsg.includes("DEBUG")) { level = "debug"; From c54215252e949b5b2ec3cc878c06b8b49de21708 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 21 Aug 2025 18:40:19 -0400 Subject: [PATCH 37/46] Trim message before sending to client --- server/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/index.ts b/server/src/index.ts index fc87aa0f..dc3351d7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -409,7 +409,7 @@ app.get( params: { level: "alert", data: { - error: message, + message, }, }, }); From fb0b56505aa985146dcee6c8c8753fd9b40512d7 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 21 Aug 2025 18:55:58 -0400 Subject: [PATCH 38/46] Trim message before sending to client --- server/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/index.ts b/server/src/index.ts index dc3351d7..11e36060 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -408,6 +408,7 @@ app.get( method: "notifications/message", params: { level: "alert", + logger: "proxy", data: { message, }, From 4cf7aeb9edf7320bf98f3777abdb0ace36c2c12d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 21 Aug 2025 19:08:51 -0400 Subject: [PATCH 39/46] proper levels for server shutdown messages --- server/src/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 11e36060..1f517d57 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -407,7 +407,7 @@ app.get( jsonrpc: "2.0", method: "notifications/message", params: { - level: "alert", + level: "emergency", logger: "proxy", data: { message, @@ -441,14 +441,14 @@ app.get( } else if (ucMsg.includes("EMERGENCY")) { level = "emergency"; } else if (ucMsg.includes("SIGINT")) { - level = "alert"; message = "SIGINT received. Server shutdown."; + level = "emergency"; } else if (ucMsg.includes("SIGHUP")) { - level = "alert"; message = "SIGHUP received. Server shutdown."; + level = "emergency"; } else if (ucMsg.includes("SIGTERM")) { - level = "alert"; message = "SIGTERM received. Server shutdown."; + level = "emergency"; } else { level = "info"; } From 3628abca9ee626f0469ebc609e80dd30bfeede21 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Sun, 10 Aug 2025 13:06:19 -0700 Subject: [PATCH 40/46] Use MCP_PROXY_FULL_ADDRESS for SSE transport backed endpoints The STDIO and SSE transports use SSE between the client and the proxy server. SSE requires the client to initiate the MCP conversation (event-stream) at the endpoint provided by the server. The endpoint provided by the server must obey where the client sees the server, rather than being assumed to be server-root `/message`. The Streamable HTTP endpoint does not need the full proxy address because the response is an event-stream directly, and does not use SSE. --- client/src/lib/hooks/useConnection.ts | 24 ++++++++++++++++++++++-- server/src/index.ts | 12 ++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 8c44d51b..a07b67dc 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -409,11 +409,20 @@ export function useConnection({ let mcpProxyServerUrl; switch (transportType) { - case "stdio": + case "stdio": { mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`); mcpProxyServerUrl.searchParams.append("command", command); mcpProxyServerUrl.searchParams.append("args", args); mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env)); + + const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS + .value as string; + if (proxyFullAddress) { + mcpProxyServerUrl.searchParams.append( + "proxyFullAddress", + proxyFullAddress, + ); + } transportOptions = { authProvider: serverAuthProvider, eventSourceInit: { @@ -431,10 +440,20 @@ export function useConnection({ }, }; break; + } - case "sse": + case "sse": { mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`); mcpProxyServerUrl.searchParams.append("url", sseUrl); + + const proxyFullAddressSSE = config.MCP_PROXY_FULL_ADDRESS + .value as string; + if (proxyFullAddressSSE) { + mcpProxyServerUrl.searchParams.append( + "proxyFullAddress", + proxyFullAddressSSE, + ); + } transportOptions = { eventSourceInit: { fetch: ( @@ -451,6 +470,7 @@ export function useConnection({ }, }; break; + } case "streamable-http": mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`); diff --git a/server/src/index.ts b/server/src/index.ts index a30c1845..c55a79d3 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -391,7 +391,11 @@ app.get( throw error; } - const webAppTransport = new SSEServerTransport("/message", res); + const proxyFullAddress = (req.query.proxyFullAddress as string) || ""; + const prefix = proxyFullAddress || ""; + const endpoint = `${prefix}/message`; + + const webAppTransport = new SSEServerTransport(endpoint, res); console.log("Created client transport"); webAppTransports.set(webAppTransport.sessionId, webAppTransport); @@ -511,7 +515,11 @@ app.get( } if (serverTransport) { - const webAppTransport = new SSEServerTransport("/message", res); + const proxyFullAddress = (req.query.proxyFullAddress as string) || ""; + const prefix = proxyFullAddress || ""; + const endpoint = `${prefix}/message`; + + const webAppTransport = new SSEServerTransport(endpoint, res); webAppTransports.set(webAppTransport.sessionId, webAppTransport); console.log("Created client transport"); serverTransports.set(webAppTransport.sessionId, serverTransport!); From 89740ef5d111eefcc47924c5e17708cd126f5402 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Fri, 22 Aug 2025 00:47:22 -0700 Subject: [PATCH 41/46] Add tests for MCP_PROXY_FULL_ADDRESS SSE endpoint support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests verify that proxyFullAddress query parameter is sent for both STDIO and SSE transports, but not Streamable HTTP, when MCP_PROXY_FULL_ADDRESS is configured. πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../hooks/__tests__/useConnection.test.tsx | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index 7518f814..624583dc 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -877,4 +877,129 @@ describe("useConnection", () => { }); }); }); + + describe("MCP_PROXY_FULL_ADDRESS Configuration", () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset the mock transport objects + mockSSETransport.url = undefined; + mockSSETransport.options = undefined; + mockStreamableHTTPTransport.url = undefined; + mockStreamableHTTPTransport.options = undefined; + }); + + test("sends proxyFullAddress query parameter for stdio transport when configured", async () => { + const propsWithProxyFullAddress = { + ...defaultProps, + transportType: "stdio" as const, + command: "test-command", + args: "test-args", + env: {}, + config: { + ...DEFAULT_INSPECTOR_CONFIG, + MCP_PROXY_FULL_ADDRESS: { + ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS, + value: "https://example.com/inspector/mcp_proxy", + }, + }, + }; + + const { result } = renderHook(() => + useConnection(propsWithProxyFullAddress), + ); + + await act(async () => { + await result.current.connect(); + }); + + // Check that the URL contains the proxyFullAddress parameter + expect(mockSSETransport.url?.searchParams.get("proxyFullAddress")).toBe( + "https://example.com/inspector/mcp_proxy", + ); + }); + + test("sends proxyFullAddress query parameter for sse transport when configured", async () => { + const propsWithProxyFullAddress = { + ...defaultProps, + transportType: "sse" as const, + sseUrl: "http://localhost:8080", + config: { + ...DEFAULT_INSPECTOR_CONFIG, + MCP_PROXY_FULL_ADDRESS: { + ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS, + value: "https://example.com/inspector/mcp_proxy", + }, + }, + }; + + const { result } = renderHook(() => + useConnection(propsWithProxyFullAddress), + ); + + await act(async () => { + await result.current.connect(); + }); + + // Check that the URL contains the proxyFullAddress parameter + expect(mockSSETransport.url?.searchParams.get("proxyFullAddress")).toBe( + "https://example.com/inspector/mcp_proxy", + ); + }); + + test("does not send proxyFullAddress parameter when MCP_PROXY_FULL_ADDRESS is empty", async () => { + const propsWithEmptyProxy = { + ...defaultProps, + transportType: "stdio" as const, + command: "test-command", + args: "test-args", + env: {}, + config: { + ...DEFAULT_INSPECTOR_CONFIG, + MCP_PROXY_FULL_ADDRESS: { + ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS, + value: "", + }, + }, + }; + + const { result } = renderHook(() => useConnection(propsWithEmptyProxy)); + + await act(async () => { + await result.current.connect(); + }); + + // Check that the URL does not contain the proxyFullAddress parameter + expect( + mockSSETransport.url?.searchParams.get("proxyFullAddress"), + ).toBeNull(); + }); + + test("does not send proxyFullAddress parameter for streamable-http transport", async () => { + const propsWithStreamableHttp = { + ...defaultProps, + transportType: "streamable-http" as const, + sseUrl: "http://localhost:8080", + config: { + ...DEFAULT_INSPECTOR_CONFIG, + MCP_PROXY_FULL_ADDRESS: { + ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS, + value: "https://example.com/inspector/mcp_proxy", + }, + }, + }; + + const { result } = renderHook(() => + useConnection(propsWithStreamableHttp), + ); + + await act(async () => { + await result.current.connect(); + }); + + // Check that streamable-http transport doesn't get proxyFullAddress parameter + expect( + mockStreamableHTTPTransport.url?.searchParams.get("proxyFullAddress"), + ).toBeNull(); + }); + }); }); From 61db7918af20ab2aca6884365fb1b2a03f601f3f Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Tue, 12 Aug 2025 10:15:02 -0700 Subject: [PATCH 42/46] Align similar `/sse` and `/stdio` handlers: formatting and move log statement --- server/src/index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index c55a79d3..c0fb3797 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -378,7 +378,6 @@ app.get( let serverTransport: Transport | undefined; try { serverTransport = await createTransport(req); - console.log("Created server transport"); } catch (error) { if (error instanceof SseError && error.code === 401) { console.error( @@ -396,10 +395,11 @@ app.get( const endpoint = `${prefix}/message`; const webAppTransport = new SSEServerTransport(endpoint, res); + webAppTransports.set(webAppTransport.sessionId, webAppTransport); console.log("Created client transport"); - webAppTransports.set(webAppTransport.sessionId, webAppTransport); serverTransports.set(webAppTransport.sessionId, serverTransport); + console.log("Created server transport"); await webAppTransport.start(); @@ -488,7 +488,7 @@ app.get( async (req, res) => { try { console.log( - "New SSE connection request. NOTE: The sse transport is deprecated and has been replaced by StreamableHttp", + "New SSE connection request. NOTE: The SSE transport is deprecated and has been replaced by StreamableHttp", ); let serverTransport: Transport | undefined; try { @@ -522,6 +522,7 @@ app.get( const webAppTransport = new SSEServerTransport(endpoint, res); webAppTransports.set(webAppTransport.sessionId, webAppTransport); console.log("Created client transport"); + serverTransports.set(webAppTransport.sessionId, serverTransport!); console.log("Created server transport"); From c2e117a4a91e4b3d0d03818298661d905c1557a1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 25 Aug 2025 10:13:32 -0400 Subject: [PATCH 43/46] Add Docker container startup command to README.md --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 1f6cb822..777f6eeb 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,14 @@ npx @modelcontextprotocol/inspector The server will start up and the UI will be accessible at `http://localhost:6274`. +### Docker Container + +You can also start it in a Docker container with the following command: + +```bash +docker run --rm --network host -p 6274:6274 -p 6277:6277 ghcr.io/modelcontextprotocol/inspector:latest +``` + ### 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`: From 6853dcf42d4abfa8785fb1c7ee550bf626ee7db6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 25 Aug 2025 11:31:54 -0400 Subject: [PATCH 44/46] In Dockerfile - use current-alpine3.22 AS builder - The node:24-slim has a high vulnerability CVE as yet unfixed which allows running commands on the host as root --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index f36fb8bb..c87e4665 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM node:24-slim AS builder +FROM current-alpine3.22 AS builder # Set working directory WORKDIR /app @@ -49,4 +49,4 @@ ENV SERVER_PORT=6277 EXPOSE ${CLIENT_PORT} ${SERVER_PORT} # Use ENTRYPOINT with CMD for arguments -ENTRYPOINT ["npm", "start"] \ No newline at end of file +ENTRYPOINT ["npm", "start"] From 98becdf5e081ee631af172b3c8079105128557e3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 25 Aug 2025 11:41:44 -0400 Subject: [PATCH 45/46] In Dockerfile - use current-alpine3.22 AS builder - The node:24-slim has a high vulnerability CVE as yet unfixed which allows running commands on the host as root --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index c87e4665..d66091d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM current-alpine3.22 AS builder +FROM node:current-alpine3.22 AS builder # Set working directory WORKDIR /app From ddf74d0766b951dbb9af970da62281b51ea8c7ed Mon Sep 17 00:00:00 2001 From: olaservo Date: Tue, 26 Aug 2025 10:00:03 -0700 Subject: [PATCH 46/46] Fix formatting --- .github/workflows/claude.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index da8e7c7a..6e916eba 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -44,10 +44,10 @@ jobs: # Trigger when assigned to an issue assignee_trigger: "claude" - + # Allow Claude to run bash # This should be safe given the repo is already public allowed_tools: "Bash" - + custom_instructions: | If posting a comment to GitHub, give a concise summary of the comment at the top and put all the details in a
block.