diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index 849a2aa2..2214add9 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -15,12 +15,22 @@ interface DynamicJsonFormProps { maxDepth?: number; } +const isTypeSupported = ( + type: JsonSchemaType["type"], + supportedTypes: string[], +): boolean => { + if (Array.isArray(type)) { + return type.every((t) => supportedTypes.includes(t)); + } + return typeof type === "string" && supportedTypes.includes(type); +}; + const isSimpleObject = (schema: JsonSchemaType): boolean => { const supportedTypes = ["string", "number", "integer", "boolean", "null"]; - if (schema.type && supportedTypes.includes(schema.type)) return true; + if (schema.type && isTypeSupported(schema.type, supportedTypes)) return true; if (schema.type === "object") { return Object.values(schema.properties ?? {}).every( - (prop) => prop.type && supportedTypes.includes(prop.type), + (prop) => prop.type && isTypeSupported(prop.type, supportedTypes), ); } if (schema.type === "array") { @@ -181,7 +191,13 @@ const DynamicJsonForm = ({ const isRequired = parentSchema?.required?.includes(propertyName || "") ?? false; - switch (propSchema.type) { + let fieldType = propSchema.type; + if (Array.isArray(fieldType)) { + // Of the possible types, find the first non-null type to determine the control to render + fieldType = fieldType.find((t) => t !== "null") ?? fieldType[0]; + } + + switch (fieldType) { case "string": { if ( propSchema.oneOf && @@ -337,6 +353,8 @@ const DynamicJsonForm = ({ required={isRequired} /> ); + case "null": + return null; case "object": if (!propSchema.properties) { return ( diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 2eb682e5..2654feed 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -7,7 +7,11 @@ import { TabsContent } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import DynamicJsonForm from "./DynamicJsonForm"; import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils"; -import { generateDefaultValue, isPropertyRequired } from "@/utils/schemaUtils"; +import { + generateDefaultValue, + isPropertyRequired, + normalizeUnionType, +} from "@/utils/schemaUtils"; import { CompatibilityCallToolResult, ListToolsResult, @@ -104,7 +108,7 @@ const ToolsTab = ({

{Object.entries(selectedTool.inputSchema.properties ?? []).map( ([key, value]) => { - const prop = value as JsonSchemaType; + const prop = normalizeUnionType(value as JsonSchemaType); const inputSchema = selectedTool.inputSchema as JsonSchemaType; const required = isPropertyRequired(key, inputSchema); @@ -148,7 +152,10 @@ const ToolsTab = ({ onChange={(e) => setParams({ ...params, - [key]: e.target.value, + [key]: + e.target.value === "" + ? undefined + : e.target.value, }) } className="mt-1" diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index fa9f2f7d..3d8e2290 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -36,7 +36,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 +46,7 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ // Import the functions to get their types import { - discoverOAuthMetadata, + discoverAuthorizationServerMetadata, registerClient, startAuthorization, exchangeAuthorization, @@ -57,9 +57,10 @@ import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types"; // 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 >; @@ -102,7 +103,9 @@ describe("AuthDebugger", () => { // Suppress console errors in tests to avoid JSDOM navigation noise jest.spyOn(console, "error").mockImplementation(() => {}); - mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); + mockDiscoverAuthorizationServerMetadata.mockResolvedValue( + mockOAuthMetadata, + ); mockRegisterClient.mockResolvedValue(mockOAuthClientInfo); mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue( new Error("No protected resource metadata found"), @@ -203,7 +206,7 @@ describe("AuthDebugger", () => { }); // Should first discover and save OAuth metadata - expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith( + expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith( new URL("https://example.com/"), ); @@ -216,7 +219,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 +365,7 @@ describe("AuthDebugger", () => { fireEvent.click(screen.getByText("Continue")); }); - expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith( + expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith( new URL("https://example.com/"), ); }); @@ -509,7 +512,9 @@ describe("AuthDebugger", () => { mockDiscoverOAuthProtectedResourceMetadata.mockResolvedValue( mockResourceMetadata, ); - mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); + mockDiscoverAuthorizationServerMetadata.mockResolvedValue( + mockOAuthMetadata, + ); await act(async () => { renderAuthDebugger({ @@ -563,7 +568,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 +610,7 @@ describe("AuthDebugger", () => { }); // Verify that regular OAuth metadata discovery was still called - expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith( + expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith( new URL("https://example.com/"), ); }); diff --git a/client/src/components/__tests__/DynamicJsonForm.test.tsx b/client/src/components/__tests__/DynamicJsonForm.test.tsx index 22813c9b..318fba2d 100644 --- a/client/src/components/__tests__/DynamicJsonForm.test.tsx +++ b/client/src/components/__tests__/DynamicJsonForm.test.tsx @@ -35,6 +35,18 @@ describe("DynamicJsonForm String Fields", () => { const input = screen.getByRole("textbox"); expect(input).toHaveProperty("type", "text"); }); + + it("should handle a union type of string and null", () => { + const schema: JsonSchemaType = { + type: ["string", "null"], + description: "Test string or null field", + }; + render( + , + ); + const input = screen.getByRole("textbox"); + expect(input).toHaveProperty("type", "text"); + }); }); describe("Format Support", () => { diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index 9989e944..7012c782 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -8,6 +8,7 @@ import { OAuthMetadata, } from "@modelcontextprotocol/sdk/shared/auth.js"; import { SESSION_KEYS, getServerSpecificKey } from "./constants"; +import { generateOAuthState } from "@/utils/oauthUtils"; export const getClientInformationFromSessionStorage = async ({ serverUrl, @@ -86,6 +87,10 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { }; } + state(): string | Promise { + return generateOAuthState(); + } + async clientInformation() { // Try to get the preregistered client information from session storage first const preregisteredClientInformation = @@ -129,6 +134,12 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { } redirectToAuthorization(authorizationUrl: URL) { + if ( + authorizationUrl.protocol !== "http:" && + authorizationUrl.protocol !== "https:" + ) { + throw new Error("Authorization URL must be HTTP or HTTPS"); + } window.location.href = authorizationUrl.href; } diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 78dfa6df..12d8ff3e 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -351,6 +351,7 @@ export function useConnection({ return; } + let lastRequest = ""; try { // Inject auth manually instead of using SSEClientTransport, because we're // proxying through the inspector server first. @@ -564,7 +565,9 @@ export function useConnection({ } if (capabilities?.logging && defaultLoggingLevel) { + lastRequest = "logging/setLevel"; await client.setLoggingLevel(defaultLoggingLevel); + lastRequest = ""; } if (onElicitationRequest) { @@ -578,6 +581,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"); } diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index 5078cfe4..618e6453 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -1,7 +1,7 @@ import { OAuthStep, AuthDebuggerState } from "./auth-types"; import { DebugInspectorOAuthClientProvider } 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 = { resourceMetadata ?? undefined, ); - const metadata = await discoverOAuthMetadata(authServerUrl); + const metadata = await discoverAuthorizationServerMetadata(authServerUrl); if (!metadata) { throw new Error("Failed to discover OAuth metadata"); } @@ -113,13 +114,6 @@ export const oauthTransitions: Record = { 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 { authorizationUrl, codeVerifier } = await startAuthorization( context.serverUrl, { @@ -127,7 +121,7 @@ export const oauthTransitions: Record = { clientInformation, redirectUrl: context.provider.redirectUrl, scope, - state: state, + state: generateOAuthState(), resource: context.state.resource ?? undefined, }, ); diff --git a/client/src/utils/__tests__/oauthUtils.ts b/client/src/utils/__tests__/oauthUtils.test.ts similarity index 92% rename from client/src/utils/__tests__/oauthUtils.ts rename to client/src/utils/__tests__/oauthUtils.test.ts index cc9674cb..c42f014e 100644 --- a/client/src/utils/__tests__/oauthUtils.ts +++ b/client/src/utils/__tests__/oauthUtils.test.ts @@ -1,6 +1,7 @@ import { generateOAuthErrorDescription, parseOAuthCallbackParams, + generateOAuthState, } from "@/utils/oauthUtils.ts"; describe("parseOAuthCallbackParams", () => { @@ -75,4 +76,11 @@ describe("generateOAuthErrorDescription", () => { "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); + }); + }); }); diff --git a/client/src/utils/__tests__/schemaUtils.test.ts b/client/src/utils/__tests__/schemaUtils.test.ts index 133e25ec..0ecd8075 100644 --- a/client/src/utils/__tests__/schemaUtils.test.ts +++ b/client/src/utils/__tests__/schemaUtils.test.ts @@ -1,6 +1,7 @@ import { generateDefaultValue, formatFieldLabel, + normalizeUnionType, cacheToolOutputSchemas, getToolOutputValidator, validateToolOutput, @@ -142,6 +143,189 @@ describe("formatFieldLabel", () => { }); }); +describe("normalizeUnionType", () => { + test("normalizes anyOf with string and null to string type", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "string" }, { type: "null" }], + description: "Optional string parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("string"); + expect(normalized.anyOf).toBeUndefined(); + expect(normalized.description).toBe("Optional string parameter"); + }); + + test("normalizes anyOf with boolean and null to boolean type", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "boolean" }, { type: "null" }], + description: "Optional boolean parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("boolean"); + expect(normalized.anyOf).toBeUndefined(); + expect(normalized.description).toBe("Optional boolean parameter"); + }); + + test("normalizes array type with string and null to string type", () => { + const schema: JsonSchemaType = { + type: ["string", "null"], + description: "Optional string parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("string"); + expect(normalized.description).toBe("Optional string parameter"); + }); + + test("normalizes array type with boolean and null to boolean type", () => { + const schema: JsonSchemaType = { + type: ["boolean", "null"], + description: "Optional boolean parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("boolean"); + expect(normalized.description).toBe("Optional boolean parameter"); + }); + + test("normalizes anyOf with number and null to number type", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "number" }, { type: "null" }], + description: "Optional number parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("number"); + expect(normalized.anyOf).toBeUndefined(); + expect(normalized.description).toBe("Optional number parameter"); + }); + + test("normalizes anyOf with integer and null to integer type", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "integer" }, { type: "null" }], + description: "Optional integer parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("integer"); + expect(normalized.anyOf).toBeUndefined(); + expect(normalized.description).toBe("Optional integer parameter"); + }); + + test("normalizes array type with number and null to number type", () => { + const schema: JsonSchemaType = { + type: ["number", "null"], + description: "Optional number parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("number"); + expect(normalized.description).toBe("Optional number parameter"); + }); + + test("normalizes array type with integer and null to integer type", () => { + const schema: JsonSchemaType = { + type: ["integer", "null"], + description: "Optional integer parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("integer"); + expect(normalized.description).toBe("Optional integer parameter"); + }); + + test("handles anyOf with reversed order (null first)", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "null" }, { type: "string" }], + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("string"); + expect(normalized.anyOf).toBeUndefined(); + }); + + test("leaves non-union schemas unchanged", () => { + const schema: JsonSchemaType = { + type: "string", + description: "Regular string parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized).toEqual(schema); + }); + + test("leaves anyOf with non-matching types unchanged", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "string" }, { type: "number" }], + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized).toEqual(schema); + }); + + test("leaves anyOf with more than two types unchanged", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }], + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized).toEqual(schema); + }); + + test("leaves array type with non-matching types unchanged", () => { + const schema: JsonSchemaType = { + type: ["string", "number"], + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized).toEqual(schema); + }); + + test("handles schemas without type or anyOf", () => { + const schema: JsonSchemaType = { + description: "Schema without type", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized).toEqual(schema); + }); + + test("preserves other properties when normalizing", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "string" }, { type: "null" }], + description: "Optional string", + minLength: 1, + maxLength: 100, + pattern: "^[a-z]+$", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("string"); + expect(normalized.anyOf).toBeUndefined(); + expect(normalized.description).toBe("Optional string"); + expect(normalized.minLength).toBe(1); + expect(normalized.maxLength).toBe(100); + expect(normalized.pattern).toBe("^[a-z]+$"); + }); +}); + describe("Output Schema Validation", () => { const mockTools: Tool[] = [ { diff --git a/client/src/utils/jsonUtils.ts b/client/src/utils/jsonUtils.ts index 338b642a..60f5b1d6 100644 --- a/client/src/utils/jsonUtils.ts +++ b/client/src/utils/jsonUtils.ts @@ -21,7 +21,16 @@ export type JsonSchemaType = { | "boolean" | "array" | "object" - | "null"; + | "null" + | ( + | "string" + | "number" + | "integer" + | "boolean" + | "array" + | "object" + | "null" + )[]; title?: string; description?: string; required?: string[]; diff --git a/client/src/utils/oauthUtils.ts b/client/src/utils/oauthUtils.ts index c971271e..83de080b 100644 --- a/client/src/utils/oauthUtils.ts +++ b/client/src/utils/oauthUtils.ts @@ -48,6 +48,20 @@ export const parseOAuthCallbackParams = (location: string): CallbackParams => { }; }; +/** + * Generate a random state for the OAuth 2.0 flow. + * + * @returns A random state for the OAuth 2.0 flow. + */ +export const generateOAuthState = () => { + // Generate a random state + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join( + "", + ); +}; + export const generateOAuthErrorDescription = ( params: Extract, ): string => { diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts index 9fc7a724..7a175142 100644 --- a/client/src/utils/schemaUtils.ts +++ b/client/src/utils/schemaUtils.ts @@ -145,6 +145,95 @@ export function isPropertyRequired( return schema.required?.includes(propertyName) ?? false; } +/** + * Normalizes union types (like string|null from FastMCP) to simple types for form rendering + * @param schema The JSON schema to normalize + * @returns A normalized schema or the original schema + */ +export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType { + // Handle anyOf with exactly string and null (FastMCP pattern) + if ( + schema.anyOf && + schema.anyOf.length === 2 && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "string") && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") + ) { + return { ...schema, type: "string", anyOf: undefined }; + } + + // Handle anyOf with exactly boolean and null (FastMCP pattern) + if ( + schema.anyOf && + schema.anyOf.length === 2 && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "boolean") && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") + ) { + return { ...schema, type: "boolean", anyOf: undefined }; + } + + // Handle anyOf with exactly number and null (FastMCP pattern) + if ( + schema.anyOf && + schema.anyOf.length === 2 && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "number") && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") + ) { + return { ...schema, type: "number", anyOf: undefined }; + } + + // Handle anyOf with exactly integer and null (FastMCP pattern) + if ( + schema.anyOf && + schema.anyOf.length === 2 && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "integer") && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") + ) { + return { ...schema, type: "integer", anyOf: undefined }; + } + + // Handle array type with exactly string and null + if ( + Array.isArray(schema.type) && + schema.type.length === 2 && + schema.type.includes("string") && + schema.type.includes("null") + ) { + return { ...schema, type: "string" }; + } + + // Handle array type with exactly boolean and null + if ( + Array.isArray(schema.type) && + schema.type.length === 2 && + schema.type.includes("boolean") && + schema.type.includes("null") + ) { + return { ...schema, type: "boolean" }; + } + + // Handle array type with exactly number and null + if ( + Array.isArray(schema.type) && + schema.type.length === 2 && + schema.type.includes("number") && + schema.type.includes("null") + ) { + return { ...schema, type: "number" }; + } + + // Handle array type with exactly integer and null + if ( + Array.isArray(schema.type) && + schema.type.length === 2 && + schema.type.includes("integer") && + schema.type.includes("null") + ) { + return { ...schema, type: "integer" }; + } + + return schema; +} + /** * Formats a field key into a human-readable label * @param key The field key to format diff --git a/package-lock.json b/package-lock.json index e5757eb6..6103a16e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1999,9 +1999,9 @@ "link": true }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.0.tgz", - "integrity": "sha512-qFfbWFA7r1Sd8D697L7GkTd36yqDuTkvz0KfOGkgXR8EUhQn3/EDNIR/qUdQNMT8IjmasBvHWuXeisxtXTQT2g==", + "version": "1.17.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.2.tgz", + "integrity": "sha512-EFLRNXR/ixpXQWu6/3Cu30ndDFIFNaqUXcTqsGebujeMan9FzhAaFFswLRiFj61rgygDRr8WO1N+UijjgRxX9g==", "license": "MIT", "dependencies": { "ajv": "^6.12.6",