From d7cc0fae2a230141f9fd50f5182958ca5586542d Mon Sep 17 00:00:00 2001 From: Baruch Odem Date: Fri, 30 May 2025 15:08:40 +0300 Subject: [PATCH 01/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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 9c5c7575c0c095d0134bfb1338252a6fee1f98a0 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 18 Jun 2025 17:14:07 +0100 Subject: [PATCH 25/43] fix: Bind client to localhost by default to prevent DNS rebinding attacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the security hardening started in e8e9909 by also binding the client to localhost only. Previously only the server was protected while the client remained exposed to the network, allowing attackers to access the server through the client as a proxy. Changes: - Add HOST environment variable support to client (prod mode) - Configure Vite dev server to bind to localhost by default - Update browser auto-open URLs to use actual host instead of hardcoded 127.0.0.1 - Fix missing cancelled parameter in startProdClient function šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 4 ++-- client/bin/client.js | 7 ++++--- client/bin/start.js | 20 ++++++++++++-------- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 94efe83c..1a5bffff 100644 --- a/README.md +++ b/README.md @@ -168,13 +168,13 @@ DANGEROUSLY_OMIT_AUTH=true npm start #### Local-only Binding -By default, the MCP Inspector proxy server binds only to `127.0.0.1` (localhost) to prevent network access. This ensures the server is not accessible from other devices on the network. If you need to bind to all interfaces for development purposes, you can override this with the `HOST` environment variable: +By default, both the MCP Inspector proxy server and client bind only to `127.0.0.1` (localhost) to prevent network access. This ensures they are not accessible from other devices on the network. If you need to bind to all interfaces for development purposes, you can override this with the `HOST` environment variable: ```bash HOST=0.0.0.0 npm start ``` -**Warning:** Only bind to all interfaces in trusted network environments, as this exposes the proxy server's ability to execute local processes. +**Warning:** Only bind to all interfaces in trusted network environments, as this exposes the proxy server's ability to execute local processes and both services to network access. #### DNS Rebinding Protection diff --git a/client/bin/client.js b/client/bin/client.js index 7179e19e..45fc2421 100755 --- a/client/bin/client.js +++ b/client/bin/client.js @@ -40,18 +40,19 @@ const server = http.createServer((request, response) => { }); const port = process.env.PORT || 6274; +const host = process.env.HOST || "127.0.0.1"; server.on("listening", () => { console.log( - `šŸ” MCP Inspector is up and running at http://127.0.0.1:${port} šŸš€`, + `šŸ” MCP Inspector is up and running at http://${host}:${port} šŸš€`, ); }); server.on("error", (err) => { if (err.message.includes(`EADDRINUSE`)) { console.error( - `āŒ MCP Inspector PORT IS IN USE at http://127.0.0.1:${port} āŒ `, + `āŒ MCP Inspector PORT IS IN USE at http://${host}:${port} āŒ `, ); } else { throw err; } }); -server.listen(port); +server.listen(port, host); diff --git a/client/bin/start.js b/client/bin/start.js index ad75078a..54b125e9 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -102,7 +102,8 @@ async function startDevClient(clientOptions) { const { CLIENT_PORT, authDisabled, sessionToken, abort, cancelled } = clientOptions; const clientCommand = "npx"; - const clientArgs = ["vite", "--port", CLIENT_PORT]; + const host = process.env.HOST || "127.0.0.1"; + const clientArgs = ["vite", "--port", CLIENT_PORT, "--host", host]; const client = spawn(clientCommand, clientArgs, { cwd: resolve(__dirname, ".."), @@ -113,9 +114,10 @@ async function startDevClient(clientOptions) { // Auto-open browser after vite starts if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { + const clientHost = process.env.HOST || "127.0.0.1"; const url = authDisabled - ? `http://127.0.0.1:${CLIENT_PORT}` - : `http://127.0.0.1:${CLIENT_PORT}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`; + ? `http://${clientHost}:${CLIENT_PORT}` + : `http://${clientHost}:${CLIENT_PORT}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`; // Give vite time to start before opening browser setTimeout(() => { @@ -139,7 +141,8 @@ async function startDevClient(clientOptions) { } async function startProdClient(clientOptions) { - const { CLIENT_PORT, authDisabled, sessionToken, abort } = clientOptions; + const { CLIENT_PORT, authDisabled, sessionToken, abort, cancelled } = + clientOptions; const inspectorClientPath = resolve( __dirname, "../..", @@ -148,11 +151,12 @@ async function startProdClient(clientOptions) { "client.js", ); - // Auto-open browser with token - if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { + // Only auto-open browser if not cancelled + if (process.env.MCP_AUTO_OPEN_ENABLED !== "false" && !cancelled) { + const clientHost = process.env.HOST || "127.0.0.1"; const url = authDisabled - ? `http://127.0.0.1:${CLIENT_PORT}` - : `http://127.0.0.1:${CLIENT_PORT}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`; + ? `http://${clientHost}:${CLIENT_PORT}` + : `http://${clientHost}:${CLIENT_PORT}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`; open(url); } From fe2c5c767d7a7670f997a42cbe5d58ea2164d5ff Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 19 Jun 2025 11:47:04 +0100 Subject: [PATCH 26/43] refactor: deduplicate URL generation and standardize on localhost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract duplicated URL generation code into getClientUrl() helper function in start.js - Replace all 127.0.0.1 references with localhost for consistency across codebase - Update server to respect HOST environment variable for URL generation - Remove 127.0.0.1 from default allowed origins in CORS configuration - Update documentation to use localhost instead of 127.0.0.1 šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CONTRIBUTING.md | 2 +- README.md | 4 ++-- client/bin/client.js | 2 +- client/bin/start.js | 20 +++++++++++--------- server/src/index.ts | 13 ++++++------- 5 files changed, 21 insertions(+), 20 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1eeeb864..0129a9e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ Thanks for your interest in contributing! This guide explains how to get involve 1. Fork the repository and clone it locally 2. Install dependencies with `npm install` 3. Run `npm run dev` to start both client and server in development mode -4. Use the web UI at http://127.0.0.1:6274 to interact with the inspector +4. Use the web UI at http://localhost:6274 to interact with the inspector ## Development Process & Pull Requests diff --git a/README.md b/README.md index 1a5bffff..deec2a45 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ DANGEROUSLY_OMIT_AUTH=true npm start #### Local-only Binding -By default, both the MCP Inspector proxy server and client bind only to `127.0.0.1` (localhost) to prevent network access. This ensures they are not accessible from other devices on the network. If you need to bind to all interfaces for development purposes, you can override this with the `HOST` environment variable: +By default, both the MCP Inspector proxy server and client bind only to `localhost` to prevent network access. This ensures they are not accessible from other devices on the network. If you need to bind to all interfaces for development purposes, you can override this with the `HOST` environment variable: ```bash HOST=0.0.0.0 npm start @@ -181,7 +181,7 @@ HOST=0.0.0.0 npm start To prevent DNS rebinding attacks, the MCP Inspector validates the `Origin` header on incoming requests. By default, only requests from the client origin are allowed (respects `CLIENT_PORT` if set, defaulting to port 6274). You can configure additional allowed origins by setting the `ALLOWED_ORIGINS` environment variable (comma-separated list): ```bash -ALLOWED_ORIGINS=http://localhost:6274,http://127.0.0.1:6274,http://localhost:8000 npm start +ALLOWED_ORIGINS=http://localhost:6274,http://localhost:8000 npm start ``` ### Configuration diff --git a/client/bin/client.js b/client/bin/client.js index 45fc2421..7c35a131 100755 --- a/client/bin/client.js +++ b/client/bin/client.js @@ -40,7 +40,7 @@ const server = http.createServer((request, response) => { }); const port = process.env.PORT || 6274; -const host = process.env.HOST || "127.0.0.1"; +const host = process.env.HOST || "localhost"; server.on("listening", () => { console.log( `šŸ” MCP Inspector is up and running at http://${host}:${port} šŸš€`, diff --git a/client/bin/start.js b/client/bin/start.js index 54b125e9..51d3ef76 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -12,6 +12,14 @@ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms, true)); } +function getClientUrl(port, authDisabled, sessionToken) { + const host = process.env.HOST || "localhost"; + const baseUrl = `http://${host}:${port}`; + return authDisabled + ? baseUrl + : `${baseUrl}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`; +} + async function startDevServer(serverOptions) { const { SERVER_PORT, CLIENT_PORT, sessionToken, envVars, abort } = serverOptions; @@ -102,7 +110,7 @@ async function startDevClient(clientOptions) { const { CLIENT_PORT, authDisabled, sessionToken, abort, cancelled } = clientOptions; const clientCommand = "npx"; - const host = process.env.HOST || "127.0.0.1"; + const host = process.env.HOST || "localhost"; const clientArgs = ["vite", "--port", CLIENT_PORT, "--host", host]; const client = spawn(clientCommand, clientArgs, { @@ -114,10 +122,7 @@ async function startDevClient(clientOptions) { // Auto-open browser after vite starts if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { - const clientHost = process.env.HOST || "127.0.0.1"; - const url = authDisabled - ? `http://${clientHost}:${CLIENT_PORT}` - : `http://${clientHost}:${CLIENT_PORT}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`; + const url = getClientUrl(CLIENT_PORT, authDisabled, sessionToken); // Give vite time to start before opening browser setTimeout(() => { @@ -153,10 +158,7 @@ async function startProdClient(clientOptions) { // Only auto-open browser if not cancelled if (process.env.MCP_AUTO_OPEN_ENABLED !== "false" && !cancelled) { - const clientHost = process.env.HOST || "127.0.0.1"; - const url = authDisabled - ? `http://${clientHost}:${CLIENT_PORT}` - : `http://${clientHost}:${CLIENT_PORT}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`; + const url = getClientUrl(CLIENT_PORT, authDisabled, sessionToken); open(url); } diff --git a/server/src/index.ts b/server/src/index.ts index 7653597a..a18fa566 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -104,12 +104,10 @@ const originValidationMiddleware = ( // Default origins based on CLIENT_PORT or use environment variable const clientPort = process.env.CLIENT_PORT || "6274"; - const defaultOrigins = [ - `http://localhost:${clientPort}`, - `http://127.0.0.1:${clientPort}`, + const defaultOrigin = `http://localhost:${clientPort}`; + const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(",") || [ + defaultOrigin, ]; - const allowedOrigins = - process.env.ALLOWED_ORIGINS?.split(",") || defaultOrigins; if (origin && !allowedOrigins.includes(origin)) { console.error(`Invalid origin: ${origin}`); @@ -531,7 +529,7 @@ app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => { }); const PORT = parseInt(process.env.PORT || "6277", 10); -const HOST = process.env.HOST || "127.0.0.1"; +const HOST = process.env.HOST || "localhost"; const server = app.listen(PORT, HOST); server.on("listening", () => { @@ -544,7 +542,8 @@ server.on("listening", () => { // Display clickable URL with pre-filled token const clientPort = process.env.CLIENT_PORT || "6274"; - const clientUrl = `http://localhost:${clientPort}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`; + 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`, ); From d0673ba9e1142ba3e720dd4bb655cee7c416ead1 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 20 Jun 2025 18:58:52 +0100 Subject: [PATCH 27/43] refactor: Use specific SERVER_PORT and CLIENT_PORT env vars Per PR feedback from cliffhall: - Changed PORT to SERVER_PORT in server/src/index.ts - Changed PORT to CLIENT_PORT in client/bin/client.js - Added parseInt() for CLIENT_PORT consistency - Updated start.js to pass correct env var names This improves clarity by using specific environment variables for server and client ports instead of the generic PORT variable. --- client/bin/client.js | 2 +- client/bin/start.js | 8 ++++---- server/src/index.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/bin/client.js b/client/bin/client.js index 7c35a131..e25ecb7c 100755 --- a/client/bin/client.js +++ b/client/bin/client.js @@ -39,7 +39,7 @@ const server = http.createServer((request, response) => { return handler(request, response, handlerOptions); }); -const port = process.env.PORT || 6274; +const port = parseInt(process.env.CLIENT_PORT || "6274", 10); const host = process.env.HOST || "localhost"; server.on("listening", () => { console.log( diff --git a/client/bin/start.js b/client/bin/start.js index 51d3ef76..bea3031f 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -31,7 +31,7 @@ async function startDevServer(serverOptions) { cwd: resolve(__dirname, "../..", "server"), env: { ...process.env, - PORT: SERVER_PORT, + SERVER_PORT: SERVER_PORT, CLIENT_PORT: CLIENT_PORT, MCP_PROXY_TOKEN: sessionToken, MCP_ENV_VARS: JSON.stringify(envVars), @@ -90,7 +90,7 @@ async function startProdServer(serverOptions) { { env: { ...process.env, - PORT: SERVER_PORT, + SERVER_PORT: SERVER_PORT, CLIENT_PORT: CLIENT_PORT, MCP_PROXY_TOKEN: sessionToken, MCP_ENV_VARS: JSON.stringify(envVars), @@ -115,7 +115,7 @@ async function startDevClient(clientOptions) { const client = spawn(clientCommand, clientArgs, { cwd: resolve(__dirname, ".."), - env: { ...process.env, PORT: CLIENT_PORT }, + env: { ...process.env, CLIENT_PORT: CLIENT_PORT }, signal: abort.signal, echoOutput: true, }); @@ -163,7 +163,7 @@ async function startProdClient(clientOptions) { } await spawnPromise("node", [inspectorClientPath], { - env: { ...process.env, PORT: CLIENT_PORT }, + env: { ...process.env, CLIENT_PORT: CLIENT_PORT }, signal: abort.signal, echoOutput: true, }); diff --git a/server/src/index.ts b/server/src/index.ts index a18fa566..89def8ba 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -528,7 +528,7 @@ app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => { } }); -const PORT = parseInt(process.env.PORT || "6277", 10); +const PORT = parseInt(process.env.SERVER_PORT || "6277", 10); const HOST = process.env.HOST || "localhost"; const server = app.listen(PORT, HOST); From 7dc7b09cd4b01acd6ba2d3bf31890d94e47ccf37 Mon Sep 17 00:00:00 2001 From: Sebastian Ko Date: Wed, 25 Jun 2025 00:45:03 -0400 Subject: [PATCH 28/43] 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 29/43] 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 30/43] 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 31/43] 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 32/43] 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 33/43] 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 d6864b8c710b92baffd915a473e63a0f2d218289 Mon Sep 17 00:00:00 2001 From: Francisco Veiras <74626997+francisco-svg761@users.noreply.github.com> Date: Fri, 27 Jun 2025 17:30:30 -0300 Subject: [PATCH 34/43] feat: add copy JSON value --- client/src/components/DynamicJsonForm.tsx | 57 +++++++++++++++++++---- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index 6a5993c3..93cde052 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -5,6 +5,8 @@ import JsonEditor from "./JsonEditor"; import { updateValueAtPath } from "@/utils/jsonUtils"; import { generateDefaultValue } from "@/utils/schemaUtils"; import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils"; +import { useToast } from "@/lib/hooks/useToast"; +import { CheckCheck, Copy } from "lucide-react"; interface DynamicJsonFormProps { schema: JsonSchemaType; @@ -31,6 +33,9 @@ const DynamicJsonForm = ({ const isOnlyJSON = !isSimpleObject(schema); const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON); const [jsonError, setJsonError] = useState(); + const [copiedJson, setCopiedJson] = useState(false); + const { toast } = useToast(); + // Store the raw JSON string to allow immediate feedback during typing // while deferring parsing until the user stops typing const [rawJsonValue, setRawJsonValue] = useState( @@ -246,19 +251,55 @@ const DynamicJsonForm = ({ } }, [shouldUseJsonMode, isJsonMode]); + const handleCopyJson = useCallback(() => { + try { + navigator.clipboard + .writeText(JSON.stringify(value, null, 2) ?? "[]") + .then(() => { + setCopiedJson(true); + + toast({ + title: "JSON copied", + description: + "The JSON data has been successfully copied to your clipboard.", + }); + + setTimeout(() => { + setCopiedJson(false); + }, 2000); + }) + .catch((error) => { + reportError(error); + }); + } catch (error) { + reportError(error); + } + }, [toast, value]); + return (
{isJsonMode && ( - + <> + + + )} + {!isOnlyJSON && ( + +
+
+ + {uri} + + {mimeType && ( + + {mimeType} + + )} +
+ + {name && ( +
+ {name} +
+ )} + + {description && ( +

+ {description} +

+ )} +
+
+ ); +}; + +export default ResourceLinkView; diff --git a/client/src/components/ToolResults.tsx b/client/src/components/ToolResults.tsx index c6d90700..cf6bb90d 100644 --- a/client/src/components/ToolResults.tsx +++ b/client/src/components/ToolResults.tsx @@ -1,4 +1,5 @@ import JsonView from "./JsonView"; +import ResourceLinkView from "./ResourceLinkView"; import { CallToolResultSchema, CompatibilityCallToolResult, @@ -200,6 +201,14 @@ const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => { ) : ( ))} + {item.type === "resource_link" && ( + + )}
))}
From 5868c6bad6075e7c9dd49c3eb9b4ef946ceed53f Mon Sep 17 00:00:00 2001 From: Manabu McCloskey Date: Thu, 3 Jul 2025 14:48:46 +0000 Subject: [PATCH 40/43] 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 b08aa6823693d562f3eead0c3ca62f169783dc8e Mon Sep 17 00:00:00 2001 From: Francisco Veiras <74626997+francisco-svg761@users.noreply.github.com> Date: Thu, 3 Jul 2025 20:04:53 -0300 Subject: [PATCH 41/43] test: DynamicJsonForm tests with Copy JSON functionality --- .../__tests__/DynamicJsonForm.test.tsx | 76 ++++++++++++++++++- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/client/src/components/__tests__/DynamicJsonForm.test.tsx b/client/src/components/__tests__/DynamicJsonForm.test.tsx index afd435d7..cd321418 100644 --- a/client/src/components/__tests__/DynamicJsonForm.test.tsx +++ b/client/src/components/__tests__/DynamicJsonForm.test.tsx @@ -117,8 +117,11 @@ describe("DynamicJsonForm Complex Fields", () => { const input = screen.getByRole("textbox"); expect(input).toHaveProperty("type", "textarea"); const buttons = screen.getAllByRole("button"); - expect(buttons).toHaveLength(1); - expect(buttons[0]).toHaveProperty("textContent", "Format JSON"); + expect(buttons).toHaveLength(2); // Copy JSON + Format JSON + const copyButton = screen.getByRole("button", { name: /copy json/i }); + const formatButton = screen.getByRole("button", { name: /format json/i }); + expect(copyButton).toBeTruthy(); + expect(formatButton).toBeTruthy(); }); it("should pass changed values to onChange", () => { @@ -137,3 +140,72 @@ describe("DynamicJsonForm Complex Fields", () => { }); }); }); + +describe("DynamicJsonForm Copy JSON Functionality", () => { + const mockWriteText = jest.fn(() => Promise.resolve()); + + beforeEach(() => { + jest.clearAllMocks(); + Object.assign(navigator, { + clipboard: { + writeText: mockWriteText, + }, + }); + }); + + const renderFormInJsonMode = (props = {}) => { + const defaultProps = { + schema: { + type: "object", + properties: { + nested: { oneOf: [{ type: "string" }, { type: "integer" }] }, + }, + } as unknown as JsonSchemaType, + value: { nested: "test value" }, + onChange: jest.fn(), + }; + return render(); + }; + + describe("Copy JSON Button", () => { + it("should render Copy JSON button when in JSON mode", () => { + renderFormInJsonMode(); + + const copyButton = screen.getByRole("button", { name: "Copy JSON" }); + expect(copyButton).toBeTruthy(); + }); + + it("should not render Copy JSON button when not in JSON mode", () => { + const simpleSchema = { + type: "string" as const, + description: "Test string field", + }; + + render( + , + ); + + const copyButton = screen.queryByRole("button", { name: "Copy JSON" }); + expect(copyButton).toBeNull(); + }); + + it("should copy JSON to clipboard when clicked", async () => { + const testValue = { nested: "test value", number: 42 }; + + renderFormInJsonMode({ value: testValue }); + + const copyButton = screen.getByRole("button", { name: "Copy JSON" }); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(mockWriteText).toHaveBeenCalledWith( + JSON.stringify(testValue, null, 2), + ); + }); + }); + }); +}); From d1e2498ad0e3fc0574eafd4288f6fe4572ae70e3 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Thu, 3 Jul 2025 21:28:00 -0700 Subject: [PATCH 42/43] 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": { From 9842a6278a71c9f5515bd24584b3a43b67be3a97 Mon Sep 17 00:00:00 2001 From: Francisco Veiras <74626997+francisco-svg761@users.noreply.github.com> Date: Sun, 6 Jul 2025 12:08:20 -0300 Subject: [PATCH 43/43] fix: Copy JSON button form submission --- client/src/components/DynamicJsonForm.tsx | 51 +++++++++++++---------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index 93cde052..70303aec 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -252,28 +252,32 @@ const DynamicJsonForm = ({ }, [shouldUseJsonMode, isJsonMode]); const handleCopyJson = useCallback(() => { - try { - navigator.clipboard - .writeText(JSON.stringify(value, null, 2) ?? "[]") - .then(() => { - setCopiedJson(true); + const copyToClipboard = async () => { + try { + await navigator.clipboard.writeText( + JSON.stringify(value, null, 2) ?? "[]", + ); + setCopiedJson(true); - toast({ - title: "JSON copied", - description: - "The JSON data has been successfully copied to your clipboard.", - }); - - setTimeout(() => { - setCopiedJson(false); - }, 2000); - }) - .catch((error) => { - reportError(error); + toast({ + title: "JSON copied", + description: + "The JSON data has been successfully copied to your clipboard.", }); - } catch (error) { - reportError(error); - } + + setTimeout(() => { + setCopiedJson(false); + }, 2000); + } catch (error) { + toast({ + title: "Error", + description: `Failed to copy JSON: ${error instanceof Error ? error.message : String(error)}`, + variant: "destructive", + }); + } + }; + + copyToClipboard(); }, [toast, value]); return ( @@ -281,7 +285,12 @@ const DynamicJsonForm = ({
{isJsonMode && ( <> -