feat(mcp): speak MCP 2026-07-28 next to the classic handshake, with tasks for long calls (#60)

This commit is contained in:
Roel authored and GitHub committed 2026-11-01 19:10:03 +01:00
1 parent aeb6159a1b
commit fc4c7bdd89
9 files changed
+546 -54

No files matched your search

+3
View File
@@ -115,6 +115,9 @@ Built for the agent's circumstances too:
- **A `next` field wherever there is an obvious next step** (graphs, truncated results, every porting step), with the exact call.
- **Big results do not flood the context.** Answers over 256 KB are saved to `~/.reverseport/tool-output/`; the
agent gets a short version with the path. Slightly wrong input from smaller models is repaired where it is safe.
- **Long work does not time out.** On MCP 2026-07-28 a call still running after a few seconds (a cold Ghidra
import, a big port plan) comes back as a task the client polls, with progress in its status; quick calls answer
directly. Older clients keep the classic handshake on the same server.
- **Agents are heard.** `share_feedback` leaves a note for the maintainers; it stays on the user's machine until
they share it.
+18
View File
@@ -75,6 +75,24 @@ The server answers only on localhost. Pages served from localhost may call it fr
agents such as GPT Actions need a public HTTPS URL), set `REVERSEPORT_TOKEN`, `REVERSEPORT_HOST=0.0.0.0` and
`REVERSEPORT_ALLOWED_HOSTS=<host names>`; without a token it refuses to listen beyond this machine.
## MCP versions and long-running calls
The server speaks both MCP eras on the same connection. Clients that open with `initialize` get the classic
session (2025-11-25 and older). Clients on MCP 2026-07-28 send `server/discover` or any request with
`io.modelcontextprotocol/protocolVersion` in `_meta` and are served without a handshake: every result carries
`resultType`, list results carry `ttlMs` and `cacheScope`, and `tools/list` keeps a fixed order so prompt caches
stay warm.
A 2026-07-28 client that declares the `io.modelcontextprotocol/tasks` extension gets long calls as tasks: a
`tools/call` still running after 2.5 seconds returns a task id instead of blocking, and `tasks/get` shows its status
(progress from the engine becomes `statusMessage`) and, at the end, the normal tool result. `tasks/cancel` stops
it. Quick calls answer directly, so nothing gets slower. Task ids last six hours and do not survive a restart of
the server. `REVERSEPORT_TASK_AFTER_MS` changes the 2.5 seconds. Claude Code uses tasks in its CLI from v2.1.304.
`subscriptions/listen` (stdio) delivers `notifications/tools/list_changed` when the engine starts, stops or the
tool profile changes. Over HTTP the endpoint answers with JSON only, so a stream cannot stay open there; call
`tools/list` again instead.
## Fit the tool list to the agent
Every agent can reach every tool. What changes is how much is listed up front:
+18
View File
@@ -71,6 +71,24 @@ free-form object is sent with `strict: false`; de andere zijn strikt.
De server antwoordt alleen op localhost. Pagina's die vanaf localhost worden aangeboden, kunnen het vanuit een browser aanroepen (CORS). Stel `REVERSEPORT_TOKEN` in om `Authorization: Bearer <token>` te vereisen. Om het vanuit Docker of via een tunnel te bereiken (gehoste agents zoals GPT Actions hebben een openbare HTTPS-URL nodig), stel `REVERSEPORT_TOKEN`, `REVERSEPORT_HOST=0.0.0.0` and
`REVERSEPORT_ALLOWED_HOSTS=<host names>`; zonder token weigert de server buiten deze machine te luisteren.
## MCP-versies en lange klussen
De server spreekt beide MCP-tijdperken op dezelfde verbinding. Clients die met `initialize` beginnen krijgen de
klassieke sessie (2025-11-25 en ouder). Clients op MCP 2026-07-28 sturen `server/discover` of een verzoek met
`io.modelcontextprotocol/protocolVersion` in `_meta` en worden zonder handdruk bediend: elk resultaat heeft
`resultType`, lijsten hebben `ttlMs` en `cacheScope`, en `tools/list` houdt een vaste volgorde, zodat de
prompt-cache van de agent warm blijft.
Een 2026-07-28-client die de extensie `io.modelcontextprotocol/tasks` meldt, krijgt lange aanroepen als taak: een
`tools/call` die na 2,5 seconde nog loopt, geeft een taaknummer terug in plaats van te blijven wachten. `tasks/get`
toont de stand (voortgang van de motor komt in `statusMessage`) en aan het eind het gewone resultaat.
`tasks/cancel` stopt de taak. Snelle aanroepen antwoorden direct, dus niets wordt trager. Taaknummers gelden zes
uur en overleven geen herstart van de server. `REVERSEPORT_TASK_AFTER_MS` verandert de 2,5 seconde. Claude Code
gebruikt taken in de CLI vanaf v2.1.304.
`subscriptions/listen` (stdio) meldt `notifications/tools/list_changed` als de motor start of stopt, of als het
tool-profiel verandert. Via HTTP antwoordt het endpoint alleen met JSON; vraag daar `tools/list` gewoon opnieuw op.
## Pas de tool-lijst aan de agent aan
Elke agent kan elke tool bereiken. Wat verandert, is hoeveel er vooraf wordt weergegeven:
+3 -1
View File
@@ -36,7 +36,8 @@ function dispatchOne(line) {
return relay.handle(msg).then(res => { if (res) write(res); }, e => { process.stderr.write(`reverseport: ${e.stack ?? e.message}\n`); if (msg?.id !== undefined) write({ jsonrpc: '2.0', id: msg.id, error: { code: -32603, message: e.message } }); });
}
// initialize starts the engine; everything that arrives meanwhile waits for it, then requests run concurrently.
// A legacy initialize starts the engine and everything that arrives meanwhile waits for it; MCP 2026-07-28 requests
// (no handshake) start it on first use. Requests run concurrently.
let gate = null;
const decoder = new StringDecoder('utf8');
let buffer = '';
@@ -57,6 +58,7 @@ process.stdin.on('data', chunk => {
process.stdin.on('end', async () => {
if (buffer.trim()) await (gate ?? Promise.resolve()).then(() => dispatch(buffer.trim()));
await gate;
relay.endSubscriptions(); // open subscriptions/listen streams end gracefully
await Promise.allSettled([...inFlight]); // let running calls finish before the engine is closed
relay.close();
});
+16 -2
View File
@@ -5,7 +5,8 @@
// POST /api/agent/call {name, arguments}
// POST /api/agent/tools/{name} {…arguments}
// GET /api/openapi.json (GPT Actions, LangChain/LlamaIndex OpenAPI tools, n8n, Semantic Kernel)
// POST /mcp MCP Streamable HTTP (JSON responses) for URL-based MCP clients
// POST /mcp MCP Streamable HTTP (JSON responses) for URL-based MCP clients: the legacy
// initialize session and stateless MCP 2026-07-28 requests
// The same tools as `reverseport mcp`: ReversePort's own plus the deep-analysis engine's (when built).
// REVERSEPORT_TOKEN=… requires `Authorization: Bearer …`. Browser pages on localhost may call it (CORS).
// Reaching it from Docker or a tunnel: REVERSEPORT_HOST=0.0.0.0 plus REVERSEPORT_ALLOWED_HOSTS=host1,host2 — only
@@ -41,6 +42,17 @@ export function openapiFor(tools, port) {
...(secured ? { components: { securitySchemes: { bearer: { type: 'http', scheme: 'bearer' } } }, security: [{ bearer: [] }] } : {}) };
}
/** MCP 2026-07-28 requests repeat their version and method in headers; when given, they must agree with the body. */
export function headerMismatch(headers, message) {
const version = message?.params?._meta?.['io.modelcontextprotocol/protocolVersion'];
if (version === undefined) return null;
const sentVersion = headers['mcp-protocol-version'];
if (sentVersion !== undefined && sentVersion !== version) return `MCP-Protocol-Version is ${sentVersion} but the body says ${version}.`;
const sentMethod = headers['mcp-method'];
if (sentMethod !== undefined && sentMethod !== message.method) return `Mcp-Method is ${sentMethod} but the body calls ${message.method}.`;
return null;
}
const unwrap = result => {
if (result?.structuredContent !== undefined) return result.structuredContent;
const text = result?.content?.find(c => c.type === 'text')?.text;
@@ -60,7 +72,7 @@ export function createServer({ relay } = {}) {
const host = (req.headers.host ?? '').replace(/:\d+$/, '').toLowerCase();
if (!allowedHosts().has(host)) return fail(403, new RPError('FORBIDDEN_HOST', 'ReversePort only answers on localhost (or hosts listed in REVERSEPORT_ALLOWED_HOSTS).'));
if (origin && !LOCAL_ORIGIN.test(origin)) return fail(403, new RPError('FORBIDDEN_ORIGIN', 'Only pages on localhost may call ReversePort.'));
if (req.method === 'OPTIONS') { res.writeHead(204, { ...cors, 'access-control-allow-methods': 'GET, POST, DELETE, OPTIONS', 'access-control-allow-headers': 'content-type, authorization, mcp-session-id, mcp-protocol-version', 'access-control-max-age': '600' }); return res.end(); }
if (req.method === 'OPTIONS') { res.writeHead(204, { ...cors, 'access-control-allow-methods': 'GET, POST, DELETE, OPTIONS', 'access-control-allow-headers': 'content-type, authorization, mcp-session-id, mcp-protocol-version, mcp-method, mcp-name', 'access-control-max-age': '600' }); return res.end(); }
const token = process.env.REVERSEPORT_TOKEN;
if (token && req.headers.authorization !== `Bearer ${token}`) return fail(401, new RPError('UNAUTHORIZED', 'Send Authorization: Bearer <REVERSEPORT_TOKEN>.'));
const body = async () => {
@@ -96,6 +108,8 @@ export function createServer({ relay } = {}) {
const replies = [];
let session = req.headers['mcp-session-id'];
for (const m of messages) {
const mismatch = headerMismatch(req.headers, m);
if (mismatch) return json(400, { jsonrpc: '2.0', id: m?.id ?? null, error: { code: -32020, message: `Header mismatch: ${mismatch}` } });
const r = await shared.handle(m);
if (m?.method === 'initialize' && r?.result) { session = crypto.randomUUID(); sessions.add(session); }
if (r) replies.push(r);
+272 -51
View File
@@ -8,6 +8,7 @@
// other engine tool stays one call away through engine_tools (find) and engine_call (use). REVERSEPORT_TOOLS=all
// lists everything, REVERSEPORT_TOOLS=port only ReversePort's own, or give a comma list of names and globs.
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { StringDecoder } from 'node:string_decoder';
@@ -18,7 +19,26 @@ import { RPError } from './util.mjs';
import { budgetValue, budgetMcpResult } from './budget.mjs';
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
/** Legacy MCP revisions: an initialize handshake opens a session. */
export const SUPPORTED_PROTOCOLS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
/** Modern MCP revisions: stateless, every request carries its version, capabilities and client in _meta. */
export const MODERN_PROTOCOLS = ['2026-07-28'];
export const TASKS_EXTENSION = 'io.modelcontextprotocol/tasks';
const META = {
version: 'io.modelcontextprotocol/protocolVersion',
capabilities: 'io.modelcontextprotocol/clientCapabilities',
client: 'io.modelcontextprotocol/clientInfo',
server: 'io.modelcontextprotocol/serverInfo',
logLevel: 'io.modelcontextprotocol/logLevel',
subscription: 'io.modelcontextprotocol/subscriptionId',
};
/** A modern tools/call still running after this long becomes a task the client polls (tasks extension). */
const TASK_AFTER_MS = Math.max(0, Number(process.env.REVERSEPORT_TASK_AFTER_MS ?? 2500) || 0);
const TASK_TTL_MS = 6 * 3600_000;
const TASK_POLL_MS = 1000;
const MAX_TASKS = 200;
const LIST_TTL_MS = 300_000;
const LIST_METHODS = new Set(['tools/list', 'prompts/list', 'resources/list', 'resources/read', 'resources/templates/list']);
const ENGINE_START_TIMEOUT_MS = Number(process.env.REVERSEPORT_ENGINE_TIMEOUT_MS) || 60_000;
const MAX_RESTARTS = 3;
@@ -279,19 +299,45 @@ export function createRelay(hooks = {}, options = {}) {
const explicitTools = options.tools ?? process.env.REVERSEPORT_TOOLS;
let profile = PROFILES[String(options.profile ?? process.env.REVERSEPORT_PROFILE ?? 'standard').toLowerCase()] ? String(options.profile ?? process.env.REVERSEPORT_PROFILE ?? 'standard').toLowerCase() : 'standard';
let filter = toolFilter(explicitTools ?? PROFILES[profile].tools);
const state = { initialized: false, clientReady: false, engine: null, note: null, status: 'not-started', knownEngineTools: new Set(), restarts: 0, starting: null, initParams: null, clientInfo: null };
const state = { initialized: false, clientReady: false, engine: null, note: null, status: 'not-started', knownEngineTools: new Set(), restarts: 0, starting: null, initParams: null, clientInfo: null,
legacy: false, modernStart: null, modernLogLevel: null, subscriptions: new Map(), tasks: new Map(), requestKeys: new Map(), taskTokens: new Map() };
let writerQueue = Promise.resolve();
let taskTokenCount = 0;
/** List changes reach a legacy client directly and modern clients only through their subscriptions/listen streams. */
function listChanged(kind) {
const method = `notifications/${kind}/list_changed`;
if (state.legacy && state.clientReady) hooks.notify?.({ method, params: {} });
const flag = { tools: 'toolsListChanged', prompts: 'promptsListChanged', resources: 'resourcesListChanged' }[kind];
for (const [id, sub] of state.subscriptions) if (sub.notifications[flag]) hooks.notify?.({ method, params: { _meta: { [META.subscription]: id } } });
}
const engineHooks = {
onNotification: msg => { if (state.clientReady) hooks.notify?.({ method: msg.method, ...(msg.params === undefined ? {} : { params: msg.params }) }); },
onRequest: msg => hooks.request ? hooks.request({ method: msg.method, ...(msg.params === undefined ? {} : { params: msg.params }) }) : (msg.method === 'roots/list' ? { result: { roots: [] } } : { error: { code: -32601, message: `${msg.method} is not available over this connection.` } }),
onNotification: msg => {
const params = msg.params;
const kind = /^notifications\/(tools|prompts|resources)\/list_changed$/.exec(msg.method)?.[1];
if (kind) return listChanged(kind);
if (msg.method === 'notifications/progress' && state.taskTokens.has(params?.progressToken)) {
const task = state.taskTokens.get(params.progressToken);
if (task) { // the call became a task: progress goes into its status, not onto the wire
const amount = params.total ? `${params.progress}/${params.total}` : `${params.progress}`;
task.statusMessage = params.message ? `${params.message} (${amount})` : `Progress ${amount}`;
task.lastUpdatedAt = new Date().toISOString();
}
if (task || String(params.progressToken).startsWith('rp-task-')) return;
}
if (state.legacy) { if (state.clientReady) hooks.notify?.({ method: msg.method, ...(params === undefined ? {} : { params }) }); return; }
// Modern clients: request-scoped progress, and log messages only once they asked for a log level.
if (msg.method === 'notifications/progress' || (msg.method === 'notifications/message' && state.modernLogLevel)) hooks.notify?.({ method: msg.method, ...(params === undefined ? {} : { params }) });
},
onRequest: msg => (hooks.request && state.legacy) ? hooks.request({ method: msg.method, ...(msg.params === undefined ? {} : { params: msg.params }) }) : (msg.method === 'roots/list' ? { result: { roots: [] } } : { error: { code: -32601, message: `${msg.method} is not available over this connection.` } }),
onExit: reason => {
for (const t of state.engine?.tools ?? []) state.knownEngineTools.add(t.name);
state.status = state.restarts >= MAX_RESTARTS ? 'limit' : 'stopped';
state.note = state.status === 'limit'
? `The deep-analysis engine ${reason} and has stopped ${MAX_RESTARTS} times in a row, so it will not restart in this session. ReversePort's own tools still work.`
: `The deep-analysis engine ${reason}. ReversePort's own tools still work; the next engine tool call restarts it.`;
if (state.clientReady) hooks.notify?.({ method: 'notifications/tools/list_changed', params: {} });
listChanged('tools');
},
};
@@ -322,7 +368,7 @@ export function createRelay(hooks = {}, options = {}) {
if (!state.engine || state.restarts >= MAX_RESTARTS) { if (state.engine) state.status = 'limit'; return null; }
if (!state.starting) {
state.restarts++;
state.starting = startEngine(state.initParams).then(e => { if (e) state.engine = e; state.starting = null; if (e && state.clientReady) hooks.notify?.({ method: 'notifications/tools/list_changed', params: {} }); return e; });
state.starting = startEngine(state.initParams).then(e => { if (e) state.engine = e; state.starting = null; if (e) listChanged('tools'); return e; });
}
return state.starting;
}
@@ -387,7 +433,7 @@ export function createRelay(hooks = {}, options = {}) {
profile = wanted;
if (!explicitTools) filter = toolFilter(PROFILES[profile].tools);
const after = listedTools().length;
if (state.clientReady) hooks.notify?.({ method: 'notifications/tools/list_changed', params: {} });
listChanged('tools');
return toolResult({ profile, about: PROFILES[profile].about, listedBefore: before, listedNow: after, ...(explicitTools ? { note: 'REVERSEPORT_TOOLS is set, so the tool selection follows it; only description length changed.' } : {}), next: 'Call tools/list again if your client does not refresh by itself. engine_tools/engine_call still reach every tool.' });
}
if (name === 'engine_tools') {
@@ -414,58 +460,184 @@ export function createRelay(hooks = {}, options = {}) {
return callEngineTool(args.tool, args.arguments, params?._meta, clientKey);
}
async function handle(msg) {
if (!msg || Array.isArray(msg) || msg.jsonrpc !== '2.0') return { jsonrpc: '2.0', id: msg?.id ?? null, error: { code: -32600, message: 'Invalid request: send one JSON-RPC 2.0 object per line.' } };
/** One tools/call (both eras). `key` identifies the request towards the engine, so cancellation finds it. */
async function toolsCall(params, key) {
const name = params?.name;
const engine = state.engine?.alive ? state.engine : null;
if (localToolDefs.some(t => t.name === name)) return runLocal(name, params.arguments);
if (META_TOOLS.some(t => t.name === name)) return meta(name, params.arguments, params, key);
if (engine?.tools.some(t => t.name === name) || state.knownEngineTools.has(name)) return callEngineTool(name, params.arguments, params._meta, key);
if (!engine && knownEngineToolNames().has(name)) return unavailable(name);
const names = [...localToolDefs.map(t => t.name), ...META_TOOLS.map(t => t.name), ...(engine ? engine.tools.map(t => t.name) : knownEngineToolNames())];
const near = closest(name, names, 3);
return toolError(new RPError('UNKNOWN_TOOL', `Unknown tool "${name}".${near ? ` Did you mean "${near}"?` : ''} tools/list shows the listed tools${state.engine ? '; engine_tools {query} finds the rest' : ''}.`));
}
/** tools/call with a request key that notifications/cancelled can find while the request is open. */
async function trackedCall(id, params) {
const key = { requestId: id };
state.requestKeys.set(id, key);
try { return await toolsCall(params, key); } finally { if (state.requestKeys.get(id) === key) state.requestKeys.delete(id); }
}
const serverInfo = () => ({ name: 'reverseport', title: 'ReversePort', version: VERSION });
const instructions = () => [INSTRUCTIONS, state.note].filter(Boolean).join('\n\n');
// ------------------------------------------------------------------------------------------ modern MCP (2026-07-28)
function modernCapabilities() {
const engineCaps = state.engine?.initResult?.capabilities ?? { prompts: {}, resources: {}, completions: {} };
const { logging, experimental, tools, ...rest } = engineCaps; // logging is deprecated in 2026-07-28
return { ...rest, tools: { ...(tools ?? {}), listChanged: true }, extensions: { [TASKS_EXTENSION]: {} } };
}
/** Start the engine once for modern clients; concurrent first requests share the start. */
function ensureModern(meta) {
if (state.initialized) return state.modernStart ?? Promise.resolve();
state.initialized = true;
state.clientReady = true;
state.clientInfo = meta?.[META.client] ?? null;
// The engine keeps the legacy handshake with us; it gets no client capabilities, so it never asks for roots.
state.initParams = { protocolVersion: SUPPORTED_PROTOCOLS[0], capabilities: {}, clientInfo: state.clientInfo ?? { name: 'reverseport-modern-client', version: '0' } };
state.modernStart = startEngine(state.initParams).then(e => { state.engine = e; if (!e) state.restarts = MAX_RESTARTS; });
return state.modernStart;
}
function decorate(result, method) {
const out = { ...result, resultType: result?.resultType ?? 'complete', _meta: { ...(result?._meta ?? {}), [META.server]: serverInfo() } };
if (LIST_METHODS.has(method)) { out.ttlMs ??= LIST_TTL_MS; out.cacheScope ??= 'private'; }
return out;
}
const taskView = task => {
const { record, key, token, ...view } = task;
for (const k of Object.keys(view)) if (view[k] === undefined) delete view[k];
return view;
};
function purgeTasks(now = Date.now()) {
for (const [taskId, task] of state.tasks) {
const terminal = ['completed', 'failed', 'cancelled'].includes(task.status);
if (terminal && now - Date.parse(task.createdAt) > task.ttlMs) state.tasks.delete(taskId);
}
if (state.tasks.size <= MAX_TASKS) return;
for (const [taskId, task] of state.tasks) {
if (state.tasks.size <= MAX_TASKS) break;
if (['completed', 'failed', 'cancelled'].includes(task.status)) state.tasks.delete(taskId);
}
}
/** tools/call for a client that accepts tasks: answer directly when quick, else hand back a task to poll. */
async function callAsTask(id, params) {
const ownToken = params?._meta?.progressToken;
const token = ownToken ?? `rp-task-${++taskTokenCount}`;
const forwarded = { ...params, _meta: { ...(params?._meta ?? {}), progressToken: token } };
if (ownToken === undefined) state.taskTokens.set(token, null); // ours: progress is folded into the task only
const key = { requestId: id };
state.requestKeys.set(id, key);
const work = toolsCall(forwarded, key);
const later = Symbol('later');
let timer;
const first = await Promise.race([work, new Promise(r => { timer = setTimeout(() => r(later), TASK_AFTER_MS); })]);
clearTimeout(timer);
if (first !== later) {
state.requestKeys.delete(id);
if (ownToken === undefined) state.taskTokens.delete(token);
return decorate(first, 'tools/call');
}
purgeTasks();
const now = new Date().toISOString();
const task = { taskId: randomUUID(), status: 'working', statusMessage: `${params?.name ?? 'The tool'} is still running.`, createdAt: now, lastUpdatedAt: now, ttlMs: TASK_TTL_MS, pollIntervalMs: TASK_POLL_MS, key, token };
state.tasks.set(task.taskId, task);
state.taskTokens.set(token, task);
state.requestKeys.delete(id); // from now on tasks/cancel, not notifications/cancelled, ends it
const settle = update => {
state.taskTokens.delete(token);
if (task.status !== 'working') return; // cancelled meanwhile: the late result is dropped
Object.assign(task, update, { lastUpdatedAt: new Date().toISOString() });
};
work.then(result => settle({ status: 'completed', statusMessage: result?.isError ? 'Finished with a tool error; see the result.' : 'Finished.', result: decorate(result, 'tools/call') }),
e => settle({ status: 'failed', statusMessage: e?.message ?? String(e), error: { code: -32603, message: e?.message ?? String(e) } }));
return { resultType: 'task', ...taskView(task), _meta: { [META.server]: serverInfo() } };
}
function taskRequest(method, params, caps) {
if (!caps?.extensions?.[TASKS_EXTENSION]) return { error: { code: -32021, message: `Missing required client capability: ${method} needs the ${TASKS_EXTENSION} extension in this request's io.modelcontextprotocol/clientCapabilities.`, data: { required: { extensions: { [TASKS_EXTENSION]: {} } } } } };
purgeTasks();
const task = state.tasks.get(params?.taskId);
if (!task) return { error: { code: -32602, message: `Failed to retrieve task: Task not found (${String(params?.taskId ?? '')}). Task ids last ${TASK_TTL_MS / 3600_000} hours and do not survive a server restart; call the tool again.` } };
if (method === 'tasks/get') return { result: { ...taskView(task), resultType: 'complete' } };
if (method === 'tasks/update') return { result: { resultType: 'complete' } }; // ReversePort tasks never wait for input
if (task.status === 'working') { // tasks/cancel
const engine = state.engine?.alive ? state.engine : null;
const engineId = engine?.idFor(task.key);
if (engineId !== undefined) {
engine.write({ method: 'notifications/cancelled', params: { requestId: engineId, reason: 'The client cancelled the task.' } });
const pending = engine.pending.get(engineId); // the engine sends nothing more for it: settle it here
engine.pending.delete(engineId);
pending?.resolve(toolError(new RPError('CANCELLED', 'The client cancelled this task.')));
}
Object.assign(task, { status: 'cancelled', statusMessage: 'Cancelled by the client.', lastUpdatedAt: new Date().toISOString() });
state.taskTokens.delete(task.token);
}
return { result: { resultType: 'complete' } };
}
async function listen(id, params) {
if (!hooks.notify) return { error: { code: -32601, message: 'subscriptions/listen needs a streaming connection (stdio). This HTTP endpoint answers with JSON only: call tools/list again when a result says the list changed.' } };
const wanted = params?.notifications ?? {};
const notifications = {};
for (const flag of ['toolsListChanged', 'promptsListChanged', 'resourcesListChanged']) if (wanted[flag] === true) notifications[flag] = true;
const sub = { notifications };
const ended = new Promise(resolve => { sub.end = resolve; });
state.subscriptions.set(id, sub);
hooks.notify({ method: 'notifications/subscriptions/acknowledged', params: { _meta: { [META.subscription]: id }, notifications } });
const how = await ended;
state.subscriptions.delete(id);
return how === 'client' ? null : { result: { resultType: 'complete', _meta: { [META.subscription]: id } } };
}
async function handleModern(msg, version) {
const { id, method, params } = msg;
const reply = result => ({ jsonrpc: '2.0', id, result: decorate(result, method) });
const fail = (code, message, data) => ({ jsonrpc: '2.0', id, error: { code, message, ...(data === undefined ? {} : { data }) } });
if (!MODERN_PROTOCOLS.includes(version)) return fail(-32022, 'Unsupported protocol version', { supported: [...MODERN_PROTOCOLS, ...SUPPORTED_PROTOCOLS], requested: version });
const metaIn = params._meta;
const caps = metaIn[META.capabilities] ?? {};
if (metaIn[META.logLevel]) state.modernLogLevel = metaIn[META.logLevel];
if (method === 'ping' || method === 'logging/setLevel') return fail(-32601, `Method not found: ${method} is not part of MCP ${version}.`);
await ensureModern(metaIn);
if (method === 'subscriptions/listen') {
const r = await listen(id, params);
if (!r) return undefined; // cancelled by the client: nothing more is sent for it
return r.error ? fail(r.error.code, r.error.message) : { jsonrpc: '2.0', id, result: r.result };
}
if (method === 'tasks/get' || method === 'tasks/update' || method === 'tasks/cancel') {
const r = taskRequest(method, params, caps);
return r.error ? fail(r.error.code, r.error.message, r.error.data) : { jsonrpc: '2.0', id, result: { ...r.result, _meta: { [META.server]: serverInfo() } } };
}
if (method === 'tools/call') {
if (caps?.extensions?.[TASKS_EXTENSION]) return { jsonrpc: '2.0', id, result: await callAsTask(id, params) };
return reply(await trackedCall(id, params));
}
const res = await legacyRequest({ id, method, params });
return res?.error ? res : reply(res.result);
}
// ------------------------------------------------------------------------------------------ requests (both eras)
async function legacyRequest({ id, method, params }) {
const reply = result => ({ jsonrpc: '2.0', id, result });
const fail = (code, message, data) => ({ jsonrpc: '2.0', id, error: { code, message, ...(data === undefined ? {} : { data }) } });
const engine = state.engine?.alive ? state.engine : null;
if (id === undefined) { // notifications
if (method === 'notifications/initialized') { state.clientReady = true; return; }
if (method === 'notifications/cancelled' && params?.requestId !== undefined) {
const engineId = engine?.idFor(params.requestId);
if (engineId !== undefined) engine.write({ method, params: { ...params, requestId: engineId } });
return;
}
if (engine) engine.write({ method, ...(params === undefined ? {} : { params }) });
return;
}
if (method === 'initialize') {
if (!state.initialized) {
state.initialized = true;
state.initParams = params ?? {};
state.clientInfo = params?.clientInfo ?? null;
state.engine = await startEngine(state.initParams);
if (!state.engine) state.restarts = MAX_RESTARTS; // nothing to restart
}
const wanted = params?.protocolVersion;
const protocolVersion = state.engine?.initResult?.protocolVersion ?? (SUPPORTED_PROTOCOLS.includes(wanted) ? wanted : SUPPORTED_PROTOCOLS[0]);
const engineCaps = state.engine?.initResult?.capabilities ?? {};
const capabilities = { logging: {}, ...engineCaps, tools: { ...(engineCaps.tools ?? {}), listChanged: true } };
// The engine's own instructions repeat the routing above, so they are not sent twice.
const instructions = [INSTRUCTIONS, state.note].filter(Boolean).join('\n\n');
return reply({ protocolVersion, capabilities, serverInfo: { name: 'reverseport', title: 'ReversePort', version: VERSION }, instructions });
}
if (method === 'ping') return reply({});
if (!state.initialized) return fail(-32002, 'Server not initialized: send initialize first.');
if (method === 'tools/list') return reply({ tools: params?.cursor ? [] : listedTools() });
if (method === 'tools/call') {
const name = params?.name;
if (localToolDefs.some(t => t.name === name)) return reply(await runLocal(name, params.arguments));
if (META_TOOLS.some(t => t.name === name)) return reply(await meta(name, params.arguments, params, id));
if (engine?.tools.some(t => t.name === name) || state.knownEngineTools.has(name)) return reply(await callEngineTool(name, params.arguments, params._meta, id));
if (!engine && knownEngineToolNames().has(name)) return reply(unavailable(name));
const names = [...localToolDefs.map(t => t.name), ...META_TOOLS.map(t => t.name), ...(engine ? engine.tools.map(t => t.name) : knownEngineToolNames())];
const near = closest(name, names, 3);
return reply(toolError(new RPError('UNKNOWN_TOOL', `Unknown tool "${name}".${near ? ` Did you mean "${near}"?` : ''} tools/list shows the listed tools${state.engine ? '; engine_tools {query} finds the rest' : ''}.`)));
}
if (method === 'tools/call') return reply(await trackedCall(id, params));
if (method === 'logging/setLevel' && !engine) return reply({});
if (engine) {
const res = await engine.forward(method, params, id);
return res.error ? { jsonrpc: '2.0', id, error: res.error } : reply(res.result);
const key = { requestId: id };
state.requestKeys.set(id, key);
try {
const res = await engine.forward(method, params, key);
return res.error ? { jsonrpc: '2.0', id, error: res.error } : reply(res.result);
} finally { if (state.requestKeys.get(id) === key) state.requestKeys.delete(id); }
}
if (method === 'prompts/list') return reply({ prompts: [] });
if (method === 'resources/list') return reply({ resources: [] });
@@ -474,6 +646,53 @@ export function createRelay(hooks = {}, options = {}) {
return fail(-32601, `Method not found: ${method}`);
}
async function handle(msg) {
if (!msg || Array.isArray(msg) || msg.jsonrpc !== '2.0') return { jsonrpc: '2.0', id: msg?.id ?? null, error: { code: -32600, message: 'Invalid request: send one JSON-RPC 2.0 object per line.' } };
const { id, method, params } = msg;
const reply = result => ({ jsonrpc: '2.0', id, result });
const fail = (code, message, data) => ({ jsonrpc: '2.0', id, error: { code, message, ...(data === undefined ? {} : { data }) } });
if (id === undefined) { // notifications
const engine = state.engine?.alive ? state.engine : null;
if (method === 'notifications/initialized') { state.clientReady = true; return; }
if (method === 'notifications/cancelled' && params?.requestId !== undefined) {
const sub = state.subscriptions.get(params.requestId);
if (sub) return sub.end('client');
const key = state.requestKeys.get(params.requestId);
const engineId = key ? engine?.idFor(key) : undefined;
if (engineId !== undefined) engine.write({ method, params: { ...params, requestId: engineId } });
return;
}
if (engine && state.legacy) engine.write({ method, ...(params === undefined ? {} : { params }) });
return;
}
// MCP 2026-07-28: discovery, and every request that carries its protocol version in _meta.
if (method === 'server/discover') return reply({ resultType: 'complete', supportedVersions: [...MODERN_PROTOCOLS, ...SUPPORTED_PROTOCOLS], capabilities: modernCapabilities(), _meta: { [META.server]: serverInfo() }, instructions: instructions(), ttlMs: LIST_TTL_MS, cacheScope: 'private' });
const modern = params?._meta?.[META.version];
if (modern !== undefined && method !== 'initialize') return handleModern(msg, modern);
if (method === 'initialize') {
state.legacy = true;
if (!state.initialized) {
state.initialized = true;
state.initParams = params ?? {};
state.clientInfo = params?.clientInfo ?? null;
state.engine = await startEngine(state.initParams);
if (!state.engine) state.restarts = MAX_RESTARTS; // nothing to restart
} else if (state.modernStart) await state.modernStart;
const wanted = params?.protocolVersion;
const protocolVersion = state.engine?.initResult?.protocolVersion ?? (SUPPORTED_PROTOCOLS.includes(wanted) ? wanted : SUPPORTED_PROTOCOLS[0]);
const engineCaps = state.engine?.initResult?.capabilities ?? {};
const capabilities = { logging: {}, ...engineCaps, tools: { ...(engineCaps.tools ?? {}), listChanged: true } };
// The engine's own instructions repeat the routing above, so they are not sent twice.
return reply({ protocolVersion, capabilities, serverInfo: serverInfo(), instructions: instructions() });
}
if (method === 'ping') return reply({});
if (!state.initialized) return fail(-32002, `Server not initialized: send initialize first, or send MCP ${MODERN_PROTOCOLS[0]} requests with io.modelcontextprotocol/protocolVersion in _meta.`);
return legacyRequest(msg);
}
return {
handle,
/** For the HTTP API: initialize once without a client handshake. */
@@ -488,6 +707,8 @@ export function createRelay(hooks = {}, options = {}) {
if (res.error) return toolError(new RPError('UNKNOWN_TOOL', res.error.message));
return res.result;
},
/** End open subscriptions/listen streams gracefully (shutdown). */
endSubscriptions() { for (const sub of state.subscriptions.values()) sub.end('server'); },
info: () => ({ engine: state.engine?.alive ? 'running' : 'unavailable', note: state.note, profile, toolset: filter.label, listed: listedTools().length, engineTools: engineTools().length }),
close() { if (state.engine?.alive) { try { state.engine.child.stdin.end(); } catch {} } },
};
+14
View File
@@ -113,6 +113,20 @@ test('HTTP: every vendor format, MCP over HTTP, CORS for localhost pages', async
assert.ok(listed.result.tools.some(t => t.name === 're_scan'));
const called = await (await mcp({ id: 3, method: 'tools/call', params: { name: 'port_convert', arguments: { from: 'godot', to: 'unreal', position: [1, 2, 3] } } })).json();
assert.deepEqual(called.result.structuredContent.position, [-300, 100, 200]);
// MCP 2026-07-28 over the same endpoint: no session, version and method in headers and _meta
const modernMeta = { 'io.modelcontextprotocol/protocolVersion': '2026-07-28', 'io.modelcontextprotocol/clientCapabilities': {} };
const modernHeaders = method => ({ 'mcp-protocol-version': '2026-07-28', 'mcp-method': method });
const discovered = await mcp({ id: 10, method: 'server/discover', params: { _meta: modernMeta } }, modernHeaders('server/discover'));
assert.equal(discovered.headers.get('mcp-session-id'), null, 'no protocol session in 2026-07-28');
assert.ok((await discovered.json()).result.supportedVersions.includes('2026-07-28'));
const modernCall = await (await mcp({ id: 11, method: 'tools/call', params: { name: 'port_convert', arguments: { from: 'godot', to: 'unreal', position: [1, 2, 3] }, _meta: modernMeta } }, modernHeaders('tools/call'))).json();
assert.equal(modernCall.result.resultType, 'complete');
assert.deepEqual(modernCall.result.structuredContent.position, [-300, 100, 200]);
const mismatch = await mcp({ id: 12, method: 'tools/list', params: { _meta: modernMeta } }, modernHeaders('tools/call'));
assert.equal(mismatch.status, 400);
assert.equal((await mismatch.json()).error.code, -32020);
const noStream = await (await mcp({ id: 13, method: 'subscriptions/listen', params: { notifications: { toolsListChanged: true }, _meta: modernMeta } }, modernHeaders('subscriptions/listen'))).json();
assert.equal(noStream.error.code, -32601, 'JSON-only HTTP cannot hold a stream open and says so');
const pre = await fetch(`${base}/mcp`, { method: 'OPTIONS', headers: { origin: 'http://localhost:3000', 'access-control-request-method': 'POST' } });
assert.equal(pre.status, 204);
assert.equal(pre.headers.get('access-control-allow-origin'), 'http://localhost:3000');
+5
View File
@@ -10,6 +10,7 @@ const tools = [
{ name: 'ask_roots', description: 'fake', inputSchema: { type: 'object' } },
{ name: 're_scan', description: 'shadowed', inputSchema: { type: 'object' } },
{ name: 'crash', description: 'fake: exits the engine', inputSchema: { type: 'object' } },
{ name: 'slow_ok', description: 'fake: answers after ms with progress', inputSchema: { type: 'object', properties: { ms: { type: 'integer' } } } },
];
createInterface({ input: process.stdin }).on('line', line => {
const m = JSON.parse(line);
@@ -26,6 +27,10 @@ createInterface({ input: process.stdin }).on('line', line => {
return send({ id: m.id, result: { content: [{ type: 'text', text: `code at ${args.address}` }], isError: false } });
}
if (name === 'slow') return; // answered never; the client cancels it
if (name === 'slow_ok') {
if (_meta?.progressToken !== undefined) setTimeout(() => send({ method: 'notifications/progress', params: { progressToken: _meta.progressToken, progress: 1, total: 2, message: 'Importing' } }), Math.min(100, args.ms / 2));
return setTimeout(() => send({ id: m.id, result: { content: [{ type: 'text', text: `done after ${args.ms}` }], isError: false } }), args.ms);
}
if (name === 'ask_roots') {
const id = nextId++;
waiting.set(id, r => send({ id: m.id, result: { content: [{ type: 'text', text: JSON.stringify(r.result) }], isError: false } }));
+197
View File
@@ -0,0 +1,197 @@
// MCP 2026-07-28 next to the legacy handshake: discovery, stateless requests, tasks for long calls, subscriptions.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { tempDir } from './helpers.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const FAKE = path.join(ROOT, 'test', 'fixtures', 'engine', 'fake-engine.mjs');
const home = await tempDir();
const V = '2026-07-28';
const TASKS = 'io.modelcontextprotocol/tasks';
function session(env = {}) {
const child = spawn(process.execPath, [path.join(ROOT, 'mcp.mjs')], {
env: { ...process.env, REVERSEPORT_HOME: home, REVERSEPORT_ENGINE_COMMAND: JSON.stringify([process.execPath, FAKE]), REVERSEPORT_TOOLS: 'all', ...env },
stdio: ['pipe', 'pipe', 'pipe'],
});
const seen = [];
const waiters = [];
let out = '';
child.stdout.on('data', d => {
out += d;
let i;
while ((i = out.indexOf('\n')) >= 0) {
const msg = JSON.parse(out.slice(0, i));
out = out.slice(i + 1);
seen.push(msg);
for (const w of [...waiters]) if (w.match(msg)) { waiters.splice(waiters.indexOf(w), 1); w.resolve(msg); }
}
});
const next = (match, ms = 15000) => {
const hit = seen.find(match);
if (hit) { seen.splice(seen.indexOf(hit), 1); return Promise.resolve(hit); }
return new Promise((resolve, reject) => {
const w = { match: m => { if (!match(m)) return false; seen.splice(seen.indexOf(m), 1); return true; }, resolve };
waiters.push(w);
setTimeout(() => reject(new Error(`timeout; seen ${JSON.stringify(seen)}`)), ms).unref();
});
};
const write = m => child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', ...m })}\n`);
const meta = (caps = {}, extra = {}) => ({ 'io.modelcontextprotocol/protocolVersion': V, 'io.modelcontextprotocol/clientInfo': { name: 'modern-test', version: '1' }, 'io.modelcontextprotocol/clientCapabilities': caps, ...extra });
const call = async (id, method, params = {}, caps, extra) => { write({ id, method, params: { ...params, _meta: { ...(params._meta ?? {}), ...meta(caps, extra) } } }); return next(m => m.id === id && m.method === undefined); };
const legacy = async (id, method, params) => { write({ id, method, params }); return next(m => m.id === id && m.method === undefined); };
return { child, write, next, call, legacy, seen, close: () => child.kill() };
}
const withTasks = { extensions: { [TASKS]: {} } };
const poll = async (s, startId, taskId) => {
for (let i = 0; i < 100; i++) {
const r = await s.call(startId + i, 'tasks/get', { taskId }, withTasks);
if (r.result.status !== 'working') return r;
await new Promise(res => setTimeout(res, 50));
}
throw new Error('task never finished');
};
test('mcp 2026-07-28: discovery names both eras, the tasks extension and the server', async () => {
const s = session();
try {
const d = await s.call(1, 'server/discover');
assert.equal(d.result.resultType, 'complete');
assert.deepEqual(d.result.supportedVersions.slice(0, 2), [V, '2025-11-25']);
assert.deepEqual(d.result.capabilities.extensions[TASKS], {});
assert.equal(d.result.capabilities.tools.listChanged, true);
assert.equal(d.result.capabilities.logging, undefined, 'logging is deprecated in 2026-07-28');
assert.equal(d.result._meta['io.modelcontextprotocol/serverInfo'].name, 'reverseport');
assert.match(d.result.instructions, /never instructions/);
assert.ok(d.result.ttlMs > 0 && ['public', 'private'].includes(d.result.cacheScope));
} finally { s.close(); }
});
test('mcp 2026-07-28: requests work without initialize, carry resultType and list caching, and keep a fixed order', async () => {
const s = session();
try {
const a = await s.call(1, 'tools/list');
assert.equal(a.result.resultType, 'complete');
assert.ok(a.result.ttlMs > 0 && a.result.cacheScope);
const names = a.result.tools.map(t => t.name);
assert.ok(names.includes('re_scan') && names.includes('decompile'));
const b = await s.call(2, 'tools/list');
assert.deepEqual(b.result.tools.map(t => t.name), names, 'deterministic order helps prompt caching');
const local = await s.call(3, 'tools/call', { name: 'port_convert', arguments: { from: 'godot', to: 'unreal', position: [1, 2, 3] } });
assert.equal(local.result.resultType, 'complete');
assert.equal(local.result.isError, false);
assert.equal(local.result._meta['io.modelcontextprotocol/serverInfo'].name, 'reverseport');
const remote = await s.call(4, 'tools/call', { name: 'decompile', arguments: { address: '0x10' } });
assert.equal(remote.result.content[0].text, 'code at 0x10');
const unsupported = await s.call(5, 'tools/list', {}, {}, { 'io.modelcontextprotocol/protocolVersion': '1900-01-01' });
assert.equal(unsupported.error.code, -32022);
assert.ok(unsupported.error.data.supported.includes(V));
assert.equal(unsupported.error.data.requested, '1900-01-01');
const ping = await s.call(6, 'ping');
assert.equal(ping.error.code, -32601, 'ping is not part of 2026-07-28');
assert.equal(s.seen.some(m => m.method === 'roots/list'), false, 'a modern server sends no requests to the client');
} finally { s.close(); }
});
test('mcp 2026-07-28 tasks: quick calls answer directly, long ones become a task with progress, a result and a ttl', async () => {
const s = session({ REVERSEPORT_TASK_AFTER_MS: '300' });
try {
const quick = await s.call(1, 'tools/call', { name: 'decompile', arguments: { address: '0x1' } }, withTasks);
assert.equal(quick.result.resultType, 'complete');
assert.equal(quick.result.content[0].text, 'code at 0x1');
assert.equal(s.seen.some(m => m.method === 'notifications/progress'), false, 'our own progress token never reaches the client');
const started = await s.call(2, 'tools/call', { name: 'slow_ok', arguments: { ms: 1200 } }, withTasks);
assert.equal(started.result.resultType, 'task');
const { taskId } = started.result;
assert.match(taskId, /^[0-9a-f-]{36}$/, 'unguessable task id');
assert.equal(started.result.status, 'working');
assert.ok(started.result.pollIntervalMs >= 250 && started.result.ttlMs > 0 && started.result.createdAt && started.result.lastUpdatedAt);
const midway = await s.call(3, 'tasks/get', { taskId }, withTasks);
assert.equal(midway.result.resultType, 'complete');
assert.equal(midway.result.status, 'working');
assert.match(midway.result.statusMessage, /Importing \(1\/2\)|still running/);
const done = await poll(s, 10, taskId);
assert.equal(done.result.status, 'completed');
assert.equal(done.result.result.content[0].text, 'done after 1200');
assert.equal(done.result.result.resultType, 'complete');
const missing = await s.call(200, 'tasks/get', { taskId: 'no-such-task' }, withTasks);
assert.equal(missing.error.code, -32602);
const noCap = await s.call(201, 'tasks/get', { taskId });
assert.equal(noCap.error.code, -32021, 'tasks/* needs the extension on that request');
const withoutCap = await s.call(202, 'tools/call', { name: 'slow_ok', arguments: { ms: 500 } });
assert.equal(withoutCap.result.resultType, 'complete', 'no task for a client that did not declare the extension');
assert.equal(withoutCap.result.content[0].text, 'done after 500');
} finally { s.close(); }
});
test('mcp 2026-07-28 tasks: cancel reaches the engine and the task stays cancelled', async () => {
const s = session({ REVERSEPORT_TASK_AFTER_MS: '200' });
try {
const started = await s.call(1, 'tools/call', { name: 'slow', arguments: {} }, withTasks, { 'io.modelcontextprotocol/logLevel': 'info' });
assert.equal(started.result.resultType, 'task');
const cancelled = await s.call(2, 'tasks/cancel', { taskId: started.result.taskId }, withTasks);
assert.equal(cancelled.result.resultType, 'complete');
const note = await s.next(m => m.method === 'notifications/message');
assert.match(note.params.data, /^cancelled \d+$/, 'the engine got notifications/cancelled with its own id');
const after = await s.call(3, 'tasks/get', { taskId: started.result.taskId }, withTasks);
assert.equal(after.result.status, 'cancelled');
} finally { s.close(); }
});
test('mcp 2026-07-28 subscriptions: list changes go only to the stream that asked, tagged with its id', async () => {
const s = session();
try {
s.write({ id: 'sub-1', method: 'subscriptions/listen', params: { notifications: { toolsListChanged: true, resourceSubscriptions: ['file:///x'] }, _meta: { 'io.modelcontextprotocol/protocolVersion': V, 'io.modelcontextprotocol/clientCapabilities': {} } } });
const ack = await s.next(m => m.method === 'notifications/subscriptions/acknowledged');
assert.equal(ack.params._meta['io.modelcontextprotocol/subscriptionId'], 'sub-1');
assert.deepEqual(ack.params.notifications, { toolsListChanged: true }, 'only what ReversePort can honour is acknowledged');
const switched = await s.call(2, 'tools/call', { name: 'set_tool_profile', arguments: { profile: 'compact' } });
assert.equal(switched.result.isError, false);
const changed = await s.next(m => m.method === 'notifications/tools/list_changed');
assert.equal(changed.params._meta['io.modelcontextprotocol/subscriptionId'], 'sub-1');
s.write({ method: 'notifications/cancelled', params: { requestId: 'sub-1' } });
await new Promise(r => setTimeout(r, 200));
assert.equal(s.seen.some(m => m.id === 'sub-1'), false, 'a stream the client cancelled gets no response');
} finally { s.close(); }
});
test('mcp 2026-07-28: the stream ends gracefully when the client closes stdin', async () => {
const s = session({ REVERSEPORT_ENGINE: 'off' });
try {
s.write({ id: 'sub-2', method: 'subscriptions/listen', params: { notifications: { toolsListChanged: true }, _meta: { 'io.modelcontextprotocol/protocolVersion': V, 'io.modelcontextprotocol/clientCapabilities': {} } } });
await s.next(m => m.method === 'notifications/subscriptions/acknowledged');
s.child.stdin.end();
const end = await s.next(m => m.id === 'sub-2');
assert.equal(end.result.resultType, 'complete');
assert.equal(end.result._meta['io.modelcontextprotocol/subscriptionId'], 'sub-2');
} finally { s.close(); }
});
test('mcp legacy clients are unchanged next to the modern path', async () => {
const s = session();
try {
const i = await s.legacy(1, 'initialize', { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'legacy', version: '1' } });
assert.equal(i.result.protocolVersion, '2025-11-25');
assert.equal(i.result.resultType, undefined, 'legacy results keep their shape');
s.write({ method: 'notifications/initialized' });
assert.deepEqual((await s.legacy(2, 'ping', {})).result, {});
const list = await s.legacy(3, 'tools/list', {});
assert.ok(list.result.tools.some(t => t.name === 're_scan'));
assert.equal(list.result.ttlMs, undefined);
} finally { s.close(); }
});