diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..0a3afdcb --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +npx lint-staged +git update-index --again diff --git a/README.md b/README.md index ed1e5093..1f6cb822 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 diff --git a/cli/package.json b/cli/package.json index 6bb55253..2f0f53b6 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.16.3", + "version": "0.16.5", "description": "CLI for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -21,7 +21,7 @@ }, "devDependencies": {}, "dependencies": { - "@modelcontextprotocol/sdk": "^1.17.0", + "@modelcontextprotocol/sdk": "^1.17.3", "commander": "^13.1.0", "spawn-rx": "^5.1.2" } diff --git a/cli/src/transport.ts b/cli/src/transport.ts index b6276356..812e9e30 100644 --- a/cli/src/transport.ts +++ b/cli/src/transport.ts @@ -32,8 +32,8 @@ function createStdioTransport(options: TransportOptions): Transport { const defaultEnv = getDefaultEnvironment(); const env: Record = { - ...processEnv, ...defaultEnv, + ...processEnv, }; const { cmd: actualCommand, args: actualArgs } = findActualExecutable( diff --git a/client/package.json b/client/package.json index ed557bdb..d4b233cc 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.16.3", + "version": "0.16.5", "description": "Client-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -25,7 +25,7 @@ "cleanup:e2e": "node e2e/global-teardown.js" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.17.0", + "@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/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx index 99774297..95ccc076 100644 --- a/client/src/components/OAuthDebugCallback.tsx +++ b/client/src/components/OAuthDebugCallback.tsx @@ -46,6 +46,17 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { if (storedState) { try { restoredState = JSON.parse(storedState); + 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) { diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 3d8e2290..b3da23fd 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,57 @@ 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().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(), + 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 +127,9 @@ const mockDiscoverOAuthProtectedResourceMetadata = discoverOAuthProtectedResourceMetadata as jest.MockedFunction< typeof discoverOAuthProtectedResourceMetadata >; +const mockDiscoverScopes = discoverScopes as jest.MockedFunction< + typeof discoverScopes +>; const sessionStorageMock = { getItem: jest.fn(), @@ -103,9 +158,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 +488,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); @@ -442,6 +520,103 @@ describe("AuthDebugger", () => { }); }); + describe("Client Registration behavior", () => { + it("uses preregistered (static) client information without calling DCR", async () => { + const preregClientInfo = { + client_id: "static_client_id", + client_secret: "static_client_secret", + redirect_uris: ["http://localhost:3000/oauth/callback/debug"], + }; + + // Return preregistered client info for the server-specific key + sessionStorageMock.getItem.mockImplementation((key) => { + if ( + key === + `[${defaultProps.serverUrl}] ${SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION}` + ) { + return JSON.stringify(preregClientInfo); + } + return null; + }); + + const updateAuthState = jest.fn(); + + await act(async () => { + renderAuthDebugger({ + updateAuthState, + authState: { + ...defaultAuthState, + isInitiatingAuth: false, + oauthStep: "client_registration", + oauthMetadata: mockOAuthMetadata as unknown as OAuthMetadata, + }, + }); + }); + + // Proceed from client_registration → authorization_redirect + await act(async () => { + fireEvent.click(screen.getByText("Continue")); + }); + + // Should NOT attempt dynamic client registration + expect(mockRegisterClient).not.toHaveBeenCalled(); + + // Should advance with the preregistered client info + expect(updateAuthState).toHaveBeenCalledWith( + expect.objectContaining({ + oauthClientInfo: expect.objectContaining({ + client_id: "static_client_id", + }), + oauthStep: "authorization_redirect", + }), + ); + }); + + it("falls back to DCR when no static client information is available", async () => { + // No preregistered or dynamic client info present in session storage + sessionStorageMock.getItem.mockImplementation(() => null); + + // DCR returns a new client + mockRegisterClient.mockResolvedValueOnce(mockOAuthClientInfo); + + const updateAuthState = jest.fn(); + + await act(async () => { + renderAuthDebugger({ + updateAuthState, + authState: { + ...defaultAuthState, + isInitiatingAuth: false, + oauthStep: "client_registration", + oauthMetadata: mockOAuthMetadata as unknown as OAuthMetadata, + }, + }); + }); + + await act(async () => { + fireEvent.click(screen.getByText("Continue")); + }); + + expect(mockRegisterClient).toHaveBeenCalledTimes(1); + + // Should save and advance with the DCR client info + expect(updateAuthState).toHaveBeenCalledWith( + expect.objectContaining({ + oauthClientInfo: expect.objectContaining({ + client_id: "test_client_id", + }), + oauthStep: "authorization_redirect", + }), + ); + + // Verify the dynamically registered client info was persisted + expect(sessionStorage.setItem).toHaveBeenCalledWith( + `[${defaultProps.serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`, + expect.any(String), + ); + }); + }); + describe("OAuth State Persistence", () => { it("should store auth state to sessionStorage before redirect in Quick OAuth Flow", async () => { const updateAuthState = jest.fn(); diff --git a/client/src/lib/__tests__/auth.test.ts b/client/src/lib/__tests__/auth.test.ts new file mode 100644 index 00000000..329b7f02 --- /dev/null +++ b/client/src/lib/__tests__/auth.test.ts @@ -0,0 +1,155 @@ +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: (() => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + 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 7012c782..78d52a59 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, @@ -67,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"; @@ -84,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 ?? "", }; } @@ -110,10 +151,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, }); } 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..d3690f31 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 { @@ -315,11 +319,27 @@ 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 + let resourceMetadata; + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + new URL("/", sseUrl), + ); + } catch { + // Resource metadata is optional, continue without it + } + scope = await discoverScopes(sseUrl, resourceMetadata); + } + const serverAuthProvider = new InspectorOAuthClientProvider( + sseUrl, + scope, + ); 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 618e6453..91465853 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, @@ -89,12 +89,16 @@ export const oauthTransitions: Record = { clientMetadata.scope = scopesSupported.join(" "); } - const fullInformation = await registerClient(context.serverUrl, { - metadata, - clientMetadata, - }); + // Try Static client first, with DCR as fallback + let fullInformation = await context.provider.clientInformation(); + if (!fullInformation) { + fullInformation = await registerClient(context.serverUrl, { + metadata, + clientMetadata, + }); + context.provider.saveClientInformation(fullInformation); + } - context.provider.saveClientInformation(fullInformation); context.updateState({ oauthClientInfo: fullInformation, oauthStep: "authorization_redirect", @@ -109,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, @@ -173,7 +177,11 @@ 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); diff --git a/package-lock.json b/package-lock.json index ebe06dcc..3a0e99d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.16.3", + "version": "0.16.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "0.16.3", + "version": "0.16.5", "license": "MIT", "workspaces": [ "client", @@ -14,10 +14,10 @@ "cli" ], "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.16.3", - "@modelcontextprotocol/inspector-client": "^0.16.3", - "@modelcontextprotocol/inspector-server": "^0.16.3", - "@modelcontextprotocol/sdk": "^1.17.2", + "@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", "shell-quote": "^1.8.3", @@ -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" @@ -44,10 +46,10 @@ }, "cli": { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.16.3", + "version": "0.16.5", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.17.0", + "@modelcontextprotocol/sdk": "^1.17.3", "commander": "^13.1.0", "spawn-rx": "^5.1.2" }, @@ -67,10 +69,10 @@ }, "client": { "name": "@modelcontextprotocol/inspector-client", - "version": "0.16.3", + "version": "0.16.5", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.17.0", + "@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", @@ -2009,9 +2011,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", @@ -4671,6 +4673,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", @@ -4746,6 +4835,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", @@ -4986,9 +5082,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" @@ -5272,6 +5368,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", @@ -5620,6 +5729,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", @@ -6083,6 +6199,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", @@ -6363,6 +6492,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", @@ -7900,6 +8045,160 @@ "dev": true, "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", @@ -7936,6 +8235,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", @@ -8115,6 +8568,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", @@ -8165,6 +8631,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", @@ -8550,6 +9029,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", @@ -9292,6 +9784,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", @@ -9303,6 +9841,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", @@ -9780,6 +10325,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", @@ -9860,6 +10448,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", @@ -11067,16 +11665,16 @@ "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==", "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { - "node": ">= 14" + "node": ">= 14.6" } }, "node_modules/yargs": { @@ -11148,10 +11746,10 @@ }, "server": { "name": "@modelcontextprotocol/inspector-server", - "version": "0.16.3", + "version": "0.16.5", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.17.0", + "@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 bd494090..879c7b2c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.16.3", + "version": "0.16.5", "description": "Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -41,16 +41,16 @@ "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" }, "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.16.3", - "@modelcontextprotocol/inspector-client": "^0.16.3", - "@modelcontextprotocol/inspector-server": "^0.16.3", - "@modelcontextprotocol/sdk": "^1.17.2", + "@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", "shell-quote": "^1.8.3", @@ -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" + ] } } diff --git a/server/package.json b/server/package.json index 04135574..fb835f63 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-server", - "version": "0.16.3", + "version": "0.16.5", "description": "Server-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -27,7 +27,7 @@ "typescript": "^5.6.2" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.17.0", + "@modelcontextprotocol/sdk": "^1.17.3", "cors": "^2.8.5", "express": "^5.1.0", "ws": "^8.18.0", diff --git a/server/src/index.ts b/server/src/index.ts index 34a69414..0a0f7bcc 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -184,7 +184,7 @@ const createTransport = async (req: express.Request): Promise => { 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 env = { ...defaultEnvironment, ...process.env, ...queryEnv }; const { cmd, args } = findActualExecutable(command, origArgs);