Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
2e84333
add EXIT_CODE_ERROR and EXIT_CODE_KEYBOARD_INTERRUPT so the entrypoin…
kartikloops Jun 25, 2026
0672acb
add CleanupManager with SIGTERM/SIGINT handlers and an unref'd force-…
kartikloops Jun 25, 2026
b17e371
add tests for CleanupManager covering handler dispatch, triggerShutdo…
kartikloops Jun 25, 2026
b084ae8
add commander-based CLI parsing that rejects --host and --port unless…
kartikloops Jun 25, 2026
bf20dfb
add tests for CLI parsing covering defaults, http overrides, and the …
kartikloops Jun 25, 2026
ea5e167
add createMcpServer registering all 10 tools with zod schemas and ann…
kartikloops Jun 25, 2026
fe47bf6
add runServer with stdio and stateless http transports so the server …
kartikloops Jun 25, 2026
587dd30
add a smoke test booting the stdio server in-memory so all 10 tools a…
kartikloops Jun 25, 2026
829e42b
replace the main.ts stub with the full entrypoint, dynamically import…
kartikloops Jun 25, 2026
ea9888c
create a fresh streamable-http transport and server per request so co…
kartikloops Jun 25, 2026
6a04c1f
cleanup
kartikloops Jun 25, 2026
eae2260
fixed pty failure
kartikloops Jun 26, 2026
c6ddf6c
reject cleanly when httpServer.listen fails so a bind error (port in …
kartikloops Jul 3, 2026
fd195b7
reject out-of-range and malformed --port values (must be 1-65535) so …
kartikloops Jul 3, 2026
1b77ff6
reframe the PTY spawn-failure message so a missing/non-executable bin…
kartikloops Jul 5, 2026
dc8a843
exit non-zero (EXIT_CODE_ERROR) on forced shutdown so a stalled grace…
kartikloops Jul 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions typescript/__tests__/config/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { parseCliArgs } from "../../src/config/cli.js";
import { ValidationError } from "../../src/exceptions.js";

describe("parseCliArgs", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("returns stdio defaults with no args", () => {
expect(parseCliArgs([])).toEqual({
transport: { mode: "stdio", host: "localhost", port: 8000 },
verbose: false,
logLevel: "INFO",
});
});

it("parses http transport with custom host and port", () => {
expect(parseCliArgs(["-t", "http", "--host", "0.0.0.0", "--port", "8080"])).toEqual({
transport: { mode: "http", host: "0.0.0.0", port: 8080 },
verbose: false,
logLevel: "INFO",
});
});

it("parses verbose and log level", () => {
const config = parseCliArgs(["--verbose", "--log-level", "DEBUG"]);
expect(config.verbose).toBe(true);
expect(config.logLevel).toBe("DEBUG");
});

it("rejects --host without http transport", () => {
expect(() => parseCliArgs(["--host", "0.0.0.0"])).toThrow(ValidationError);
expect(() => parseCliArgs(["--host", "0.0.0.0"])).toThrow(
"--host and --port options are only valid with --transport http",
);
});

it("rejects --port without http transport", () => {
expect(() => parseCliArgs(["--port", "9000"])).toThrow(
"--host and --port options are only valid with --transport http",
);
});

it("rejects an invalid transport choice", () => {
// commander prints the error to stderr before throwing; silence it.
vi.spyOn(process.stderr, "write").mockReturnValue(true);
expect(() => parseCliArgs(["--transport", "bogus"])).toThrow(ValidationError);
});

it("rejects an invalid log level choice", () => {
vi.spyOn(process.stderr, "write").mockReturnValue(true);
expect(() => parseCliArgs(["--log-level", "TRACE"])).toThrow(ValidationError);
});

it("rejects a non-numeric port", () => {
vi.spyOn(process.stderr, "write").mockReturnValue(true);
expect(() => parseCliArgs(["-t", "http", "--port", "abc"])).toThrow(ValidationError);
});

it("rejects out-of-range and malformed ports", () => {
vi.spyOn(process.stderr, "write").mockReturnValue(true);
for (const bad of ["-1", "0", "65536", "99999", "80abc", "1.5"]) {
expect(() => parseCliArgs(["-t", "http", "--port", bad])).toThrow(ValidationError);
}
});

it("accepts boundary ports 1 and 65535", () => {
expect(parseCliArgs(["-t", "http", "--port", "1"]).transport.port).toBe(1);
expect(parseCliArgs(["-t", "http", "--port", "65535"]).transport.port).toBe(65535);
});
});
12 changes: 12 additions & 0 deletions typescript/__tests__/interactive/pty_handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,18 @@ describe("PtyHandler", () => {
await expect(handler.createSession(["echo"])).rejects.toThrow("Failed to create PTY session");
});

it("includes PATH hint when spawn fails with posix_spawnp/ENOENT", async () => {
vi.mocked(spawn).mockImplementation(() => {
throw new Error("posix_spawnp failed");
});

await expect(handler.createSession(["openroad"])).rejects.toThrow(
"could not be started",
);
await expect(handler.createSession(["openroad"])).rejects.toThrow("PATH=");
await expect(handler.createSession(["openroad"])).rejects.toThrow("spawn-helper");
});

it("registers onExit and marks process dead when it fires", async () => {
await handler.createSession(["echo"]);
expect(handler.isProcessAlive()).toBe(true);
Expand Down
82 changes: 82 additions & 0 deletions typescript/__tests__/server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, it, expect, vi } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createMcpServer } from "../src/server.js";
import type { OpenROADManager } from "../src/core/manager.js";

// node-pty must never spawn during a boot/list-tools smoke test.
vi.mock("node-pty", () => ({ spawn: vi.fn() }));

const EXPECTED_TOOLS = [
"interactive_openroad_query",
"interactive_openroad_exec",
"list_interactive_sessions",
"create_interactive_session",
"terminate_interactive_session",
"inspect_interactive_session",
"get_session_history",
"get_session_metrics",
"list_report_images",
"read_report_image",
].sort();

/** Minimal manager stub: listing tools needs no calls; one round-trip uses listSessions. */
function makeMockManager(): OpenROADManager {
return {
listSessions: vi.fn().mockResolvedValue([]),
} as unknown as OpenROADManager;
}

async function connectClient(manager: OpenROADManager): Promise<Client> {
const server = createMcpServer(manager);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
const client = new Client({ name: "test-client", version: "0.0.0" });
await client.connect(clientTransport);
return client;
}

describe("createMcpServer over MCP", () => {
it("enumerates exactly the 10 expected tools", async () => {
const client = await connectClient(makeMockManager());
const { tools } = await client.listTools();

expect(tools.map((t) => t.name).sort()).toEqual(EXPECTED_TOOLS);
await client.close();
});

it("carries the correct behaviour annotations", async () => {
const client = await connectClient(makeMockManager());
const { tools } = await client.listTools();
const byName = new Map(tools.map((t) => [t.name, t]));

expect(byName.get("interactive_openroad_query")?.annotations).toMatchObject({
readOnlyHint: true,
destructiveHint: false,
});
expect(byName.get("interactive_openroad_exec")?.annotations).toMatchObject({
readOnlyHint: false,
destructiveHint: true,
});
expect(byName.get("list_interactive_sessions")?.annotations).toMatchObject({
readOnlyHint: true,
idempotentHint: true,
});
await client.close();
});

it("round-trips a tool call returning a JSON string in text content", async () => {
const manager = makeMockManager();
const client = await connectClient(manager);

const result = await client.callTool({ name: "list_interactive_sessions" });
const content = result.content as Array<{ type: string; text: string }>;

expect(content[0]?.type).toBe("text");
const parsed = JSON.parse(content[0]!.text) as { sessions: unknown[]; total_count: number };
expect(parsed.sessions).toEqual([]);
expect(parsed.total_count).toBe(0);
expect(manager.listSessions).toHaveBeenCalledOnce();
await client.close();
});
});
98 changes: 98 additions & 0 deletions typescript/__tests__/utils/cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { CleanupManager } from "../../src/utils/cleanup.js";
import { EXIT_CODE_ERROR, FORCE_EXIT_DELAY_SECONDS } from "../../src/constants.js";

describe("CleanupManager", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});

it("runs registered handlers on runHandlers", async () => {
const mgr = new CleanupManager();
const ran: string[] = [];
mgr.registerAsyncCleanupHandler(() => {
ran.push("a");
});
mgr.registerAsyncCleanupHandler(async () => {
await Promise.resolve();
ran.push("b");
});

await mgr.runHandlers();

expect(ran).toEqual(["a", "b"]);
});

it("isolates a throwing handler so later handlers still run", async () => {
const mgr = new CleanupManager();
const ran: string[] = [];
mgr.registerAsyncCleanupHandler(() => {
throw new Error("boom");
});
mgr.registerAsyncCleanupHandler(() => {
ran.push("after");
});

await expect(mgr.runHandlers()).resolves.toBeUndefined();
expect(ran).toEqual(["after"]);
});

it("triggerShutdown resolves waitForShutdown", async () => {
const mgr = new CleanupManager();
let resolved = false;
const wait = mgr.waitForShutdown().then(() => {
resolved = true;
});

mgr.triggerShutdown();
await wait;

expect(resolved).toBe(true);
});

it("triggerShutdown is idempotent", async () => {
const mgr = new CleanupManager();
mgr.triggerShutdown();
mgr.triggerShutdown(); // second call must be a no-op, not throw
await expect(mgr.waitForShutdown()).resolves.toBeUndefined();
});

it("a signal triggers shutdown and arms the force-exit timer", async () => {
vi.useFakeTimers();
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation((() => undefined) as never);

const sigtermBefore = process.listeners("SIGTERM");
const sigintBefore = process.listeners("SIGINT");

try {
const mgr = new CleanupManager();
mgr.setupSignalHandlers();

let resolved = false;
const wait = mgr.waitForShutdown().then(() => {
resolved = true;
});

process.emit("SIGTERM", "SIGTERM");
await wait;
expect(resolved).toBe(true);

// Force-exit fires only after the configured delay, and exits non-zero
// because a forced exit means graceful shutdown did not complete.
expect(exitSpy).not.toHaveBeenCalled();
vi.advanceTimersByTime(FORCE_EXIT_DELAY_SECONDS * 1000);
expect(exitSpy).toHaveBeenCalledWith(EXIT_CODE_ERROR);
} finally {
// Remove only the listeners this test added, leaving the runner's intact.
for (const l of process.listeners("SIGTERM")) {
if (!sigtermBefore.includes(l)) process.removeListener("SIGTERM", l);
}
for (const l of process.listeners("SIGINT")) {
if (!sigintBefore.includes(l)) process.removeListener("SIGINT", l);
}
}
});
});
1 change: 1 addition & 0 deletions typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"dist/"
],
"scripts": {
"postinstall": "node scripts/fix-node-pty.cjs",
"build": "tsc --project tsconfig.json",
"typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
"lint": "eslint .",
Expand Down
27 changes: 27 additions & 0 deletions typescript/scripts/fix-node-pty.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* node-pty ships platform prebuilds with a spawn-helper binary that must be
* executable. Some npm installs (sandboxed CI, strict umask, package managers)
* leave it mode 0644, which makes posix_spawnp fail for every PTY session.
*/
const fs = require("node:fs");
const path = require("node:path");

const prebuildsDir = path.join(__dirname, "..", "node_modules", "node-pty", "prebuilds");

if (!fs.existsSync(prebuildsDir)) {
process.exit(0);
}

for (const entry of fs.readdirSync(prebuildsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const helper = path.join(prebuildsDir, entry.name, "spawn-helper");
if (!fs.existsSync(helper)) continue;
try {
const mode = fs.statSync(helper).mode & 0o777;
if ((mode & 0o111) === 0) {
fs.chmodSync(helper, mode | 0o755);
}
} catch {
// Best effort; PTY spawn will surface a clearer error at runtime.
}
}
Loading
Loading