Skip to content

Commit 4072dae

Browse files
gabrypavanelloSirius
andauthored
feat(inspector): stdio MCP server transport support (#132)
* feat(inspector): stdio MCP server transport support Add stdio transport alongside existing HTTP for connecting to MCP servers that communicate over stdin/stdout. ## Architecture - ConnectionParams discriminated union: { transport: 'http', url } | { transport: 'stdio', command, args?, env?, cwd? } - ONE branch point at createTestClient() — all other code is transport-agnostic - Auto-restart for stdio with exponential backoff (1s, 2s, 4s, max 3 retries) ## Changes ### Types & Transport (Phase 1) - Add ConnectionParams type to @mcp-apps-kit/testing - createTestClient() accepts ConnectionParams, branches to StdioClientTransport or StreamableHTTPClientTransport - Add onTransportClose callback to TestClientOptions - Add connectionParams field to ConnectionState ### Connection Chain (Phase 2) - ConnectionManager.connect() accepts ConnectionParams with input validation - Auto-restart logic: onTransportClose → exponential backoff reconnect (stdio only) - ConnectionRegistry.createConnection() accepts ConnectionParams - All callers updated (28+ test files, 4 source files) ### Tool API (Phase 3) - connect_to_server tool supports both transports via Zod union schema - Backward compatible: plain { url } still works (defaults to HTTP) ### Dashboard API (Phase 4) - POST /dashboard/connections accepts ConnectionParams body - Backward compat: { url } without transport field defaults to HTTP - Response includes transport type ### Dashboard UI (Phase 5) - Transport dropdown (HTTP/stdio) in ConnectionBar - stdio mode: command + args inputs replace URL input - Advanced Settings toggle: env vars + cwd (stdio only) - Server history stores transport type, shows stdio: badge - Selecting stdio history entry populates command/args fields * fix(inspector): resolve stdio auto-restart race condition and label inconsistency - Add connectionGeneration counter to ConnectionManager to prevent stale restart attempts after explicit disconnect() - Align label format in test-client.ts to match connection.ts ('stdio: command' with space) - Add stdio-transport behavioral test suite (18 tests) * fix(inspector): remove unnecessary type assertion in connect tool * test(testing): improve test-client coverage for stdio transport paths Add mocked unit tests covering stdio/HTTP transport creation, env merging, cwd forwarding, onTransportClose wiring, connection error labels, and client operations (callTool, history, disconnect). Branches coverage: 49.73% → 52.41% (above 50% threshold). * fix(inspector): address PR review comments - Throw error for missing HTTP URL instead of falling back to empty string (connect.ts) - Use element-wise args comparison in server history dedup instead of join(' ') which can collide different arrays (useServerHistory.ts) - Reset autoRestartAttempts on successful reconnect so subsequent crashes get full retry budget (connection.ts) - Wrap disconnect() in auto-restart catch with .catch() to prevent swallowed errors (connection.ts) - Standardize stdio label format with space after colon (connect.ts) * fix(inspector): convert advanced settings to popover dropdown - Replace inline collapsible with floating Popover component - Settings gear button on right side of connection bar - Popover: dark bg, shadow, z-1000, dismiss on click-outside/Escape - Reusable Popover accepts panelStyle for future settings - Button order: connect/disconnect → settings → close - No header layout shift when settings are open * fix(inspector): address PR review comments — security docs, label consistency, UI guards - Standardize stdio display label to 'stdio:command args' (no space after colon) across connection.ts, connect.ts, test-client.ts and all tests - Hoist argsEqual() to module scope in useServerHistory.ts for proper element-wise array comparison (prevents join collision) - Add type guard for non-string args in history filter - Add isCreating guard on handleKeyDown and handleSelectHistory in ConnectionBar.tsx to prevent duplicate connection requests - Add JSDoc security warning on ConnectionParams.env about process.env merge - Add inline SECURITY NOTE in test-client.ts env merging code * security(testing): allowlist parent env vars for stdio child processes Replace full process.env merge with explicit safe-var allowlist (PATH, HOME, LANG, NODE_ENV, XDG dirs, etc.) when spawning stdio child processes. Sensitive variables (API keys, credentials, tokens) are never inherited automatically. Add inheritEnv flag to ConnectionParams for explicit control: - When env is provided: only safe parent vars + user overrides (default) - When inheritEnv=false: only safe parent vars - When neither env nor inheritEnv set: Node.js default (full process.env) Addresses PR #132 security review comment on env var leakage. --------- Co-authored-by: Sirius <sirius@clawd.bot>
1 parent 223a076 commit 4072dae

54 files changed

Lines changed: 2068 additions & 377 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

examples/minimal/tests/advanced-features.test.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -393,9 +393,12 @@ describe("Advanced Features", () => {
393393
const server = await startTestServer(app, { port: testPort });
394394
await new Promise((resolve) => setTimeout(resolve, 100));
395395

396-
const client = await createTestClient(`http://localhost:${testPort}/v1/mcp`, {
397-
trackHistory: true,
398-
});
396+
const client = await createTestClient(
397+
{ transport: "http", url: `http://localhost:${testPort}/v1/mcp` },
398+
{
399+
trackHistory: true,
400+
}
401+
);
399402

400403
await client.callTool("greet", { name: "History1" });
401404
await client.callTool("greet", { name: "History2" });
@@ -418,7 +421,10 @@ describe("Advanced Features", () => {
418421
const testPort = 3014;
419422
const server = await startTestServer(app, { port: testPort });
420423
await new Promise((resolve) => setTimeout(resolve, 100));
421-
const client = await createTestClient(`http://localhost:${testPort}/v1/mcp`);
424+
const client = await createTestClient({
425+
transport: "http",
426+
url: `http://localhost:${testPort}/v1/mcp`,
427+
});
422428

423429
const tools = await client.listTools();
424430
expect(tools.some((t) => t.name === "greet")).toBe(true);
@@ -435,9 +441,12 @@ describe("Advanced Features", () => {
435441
await new Promise((resolve) => setTimeout(resolve, 100));
436442

437443
// Client with short timeout
438-
const client = await createTestClient(`http://localhost:${testPort}/v1/mcp`, {
439-
timeout: 5000, // 5 second timeout
440-
});
444+
const client = await createTestClient(
445+
{ transport: "http", url: `http://localhost:${testPort}/v1/mcp` },
446+
{
447+
timeout: 5000, // 5 second timeout
448+
}
449+
);
441450

442451
// Should complete within timeout
443452
const result = await client.callTool("greet", { name: "Timeout" });

examples/minimal/tests/greet-v1.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@ describe("Greet Tool V1", () => {
1616
const server = await startTestServer(app, { port: testPort });
1717
await new Promise((resolve) => setTimeout(resolve, 100));
1818

19-
const client = await createTestClient(`http://localhost:${testPort}/v1/mcp`, {
20-
trackHistory: true,
21-
});
19+
const client = await createTestClient(
20+
{ transport: "http", url: `http://localhost:${testPort}/v1/mcp` },
21+
{
22+
trackHistory: true,
23+
}
24+
);
2225

2326
env = {
2427
server,

examples/minimal/tests/greet-v2.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@ describe("Greet Tool V2", () => {
1616
const server = await startTestServer(app, { port: testPort });
1717
await new Promise((resolve) => setTimeout(resolve, 100));
1818

19-
const client = await createTestClient(`http://localhost:${testPort}/v2/mcp`, {
20-
trackHistory: true,
21-
});
19+
const client = await createTestClient(
20+
{ transport: "http", url: `http://localhost:${testPort}/v2/mcp` },
21+
{
22+
trackHistory: true,
23+
}
24+
);
2225

2326
env = {
2427
server,

examples/minimal/tests/integration.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@ describe("Minimal Example Integration", () => {
1515
const server = await startTestServer(app, { port: testPort });
1616
await new Promise((resolve) => setTimeout(resolve, 100));
1717

18-
const client = await createTestClient(`http://localhost:${testPort}/v1/mcp`, {
19-
trackHistory: true,
20-
timeout: 10000,
21-
});
18+
const client = await createTestClient(
19+
{ transport: "http", url: `http://localhost:${testPort}/v1/mcp` },
20+
{
21+
trackHistory: true,
22+
timeout: 10000,
23+
}
24+
);
2225

2326
env = {
2427
server,

examples/minimal/tests/integration/versioning.test.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,19 @@ describe("Versioning", () => {
1717
mainServer = await startTestServer(app as unknown, { port: testPort });
1818
await new Promise((resolve) => setTimeout(resolve, 100));
1919

20-
const v1Client = await createTestClient(`http://localhost:${testPort}/v1/mcp`, {
21-
trackHistory: true,
22-
});
23-
24-
const v2Client = await createTestClient(`http://localhost:${testPort}/v2/mcp`, {
25-
trackHistory: true,
26-
});
20+
const v1Client = await createTestClient(
21+
{ transport: "http", url: `http://localhost:${testPort}/v1/mcp` },
22+
{
23+
trackHistory: true,
24+
}
25+
);
26+
27+
const v2Client = await createTestClient(
28+
{ transport: "http", url: `http://localhost:${testPort}/v2/mcp` },
29+
{
30+
trackHistory: true,
31+
}
32+
);
2733

2834
v1Env = {
2935
server: mainServer,

examples/weather-app/tests/integration/server.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@ describe("Weather App MCP Server", () => {
1717
const server = await startTestServer(app, { port: 0 });
1818
await new Promise((r) => setTimeout(r, 100));
1919

20-
const client = await createTestClient(server.mcpUrl, {
21-
trackHistory: true,
22-
timeout: 15000,
23-
});
20+
const client = await createTestClient(
21+
{ transport: "http", url: server.mcpUrl },
22+
{
23+
trackHistory: true,
24+
timeout: 15000,
25+
}
26+
);
2427

2528
env = {
2629
server,

packages/create-app/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,7 @@ describe("${name} MCP Server", () => {
556556
const server = await startTestServer(app, { port: 0 });
557557
await new Promise((r) => setTimeout(r, 100));
558558
559-
const client = await createTestClient(server.mcpUrl, {
559+
const client = await createTestClient({ transport: "http", url: server.mcpUrl }, {
560560
trackHistory: true,
561561
timeout: 10000,
562562
});
@@ -1106,7 +1106,7 @@ describe("${name} MCP Server", () => {
11061106
const server = await startTestServer(app, { port: 0 });
11071107
await new Promise((r) => setTimeout(r, 100));
11081108
1109-
const client = await createTestClient(server.mcpUrl, {
1109+
const client = await createTestClient({ transport: "http", url: server.mcpUrl }, {
11101110
trackHistory: true,
11111111
timeout: 10000,
11121112
});

packages/inspector/src/connection-registry.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { randomUUID } from "node:crypto";
88
import { EventEmitter } from "node:events";
9+
import type { ConnectionParams } from "@mcp-apps-kit/testing";
910
import { ConnectionManager } from "./connection";
1011
import type { ConnectOptions, ConnectionStatusOutput, InspectorServerOptions } from "./types";
1112

@@ -61,12 +62,12 @@ export class ConnectionRegistry extends EventEmitter {
6162
/**
6263
* Create a new connection and connect to the target server.
6364
*
64-
* @param url - MCP server URL to connect to.
65+
* @param params - Connection parameters (transport type + config).
6566
* @param options - Connection options passed to the ConnectionManager.
6667
* @returns The new connection id and manager instance.
6768
*/
6869
async createConnection(
69-
url: string,
70+
params: ConnectionParams,
7071
options?: ConnectOptions
7172
): Promise<{ id: string; connectionManager: ConnectionManager }> {
7273
if (this.connections.size >= this.maxConnections) {
@@ -80,7 +81,7 @@ export class ConnectionRegistry extends EventEmitter {
8081
});
8182

8283
try {
83-
await connectionManager.connect(url, options);
84+
await connectionManager.connect(params, options);
8485
} catch (error) {
8586
try {
8687
await connectionManager.disconnect();

0 commit comments

Comments
 (0)