From b555e41c0b3c87cbc08cade9a92e587ee4912155 Mon Sep 17 00:00:00 2001 From: olaservo Date: Sun, 1 Jun 2025 09:03:02 -0700 Subject: [PATCH 01/19] 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 02/19] 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 03/19] 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 04/19] 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 05/19] 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 06/19] 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 21:21:43 -0700 Subject: [PATCH 07/19] 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 08/19] 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 09/19] 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 10/19] 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 11/19] 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 b3e1b5cb6acd398619a4ce9e607a19c5c8402f67 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 11 Jun 2025 06:45:23 -0700 Subject: [PATCH 12/19] 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 13/19] 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 f5dba84051f4ae61d7bf8c121cea1ed09b55be98 Mon Sep 17 00:00:00 2001 From: Mark Anthony Cianfrani Date: Sat, 21 Jun 2025 11:57:42 -0400 Subject: [PATCH 14/19] 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 9fcfd89326361ce69e1d97af219847ff478228a8 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 24 Jun 2025 11:42:45 +0100 Subject: [PATCH 15/19] 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 16/19] 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 17/19] 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 18/19] 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 19/19] 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,