Merge branch 'main' into bugfix/authorization-url-generate-state
This commit is contained in:
commit
8383714484
4 changed files with 1941 additions and 128 deletions
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue