Merge branch 'main' into dcr-fallback-fix
This commit is contained in:
commit
25f721afc4
10 changed files with 865 additions and 50 deletions
|
|
@ -28,8 +28,15 @@ function getClientUrl(port, authDisabled, sessionToken, serverPort) {
|
|||
}
|
||||
|
||||
async function startDevServer(serverOptions) {
|
||||
const { SERVER_PORT, CLIENT_PORT, sessionToken, envVars, abort } =
|
||||
serverOptions;
|
||||
const {
|
||||
SERVER_PORT,
|
||||
CLIENT_PORT,
|
||||
sessionToken,
|
||||
envVars,
|
||||
abort,
|
||||
transport,
|
||||
serverUrl,
|
||||
} = serverOptions;
|
||||
const serverCommand = "npx";
|
||||
const serverArgs = ["tsx", "watch", "--clear-screen=false", "src/index.ts"];
|
||||
const isWindows = process.platform === "win32";
|
||||
|
|
@ -42,6 +49,8 @@ async function startDevServer(serverOptions) {
|
|||
CLIENT_PORT,
|
||||
MCP_PROXY_AUTH_TOKEN: sessionToken,
|
||||
MCP_ENV_VARS: JSON.stringify(envVars),
|
||||
...(transport ? { MCP_TRANSPORT: transport } : {}),
|
||||
...(serverUrl ? { MCP_SERVER_URL: serverUrl } : {}),
|
||||
},
|
||||
signal: abort.signal,
|
||||
echoOutput: true,
|
||||
|
|
@ -78,6 +87,8 @@ async function startProdServer(serverOptions) {
|
|||
abort,
|
||||
command,
|
||||
mcpServerArgs,
|
||||
transport,
|
||||
serverUrl,
|
||||
} = serverOptions;
|
||||
const inspectorServerPath = resolve(
|
||||
__dirname,
|
||||
|
|
@ -95,6 +106,8 @@ async function startProdServer(serverOptions) {
|
|||
...(mcpServerArgs && mcpServerArgs.length > 0
|
||||
? [`--args=${mcpServerArgs.join(" ")}`]
|
||||
: []),
|
||||
...(transport ? [`--transport=${transport}`] : []),
|
||||
...(serverUrl ? [`--server-url=${serverUrl}`] : []),
|
||||
],
|
||||
{
|
||||
env: {
|
||||
|
|
@ -208,6 +221,8 @@ async function main() {
|
|||
let command = null;
|
||||
let parsingFlags = true;
|
||||
let isDev = false;
|
||||
let transport = null;
|
||||
let serverUrl = null;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
|
@ -222,6 +237,16 @@ async function main() {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (parsingFlags && arg === "--transport" && i + 1 < args.length) {
|
||||
transport = args[++i];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsingFlags && arg === "--server-url" && i + 1 < args.length) {
|
||||
serverUrl = args[++i];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsingFlags && arg === "-e" && i + 1 < args.length) {
|
||||
const envVar = args[++i];
|
||||
const equalsIndex = envVar.indexOf("=");
|
||||
|
|
@ -273,6 +298,8 @@ async function main() {
|
|||
abort,
|
||||
command,
|
||||
mcpServerArgs,
|
||||
transport,
|
||||
serverUrl,
|
||||
};
|
||||
|
||||
const result = isDev
|
||||
|
|
|
|||
65
client/e2e/cli-arguments.spec.ts
Normal file
65
client/e2e/cli-arguments.spec.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// These tests verify that CLI arguments correctly set URL parameters
|
||||
// The CLI should parse config files and pass transport/serverUrl as URL params
|
||||
test.describe("CLI Arguments @cli", () => {
|
||||
test("should pass transport parameter from command line", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Simulate: npx . --transport sse --server-url http://localhost:3000/sse
|
||||
await page.goto(
|
||||
"http://localhost:6274/?transport=sse&serverUrl=http://localhost:3000/sse",
|
||||
);
|
||||
|
||||
// Wait for the Transport Type dropdown to be visible
|
||||
const selectTrigger = page.getByLabel("Transport Type");
|
||||
await expect(selectTrigger).toBeVisible();
|
||||
|
||||
// Verify transport dropdown shows SSE
|
||||
await expect(selectTrigger).toContainText("SSE");
|
||||
|
||||
// Verify URL field is visible and populated
|
||||
const urlInput = page.locator("#sse-url-input");
|
||||
await expect(urlInput).toBeVisible();
|
||||
await expect(urlInput).toHaveValue("http://localhost:3000/sse");
|
||||
});
|
||||
|
||||
test("should pass transport parameter for streamable-http", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Simulate config with streamable-http transport
|
||||
await page.goto(
|
||||
"http://localhost:6274/?transport=streamable-http&serverUrl=http://localhost:3000/mcp",
|
||||
);
|
||||
|
||||
// Wait for the Transport Type dropdown to be visible
|
||||
const selectTrigger = page.getByLabel("Transport Type");
|
||||
await expect(selectTrigger).toBeVisible();
|
||||
|
||||
// Verify transport dropdown shows Streamable HTTP
|
||||
await expect(selectTrigger).toContainText("Streamable HTTP");
|
||||
|
||||
// Verify URL field is visible and populated
|
||||
const urlInput = page.locator("#sse-url-input");
|
||||
await expect(urlInput).toBeVisible();
|
||||
await expect(urlInput).toHaveValue("http://localhost:3000/mcp");
|
||||
});
|
||||
|
||||
test("should not pass transport parameter for stdio config", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Simulate stdio config (no transport param needed)
|
||||
await page.goto("http://localhost:6274/");
|
||||
|
||||
// Wait for the Transport Type dropdown to be visible
|
||||
const selectTrigger = page.getByLabel("Transport Type");
|
||||
await expect(selectTrigger).toBeVisible();
|
||||
|
||||
// Verify transport dropdown defaults to STDIO
|
||||
await expect(selectTrigger).toContainText("STDIO");
|
||||
|
||||
// Verify command/args fields are visible
|
||||
await expect(page.locator("#command-input")).toBeVisible();
|
||||
await expect(page.locator("#arguments-input")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector-client",
|
||||
"version": "0.16.2",
|
||||
"version": "0.16.3",
|
||||
"description": "Client-side application for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
|
|
|
|||
|
|
@ -455,6 +455,14 @@ const App = () => {
|
|||
if (data.defaultArgs) {
|
||||
setArgs(data.defaultArgs);
|
||||
}
|
||||
if (data.defaultTransport) {
|
||||
setTransportType(
|
||||
data.defaultTransport as "stdio" | "sse" | "streamable-http",
|
||||
);
|
||||
}
|
||||
if (data.defaultServerUrl) {
|
||||
setSseUrl(data.defaultServerUrl);
|
||||
}
|
||||
})
|
||||
.catch((error) =>
|
||||
console.error("Error fetching default environment:", error),
|
||||
|
|
|
|||
|
|
@ -15,12 +15,22 @@ interface DynamicJsonFormProps {
|
|||
maxDepth?: number;
|
||||
}
|
||||
|
||||
const isTypeSupported = (
|
||||
type: JsonSchemaType["type"],
|
||||
supportedTypes: string[],
|
||||
): boolean => {
|
||||
if (Array.isArray(type)) {
|
||||
return type.every((t) => supportedTypes.includes(t));
|
||||
}
|
||||
return typeof type === "string" && supportedTypes.includes(type);
|
||||
};
|
||||
|
||||
const isSimpleObject = (schema: JsonSchemaType): boolean => {
|
||||
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
|
||||
if (schema.type && supportedTypes.includes(schema.type)) return true;
|
||||
if (schema.type && isTypeSupported(schema.type, supportedTypes)) return true;
|
||||
if (schema.type === "object") {
|
||||
return Object.values(schema.properties ?? {}).every(
|
||||
(prop) => prop.type && supportedTypes.includes(prop.type),
|
||||
(prop) => prop.type && isTypeSupported(prop.type, supportedTypes),
|
||||
);
|
||||
}
|
||||
if (schema.type === "array") {
|
||||
|
|
@ -181,7 +191,13 @@ const DynamicJsonForm = ({
|
|||
const isRequired =
|
||||
parentSchema?.required?.includes(propertyName || "") ?? false;
|
||||
|
||||
switch (propSchema.type) {
|
||||
let fieldType = propSchema.type;
|
||||
if (Array.isArray(fieldType)) {
|
||||
// Of the possible types, find the first non-null type to determine the control to render
|
||||
fieldType = fieldType.find((t) => t !== "null") ?? fieldType[0];
|
||||
}
|
||||
|
||||
switch (fieldType) {
|
||||
case "string": {
|
||||
if (
|
||||
propSchema.oneOf &&
|
||||
|
|
@ -337,6 +353,8 @@ const DynamicJsonForm = ({
|
|||
required={isRequired}
|
||||
/>
|
||||
);
|
||||
case "null":
|
||||
return null;
|
||||
case "object":
|
||||
if (!propSchema.properties) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@ 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, isPropertyRequired } from "@/utils/schemaUtils";
|
||||
import {
|
||||
generateDefaultValue,
|
||||
isPropertyRequired,
|
||||
normalizeUnionType,
|
||||
} from "@/utils/schemaUtils";
|
||||
import {
|
||||
CompatibilityCallToolResult,
|
||||
ListToolsResult,
|
||||
|
|
@ -104,7 +108,7 @@ const ToolsTab = ({
|
|||
</p>
|
||||
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
||||
([key, value]) => {
|
||||
const prop = value as JsonSchemaType;
|
||||
const prop = normalizeUnionType(value as JsonSchemaType);
|
||||
const inputSchema =
|
||||
selectedTool.inputSchema as JsonSchemaType;
|
||||
const required = isPropertyRequired(key, inputSchema);
|
||||
|
|
@ -148,7 +152,10 @@ const ToolsTab = ({
|
|||
onChange={(e) =>
|
||||
setParams({
|
||||
...params,
|
||||
[key]: e.target.value,
|
||||
[key]:
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: e.target.value,
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
|
|
|
|||
|
|
@ -35,6 +35,18 @@ describe("DynamicJsonForm String Fields", () => {
|
|||
const input = screen.getByRole("textbox");
|
||||
expect(input).toHaveProperty("type", "text");
|
||||
});
|
||||
|
||||
it("should handle a union type of string and null", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: ["string", "null"],
|
||||
description: "Test string or null field",
|
||||
};
|
||||
render(
|
||||
<DynamicJsonForm schema={schema} value={null} onChange={jest.fn()} />,
|
||||
);
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toHaveProperty("type", "text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Format Support", () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
generateDefaultValue,
|
||||
formatFieldLabel,
|
||||
normalizeUnionType,
|
||||
cacheToolOutputSchemas,
|
||||
getToolOutputValidator,
|
||||
validateToolOutput,
|
||||
|
|
@ -142,6 +143,189 @@ describe("formatFieldLabel", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("normalizeUnionType", () => {
|
||||
test("normalizes anyOf with string and null to string type", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
description: "Optional string parameter",
|
||||
};
|
||||
|
||||
const normalized = normalizeUnionType(schema);
|
||||
|
||||
expect(normalized.type).toBe("string");
|
||||
expect(normalized.anyOf).toBeUndefined();
|
||||
expect(normalized.description).toBe("Optional string parameter");
|
||||
});
|
||||
|
||||
test("normalizes anyOf with boolean and null to boolean type", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
anyOf: [{ type: "boolean" }, { type: "null" }],
|
||||
description: "Optional boolean parameter",
|
||||
};
|
||||
|
||||
const normalized = normalizeUnionType(schema);
|
||||
|
||||
expect(normalized.type).toBe("boolean");
|
||||
expect(normalized.anyOf).toBeUndefined();
|
||||
expect(normalized.description).toBe("Optional boolean parameter");
|
||||
});
|
||||
|
||||
test("normalizes array type with string and null to string type", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: ["string", "null"],
|
||||
description: "Optional string parameter",
|
||||
};
|
||||
|
||||
const normalized = normalizeUnionType(schema);
|
||||
|
||||
expect(normalized.type).toBe("string");
|
||||
expect(normalized.description).toBe("Optional string parameter");
|
||||
});
|
||||
|
||||
test("normalizes array type with boolean and null to boolean type", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: ["boolean", "null"],
|
||||
description: "Optional boolean parameter",
|
||||
};
|
||||
|
||||
const normalized = normalizeUnionType(schema);
|
||||
|
||||
expect(normalized.type).toBe("boolean");
|
||||
expect(normalized.description).toBe("Optional boolean parameter");
|
||||
});
|
||||
|
||||
test("normalizes anyOf with number and null to number type", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
anyOf: [{ type: "number" }, { type: "null" }],
|
||||
description: "Optional number parameter",
|
||||
};
|
||||
|
||||
const normalized = normalizeUnionType(schema);
|
||||
|
||||
expect(normalized.type).toBe("number");
|
||||
expect(normalized.anyOf).toBeUndefined();
|
||||
expect(normalized.description).toBe("Optional number parameter");
|
||||
});
|
||||
|
||||
test("normalizes anyOf with integer and null to integer type", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
anyOf: [{ type: "integer" }, { type: "null" }],
|
||||
description: "Optional integer parameter",
|
||||
};
|
||||
|
||||
const normalized = normalizeUnionType(schema);
|
||||
|
||||
expect(normalized.type).toBe("integer");
|
||||
expect(normalized.anyOf).toBeUndefined();
|
||||
expect(normalized.description).toBe("Optional integer parameter");
|
||||
});
|
||||
|
||||
test("normalizes array type with number and null to number type", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: ["number", "null"],
|
||||
description: "Optional number parameter",
|
||||
};
|
||||
|
||||
const normalized = normalizeUnionType(schema);
|
||||
|
||||
expect(normalized.type).toBe("number");
|
||||
expect(normalized.description).toBe("Optional number parameter");
|
||||
});
|
||||
|
||||
test("normalizes array type with integer and null to integer type", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: ["integer", "null"],
|
||||
description: "Optional integer parameter",
|
||||
};
|
||||
|
||||
const normalized = normalizeUnionType(schema);
|
||||
|
||||
expect(normalized.type).toBe("integer");
|
||||
expect(normalized.description).toBe("Optional integer parameter");
|
||||
});
|
||||
|
||||
test("handles anyOf with reversed order (null first)", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
anyOf: [{ type: "null" }, { type: "string" }],
|
||||
};
|
||||
|
||||
const normalized = normalizeUnionType(schema);
|
||||
|
||||
expect(normalized.type).toBe("string");
|
||||
expect(normalized.anyOf).toBeUndefined();
|
||||
});
|
||||
|
||||
test("leaves non-union schemas unchanged", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "string",
|
||||
description: "Regular string parameter",
|
||||
};
|
||||
|
||||
const normalized = normalizeUnionType(schema);
|
||||
|
||||
expect(normalized).toEqual(schema);
|
||||
});
|
||||
|
||||
test("leaves anyOf with non-matching types unchanged", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
anyOf: [{ type: "string" }, { type: "number" }],
|
||||
};
|
||||
|
||||
const normalized = normalizeUnionType(schema);
|
||||
|
||||
expect(normalized).toEqual(schema);
|
||||
});
|
||||
|
||||
test("leaves anyOf with more than two types unchanged", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }],
|
||||
};
|
||||
|
||||
const normalized = normalizeUnionType(schema);
|
||||
|
||||
expect(normalized).toEqual(schema);
|
||||
});
|
||||
|
||||
test("leaves array type with non-matching types unchanged", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: ["string", "number"],
|
||||
};
|
||||
|
||||
const normalized = normalizeUnionType(schema);
|
||||
|
||||
expect(normalized).toEqual(schema);
|
||||
});
|
||||
|
||||
test("handles schemas without type or anyOf", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
description: "Schema without type",
|
||||
};
|
||||
|
||||
const normalized = normalizeUnionType(schema);
|
||||
|
||||
expect(normalized).toEqual(schema);
|
||||
});
|
||||
|
||||
test("preserves other properties when normalizing", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
description: "Optional string",
|
||||
minLength: 1,
|
||||
maxLength: 100,
|
||||
pattern: "^[a-z]+$",
|
||||
};
|
||||
|
||||
const normalized = normalizeUnionType(schema);
|
||||
|
||||
expect(normalized.type).toBe("string");
|
||||
expect(normalized.anyOf).toBeUndefined();
|
||||
expect(normalized.description).toBe("Optional string");
|
||||
expect(normalized.minLength).toBe(1);
|
||||
expect(normalized.maxLength).toBe(100);
|
||||
expect(normalized.pattern).toBe("^[a-z]+$");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Output Schema Validation", () => {
|
||||
const mockTools: Tool[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -21,7 +21,16 @@ export type JsonSchemaType = {
|
|||
| "boolean"
|
||||
| "array"
|
||||
| "object"
|
||||
| "null";
|
||||
| "null"
|
||||
| (
|
||||
| "string"
|
||||
| "number"
|
||||
| "integer"
|
||||
| "boolean"
|
||||
| "array"
|
||||
| "object"
|
||||
| "null"
|
||||
)[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
required?: string[];
|
||||
|
|
|
|||
|
|
@ -145,6 +145,95 @@ export function isPropertyRequired(
|
|||
return schema.required?.includes(propertyName) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes union types (like string|null from FastMCP) to simple types for form rendering
|
||||
* @param schema The JSON schema to normalize
|
||||
* @returns A normalized schema or the original schema
|
||||
*/
|
||||
export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
|
||||
// Handle anyOf with exactly string and null (FastMCP pattern)
|
||||
if (
|
||||
schema.anyOf &&
|
||||
schema.anyOf.length === 2 &&
|
||||
schema.anyOf.some((t) => (t as JsonSchemaType).type === "string") &&
|
||||
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
|
||||
) {
|
||||
return { ...schema, type: "string", anyOf: undefined };
|
||||
}
|
||||
|
||||
// Handle anyOf with exactly boolean and null (FastMCP pattern)
|
||||
if (
|
||||
schema.anyOf &&
|
||||
schema.anyOf.length === 2 &&
|
||||
schema.anyOf.some((t) => (t as JsonSchemaType).type === "boolean") &&
|
||||
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
|
||||
) {
|
||||
return { ...schema, type: "boolean", anyOf: undefined };
|
||||
}
|
||||
|
||||
// Handle anyOf with exactly number and null (FastMCP pattern)
|
||||
if (
|
||||
schema.anyOf &&
|
||||
schema.anyOf.length === 2 &&
|
||||
schema.anyOf.some((t) => (t as JsonSchemaType).type === "number") &&
|
||||
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
|
||||
) {
|
||||
return { ...schema, type: "number", anyOf: undefined };
|
||||
}
|
||||
|
||||
// Handle anyOf with exactly integer and null (FastMCP pattern)
|
||||
if (
|
||||
schema.anyOf &&
|
||||
schema.anyOf.length === 2 &&
|
||||
schema.anyOf.some((t) => (t as JsonSchemaType).type === "integer") &&
|
||||
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
|
||||
) {
|
||||
return { ...schema, type: "integer", anyOf: undefined };
|
||||
}
|
||||
|
||||
// Handle array type with exactly string and null
|
||||
if (
|
||||
Array.isArray(schema.type) &&
|
||||
schema.type.length === 2 &&
|
||||
schema.type.includes("string") &&
|
||||
schema.type.includes("null")
|
||||
) {
|
||||
return { ...schema, type: "string" };
|
||||
}
|
||||
|
||||
// Handle array type with exactly boolean and null
|
||||
if (
|
||||
Array.isArray(schema.type) &&
|
||||
schema.type.length === 2 &&
|
||||
schema.type.includes("boolean") &&
|
||||
schema.type.includes("null")
|
||||
) {
|
||||
return { ...schema, type: "boolean" };
|
||||
}
|
||||
|
||||
// Handle array type with exactly number and null
|
||||
if (
|
||||
Array.isArray(schema.type) &&
|
||||
schema.type.length === 2 &&
|
||||
schema.type.includes("number") &&
|
||||
schema.type.includes("null")
|
||||
) {
|
||||
return { ...schema, type: "number" };
|
||||
}
|
||||
|
||||
// Handle array type with exactly integer and null
|
||||
if (
|
||||
Array.isArray(schema.type) &&
|
||||
schema.type.length === 2 &&
|
||||
schema.type.includes("integer") &&
|
||||
schema.type.includes("null")
|
||||
) {
|
||||
return { ...schema, type: "integer" };
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a field key into a human-readable label
|
||||
* @param key The field key to format
|
||||
|
|
|
|||
Loading…
Reference in a new issue