// 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: string | undefined, name: string): string {
if (!value) {
throw new Error(`Missing required env var: ${name}`);
}
return value;
}
type JsonRpcResponse = {
jsonrpc: "2.0";
id: number | string | null;
result?: unknown;
error?: { code: number; message: string };
};
class StdioRpcClient {
private buffer = Buffer.alloc(0);
private lineBuffer = "";
private transportMode: "header" | "line" | null = null;
private pending = new Map<number, (response: JsonRpcResponse) => void>();
private nextId = 1;
private stderrBuffer = "";
private exited = false;
constructor(private proc: ReturnType<typeof spawn>) {
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(): string {
return this.stderrBuffer.trim();
}
request(method: string, params?: unknown, timeoutMs = 5000): Promise<JsonRpcResponse> {
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)}\n`;
this.proc.stdin.write(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: string, params?: unknown): void {
if (!this.proc.stdin) {
throw new Error("stdin is not available");
}
const payload = { jsonrpc: "2.0", method, params };
const message = `${JSON.stringify(payload)}\n`;
this.proc.stdin.write(message);
}
private processBuffer(): void {
if (this.transportMode === null) {
const text = this.buffer.toString("utf8");
if (text.includes("Content-Length:")) {
this.transportMode = "header";
} else {
this.transportMode = "line";
}
}
if (this.transportMode === "header") {
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);
this.handleResponse(messageText);
}
}
const chunkText = this.buffer.toString("utf8");
this.buffer = Buffer.alloc(0);
this.lineBuffer += chunkText;
while (true) {
const newlineIndex = this.lineBuffer.search(/\r?\n/);
if (newlineIndex === -1) {
return;
}
const line = this.lineBuffer.slice(0, newlineIndex).trim();
this.lineBuffer = this.lineBuffer.slice(newlineIndex + 1);
if (!line) {
continue;
}
this.handleResponse(line);
}
}
private handleResponse(payload: string): void {
const response = JSON.parse(payload) as JsonRpcResponse;
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 as { tools?: { name: string }[] }).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 as { content?: { text?: string }[]; isError?: boolean };
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 as { content?: { text?: string }[]; isError?: boolean };
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();
}
}
);