diff --git a/.github/workflows/e2e_tests.yml b/.github/workflows/e2e_tests.yml index 2630a495..573672cc 100644 --- a/.github/workflows/e2e_tests.yml +++ b/.github/workflows/e2e_tests.yml @@ -21,8 +21,10 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 + id: setup_node with: - node-version: 18 + node-version-file: package.json + cache: npm # Cache Playwright browsers - name: Cache Playwright browsers @@ -69,7 +71,7 @@ jobs: job-summary: true icon-style: "emojis" custom-info: | - **Test Environment:** Ubuntu Latest, Node.js 18 + **Test Environment:** Ubuntu Latest, Node.js ${{ steps.setup_node.outputs.node-version }} **Browsers:** Chromium, Firefox šŸ“Š [View Detailed HTML Report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) (download artifacts) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 65e98fa4..d01f7175 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 18 + node-version-file: package.json cache: npm # Working around https://github.com/npm/cli/issues/4828 @@ -53,7 +53,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 18 + node-version-file: package.json cache: npm registry-url: "https://registry.npmjs.org" diff --git a/.node-version b/.node-version new file mode 100644 index 00000000..980f29c2 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22.x.x diff --git a/client/bin/client.js b/client/bin/client.js index e25ecb7c..2a7419e6 100755 --- a/client/bin/client.js +++ b/client/bin/client.js @@ -1,5 +1,6 @@ #!/usr/bin/env node +import open from "open"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; import handler from "serve-handler"; @@ -42,9 +43,12 @@ const server = http.createServer((request, response) => { const port = parseInt(process.env.CLIENT_PORT || "6274", 10); const host = process.env.HOST || "localhost"; server.on("listening", () => { - console.log( - `šŸ” MCP Inspector is up and running at http://${host}:${port} šŸš€`, - ); + const url = process.env.INSPECTOR_URL || `http://${host}:${port}`; + console.log(`\nšŸš€ MCP Inspector is up and running at:\n ${url}\n`); + if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { + console.log(`🌐 Opening browser...`); + open(url); + } }); server.on("error", (err) => { if (err.message.includes(`EADDRINUSE`)) { diff --git a/client/bin/start.js b/client/bin/start.js index bea3031f..70ca046e 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -7,17 +7,24 @@ import { fileURLToPath } from "url"; import { randomBytes } from "crypto"; const __dirname = dirname(fileURLToPath(import.meta.url)); +const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277"; function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms, true)); } -function getClientUrl(port, authDisabled, sessionToken) { +function getClientUrl(port, authDisabled, sessionToken, serverPort) { const host = process.env.HOST || "localhost"; const baseUrl = `http://${host}:${port}`; - return authDisabled - ? baseUrl - : `${baseUrl}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`; + + const params = new URLSearchParams(); + if (serverPort && serverPort !== DEFAULT_MCP_PROXY_LISTEN_PORT) { + params.set("MCP_PROXY_PORT", serverPort); + } + if (!authDisabled) { + params.set("MCP_PROXY_AUTH_TOKEN", sessionToken); + } + return params.size > 0 ? `${baseUrl}/?${params.toString()}` : baseUrl; } async function startDevServer(serverOptions) { @@ -31,8 +38,8 @@ async function startDevServer(serverOptions) { cwd: resolve(__dirname, "../..", "server"), env: { ...process.env, - SERVER_PORT: SERVER_PORT, - CLIENT_PORT: CLIENT_PORT, + SERVER_PORT, + CLIENT_PORT, MCP_PROXY_TOKEN: sessionToken, MCP_ENV_VARS: JSON.stringify(envVars), }, @@ -90,8 +97,8 @@ async function startProdServer(serverOptions) { { env: { ...process.env, - SERVER_PORT: SERVER_PORT, - CLIENT_PORT: CLIENT_PORT, + SERVER_PORT, + CLIENT_PORT, MCP_PROXY_TOKEN: sessionToken, MCP_ENV_VARS: JSON.stringify(envVars), }, @@ -107,29 +114,40 @@ async function startProdServer(serverOptions) { } async function startDevClient(clientOptions) { - const { CLIENT_PORT, authDisabled, sessionToken, abort, cancelled } = - clientOptions; + const { + CLIENT_PORT, + SERVER_PORT, + authDisabled, + sessionToken, + abort, + cancelled, + } = clientOptions; const clientCommand = "npx"; const host = process.env.HOST || "localhost"; const clientArgs = ["vite", "--port", CLIENT_PORT, "--host", host]; const client = spawn(clientCommand, clientArgs, { cwd: resolve(__dirname, ".."), - env: { ...process.env, CLIENT_PORT: CLIENT_PORT }, + env: { ...process.env, CLIENT_PORT }, signal: abort.signal, echoOutput: true, }); - // Auto-open browser after vite starts - if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { - const url = getClientUrl(CLIENT_PORT, authDisabled, sessionToken); + const url = getClientUrl( + CLIENT_PORT, + authDisabled, + sessionToken, + SERVER_PORT, + ); - // Give vite time to start before opening browser - setTimeout(() => { + // Give vite time to start before opening or logging the URL + setTimeout(() => { + console.log(`\nšŸš€ MCP Inspector is up and running at:\n ${url}\n`); + if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { + console.log("🌐 Opening browser..."); open(url); - console.log(`\nšŸ”— Opening browser at: ${url}\n`); - }, 3000); - } + } + }, 3000); await new Promise((resolve) => { client.subscribe({ @@ -146,8 +164,14 @@ async function startDevClient(clientOptions) { } async function startProdClient(clientOptions) { - const { CLIENT_PORT, authDisabled, sessionToken, abort, cancelled } = - clientOptions; + const { + CLIENT_PORT, + SERVER_PORT, + authDisabled, + sessionToken, + abort, + cancelled, + } = clientOptions; const inspectorClientPath = resolve( __dirname, "../..", @@ -156,14 +180,19 @@ async function startProdClient(clientOptions) { "client.js", ); - // Only auto-open browser if not cancelled - if (process.env.MCP_AUTO_OPEN_ENABLED !== "false" && !cancelled) { - const url = getClientUrl(CLIENT_PORT, authDisabled, sessionToken); - open(url); - } + const url = getClientUrl( + CLIENT_PORT, + authDisabled, + sessionToken, + SERVER_PORT, + ); await spawnPromise("node", [inspectorClientPath], { - env: { ...process.env, CLIENT_PORT: CLIENT_PORT }, + env: { + ...process.env, + CLIENT_PORT, + INSPECTOR_URL: url, + }, signal: abort.signal, echoOutput: true, }); @@ -210,7 +239,7 @@ async function main() { } const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274"; - const SERVER_PORT = process.env.SERVER_PORT ?? "6277"; + const SERVER_PORT = process.env.SERVER_PORT ?? DEFAULT_MCP_PROXY_LISTEN_PORT; console.log( isDev @@ -255,6 +284,7 @@ async function main() { try { const clientOptions = { CLIENT_PORT, + SERVER_PORT, authDisabled, sessionToken, abort, diff --git a/client/index.html b/client/index.html index 00f5600b..b3736a82 100644 --- a/client/index.html +++ b/client/index.html @@ -7,7 +7,7 @@ MCP Inspector -
+
diff --git a/client/src/App.css b/client/src/App.css index 5f47310f..0d669ffa 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -1,7 +1,3 @@ -#root { - margin: 0 auto; -} - .logo { height: 6em; padding: 1.5em; diff --git a/client/src/App.tsx b/client/src/App.tsx index 26eb44c5..3ceafcae 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -52,7 +52,7 @@ import { z } from "zod"; import "./App.css"; import AuthDebugger from "./components/AuthDebugger"; import ConsoleTab from "./components/ConsoleTab"; -import HistoryAndNotifications from "./components/History"; +import HistoryAndNotifications from "./components/HistoryAndNotifications"; import PingTab from "./components/PingTab"; import PromptsTab, { Prompt } from "./components/PromptsTab"; import ResourcesTab from "./components/ResourcesTab"; diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index 70303aec..4fbd45ca 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -18,10 +18,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 = ({ @@ -118,6 +147,8 @@ const DynamicJsonForm = ({ currentValue: JsonValue, path: string[] = [], depth: number = 0, + parentSchema?: JsonSchemaType, + propertyName?: string, ) => { if ( depth >= maxDepth && @@ -127,7 +158,8 @@ const DynamicJsonForm = ({ return ( { 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": @@ -172,9 +203,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); @@ -184,7 +213,7 @@ const DynamicJsonForm = ({ } }} placeholder={propSchema.description} - required={propSchema.required} + required={isRequired} /> ); case "integer": @@ -195,9 +224,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); @@ -208,7 +235,7 @@ const DynamicJsonForm = ({ } }} placeholder={propSchema.description} - required={propSchema.required} + required={isRequired} /> ); case "boolean": @@ -218,9 +245,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 ( + { + try { + const parsed = JSON.parse(newValue); + handleFieldChange(path, parsed); + setJsonError(undefined); + } catch (err) { + setJsonError( + err instanceof Error ? err.message : "Invalid JSON", + ); + } + }} + error={jsonError} + /> + ); + } + + return ( +
+ {Object.entries(propSchema.properties).map(([key, subSchema]) => ( +
+ + {renderFormFields( + subSchema as JsonSchemaType, + (currentValue as Record)?.[key], + [...path, key], + depth + 1, + propSchema, + key, + )} +
+ ))} +
+ ); + 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 ( +
+ {propSchema.description && ( +

+ {propSchema.description} +

+ )} + + {propSchema.items?.description && ( +

+ Items: {propSchema.items.description} +

+ )} + +
+ {arrayValue.map((item, index) => ( +
+ {renderFormFields( + propSchema.items as JsonSchemaType, + item, + [...path, index.toString()], + depth + 1, + )} + +
+ ))} + +
+
+ ); + } + + // For complex arrays, fall back to JSON editor + return ( + { + 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; } diff --git a/client/src/components/History.tsx b/client/src/components/HistoryAndNotifications.tsx similarity index 89% rename from client/src/components/History.tsx rename to client/src/components/HistoryAndNotifications.tsx index 78394dea..34c035f5 100644 --- a/client/src/components/History.tsx +++ b/client/src/components/HistoryAndNotifications.tsx @@ -110,15 +110,27 @@ const HistoryAndNotifications = ({ >
toggleNotificationExpansion(index)} + onClick={() => + toggleNotificationExpansion( + serverNotifications.length - 1 - index, + ) + } > {serverNotifications.length - index}.{" "} {notification.method} - {expandedNotifications[index] ? "ā–¼" : "ā–¶"} + + {expandedNotifications[ + serverNotifications.length - 1 - index + ] + ? "ā–¼" + : "ā–¶"} +
- {expandedNotifications[index] && ( + {expandedNotifications[ + serverNotifications.length - 1 - index + ] && (
diff --git a/client/src/components/PromptsTab.tsx b/client/src/components/PromptsTab.tsx index 66c15d8f..466b3d52 100644 --- a/client/src/components/PromptsTab.tsx +++ b/client/src/components/PromptsTab.tsx @@ -98,12 +98,12 @@ const PromptsTab = ({ setPromptArgs({}); }} renderItem={(prompt) => ( - <> +
{prompt.name} - + {prompt.description} - +
)} title="Prompts" buttonText={nextCursor ? "List More Prompts" : "List Prompts"} diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 8a7f6578..9e4bc69a 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -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 (
{prop.type === "boolean" ? (
@@ -163,12 +173,13 @@ const ToolsTab = ({ name={key} placeholder={prop.description} value={(params[key] as string) ?? ""} - onChange={(e) => + onChange={(e) => { + const value = e.target.value; setParams({ ...params, - [key]: Number(e.target.value), - }) - } + [key]: value === "" ? "" : Number(value), + }); + }} className="mt-1" /> ) : ( diff --git a/client/src/components/__tests__/DynamicJsonForm.array.test.tsx b/client/src/components/__tests__/DynamicJsonForm.array.test.tsx new file mode 100644 index 00000000..00a324bf --- /dev/null +++ b/client/src/components/__tests__/DynamicJsonForm.array.test.tsx @@ -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(); + }; + + 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(); + }; + + 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"); + }); + }); +}); diff --git a/client/src/components/__tests__/HistoryAndNotifications.test.tsx b/client/src/components/__tests__/HistoryAndNotifications.test.tsx new file mode 100644 index 00000000..42c58519 --- /dev/null +++ b/client/src/components/__tests__/HistoryAndNotifications.test.tsx @@ -0,0 +1,226 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, jest } from "@jest/globals"; +import HistoryAndNotifications from "../HistoryAndNotifications"; +import { ServerNotification } from "@modelcontextprotocol/sdk/types.js"; + +// Mock JsonView component +jest.mock("../JsonView", () => { + return function JsonView({ data }: { data: string }) { + return
{data}
; + }; +}); + +describe("HistoryAndNotifications", () => { + const mockRequestHistory = [ + { + request: JSON.stringify({ method: "test/method1", params: {} }), + response: JSON.stringify({ result: "success" }), + }, + { + request: JSON.stringify({ method: "test/method2", params: {} }), + response: JSON.stringify({ result: "success" }), + }, + ]; + + const mockNotifications: ServerNotification[] = [ + { + method: "notifications/message", + params: { + level: "info" as const, + message: "First notification", + }, + }, + { + method: "notifications/progress", + params: { + progressToken: "test-token", + progress: 50, + message: "Second notification", + }, + }, + ]; + + it("renders history and notifications sections", () => { + render( + , + ); + + expect(screen.getByText("History")).toBeTruthy(); + expect(screen.getByText("Server Notifications")).toBeTruthy(); + }); + + it("displays request history items with correct numbering", () => { + render( + , + ); + + // Items should be numbered in reverse order (newest first) + expect(screen.getByText("2. test/method2")).toBeTruthy(); + expect(screen.getByText("1. test/method1")).toBeTruthy(); + }); + + it("displays server notifications with correct numbering", () => { + render( + , + ); + + // Items should be numbered in reverse order (newest first) + expect(screen.getByText("2. notifications/progress")).toBeTruthy(); + expect(screen.getByText("1. notifications/message")).toBeTruthy(); + }); + + it("expands and collapses request items when clicked", () => { + render( + , + ); + + const firstRequestHeader = screen.getByText("2. test/method2"); + + // Initially collapsed - should show ā–¶ arrows (there are multiple) + expect(screen.getAllByText("ā–¶")).toHaveLength(2); + expect(screen.queryByText("Request:")).toBeNull(); + + // Click to expand + fireEvent.click(firstRequestHeader); + + // Should now be expanded - one ā–¼ and one ā–¶ + expect(screen.getByText("ā–¼")).toBeTruthy(); + expect(screen.getAllByText("ā–¶")).toHaveLength(1); + expect(screen.getByText("Request:")).toBeTruthy(); + expect(screen.getByText("Response:")).toBeTruthy(); + }); + + it("expands and collapses notification items when clicked", () => { + render( + , + ); + + const firstNotificationHeader = screen.getByText( + "2. notifications/progress", + ); + + // Initially collapsed + expect(screen.getAllByText("ā–¶")).toHaveLength(2); + expect(screen.queryByText("Details:")).toBeNull(); + + // Click to expand + fireEvent.click(firstNotificationHeader); + + // Should now be expanded + expect(screen.getByText("ā–¼")).toBeTruthy(); + expect(screen.getAllByText("ā–¶")).toHaveLength(1); + expect(screen.getByText("Details:")).toBeTruthy(); + }); + + it("maintains expanded state when new notifications are added", () => { + const { rerender } = render( + , + ); + + // Find and expand the older notification (should be "1. notifications/message") + const olderNotificationHeader = screen.getByText( + "1. notifications/message", + ); + fireEvent.click(olderNotificationHeader); + + // Verify it's expanded + expect(screen.getByText("Details:")).toBeTruthy(); + + // Add a new notification at the beginning (simulating real behavior) + const newNotifications: ServerNotification[] = [ + { + method: "notifications/resources/updated", + params: { uri: "file://test.txt" }, + }, + ...mockNotifications, + ]; + + // Re-render with new notifications + rerender( + , + ); + + // The original notification should still be expanded + // It's now numbered as "2. notifications/message" due to the new item + expect(screen.getByText("3. notifications/progress")).toBeTruthy(); + expect(screen.getByText("2. notifications/message")).toBeTruthy(); + expect(screen.getByText("1. notifications/resources/updated")).toBeTruthy(); + + // The originally expanded notification should still show its details + expect(screen.getByText("Details:")).toBeTruthy(); + }); + + it("maintains expanded state when new requests are added", () => { + const { rerender } = render( + , + ); + + // Find and expand the older request (should be "1. test/method1") + const olderRequestHeader = screen.getByText("1. test/method1"); + fireEvent.click(olderRequestHeader); + + // Verify it's expanded + expect(screen.getByText("Request:")).toBeTruthy(); + expect(screen.getByText("Response:")).toBeTruthy(); + + // Add a new request at the beginning + const newRequestHistory = [ + { + request: JSON.stringify({ method: "test/new_method", params: {} }), + response: JSON.stringify({ result: "new success" }), + }, + ...mockRequestHistory, + ]; + + // Re-render with new request history + rerender( + , + ); + + // The original request should still be expanded + // It's now numbered as "2. test/method1" due to the new item + expect(screen.getByText("3. test/method2")).toBeTruthy(); + expect(screen.getByText("2. test/method1")).toBeTruthy(); + expect(screen.getByText("1. test/new_method")).toBeTruthy(); + + // The originally expanded request should still show its details + expect(screen.getByText("Request:")).toBeTruthy(); + expect(screen.getByText("Response:")).toBeTruthy(); + }); + + it("displays empty state messages when no data is available", () => { + render( + , + ); + + expect(screen.getByText("No history yet")).toBeTruthy(); + expect(screen.getByText("No notifications yet")).toBeTruthy(); + }); +}); diff --git a/client/src/components/__tests__/ToolsTab.test.tsx b/client/src/components/__tests__/ToolsTab.test.tsx index c9f9b315..dc353ba5 100644 --- a/client/src/components/__tests__/ToolsTab.test.tsx +++ b/client/src/components/__tests__/ToolsTab.test.tsx @@ -111,6 +111,27 @@ describe("ToolsTab", () => { }); }); + it("should allow typing negative numbers", async () => { + renderToolsTab({ + selectedTool: mockTools[0], + }); + + const input = screen.getByRole("spinbutton") as HTMLInputElement; + + // Complete the negative number + fireEvent.change(input, { target: { value: "-42" } }); + expect(input.value).toBe("-42"); + + const submitButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[0].name, { + num: -42, + }); + }); + it("should disable button and change text while tool is running", async () => { // Create a promise that we can resolve later let resolvePromise: ((value: unknown) => void) | undefined; diff --git a/client/src/utils/__tests__/schemaUtils.test.ts b/client/src/utils/__tests__/schemaUtils.test.ts index ca6b65ca..133e25ec 100644 --- a/client/src/utils/__tests__/schemaUtils.test.ts +++ b/client/src/utils/__tests__/schemaUtils.test.ts @@ -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" }, }, }, }, diff --git a/client/src/utils/configUtils.ts b/client/src/utils/configUtils.ts index 40e47a94..418f0007 100644 --- a/client/src/utils/configUtils.ts +++ b/client/src/utils/configUtils.ts @@ -4,12 +4,27 @@ 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; + let proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS.value as string; if (proxyFullAddress) { + proxyFullAddress = proxyFullAddress.replace(/\/+$/, ""); 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 +55,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" diff --git a/client/src/utils/jsonUtils.ts b/client/src/utils/jsonUtils.ts index 28cbf303..83317938 100644 --- a/client/src/utils/jsonUtils.ts +++ b/client/src/utils/jsonUtils.ts @@ -17,7 +17,7 @@ export type JsonSchemaType = { | "object" | "null"; description?: string; - required?: boolean; + required?: string[]; default?: JsonValue; properties?: Record; items?: JsonSchemaType; diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts index 30210532..9fc7a724 100644 --- a/client/src/utils/schemaUtils.ts +++ b/client/src/utils/schemaUtils.ts @@ -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 diff --git a/package-lock.json b/package-lock.json index ce3b7ba7..075b0a28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,9 @@ "prettier": "3.3.3", "rimraf": "^6.0.1", "typescript": "^5.4.2" + }, + "engines": { + "node": ">=22.7.5" } }, "cli": { diff --git a/package.json b/package.json index de02890d..5d5fe52f 100644 --- a/package.json +++ b/package.json @@ -64,5 +64,8 @@ "prettier": "3.3.3", "rimraf": "^6.0.1", "typescript": "^5.4.2" + }, + "engines": { + "node": ">=22.7.5" } } diff --git a/server/src/index.ts b/server/src/index.ts index 89def8ba..971cf158 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -21,6 +21,7 @@ import { findActualExecutable } from "spawn-rx"; import mcpProxy from "./mcpProxy.js"; import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto"; +const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277"; const SSE_HEADERS_PASSTHROUGH = ["authorization"]; const STREAMABLE_HTTP_HEADERS_PASSTHROUGH = [ "authorization", @@ -528,24 +529,19 @@ app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => { } }); -const PORT = parseInt(process.env.SERVER_PORT || "6277", 10); +const PORT = parseInt( + process.env.SERVER_PORT || DEFAULT_MCP_PROXY_LISTEN_PORT, + 10, +); const HOST = process.env.HOST || "localhost"; const server = app.listen(PORT, HOST); server.on("listening", () => { console.log(`āš™ļø Proxy server listening on ${HOST}:${PORT}`); if (!authDisabled) { - console.log(`šŸ”‘ Session token: ${sessionToken}`); console.log( - `Use this token to authenticate requests or set DANGEROUSLY_OMIT_AUTH=true to disable auth`, - ); - - // Display clickable URL with pre-filled token - const clientPort = process.env.CLIENT_PORT || "6274"; - const clientHost = process.env.HOST || "localhost"; - const clientUrl = `http://${clientHost}:${clientPort}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`; - console.log( - `\nšŸ”— Open inspector with token pre-filled:\n ${clientUrl}\n`, + `šŸ”‘ Session token: ${sessionToken}\n ` + + `Use this token to authenticate requests or set DANGEROUSLY_OMIT_AUTH=true to disable auth`, ); } else { console.log( diff --git a/server/src/mcpProxy.ts b/server/src/mcpProxy.ts index 9dee9de4..664f1711 100644 --- a/server/src/mcpProxy.ts +++ b/server/src/mcpProxy.ts @@ -6,12 +6,10 @@ function onClientError(error: Error) { } function onServerError(error: Error) { - if ( - (error?.message && - error.message.includes("Error POSTing to endpoint (HTTP 404)")) || - (error?.cause && JSON.stringify(error.cause).includes("ECONNREFUSED")) - ) { + if (error?.cause && JSON.stringify(error.cause).includes("ECONNREFUSED")) { console.error("Connection refused. Is the MCP server running?"); + } else if (error.message && error.message.includes("404")) { + console.error("Error accessing endpoint (HTTP 404)"); } else { console.error("Error from MCP server:", error); }