Files
rea/tests/boundary/mcp/gdbCloseDisposition.test.ts
T
morlutoandLuigi Pinna 847472acf5 fix(gdb): report close disposition as a capability precondition
Adapt #4732 using canonical provider identity and explicit producer recovery. Keep the valid session retained for caller-selected disposition and preserve default capability error behavior. Refs #4653.

Co-authored-by: Luigi Pinna <luigipinna3@gmail.com>
2026-10-18 10:03:39 +08:00

141 lines
5.3 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { McpServer } from "@modelcontextprotocol/server";
import { expect, it } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { GdbSessionManager } from "../../../src/gdb/GdbSessionManager.js";
import { silentLogger } from "../../../src/logger.js";
import { registerGdbTools } from "../../../src/server/registerGdbTools.js";
import { createToolResultDelivery } from "../../../src/server/toolResult.js";
it.skipIf(process.platform === "win32").each(["kill", "detach"])(
"retains a valid GDB session through disposition refusal and explicit %s recovery",
async (disposition) => {
const directory = await mkdtemp(join(tmpdir(), "rea-gdb-disposition-"));
const provider = join(directory, "fake-gdb.mjs");
await writeFile(
provider,
`#!${process.execPath}
import { createInterface } from 'node:readline';
let live = false;
process.stdout.write('(gdb)\\n');
createInterface({ input: process.stdin }).on('line', line => {
const token = /^(\\d+)/.exec(line)?.[1];
if (!token) return;
const command = line.slice(token.length);
if (command === '-interpreter-exec console "run"') {
live = true;
process.stdout.write(token + '^running\\n*stopped,reason="breakpoint-hit",thread-id="1"\\n(gdb)\\n');
return;
}
const disposition = command === '-interpreter-exec console "kill"' || command === '-interpreter-exec console "detach"';
if (disposition) live = false;
const result = command === '-list-thread-groups --recurse 1'
? (live ? 'done,groups=[{id="i1",type="process",pid="4242"}]' : 'done,groups=[]')
: disposition || command === '-gdb-set auto-load off' || command === '-gdb-exit'
? 'done' : 'error,msg="Unmodeled fixture command"';
process.stdout.write(token + '^' + result + '\\n(gdb)\\n', () => {
if (line.includes('-gdb-exit')) process.exit(0);
});
});
`,
);
await chmod(provider, 0o700);
const manager = new GdbSessionManager({
platform: "linux",
environment: { REA_GDB_COMMAND: provider },
});
const server = new McpServer({
name: "gdb-disposition-test",
version: "1",
});
registerGdbTools(
server,
manager,
silentLogger,
createToolResultDelivery(undefined),
);
const client = new Client({ name: "gdb-disposition-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
let sessionId: string | undefined;
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const started = await client.callTool({
name: "start_gdb_session",
arguments: {},
});
sessionId = toolContract("start_gdb_session").outputSchema.parse(
started.structuredContent,
).session_id;
const running = await client.callTool({
name: "gdb_console",
arguments: { session_id: sessionId, command: "run" },
});
expect(running.isError).not.toBe(true);
for (let attempt = 0; attempt < 2; attempt += 1) {
const refusal = await client.callTool({
name: "close_gdb_session",
arguments: { session_id: sessionId },
});
expect(refusal.isError).toBe(true);
expect(refusal.structuredContent).toMatchObject({
error: {
code: "capability_unavailable",
category: "unavailable",
retryable: true,
remediation: { action: expect.stringContaining("gdb_console") },
details: {
provider_id: "gnu.gdb",
operation: "close_gdb_session",
reason: expect.stringContaining("inferior"),
},
},
});
expect(JSON.stringify(refusal.structuredContent)).not.toContain(
"invalid_value",
);
expect(manager.status(sessionId)?.state).toBe("ready");
}
const disposed = await client.callTool({
name: "gdb_console",
arguments: { session_id: sessionId, command: disposition },
});
expect(disposed.isError).not.toBe(true);
const closed = await client.callTool({
name: "close_gdb_session",
arguments: { session_id: sessionId },
});
expect(closed.isError).not.toBe(true);
expect(closed.structuredContent).toMatchObject({
target_state: "none_observed",
});
expect(manager.status(sessionId)).toBeUndefined();
const unknown = await client.callTool({
name: "close_gdb_session",
arguments: { session_id: randomUUID() },
});
expect(unknown.structuredContent).toMatchObject({
error: { code: "invalid_request", category: "invalid_input" },
});
} finally {
if (sessionId !== undefined && manager.status(sessionId) !== undefined) {
const disposed = await manager.execute(sessionId, "kill");
if (!disposed.ok) throw disposed.error;
const closed = await manager.close(sessionId);
if (!closed.ok) throw closed.error;
}
await client.close();
await server.close();
await manager.closeAll();
await rm(directory, { recursive: true, force: true });
}
},
);