From 0202374f6bf54656e447e920097f954d2c1f09e3 Mon Sep 17 00:00:00 2001 From: EthanHuo Date: Wed, 11 Nov 2026 14:49:08 +0800 Subject: [PATCH] fix: a newer client stops an older mcpxd that refuses its handshake, release 0.16.1 mcpxd v3+ answers a foreign protocol with protocol-mismatch and hangs up, so upgrading from 0.15 to 0.16 (protocol 3 -> 4) left the client unable to stop the running daemon. The client now re-handshakes in the daemon's own protocol and sends stop; mismatch answers carry the daemon's protocol, and hello/stop are frozen across protocol versions. --- package.json | 2 +- src/daemon-client.ts | 30 +++++++++++++++++++++++++++- src/daemon-protocol.ts | 18 ++++++++++++++--- src/daemon-server.ts | 8 ++++---- tests/daemon-client.test.ts | 40 ++++++++++++++++++++++++++++++++++++- 5 files changed, 88 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 78f55d1..788ee72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcpx", - "version": "0.16.0", + "version": "0.16.1", "license": "MIT", "bin": { "mcpx": "./src/main.ts" diff --git a/src/daemon-client.ts b/src/daemon-client.ts index 9283ad0..9d2f83f 100644 --- a/src/daemon-client.ts +++ b/src/daemon-client.ts @@ -73,6 +73,7 @@ async function probeDaemon(): Promise< async function stopIncompatibleDaemon(): Promise { const socket = await connectDaemonSocket() + let daemonProtocol: number | undefined try { const hello = await requestJsonLine(socket, helloMessage()) if ( @@ -85,12 +86,28 @@ async function stopIncompatibleDaemon(): Promise { op: 'stop', }) } else { + daemonProtocol = mismatchedProtocol(hello) // V2 accepts its stop command after returning protocol-mismatch. - await requestJsonLine(socket, { op: 'stop' }) + if (daemonProtocol === undefined) + await requestJsonLine(socket, { op: 'stop' }) } } finally { socket.end() } + // V3 and later end the connection on a mismatch; speak their protocol on a + // new one, whose `hello` and `stop` every version keeps unchanged. + if (daemonProtocol !== undefined) { + const legacy = await connectDaemonSocket() + try { + await requestJsonLine(legacy, helloMessage(daemonProtocol)) + await requestJsonLine(legacy, { + requestId: crypto.randomUUID(), + op: 'stop', + }) + } finally { + legacy.end() + } + } const deadline = Date.now() + START_TIMEOUT_MS while (Date.now() < deadline) { @@ -100,6 +117,17 @@ async function stopIncompatibleDaemon(): Promise { throw new Error('Incompatible mcpxd did not stop before the timeout.') } +/** The protocol an older daemon named when it refused our handshake. */ +function mismatchedProtocol(message: unknown): number | undefined { + if (!isDaemonMessage(message) || message.ok) return undefined + if (message.error.code !== 'protocol-mismatch') return undefined + if (typeof message.protocolVersion === 'number') + return message.protocolVersion + // V3 named its protocol only in the message text. + const named = /expected (\d+)\./.exec(message.error.message)?.[1] + return named === undefined ? undefined : Number(named) +} + function isDaemonMessage(value: unknown): value is DaemonMessage { return !!value && typeof value === 'object' && 'ok' in value } diff --git a/src/daemon-protocol.ts b/src/daemon-protocol.ts index 51479d0..a284317 100644 --- a/src/daemon-protocol.ts +++ b/src/daemon-protocol.ts @@ -30,9 +30,19 @@ export type ClientMessage = { clientVersion: string } +/** + * Every protocol version keeps the `hello` handshake and the `stop` intent + * unchanged, and a `protocol-mismatch` answer names the daemon's own version: + * that is how a newer client stops an older mcpxd to replace it. + */ export type DaemonMessage = | { ok: true; protocolVersion?: number; result?: unknown } - | { ok: false; error: { code: string; message: string } } + | { + ok: false + error: { code: string; message: string } + /** The daemon's protocol, on `protocol-mismatch`. */ + protocolVersion?: number + } export function notificationModeFromEnv(): NotificationMode { const raw = process.env[NOTIFICATION_MODE_ENV] @@ -43,10 +53,12 @@ export function notificationModeFromEnv(): NotificationMode { ) } -export function helloMessage(): ClientMessage { +export function helloMessage( + protocolVersion: number = DAEMON_PROTOCOL_VERSION, +): ClientMessage { return { op: 'hello', - protocolVersion: DAEMON_PROTOCOL_VERSION, + protocolVersion, clientVersion: MCPX_VERSION, } } diff --git a/src/daemon-server.ts b/src/daemon-server.ts index f560ee9..9529090 100644 --- a/src/daemon-server.ts +++ b/src/daemon-server.ts @@ -90,13 +90,13 @@ function handleConnection(server: net.Server, socket: Socket): void { } if (message.protocolVersion !== DAEMON_PROTOCOL_VERSION) { phase = 'complete' - writeJsonLine( - socket, - errorResponse( + writeJsonLine(socket, { + ...errorResponse( 'protocol-mismatch', `Unsupported mcpxd protocol ${message.protocolVersion}; expected ${DAEMON_PROTOCOL_VERSION}.`, ), - ) + protocolVersion: DAEMON_PROTOCOL_VERSION, + }) return } phase = 'ready' diff --git a/tests/daemon-client.test.ts b/tests/daemon-client.test.ts index 75c2399..4a6f602 100644 --- a/tests/daemon-client.test.ts +++ b/tests/daemon-client.test.ts @@ -63,6 +63,24 @@ describe('MCP Runtime process client', () => { expect(fakeDaemon).toBeUndefined() }) + for (const strictHello of ['message', 'field'] as const) { + it(`stops an older daemon that refuses the new handshake (${strictHello})`, async () => { + await startFakeDaemon({ + protocolVersion: 3, + version: '0.15.0', + strictHello, + }) + + const status = (await requestRuntime( + { requestId: crypto.randomUUID(), op: 'status' }, + mainPath, + )) as { protocolVersion: number } + + expect(status.protocolVersion).toBe(4) + expect(fakeDaemon).toBeUndefined() + }) + } + it('stops an older same-protocol Runtime before restart', async () => { await startFakeDaemon({ protocolVersion: 3, version: '0.0.0' }) @@ -175,6 +193,8 @@ describe('MCP Runtime process client', () => { version: string closeOnIntent?: boolean inputThenTerminal?: boolean + /** Behave like a real v3+ mcpxd: refuse other protocols and hang up. */ + strictHello?: 'message' | 'field' }): Promise { await fs.mkdir(path.dirname(daemonSocketPath()), { recursive: true }) fakeDaemon = net.createServer((socket) => { @@ -186,9 +206,27 @@ describe('MCP Runtime process client', () => { const message = JSON.parse(buffer.slice(0, newline)) as { op?: string requestId?: string + protocolVersion?: number } buffer = buffer.slice(newline + 1) - if (message.op === 'hello') { + if ( + message.op === 'hello' && + options.strictHello && + message.protocolVersion !== options.protocolVersion + ) { + writeJsonLine(socket, { + ok: false, + error: { + code: 'protocol-mismatch', + message: `Unsupported mcpxd protocol ${message.protocolVersion}; expected ${options.protocolVersion}.`, + }, + ...(options.strictHello === 'field' + ? { protocolVersion: options.protocolVersion } + : {}), + }) + socket.end() + return + } else if (message.op === 'hello') { writeJsonLine(socket, { ok: true, protocolVersion: options.protocolVersion,