Merge branch 'main' into jesselumarie/test-SEP-974
This commit is contained in:
commit
35286a24c6
5 changed files with 3294 additions and 705 deletions
|
|
@ -102,16 +102,40 @@ export const clearClientInformationFromSessionStorage = ({
|
|||
sessionStorage.removeItem(key);
|
||||
};
|
||||
|
||||
export const getScopeFromSessionStorage = (
|
||||
serverUrl: string,
|
||||
): string | undefined => {
|
||||
const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl);
|
||||
const value = sessionStorage.getItem(key);
|
||||
return value || undefined;
|
||||
};
|
||||
|
||||
export const saveScopeToSessionStorage = (
|
||||
serverUrl: string,
|
||||
scope: string | undefined,
|
||||
) => {
|
||||
const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl);
|
||||
if (scope) {
|
||||
sessionStorage.setItem(key, scope);
|
||||
} else {
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
};
|
||||
|
||||
export const clearScopeFromSessionStorage = (serverUrl: string) => {
|
||||
const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl);
|
||||
sessionStorage.removeItem(key);
|
||||
};
|
||||
|
||||
export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||
constructor(
|
||||
protected serverUrl: string,
|
||||
scope?: string,
|
||||
) {
|
||||
this.scope = scope;
|
||||
constructor(protected serverUrl: string) {
|
||||
// Save the server URL to session storage
|
||||
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
|
||||
}
|
||||
scope: string | undefined;
|
||||
|
||||
get scope(): string | undefined {
|
||||
return getScopeFromSessionStorage(this.serverUrl);
|
||||
}
|
||||
|
||||
get redirectUrl() {
|
||||
return window.location.origin + "/oauth/callback";
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const SESSION_KEYS = {
|
|||
PREREGISTERED_CLIENT_INFORMATION: "mcp_preregistered_client_information",
|
||||
SERVER_METADATA: "mcp_server_metadata",
|
||||
AUTH_DEBUGGER_STATE: "mcp_auth_debugger_state",
|
||||
SCOPE: "mcp_scope",
|
||||
} as const;
|
||||
|
||||
// Generate server-specific session storage keys
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useConnection } from "../useConnection";
|
||||
import { z } from "zod";
|
||||
import { ClientRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
ClientRequest,
|
||||
JSONRPCMessage,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { DEFAULT_INSPECTOR_CONFIG, CLIENT_IDENTITY } from "../../constants";
|
||||
import {
|
||||
SSEClientTransportOptions,
|
||||
|
|
@ -42,10 +45,12 @@ const mockSSETransport: {
|
|||
start: jest.Mock;
|
||||
url: URL | undefined;
|
||||
options: SSEClientTransportOptions | undefined;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
} = {
|
||||
start: jest.fn(),
|
||||
url: undefined,
|
||||
options: undefined,
|
||||
onmessage: undefined,
|
||||
};
|
||||
|
||||
const mockStreamableHTTPTransport: {
|
||||
|
|
@ -112,6 +117,8 @@ jest.mock("../../auth", () => ({
|
|||
})),
|
||||
clearClientInformationFromSessionStorage: jest.fn(),
|
||||
saveClientInformationToSessionStorage: jest.fn(),
|
||||
saveScopeToSessionStorage: jest.fn(),
|
||||
clearScopeFromSessionStorage: jest.fn(),
|
||||
discoverScopes: jest.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -480,6 +487,129 @@ describe("useConnection", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Ref Resolution", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test("resolves $ref references in requestedSchema properties before validation", async () => {
|
||||
const mockProtocolOnMessage = jest.fn();
|
||||
|
||||
mockSSETransport.onmessage = mockProtocolOnMessage;
|
||||
|
||||
const { result } = renderHook(() => useConnection(defaultProps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
const mockRequestWithRef: JSONRPCMessage = {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "elicitation/create",
|
||||
params: {
|
||||
message: "Please provide your information",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
source: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
title: "A Connectable Node",
|
||||
},
|
||||
target: {
|
||||
$ref: "#/properties/source",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
mockSSETransport.onmessage!(mockRequestWithRef);
|
||||
});
|
||||
|
||||
expect(mockProtocolOnMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
const message = mockProtocolOnMessage.mock.calls[0][0];
|
||||
expect(message.params.requestedSchema.properties.target).toEqual({
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
title: "A Connectable Node",
|
||||
});
|
||||
});
|
||||
|
||||
test("resolves $ref references to $defs in requestedSchema", async () => {
|
||||
const mockProtocolOnMessage = jest.fn();
|
||||
|
||||
mockSSETransport.onmessage = mockProtocolOnMessage;
|
||||
|
||||
const { result } = renderHook(() => useConnection(defaultProps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
const mockRequestWithDefs: JSONRPCMessage = {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "elicitation/create",
|
||||
params: {
|
||||
message: "Please provide your information",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
user: {
|
||||
$ref: "#/$defs/UserInput",
|
||||
},
|
||||
},
|
||||
$defs: {
|
||||
UserInput: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
title: "Name",
|
||||
},
|
||||
age: {
|
||||
type: "integer",
|
||||
title: "Age",
|
||||
minimum: 0,
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
mockSSETransport.onmessage!(mockRequestWithDefs);
|
||||
});
|
||||
|
||||
expect(mockProtocolOnMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
const message = mockProtocolOnMessage.mock.calls[0][0];
|
||||
// The $ref should be resolved to the actual UserInput definition
|
||||
expect(message.params.requestedSchema.properties.user).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
title: "Name",
|
||||
},
|
||||
age: {
|
||||
type: "integer",
|
||||
title: "Age",
|
||||
minimum: 0,
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL Port Handling", () => {
|
||||
const SSEClientTransport = jest.requireMock(
|
||||
"@modelcontextprotocol/sdk/client/sse.js",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ import {
|
|||
clearClientInformationFromSessionStorage,
|
||||
InspectorOAuthClientProvider,
|
||||
saveClientInformationToSessionStorage,
|
||||
saveScopeToSessionStorage,
|
||||
clearScopeFromSessionStorage,
|
||||
discoverScopes,
|
||||
} from "../auth";
|
||||
import {
|
||||
|
|
@ -58,6 +60,7 @@ import { getMCPServerRequestTimeout } from "@/utils/configUtils";
|
|||
import { InspectorConfig } from "../configurationTypes";
|
||||
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import { CustomHeaders } from "../types/customHeaders";
|
||||
import { resolveRefsInMessage } from "@/utils/schemaUtils";
|
||||
|
||||
interface UseConnectionOptions {
|
||||
transportType: "stdio" | "sse" | "streamable-http";
|
||||
|
|
@ -82,6 +85,7 @@ interface UseConnectionOptions {
|
|||
getRoots?: () => any[];
|
||||
defaultLoggingLevel?: LoggingLevel;
|
||||
serverImplementation?: Implementation;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function useConnection({
|
||||
|
|
@ -101,6 +105,7 @@ export function useConnection({
|
|||
onElicitationRequest,
|
||||
getRoots,
|
||||
defaultLoggingLevel,
|
||||
metadata = {},
|
||||
}: UseConnectionOptions) {
|
||||
const [connectionStatus, setConnectionStatus] =
|
||||
useState<ConnectionStatus>("disconnected");
|
||||
|
|
@ -146,6 +151,15 @@ export function useConnection({
|
|||
});
|
||||
}, [oauthClientId, oauthClientSecret, sseUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!oauthScope) {
|
||||
clearScopeFromSessionStorage(sseUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
saveScopeToSessionStorage(sseUrl, oauthScope);
|
||||
}, [oauthScope, sseUrl]);
|
||||
|
||||
const pushHistory = (request: object, response?: object) => {
|
||||
setRequestHistory((prev) => [
|
||||
...prev,
|
||||
|
|
@ -167,6 +181,20 @@ export function useConnection({
|
|||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Add metadata to the request if available, but skip for tool calls
|
||||
// as they handle metadata merging separately
|
||||
const shouldAddGeneralMetadata =
|
||||
request.method !== "tools/call" && Object.keys(metadata).length > 0;
|
||||
const requestWithMetadata = shouldAddGeneralMetadata
|
||||
? {
|
||||
...request,
|
||||
params: {
|
||||
...request.params,
|
||||
_meta: metadata,
|
||||
},
|
||||
}
|
||||
: request;
|
||||
|
||||
// prepare MCP Client request options
|
||||
const mcpRequestOptions: RequestOptions = {
|
||||
signal: options?.signal ?? abortController.signal,
|
||||
|
|
@ -195,13 +223,17 @@ export function useConnection({
|
|||
|
||||
let response;
|
||||
try {
|
||||
response = await mcpClient.request(request, schema, mcpRequestOptions);
|
||||
response = await mcpClient.request(
|
||||
requestWithMetadata,
|
||||
schema,
|
||||
mcpRequestOptions,
|
||||
);
|
||||
|
||||
pushHistory(request, response);
|
||||
pushHistory(requestWithMetadata, response);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
pushHistory(request, { error: errorMessage });
|
||||
pushHistory(requestWithMetadata, { error: errorMessage });
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
|
@ -350,10 +382,9 @@ export function useConnection({
|
|||
}
|
||||
scope = await discoverScopes(sseUrl, resourceMetadata);
|
||||
}
|
||||
const serverAuthProvider = new InspectorOAuthClientProvider(
|
||||
sseUrl,
|
||||
scope,
|
||||
);
|
||||
|
||||
saveScopeToSessionStorage(sseUrl, scope);
|
||||
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
|
||||
|
||||
const result = await auth(serverAuthProvider, {
|
||||
serverUrl: sseUrl,
|
||||
|
|
@ -685,6 +716,14 @@ export function useConnection({
|
|||
|
||||
await client.connect(transport as Transport);
|
||||
|
||||
const protocolOnMessage = transport.onmessage;
|
||||
if (protocolOnMessage) {
|
||||
transport.onmessage = (message) => {
|
||||
const resolvedMessage = resolveRefsInMessage(message);
|
||||
protocolOnMessage(resolvedMessage);
|
||||
};
|
||||
}
|
||||
|
||||
setClientTransport(transport);
|
||||
|
||||
capabilities = client.getServerCapabilities();
|
||||
|
|
|
|||
|
|
@ -80,13 +80,16 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
const metadata = context.state.oauthMetadata!;
|
||||
const clientMetadata = context.provider.clientMetadata;
|
||||
|
||||
// Prefer scopes from resource metadata if available
|
||||
const scopesSupported =
|
||||
context.state.resourceMetadata?.scopes_supported ||
|
||||
metadata.scopes_supported;
|
||||
// Add all supported scopes to client registration
|
||||
if (scopesSupported) {
|
||||
clientMetadata.scope = scopesSupported.join(" ");
|
||||
// Priority: user-provided scope > discovered scopes
|
||||
if (!context.provider.scope || context.provider.scope.trim() === "") {
|
||||
// Prefer scopes from resource metadata if available
|
||||
const scopesSupported =
|
||||
context.state.resourceMetadata?.scopes_supported ||
|
||||
metadata.scopes_supported;
|
||||
// Add all supported scopes to client registration
|
||||
if (scopesSupported) {
|
||||
clientMetadata.scope = scopesSupported.join(" ");
|
||||
}
|
||||
}
|
||||
|
||||
// Try Static client first, with DCR as fallback
|
||||
|
|
@ -113,10 +116,14 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
const metadata = context.state.oauthMetadata!;
|
||||
const clientInformation = context.state.oauthClientInfo!;
|
||||
|
||||
const scope = await discoverScopes(
|
||||
context.serverUrl,
|
||||
context.state.resourceMetadata ?? undefined,
|
||||
);
|
||||
// Priority: user-provided scope > discovered scopes
|
||||
let scope = context.provider.scope;
|
||||
if (!scope || scope.trim() === "") {
|
||||
scope = await discoverScopes(
|
||||
context.serverUrl,
|
||||
context.state.resourceMetadata ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const { authorizationUrl, codeVerifier } = await startAuthorization(
|
||||
context.serverUrl,
|
||||
|
|
|
|||
Loading…
Reference in a new issue