Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,7 @@ The dev proxy (`tools/dev-proxy/dev-proxy.mjs`) is a lightweight MCP proxy that

**When to use**: Whenever you are actively developing mcp-debugger — making code changes, adding adapters, or installing new toolchains (Go, etc.) that need to be picked up by the running server.

**How it works**: After code changes, call the `dev_rebuild_and_restart` tool. The proxy kills the backend, runs `npm run build`, spawns a fresh process, and reconnects — all transparently. If the backend crashes, dev tools remain available to bring it back.
**How it works**: After code changes, call the `dev_rebuild_and_restart` tool. The proxy kills the backend, runs `npm run build`, spawns a fresh process, and reconnects — all transparently. If the backend crashes, dev tools remain available to bring it back. The proxy speaks both protocol eras to its client through the SDK's stdio entry (a probing client lands on 2026-07-28), negotiates the era with its backend on either transport (over stdio in place, on the backend's own pipe), translates resource subscriptions across eras and re-expresses them after every restart, and starts the backend on the first request that needs it — never when the process starts, because a v2 client's `auto` probe runs on a disposable sibling proxy process (issue #1025, step 2; `tools/dev-proxy/README.md`).

**Configuration** (env vars, all optional):
- `DEV_PROXY_PORT` — Backend HTTP port (default: 3001; used by `http` mode)
Expand Down Expand Up @@ -584,7 +584,7 @@ Each session also exposes its captured output as an MCP resource (`debug://sessi
**Dev proxy only** (these 3 tools are injected by the dev proxy process itself, not by the main mcp-debugger server):
- `dev_restart_debugger` - Restart the backend (pass `rebuild: true` to build first)
- `dev_rebuild_and_restart` - Run `npm run build` then restart the backend
- `dev_server_status` - Check backend state, PID, uptime, transport, project root, port, and display-safe backend environment overrides
- `dev_server_status` - Check backend state, PID, uptime, transport, protocol era and version, project root, port, and display-safe backend environment overrides

### Troubleshooting MCP Connection
- **If server shows "Failed to connect"**:
Expand Down
1 change: 1 addition & 0 deletions changelog.d/1025-dev-proxy.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**The dev proxy (`tools/dev-proxy`) speaks both MCP protocol eras and starts its backend on first use.** The client-facing side is served through the SDK's stdio entry, so a probing client — Claude Code's v2 runtime — lands on the current 2026-07-28 revision while an `initialize` client keeps the 2025 era; towards its backend the proxy negotiates the era too, on either transport — over stdio on the backend's own pipe, so the backend still starts once and an older build falls back to the 2025 era — and `dev_server_status` reports `protocolEra`/`protocolVersion`, with a note before the first start that the backend starts on first use. Resource subscriptions are translated across eras (one `subscriptions/listen` to a 2026 backend, one `resources/subscribe` per URI to a 2025 one) and re-expressed after every backend restart, so a `dev_rebuild_and_restart` no longer loses them; a subscription to a resource the restarted backend no longer has is dropped instead of failing the rest. The backend now starts on the first request that needs it rather than at process start: a v2 client's `auto` negotiation over stdio probes `server/discover` on a disposable sibling proxy process, which used to start a backend of its own and race the real proxy for `DEV_PROXY_PORT` (measured: two backends per connect). The proxy exits when the SDK transport closes the wire by itself, not only on stdin EOF. Completes step 2 of #1025 for the development loop.
18 changes: 18 additions & 0 deletions tests/fixtures/dev-proxy/real-backend.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// The real mcp-debugger server as a dev-proxy stdio backend, counting its own
// starts (tests/integration/dev-proxy-eras.test.ts). The legacy fixture beside
// this one speaks only the 2025 era; this is the built server, which speaks
// both, so the proxy's negotiation with a stdio backend can be observed — and
// the event line shows whether the proxy spawned it once or twice.
import { appendFileSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';

const events = process.env.DEV_PROXY_FIXTURE_EVENTS;
if (events) {
appendFileSync(events, `${JSON.stringify({ event: 'backend-start', pid: process.pid })}\n`);
}

const entry = path.resolve(process.env.DEV_PROXY_ROOT ?? process.cwd(), 'dist/index.js');
// The entry runs its CLI only as the main module, which it tells from argv.
process.argv = [process.argv[0], entry, 'stdio'];
await import(pathToFileURL(entry).href);
25 changes: 24 additions & 1 deletion tests/fixtures/dev-proxy/startup-backend.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ if (releaseFile) {
}
}

const server = new Server({ name: 'startup-fixture', version: '1.0.0' }, { capabilities: { tools: {}, resources: {} } });
const server = new Server({ name: 'startup-fixture', version: '1.0.0' }, { capabilities: { tools: {}, resources: { subscribe: true } } });
server.setRequestHandler('tools/list', async () => ({
tools: [{
name: process.env.DEV_PROXY_FIXTURE_TOOL || 'fixture_tool',
Expand All @@ -42,6 +42,18 @@ server.setRequestHandler('tools/list', async () => ({
// Answering at all is the point: it proves the proxy forwarded the call rather
// than refusing it while the backend was still starting.
server.setRequestHandler('tools/call', async (request) => {
// `touch: <uri>` makes this 2025 backend send resources/updated for a URI —
// only one the proxy has subscribed, like the real server's per-connection
// subscribe set (issue #1025, step 2: the proxy's subscription relay across
// eras). An unsubscribed touch is refused, so a test cannot pass on a ping
// nobody asked for.
const touch = request.params.arguments?.touch;
if (typeof touch === 'string') {
if (!subscribed.has(touch)) {
return { content: [{ type: 'text', text: `not subscribed: ${touch}` }], isError: true };
}
await server.sendResourceUpdated({ uri: touch });
}
if (request.params.arguments?.floodStderr) {
// More than a pipe's capacity: answering proves the proxy drained stderr
// while a build was running, rather than leaving this write blocked.
Expand All @@ -57,5 +69,16 @@ server.setRequestHandler('resources/list', async () => ({
server.setRequestHandler('resources/read', async () => ({
contents: [{ uri: 'fixture://status', text: process.env.DEV_PROXY_FIXTURE_TOOL || 'fixture_tool' }],
}));
// A 2025 backend's subscriptions: the per-connection set the real server keeps
// (lost with the process, which is why the proxy re-sends them after a restart).
const subscribed = new Set();
server.setRequestHandler('resources/subscribe', async (request) => {
subscribed.add(request.params.uri);
return {};
});
server.setRequestHandler('resources/unsubscribe', async (request) => {
subscribed.delete(request.params.uri);
return {};
});
process.stdin.on('end', () => process.exit(0));
await server.connect(new StdioServerTransport());
157 changes: 157 additions & 0 deletions tests/integration/dev-proxy-eras.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* The dev proxy on both protocol eras (issue #1025, step 2, PR 5), driven as
* Claude Code drives it: a v2 client over the SDK's own StdioClientTransport.
*
* In `auto` mode that client probes server/discover on a DISPOSABLE SIBLING
* process spawned from the same parameters, then starts the real one. Measured
* before this change: the sibling ran the proxy's eager backend start too —
* two backends per connect, both racing ensurePortFree on one port. Now the
* backend starts on the first request that reaches a handler, which a probe
* never is.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';

const root = path.resolve(__dirname, '../..');
const devTools = ['dev_restart_debugger', 'dev_rebuild_and_restart', 'dev_server_status'];
const clients: Client[] = [];
const directories: string[] = [];

afterEach(async () => {
for (const client of clients.splice(0)) await client.close().catch(() => undefined);
for (const dir of directories.splice(0)) await rm(dir, { recursive: true, force: true });
});

type Mode = 'auto' | 'legacy' | { pin: '2026-07-28' };

async function connect(mode: Mode, env: Record<string, string> = {}) {
const dir = await mkdtemp(path.join(os.tmpdir(), 'mcp-proxy-eras-'));
directories.push(dir);
const events = path.join(dir, 'events.ndjson');
const client = new Client({ name: 'eras-test', version: '1.0.0' }, { versionNegotiation: { mode } });
clients.push(client);
// The SDK's base transport, exactly as Claude Code uses it: the sibling path
// runs only for the base class (a subclass probes in place).
const transport = new StdioClientTransport({
command: process.execPath,
args: [path.join(root, 'tools/dev-proxy/dev-proxy.mjs')],
cwd: root,
env: {
...process.env,
DEV_PROXY_ROOT: root,
DEV_PROXY_BACKEND_TRANSPORT: 'stdio',
DEV_PROXY_BACKEND_CMD: `"${process.execPath}" "${path.join(root, 'tests/fixtures/dev-proxy/startup-backend.mjs')}"`,
DEV_PROXY_FIXTURE_EVENTS: events,
DEV_PROXY_FIXTURE_FAIL: '0',
DEV_PROXY_FIXTURE_RELEASE: '',
DEV_PROXY_FIXTURE_TOOL: 'fixture_tool',
...env,
},
stderr: 'pipe',
});
transport.stderr?.on('data', () => {});
await client.connect(transport);
const backendStarts = async () => (await readFile(events, 'utf8').catch(() => ''))
.split('\n').filter((line) => line.includes('"backend-start"')).length;
return { client, backendStarts };
}

describe('dev-proxy protocol eras (issue #1025, step 2)', () => {
it('an auto-negotiating client lands on 2026-07-28, and only the real connection starts a backend, on its first request', async () => {
const { client, backendStarts } = await connect('auto');

expect(client.getProtocolEra()).toBe('modern');
expect(client.getNegotiatedProtocolVersion()).toBe('2026-07-28');
// The sibling probe and the real connect have both happened; neither started a backend.
// (An eager sibling start might not have appended its event yet, so this first read is
// a probabilistic detector; the two reads below are the deterministic ones.)
expect(await backendStarts()).toBe(0);

const status = await client.callTool({ name: 'dev_server_status', arguments: {} });
const text = (status.content as Array<{ text: string }>)[0].text;
// "stopped" alone reads like a crash; before the first start the status says why.
expect(JSON.parse(text)).toMatchObject({
state: 'stopped',
protocolEra: null,
note: expect.stringContaining('starts on the first request that needs it'),
});
expect(await backendStarts()).toBe(0);

const listed = await client.listTools();
expect(listed.tools.map((tool) => tool.name)).toEqual(['fixture_tool', ...devTools]);
expect(await backendStarts()).toBe(1);

// The fixture backend is a 2025 server: the status says which era the backend speaks.
const after = await client.callTool({ name: 'dev_server_status', arguments: {} });
const running = JSON.parse((after.content as Array<{ text: string }>)[0].text);
expect(running).toMatchObject({ state: 'running', protocolEra: 'legacy', protocolVersion: '2025-11-25' });
expect(running).not.toHaveProperty('note');
});

it('drops the first-use note once the backend has been started any way at all, a dev restart included', async () => {
const { client, backendStarts } = await connect('legacy');

// The first request this proxy handles is a dev tool: it starts the backend
// without going through the deferred first start.
const restarted = await client.callTool({ name: 'dev_restart_debugger', arguments: {} });
expect(restarted.isError, JSON.stringify(restarted.content)).not.toBe(true);
expect(await backendStarts()).toBe(1);

const status = await client.callTool({ name: 'dev_server_status', arguments: {} });
const body = JSON.parse((status.content as Array<{ text: string }>)[0].text);
expect(body.state).toBe('running');
expect(body).not.toHaveProperty('note');
});

it('a 2025 client keeps working unchanged', async () => {
const { client, backendStarts } = await connect('legacy');

expect(client.getProtocolEra()).toBe('legacy');
expect((await client.listTools()).tools.map((tool) => tool.name)).toEqual(['fixture_tool', ...devTools]);
expect(await backendStarts()).toBe(1);
});

// The proxy is itself a v2 client of its backend. Over stdio the SDK's `auto`
// mode probes on a disposable sibling process for its base transport — a second
// backend — so the proxy used to pin a stdio backend to the 2025 era and never
// exercise the server's modern stdio leg. A subclassed transport probes in place
// (documented SDK behaviour): one process, and a legacy server falls back to
// `initialize` on the same pipe, which the first case above pins (the fixture
// is a 2025 server: one start, era legacy).
it('negotiates with a stdio backend in place: the real server lands on 2026-07-28, started once', async () => {
const { client, backendStarts } = await connect('legacy', {
DEV_PROXY_BACKEND_CMD: `"${process.execPath}" "${path.join(root, 'tests/fixtures/dev-proxy/real-backend.mjs')}"`,
});

const listed = await client.listTools();
expect(listed.tools.map((tool) => tool.name)).toEqual(expect.arrayContaining(['create_debug_session', ...devTools]));

const status = await client.callTool({ name: 'dev_server_status', arguments: {} });
expect(JSON.parse((status.content as Array<{ text: string }>)[0].text)).toMatchObject({
state: 'running', backendTransport: 'stdio', protocolEra: 'modern', protocolVersion: '2026-07-28',
});
expect(await backendStarts()).toBe(1);
}, 60_000);

it('a 2026-07-28 client hears a backend resources/updated on its listen stream through the relay', async () => {
const { client } = await connect({ pin: '2026-07-28' });
const updated: string[] = [];
client.setNotificationHandler('notifications/resources/updated', async (n) => { updated.push(n.params.uri); });
await client.listTools();
const subscription = await client.listen({ resourceSubscriptions: ['fixture://status'] });

// The fixture backend (2025) sends resources/updated for a touched URI only
// if the proxy subscribed it. A 2026 client's listen set is served by the SDK
// entry and never reaches the proxy, so while a 2026 client is pinned the
// proxy subscribes the backend to every resource it lists; the front's own
// router then delivers only what this client listened for.
const touched = await client.callTool({ name: 'fixture_tool', arguments: { touch: 'fixture://status' } });
expect(touched.isError, JSON.stringify(touched.content)).not.toBe(true);
await expect.poll(() => updated, { timeout: 5000 }).toEqual(['fixture://status']);
await subscription.close();
});
});
1 change: 1 addition & 0 deletions tests/unit/dev-proxy/backend-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,4 @@ describe('dev-proxy backend environment overrides', () => {
expect(result.MCP_EXIT_ON_STDIN_CLOSE).toBe('1');
});
});

96 changes: 96 additions & 0 deletions tests/unit/dev-proxy/backend-stdio-transport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* The dev proxy's stdio transport to its backend (issue #1025, step 2).
*
* It is a subclass of the SDK's transport so that `auto` negotiation probes on
* the backend's own pipe instead of on a second backend process. That has a
* cost the SDK does not cover: under `auto` negotiation the SDK does not watch
* the signal given to connect() while its probe is unanswered (measured, client
* 2.4.0: `legacy` rejects the connect and closes the transport on an abort,
* `auto` leaves the connect pending and the child running, sibling path and
* in place alike). So a start that is still negotiating when the proxy shuts
* down hangs, and the backend it spawned outlives the proxy — CI caught it on
* Linux in the rebuild suite's shutdown cases. The transport therefore closes
* itself on the proxy's shutdown signal, as the SDK does by itself in
* `legacy` mode.
*/
import { getEventListeners } from 'node:events';
import { afterEach, describe, expect, it } from 'vitest';
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
import { BackendStdioTransport } from '../../../tools/dev-proxy/backend-stdio-transport.mjs';

// A backend that never answers: what a shutdown finds while a start is still negotiating.
const SILENT = { command: process.execPath, args: ['-e', 'setInterval(() => {}, 1000)'] };

const isAlive = (pid: number): boolean => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

describe('dev-proxy backend stdio transport', () => {
const pids: number[] = [];
const transports: BackendStdioTransport[] = [];

afterEach(async () => {
for (const transport of transports.splice(0)) await transport.close().catch(() => undefined);
for (const pid of pids.splice(0)) {
try { process.kill(pid); } catch { /* already gone */ }
}
});

function negotiating(signal: AbortSignal) {
const transport = new BackendStdioTransport(SILENT, { signal });
transports.push(transport);
const client = new Client({ name: 'backend-transport-test', version: '0.0.0' }, { versionNegotiation: { mode: 'auto' } });
const connecting = client.connect(transport, { signal }).then(() => 'connected' as const, () => 'rejected' as const);
const pid = transport.pid!;
pids.push(pid);
return { transport, connecting, pid };
}

it('is a subclass of the SDK transport: that is what makes the negotiation probe in place', () => {
const transport = new BackendStdioTransport(SILENT);

expect(transport).toBeInstanceOf(StdioClientTransport);
expect(Object.getPrototypeOf(transport)).not.toBe(StdioClientTransport.prototype);
});

it('ends a start that is still negotiating when the proxy shuts down: the connect settles and the backend is gone', async () => {
const shutdown = new AbortController();
const { connecting, pid } = negotiating(shutdown.signal);
expect(isAlive(pid)).toBe(true);

shutdown.abort();

// Without the transport's own close the SDK leaves this pending past the abort.
expect(await Promise.race([connecting, delay(4000).then(() => 'still pending' as const)])).toBe('rejected');
await expect.poll(() => isAlive(pid), { timeout: 8000 }).toBe(false);
}, 20_000);

it('lets go of the shutdown signal once it is closed, so restarts do not pile listeners onto it', async () => {
const shutdown = new AbortController();
const { transport } = negotiating(shutdown.signal);
expect(getEventListeners(shutdown.signal, 'abort').length).toBeGreaterThan(0);
const before = getEventListeners(shutdown.signal, 'abort').length;

await transport.close();

expect(getEventListeners(shutdown.signal, 'abort').length).toBe(before - 1);
}, 20_000);

it('lets go of the signal when its owner says the backend has gone by itself', () => {
const shutdown = new AbortController();
const transport = new BackendStdioTransport(SILENT, { signal: shutdown.signal });
expect(getEventListeners(shutdown.signal, 'abort')).toHaveLength(1);

transport.detachFromSignal();
transport.detachFromSignal();

expect(getEventListeners(shutdown.signal, 'abort')).toHaveLength(0);
});
});
Loading
Loading