Merge branch 'main' into auto_open

This commit is contained in:
KAWAKAMI Moeki
2025-04-17 18:13:53 +09:00
37 changed files with 3251 additions and 2285 deletions

View File

@@ -1,11 +1,10 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import JsonEditor from "./JsonEditor";
import { updateValueAtPath } from "@/utils/jsonUtils";
import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils";
import type { JsonValue, JsonSchemaType, JsonObject } from "@/utils/jsonUtils";
import { generateDefaultValue } from "@/utils/schemaUtils";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
interface DynamicJsonFormProps {
schema: JsonSchemaType;
@@ -14,13 +13,23 @@ interface DynamicJsonFormProps {
maxDepth?: number;
}
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),
);
};
const DynamicJsonForm = ({
schema,
value,
onChange,
maxDepth = 3,
}: DynamicJsonFormProps) => {
const [isJsonMode, setIsJsonMode] = useState(false);
const isOnlyJSON = !isSimpleObject(schema);
const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON);
const [jsonError, setJsonError] = useState<string>();
// Store the raw JSON string to allow immediate feedback during typing
// while deferring parsing until the user stops typing
@@ -207,111 +216,6 @@ const DynamicJsonForm = ({
required={propSchema.required}
/>
);
case "object": {
// Handle case where we have a value but no schema properties
const objectValue = (currentValue as JsonObject) || {};
// If we have schema properties, use them to render fields
if (propSchema.properties) {
return (
<div className="space-y-4 border rounded-md p-4">
{Object.entries(propSchema.properties).map(([key, prop]) => (
<div key={key} className="space-y-2">
<Label>{formatFieldLabel(key)}</Label>
{renderFormFields(
prop,
objectValue[key],
[...path, key],
depth + 1,
)}
</div>
))}
</div>
);
}
// If we have a value but no schema properties, render fields based on the value
else if (Object.keys(objectValue).length > 0) {
return (
<div className="space-y-4 border rounded-md p-4">
{Object.entries(objectValue).map(([key, value]) => (
<div key={key} className="space-y-2">
<Label>{formatFieldLabel(key)}</Label>
<Input
type="text"
value={String(value)}
onChange={(e) =>
handleFieldChange([...path, key], e.target.value)
}
/>
</div>
))}
</div>
);
}
// If we have neither schema properties nor value, return null
return null;
}
case "array": {
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
if (!propSchema.items) return null;
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 = generateDefaultValue(
propSchema.items as JsonSchemaType,
);
handleFieldChange(path, [
...arrayValue,
defaultValue ?? null,
]);
}}
title={
propSchema.items?.description
? `Add new ${propSchema.items.description}`
: "Add new item"
}
>
Add Item
</Button>
</div>
</div>
);
}
default:
return null;
}
@@ -350,9 +254,11 @@ const DynamicJsonForm = ({
Format JSON
</Button>
)}
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
</Button>
{!isOnlyJSON && (
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
</Button>
)}
</div>
{isJsonMode ? (

View File

@@ -227,7 +227,7 @@ const JsonNode = memo(
)}
<pre
className={clsx(
typeStyleMap.string,
isError ? typeStyleMap.error : typeStyleMap.string,
"break-all whitespace-pre-wrap",
)}
>

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef } from "react";
import { authProvider } from "../lib/auth";
import { InspectorOAuthClientProvider } from "../lib/auth";
import { SESSION_KEYS } from "../lib/constants";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
@@ -25,7 +25,10 @@ const OAuthCallback = () => {
}
try {
const result = await auth(authProvider, {
// Create an auth provider with the current server URL
const serverAuthProvider = new InspectorOAuthClientProvider(serverUrl);
const result = await auth(serverAuthProvider, {
serverUrl,
authorizationCode: code,
});

View File

@@ -43,7 +43,7 @@ const PromptsTab = ({
clearPrompts: () => void;
getPrompt: (name: string, args: Record<string, string>) => void;
selectedPrompt: Prompt | null;
setSelectedPrompt: (prompt: Prompt) => void;
setSelectedPrompt: (prompt: Prompt | null) => void;
handleCompletion: (
ref: PromptReference | ResourceReference,
argName: string,
@@ -89,7 +89,10 @@ const PromptsTab = ({
<ListPane
items={prompts}
listItems={listPrompts}
clearItems={clearPrompts}
clearItems={() => {
clearPrompts();
setSelectedPrompt(null);
}}
setSelectedItem={(prompt) => {
setSelectedPrompt(prompt);
setPromptArgs({});

View File

@@ -104,7 +104,6 @@ const ResourcesTab = ({
if (selectedTemplate) {
const uri = fillTemplate(selectedTemplate.uriTemplate, templateValues);
readResource(uri);
setSelectedTemplate(null);
// We don't have the full Resource object here, so we create a partial one
setSelectedResource({ uri, name: uri } as Resource);
}
@@ -116,7 +115,13 @@ const ResourcesTab = ({
<ListPane
items={resources}
listItems={listResources}
clearItems={clearResources}
clearItems={() => {
clearResources();
// Condition to check if selected resource is not resource template's resource
if (!selectedTemplate) {
setSelectedResource(null);
}
}}
setSelectedItem={(resource) => {
setSelectedResource(resource);
readResource(resource.uri);
@@ -139,7 +144,14 @@ const ResourcesTab = ({
<ListPane
items={resourceTemplates}
listItems={listResourceTemplates}
clearItems={clearResourceTemplates}
clearItems={() => {
clearResourceTemplates();
// Condition to check if selected resource is resource template's resource
if (selectedTemplate) {
setSelectedResource(null);
}
setSelectedTemplate(null);
}}
setSelectedItem={(template) => {
setSelectedTemplate(template);
setSelectedResource(null);

View File

@@ -51,9 +51,12 @@ interface SidebarProps {
setEnv: (env: Record<string, string>) => void;
bearerToken: string;
setBearerToken: (token: string) => void;
headerName?: string;
setHeaderName?: (name: string) => void;
onConnect: () => void;
onDisconnect: () => void;
stdErrNotifications: StdErrNotification[];
clearStdErrNotifications: () => void;
logLevel: LoggingLevel;
sendLogLevelRequest: (level: LoggingLevel) => void;
loggingSupported: boolean;
@@ -75,9 +78,12 @@ const Sidebar = ({
setEnv,
bearerToken,
setBearerToken,
headerName,
setHeaderName,
onConnect,
onDisconnect,
stdErrNotifications,
clearStdErrNotifications,
logLevel,
sendLogLevelRequest,
loggingSupported,
@@ -174,6 +180,7 @@ const Sidebar = ({
variant="outline"
onClick={() => setShowBearerToken(!showBearerToken)}
className="flex items-center w-full"
data-testid="auth-button"
aria-expanded={showBearerToken}
>
{showBearerToken ? (
@@ -185,6 +192,16 @@ const Sidebar = ({
</Button>
{showBearerToken && (
<div className="space-y-2">
<label className="text-sm font-medium">Header Name</label>
<Input
placeholder="Authorization"
onChange={(e) =>
setHeaderName && setHeaderName(e.target.value)
}
data-testid="header-input"
className="font-mono"
value={headerName}
/>
<label
className="text-sm font-medium"
htmlFor="bearer-token-input"
@@ -196,6 +213,7 @@ const Sidebar = ({
placeholder="Bearer Token"
value={bearerToken}
onChange={(e) => setBearerToken(e.target.value)}
data-testid="bearer-token-input"
className="font-mono"
type="password"
/>
@@ -516,7 +534,9 @@ const Sidebar = ({
</SelectTrigger>
<SelectContent>
{Object.values(LoggingLevelSchema.enum).map((level) => (
<SelectItem value={level}>{level}</SelectItem>
<SelectItem key={level} value={level}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
@@ -526,9 +546,19 @@ const Sidebar = ({
{stdErrNotifications.length > 0 && (
<>
<div className="mt-4 border-t border-gray-200 pt-4">
<h3 className="text-sm font-medium">
Error output from MCP server
</h3>
<div className="flex justify-between items-center">
<h3 className="text-sm font-medium">
Error output from MCP server
</h3>
<Button
variant="outline"
size="sm"
onClick={clearStdErrNotifications}
className="h-8 px-2"
>
Clear
</Button>
</div>
<div className="mt-2 max-h-80 overflow-y-auto">
{stdErrNotifications.map((notification, index) => (
<div

View File

@@ -43,7 +43,13 @@ const ToolsTab = ({
const [isToolRunning, setIsToolRunning] = useState(false);
useEffect(() => {
setParams({});
const params = Object.entries(
selectedTool?.inputSchema.properties ?? [],
).map(([key, value]) => [
key,
generateDefaultValue(value as JsonSchemaType),
]);
setParams(Object.fromEntries(params));
}, [selectedTool]);
const renderToolResult = () => {
@@ -217,13 +223,10 @@ const ToolsTab = ({
}}
/>
</div>
) : (
) : prop.type === "number" ||
prop.type === "integer" ? (
<Input
type={
prop.type === "number" || prop.type === "integer"
? "number"
: "text"
}
type="number"
id={key}
name={key}
placeholder={prop.description}
@@ -231,15 +234,29 @@ const ToolsTab = ({
onChange={(e) =>
setParams({
...params,
[key]:
prop.type === "number" ||
prop.type === "integer"
? Number(e.target.value)
: e.target.value,
[key]: Number(e.target.value),
})
}
className="mt-1"
/>
) : (
<div className="mt-1">
<DynamicJsonForm
schema={{
type: prop.type,
properties: prop.properties,
description: prop.description,
items: prop.items,
}}
value={params[key] as JsonValue}
onChange={(newValue: JsonValue) => {
setParams({
...params,
[key]: newValue,
});
}}
/>
</div>
)}
</div>
);

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import DynamicJsonForm from "../DynamicJsonForm";
import type { JsonSchemaType } from "@/utils/jsonUtils";
@@ -93,3 +93,47 @@ describe("DynamicJsonForm Integer Fields", () => {
});
});
});
describe("DynamicJsonForm Complex Fields", () => {
const renderForm = (props = {}) => {
const defaultProps = {
schema: {
type: "object",
properties: {
// The simplified JsonSchemaType does not accept oneOf fields
// But they exist in the more-complete JsonSchema7Type
nested: { oneOf: [{ type: "string" }, { type: "integer" }] },
},
} as unknown as JsonSchemaType,
value: undefined,
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
describe("Basic Operations", () => {
it("should render textbox and autoformat button, but no switch-to-form button", () => {
renderForm();
const input = screen.getByRole("textbox");
expect(input).toHaveProperty("type", "textarea");
const buttons = screen.getAllByRole("button");
expect(buttons).toHaveLength(1);
expect(buttons[0]).toHaveProperty("textContent", "Format JSON");
});
it("should pass changed values to onChange", () => {
const onChange = jest.fn();
renderForm({ onChange });
const input = screen.getByRole("textbox");
fireEvent.change(input, {
target: { value: `{ "nested": "i am string" }` },
});
// The onChange handler is debounced when using the JSON view, so we need to wait a little bit
waitFor(() => {
expect(onChange).toHaveBeenCalledWith(`{ "nested": "i am string" }`);
});
});
});
});

View File

@@ -1,4 +1,5 @@
import { render, screen, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, beforeEach, jest } from "@jest/globals";
import Sidebar from "../Sidebar";
import { DEFAULT_INSPECTOR_CONFIG } from "@/lib/constants";
@@ -29,6 +30,7 @@ describe("Sidebar Environment Variables", () => {
onConnect: jest.fn(),
onDisconnect: jest.fn(),
stdErrNotifications: [],
clearStdErrNotifications: jest.fn(),
logLevel: "info" as const,
sendLogLevelRequest: jest.fn(),
loggingSupported: true,
@@ -108,6 +110,157 @@ describe("Sidebar Environment Variables", () => {
});
});
describe("Authentication", () => {
const openAuthSection = () => {
const button = screen.getByTestId("auth-button");
fireEvent.click(button);
};
it("should update bearer token", () => {
const setBearerToken = jest.fn();
renderSidebar({
bearerToken: "",
setBearerToken,
transportType: "sse", // Set transport type to SSE
});
openAuthSection();
const tokenInput = screen.getByTestId("bearer-token-input");
fireEvent.change(tokenInput, { target: { value: "new_token" } });
expect(setBearerToken).toHaveBeenCalledWith("new_token");
});
it("should update header name", () => {
const setHeaderName = jest.fn();
renderSidebar({
headerName: "Authorization",
setHeaderName,
transportType: "sse",
});
openAuthSection();
const headerInput = screen.getByTestId("header-input");
fireEvent.change(headerInput, { target: { value: "X-Custom-Auth" } });
expect(setHeaderName).toHaveBeenCalledWith("X-Custom-Auth");
});
it("should clear bearer token", () => {
const setBearerToken = jest.fn();
renderSidebar({
bearerToken: "existing_token",
setBearerToken,
transportType: "sse", // Set transport type to SSE
});
openAuthSection();
const tokenInput = screen.getByTestId("bearer-token-input");
fireEvent.change(tokenInput, { target: { value: "" } });
expect(setBearerToken).toHaveBeenCalledWith("");
});
it("should properly render bearer token input", () => {
const { rerender } = renderSidebar({
bearerToken: "existing_token",
transportType: "sse", // Set transport type to SSE
});
openAuthSection();
// Token input should be a password field
const tokenInput = screen.getByTestId("bearer-token-input");
expect(tokenInput).toHaveProperty("type", "password");
// Update the token
fireEvent.change(tokenInput, { target: { value: "new_token" } });
// Rerender with updated token
rerender(
<TooltipProvider>
<Sidebar
{...defaultProps}
bearerToken="new_token"
transportType="sse"
/>
</TooltipProvider>,
);
// Token input should still exist after update
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
});
it("should maintain token visibility state after update", () => {
const { rerender } = renderSidebar({
bearerToken: "existing_token",
transportType: "sse", // Set transport type to SSE
});
openAuthSection();
// Token input should be a password field
const tokenInput = screen.getByTestId("bearer-token-input");
expect(tokenInput).toHaveProperty("type", "password");
// Update the token
fireEvent.change(tokenInput, { target: { value: "new_token" } });
// Rerender with updated token
rerender(
<TooltipProvider>
<Sidebar
{...defaultProps}
bearerToken="new_token"
transportType="sse"
/>
</TooltipProvider>,
);
// Token input should still exist after update
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
});
it("should maintain header name when toggling auth section", () => {
renderSidebar({
headerName: "X-API-Key",
transportType: "sse",
});
// Open auth section
openAuthSection();
// Verify header name is displayed
const headerInput = screen.getByTestId("header-input");
expect(headerInput).toHaveValue("X-API-Key");
// Close auth section
const authButton = screen.getByTestId("auth-button");
fireEvent.click(authButton);
// Reopen auth section
fireEvent.click(authButton);
// Verify header name is still preserved
expect(screen.getByTestId("header-input")).toHaveValue("X-API-Key");
});
it("should display default header name when not specified", () => {
renderSidebar({
headerName: undefined,
transportType: "sse",
});
openAuthSection();
const headerInput = screen.getByTestId("header-input");
expect(headerInput).toHaveAttribute("placeholder", "Authorization");
});
});
describe("Key Editing", () => {
it("should maintain order when editing first key", () => {
const setEnv = jest.fn();