Merge branch 'main' into dcr-fallback-fix
This commit is contained in:
commit
25f721afc4
20 changed files with 865 additions and 50 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -14,3 +14,4 @@ sdk
|
||||||
client/playwright-report/
|
client/playwright-report/
|
||||||
client/results.json
|
client/results.json
|
||||||
client/test-results/
|
client/test-results/
|
||||||
|
mcp.json
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@ packages
|
||||||
server/build
|
server/build
|
||||||
CODE_OF_CONDUCT.md
|
CODE_OF_CONDUCT.md
|
||||||
SECURITY.md
|
SECURITY.md
|
||||||
|
mcp.json
|
||||||
|
|
|
||||||
72
README.md
72
README.md
|
|
@ -234,6 +234,78 @@ Example server configuration file:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Transport Types in Config Files
|
||||||
|
|
||||||
|
The inspector automatically detects the transport type from your config file. You can specify different transport types:
|
||||||
|
|
||||||
|
**STDIO (default):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"my-stdio-server": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["@modelcontextprotocol/server-everything"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**SSE (Server-Sent Events):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"my-sse-server": {
|
||||||
|
"type": "sse",
|
||||||
|
"url": "http://localhost:3000/sse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Streamable HTTP:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"my-http-server": {
|
||||||
|
"type": "streamable-http",
|
||||||
|
"url": "http://localhost:3000/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Default Server Selection
|
||||||
|
|
||||||
|
You can launch the inspector without specifying a server name if your config has:
|
||||||
|
|
||||||
|
1. **A single server** - automatically selected:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Automatically uses "my-server" if it's the only one
|
||||||
|
npx @modelcontextprotocol/inspector --config mcp.json
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **A server named "default-server"** - automatically selected:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"default-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["@modelcontextprotocol/server-everything"]
|
||||||
|
},
|
||||||
|
"other-server": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["other.js"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
> **Tip:** You can easily generate this configuration format using the **Server Entry** and **Servers File** buttons in the Inspector UI, as described in the Servers File Export section above.
|
> **Tip:** You can easily generate this configuration format using the **Server Entry** and **Servers File** buttons in the Inspector UI, as described in the Servers File Export section above.
|
||||||
|
|
||||||
You can also set the initial `transport` type, `serverUrl`, `serverCommand`, and `serverArgs` via query params, for example:
|
You can also set the initial `transport` type, `serverUrl`, `serverCommand`, and `serverArgs` via query params, for example:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-cli",
|
"name": "@modelcontextprotocol/inspector-cli",
|
||||||
"version": "0.16.2",
|
"version": "0.16.3",
|
||||||
"description": "CLI for the Model Context Protocol inspector",
|
"description": "CLI for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,85 @@ try {
|
||||||
const invalidConfigPath = path.join(TEMP_DIR, "invalid-config.json");
|
const invalidConfigPath = path.join(TEMP_DIR, "invalid-config.json");
|
||||||
fs.writeFileSync(invalidConfigPath, '{\n "mcpServers": {\n "invalid": {');
|
fs.writeFileSync(invalidConfigPath, '{\n "mcpServers": {\n "invalid": {');
|
||||||
|
|
||||||
|
// Create config files with different transport types for testing
|
||||||
|
const sseConfigPath = path.join(TEMP_DIR, "sse-config.json");
|
||||||
|
fs.writeFileSync(
|
||||||
|
sseConfigPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
mcpServers: {
|
||||||
|
"test-sse": {
|
||||||
|
type: "sse",
|
||||||
|
url: "http://localhost:3000/sse",
|
||||||
|
note: "Test SSE server",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const httpConfigPath = path.join(TEMP_DIR, "http-config.json");
|
||||||
|
fs.writeFileSync(
|
||||||
|
httpConfigPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
mcpServers: {
|
||||||
|
"test-http": {
|
||||||
|
type: "streamable-http",
|
||||||
|
url: "http://localhost:3000/mcp",
|
||||||
|
note: "Test HTTP server",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const stdioConfigPath = path.join(TEMP_DIR, "stdio-config.json");
|
||||||
|
fs.writeFileSync(
|
||||||
|
stdioConfigPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
mcpServers: {
|
||||||
|
"test-stdio": {
|
||||||
|
type: "stdio",
|
||||||
|
command: "npx",
|
||||||
|
args: ["@modelcontextprotocol/server-everything"],
|
||||||
|
env: {
|
||||||
|
TEST_ENV: "test-value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Config without type field (backward compatibility)
|
||||||
|
const legacyConfigPath = path.join(TEMP_DIR, "legacy-config.json");
|
||||||
|
fs.writeFileSync(
|
||||||
|
legacyConfigPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
mcpServers: {
|
||||||
|
"test-legacy": {
|
||||||
|
command: "npx",
|
||||||
|
args: ["@modelcontextprotocol/server-everything"],
|
||||||
|
env: {
|
||||||
|
LEGACY_ENV: "legacy-value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Function to run a basic test
|
// Function to run a basic test
|
||||||
async function runBasicTest(testName, ...args) {
|
async function runBasicTest(testName, ...args) {
|
||||||
const outputFile = path.join(
|
const outputFile = path.join(
|
||||||
|
|
@ -649,6 +728,160 @@ async function runTests() {
|
||||||
"debug",
|
"debug",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\n${colors.YELLOW}=== Running Config Transport Type Tests ===${colors.NC}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 25: Config with stdio transport type
|
||||||
|
await runBasicTest(
|
||||||
|
"config_stdio_type",
|
||||||
|
"--config",
|
||||||
|
stdioConfigPath,
|
||||||
|
"--server",
|
||||||
|
"test-stdio",
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 26: Config with SSE transport type (CLI mode) - expects connection error
|
||||||
|
await runErrorTest(
|
||||||
|
"config_sse_type_cli",
|
||||||
|
"--config",
|
||||||
|
sseConfigPath,
|
||||||
|
"--server",
|
||||||
|
"test-sse",
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 27: Config with streamable-http transport type (CLI mode) - expects connection error
|
||||||
|
await runErrorTest(
|
||||||
|
"config_http_type_cli",
|
||||||
|
"--config",
|
||||||
|
httpConfigPath,
|
||||||
|
"--server",
|
||||||
|
"test-http",
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 28: Legacy config without type field (backward compatibility)
|
||||||
|
await runBasicTest(
|
||||||
|
"config_legacy_no_type",
|
||||||
|
"--config",
|
||||||
|
legacyConfigPath,
|
||||||
|
"--server",
|
||||||
|
"test-legacy",
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\n${colors.YELLOW}=== Running Default Server Tests ===${colors.NC}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create config with single server for auto-selection
|
||||||
|
const singleServerConfigPath = path.join(
|
||||||
|
TEMP_DIR,
|
||||||
|
"single-server-config.json",
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
singleServerConfigPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
mcpServers: {
|
||||||
|
"only-server": {
|
||||||
|
command: "npx",
|
||||||
|
args: ["@modelcontextprotocol/server-everything"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create config with default-server
|
||||||
|
const defaultServerConfigPath = path.join(
|
||||||
|
TEMP_DIR,
|
||||||
|
"default-server-config.json",
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
defaultServerConfigPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
mcpServers: {
|
||||||
|
"default-server": {
|
||||||
|
command: "npx",
|
||||||
|
args: ["@modelcontextprotocol/server-everything"],
|
||||||
|
},
|
||||||
|
"other-server": {
|
||||||
|
command: "node",
|
||||||
|
args: ["other.js"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create config with multiple servers (no default)
|
||||||
|
const multiServerConfigPath = path.join(TEMP_DIR, "multi-server-config.json");
|
||||||
|
fs.writeFileSync(
|
||||||
|
multiServerConfigPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
mcpServers: {
|
||||||
|
server1: {
|
||||||
|
command: "npx",
|
||||||
|
args: ["@modelcontextprotocol/server-everything"],
|
||||||
|
},
|
||||||
|
server2: {
|
||||||
|
command: "node",
|
||||||
|
args: ["other.js"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 29: Config with single server auto-selection
|
||||||
|
await runBasicTest(
|
||||||
|
"single_server_auto_select",
|
||||||
|
"--config",
|
||||||
|
singleServerConfigPath,
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 30: Config with default-server should now require explicit selection (multiple servers)
|
||||||
|
await runErrorTest(
|
||||||
|
"default_server_requires_explicit_selection",
|
||||||
|
"--config",
|
||||||
|
defaultServerConfigPath,
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 31: Config with multiple servers and no default (should fail)
|
||||||
|
await runErrorTest(
|
||||||
|
"multi_server_no_default",
|
||||||
|
"--config",
|
||||||
|
multiServerConfigPath,
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`\n${colors.YELLOW}=== Running HTTP Transport Tests ===${colors.NC}`,
|
`\n${colors.YELLOW}=== Running HTTP Transport Tests ===${colors.NC}`,
|
||||||
);
|
);
|
||||||
|
|
@ -668,7 +901,7 @@ async function runTests() {
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||||
|
|
||||||
// Test 25: HTTP transport inferred from URL ending with /mcp
|
// Test 32: HTTP transport inferred from URL ending with /mcp
|
||||||
await runBasicTest(
|
await runBasicTest(
|
||||||
"http_transport_inferred",
|
"http_transport_inferred",
|
||||||
"http://127.0.0.1:3001/mcp",
|
"http://127.0.0.1:3001/mcp",
|
||||||
|
|
@ -677,7 +910,7 @@ async function runTests() {
|
||||||
"tools/list",
|
"tools/list",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Test 26: HTTP transport with explicit --transport http flag
|
// Test 33: HTTP transport with explicit --transport http flag
|
||||||
await runBasicTest(
|
await runBasicTest(
|
||||||
"http_transport_with_explicit_flag",
|
"http_transport_with_explicit_flag",
|
||||||
"http://127.0.0.1:3001/mcp",
|
"http://127.0.0.1:3001/mcp",
|
||||||
|
|
@ -688,7 +921,7 @@ async function runTests() {
|
||||||
"tools/list",
|
"tools/list",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Test 27: HTTP transport with suffix and --transport http flag
|
// Test 34: HTTP transport with suffix and --transport http flag
|
||||||
await runBasicTest(
|
await runBasicTest(
|
||||||
"http_transport_with_explicit_flag_and_suffix",
|
"http_transport_with_explicit_flag_and_suffix",
|
||||||
"http://127.0.0.1:3001/mcp",
|
"http://127.0.0.1:3001/mcp",
|
||||||
|
|
@ -699,7 +932,7 @@ async function runTests() {
|
||||||
"tools/list",
|
"tools/list",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Test 28: SSE transport given to HTTP server (should fail)
|
// Test 35: SSE transport given to HTTP server (should fail)
|
||||||
await runErrorTest(
|
await runErrorTest(
|
||||||
"sse_transport_given_to_http_server",
|
"sse_transport_given_to_http_server",
|
||||||
"http://127.0.0.1:3001",
|
"http://127.0.0.1:3001",
|
||||||
|
|
@ -710,7 +943,7 @@ async function runTests() {
|
||||||
"tools/list",
|
"tools/list",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Test 29: HTTP transport without URL (should fail)
|
// Test 36: HTTP transport without URL (should fail)
|
||||||
await runErrorTest(
|
await runErrorTest(
|
||||||
"http_transport_without_url",
|
"http_transport_without_url",
|
||||||
"--transport",
|
"--transport",
|
||||||
|
|
@ -720,7 +953,7 @@ async function runTests() {
|
||||||
"tools/list",
|
"tools/list",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Test 30: SSE transport without URL (should fail)
|
// Test 37: SSE transport without URL (should fail)
|
||||||
await runErrorTest(
|
await runErrorTest(
|
||||||
"sse_transport_without_url",
|
"sse_transport_without_url",
|
||||||
"--transport",
|
"--transport",
|
||||||
|
|
|
||||||
125
cli/src/cli.ts
125
cli/src/cli.ts
|
|
@ -14,6 +14,8 @@ type Args = {
|
||||||
args: string[];
|
args: string[];
|
||||||
envArgs: Record<string, string>;
|
envArgs: Record<string, string>;
|
||||||
cli: boolean;
|
cli: boolean;
|
||||||
|
transport?: "stdio" | "sse" | "streamable-http";
|
||||||
|
serverUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CliOptions = {
|
type CliOptions = {
|
||||||
|
|
@ -21,13 +23,22 @@ type CliOptions = {
|
||||||
config?: string;
|
config?: string;
|
||||||
server?: string;
|
server?: string;
|
||||||
cli?: boolean;
|
cli?: boolean;
|
||||||
|
transport?: string;
|
||||||
|
serverUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ServerConfig = {
|
type ServerConfig =
|
||||||
command: string;
|
| {
|
||||||
args?: string[];
|
type: "stdio";
|
||||||
env?: Record<string, string>;
|
command: string;
|
||||||
};
|
args?: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "sse" | "streamable-http";
|
||||||
|
url: string;
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function handleError(error: unknown): never {
|
function handleError(error: unknown): never {
|
||||||
let message: string;
|
let message: string;
|
||||||
|
|
@ -74,6 +85,16 @@ async function runWebClient(args: Args): Promise<void> {
|
||||||
startArgs.push("-e", `${key}=${value}`);
|
startArgs.push("-e", `${key}=${value}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pass transport type if specified
|
||||||
|
if (args.transport) {
|
||||||
|
startArgs.push("--transport", args.transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass server URL if specified
|
||||||
|
if (args.serverUrl) {
|
||||||
|
startArgs.push("--server-url", args.serverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
// Pass command and args (using -- to separate them)
|
// Pass command and args (using -- to separate them)
|
||||||
if (args.command) {
|
if (args.command) {
|
||||||
startArgs.push("--", args.command, ...args.args);
|
startArgs.push("--", args.command, ...args.args);
|
||||||
|
|
@ -103,7 +124,21 @@ async function runCli(args: Args): Promise<void> {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await spawnPromise("node", [cliPath, args.command, ...args.args], {
|
// Build CLI arguments
|
||||||
|
const cliArgs = [cliPath];
|
||||||
|
|
||||||
|
// Add transport flag if specified
|
||||||
|
if (args.transport && args.transport !== "stdio") {
|
||||||
|
// Convert streamable-http back to http for CLI mode
|
||||||
|
const cliTransport =
|
||||||
|
args.transport === "streamable-http" ? "http" : args.transport;
|
||||||
|
cliArgs.push("--transport", cliTransport);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add command and remaining args
|
||||||
|
cliArgs.push(args.command, ...args.args);
|
||||||
|
|
||||||
|
await spawnPromise("node", cliArgs, {
|
||||||
env: { ...process.env, ...args.envArgs },
|
env: { ...process.env, ...args.envArgs },
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
echoOutput: true,
|
echoOutput: true,
|
||||||
|
|
@ -190,7 +225,9 @@ function parseArgs(): Args {
|
||||||
)
|
)
|
||||||
.option("--config <path>", "config file path")
|
.option("--config <path>", "config file path")
|
||||||
.option("--server <n>", "server name from config file")
|
.option("--server <n>", "server name from config file")
|
||||||
.option("--cli", "enable CLI mode");
|
.option("--cli", "enable CLI mode")
|
||||||
|
.option("--transport <type>", "transport type (stdio, sse, http)")
|
||||||
|
.option("--server-url <url>", "server URL for SSE/HTTP transport");
|
||||||
|
|
||||||
// Parse only the arguments before --
|
// Parse only the arguments before --
|
||||||
program.parse(preArgs);
|
program.parse(preArgs);
|
||||||
|
|
@ -201,14 +238,33 @@ function parseArgs(): Args {
|
||||||
// Add back any arguments that came after --
|
// Add back any arguments that came after --
|
||||||
const finalArgs = [...remainingArgs, ...postArgs];
|
const finalArgs = [...remainingArgs, ...postArgs];
|
||||||
|
|
||||||
// Validate that config and server are provided together
|
// Validate config and server options
|
||||||
if (
|
if (!options.config && options.server) {
|
||||||
(options.config && !options.server) ||
|
throw new Error("--server requires --config to be specified");
|
||||||
(!options.config && options.server)
|
}
|
||||||
) {
|
|
||||||
throw new Error(
|
// If config is provided without server, try to auto-select
|
||||||
"Both --config and --server must be provided together. If you specify one, you must specify the other.",
|
if (options.config && !options.server) {
|
||||||
|
const configContent = fs.readFileSync(
|
||||||
|
path.isAbsolute(options.config)
|
||||||
|
? options.config
|
||||||
|
: path.resolve(process.cwd(), options.config),
|
||||||
|
"utf8",
|
||||||
);
|
);
|
||||||
|
const parsedConfig = JSON.parse(configContent);
|
||||||
|
const servers = Object.keys(parsedConfig.mcpServers || {});
|
||||||
|
|
||||||
|
if (servers.length === 1) {
|
||||||
|
// Use the only server if there's just one
|
||||||
|
options.server = servers[0];
|
||||||
|
} else if (servers.length === 0) {
|
||||||
|
throw new Error("No servers found in config file");
|
||||||
|
} else {
|
||||||
|
// Multiple servers, require explicit selection
|
||||||
|
throw new Error(
|
||||||
|
`Multiple servers found in config file. Please specify one with --server.\nAvailable servers: ${servers.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If config file is specified, load and use the options from the file. We must merge the args
|
// If config file is specified, load and use the options from the file. We must merge the args
|
||||||
|
|
@ -217,23 +273,52 @@ function parseArgs(): Args {
|
||||||
if (options.config && options.server) {
|
if (options.config && options.server) {
|
||||||
const config = loadConfigFile(options.config, options.server);
|
const config = loadConfigFile(options.config, options.server);
|
||||||
|
|
||||||
return {
|
if (config.type === "stdio") {
|
||||||
command: config.command,
|
return {
|
||||||
args: [...(config.args || []), ...finalArgs],
|
command: config.command,
|
||||||
envArgs: { ...(config.env || {}), ...(options.e || {}) },
|
args: [...(config.args || []), ...finalArgs],
|
||||||
cli: options.cli || false,
|
envArgs: { ...(config.env || {}), ...(options.e || {}) },
|
||||||
};
|
cli: options.cli || false,
|
||||||
|
transport: "stdio",
|
||||||
|
};
|
||||||
|
} else if (config.type === "sse" || config.type === "streamable-http") {
|
||||||
|
return {
|
||||||
|
command: config.url,
|
||||||
|
args: finalArgs,
|
||||||
|
envArgs: options.e || {},
|
||||||
|
cli: options.cli || false,
|
||||||
|
transport: config.type,
|
||||||
|
serverUrl: config.url,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Backwards compatibility: if no type field, assume stdio
|
||||||
|
return {
|
||||||
|
command: (config as any).command || "",
|
||||||
|
args: [...((config as any).args || []), ...finalArgs],
|
||||||
|
envArgs: { ...((config as any).env || {}), ...(options.e || {}) },
|
||||||
|
cli: options.cli || false,
|
||||||
|
transport: "stdio",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise use command line arguments
|
// Otherwise use command line arguments
|
||||||
const command = finalArgs[0] || "";
|
const command = finalArgs[0] || "";
|
||||||
const args = finalArgs.slice(1);
|
const args = finalArgs.slice(1);
|
||||||
|
|
||||||
|
// Map "http" shorthand to "streamable-http"
|
||||||
|
let transport = options.transport;
|
||||||
|
if (transport === "http") {
|
||||||
|
transport = "streamable-http";
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
command,
|
command,
|
||||||
args,
|
args,
|
||||||
envArgs: options.e || {},
|
envArgs: options.e || {},
|
||||||
cli: options.cli || false,
|
cli: options.cli || false,
|
||||||
|
transport: transport as "stdio" | "sse" | "streamable-http" | undefined,
|
||||||
|
serverUrl: options.serverUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,15 @@ function getClientUrl(port, authDisabled, sessionToken, serverPort) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startDevServer(serverOptions) {
|
async function startDevServer(serverOptions) {
|
||||||
const { SERVER_PORT, CLIENT_PORT, sessionToken, envVars, abort } =
|
const {
|
||||||
serverOptions;
|
SERVER_PORT,
|
||||||
|
CLIENT_PORT,
|
||||||
|
sessionToken,
|
||||||
|
envVars,
|
||||||
|
abort,
|
||||||
|
transport,
|
||||||
|
serverUrl,
|
||||||
|
} = serverOptions;
|
||||||
const serverCommand = "npx";
|
const serverCommand = "npx";
|
||||||
const serverArgs = ["tsx", "watch", "--clear-screen=false", "src/index.ts"];
|
const serverArgs = ["tsx", "watch", "--clear-screen=false", "src/index.ts"];
|
||||||
const isWindows = process.platform === "win32";
|
const isWindows = process.platform === "win32";
|
||||||
|
|
@ -42,6 +49,8 @@ async function startDevServer(serverOptions) {
|
||||||
CLIENT_PORT,
|
CLIENT_PORT,
|
||||||
MCP_PROXY_AUTH_TOKEN: sessionToken,
|
MCP_PROXY_AUTH_TOKEN: sessionToken,
|
||||||
MCP_ENV_VARS: JSON.stringify(envVars),
|
MCP_ENV_VARS: JSON.stringify(envVars),
|
||||||
|
...(transport ? { MCP_TRANSPORT: transport } : {}),
|
||||||
|
...(serverUrl ? { MCP_SERVER_URL: serverUrl } : {}),
|
||||||
},
|
},
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
echoOutput: true,
|
echoOutput: true,
|
||||||
|
|
@ -78,6 +87,8 @@ async function startProdServer(serverOptions) {
|
||||||
abort,
|
abort,
|
||||||
command,
|
command,
|
||||||
mcpServerArgs,
|
mcpServerArgs,
|
||||||
|
transport,
|
||||||
|
serverUrl,
|
||||||
} = serverOptions;
|
} = serverOptions;
|
||||||
const inspectorServerPath = resolve(
|
const inspectorServerPath = resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
|
|
@ -95,6 +106,8 @@ async function startProdServer(serverOptions) {
|
||||||
...(mcpServerArgs && mcpServerArgs.length > 0
|
...(mcpServerArgs && mcpServerArgs.length > 0
|
||||||
? [`--args=${mcpServerArgs.join(" ")}`]
|
? [`--args=${mcpServerArgs.join(" ")}`]
|
||||||
: []),
|
: []),
|
||||||
|
...(transport ? [`--transport=${transport}`] : []),
|
||||||
|
...(serverUrl ? [`--server-url=${serverUrl}`] : []),
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
env: {
|
env: {
|
||||||
|
|
@ -208,6 +221,8 @@ async function main() {
|
||||||
let command = null;
|
let command = null;
|
||||||
let parsingFlags = true;
|
let parsingFlags = true;
|
||||||
let isDev = false;
|
let isDev = false;
|
||||||
|
let transport = null;
|
||||||
|
let serverUrl = null;
|
||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
const arg = args[i];
|
const arg = args[i];
|
||||||
|
|
@ -222,6 +237,16 @@ async function main() {
|
||||||
continue;
|
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) {
|
if (parsingFlags && arg === "-e" && i + 1 < args.length) {
|
||||||
const envVar = args[++i];
|
const envVar = args[++i];
|
||||||
const equalsIndex = envVar.indexOf("=");
|
const equalsIndex = envVar.indexOf("=");
|
||||||
|
|
@ -273,6 +298,8 @@ async function main() {
|
||||||
abort,
|
abort,
|
||||||
command,
|
command,
|
||||||
mcpServerArgs,
|
mcpServerArgs,
|
||||||
|
transport,
|
||||||
|
serverUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = isDev
|
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",
|
"name": "@modelcontextprotocol/inspector-client",
|
||||||
"version": "0.16.2",
|
"version": "0.16.3",
|
||||||
"description": "Client-side application for the Model Context Protocol inspector",
|
"description": "Client-side application for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
|
|
|
||||||
|
|
@ -455,6 +455,14 @@ const App = () => {
|
||||||
if (data.defaultArgs) {
|
if (data.defaultArgs) {
|
||||||
setArgs(data.defaultArgs);
|
setArgs(data.defaultArgs);
|
||||||
}
|
}
|
||||||
|
if (data.defaultTransport) {
|
||||||
|
setTransportType(
|
||||||
|
data.defaultTransport as "stdio" | "sse" | "streamable-http",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.defaultServerUrl) {
|
||||||
|
setSseUrl(data.defaultServerUrl);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((error) =>
|
.catch((error) =>
|
||||||
console.error("Error fetching default environment:", error),
|
console.error("Error fetching default environment:", error),
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,22 @@ interface DynamicJsonFormProps {
|
||||||
maxDepth?: number;
|
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 isSimpleObject = (schema: JsonSchemaType): boolean => {
|
||||||
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
|
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") {
|
if (schema.type === "object") {
|
||||||
return Object.values(schema.properties ?? {}).every(
|
return Object.values(schema.properties ?? {}).every(
|
||||||
(prop) => prop.type && supportedTypes.includes(prop.type),
|
(prop) => prop.type && isTypeSupported(prop.type, supportedTypes),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (schema.type === "array") {
|
if (schema.type === "array") {
|
||||||
|
|
@ -181,7 +191,13 @@ const DynamicJsonForm = ({
|
||||||
const isRequired =
|
const isRequired =
|
||||||
parentSchema?.required?.includes(propertyName || "") ?? false;
|
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": {
|
case "string": {
|
||||||
if (
|
if (
|
||||||
propSchema.oneOf &&
|
propSchema.oneOf &&
|
||||||
|
|
@ -337,6 +353,8 @@ const DynamicJsonForm = ({
|
||||||
required={isRequired}
|
required={isRequired}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "null":
|
||||||
|
return null;
|
||||||
case "object":
|
case "object":
|
||||||
if (!propSchema.properties) {
|
if (!propSchema.properties) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,11 @@ import { TabsContent } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import DynamicJsonForm from "./DynamicJsonForm";
|
import DynamicJsonForm from "./DynamicJsonForm";
|
||||||
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||||
import { generateDefaultValue, isPropertyRequired } from "@/utils/schemaUtils";
|
import {
|
||||||
|
generateDefaultValue,
|
||||||
|
isPropertyRequired,
|
||||||
|
normalizeUnionType,
|
||||||
|
} from "@/utils/schemaUtils";
|
||||||
import {
|
import {
|
||||||
CompatibilityCallToolResult,
|
CompatibilityCallToolResult,
|
||||||
ListToolsResult,
|
ListToolsResult,
|
||||||
|
|
@ -104,7 +108,7 @@ const ToolsTab = ({
|
||||||
</p>
|
</p>
|
||||||
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
||||||
([key, value]) => {
|
([key, value]) => {
|
||||||
const prop = value as JsonSchemaType;
|
const prop = normalizeUnionType(value as JsonSchemaType);
|
||||||
const inputSchema =
|
const inputSchema =
|
||||||
selectedTool.inputSchema as JsonSchemaType;
|
selectedTool.inputSchema as JsonSchemaType;
|
||||||
const required = isPropertyRequired(key, inputSchema);
|
const required = isPropertyRequired(key, inputSchema);
|
||||||
|
|
@ -148,7 +152,10 @@ const ToolsTab = ({
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setParams({
|
setParams({
|
||||||
...params,
|
...params,
|
||||||
[key]: e.target.value,
|
[key]:
|
||||||
|
e.target.value === ""
|
||||||
|
? undefined
|
||||||
|
: e.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,18 @@ describe("DynamicJsonForm String Fields", () => {
|
||||||
const input = screen.getByRole("textbox");
|
const input = screen.getByRole("textbox");
|
||||||
expect(input).toHaveProperty("type", "text");
|
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", () => {
|
describe("Format Support", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
generateDefaultValue,
|
generateDefaultValue,
|
||||||
formatFieldLabel,
|
formatFieldLabel,
|
||||||
|
normalizeUnionType,
|
||||||
cacheToolOutputSchemas,
|
cacheToolOutputSchemas,
|
||||||
getToolOutputValidator,
|
getToolOutputValidator,
|
||||||
validateToolOutput,
|
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", () => {
|
describe("Output Schema Validation", () => {
|
||||||
const mockTools: Tool[] = [
|
const mockTools: Tool[] = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,16 @@ export type JsonSchemaType = {
|
||||||
| "boolean"
|
| "boolean"
|
||||||
| "array"
|
| "array"
|
||||||
| "object"
|
| "object"
|
||||||
| "null";
|
| "null"
|
||||||
|
| (
|
||||||
|
| "string"
|
||||||
|
| "number"
|
||||||
|
| "integer"
|
||||||
|
| "boolean"
|
||||||
|
| "array"
|
||||||
|
| "object"
|
||||||
|
| "null"
|
||||||
|
)[];
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
required?: string[];
|
required?: string[];
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,95 @@ export function isPropertyRequired(
|
||||||
return schema.required?.includes(propertyName) ?? false;
|
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
|
* Formats a field key into a human-readable label
|
||||||
* @param key The field key to format
|
* @param key The field key to format
|
||||||
|
|
|
||||||
16
package-lock.json
generated
16
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector",
|
"name": "@modelcontextprotocol/inspector",
|
||||||
"version": "0.16.2",
|
"version": "0.16.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@modelcontextprotocol/inspector",
|
"name": "@modelcontextprotocol/inspector",
|
||||||
"version": "0.16.2",
|
"version": "0.16.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"client",
|
"client",
|
||||||
|
|
@ -14,9 +14,9 @@
|
||||||
"cli"
|
"cli"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/inspector-cli": "^0.16.2",
|
"@modelcontextprotocol/inspector-cli": "^0.16.3",
|
||||||
"@modelcontextprotocol/inspector-client": "^0.16.2",
|
"@modelcontextprotocol/inspector-client": "^0.16.3",
|
||||||
"@modelcontextprotocol/inspector-server": "^0.16.2",
|
"@modelcontextprotocol/inspector-server": "^0.16.3",
|
||||||
"@modelcontextprotocol/sdk": "^1.17.0",
|
"@modelcontextprotocol/sdk": "^1.17.0",
|
||||||
"concurrently": "^9.2.0",
|
"concurrently": "^9.2.0",
|
||||||
"open": "^10.2.0",
|
"open": "^10.2.0",
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
},
|
},
|
||||||
"cli": {
|
"cli": {
|
||||||
"name": "@modelcontextprotocol/inspector-cli",
|
"name": "@modelcontextprotocol/inspector-cli",
|
||||||
"version": "0.16.2",
|
"version": "0.16.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.17.0",
|
"@modelcontextprotocol/sdk": "^1.17.0",
|
||||||
|
|
@ -67,7 +67,7 @@
|
||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
"name": "@modelcontextprotocol/inspector-client",
|
"name": "@modelcontextprotocol/inspector-client",
|
||||||
"version": "0.16.2",
|
"version": "0.16.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.17.0",
|
"@modelcontextprotocol/sdk": "^1.17.0",
|
||||||
|
|
@ -11059,7 +11059,7 @@
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"name": "@modelcontextprotocol/inspector-server",
|
"name": "@modelcontextprotocol/inspector-server",
|
||||||
"version": "0.16.2",
|
"version": "0.16.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.17.0",
|
"@modelcontextprotocol/sdk": "^1.17.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector",
|
"name": "@modelcontextprotocol/inspector",
|
||||||
"version": "0.16.2",
|
"version": "0.16.3",
|
||||||
"description": "Model Context Protocol inspector",
|
"description": "Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
|
|
@ -47,9 +47,9 @@
|
||||||
"check-version": "node scripts/check-version-consistency.js"
|
"check-version": "node scripts/check-version-consistency.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/inspector-cli": "^0.16.2",
|
"@modelcontextprotocol/inspector-cli": "^0.16.3",
|
||||||
"@modelcontextprotocol/inspector-client": "^0.16.2",
|
"@modelcontextprotocol/inspector-client": "^0.16.3",
|
||||||
"@modelcontextprotocol/inspector-server": "^0.16.2",
|
"@modelcontextprotocol/inspector-server": "^0.16.3",
|
||||||
"@modelcontextprotocol/sdk": "^1.17.0",
|
"@modelcontextprotocol/sdk": "^1.17.0",
|
||||||
"concurrently": "^9.2.0",
|
"concurrently": "^9.2.0",
|
||||||
"open": "^10.2.0",
|
"open": "^10.2.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-server",
|
"name": "@modelcontextprotocol/inspector-server",
|
||||||
"version": "0.16.2",
|
"version": "0.16.3",
|
||||||
"description": "Server-side application for the Model Context Protocol inspector",
|
"description": "Server-side application for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ const { values } = parseArgs({
|
||||||
env: { type: "string", default: "" },
|
env: { type: "string", default: "" },
|
||||||
args: { type: "string", default: "" },
|
args: { type: "string", default: "" },
|
||||||
command: { type: "string", default: "" },
|
command: { type: "string", default: "" },
|
||||||
|
transport: { type: "string", default: "" },
|
||||||
|
"server-url": { type: "string", default: "" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -523,6 +525,8 @@ app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => {
|
||||||
defaultEnvironment,
|
defaultEnvironment,
|
||||||
defaultCommand: values.command,
|
defaultCommand: values.command,
|
||||||
defaultArgs: values.args,
|
defaultArgs: values.args,
|
||||||
|
defaultTransport: values.transport,
|
||||||
|
defaultServerUrl: values["server-url"],
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in /config route:", error);
|
console.error("Error in /config route:", error);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue