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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mcpx",
"version": "0.16.0",
"version": "0.16.1",
"license": "MIT",
"bin": {
"mcpx": "./src/main.ts"
Expand Down
30 changes: 29 additions & 1 deletion src/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ async function probeDaemon(): Promise<

async function stopIncompatibleDaemon(): Promise<void> {
const socket = await connectDaemonSocket()
let daemonProtocol: number | undefined
try {
const hello = await requestJsonLine(socket, helloMessage())
if (
Expand All @@ -85,12 +86,28 @@ async function stopIncompatibleDaemon(): Promise<void> {
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) {
Expand All @@ -100,6 +117,17 @@ async function stopIncompatibleDaemon(): Promise<void> {
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
}
18 changes: 15 additions & 3 deletions src/daemon-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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,
}
}
8 changes: 4 additions & 4 deletions src/daemon-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
40 changes: 39 additions & 1 deletion tests/daemon-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })

Expand Down Expand Up @@ -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<void> {
await fs.mkdir(path.dirname(daemonSocketPath()), { recursive: true })
fakeDaemon = net.createServer((socket) => {
Expand All @@ -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,
Expand Down
Loading