From d7cc0fae2a230141f9fd50f5182958ca5586542d Mon Sep 17 00:00:00 2001 From: Baruch Odem Date: Fri, 30 May 2025 15:08:40 +0300 Subject: [PATCH 01/36] chore: Add .node-version file and update workflows to use it --- .github/workflows/main.yml | 4 ++-- .node-version | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 .node-version diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8efb6773..54addc87 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: .node-version cache: npm # Working around https://github.com/npm/cli/issues/4828 @@ -50,7 +50,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 18 + node-version-file: .node-version 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 From b555e41c0b3c87cbc08cade9a92e587ee4912155 Mon Sep 17 00:00:00 2001 From: olaservo Date: Sun, 1 Jun 2025 09:03:02 -0700 Subject: [PATCH 02/36] Address the schema architecture crisis --- client/src/components/DynamicJsonForm.tsx | 89 ++++++++++++++++++----- client/src/components/ToolsTab.tsx | 8 +- client/src/utils/jsonUtils.ts | 2 +- client/src/utils/schemaUtils.ts | 55 +++++++++----- 4 files changed, 114 insertions(+), 40 deletions(-) diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index 6a5993c3..bd8c6cfd 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -113,6 +113,8 @@ const DynamicJsonForm = ({ currentValue: JsonValue, path: string[] = [], depth: number = 0, + parentSchema?: JsonSchemaType, + propertyName?: string, ) => { if ( depth >= maxDepth && @@ -122,7 +124,7 @@ 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": @@ -167,9 +167,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 +177,7 @@ const DynamicJsonForm = ({ } }} placeholder={propSchema.description} - required={propSchema.required} + required={isRequired} /> ); case "integer": @@ -190,9 +188,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 +199,7 @@ const DynamicJsonForm = ({ } }} placeholder={propSchema.description} - required={propSchema.required} + required={isRequired} /> ); case "boolean": @@ -213,7 +209,64 @@ 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": + 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: diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 8a7f6578..93073da3 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,7 @@ 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,13 +92,15 @@ 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" ? (
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..fe643b95 100644 --- a/client/src/utils/schemaUtils.ts +++ b/client/src/utils/schemaUtils.ts @@ -81,46 +81,65 @@ 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 = parentSchema?.required?.includes(propertyName || "") ?? 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]) => { + const isPropertyRequired = schema.required?.includes(key) ?? false; + if (isPropertyRequired) { + 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 From 990bc0f7fdae85c91300ffd6b086ae55e5b6e52c Mon Sep 17 00:00:00 2001 From: olaservo Date: Sun, 1 Jun 2025 15:41:30 -0700 Subject: [PATCH 03/36] Fix formatting --- client/src/components/DynamicJsonForm.tsx | 16 +++++++++++----- client/src/components/ToolsTab.tsx | 14 +++++++++++--- client/src/utils/schemaUtils.ts | 10 +++++++--- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index bd8c6cfd..0a73a989 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -124,7 +124,8 @@ const DynamicJsonForm = ({ return ( ); } - + return (
{Object.entries(propSchema.properties).map(([key, subSchema]) => ( @@ -263,7 +267,9 @@ const DynamicJsonForm = ({ handleFieldChange(path, parsed); setJsonError(undefined); } catch (err) { - setJsonError(err instanceof Error ? err.message : "Invalid JSON"); + setJsonError( + err instanceof Error ? err.message : "Invalid JSON", + ); } }} error={jsonError} diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 93073da3..cf85e4eb 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -48,7 +48,11 @@ const ToolsTab = ({ selectedTool?.inputSchema.properties ?? [], ).map(([key, value]) => [ key, - generateDefaultValue(value as JsonSchemaType, key, selectedTool?.inputSchema as JsonSchemaType), + generateDefaultValue( + value as JsonSchemaType, + key, + selectedTool?.inputSchema as JsonSchemaType, + ), ]); setParams(Object.fromEntries(params)); }, [selectedTool]); @@ -92,7 +96,8 @@ const ToolsTab = ({ {Object.entries(selectedTool.inputSchema.properties ?? []).map( ([key, value]) => { const prop = value as JsonSchemaType; - const inputSchema = selectedTool.inputSchema as JsonSchemaType; + const inputSchema = + selectedTool.inputSchema as JsonSchemaType; const required = isPropertyRequired(key, inputSchema); return (
@@ -100,7 +105,10 @@ const ToolsTab = ({ htmlFor={key} className="block text-sm font-medium text-gray-700 dark:text-gray-300" > - {key}{required && *} + {key} + {required && ( + * + )} {prop.type === "boolean" ? (
diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts index fe643b95..c47022e0 100644 --- a/client/src/utils/schemaUtils.ts +++ b/client/src/utils/schemaUtils.ts @@ -88,14 +88,15 @@ export function hasOutputSchema(toolName: string): boolean { export function generateDefaultValue( schema: JsonSchemaType, propertyName?: string, - parentSchema?: JsonSchemaType + parentSchema?: JsonSchemaType, ): JsonValue { if ("default" in schema && schema.default !== undefined) { return schema.default; } // Check if this property is required in the parent schema - const isRequired = parentSchema?.required?.includes(propertyName || "") ?? false; + const isRequired = + parentSchema?.required?.includes(propertyName || "") ?? false; switch (schema.type) { case "string": @@ -136,7 +137,10 @@ export function generateDefaultValue( * @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 { +export function isPropertyRequired( + propertyName: string, + schema: JsonSchemaType, +): boolean { return schema.required?.includes(propertyName) ?? false; } From 06af321530a541d469e7683acbbbc372bcdbd87a Mon Sep 17 00:00:00 2001 From: olaservo Date: Sun, 1 Jun 2025 16:07:45 -0700 Subject: [PATCH 04/36] Fix tests --- .../src/utils/__tests__/schemaUtils.test.ts | 50 ++++++++++--------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/client/src/utils/__tests__/schemaUtils.test.ts b/client/src/utils/__tests__/schemaUtils.test.ts index ca6b65ca..ed2c50f9 100644 --- a/client/src/utils/__tests__/schemaUtils.test.ts +++ b/client/src/utils/__tests__/schemaUtils.test.ts @@ -11,60 +11,64 @@ 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( + 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( + test("generates undefined for non-required primitive types", () => { + expect(generateDefaultValue({ type: "string" })).toBe( undefined, ); - expect(generateDefaultValue({ type: "number", required: false })).toBe( + expect(generateDefaultValue({ type: "number" })).toBe( undefined, ); - expect(generateDefaultValue({ type: "boolean", required: false })).toBe( + expect(generateDefaultValue({ type: "boolean" })).toBe( undefined, ); }); @@ -72,11 +76,11 @@ describe("generateDefaultValue", () => { 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 +93,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" }, }, }, }, From e5e2edc4a1c223960980878be56318822ad8772e Mon Sep 17 00:00:00 2001 From: olaservo Date: Sun, 1 Jun 2025 16:11:20 -0700 Subject: [PATCH 05/36] Fix formatting --- .../src/utils/__tests__/schemaUtils.test.ts | 46 ++++++++----------- 1 file changed, 19 insertions(+), 27 deletions(-) diff --git a/client/src/utils/__tests__/schemaUtils.test.ts b/client/src/utils/__tests__/schemaUtils.test.ts index ed2c50f9..133e25ec 100644 --- a/client/src/utils/__tests__/schemaUtils.test.ts +++ b/client/src/utils/__tests__/schemaUtils.test.ts @@ -12,24 +12,30 @@ import type { Tool } from "@modelcontextprotocol/sdk/types.js"; describe("generateDefaultValue", () => { test("generates default string", () => { const parentSchema = { type: "object" as const, required: ["testProp"] }; - expect(generateDefaultValue({ type: "string" }, "testProp", parentSchema)).toBe(""); + expect( + generateDefaultValue({ type: "string" }, "testProp", parentSchema), + ).toBe(""); }); test("generates default number", () => { const parentSchema = { type: "object" as const, required: ["testProp"] }; - expect(generateDefaultValue({ type: "number" }, "testProp", parentSchema)).toBe(0); + expect( + generateDefaultValue({ type: "number" }, "testProp", parentSchema), + ).toBe(0); }); test("generates default integer", () => { const parentSchema = { type: "object" as const, required: ["testProp"] }; - expect(generateDefaultValue({ type: "integer" }, "testProp", parentSchema)).toBe(0); + expect( + generateDefaultValue({ type: "integer" }, "testProp", parentSchema), + ).toBe(0); }); test("generates default boolean", () => { const parentSchema = { type: "object" as const, required: ["testProp"] }; - expect(generateDefaultValue({ type: "boolean" }, "testProp", parentSchema)).toBe( - false, - ); + expect( + generateDefaultValue({ type: "boolean" }, "testProp", parentSchema), + ).toBe(false); }); test("generates default array", () => { @@ -37,40 +43,26 @@ describe("generateDefaultValue", () => { }); test("generates default empty object", () => { - expect(generateDefaultValue({ type: "object" })).toEqual( - {}, - ); + expect(generateDefaultValue({ type: "object" })).toEqual({}); }); test("generates default null for unknown types", () => { // @ts-expect-error Testing with invalid type - expect(generateDefaultValue({ type: "unknown" })).toBe( - undefined, - ); + expect(generateDefaultValue({ type: "unknown" })).toBe(undefined); }); test("generates empty array for non-required array", () => { - expect(generateDefaultValue({ type: "array" })).toEqual( - [], - ); + expect(generateDefaultValue({ type: "array" })).toEqual([]); }); test("generates empty object for non-required object", () => { - expect(generateDefaultValue({ type: "object" })).toEqual( - {}, - ); + expect(generateDefaultValue({ type: "object" })).toEqual({}); }); 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, - ); + expect(generateDefaultValue({ type: "string" })).toBe(undefined); + expect(generateDefaultValue({ type: "number" })).toBe(undefined); + expect(generateDefaultValue({ type: "boolean" })).toBe(undefined); }); test("generates object with properties", () => { From 3475670f1d2e2c08ddb4df070a087d75fb7debbb Mon Sep 17 00:00:00 2001 From: olaservo Date: Sun, 1 Jun 2025 20:53:17 -0700 Subject: [PATCH 06/36] Incorporate nested logic again --- client/src/components/DynamicJsonForm.tsx | 105 ++++++- .../__tests__/DynamicJsonForm.array.test.tsx | 256 ++++++++++++++++++ 2 files changed, 356 insertions(+), 5 deletions(-) create mode 100644 client/src/components/__tests__/DynamicJsonForm.array.test.tsx diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index 0a73a989..f799afbb 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -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 = ({ @@ -257,7 +286,72 @@ const DynamicJsonForm = ({ ))}
); - case "array": + 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 ( ); + } default: return null; } 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"); + }); + }); +}); From 0a1de24fdff629adddc3df4f1fbab0e47b0b6ac1 Mon Sep 17 00:00:00 2001 From: olaservo Date: Sun, 1 Jun 2025 21:05:33 -0700 Subject: [PATCH 07/36] Fix formatting --- client/src/components/DynamicJsonForm.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index f799afbb..081a7d3b 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -31,7 +31,7 @@ const getArrayItemDefault = (schema: JsonSchemaType): JsonValue => { if ("default" in schema && schema.default !== undefined) { return schema.default; } - + switch (schema.type) { case "string": return ""; @@ -289,13 +289,15 @@ const DynamicJsonForm = ({ 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.description} +

)} {propSchema.items?.description && ( @@ -333,10 +335,7 @@ const DynamicJsonForm = ({ const defaultValue = getArrayItemDefault( propSchema.items as JsonSchemaType, ); - handleFieldChange(path, [ - ...arrayValue, - defaultValue, - ]); + handleFieldChange(path, [...arrayValue, defaultValue]); }} title={ propSchema.items?.description @@ -350,7 +349,7 @@ const DynamicJsonForm = ({
); } - + // For complex arrays, fall back to JSON editor return ( Date: Tue, 10 Jun 2025 12:17:12 -0400 Subject: [PATCH 08/36] Remove additional slash in the end of the proxy base url --- client/src/lib/hooks/useConnection.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index d42b8d38..75e2ffd5 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -241,7 +241,9 @@ export function useConnection({ const checkProxyHealth = async () => { try { - const proxyHealthUrl = new URL(`${getMCPProxyAddress(config)}/health`); + const proxyBaseUrl = getMCPProxyAddress(config).replace(/\/+$/, ''); + const proxyHealthUrl = new URL(`${proxyBaseUrl}/health`); + const proxyHealthResponse = await fetch(proxyHealthUrl); const proxyHealth = await proxyHealthResponse.json(); if (proxyHealth?.status !== "ok") { From da003c7553aec04db1b7188016cb881689edfe9b Mon Sep 17 00:00:00 2001 From: Sebastian Ko Date: Tue, 10 Jun 2025 12:32:15 -0400 Subject: [PATCH 09/36] Change '' to "" --- client/src/lib/hooks/useConnection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 75e2ffd5..4c18f90b 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -241,7 +241,7 @@ export function useConnection({ const checkProxyHealth = async () => { try { - const proxyBaseUrl = getMCPProxyAddress(config).replace(/\/+$/, ''); + const proxyBaseUrl = getMCPProxyAddress(config).replace(/\/+$/, ""); const proxyHealthUrl = new URL(`${proxyBaseUrl}/health`); const proxyHealthResponse = await fetch(proxyHealthUrl); From 6c7c4176454b7ebeacf47026bb12a2daa7bfce86 Mon Sep 17 00:00:00 2001 From: Sebastian Ko Date: Tue, 10 Jun 2025 12:32:52 -0400 Subject: [PATCH 10/36] add comment --- client/src/lib/hooks/useConnection.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 4c18f90b..698c2ea2 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -241,6 +241,7 @@ export function useConnection({ const checkProxyHealth = async () => { try { + // Remove additional slashes const proxyBaseUrl = getMCPProxyAddress(config).replace(/\/+$/, ""); const proxyHealthUrl = new URL(`${proxyBaseUrl}/health`); From 51eb7cf849dce561957347c07b1c82c22c1d7662 Mon Sep 17 00:00:00 2001 From: Ola Hungerford Date: Tue, 10 Jun 2025 21:21:43 -0700 Subject: [PATCH 11/36] Update client/src/utils/schemaUtils.ts Co-authored-by: Cliff Hall --- client/src/utils/schemaUtils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts index c47022e0..a0cf1a15 100644 --- a/client/src/utils/schemaUtils.ts +++ b/client/src/utils/schemaUtils.ts @@ -95,8 +95,7 @@ export function generateDefaultValue( } // Check if this property is required in the parent schema - const isRequired = - parentSchema?.required?.includes(propertyName || "") ?? false; + const isRequired = isPropertyRequired(propertyName, parentSchema); switch (schema.type) { case "string": From cf2778ad1914cd38b66898e162ebf358ea0f20bf Mon Sep 17 00:00:00 2001 From: Ola Hungerford Date: Tue, 10 Jun 2025 21:21:51 -0700 Subject: [PATCH 12/36] Update client/src/utils/schemaUtils.ts Co-authored-by: Cliff Hall --- client/src/utils/schemaUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts index a0cf1a15..65e334d6 100644 --- a/client/src/utils/schemaUtils.ts +++ b/client/src/utils/schemaUtils.ts @@ -113,13 +113,13 @@ export function generateDefaultValue( const obj: JsonObject = {}; // Only include properties that are required according to the schema's required array Object.entries(schema.properties).forEach(([key, prop]) => { - const isPropertyRequired = schema.required?.includes(key) ?? false; - if (isPropertyRequired) { + if (isPropertyRequired(key, schema)) { const value = generateDefaultValue(prop, key, schema); if (value !== undefined) { obj[key] = value; } } + }); return obj; } From 123a4626811493aa48a9c6ffe58a5aa53087648a Mon Sep 17 00:00:00 2001 From: olaservo Date: Tue, 10 Jun 2025 21:23:04 -0700 Subject: [PATCH 13/36] Fix formatting --- client/src/utils/schemaUtils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts index 65e334d6..b903058b 100644 --- a/client/src/utils/schemaUtils.ts +++ b/client/src/utils/schemaUtils.ts @@ -119,7 +119,6 @@ export function generateDefaultValue( obj[key] = value; } } - }); return obj; } From a2061a6f761800216f20c3ecf7a0c8cfecfbfb42 Mon Sep 17 00:00:00 2001 From: olaservo Date: Tue, 10 Jun 2025 21:27:54 -0700 Subject: [PATCH 14/36] Add check to fix build failure --- client/src/utils/schemaUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts index b903058b..38bc8aae 100644 --- a/client/src/utils/schemaUtils.ts +++ b/client/src/utils/schemaUtils.ts @@ -95,7 +95,7 @@ export function generateDefaultValue( } // Check if this property is required in the parent schema - const isRequired = isPropertyRequired(propertyName, parentSchema); + const isRequired = propertyName && parentSchema ? isPropertyRequired(propertyName, parentSchema) : false; switch (schema.type) { case "string": From 2fb5d4b684ed247a892ef18b6b7f00a9ca83ddb8 Mon Sep 17 00:00:00 2001 From: olaservo Date: Tue, 10 Jun 2025 21:29:08 -0700 Subject: [PATCH 15/36] Fix formatting --- client/src/utils/schemaUtils.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts index 38bc8aae..9fc7a724 100644 --- a/client/src/utils/schemaUtils.ts +++ b/client/src/utils/schemaUtils.ts @@ -95,7 +95,10 @@ export function generateDefaultValue( } // Check if this property is required in the parent schema - const isRequired = propertyName && parentSchema ? isPropertyRequired(propertyName, parentSchema) : false; + const isRequired = + propertyName && parentSchema + ? isPropertyRequired(propertyName, parentSchema) + : false; switch (schema.type) { case "string": From 509ca909bb00768754008ebae9ce2b80d9e52414 Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:01:36 +0100 Subject: [PATCH 16/36] make prompt and tool listing display consistent --- client/src/components/PromptsTab.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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"} From b3e1b5cb6acd398619a4ce9e607a19c5c8402f67 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 11 Jun 2025 06:45:23 -0700 Subject: [PATCH 17/36] Update defineProperty to make jest-dom happy --- client/src/components/__tests__/AuthDebugger.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 2130e68b..70862768 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -88,6 +88,8 @@ Object.defineProperty(window, "location", { value: { origin: "http://localhost:3000", }, + configurable: true, + writable: true, }); describe("AuthDebugger", () => { From dac224bad7e6a5c1810600277e74501f1f9a749a Mon Sep 17 00:00:00 2001 From: olaservo Date: Thu, 12 Jun 2025 03:56:20 -0700 Subject: [PATCH 18/36] Remove mocking --- .../__tests__/AuthDebugger.test.tsx | 22 +------------------ 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 70862768..eb7ff3ba 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -84,14 +84,6 @@ Object.defineProperty(window, "sessionStorage", { value: sessionStorageMock, }); -Object.defineProperty(window, "location", { - value: { - origin: "http://localhost:3000", - }, - configurable: true, - writable: true, -}); - describe("AuthDebugger", () => { const defaultAuthState = EMPTY_DEBUGGER_STATE; @@ -106,7 +98,7 @@ describe("AuthDebugger", () => { jest.clearAllMocks(); sessionStorageMock.getItem.mockReturnValue(null); - // Supress + // Suppress console errors in tests to avoid JSDOM navigation noise jest.spyOn(console, "error").mockImplementation(() => {}); mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); @@ -449,18 +441,6 @@ describe("AuthDebugger", () => { it("should store auth state to sessionStorage before redirect in Quick OAuth Flow", async () => { const updateAuthState = jest.fn(); - // Mock window.location.href setter - const originalLocation = window.location; - const locationMock = { - ...originalLocation, - href: "", - origin: "http://localhost:3000", - }; - Object.defineProperty(window, "location", { - writable: true, - value: locationMock, - }); - // Setup mocks for OAuth flow mockStartAuthorization.mockResolvedValue({ authorizationUrl: new URL( From 03e21bd3e469ebadbeb5c3339894017565551156 Mon Sep 17 00:00:00 2001 From: Sebastian Ko Date: Sun, 15 Jun 2025 21:25:01 -0400 Subject: [PATCH 19/36] Move remove slashes logic to getMCPProxyAddress --- client/src/utils/configUtils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/utils/configUtils.ts b/client/src/utils/configUtils.ts index ae404d65..aad03ce8 100644 --- a/client/src/utils/configUtils.ts +++ b/client/src/utils/configUtils.ts @@ -5,8 +5,9 @@ import { } from "@/lib/constants"; export const getMCPProxyAddress = (config: InspectorConfig): string => { - const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS.value as string; + var 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}`; From 6034ea36dc405a48d7bc05337683afa165cb72dc Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Sat, 14 Jun 2025 22:31:30 -0700 Subject: [PATCH 20/36] Fix expansion state tracking in Server Notifications --- client/src/components/History.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/client/src/components/History.tsx b/client/src/components/History.tsx index 78394dea..34c035f5 100644 --- a/client/src/components/History.tsx +++ b/client/src/components/History.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 + ] && (
From 7eaa948e9800230237d2b701482a943183e53561 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Sat, 14 Jun 2025 23:29:19 -0700 Subject: [PATCH 21/36] Add tests for HistoryAndNotifications component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests cover basic rendering, numbering, expansion functionality, and expansion state persistence when new items are added. Includes regression test for notification expansion state tracking bug. šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../src/components/__tests__/History.test.tsx | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 client/src/components/__tests__/History.test.tsx diff --git a/client/src/components/__tests__/History.test.tsx b/client/src/components/__tests__/History.test.tsx new file mode 100644 index 00000000..19cae083 --- /dev/null +++ b/client/src/components/__tests__/History.test.tsx @@ -0,0 +1,216 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { describe, it, expect, jest } from "@jest/globals"; +import HistoryAndNotifications from "../History"; +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: "notification/test1", + params: { message: "First notification" }, + }, + { + method: "notification/test2", + params: { message: "Second notification" }, + }, + ]; + + it("renders history and notifications sections", () => { + render( + , + ); + + expect(screen.getByText("History")).toBeInTheDocument(); + expect(screen.getByText("Server Notifications")).toBeInTheDocument(); + }); + + it("displays request history items with correct numbering", () => { + render( + , + ); + + // Items should be numbered in reverse order (newest first) + expect(screen.getByText("2. test/method2")).toBeInTheDocument(); + expect(screen.getByText("1. test/method1")).toBeInTheDocument(); + }); + + it("displays server notifications with correct numbering", () => { + render( + , + ); + + // Items should be numbered in reverse order (newest first) + expect(screen.getByText("2. notification/test2")).toBeInTheDocument(); + expect(screen.getByText("1. notification/test1")).toBeInTheDocument(); + }); + + 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:")).not.toBeInTheDocument(); + + // Click to expand + fireEvent.click(firstRequestHeader); + + // Should now be expanded - one ā–¼ and one ā–¶ + expect(screen.getByText("ā–¼")).toBeInTheDocument(); + expect(screen.getAllByText("ā–¶")).toHaveLength(1); + expect(screen.getByText("Request:")).toBeInTheDocument(); + expect(screen.getByText("Response:")).toBeInTheDocument(); + }); + + it("expands and collapses notification items when clicked", () => { + render( + , + ); + + const firstNotificationHeader = screen.getByText("2. notification/test2"); + + // Initially collapsed + expect(screen.getAllByText("ā–¶")).toHaveLength(2); + expect(screen.queryByText("Details:")).not.toBeInTheDocument(); + + // Click to expand + fireEvent.click(firstNotificationHeader); + + // Should now be expanded + expect(screen.getByText("ā–¼")).toBeInTheDocument(); + expect(screen.getAllByText("ā–¶")).toHaveLength(1); + expect(screen.getByText("Details:")).toBeInTheDocument(); + }); + + it("maintains expanded state when new notifications are added", () => { + const { rerender } = render( + , + ); + + // Find and expand the older notification (should be "1. notification/test1") + const olderNotificationHeader = screen.getByText("1. notification/test1"); + fireEvent.click(olderNotificationHeader); + + // Verify it's expanded + expect(screen.getByText("Details:")).toBeInTheDocument(); + + // Add a new notification at the beginning (simulating real behavior) + const newNotifications: ServerNotification[] = [ + { + method: "notification/new", + params: { message: "New notification" }, + }, + ...mockNotifications, + ]; + + // Re-render with new notifications + rerender( + , + ); + + // The original notification should still be expanded + // It's now numbered as "2. notification/test1" due to the new item + expect(screen.getByText("3. notification/test2")).toBeInTheDocument(); + expect(screen.getByText("2. notification/test1")).toBeInTheDocument(); + expect(screen.getByText("1. notification/new")).toBeInTheDocument(); + + // The originally expanded notification should still show its details + expect(screen.getByText("Details:")).toBeInTheDocument(); + }); + + 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:")).toBeInTheDocument(); + expect(screen.getByText("Response:")).toBeInTheDocument(); + + // 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")).toBeInTheDocument(); + expect(screen.getByText("2. test/method1")).toBeInTheDocument(); + expect(screen.getByText("1. test/new_method")).toBeInTheDocument(); + + // The originally expanded request should still show its details + expect(screen.getByText("Request:")).toBeInTheDocument(); + expect(screen.getByText("Response:")).toBeInTheDocument(); + }); + + it("displays empty state messages when no data is available", () => { + render( + , + ); + + expect(screen.getByText("No history yet")).toBeInTheDocument(); + expect(screen.getByText("No notifications yet")).toBeInTheDocument(); + }); +}); From 78eaa177a49183cf1d95fe31055237aebf4aa196 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Sat, 14 Jun 2025 23:34:41 -0700 Subject: [PATCH 22/36] Rename History file to HistoryAndNotifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename History.tsx to HistoryAndNotifications.tsx for consistency with component name and update imports. šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- client/src/App.tsx | 2 +- ...istory.tsx => HistoryAndNotifications.tsx} | 0 ...t.tsx => HistoryAndNotifications.test.tsx} | 88 +++++++++++-------- 3 files changed, 50 insertions(+), 40 deletions(-) rename client/src/components/{History.tsx => HistoryAndNotifications.tsx} (100%) rename client/src/components/__tests__/{History.test.tsx => HistoryAndNotifications.test.tsx} (66%) 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/History.tsx b/client/src/components/HistoryAndNotifications.tsx similarity index 100% rename from client/src/components/History.tsx rename to client/src/components/HistoryAndNotifications.tsx diff --git a/client/src/components/__tests__/History.test.tsx b/client/src/components/__tests__/HistoryAndNotifications.test.tsx similarity index 66% rename from client/src/components/__tests__/History.test.tsx rename to client/src/components/__tests__/HistoryAndNotifications.test.tsx index 19cae083..42c58519 100644 --- a/client/src/components/__tests__/History.test.tsx +++ b/client/src/components/__tests__/HistoryAndNotifications.test.tsx @@ -1,7 +1,6 @@ import { render, screen, fireEvent } from "@testing-library/react"; -import "@testing-library/jest-dom"; import { describe, it, expect, jest } from "@jest/globals"; -import HistoryAndNotifications from "../History"; +import HistoryAndNotifications from "../HistoryAndNotifications"; import { ServerNotification } from "@modelcontextprotocol/sdk/types.js"; // Mock JsonView component @@ -25,12 +24,19 @@ describe("HistoryAndNotifications", () => { const mockNotifications: ServerNotification[] = [ { - method: "notification/test1", - params: { message: "First notification" }, + method: "notifications/message", + params: { + level: "info" as const, + message: "First notification", + }, }, { - method: "notification/test2", - params: { message: "Second notification" }, + method: "notifications/progress", + params: { + progressToken: "test-token", + progress: 50, + message: "Second notification", + }, }, ]; @@ -42,8 +48,8 @@ describe("HistoryAndNotifications", () => { />, ); - expect(screen.getByText("History")).toBeInTheDocument(); - expect(screen.getByText("Server Notifications")).toBeInTheDocument(); + expect(screen.getByText("History")).toBeTruthy(); + expect(screen.getByText("Server Notifications")).toBeTruthy(); }); it("displays request history items with correct numbering", () => { @@ -55,8 +61,8 @@ describe("HistoryAndNotifications", () => { ); // Items should be numbered in reverse order (newest first) - expect(screen.getByText("2. test/method2")).toBeInTheDocument(); - expect(screen.getByText("1. test/method1")).toBeInTheDocument(); + expect(screen.getByText("2. test/method2")).toBeTruthy(); + expect(screen.getByText("1. test/method1")).toBeTruthy(); }); it("displays server notifications with correct numbering", () => { @@ -68,8 +74,8 @@ describe("HistoryAndNotifications", () => { ); // Items should be numbered in reverse order (newest first) - expect(screen.getByText("2. notification/test2")).toBeInTheDocument(); - expect(screen.getByText("1. notification/test1")).toBeInTheDocument(); + expect(screen.getByText("2. notifications/progress")).toBeTruthy(); + expect(screen.getByText("1. notifications/message")).toBeTruthy(); }); it("expands and collapses request items when clicked", () => { @@ -84,16 +90,16 @@ describe("HistoryAndNotifications", () => { // Initially collapsed - should show ā–¶ arrows (there are multiple) expect(screen.getAllByText("ā–¶")).toHaveLength(2); - expect(screen.queryByText("Request:")).not.toBeInTheDocument(); + expect(screen.queryByText("Request:")).toBeNull(); // Click to expand fireEvent.click(firstRequestHeader); // Should now be expanded - one ā–¼ and one ā–¶ - expect(screen.getByText("ā–¼")).toBeInTheDocument(); + expect(screen.getByText("ā–¼")).toBeTruthy(); expect(screen.getAllByText("ā–¶")).toHaveLength(1); - expect(screen.getByText("Request:")).toBeInTheDocument(); - expect(screen.getByText("Response:")).toBeInTheDocument(); + expect(screen.getByText("Request:")).toBeTruthy(); + expect(screen.getByText("Response:")).toBeTruthy(); }); it("expands and collapses notification items when clicked", () => { @@ -104,19 +110,21 @@ describe("HistoryAndNotifications", () => { />, ); - const firstNotificationHeader = screen.getByText("2. notification/test2"); + const firstNotificationHeader = screen.getByText( + "2. notifications/progress", + ); // Initially collapsed expect(screen.getAllByText("ā–¶")).toHaveLength(2); - expect(screen.queryByText("Details:")).not.toBeInTheDocument(); + expect(screen.queryByText("Details:")).toBeNull(); // Click to expand fireEvent.click(firstNotificationHeader); // Should now be expanded - expect(screen.getByText("ā–¼")).toBeInTheDocument(); + expect(screen.getByText("ā–¼")).toBeTruthy(); expect(screen.getAllByText("ā–¶")).toHaveLength(1); - expect(screen.getByText("Details:")).toBeInTheDocument(); + expect(screen.getByText("Details:")).toBeTruthy(); }); it("maintains expanded state when new notifications are added", () => { @@ -127,18 +135,20 @@ describe("HistoryAndNotifications", () => { />, ); - // Find and expand the older notification (should be "1. notification/test1") - const olderNotificationHeader = screen.getByText("1. notification/test1"); + // 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:")).toBeInTheDocument(); + expect(screen.getByText("Details:")).toBeTruthy(); // Add a new notification at the beginning (simulating real behavior) const newNotifications: ServerNotification[] = [ { - method: "notification/new", - params: { message: "New notification" }, + method: "notifications/resources/updated", + params: { uri: "file://test.txt" }, }, ...mockNotifications, ]; @@ -152,13 +162,13 @@ describe("HistoryAndNotifications", () => { ); // The original notification should still be expanded - // It's now numbered as "2. notification/test1" due to the new item - expect(screen.getByText("3. notification/test2")).toBeInTheDocument(); - expect(screen.getByText("2. notification/test1")).toBeInTheDocument(); - expect(screen.getByText("1. notification/new")).toBeInTheDocument(); + // 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:")).toBeInTheDocument(); + expect(screen.getByText("Details:")).toBeTruthy(); }); it("maintains expanded state when new requests are added", () => { @@ -174,8 +184,8 @@ describe("HistoryAndNotifications", () => { fireEvent.click(olderRequestHeader); // Verify it's expanded - expect(screen.getByText("Request:")).toBeInTheDocument(); - expect(screen.getByText("Response:")).toBeInTheDocument(); + expect(screen.getByText("Request:")).toBeTruthy(); + expect(screen.getByText("Response:")).toBeTruthy(); // Add a new request at the beginning const newRequestHistory = [ @@ -196,13 +206,13 @@ describe("HistoryAndNotifications", () => { // 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")).toBeInTheDocument(); - expect(screen.getByText("2. test/method1")).toBeInTheDocument(); - expect(screen.getByText("1. test/new_method")).toBeInTheDocument(); + 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:")).toBeInTheDocument(); - expect(screen.getByText("Response:")).toBeInTheDocument(); + expect(screen.getByText("Request:")).toBeTruthy(); + expect(screen.getByText("Response:")).toBeTruthy(); }); it("displays empty state messages when no data is available", () => { @@ -210,7 +220,7 @@ describe("HistoryAndNotifications", () => { , ); - expect(screen.getByText("No history yet")).toBeInTheDocument(); - expect(screen.getByText("No notifications yet")).toBeInTheDocument(); + expect(screen.getByText("No history yet")).toBeTruthy(); + expect(screen.getByText("No notifications yet")).toBeTruthy(); }); }); From f5dba84051f4ae61d7bf8c121cea1ed09b55be98 Mon Sep 17 00:00:00 2001 From: Mark Anthony Cianfrani Date: Sat, 21 Jun 2025 11:57:42 -0400 Subject: [PATCH 23/36] fix: allow typing negative numbers in tool parameter inputs Number inputs were immediately converting values to Number() on every keystroke, which prevented users from typing negative numbers since the intermediate "-" character cannot be converted to a valid number. Now the input stores the raw string value and only converts to Number when the value is non-empty, allowing proper negative number entry. --- client/src/components/ToolsTab.tsx | 9 ++++---- .../components/__tests__/ToolsTab.test.tsx | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 8a7f6578..2b139ed6 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -163,12 +163,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__/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; From c0e9899cd4716e231aaadc34dc893ba9022c4217 Mon Sep 17 00:00:00 2001 From: Manabu McCloskey Date: Mon, 23 Jun 2025 22:58:54 +0000 Subject: [PATCH 24/36] fix connection refused error message Signed-off-by: Manabu McCloskey --- server/src/mcpProxy.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/server/src/mcpProxy.ts b/server/src/mcpProxy.ts index 9dee9de4..d37e312f 100644 --- a/server/src/mcpProxy.ts +++ b/server/src/mcpProxy.ts @@ -6,11 +6,7 @@ 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 { console.error("Error from MCP server:", error); From 7dc7b09cd4b01acd6ba2d3bf31890d94e47ccf37 Mon Sep 17 00:00:00 2001 From: Sebastian Ko Date: Wed, 25 Jun 2025 00:45:03 -0400 Subject: [PATCH 25/36] Change var to let --- client/src/utils/configUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/utils/configUtils.ts b/client/src/utils/configUtils.ts index 43da9188..f7bd4bb9 100644 --- a/client/src/utils/configUtils.ts +++ b/client/src/utils/configUtils.ts @@ -5,7 +5,7 @@ import { } from "@/lib/constants"; export const getMCPProxyAddress = (config: InspectorConfig): string => { - var 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; From 9fcfd89326361ce69e1d97af219847ff478228a8 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 24 Jun 2025 11:42:45 +0100 Subject: [PATCH 26/36] fix: pass proxy port via URL params when non-default - Update client to read MCP_PROXY_PORT from URL parameters - Update server to include port in displayed URL when non-default - Update start scripts to pass SERVER_PORT to client - Use DEFAULT_MCP_PROXY_LISTEN_PORT constant instead of magic numbers This fixes the issue where the client couldn't connect to the proxy when SERVER_PORT was set to a non-default value. --- client/bin/start.js | 57 ++++++++++++++++++++++++++------- client/src/utils/configUtils.ts | 25 +++++++++------ server/src/index.ts | 18 +++++++++-- 3 files changed, 77 insertions(+), 23 deletions(-) diff --git a/client/bin/start.js b/client/bin/start.js index bea3031f..96556790 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -7,17 +7,29 @@ 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}`; + + if (authDisabled) { + return baseUrl; + } + + const params = new URLSearchParams(); + params.set("MCP_PROXY_AUTH_TOKEN", sessionToken); + + // Add server port if it's not the default + if (serverPort && serverPort !== DEFAULT_MCP_PROXY_LISTEN_PORT) { + params.set("MCP_PROXY_PORT", serverPort); + } + + return `${baseUrl}/?${params.toString()}`; } async function startDevServer(serverOptions) { @@ -107,8 +119,14 @@ 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]; @@ -122,7 +140,12 @@ async function startDevClient(clientOptions) { // 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(() => { @@ -146,8 +169,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, "../..", @@ -158,7 +187,12 @@ async function startProdClient(clientOptions) { // Only auto-open browser if not cancelled if (process.env.MCP_AUTO_OPEN_ENABLED !== "false" && !cancelled) { - const url = getClientUrl(CLIENT_PORT, authDisabled, sessionToken); + const url = getClientUrl( + CLIENT_PORT, + authDisabled, + sessionToken, + SERVER_PORT, + ); open(url); } @@ -210,7 +244,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 +289,7 @@ async function main() { try { const clientOptions = { CLIENT_PORT, + SERVER_PORT, authDisabled, sessionToken, abort, diff --git a/client/src/utils/configUtils.ts b/client/src/utils/configUtils.ts index 40e47a94..85319497 100644 --- a/client/src/utils/configUtils.ts +++ b/client/src/utils/configUtils.ts @@ -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" diff --git a/server/src/index.ts b/server/src/index.ts index 89def8ba..3e02cad1 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,7 +529,10 @@ 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); @@ -543,7 +547,17 @@ server.on("listening", () => { // 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}`; + + // Build URL with query parameters + const params = new URLSearchParams(); + params.set("MCP_PROXY_AUTH_TOKEN", sessionToken); + + // Add server port if it's not the default + if (PORT !== parseInt(DEFAULT_MCP_PROXY_LISTEN_PORT, 10)) { + params.set("MCP_PROXY_PORT", PORT.toString()); + } + + const clientUrl = `http://${clientHost}:${clientPort}/?${params.toString()}`; console.log( `\nšŸ”— Open inspector with token pre-filled:\n ${clientUrl}\n`, ); From 9e9419bf21df5d366e17cb0a96c224ba3e277813 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 26 Jun 2025 10:33:58 +0100 Subject: [PATCH 27/36] fix: ensure MCP_PROXY_PORT is included when auth is disabled Address PR feedback by restructuring getClientUrl to always include MCP_PROXY_PORT parameter when using non-default port, regardless of auth status. Also improved code clarity by using params.size check. --- client/bin/start.js | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/client/bin/start.js b/client/bin/start.js index 96556790..e5e32832 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -17,19 +17,14 @@ function getClientUrl(port, authDisabled, sessionToken, serverPort) { const host = process.env.HOST || "localhost"; const baseUrl = `http://${host}:${port}`; - if (authDisabled) { - return baseUrl; - } - const params = new URLSearchParams(); - params.set("MCP_PROXY_AUTH_TOKEN", sessionToken); - - // Add server port if it's not the default if (serverPort && serverPort !== DEFAULT_MCP_PROXY_LISTEN_PORT) { params.set("MCP_PROXY_PORT", serverPort); } - - return `${baseUrl}/?${params.toString()}`; + if (!authDisabled) { + params.set("MCP_PROXY_AUTH_TOKEN", sessionToken); + } + return params.size > 0 ? `${baseUrl}/?${params.toString()}` : baseUrl; } async function startDevServer(serverOptions) { From ae1aeb3c3ffc4feed9b83e02f4e22c3711a81c10 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 27 Jun 2025 13:08:29 +0100 Subject: [PATCH 28/36] fix: improve logging consistency between dev and prod client startup Refactored startProdClient to match startDevClient's logging pattern, ensuring consistent user feedback whether auto-open is enabled or disabled. Also ensures that we log full information on how to open the browser regardless of whether MCP_AUTO_OPEN_ENABLED=false. --- client/bin/start.js | 49 +++++++++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/client/bin/start.js b/client/bin/start.js index e5e32832..8fa246cb 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -133,21 +133,21 @@ async function startDevClient(clientOptions) { echoOutput: true, }); - // Auto-open browser after vite starts - if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { - const url = getClientUrl( - CLIENT_PORT, - authDisabled, - sessionToken, - SERVER_PORT, - ); + 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({ @@ -180,15 +180,20 @@ 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, - SERVER_PORT, - ); - open(url); + const url = getClientUrl( + CLIENT_PORT, + authDisabled, + sessionToken, + SERVER_PORT, + ); + + // Handle auto-open and logging + if (!cancelled) { + console.log(`\nšŸš€ MCP Inspector is up and running at:\n ${url}\n`); + if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { + console.log(`\n🌐 Opening browser...:\n`); + open(url); + } } await spawnPromise("node", [inspectorClientPath], { From 042e7a2aa79939fc2f677531b8d4180cca176c7c Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 27 Jun 2025 14:46:40 +0100 Subject: [PATCH 29/36] refactor: move browser auto-open logic to client process Simplifies the startup flow by consolidating browser opening in the client script rather than the start script for prod. We were duplicating a lot of logging, making it easy for logs to go out of sync - this is a proposal to consolidate: - server only logs info about itself - client only logs info about itself To reduce the number of places we print connection information to the user. --- client/bin/client.js | 10 +++++++--- client/bin/start.js | 15 +++++---------- server/src/index.ts | 22 ++-------------------- 3 files changed, 14 insertions(+), 33 deletions(-) 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 8fa246cb..507026cc 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -187,17 +187,12 @@ async function startProdClient(clientOptions) { SERVER_PORT, ); - // Handle auto-open and logging - if (!cancelled) { - console.log(`\nšŸš€ MCP Inspector is up and running at:\n ${url}\n`); - if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { - console.log(`\n🌐 Opening browser...:\n`); - open(url); - } - } - await spawnPromise("node", [inspectorClientPath], { - env: { ...process.env, CLIENT_PORT: CLIENT_PORT }, + env: { + ...process.env, + CLIENT_PORT: CLIENT_PORT, + INSPECTOR_URL: url, + }, signal: abort.signal, echoOutput: true, }); diff --git a/server/src/index.ts b/server/src/index.ts index 3e02cad1..971cf158 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -539,27 +539,9 @@ 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"; - - // Build URL with query parameters - const params = new URLSearchParams(); - params.set("MCP_PROXY_AUTH_TOKEN", sessionToken); - - // Add server port if it's not the default - if (PORT !== parseInt(DEFAULT_MCP_PROXY_LISTEN_PORT, 10)) { - params.set("MCP_PROXY_PORT", PORT.toString()); - } - - const clientUrl = `http://${clientHost}:${clientPort}/?${params.toString()}`; - 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( From 1aea3374ff14b3bd0f50d2236cf9c44d5a362c0f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 27 Jun 2025 17:40:22 +0100 Subject: [PATCH 30/36] refactor: use ES6 shorthand property syntax in start.js Replace explicit property assignments with shorthand syntax when property names match variable names (e.g., SERVER_PORT: SERVER_PORT becomes just SERVER_PORT). --- client/bin/start.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/bin/start.js b/client/bin/start.js index 507026cc..70ca046e 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -38,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), }, @@ -97,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), }, @@ -128,7 +128,7 @@ async function startDevClient(clientOptions) { 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, }); @@ -190,7 +190,7 @@ async function startProdClient(clientOptions) { await spawnPromise("node", [inspectorClientPath], { env: { ...process.env, - CLIENT_PORT: CLIENT_PORT, + CLIENT_PORT, INSPECTOR_URL: url, }, signal: abort.signal, From 80082e127278e498e027c74fa6ff26d615a974e0 Mon Sep 17 00:00:00 2001 From: Baruch Odem Date: Sun, 29 Jun 2025 07:46:25 +0300 Subject: [PATCH 31/36] chore: update Node.js version requirement and adjust workflows to use package.json for node version --- .github/workflows/e2e_tests.yml | 6 ++++-- .github/workflows/main.yml | 4 ++-- package.json | 3 +++ 3 files changed, 9 insertions(+), 4 deletions(-) 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 199e33a4..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-file: .node-version + 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-file: .node-version + node-version-file: package.json cache: npm registry-url: "https://registry.npmjs.org" diff --git a/package.json b/package.json index de02890d..722ca3d7 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.0.0" } } From 0027e870cc6bd7bb2665cf92426a44c104abb3dc Mon Sep 17 00:00:00 2001 From: Baruch Odem Date: Tue, 1 Jul 2025 08:25:39 +0300 Subject: [PATCH 32/36] follow the README https://github.com/modelcontextprotocol/inspector/pull/318 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 722ca3d7..5d5fe52f 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,6 @@ "typescript": "^5.4.2" }, "engines": { - "node": ">=22.0.0" + "node": ">=22.7.5" } } From daf5f0af382bec502cc33cec87a1e1b4067f7781 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Tue, 1 Jul 2025 09:30:36 -0700 Subject: [PATCH 33/36] Full-width #root --- client/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 -
+
From 0f2548e71d49a7a14eb844e264431456028fe9b9 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Tue, 1 Jul 2025 09:31:19 -0700 Subject: [PATCH 34/36] Remove unnecessary margin property --- client/src/App.css | 4 ---- 1 file changed, 4 deletions(-) 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; From 5868c6bad6075e7c9dd49c3eb9b4ef946ceed53f Mon Sep 17 00:00:00 2001 From: Manabu McCloskey Date: Thu, 3 Jul 2025 14:48:46 +0000 Subject: [PATCH 35/36] print explicit 404 message Signed-off-by: Manabu McCloskey --- server/src/mcpProxy.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/mcpProxy.ts b/server/src/mcpProxy.ts index d37e312f..664f1711 100644 --- a/server/src/mcpProxy.ts +++ b/server/src/mcpProxy.ts @@ -8,6 +8,8 @@ function onClientError(error: Error) { function onServerError(error: Error) { 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); } From d1e2498ad0e3fc0574eafd4288f6fe4572ae70e3 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Thu, 3 Jul 2025 21:28:00 -0700 Subject: [PATCH 36/36] Add engines to `package-lock.json` --- package-lock.json | 3 +++ 1 file changed, 3 insertions(+) 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": {