Merge branch 'main' into main
This commit is contained in:
commit
9eb5e7b8c2
11 changed files with 1321 additions and 306 deletions
|
|
@ -16,10 +16,39 @@ interface DynamicJsonFormProps {
|
|||
const isSimpleObject = (schema: JsonSchemaType): boolean => {
|
||||
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
|
||||
if (supportedTypes.includes(schema.type)) return true;
|
||||
if (schema.type !== "object") return false;
|
||||
return Object.values(schema.properties ?? {}).every((prop) =>
|
||||
supportedTypes.includes(prop.type),
|
||||
);
|
||||
if (schema.type === "object") {
|
||||
return Object.values(schema.properties ?? {}).every((prop) =>
|
||||
supportedTypes.includes(prop.type),
|
||||
);
|
||||
}
|
||||
if (schema.type === "array") {
|
||||
return !!schema.items && isSimpleObject(schema.items);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const getArrayItemDefault = (schema: JsonSchemaType): JsonValue => {
|
||||
if ("default" in schema && schema.default !== undefined) {
|
||||
return schema.default;
|
||||
}
|
||||
|
||||
switch (schema.type) {
|
||||
case "string":
|
||||
return "";
|
||||
case "number":
|
||||
case "integer":
|
||||
return 0;
|
||||
case "boolean":
|
||||
return false;
|
||||
case "array":
|
||||
return [];
|
||||
case "object":
|
||||
return {};
|
||||
case "null":
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const DynamicJsonForm = ({
|
||||
|
|
@ -113,6 +142,8 @@ const DynamicJsonForm = ({
|
|||
currentValue: JsonValue,
|
||||
path: string[] = [],
|
||||
depth: number = 0,
|
||||
parentSchema?: JsonSchemaType,
|
||||
propertyName?: string,
|
||||
) => {
|
||||
if (
|
||||
depth >= maxDepth &&
|
||||
|
|
@ -122,7 +153,8 @@ const DynamicJsonForm = ({
|
|||
return (
|
||||
<JsonEditor
|
||||
value={JSON.stringify(
|
||||
currentValue ?? generateDefaultValue(propSchema),
|
||||
currentValue ??
|
||||
generateDefaultValue(propSchema, propertyName, parentSchema),
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
|
|
@ -140,6 +172,10 @@ const DynamicJsonForm = ({
|
|||
);
|
||||
}
|
||||
|
||||
// Check if this property is required in the parent schema
|
||||
const isRequired =
|
||||
parentSchema?.required?.includes(propertyName || "") ?? false;
|
||||
|
||||
switch (propSchema.type) {
|
||||
case "string":
|
||||
return (
|
||||
|
|
@ -148,16 +184,11 @@ const DynamicJsonForm = ({
|
|||
value={(currentValue as string) ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
// Allow clearing non-required fields by setting undefined
|
||||
// This preserves the distinction between empty string and unset
|
||||
if (!val && !propSchema.required) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
handleFieldChange(path, val);
|
||||
}
|
||||
// Always allow setting string values, including empty strings
|
||||
handleFieldChange(path, val);
|
||||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={propSchema.required}
|
||||
required={isRequired}
|
||||
/>
|
||||
);
|
||||
case "number":
|
||||
|
|
@ -167,9 +198,7 @@ const DynamicJsonForm = ({
|
|||
value={(currentValue as number)?.toString() ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
// Allow clearing non-required number fields
|
||||
// This preserves the distinction between 0 and unset
|
||||
if (!val && !propSchema.required) {
|
||||
if (!val && !isRequired) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
const num = Number(val);
|
||||
|
|
@ -179,7 +208,7 @@ const DynamicJsonForm = ({
|
|||
}
|
||||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={propSchema.required}
|
||||
required={isRequired}
|
||||
/>
|
||||
);
|
||||
case "integer":
|
||||
|
|
@ -190,9 +219,7 @@ const DynamicJsonForm = ({
|
|||
value={(currentValue as number)?.toString() ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
// Allow clearing non-required integer fields
|
||||
// This preserves the distinction between 0 and unset
|
||||
if (!val && !propSchema.required) {
|
||||
if (!val && !isRequired) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
const num = Number(val);
|
||||
|
|
@ -203,7 +230,7 @@ const DynamicJsonForm = ({
|
|||
}
|
||||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={propSchema.required}
|
||||
required={isRequired}
|
||||
/>
|
||||
);
|
||||
case "boolean":
|
||||
|
|
@ -213,9 +240,135 @@ const DynamicJsonForm = ({
|
|||
checked={(currentValue as boolean) ?? false}
|
||||
onChange={(e) => handleFieldChange(path, e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
required={propSchema.required}
|
||||
required={isRequired}
|
||||
/>
|
||||
);
|
||||
case "object":
|
||||
if (!propSchema.properties) {
|
||||
return (
|
||||
<JsonEditor
|
||||
value={JSON.stringify(currentValue ?? {}, null, 2)}
|
||||
onChange={(newValue) => {
|
||||
try {
|
||||
const parsed = JSON.parse(newValue);
|
||||
handleFieldChange(path, parsed);
|
||||
setJsonError(undefined);
|
||||
} catch (err) {
|
||||
setJsonError(
|
||||
err instanceof Error ? err.message : "Invalid JSON",
|
||||
);
|
||||
}
|
||||
}}
|
||||
error={jsonError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border rounded p-3">
|
||||
{Object.entries(propSchema.properties).map(([key, subSchema]) => (
|
||||
<div key={key}>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{key}
|
||||
{propSchema.required?.includes(key) && (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
{renderFormFields(
|
||||
subSchema as JsonSchemaType,
|
||||
(currentValue as Record<string, JsonValue>)?.[key],
|
||||
[...path, key],
|
||||
depth + 1,
|
||||
propSchema,
|
||||
key,
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
case "array": {
|
||||
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
|
||||
if (!propSchema.items) return null;
|
||||
|
||||
// If the array items are simple, render as form fields, otherwise use JSON editor
|
||||
if (isSimpleObject(propSchema.items)) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{propSchema.description && (
|
||||
<p className="text-sm text-gray-600">
|
||||
{propSchema.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{propSchema.items?.description && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Items: {propSchema.items.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{arrayValue.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
{renderFormFields(
|
||||
propSchema.items as JsonSchemaType,
|
||||
item,
|
||||
[...path, index.toString()],
|
||||
depth + 1,
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newArray = [...arrayValue];
|
||||
newArray.splice(index, 1);
|
||||
handleFieldChange(path, newArray);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const defaultValue = getArrayItemDefault(
|
||||
propSchema.items as JsonSchemaType,
|
||||
);
|
||||
handleFieldChange(path, [...arrayValue, defaultValue]);
|
||||
}}
|
||||
title={
|
||||
propSchema.items?.description
|
||||
? `Add new ${propSchema.items.description}`
|
||||
: "Add new item"
|
||||
}
|
||||
>
|
||||
Add Item
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For complex arrays, fall back to JSON editor
|
||||
return (
|
||||
<JsonEditor
|
||||
value={JSON.stringify(currentValue ?? [], null, 2)}
|
||||
onChange={(newValue) => {
|
||||
try {
|
||||
const parsed = JSON.parse(newValue);
|
||||
handleFieldChange(path, parsed);
|
||||
setJsonError(undefined);
|
||||
} catch (err) {
|
||||
setJsonError(
|
||||
err instanceof Error ? err.message : "Invalid JSON",
|
||||
);
|
||||
}
|
||||
}}
|
||||
error={jsonError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ export const OAuthFlowProgress = ({
|
|||
{authState.resourceMetadataError && (
|
||||
<div className="mt-2 p-3 border border-blue-300 bg-blue-50 rounded-md">
|
||||
<p className="text-sm font-medium text-blue-700">
|
||||
ℹ️ No resource metadata available from{" "}
|
||||
ℹ️ Problem with resource metadata from{" "}
|
||||
<a
|
||||
href={
|
||||
new URL(
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ 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 } from "@/utils/schemaUtils";
|
||||
import { generateDefaultValue, isPropertyRequired } from "@/utils/schemaUtils";
|
||||
import {
|
||||
CompatibilityCallToolResult,
|
||||
ListToolsResult,
|
||||
|
|
@ -48,7 +48,11 @@ const ToolsTab = ({
|
|||
selectedTool?.inputSchema.properties ?? [],
|
||||
).map(([key, value]) => [
|
||||
key,
|
||||
generateDefaultValue(value as JsonSchemaType),
|
||||
generateDefaultValue(
|
||||
value as JsonSchemaType,
|
||||
key,
|
||||
selectedTool?.inputSchema as JsonSchemaType,
|
||||
),
|
||||
]);
|
||||
setParams(Object.fromEntries(params));
|
||||
}, [selectedTool]);
|
||||
|
|
@ -92,6 +96,9 @@ const ToolsTab = ({
|
|||
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
||||
([key, value]) => {
|
||||
const prop = value as JsonSchemaType;
|
||||
const inputSchema =
|
||||
selectedTool.inputSchema as JsonSchemaType;
|
||||
const required = isPropertyRequired(key, inputSchema);
|
||||
return (
|
||||
<div key={key}>
|
||||
<Label
|
||||
|
|
@ -99,6 +106,9 @@ const ToolsTab = ({
|
|||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{key}
|
||||
{required && (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
)}
|
||||
</Label>
|
||||
{prop.type === "boolean" ? (
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
|||
startAuthorization: jest.fn(),
|
||||
exchangeAuthorization: jest.fn(),
|
||||
discoverOAuthProtectedResourceMetadata: jest.fn(),
|
||||
selectResourceURL: jest.fn(),
|
||||
}));
|
||||
|
||||
// Import the functions to get their types
|
||||
|
|
@ -88,7 +89,7 @@ describe("AuthDebugger", () => {
|
|||
const defaultAuthState = EMPTY_DEBUGGER_STATE;
|
||||
|
||||
const defaultProps = {
|
||||
serverUrl: "https://example.com",
|
||||
serverUrl: "https://example.com/mcp",
|
||||
onBack: jest.fn(),
|
||||
authState: defaultAuthState,
|
||||
updateAuthState: jest.fn(),
|
||||
|
|
@ -203,7 +204,7 @@ describe("AuthDebugger", () => {
|
|||
|
||||
// Should first discover and save OAuth metadata
|
||||
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
|
||||
new URL("https://example.com"),
|
||||
new URL("https://example.com/"),
|
||||
);
|
||||
|
||||
// Check that updateAuthState was called with the right info message
|
||||
|
|
@ -320,6 +321,7 @@ describe("AuthDebugger", () => {
|
|||
isInitiatingAuth: false,
|
||||
resourceMetadata: null,
|
||||
resourceMetadataError: null,
|
||||
resource: null,
|
||||
oauthTokens: null,
|
||||
oauthStep: "metadata_discovery",
|
||||
latestError: null,
|
||||
|
|
@ -361,7 +363,7 @@ describe("AuthDebugger", () => {
|
|||
});
|
||||
|
||||
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
|
||||
new URL("https://example.com"),
|
||||
new URL("https://example.com/"),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -496,11 +498,11 @@ describe("AuthDebugger", () => {
|
|||
it("should successfully fetch and display protected resource metadata", async () => {
|
||||
const updateAuthState = jest.fn();
|
||||
const mockResourceMetadata = {
|
||||
resource: "https://example.com/api",
|
||||
resource: "https://example.com/mcp",
|
||||
authorization_servers: ["https://custom-auth.example.com"],
|
||||
bearer_methods_supported: ["header", "body"],
|
||||
resource_documentation: "https://example.com/api/docs",
|
||||
resource_policy_uri: "https://example.com/api/policy",
|
||||
resource_documentation: "https://example.com/mcp/docs",
|
||||
resource_policy_uri: "https://example.com/mcp/policy",
|
||||
};
|
||||
|
||||
// Mock successful metadata discovery
|
||||
|
|
@ -538,7 +540,7 @@ describe("AuthDebugger", () => {
|
|||
// Wait for the metadata to be fetched
|
||||
await waitFor(() => {
|
||||
expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith(
|
||||
"https://example.com",
|
||||
"https://example.com/mcp",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -584,7 +586,7 @@ describe("AuthDebugger", () => {
|
|||
// Wait for the metadata fetch to fail
|
||||
await waitFor(() => {
|
||||
expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith(
|
||||
"https://example.com",
|
||||
"https://example.com/mcp",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -594,7 +596,7 @@ describe("AuthDebugger", () => {
|
|||
expect.objectContaining({
|
||||
resourceMetadataError: mockError,
|
||||
// Should use the original server URL as fallback
|
||||
authServerUrl: new URL("https://example.com"),
|
||||
authServerUrl: new URL("https://example.com/"),
|
||||
oauthStep: "client_registration",
|
||||
}),
|
||||
);
|
||||
|
|
@ -602,7 +604,7 @@ describe("AuthDebugger", () => {
|
|||
|
||||
// Verify that regular OAuth metadata discovery was still called
|
||||
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
|
||||
new URL("https://example.com"),
|
||||
new URL("https://example.com/"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
256
client/src/components/__tests__/DynamicJsonForm.array.test.tsx
Normal file
256
client/src/components/__tests__/DynamicJsonForm.array.test.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, jest } from "@jest/globals";
|
||||
import DynamicJsonForm from "../DynamicJsonForm";
|
||||
import type { JsonSchemaType } from "@/utils/jsonUtils";
|
||||
|
||||
describe("DynamicJsonForm Array Fields", () => {
|
||||
const renderSimpleArrayForm = (props = {}) => {
|
||||
const defaultProps = {
|
||||
schema: {
|
||||
type: "array" as const,
|
||||
description: "Test array field",
|
||||
items: {
|
||||
type: "string" as const,
|
||||
description: "Array item",
|
||||
},
|
||||
} satisfies JsonSchemaType,
|
||||
value: [],
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
return render(<DynamicJsonForm {...defaultProps} {...props} />);
|
||||
};
|
||||
|
||||
const renderComplexArrayForm = (props = {}) => {
|
||||
const defaultProps = {
|
||||
schema: {
|
||||
type: "array" as const,
|
||||
description: "Test complex array field",
|
||||
items: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
nested: { type: "object" as const },
|
||||
},
|
||||
},
|
||||
} satisfies JsonSchemaType,
|
||||
value: [],
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
return render(<DynamicJsonForm {...defaultProps} {...props} />);
|
||||
};
|
||||
|
||||
describe("Simple Array Rendering", () => {
|
||||
it("should render form fields for simple array items", () => {
|
||||
renderSimpleArrayForm({ value: ["item1", "item2"] });
|
||||
|
||||
// Should show array description
|
||||
expect(screen.getByText("Test array field")).toBeDefined();
|
||||
expect(screen.getByText("Items: Array item")).toBeDefined();
|
||||
|
||||
// Should show input fields for each item
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(inputs[0]).toHaveProperty("value", "item1");
|
||||
expect(inputs[1]).toHaveProperty("value", "item2");
|
||||
|
||||
// Should show remove buttons
|
||||
const removeButtons = screen.getAllByText("Remove");
|
||||
expect(removeButtons).toHaveLength(2);
|
||||
|
||||
// Should show add button
|
||||
expect(screen.getByText("Add Item")).toBeDefined();
|
||||
});
|
||||
|
||||
it("should add new items when Add Item button is clicked", () => {
|
||||
const onChange = jest.fn();
|
||||
renderSimpleArrayForm({ value: ["item1"], onChange });
|
||||
|
||||
const addButton = screen.getByText("Add Item");
|
||||
fireEvent.click(addButton);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["item1", ""]);
|
||||
});
|
||||
|
||||
it("should remove items when Remove button is clicked", () => {
|
||||
const onChange = jest.fn();
|
||||
renderSimpleArrayForm({ value: ["item1", "item2"], onChange });
|
||||
|
||||
const removeButtons = screen.getAllByText("Remove");
|
||||
fireEvent.click(removeButtons[0]);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["item2"]);
|
||||
});
|
||||
|
||||
it("should update item values when input changes", () => {
|
||||
const onChange = jest.fn();
|
||||
renderSimpleArrayForm({ value: ["item1"], onChange });
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "updated item" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["updated item"]);
|
||||
});
|
||||
|
||||
it("should handle empty arrays", () => {
|
||||
renderSimpleArrayForm({ value: [] });
|
||||
|
||||
// Should show description and add button but no items
|
||||
expect(screen.getByText("Test array field")).toBeDefined();
|
||||
expect(screen.getByText("Add Item")).toBeDefined();
|
||||
expect(screen.queryByText("Remove")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Complex Array Fallback", () => {
|
||||
it("should render JSON editor for complex arrays", () => {
|
||||
renderComplexArrayForm();
|
||||
|
||||
// Should render as JSON editor (textarea)
|
||||
const textarea = screen.getByRole("textbox");
|
||||
expect(textarea).toHaveProperty("type", "textarea");
|
||||
|
||||
// Should not show form-specific array controls
|
||||
expect(screen.queryByText("Add Item")).toBeNull();
|
||||
expect(screen.queryByText("Remove")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Array Type Detection", () => {
|
||||
it("should detect string arrays as simple", () => {
|
||||
const schema = {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const },
|
||||
};
|
||||
renderSimpleArrayForm({ schema, value: ["test"] });
|
||||
|
||||
// Should render form fields, not JSON editor
|
||||
expect(screen.getByRole("textbox")).not.toHaveProperty(
|
||||
"type",
|
||||
"textarea",
|
||||
);
|
||||
});
|
||||
|
||||
it("should detect number arrays as simple", () => {
|
||||
const schema = {
|
||||
type: "array" as const,
|
||||
items: { type: "number" as const },
|
||||
};
|
||||
renderSimpleArrayForm({ schema, value: [1, 2] });
|
||||
|
||||
// Should render form fields (number inputs)
|
||||
const inputs = screen.getAllByRole("spinbutton");
|
||||
expect(inputs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should detect boolean arrays as simple", () => {
|
||||
const schema = {
|
||||
type: "array" as const,
|
||||
items: { type: "boolean" as const },
|
||||
};
|
||||
renderSimpleArrayForm({ schema, value: [true, false] });
|
||||
|
||||
// Should render form fields (checkboxes)
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
expect(checkboxes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should detect simple object arrays as simple", () => {
|
||||
const schema = {
|
||||
type: "array" as const,
|
||||
items: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
name: { type: "string" as const },
|
||||
age: { type: "number" as const },
|
||||
},
|
||||
},
|
||||
};
|
||||
renderSimpleArrayForm({ schema, value: [{ name: "John", age: 30 }] });
|
||||
|
||||
// Should render form fields for simple objects
|
||||
expect(screen.getByText("Add Item")).toBeDefined();
|
||||
expect(screen.getByText("Remove")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Array with Different Item Types", () => {
|
||||
it("should handle integer array items", () => {
|
||||
const schema = {
|
||||
type: "array" as const,
|
||||
items: { type: "integer" as const },
|
||||
};
|
||||
const onChange = jest.fn();
|
||||
renderSimpleArrayForm({ schema, value: [1, 2], onChange });
|
||||
|
||||
const inputs = screen.getAllByRole("spinbutton");
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(inputs[0]).toHaveProperty("value", "1");
|
||||
expect(inputs[1]).toHaveProperty("value", "2");
|
||||
|
||||
// Test adding new integer item
|
||||
const addButton = screen.getByText("Add Item");
|
||||
fireEvent.click(addButton);
|
||||
expect(onChange).toHaveBeenCalledWith([1, 2, 0]);
|
||||
});
|
||||
|
||||
it("should handle boolean array items", () => {
|
||||
const schema = {
|
||||
type: "array" as const,
|
||||
items: { type: "boolean" as const },
|
||||
};
|
||||
const onChange = jest.fn();
|
||||
renderSimpleArrayForm({ schema, value: [true, false], onChange });
|
||||
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
expect(checkboxes).toHaveLength(2);
|
||||
expect(checkboxes[0]).toHaveProperty("checked", true);
|
||||
expect(checkboxes[1]).toHaveProperty("checked", false);
|
||||
|
||||
// Test adding new boolean item
|
||||
const addButton = screen.getByText("Add Item");
|
||||
fireEvent.click(addButton);
|
||||
expect(onChange).toHaveBeenCalledWith([true, false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Array Item Descriptions", () => {
|
||||
it("should show item description when available", () => {
|
||||
const schema = {
|
||||
type: "array" as const,
|
||||
description: "List of names",
|
||||
items: {
|
||||
type: "string" as const,
|
||||
description: "Person name",
|
||||
},
|
||||
};
|
||||
renderSimpleArrayForm({ schema });
|
||||
|
||||
expect(screen.getByText("List of names")).toBeDefined();
|
||||
expect(screen.getByText("Items: Person name")).toBeDefined();
|
||||
});
|
||||
|
||||
it("should use item description in add button title", () => {
|
||||
const schema = {
|
||||
type: "array" as const,
|
||||
items: {
|
||||
type: "string" as const,
|
||||
description: "Email address",
|
||||
},
|
||||
};
|
||||
renderSimpleArrayForm({ schema });
|
||||
|
||||
const addButton = screen.getByText("Add Item");
|
||||
expect(addButton).toHaveProperty("title", "Add new Email address");
|
||||
});
|
||||
|
||||
it("should use default title when no item description", () => {
|
||||
const schema = {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const },
|
||||
};
|
||||
renderSimpleArrayForm({ schema });
|
||||
|
||||
const addButton = screen.getByText("Add Item");
|
||||
expect(addButton).toHaveProperty("title", "Add new item");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -30,6 +30,7 @@ export interface AuthDebuggerState {
|
|||
oauthStep: OAuthStep;
|
||||
resourceMetadata: OAuthProtectedResourceMetadata | null;
|
||||
resourceMetadataError: Error | null;
|
||||
resource: URL | null;
|
||||
authServerUrl: URL | null;
|
||||
oauthMetadata: OAuthMetadata | null;
|
||||
oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null;
|
||||
|
|
@ -47,6 +48,7 @@ export const EMPTY_DEBUGGER_STATE: AuthDebuggerState = {
|
|||
oauthMetadata: null,
|
||||
resourceMetadata: null,
|
||||
resourceMetadataError: null,
|
||||
resource: null,
|
||||
authServerUrl: null,
|
||||
oauthClientInfo: null,
|
||||
authorizationUrl: null,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
startAuthorization,
|
||||
exchangeAuthorization,
|
||||
discoverOAuthProtectedResourceMetadata,
|
||||
selectResourceURL,
|
||||
} from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import {
|
||||
OAuthMetadataSchema,
|
||||
|
|
@ -29,17 +30,15 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
metadata_discovery: {
|
||||
canTransition: async () => true,
|
||||
execute: async (context) => {
|
||||
let authServerUrl = new URL(context.serverUrl);
|
||||
// Default to discovering from the server's URL
|
||||
let authServerUrl = new URL("/", context.serverUrl);
|
||||
let resourceMetadata: OAuthProtectedResourceMetadata | null = null;
|
||||
let resourceMetadataError: Error | null = null;
|
||||
try {
|
||||
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
|
||||
context.serverUrl,
|
||||
);
|
||||
if (
|
||||
resourceMetadata &&
|
||||
resourceMetadata.authorization_servers?.length
|
||||
) {
|
||||
if (resourceMetadata?.authorization_servers?.length) {
|
||||
authServerUrl = new URL(resourceMetadata.authorization_servers[0]);
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -50,6 +49,13 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
}
|
||||
}
|
||||
|
||||
const resource: URL | undefined = await selectResourceURL(
|
||||
context.serverUrl,
|
||||
context.provider,
|
||||
// we default to null, so swap it for undefined if not set
|
||||
resourceMetadata ?? undefined,
|
||||
);
|
||||
|
||||
const metadata = await discoverOAuthMetadata(authServerUrl);
|
||||
if (!metadata) {
|
||||
throw new Error("Failed to discover OAuth metadata");
|
||||
|
|
@ -58,6 +64,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
context.provider.saveServerMetadata(parsedMetadata);
|
||||
context.updateState({
|
||||
resourceMetadata,
|
||||
resource,
|
||||
resourceMetadataError,
|
||||
authServerUrl,
|
||||
oauthMetadata: parsedMetadata,
|
||||
|
|
@ -113,6 +120,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
clientInformation,
|
||||
redirectUrl: context.provider.redirectUrl,
|
||||
scope,
|
||||
resource: context.state.resource ?? undefined,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -163,6 +171,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
authorizationCode: context.state.authorizationCode,
|
||||
codeVerifier,
|
||||
redirectUri: context.provider.redirectUrl,
|
||||
resource: context.state.resource ?? undefined,
|
||||
});
|
||||
|
||||
context.provider.saveTokens(tokens);
|
||||
|
|
|
|||
|
|
@ -11,72 +11,68 @@ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|||
|
||||
describe("generateDefaultValue", () => {
|
||||
test("generates default string", () => {
|
||||
expect(generateDefaultValue({ type: "string", required: true })).toBe("");
|
||||
const parentSchema = { type: "object" as const, required: ["testProp"] };
|
||||
expect(
|
||||
generateDefaultValue({ type: "string" }, "testProp", parentSchema),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
test("generates default number", () => {
|
||||
expect(generateDefaultValue({ type: "number", required: true })).toBe(0);
|
||||
const parentSchema = { type: "object" as const, required: ["testProp"] };
|
||||
expect(
|
||||
generateDefaultValue({ type: "number" }, "testProp", parentSchema),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test("generates default integer", () => {
|
||||
expect(generateDefaultValue({ type: "integer", required: true })).toBe(0);
|
||||
const parentSchema = { type: "object" as const, required: ["testProp"] };
|
||||
expect(
|
||||
generateDefaultValue({ type: "integer" }, "testProp", parentSchema),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test("generates default boolean", () => {
|
||||
expect(generateDefaultValue({ type: "boolean", required: true })).toBe(
|
||||
false,
|
||||
);
|
||||
const parentSchema = { type: "object" as const, required: ["testProp"] };
|
||||
expect(
|
||||
generateDefaultValue({ type: "boolean" }, "testProp", parentSchema),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("generates default array", () => {
|
||||
expect(generateDefaultValue({ type: "array", required: true })).toEqual([]);
|
||||
expect(generateDefaultValue({ type: "array" })).toEqual([]);
|
||||
});
|
||||
|
||||
test("generates default empty object", () => {
|
||||
expect(generateDefaultValue({ type: "object", required: true })).toEqual(
|
||||
{},
|
||||
);
|
||||
expect(generateDefaultValue({ type: "object" })).toEqual({});
|
||||
});
|
||||
|
||||
test("generates default null for unknown types", () => {
|
||||
// @ts-expect-error Testing with invalid type
|
||||
expect(generateDefaultValue({ type: "unknown", required: true })).toBe(
|
||||
null,
|
||||
);
|
||||
expect(generateDefaultValue({ type: "unknown" })).toBe(undefined);
|
||||
});
|
||||
|
||||
test("generates empty array for non-required array", () => {
|
||||
expect(generateDefaultValue({ type: "array", required: false })).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(generateDefaultValue({ type: "array" })).toEqual([]);
|
||||
});
|
||||
|
||||
test("generates empty object for non-required object", () => {
|
||||
expect(generateDefaultValue({ type: "object", required: false })).toEqual(
|
||||
{},
|
||||
);
|
||||
expect(generateDefaultValue({ type: "object" })).toEqual({});
|
||||
});
|
||||
|
||||
test("generates null for non-required primitive types", () => {
|
||||
expect(generateDefaultValue({ type: "string", required: false })).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(generateDefaultValue({ type: "number", required: false })).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(generateDefaultValue({ type: "boolean", required: false })).toBe(
|
||||
undefined,
|
||||
);
|
||||
test("generates undefined for non-required primitive types", () => {
|
||||
expect(generateDefaultValue({ type: "string" })).toBe(undefined);
|
||||
expect(generateDefaultValue({ type: "number" })).toBe(undefined);
|
||||
expect(generateDefaultValue({ type: "boolean" })).toBe(undefined);
|
||||
});
|
||||
|
||||
test("generates object with properties", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "object",
|
||||
required: true,
|
||||
required: ["name", "age", "isActive"],
|
||||
properties: {
|
||||
name: { type: "string", required: true },
|
||||
age: { type: "number", required: true },
|
||||
isActive: { type: "boolean", required: true },
|
||||
name: { type: "string" },
|
||||
age: { type: "number" },
|
||||
isActive: { type: "boolean" },
|
||||
},
|
||||
};
|
||||
expect(generateDefaultValue(schema)).toEqual({
|
||||
|
|
@ -89,18 +85,18 @@ describe("generateDefaultValue", () => {
|
|||
test("handles nested objects", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "object",
|
||||
required: true,
|
||||
required: ["user"],
|
||||
properties: {
|
||||
user: {
|
||||
type: "object",
|
||||
required: true,
|
||||
required: ["name", "address"],
|
||||
properties: {
|
||||
name: { type: "string", required: true },
|
||||
name: { type: "string" },
|
||||
address: {
|
||||
type: "object",
|
||||
required: true,
|
||||
required: ["city"],
|
||||
properties: {
|
||||
city: { type: "string", required: true },
|
||||
city: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,12 +4,26 @@ import {
|
|||
DEFAULT_INSPECTOR_CONFIG,
|
||||
} from "@/lib/constants";
|
||||
|
||||
const getSearchParam = (key: string): string | null => {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.get(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getMCPProxyAddress = (config: InspectorConfig): string => {
|
||||
const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS.value as string;
|
||||
if (proxyFullAddress) {
|
||||
return proxyFullAddress;
|
||||
}
|
||||
return `${window.location.protocol}//${window.location.hostname}:${DEFAULT_MCP_PROXY_LISTEN_PORT}`;
|
||||
|
||||
// Check for proxy port from query params, fallback to default
|
||||
const proxyPort =
|
||||
getSearchParam("MCP_PROXY_PORT") || DEFAULT_MCP_PROXY_LISTEN_PORT;
|
||||
|
||||
return `${window.location.protocol}//${window.location.hostname}:${proxyPort}`;
|
||||
};
|
||||
|
||||
export const getMCPServerRequestTimeout = (config: InspectorConfig): number => {
|
||||
|
|
@ -40,15 +54,6 @@ export const getMCPProxyAuthToken = (
|
|||
};
|
||||
};
|
||||
|
||||
const getSearchParam = (key: string): string | null => {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.get(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getInitialTransportType = ():
|
||||
| "stdio"
|
||||
| "sse"
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export type JsonSchemaType = {
|
|||
| "object"
|
||||
| "null";
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
required?: string[];
|
||||
default?: JsonValue;
|
||||
properties?: Record<string, JsonSchemaType>;
|
||||
items?: JsonSchemaType;
|
||||
|
|
|
|||
|
|
@ -81,46 +81,70 @@ export function hasOutputSchema(toolName: string): boolean {
|
|||
/**
|
||||
* Generates a default value based on a JSON schema type
|
||||
* @param schema The JSON schema definition
|
||||
* @returns A default value matching the schema type, or null for non-required fields
|
||||
* @param propertyName Optional property name for checking if it's required in parent schema
|
||||
* @param parentSchema Optional parent schema to check required array
|
||||
* @returns A default value matching the schema type
|
||||
*/
|
||||
export function generateDefaultValue(schema: JsonSchemaType): JsonValue {
|
||||
if ("default" in schema) {
|
||||
export function generateDefaultValue(
|
||||
schema: JsonSchemaType,
|
||||
propertyName?: string,
|
||||
parentSchema?: JsonSchemaType,
|
||||
): JsonValue {
|
||||
if ("default" in schema && schema.default !== undefined) {
|
||||
return schema.default;
|
||||
}
|
||||
|
||||
if (!schema.required) {
|
||||
if (schema.type === "array") return [];
|
||||
if (schema.type === "object") return {};
|
||||
return undefined;
|
||||
}
|
||||
// Check if this property is required in the parent schema
|
||||
const isRequired =
|
||||
propertyName && parentSchema
|
||||
? isPropertyRequired(propertyName, parentSchema)
|
||||
: false;
|
||||
|
||||
switch (schema.type) {
|
||||
case "string":
|
||||
return "";
|
||||
return isRequired ? "" : undefined;
|
||||
case "number":
|
||||
case "integer":
|
||||
return 0;
|
||||
return isRequired ? 0 : undefined;
|
||||
case "boolean":
|
||||
return false;
|
||||
return isRequired ? false : undefined;
|
||||
case "array":
|
||||
return [];
|
||||
case "object": {
|
||||
if (!schema.properties) return {};
|
||||
|
||||
const obj: JsonObject = {};
|
||||
Object.entries(schema.properties)
|
||||
.filter(([, prop]) => prop.required)
|
||||
.forEach(([key, prop]) => {
|
||||
const value = generateDefaultValue(prop);
|
||||
obj[key] = value;
|
||||
});
|
||||
// Only include properties that are required according to the schema's required array
|
||||
Object.entries(schema.properties).forEach(([key, prop]) => {
|
||||
if (isPropertyRequired(key, schema)) {
|
||||
const value = generateDefaultValue(prop, key, schema);
|
||||
if (value !== undefined) {
|
||||
obj[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
default:
|
||||
case "null":
|
||||
return null;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to check if a property is required in a schema
|
||||
* @param propertyName The name of the property to check
|
||||
* @param schema The parent schema containing the required array
|
||||
* @returns true if the property is required, false otherwise
|
||||
*/
|
||||
export function isPropertyRequired(
|
||||
propertyName: string,
|
||||
schema: JsonSchemaType,
|
||||
): boolean {
|
||||
return schema.required?.includes(propertyName) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a field key into a human-readable label
|
||||
* @param key The field key to format
|
||||
|
|
|
|||
Loading…
Reference in a new issue