diff --git a/CHANGELOG.md b/CHANGELOG.md index e79ffc8..f88ef0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,6 +128,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Claude Code could not connect to the Torque MCP server (refused since 2026-10-04).** + Claude Code (2.1.308 desktop-bundled and 2.1.310 on PATH, recorded 2026-10-27 through a + pass-through stdio shim) probes with `server/discover` (id `server-discover-probe-1`), + reads the version list, then sends `initialize` with `2025-11-25` on the same + connection. The kernel pinned the connection modern on the probe and refused that + `initialize` with -32010 ("eras never mix"), so every connect failed. `server/discover` + now answers (still modern-shaped, still version-exempt) without pinning an era; the + first era-bearing message pins it — `initialize` pins legacy, a request carrying the + `_meta` protocol version pins modern. Everything else about "eras never mix" holds: + discover after `initialize`, a modern-marked request on a legacy connection, and + `initialize` after a real modern request all stay refused, and ambiguous or incomplete + handshakes still pin nothing. The unpinned-connection refusal no longer names discover + as a way to pin. Falsifier: the recorded two-line handshake, replayed over stdio, + seen red (-32010) against the unpatched kernel. - **One store per project, and a bounded touch trail (found 2026-10-27 reading a live install; all CLI-enforced, each test seen red against the unpatched tree):** - **One base for every caller.** `baseDir()` honored `$CLAUDE_PLUGIN_DATA`, which Claude diff --git a/src/mcp/rpc.js b/src/mcp/rpc.js index b76273f..8a9047f 100644 --- a/src/mcp/rpc.js +++ b/src/mcp/rpc.js @@ -4,12 +4,14 @@ // resources, no state — those arrive in later steps through the `methods` table. // // Era pinning is the one invariant this module exists to enforce: a connection -// speaks exactly one protocol era. `server/discover` (protocol 2026-07-28, -// stateless, _meta-carried version) pins modern; `initialize` (a named -// request/response revision) pins legacy; after the pin, the other era's -// surface is a named refusal — never a silent downgrade, never a mixed -// connection. Mixing is how a stateless request inherits handshake state it -// never negotiated. +// speaks exactly one protocol era. The first era-bearing message pins it: a +// request carrying the 2026-07-28 _meta protocol version pins modern; +// `initialize` (a named request/response revision) pins legacy; after the pin, +// the other era's surface is a named refusal — never a silent downgrade, never +// a mixed connection. Mixing is how a stateless request inherits handshake +// state it never negotiated. `server/discover` pins nothing: it negotiates no +// state, and clients probe with it before choosing an era on the same +// connection (Claude Code does exactly that, then sends a legacy initialize). const JSONRPC = '2.0'; const META = 'io.modelcontextprotocol/'; @@ -86,7 +88,7 @@ function createKernel({ serverInfo, capabilities, methods }) { return rpcError( ERR_ERA_PINNED, `this connection has no era yet and ${method} needs one — ` + - `knock with server/discover (modern) or initialize (legacy) first` + `send initialize (legacy) or a request carrying _meta ${META}protocolVersion (modern) first` ); } return rpcError( @@ -96,7 +98,8 @@ function createKernel({ serverInfo, capabilities, methods }) { ); } - // Built-ins pin the era. Everything else routes through the method table. + // initialize pins legacy; discover answers and pins nothing. Everything + // else routes through the method table. function dispatch(msg) { const method = msg.method; @@ -136,9 +139,10 @@ function createKernel({ serverInfo, capabilities, methods }) { if (method === 'server/discover') { // Version-exempt by design (attack-round-1 ruling): discover is the // door a client knocks to learn what versions exist — demanding proof - // of a version before answering that question is circular. + // of a version before answering that question is circular. Answering + // it chooses no era either: the client reads the list and THEN picks, + // on this same connection. if (era === 'legacy') throw eraRefusal(method); - era = 'modern'; return { protocolVersions: [MODERN_VERSION, ...LEGACY_VERSIONS], capabilities: caps, @@ -204,8 +208,9 @@ function createKernel({ serverInfo, capabilities, methods }) { const isNotification = id === undefined; if (isNotification && (msg.method === 'initialize' || msg.method === 'server/discover')) { - // A handshake needs an answer to exist; a notification-shaped one can - // only pin an era silently, which poisons the connection. Dropped. + // A handshake needs an answer to exist; a notification-shaped initialize + // can only pin an era silently, which poisons the connection, and a + // discover with no id asks a question nobody hears answered. Dropped. dropped++; return null; } @@ -237,7 +242,9 @@ function createKernel({ serverInfo, capabilities, methods }) { if (typeof result !== 'object' || result === null || Array.isArray(result)) { throw rpcError(ERR_INTERNAL, `handler for ${msg.method} returned a result that does not serialize to an object`); } - if (era === 'modern') { + // discover is a 2026-07-28 method, so its answer is modern-shaped + // even on the unpinned connection it leaves behind. + if (era === 'modern' || msg.method === 'server/discover') { // Modern results always name themselves: resultType is required by // the protocol, and serverInfo travels in _meta because there is no // handshake left to have said it once. Decoration works on a spread diff --git a/test/mcp-rpc.test.js b/test/mcp-rpc.test.js index 4f400c5..83e4cf6 100644 --- a/test/mcp-rpc.test.js +++ b/test/mcp-rpc.test.js @@ -86,15 +86,16 @@ ok('a batch array is refused by name — MCP removed batching', () => { // --- modern era: server/discover ------------------------------------------- -ok('server/discover advertises versions, capabilities, identity — and pins modern', () => { +ok('server/discover advertises versions, capabilities, identity — and pins nothing', () => { const conn = kernel().createConnection(); const res = conn.handleMessage({ jsonrpc: '2.0', id: 1, method: 'server/discover' }); assert.strictEqual(res.error, undefined, res.error && res.error.message); assert.deepStrictEqual(res.result.protocolVersions, [MODERN, LEGACY, CODEX_LEGACY]); assert.deepStrictEqual(res.result.serverInfo, { name: 'torque-mcp-test', version: '0.0.0' }); assert.ok(res.result.capabilities, 'capabilities must be advertised'); - assert.strictEqual(res.result.resultType, 'complete'); - assert.strictEqual(conn.era(), 'modern'); + assert.strictEqual(res.result.resultType, 'complete', 'discover is a modern method and answers modern-shaped'); + assert.deepStrictEqual(res.result._meta[META + 'serverInfo'], { name: 'torque-mcp-test', version: '0.0.0' }); + assert.strictEqual(conn.era(), null, 'asking which versions exist chooses none of them'); }); ok('a modern result carries resultType and serverInfo in _meta on every response', () => { @@ -217,15 +218,53 @@ ok('notifications/initialized is accepted silently on a legacy connection', () = // --- era pinning: never mixed ----------------------------------------------- -ok('initialize after server/discover is refused — eras never mix', () => { +ok('initialize after server/discover opens a legacy session — the probe chose no era', () => { const conn = kernel().createConnection(); conn.handleMessage({ jsonrpc: '2.0', id: 1, method: 'server/discover' }); const res = conn.handleMessage({ jsonrpc: '2.0', id: 2, method: 'initialize', params: legacyInit() }); + assert.strictEqual(res.error, undefined, res.error && res.error.message); + assert.strictEqual(res.result.protocolVersion, LEGACY); + assert.strictEqual(conn.era(), 'legacy'); +}); + +ok('initialize after a modern request is refused — eras never mix', () => { + const conn = kernel({ + 'echo/params': { eras: ['modern'], handler: () => ({}) }, + }).createConnection(); + conn.handleMessage({ jsonrpc: '2.0', id: 1, method: 'echo/params', params: { _meta: modernMeta() } }); + const res = conn.handleMessage({ jsonrpc: '2.0', id: 2, method: 'initialize', params: legacyInit() }); assert.strictEqual(res.error.code, rpc.ERR_ERA_PINNED); assert.match(res.error.message, /modern/); assert.match(res.error.message, /reconnect/i, 'the refusal must hand the client its remedy'); }); +// Recorded 2026-10-27 from Claude Code 2.1.308 and 2.1.310 through a pass-through +// stdio shim: the client probes with server/discover (carrying the modern _meta), +// reads the version list, and opens a legacy session on the SAME connection. +// Pinning modern on the probe refused every Claude Code connect from 2026-10-04. +ok('the recorded Claude Code handshake — discover probe, then initialize 2025-11-25 — succeeds', () => { + const input = new PassThrough(); + const output = new PassThrough(); + const lines = []; + output.on('data', (chunk) => lines.push(...String(chunk).split('\n').filter((l) => l.length))); + const conn = kernel().createConnection(); + stdio.attach({ createConnection: () => conn }, { input, output }); + const clientInfo = '{"name":"claude-code","title":"Claude Code","version":"2.1.310","description":"Anthropic\'s agentic coding tool","websiteUrl":"https://claude.com/claude-code"}'; + const clientCaps = '{"roots":{"listChanged":true},"elicitation":{"form":{},"url":{}},"extensions":{"io.modelcontextprotocol/tasks":{}}}'; + input.write('{"jsonrpc":"2.0","id":"server-discover-probe-1","method":"server/discover","params":{"_meta":{' + + `"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":${clientInfo},` + + `"io.modelcontextprotocol/clientCapabilities":${clientCaps}}}}\n`); + input.write(`{"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":${clientCaps},` + + `"clientInfo":${clientInfo}},"jsonrpc":"2.0","id":0}\n`); + const [discover, init] = lines.map((l) => JSON.parse(l)); + assert.strictEqual(discover.id, 'server-discover-probe-1'); + assert.deepStrictEqual(discover.result.protocolVersions, [MODERN, LEGACY, CODEX_LEGACY]); + assert.strictEqual(init.id, 0); + assert.strictEqual(init.error, undefined, init.error && init.error.message); + assert.strictEqual(init.result.protocolVersion, LEGACY); + assert.strictEqual(conn.era(), 'legacy'); +}); + ok('server/discover after initialize is refused — eras never mix', () => { const conn = kernel().createConnection(); conn.handleMessage({ jsonrpc: '2.0', id: 1, method: 'initialize', params: legacyInit() }); @@ -510,8 +549,8 @@ ok('C14 notifications/initialized moves no era, anywhere', () => { legacy.handleMessage({ jsonrpc: '2.0', id: 1, method: 'initialize', params: legacyInit() }); assert.strictEqual(legacy.handleMessage({ jsonrpc: '2.0', method: 'notifications/initialized' }), null); assert.strictEqual(legacy.era(), 'legacy'); - const modern = kernel().createConnection(); - modern.handleMessage({ jsonrpc: '2.0', id: 1, method: 'server/discover' }); + const modern = kernel({ 'echo/params': { eras: ['modern'], handler: () => ({}) } }).createConnection(); + modern.handleMessage({ jsonrpc: '2.0', id: 1, method: 'echo/params', params: { _meta: modernMeta() } }); assert.strictEqual(modern.handleMessage({ jsonrpc: '2.0', method: 'notifications/initialized' }), null); assert.strictEqual(modern.era(), 'modern', 'a dropped legacy notification must not unpin or flip modern'); const unpinned = kernel().createConnection(); @@ -605,8 +644,8 @@ ok('D10 dropped notifications are countable — acceptance and rejection are dif legacy.handleMessage({ jsonrpc: '2.0', id: 1, method: 'initialize', params: legacyInit() }); legacy.handleMessage({ jsonrpc: '2.0', method: 'notifications/initialized' }); assert.strictEqual(legacy.dropped(), 0, 'a legitimate legacy notification is accepted, not dropped'); - const modern = kernel().createConnection(); - modern.handleMessage({ jsonrpc: '2.0', id: 1, method: 'server/discover' }); + const modern = kernel({ 'echo/params': { eras: ['modern'], handler: () => ({}) } }).createConnection(); + modern.handleMessage({ jsonrpc: '2.0', id: 1, method: 'echo/params', params: { _meta: modernMeta() } }); modern.handleMessage({ jsonrpc: '2.0', method: 'notifications/initialized' }); assert.strictEqual(modern.dropped(), 1, 'the same notification on modern is a counted drop'); });