Merge branch 'main' into jesselumarie/test-SEP-974

This commit is contained in:
Cliff Hall 2025-11-19 16:21:26 -05:00 • committed by GitHub
commit 35286a24c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 3294 additions and 705 deletions

View file

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

View file

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

View file

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

View file

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

View file

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