Merge branch 'main' into bugfix/authorization-url-generate-state

This commit is contained in:
Konstantin Konstantinov 2025-07-25 15:28:04 +03:00 • committed by GitHub
commit 8383714484
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 1941 additions and 128 deletions

View file

@ -9,8 +9,64 @@ import {
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
export const getClientInformationFromSessionStorage = async ({
serverUrl,
isPreregistered,
}: {
serverUrl: string;
isPreregistered?: boolean;
}) => {
const key = getServerSpecificKey(
isPreregistered
? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION
: SESSION_KEYS.CLIENT_INFORMATION,
serverUrl,
);
const value = sessionStorage.getItem(key);
if (!value) {
return undefined;
}
return await OAuthClientInformationSchema.parseAsync(JSON.parse(value));
};
export const saveClientInformationToSessionStorage = ({
serverUrl,
clientInformation,
isPreregistered,
}: {
serverUrl: string;
clientInformation: OAuthClientInformation;
isPreregistered?: boolean;
}) => {
const key = getServerSpecificKey(
isPreregistered
? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION
: SESSION_KEYS.CLIENT_INFORMATION,
serverUrl,
);
sessionStorage.setItem(key, JSON.stringify(clientInformation));
};
export const clearClientInformationFromSessionStorage = ({
serverUrl,
isPreregistered,
}: {
serverUrl: string;
isPreregistered?: boolean;
}) => {
const key = getServerSpecificKey(
isPreregistered
? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION
: SESSION_KEYS.CLIENT_INFORMATION,
serverUrl,
);
sessionStorage.removeItem(key);
};
export class InspectorOAuthClientProvider implements OAuthClientProvider {
constructor(public serverUrl: string) {
constructor(protected serverUrl: string) {
// Save the server URL to session storage
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
}
@ -31,24 +87,30 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
}
async clientInformation() {
const key = getServerSpecificKey(
SESSION_KEYS.CLIENT_INFORMATION,
this.serverUrl,
);
const value = sessionStorage.getItem(key);
if (!value) {
return undefined;
}
// Try to get the preregistered client information from session storage first
const preregisteredClientInformation =
await getClientInformationFromSessionStorage({
serverUrl: this.serverUrl,
isPreregistered: true,
});
return await OAuthClientInformationSchema.parseAsync(JSON.parse(value));
// If no preregistered client information is found, get the dynamically registered client information
return (
preregisteredClientInformation ??
(await getClientInformationFromSessionStorage({
serverUrl: this.serverUrl,
isPreregistered: false,
}))
);
}
saveClientInformation(clientInformation: OAuthClientInformation) {
const key = getServerSpecificKey(
SESSION_KEYS.CLIENT_INFORMATION,
this.serverUrl,
);
sessionStorage.setItem(key, JSON.stringify(clientInformation));
// Save the dynamically registered client information to session storage
saveClientInformationToSessionStorage({
serverUrl: this.serverUrl,
clientInformation,
isPreregistered: false,
});
}
async tokens() {
@ -92,9 +154,10 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
}
clear() {
sessionStorage.removeItem(
getServerSpecificKey(SESSION_KEYS.CLIENT_INFORMATION, this.serverUrl),
);
clearClientInformationFromSessionStorage({
serverUrl: this.serverUrl,
isPreregistered: false,
});
sessionStorage.removeItem(
getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl),
);

View file

@ -6,6 +6,7 @@ export const SESSION_KEYS = {
SERVER_URL: "mcp_server_url",
TOKENS: "mcp_tokens",
CLIENT_INFORMATION: "mcp_client_information",
PREREGISTERED_CLIENT_INFORMATION: "mcp_preregistered_client_information",
SERVER_METADATA: "mcp_server_metadata",
AUTH_DEBUGGER_STATE: "mcp_auth_debugger_state",
} as const;

View file

@ -4,6 +4,10 @@ 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 {
ElicitResult,
ElicitRequest,
} from "@modelcontextprotocol/sdk/types.js";
// Mock fetch
global.fetch = jest.fn().mockResolvedValue({
@ -82,6 +86,8 @@ jest.mock("../../auth", () => ({
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }),
})),
clearClientInformationFromSessionStorage: jest.fn(),
saveClientInformationToSessionStorage: jest.fn(),
}));
describe("useConnection", () => {
@ -198,6 +204,252 @@ describe("useConnection", () => {
).rejects.toThrow("MCP client not connected");
});
describe("Elicitation Support", () => {
beforeEach(() => {
jest.clearAllMocks();
});
test("declares elicitation capability during client initialization", async () => {
const Client = jest.requireMock(
"@modelcontextprotocol/sdk/client/index.js",
).Client;
const { result } = renderHook(() => useConnection(defaultProps));
await act(async () => {
await result.current.connect();
});
expect(Client).toHaveBeenCalledWith(
expect.objectContaining({
name: "mcp-inspector",
version: expect.any(String),
}),
expect.objectContaining({
capabilities: expect.objectContaining({
elicitation: {},
}),
}),
);
});
test("sets up elicitation request handler when onElicitationRequest is provided", async () => {
const mockOnElicitationRequest = jest.fn();
const propsWithElicitation = {
...defaultProps,
onElicitationRequest: mockOnElicitationRequest,
};
const { result } = renderHook(() => useConnection(propsWithElicitation));
await act(async () => {
await result.current.connect();
});
const elicitRequestHandlerCall =
mockClient.setRequestHandler.mock.calls.find((call) => {
try {
const schema = call[0];
const testRequest = {
method: "elicitation/create",
params: {
message: "test message",
requestedSchema: {
type: "object",
properties: {
name: { type: "string" },
},
},
},
};
const parseResult =
schema.safeParse && schema.safeParse(testRequest);
return parseResult?.success;
} catch {
return false;
}
});
expect(elicitRequestHandlerCall).toBeDefined();
expect(mockClient.setRequestHandler).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Function),
);
});
test("does not set up elicitation request handler when onElicitationRequest is not provided", async () => {
const { result } = renderHook(() => useConnection(defaultProps));
await act(async () => {
await result.current.connect();
});
const elicitRequestHandlerCall =
mockClient.setRequestHandler.mock.calls.find((call) => {
try {
const schema = call[0];
const testRequest = {
method: "elicitation/create",
params: {
message: "test message",
requestedSchema: {
type: "object",
properties: {
name: { type: "string" },
},
},
},
};
const parseResult =
schema.safeParse && schema.safeParse(testRequest);
return parseResult?.success;
} catch {
return false;
}
});
expect(elicitRequestHandlerCall).toBeUndefined();
});
test("elicitation request handler calls onElicitationRequest callback", async () => {
const mockOnElicitationRequest = jest.fn();
const propsWithElicitation = {
...defaultProps,
onElicitationRequest: mockOnElicitationRequest,
};
const { result } = renderHook(() => useConnection(propsWithElicitation));
await act(async () => {
await result.current.connect();
});
const elicitRequestHandlerCall =
mockClient.setRequestHandler.mock.calls.find((call) => {
try {
const schema = call[0];
const testRequest = {
method: "elicitation/create",
params: {
message: "test message",
requestedSchema: {
type: "object",
properties: {
name: { type: "string" },
},
},
},
};
const parseResult =
schema.safeParse && schema.safeParse(testRequest);
return parseResult?.success;
} catch {
return false;
}
});
expect(elicitRequestHandlerCall).toBeDefined();
const [, handler] = elicitRequestHandlerCall;
const mockElicitationRequest: ElicitRequest = {
method: "elicitation/create",
params: {
message: "Please provide your name",
requestedSchema: {
type: "object",
properties: {
name: { type: "string" },
},
required: ["name"],
},
},
};
mockOnElicitationRequest.mockImplementation((_request, resolve) => {
resolve({ action: "accept", content: { name: "test" } });
});
await act(async () => {
await handler(mockElicitationRequest);
});
expect(mockOnElicitationRequest).toHaveBeenCalledWith(
mockElicitationRequest,
expect.any(Function),
);
});
test("elicitation request handler returns a promise that resolves with the callback result", async () => {
const mockOnElicitationRequest = jest.fn();
const propsWithElicitation = {
...defaultProps,
onElicitationRequest: mockOnElicitationRequest,
};
const { result } = renderHook(() => useConnection(propsWithElicitation));
await act(async () => {
await result.current.connect();
});
const elicitRequestHandlerCall =
mockClient.setRequestHandler.mock.calls.find((call) => {
try {
const schema = call[0];
const testRequest = {
method: "elicitation/create",
params: {
message: "test message",
requestedSchema: {
type: "object",
properties: {
name: { type: "string" },
},
},
},
};
const parseResult =
schema.safeParse && schema.safeParse(testRequest);
return parseResult?.success;
} catch {
return false;
}
});
const [, handler] = elicitRequestHandlerCall;
const mockElicitationRequest: ElicitRequest = {
method: "elicitation/create",
params: {
message: "Please provide your name",
requestedSchema: {
type: "object",
properties: {
name: { type: "string" },
},
required: ["name"],
},
},
};
const mockResponse: ElicitResult = {
action: "accept",
content: { name: "John Doe" },
};
mockOnElicitationRequest.mockImplementation((_request, resolve) => {
resolve(mockResponse);
});
let handlerResult;
await act(async () => {
handlerResult = await handler(mockElicitationRequest);
});
expect(handlerResult).toEqual(mockResponse);
});
});
describe("URL Port Handling", () => {
const SSEClientTransport = jest.requireMock(
"@modelcontextprotocol/sdk/client/sse.js",

View file

@ -28,15 +28,20 @@ import {
ToolListChangedNotificationSchema,
PromptListChangedNotificationSchema,
Progress,
ElicitRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { useState } from "react";
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 { InspectorOAuthClientProvider } from "../auth";
import {
clearClientInformationFromSessionStorage,
InspectorOAuthClientProvider,
saveClientInformationToSessionStorage,
} from "../auth";
import packageJson from "../../../package.json";
import {
getMCPProxyAddress,
@ -56,12 +61,16 @@ interface UseConnectionOptions {
env: Record<string, string>;
bearerToken?: string;
headerName?: string;
oauthClientId?: string;
oauthScope?: string;
config: InspectorConfig;
onNotification?: (notification: Notification) => void;
onStdErrNotification?: (notification: Notification) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onPendingRequest?: (request: any, resolve: any, reject: any) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onElicitationRequest?: (request: any, resolve: any) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getRoots?: () => any[];
}
@ -73,10 +82,13 @@ export function useConnection({
env,
bearerToken,
headerName,
oauthClientId,
oauthScope,
config,
onNotification,
onStdErrNotification,
onPendingRequest,
onElicitationRequest,
getRoots,
}: UseConnectionOptions) {
const [connectionStatus, setConnectionStatus] =
@ -93,6 +105,22 @@ export function useConnection({
>([]);
const [completionsSupported, setCompletionsSupported] = useState(false);
useEffect(() => {
if (!oauthClientId) {
clearClientInformationFromSessionStorage({
serverUrl: sseUrl,
isPreregistered: true,
});
return;
}
saveClientInformationToSessionStorage({
serverUrl: sseUrl,
clientInformation: { client_id: oauthClientId },
isPreregistered: true,
});
}, [oauthClientId, sseUrl]);
const pushHistory = (request: object, response?: object) => {
setRequestHistory((prev) => [
...prev,
@ -279,7 +307,10 @@ export function useConnection({
if (is401Error(error)) {
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
const result = await auth(serverAuthProvider, { serverUrl: sseUrl });
const result = await auth(serverAuthProvider, {
serverUrl: sseUrl,
scope: oauthScope,
});
return result === "AUTHORIZED";
}
@ -295,6 +326,7 @@ export function useConnection({
{
capabilities: {
sampling: {},
elicitation: {},
roots: {
listChanged: true,
},
@ -521,6 +553,14 @@ export function useConnection({
});
}
if (onElicitationRequest) {
client.setRequestHandler(ElicitRequestSchema, async (request) => {
return new Promise((resolve) => {
onElicitationRequest(request, resolve);
});
});
}
setMcpClient(client);
setConnectionStatus("connected");
} catch (e) {