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>
141 lines
5.3 KiB
TypeScript
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 });
|
|
}
|
|
},
|
|
);
|