Merge branch 'main' into parse-tool-arg

This commit is contained in:
Ola Hungerford 2025-08-21 19:12:36 -07:00 • committed by GitHub
commit 0b4e395f2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 2142 additions and 679 deletions

2
.husky/pre-commit Normal file
View file

@ -0,0 +1,2 @@
npx lint-staged
git update-index --again

View file

@ -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

View file

@ -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)",
@ -23,7 +23,7 @@
},
"devDependencies": {},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.0",
"@modelcontextprotocol/sdk": "^1.17.3",
"commander": "^13.1.0",
"spawn-rx": "^5.1.2"
}

View file

@ -32,8 +32,8 @@ function createStdioTransport(options: TransportOptions): Transport {
const defaultEnv = getDefaultEnvironment();
const env: Record<string, string> = {
...processEnv,
...defaultEnv,
...processEnv,
};
const { cmd: actualCommand, args: actualArgs } = findActualExecutable(

View file

@ -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",

View file

@ -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<LoggingLevel>("debug");
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
const [stdErrNotifications, setStdErrNotifications] = useState<
StdErrNotification[]
>([]);
const [roots, setRoots] = useState<Root[]>([]);
const [env, setEnv] = useState<Record<string, string>>({});
@ -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 = () => (
<TabsContent value="auth">
<AuthDebugger
@ -829,11 +815,9 @@ const App = () => {
setOauthScope={setOauthScope}
onConnect={connectMcpServer}
onDisconnect={disconnectMcpServer}
stdErrNotifications={stdErrNotifications}
logLevel={logLevel}
sendLogLevelRequest={sendLogLevelRequest}
loggingSupported={!!serverCapabilities?.logging || false}
clearStdErrNotifications={clearStdErrNotifications}
/>
<div
onMouseDown={handleSidebarDragStart}

View file

@ -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,23 @@ 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,

View file

@ -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) {

View file

@ -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,32 @@ export const OAuthFlowProgress = ({
<p className="text-xs break-all">
{authState.authorizationUrl}
</p>
<a
href={authState.authorizationUrl}
target="_blank"
rel="noopener noreferrer"
<button
onClick={() => {
try {
validateRedirectUrl(authState.authorizationUrl!);
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",
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"
>
<ExternalLink className="h-4 w-4" />
</a>
</button>
</div>
<p className="text-xs text-muted-foreground mt-2">
Click the link to authorize in your browser. After
@ -354,7 +373,21 @@ export const OAuthFlowProgress = ({
authState.authorizationUrl && (
<Button
variant="outline"
onClick={() => window.open(authState.authorizationUrl!, "_blank")}
onClick={() => {
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",
});
}
}}
>
Open in New Tab
</Button>

View file

@ -24,7 +24,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { StdErrNotification } from "@/lib/notificationTypes";
import {
LoggingLevel,
LoggingLevelSchema,
@ -62,8 +61,6 @@ interface SidebarProps {
setOauthScope: (scope: string) => 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,
@ -268,6 +263,7 @@ const Sidebar = ({
placeholder="Command"
value={command}
onChange={(e) => setCommand(e.target.value)}
onBlur={(e) => setCommand(e.target.value.trim())}
className="font-mono"
/>
</div>
@ -760,36 +756,6 @@ const Sidebar = ({
</Select>
</div>
)}
{stdErrNotifications.length > 0 && (
<>
<div className="mt-4 border-t border-gray-200 pt-4">
<div className="flex justify-between items-center">
<h3 className="text-sm font-medium">
Error output from MCP server
</h3>
<Button
variant="outline"
size="sm"
onClick={clearStdErrNotifications}
className="h-8 px-2"
>
Clear
</Button>
</div>
<div className="mt-2 max-h-80 overflow-y-auto">
{stdErrNotifications.map((notification, index) => (
<div
key={index}
className="text-sm text-red-500 font-mono py-2 border-b border-gray-200 last:border-b-0"
>
{notification.params.content}
</div>
))}
</div>
</div>
</>
)}
</div>
</div>
</div>

View file

@ -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 = {
@ -36,7 +37,7 @@ const mockOAuthClientInfo = {
// Mock MCP SDK functions - must be before imports
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
auth: jest.fn(),
discoverOAuthMetadata: jest.fn(),
discoverAuthorizationServerMetadata: jest.fn(),
registerClient: jest.fn(),
startAuthorization: jest.fn(),
exchangeAuthorization: jest.fn(),
@ -46,7 +47,7 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
// Import the functions to get their types
import {
discoverOAuthMetadata,
discoverAuthorizationServerMetadata,
registerClient,
startAuthorization,
exchangeAuthorization,
@ -56,10 +57,62 @@ 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 mockDiscoverOAuthMetadata = discoverOAuthMetadata as jest.MockedFunction<
typeof discoverOAuthMetadata
>;
const mockDiscoverAuthorizationServerMetadata =
discoverAuthorizationServerMetadata as jest.MockedFunction<
typeof discoverAuthorizationServerMetadata
>;
const mockRegisterClient = registerClient as jest.MockedFunction<
typeof registerClient
>;
@ -74,6 +127,9 @@ const mockDiscoverOAuthProtectedResourceMetadata =
discoverOAuthProtectedResourceMetadata as jest.MockedFunction<
typeof discoverOAuthProtectedResourceMetadata
>;
const mockDiscoverScopes = discoverScopes as jest.MockedFunction<
typeof discoverScopes
>;
const sessionStorageMock = {
getItem: jest.fn(),
@ -102,7 +158,15 @@ describe("AuthDebugger", () => {
// Suppress console errors in tests to avoid JSDOM navigation noise
jest.spyOn(console, "error").mockImplementation(() => {});
mockDiscoverOAuthMetadata.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"),
@ -203,7 +267,7 @@ describe("AuthDebugger", () => {
});
// Should first discover and save OAuth metadata
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith(
new URL("https://example.com/"),
);
@ -216,7 +280,7 @@ describe("AuthDebugger", () => {
});
it("should show error when quick OAuth flow fails to discover metadata", async () => {
mockDiscoverOAuthMetadata.mockRejectedValue(
mockDiscoverAuthorizationServerMetadata.mockRejectedValue(
new Error("Metadata discovery failed"),
);
@ -362,7 +426,7 @@ describe("AuthDebugger", () => {
fireEvent.click(screen.getByText("Continue"));
});
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith(
new URL("https://example.com/"),
);
});
@ -424,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);
@ -439,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();
@ -509,7 +687,9 @@ describe("AuthDebugger", () => {
mockDiscoverOAuthProtectedResourceMetadata.mockResolvedValue(
mockResourceMetadata,
);
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
mockDiscoverAuthorizationServerMetadata.mockResolvedValue(
mockOAuthMetadata,
);
await act(async () => {
renderAuthDebugger({
@ -563,7 +743,9 @@ describe("AuthDebugger", () => {
// Mock failed metadata discovery
mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(mockError);
// But OAuth metadata should still work with the original URL
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
mockDiscoverAuthorizationServerMetadata.mockResolvedValue(
mockOAuthMetadata,
);
await act(async () => {
renderAuthDebugger({
@ -603,7 +785,7 @@ describe("AuthDebugger", () => {
});
// Verify that regular OAuth metadata discovery was still called
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith(
new URL("https://example.com/"),
);
});

File diff suppressed because it is too large Load diff

View file

@ -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();
},
);
});

View file

@ -6,8 +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";
import { validateRedirectUrl } from "@/utils/urlValidation";
/**
* 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<string | undefined> => {
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,
@ -66,10 +103,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";
@ -83,9 +125,14 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
response_types: ["code"],
client_name: "MCP Inspector",
client_uri: "https://github.com/modelcontextprotocol/inspector",
scope: this.scope ?? "",
};
}
state(): string | Promise<string> {
return generateOAuthState();
}
async clientInformation() {
// Try to get the preregistered client information from session storage first
const preregisteredClientInformation =
@ -105,10 +152,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,
});
}
@ -129,6 +183,8 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
}
redirectToAuthorization(authorizationUrl: URL) {
// Validate the URL using the shared utility
validateRedirectUrl(authorizationUrl.href);
window.location.href = authorizationUrl.href;
}

View file

@ -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<typeof auth>;
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,
});
});
});
});

View file

@ -36,12 +36,16 @@ 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 { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { Notification } from "../notificationTypes";
import {
auth,
discoverOAuthProtectedResourceMetadata,
} from "@modelcontextprotocol/sdk/client/auth.js";
import {
clearClientInformationFromSessionStorage,
InspectorOAuthClientProvider,
saveClientInformationToSessionStorage,
discoverScopes,
} from "../auth";
import packageJson from "../../../package.json";
import {
@ -88,7 +92,6 @@ export function useConnection({
oauthScope,
config,
onNotification,
onStdErrNotification,
onPendingRequest,
onElicitationRequest,
getRoots,
@ -315,11 +318,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";
}
@ -351,6 +370,7 @@ export function useConnection({
return;
}
let lastRequest = "";
try {
// Inject auth manually instead of using SSEClientTransport, because we're
// proxying through the inspector server first.
@ -484,13 +504,6 @@ export function useConnection({
};
}
if (onStdErrNotification) {
client.setNotificationHandler(
StdErrNotificationSchema,
onStdErrNotification,
);
}
let capabilities;
try {
const transport =
@ -564,7 +577,9 @@ export function useConnection({
}
if (capabilities?.logging && defaultLoggingLevel) {
lastRequest = "logging/setLevel";
await client.setLoggingLevel(defaultLoggingLevel);
lastRequest = "";
}
if (onElicitationRequest) {
@ -578,6 +593,17 @@ export function useConnection({
setMcpClient(client);
setConnectionStatus("connected");
} catch (e) {
if (
lastRequest === "logging/setLevel" &&
e instanceof McpError &&
e.code === ErrorCode.MethodNotFound
) {
toast({
title: "Error",
description: `Server declares logging capability but doesn't implement method: "${lastRequest}"`,
variant: "destructive",
});
}
console.error(e);
setConnectionStatus("error");
}

View file

@ -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<typeof StdErrNotificationSchema>;
export type Notification = z.infer<typeof NotificationSchema>;

View file

@ -1,7 +1,7 @@
import { OAuthStep, AuthDebuggerState } from "./auth-types";
import { DebugInspectorOAuthClientProvider } from "./auth";
import { DebugInspectorOAuthClientProvider, discoverScopes } from "./auth";
import {
discoverOAuthMetadata,
discoverAuthorizationServerMetadata,
registerClient,
startAuthorization,
exchangeAuthorization,
@ -12,6 +12,7 @@ import {
OAuthMetadataSchema,
OAuthProtectedResourceMetadata,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { generateOAuthState } from "@/utils/oauthUtils";
export interface StateMachineContext {
state: AuthDebuggerState;
@ -56,7 +57,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
resourceMetadata ?? undefined,
);
const metadata = await discoverOAuthMetadata(authServerUrl);
const metadata = await discoverAuthorizationServerMetadata(authServerUrl);
if (!metadata) {
throw new Error("Failed to discover OAuth metadata");
}
@ -88,12 +89,16 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
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",
@ -108,17 +113,10 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
const metadata = context.state.oauthMetadata!;
const clientInformation = context.state.oauthClientInfo!;
let scope: string | undefined = undefined;
if (metadata.scopes_supported) {
scope = metadata.scopes_supported.join(" ");
}
// Generate a random state
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const state = Array.from(array, (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
const scope = await discoverScopes(
context.serverUrl,
context.state.resourceMetadata ?? undefined,
);
const { authorizationUrl, codeVerifier } = await startAuthorization(
context.serverUrl,
@ -127,7 +125,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
clientInformation,
redirectUrl: context.provider.redirectUrl,
scope,
state: state,
state: generateOAuthState(),
resource: context.state.resource ?? undefined,
},
);
@ -179,7 +177,11 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
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);

View file

@ -0,0 +1,86 @@
import {
generateOAuthErrorDescription,
parseOAuthCallbackParams,
generateOAuthState,
} from "@/utils/oauthUtils.ts";
describe("parseOAuthCallbackParams", () => {
it("Returns successful: true and code when present", () => {
expect(parseOAuthCallbackParams("?code=fake-code")).toEqual({
successful: true,
code: "fake-code",
});
});
it("Returns successful: false and error when error is present", () => {
expect(parseOAuthCallbackParams("?error=access_denied")).toEqual({
successful: false,
error: "access_denied",
error_description: null,
error_uri: null,
});
});
it("Returns optional error metadata fields when present", () => {
const search =
"?error=access_denied&" +
"error_description=User%20Denied%20Request&" +
"error_uri=https%3A%2F%2Fexample.com%2Ferror-docs";
expect(parseOAuthCallbackParams(search)).toEqual({
successful: false,
error: "access_denied",
error_description: "User Denied Request",
error_uri: "https://example.com/error-docs",
});
});
it("Returns error when nothing present", () => {
expect(parseOAuthCallbackParams("?")).toEqual({
successful: false,
error: "invalid_request",
error_description: "Missing code or error in response",
error_uri: null,
});
});
});
describe("generateOAuthErrorDescription", () => {
it("When only error is present", () => {
expect(
generateOAuthErrorDescription({
successful: false,
error: "invalid_request",
error_description: null,
error_uri: null,
}),
).toBe("Error: invalid_request.");
});
it("When error description is present", () => {
expect(
generateOAuthErrorDescription({
successful: false,
error: "invalid_request",
error_description: "The request could not be completed as dialed",
error_uri: null,
}),
).toEqual(
"Error: invalid_request.\nDetails: The request could not be completed as dialed.",
);
});
it("When all fields present", () => {
expect(
generateOAuthErrorDescription({
successful: false,
error: "invalid_request",
error_description: "The request could not be completed as dialed",
error_uri: "https://example.com/error-docs",
}),
).toEqual(
"Error: invalid_request.\nDetails: The request could not be completed as dialed.\nMore info: https://example.com/error-docs.",
);
});
describe("generateOAuthState", () => {
it("Returns a string", () => {
expect(generateOAuthState()).toBeDefined();
expect(generateOAuthState()).toHaveLength(64);
});
});
});

View file

@ -0,0 +1,135 @@
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,<script>alert('XSS')</script>"),
).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",
);
});
});
});

Some files were not shown because too many files have changed in this diff Show more