Merge branch 'bump-version' of https://github.com/olaservo/inspector into bump-version
This commit is contained in:
commit
2cf222a2ef
13 changed files with 399 additions and 32 deletions
|
|
@ -15,12 +15,22 @@ interface DynamicJsonFormProps {
|
||||||
maxDepth?: number;
|
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 isSimpleObject = (schema: JsonSchemaType): boolean => {
|
||||||
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
|
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") {
|
if (schema.type === "object") {
|
||||||
return Object.values(schema.properties ?? {}).every(
|
return Object.values(schema.properties ?? {}).every(
|
||||||
(prop) => prop.type && supportedTypes.includes(prop.type),
|
(prop) => prop.type && isTypeSupported(prop.type, supportedTypes),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (schema.type === "array") {
|
if (schema.type === "array") {
|
||||||
|
|
@ -181,7 +191,13 @@ const DynamicJsonForm = ({
|
||||||
const isRequired =
|
const isRequired =
|
||||||
parentSchema?.required?.includes(propertyName || "") ?? false;
|
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": {
|
case "string": {
|
||||||
if (
|
if (
|
||||||
propSchema.oneOf &&
|
propSchema.oneOf &&
|
||||||
|
|
@ -337,6 +353,8 @@ const DynamicJsonForm = ({
|
||||||
required={isRequired}
|
required={isRequired}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "null":
|
||||||
|
return null;
|
||||||
case "object":
|
case "object":
|
||||||
if (!propSchema.properties) {
|
if (!propSchema.properties) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,11 @@ import { TabsContent } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import DynamicJsonForm from "./DynamicJsonForm";
|
import DynamicJsonForm from "./DynamicJsonForm";
|
||||||
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||||
import { generateDefaultValue, isPropertyRequired } from "@/utils/schemaUtils";
|
import {
|
||||||
|
generateDefaultValue,
|
||||||
|
isPropertyRequired,
|
||||||
|
normalizeUnionType,
|
||||||
|
} from "@/utils/schemaUtils";
|
||||||
import {
|
import {
|
||||||
CompatibilityCallToolResult,
|
CompatibilityCallToolResult,
|
||||||
ListToolsResult,
|
ListToolsResult,
|
||||||
|
|
@ -104,7 +108,7 @@ const ToolsTab = ({
|
||||||
</p>
|
</p>
|
||||||
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
||||||
([key, value]) => {
|
([key, value]) => {
|
||||||
const prop = value as JsonSchemaType;
|
const prop = normalizeUnionType(value as JsonSchemaType);
|
||||||
const inputSchema =
|
const inputSchema =
|
||||||
selectedTool.inputSchema as JsonSchemaType;
|
selectedTool.inputSchema as JsonSchemaType;
|
||||||
const required = isPropertyRequired(key, inputSchema);
|
const required = isPropertyRequired(key, inputSchema);
|
||||||
|
|
@ -148,7 +152,10 @@ const ToolsTab = ({
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setParams({
|
setParams({
|
||||||
...params,
|
...params,
|
||||||
[key]: e.target.value,
|
[key]:
|
||||||
|
e.target.value === ""
|
||||||
|
? undefined
|
||||||
|
: e.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ const mockOAuthClientInfo = {
|
||||||
// Mock MCP SDK functions - must be before imports
|
// Mock MCP SDK functions - must be before imports
|
||||||
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||||
auth: jest.fn(),
|
auth: jest.fn(),
|
||||||
discoverOAuthMetadata: jest.fn(),
|
discoverAuthorizationServerMetadata: jest.fn(),
|
||||||
registerClient: jest.fn(),
|
registerClient: jest.fn(),
|
||||||
startAuthorization: jest.fn(),
|
startAuthorization: jest.fn(),
|
||||||
exchangeAuthorization: jest.fn(),
|
exchangeAuthorization: jest.fn(),
|
||||||
|
|
@ -46,7 +46,7 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||||
|
|
||||||
// Import the functions to get their types
|
// Import the functions to get their types
|
||||||
import {
|
import {
|
||||||
discoverOAuthMetadata,
|
discoverAuthorizationServerMetadata,
|
||||||
registerClient,
|
registerClient,
|
||||||
startAuthorization,
|
startAuthorization,
|
||||||
exchangeAuthorization,
|
exchangeAuthorization,
|
||||||
|
|
@ -57,9 +57,10 @@ import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types";
|
import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types";
|
||||||
|
|
||||||
// Type the mocked functions properly
|
// Type the mocked functions properly
|
||||||
const mockDiscoverOAuthMetadata = discoverOAuthMetadata as jest.MockedFunction<
|
const mockDiscoverAuthorizationServerMetadata =
|
||||||
typeof discoverOAuthMetadata
|
discoverAuthorizationServerMetadata as jest.MockedFunction<
|
||||||
>;
|
typeof discoverAuthorizationServerMetadata
|
||||||
|
>;
|
||||||
const mockRegisterClient = registerClient as jest.MockedFunction<
|
const mockRegisterClient = registerClient as jest.MockedFunction<
|
||||||
typeof registerClient
|
typeof registerClient
|
||||||
>;
|
>;
|
||||||
|
|
@ -102,7 +103,9 @@ describe("AuthDebugger", () => {
|
||||||
// Suppress console errors in tests to avoid JSDOM navigation noise
|
// Suppress console errors in tests to avoid JSDOM navigation noise
|
||||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
|
mockDiscoverAuthorizationServerMetadata.mockResolvedValue(
|
||||||
|
mockOAuthMetadata,
|
||||||
|
);
|
||||||
mockRegisterClient.mockResolvedValue(mockOAuthClientInfo);
|
mockRegisterClient.mockResolvedValue(mockOAuthClientInfo);
|
||||||
mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(
|
mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(
|
||||||
new Error("No protected resource metadata found"),
|
new Error("No protected resource metadata found"),
|
||||||
|
|
@ -203,7 +206,7 @@ describe("AuthDebugger", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should first discover and save OAuth metadata
|
// Should first discover and save OAuth metadata
|
||||||
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
|
expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith(
|
||||||
new URL("https://example.com/"),
|
new URL("https://example.com/"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -216,7 +219,7 @@ describe("AuthDebugger", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show error when quick OAuth flow fails to discover metadata", async () => {
|
it("should show error when quick OAuth flow fails to discover metadata", async () => {
|
||||||
mockDiscoverOAuthMetadata.mockRejectedValue(
|
mockDiscoverAuthorizationServerMetadata.mockRejectedValue(
|
||||||
new Error("Metadata discovery failed"),
|
new Error("Metadata discovery failed"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -362,7 +365,7 @@ describe("AuthDebugger", () => {
|
||||||
fireEvent.click(screen.getByText("Continue"));
|
fireEvent.click(screen.getByText("Continue"));
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
|
expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith(
|
||||||
new URL("https://example.com/"),
|
new URL("https://example.com/"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -509,7 +512,9 @@ describe("AuthDebugger", () => {
|
||||||
mockDiscoverOAuthProtectedResourceMetadata.mockResolvedValue(
|
mockDiscoverOAuthProtectedResourceMetadata.mockResolvedValue(
|
||||||
mockResourceMetadata,
|
mockResourceMetadata,
|
||||||
);
|
);
|
||||||
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
|
mockDiscoverAuthorizationServerMetadata.mockResolvedValue(
|
||||||
|
mockOAuthMetadata,
|
||||||
|
);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
renderAuthDebugger({
|
renderAuthDebugger({
|
||||||
|
|
@ -563,7 +568,9 @@ describe("AuthDebugger", () => {
|
||||||
// Mock failed metadata discovery
|
// Mock failed metadata discovery
|
||||||
mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(mockError);
|
mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(mockError);
|
||||||
// But OAuth metadata should still work with the original URL
|
// But OAuth metadata should still work with the original URL
|
||||||
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
|
mockDiscoverAuthorizationServerMetadata.mockResolvedValue(
|
||||||
|
mockOAuthMetadata,
|
||||||
|
);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
renderAuthDebugger({
|
renderAuthDebugger({
|
||||||
|
|
@ -603,7 +610,7 @@ describe("AuthDebugger", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify that regular OAuth metadata discovery was still called
|
// Verify that regular OAuth metadata discovery was still called
|
||||||
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
|
expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith(
|
||||||
new URL("https://example.com/"),
|
new URL("https://example.com/"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,18 @@ describe("DynamicJsonForm String Fields", () => {
|
||||||
const input = screen.getByRole("textbox");
|
const input = screen.getByRole("textbox");
|
||||||
expect(input).toHaveProperty("type", "text");
|
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(
|
||||||
|
<DynamicJsonForm schema={schema} value={null} onChange={jest.fn()} />,
|
||||||
|
);
|
||||||
|
const input = screen.getByRole("textbox");
|
||||||
|
expect(input).toHaveProperty("type", "text");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Format Support", () => {
|
describe("Format Support", () => {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
OAuthMetadata,
|
OAuthMetadata,
|
||||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
|
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
|
||||||
|
import { generateOAuthState } from "@/utils/oauthUtils";
|
||||||
|
|
||||||
export const getClientInformationFromSessionStorage = async ({
|
export const getClientInformationFromSessionStorage = async ({
|
||||||
serverUrl,
|
serverUrl,
|
||||||
|
|
@ -86,6 +87,10 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state(): string | Promise<string> {
|
||||||
|
return generateOAuthState();
|
||||||
|
}
|
||||||
|
|
||||||
async clientInformation() {
|
async clientInformation() {
|
||||||
// Try to get the preregistered client information from session storage first
|
// Try to get the preregistered client information from session storage first
|
||||||
const preregisteredClientInformation =
|
const preregisteredClientInformation =
|
||||||
|
|
@ -129,6 +134,12 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectToAuthorization(authorizationUrl: URL) {
|
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;
|
window.location.href = authorizationUrl.href;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -351,6 +351,7 @@ export function useConnection({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lastRequest = "";
|
||||||
try {
|
try {
|
||||||
// Inject auth manually instead of using SSEClientTransport, because we're
|
// Inject auth manually instead of using SSEClientTransport, because we're
|
||||||
// proxying through the inspector server first.
|
// proxying through the inspector server first.
|
||||||
|
|
@ -564,7 +565,9 @@ export function useConnection({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (capabilities?.logging && defaultLoggingLevel) {
|
if (capabilities?.logging && defaultLoggingLevel) {
|
||||||
|
lastRequest = "logging/setLevel";
|
||||||
await client.setLoggingLevel(defaultLoggingLevel);
|
await client.setLoggingLevel(defaultLoggingLevel);
|
||||||
|
lastRequest = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onElicitationRequest) {
|
if (onElicitationRequest) {
|
||||||
|
|
@ -578,6 +581,17 @@ export function useConnection({
|
||||||
setMcpClient(client);
|
setMcpClient(client);
|
||||||
setConnectionStatus("connected");
|
setConnectionStatus("connected");
|
||||||
} catch (e) {
|
} 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);
|
console.error(e);
|
||||||
setConnectionStatus("error");
|
setConnectionStatus("error");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { OAuthStep, AuthDebuggerState } from "./auth-types";
|
import { OAuthStep, AuthDebuggerState } from "./auth-types";
|
||||||
import { DebugInspectorOAuthClientProvider } from "./auth";
|
import { DebugInspectorOAuthClientProvider } from "./auth";
|
||||||
import {
|
import {
|
||||||
discoverOAuthMetadata,
|
discoverAuthorizationServerMetadata,
|
||||||
registerClient,
|
registerClient,
|
||||||
startAuthorization,
|
startAuthorization,
|
||||||
exchangeAuthorization,
|
exchangeAuthorization,
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
OAuthMetadataSchema,
|
OAuthMetadataSchema,
|
||||||
OAuthProtectedResourceMetadata,
|
OAuthProtectedResourceMetadata,
|
||||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
|
import { generateOAuthState } from "@/utils/oauthUtils";
|
||||||
|
|
||||||
export interface StateMachineContext {
|
export interface StateMachineContext {
|
||||||
state: AuthDebuggerState;
|
state: AuthDebuggerState;
|
||||||
|
|
@ -56,7 +57,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
||||||
resourceMetadata ?? undefined,
|
resourceMetadata ?? undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const metadata = await discoverOAuthMetadata(authServerUrl);
|
const metadata = await discoverAuthorizationServerMetadata(authServerUrl);
|
||||||
if (!metadata) {
|
if (!metadata) {
|
||||||
throw new Error("Failed to discover OAuth metadata");
|
throw new Error("Failed to discover OAuth metadata");
|
||||||
}
|
}
|
||||||
|
|
@ -113,13 +114,6 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
||||||
scope = metadata.scopes_supported.join(" ");
|
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(
|
const { authorizationUrl, codeVerifier } = await startAuthorization(
|
||||||
context.serverUrl,
|
context.serverUrl,
|
||||||
{
|
{
|
||||||
|
|
@ -127,7 +121,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
||||||
clientInformation,
|
clientInformation,
|
||||||
redirectUrl: context.provider.redirectUrl,
|
redirectUrl: context.provider.redirectUrl,
|
||||||
scope,
|
scope,
|
||||||
state: state,
|
state: generateOAuthState(),
|
||||||
resource: context.state.resource ?? undefined,
|
resource: context.state.resource ?? undefined,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
86
client/src/utils/__tests__/oauthUtils.test.ts
Normal file
86
client/src/utils/__tests__/oauthUtils.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import {
|
||||||
|
generateOAuthErrorDescription,
|
||||||
|
parseOAuthCallbackParams,
|
||||||
|
generateOAuthState,
|
||||||
|
} from "@/utils/oauthUtils.ts";
|
||||||
|
|
||||||
|
describe("parseOAuthCallbackParams", () => {
|
||||||
|
it("Returns successful: true and code when present", () => {
|
||||||
|
expect(parseOAuthCallbackParams("?code=fake-code")).toEqual({
|
||||||
|
successful: true,
|
||||||
|
code: "fake-code",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("Returns successful: false and error when error is present", () => {
|
||||||
|
expect(parseOAuthCallbackParams("?error=access_denied")).toEqual({
|
||||||
|
successful: false,
|
||||||
|
error: "access_denied",
|
||||||
|
error_description: null,
|
||||||
|
error_uri: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("Returns optional error metadata fields when present", () => {
|
||||||
|
const search =
|
||||||
|
"?error=access_denied&" +
|
||||||
|
"error_description=User%20Denied%20Request&" +
|
||||||
|
"error_uri=https%3A%2F%2Fexample.com%2Ferror-docs";
|
||||||
|
expect(parseOAuthCallbackParams(search)).toEqual({
|
||||||
|
successful: false,
|
||||||
|
error: "access_denied",
|
||||||
|
error_description: "User Denied Request",
|
||||||
|
error_uri: "https://example.com/error-docs",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("Returns error when nothing present", () => {
|
||||||
|
expect(parseOAuthCallbackParams("?")).toEqual({
|
||||||
|
successful: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "Missing code or error in response",
|
||||||
|
error_uri: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateOAuthErrorDescription", () => {
|
||||||
|
it("When only error is present", () => {
|
||||||
|
expect(
|
||||||
|
generateOAuthErrorDescription({
|
||||||
|
successful: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: null,
|
||||||
|
error_uri: null,
|
||||||
|
}),
|
||||||
|
).toBe("Error: invalid_request.");
|
||||||
|
});
|
||||||
|
it("When error description is present", () => {
|
||||||
|
expect(
|
||||||
|
generateOAuthErrorDescription({
|
||||||
|
successful: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "The request could not be completed as dialed",
|
||||||
|
error_uri: null,
|
||||||
|
}),
|
||||||
|
).toEqual(
|
||||||
|
"Error: invalid_request.\nDetails: The request could not be completed as dialed.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("When all fields present", () => {
|
||||||
|
expect(
|
||||||
|
generateOAuthErrorDescription({
|
||||||
|
successful: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "The request could not be completed as dialed",
|
||||||
|
error_uri: "https://example.com/error-docs",
|
||||||
|
}),
|
||||||
|
).toEqual(
|
||||||
|
"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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
generateDefaultValue,
|
generateDefaultValue,
|
||||||
formatFieldLabel,
|
formatFieldLabel,
|
||||||
|
normalizeUnionType,
|
||||||
cacheToolOutputSchemas,
|
cacheToolOutputSchemas,
|
||||||
getToolOutputValidator,
|
getToolOutputValidator,
|
||||||
validateToolOutput,
|
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", () => {
|
describe("Output Schema Validation", () => {
|
||||||
const mockTools: Tool[] = [
|
const mockTools: Tool[] = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,16 @@ export type JsonSchemaType = {
|
||||||
| "boolean"
|
| "boolean"
|
||||||
| "array"
|
| "array"
|
||||||
| "object"
|
| "object"
|
||||||
| "null";
|
| "null"
|
||||||
|
| (
|
||||||
|
| "string"
|
||||||
|
| "number"
|
||||||
|
| "integer"
|
||||||
|
| "boolean"
|
||||||
|
| "array"
|
||||||
|
| "object"
|
||||||
|
| "null"
|
||||||
|
)[];
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
required?: string[];
|
required?: string[];
|
||||||
|
|
|
||||||
|
|
@ -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 = (
|
export const generateOAuthErrorDescription = (
|
||||||
params: Extract<CallbackParams, { successful: false }>,
|
params: Extract<CallbackParams, { successful: false }>,
|
||||||
): string => {
|
): string => {
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,95 @@ export function isPropertyRequired(
|
||||||
return schema.required?.includes(propertyName) ?? false;
|
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
|
* Formats a field key into a human-readable label
|
||||||
* @param key The field key to format
|
* @param key The field key to format
|
||||||
|
|
|
||||||
6
package-lock.json
generated
6
package-lock.json
generated
|
|
@ -1999,9 +1999,9 @@
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.17.0",
|
"version": "1.17.2",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.2.tgz",
|
||||||
"integrity": "sha512-qFfbWFA7r1Sd8D697L7GkTd36yqDuTkvz0KfOGkgXR8EUhQn3/EDNIR/qUdQNMT8IjmasBvHWuXeisxtXTQT2g==",
|
"integrity": "sha512-EFLRNXR/ixpXQWu6/3Cu30ndDFIFNaqUXcTqsGebujeMan9FzhAaFFswLRiFj61rgygDRr8WO1N+UijjgRxX9g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^6.12.6",
|
"ajv": "^6.12.6",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue