// ABOUTME: End-to-end test for MCP stdio JSON-RPC flow.
// ABOUTME: Verifies initialize, tools/list, and tools/call behavior.
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import path from "node:path";
const baseUrl = process.env.MINESWEEPER_BASE_URL;
const bearerToken = process.env.MINESWEEPER_BEARER_TOKEN;
const userSlug = process.env.MINESWEEPER_USER_SLUG;
function requireEnv(value, name) {
if (!value) {
throw new Error(`Missing required env var: ${name}`);
}
return value;
}
class StdioRpcClient {
proc;
buffer = Buffer.alloc(0);
pending = new Map();
nextId = 1;
stderrBuffer = "";
exited = false;
constructor(proc) {
this.proc = proc;
if (!proc.stdout || !proc.stdin) {
throw new Error("stdio pipes are required for MCP e2e test");
}
proc.stdout.on("data", (chunk) => {
this.buffer = Buffer.concat([this.buffer, chunk]);
this.processBuffer();
});
proc.stderr?.on("data", (chunk) => {
this.stderrBuffer += chunk.toString("utf8");
});
proc.on("exit", () => {
this.exited = true;
for (const [, resolver] of this.pending) {
resolver({ jsonrpc: "2.0", id: null, error: { code: -32000, message: "process exited" } });
}
this.pending.clear();
});
}
getStderr() {
return this.stderrBuffer.trim();
}
request(method, params, timeoutMs = 5000) {
if (!this.proc.stdin) {
throw new Error("stdin is not available");
}
if (this.exited) {
return Promise.resolve({ jsonrpc: "2.0", id: null, error: { code: -32000, message: "process exited" } });
}
const id = this.nextId++;
const payload = { jsonrpc: "2.0", id, method, params };
const message = JSON.stringify(payload);
const header = `Content-Length: ${Buffer.byteLength(message, "utf8")}\r\n\r\n`;
this.proc.stdin.write(header + message);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`RPC timeout for ${method}. stderr: ${this.stderrBuffer.trim()}`));
}, timeoutMs);
this.pending.set(id, (response) => {
clearTimeout(timeout);
resolve(response);
});
});
}
notify(method, params) {
if (!this.proc.stdin) {
throw new Error("stdin is not available");
}
const payload = { jsonrpc: "2.0", method, params };
const message = JSON.stringify(payload);
const header = `Content-Length: ${Buffer.byteLength(message, "utf8")}\r\n\r\n`;
this.proc.stdin.write(header + message);
}
processBuffer() {
while (true) {
const headerEnd = this.buffer.indexOf("\r\n\r\n");
if (headerEnd === -1) {
return;
}
const headerText = this.buffer.slice(0, headerEnd).toString("utf8");
const match = /Content-Length:\s*(\d+)/i.exec(headerText);
if (!match) {
this.buffer = this.buffer.slice(headerEnd + 4);
continue;
}
const contentLength = Number(match[1]);
const messageStart = headerEnd + 4;
const messageEnd = messageStart + contentLength;
if (this.buffer.length < messageEnd) {
return;
}
const messageText = this.buffer.slice(messageStart, messageEnd).toString("utf8");
this.buffer = this.buffer.slice(messageEnd);
const response = JSON.parse(messageText);
if (typeof response.id === "number") {
const resolver = this.pending.get(response.id);
if (resolver) {
this.pending.delete(response.id);
resolver(response);
}
}
}
}
}
test("MCP stdio flow", { timeout: 30000 }, async () => {
const env = {
...process.env,
MINESWEEPER_BASE_URL: requireEnv(baseUrl, "MINESWEEPER_BASE_URL"),
MINESWEEPER_BEARER_TOKEN: requireEnv(bearerToken, "MINESWEEPER_BEARER_TOKEN"),
MINESWEEPER_USER_SLUG: requireEnv(userSlug, "MINESWEEPER_USER_SLUG"),
};
const distRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const serverPath = path.join(distRoot, "src", "index.js");
const proc = spawn(process.execPath, [serverPath], {
env,
stdio: ["pipe", "pipe", "pipe"],
});
const client = new StdioRpcClient(proc);
try {
const initResponse = await client.request("initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "test", version: "0.0.0" },
});
assert.equal(initResponse.error, undefined, `initialize error: ${initResponse.error?.message ?? "unknown"}, stderr: ${client.getStderr()}`);
assert.ok(initResponse.result);
client.notify("initialized");
const listResponse = await client.request("tools/list");
const tools = listResponse.result.tools ?? [];
assert.ok(tools.find((tool) => tool.name === "user_start"));
const startResponse = await client.request("tools/call", {
name: "user_start",
arguments: { user_slug: env.MINESWEEPER_USER_SLUG },
});
const startResult = startResponse.result;
const startText = startResult.content?.[0]?.text ?? "";
assert.equal(startResult.isError, undefined, `user_start failed: ${startText}`);
const startPayload = JSON.parse(startText || "{}");
assert.ok(startPayload.public_id);
const stateResponse = await client.request("tools/call", {
name: "game_state",
arguments: { public_id: startPayload.public_id },
});
const stateResult = stateResponse.result;
const stateText = stateResult.content?.[0]?.text ?? "";
assert.equal(stateResult.isError, undefined, `game_state failed: ${stateText}`);
await client.request("tools/call", {
name: "game_end",
arguments: { public_id: startPayload.public_id },
});
}
finally {
proc.kill();
}
});