Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

import { setupJsonConsole } from "./utils/console.js";
import { installStdoutWriteGuard } from "./utils/safe-transport.js";

import { CreateUiTool } from "./tools/create-ui.js";
import { FetchUiTool } from "./tools/fetch-ui.js";
Expand Down Expand Up @@ -69,6 +70,7 @@ async function runServer() {
cleanup();
});

installStdoutWriteGuard();
await server.connect(transport);
console.log(`Server started (PID: ${process.pid})`);
}
Expand Down
109 changes: 109 additions & 0 deletions src/utils/safe-transport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {
isValidId,
looksLikeJsonRpcResponse,
sanitiseJsonRpcId,
} from "./safe-transport.js";

describe("isValidId", () => {
it("accepts string ids", () => {
expect(isValidId("abc")).toBe(true);
expect(isValidId("")).toBe(true);
});

it("accepts number ids", () => {
expect(isValidId(0)).toBe(true);
expect(isValidId(42)).toBe(true);
expect(isValidId(1.5)).toBe(true);
expect(isValidId(-1)).toBe(true);
});

it("accepts null id", () => {
expect(isValidId(null)).toBe(true);
});

it("rejects undefined id", () => {
expect(isValidId(undefined)).toBe(false);
});

it("rejects object id", () => {
expect(isValidId({ bad: "id" })).toBe(false);
});

it("rejects array id", () => {
expect(isValidId([1, 2])).toBe(false);
});

it("rejects boolean id", () => {
expect(isValidId(true)).toBe(false);
expect(isValidId(false)).toBe(false);
});
});

describe("looksLikeJsonRpcResponse", () => {
it("returns true for a jsonrpc 2.0 response", () => {
expect(looksLikeJsonRpcResponse('{"jsonrpc":"2.0","id":1,"result":{}}')).toBe(true);
});

it("returns true for jsonrpc response with spaces", () => {
expect(looksLikeJsonRpcResponse('{"jsonrpc": "2.0", "id": 1, "error": {}}')).toBe(true);
});

it("returns false for a request object", () => {
expect(looksLikeJsonRpcResponse('{"jsonrpc":"2.0","id":1,"method":"ping"}')).toBe(false);
});

it("returns false for non-JSON text", () => {
expect(looksLikeJsonRpcResponse("not json at all")).toBe(false);
});

it("returns false for empty string", () => {
expect(looksLikeJsonRpcResponse("")).toBe(false);
});
});

describe("sanitiseJsonRpcId", () => {
it("leaves valid responses unchanged", () => {
const input = '{"jsonrpc":"2.0","id":"abc","result":{}}';
expect(sanitiseJsonRpcId(input)).toBe(input);
});

it("leaves valid number-id responses unchanged", () => {
const input = '{"jsonrpc":"2.0","id":42,"result":{}}';
expect(sanitiseJsonRpcId(input)).toBe(input);
});

it("leaves valid null-id responses unchanged", () => {
const input = '{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid Request"}}';
expect(sanitiseJsonRpcId(input)).toBe(input);
});

it("replaces object id with null in error response", () => {
// The crash reproducer: id is an object.
const input = '{"jsonrpc":"2.0","id":{"bad":"id"},"error":{"code":-32600,"message":"Invalid Request"}}';
const output = JSON.parse(sanitiseJsonRpcId(input));
expect(output.id).toBe(null);
expect(output.jsonrpc).toBe("2.0");
expect(output.error.code).toBe(-32600);
});

it("replaces array id with null", () => {
const input = '{"jsonrpc":"2.0","id":[1,2],"result":{}}';
const output = JSON.parse(sanitiseJsonRpcId(input));
expect(output.id).toBe(null);
});

it("leaves request objects unchanged (no id to corrupt)", () => {
const input = '{"jsonrpc":"2.0","method":"ping","params":{}}';
expect(sanitiseJsonRpcId(input)).toBe(input);
});

it("leaves batch responses unchanged", () => {
const input = '[{"jsonrpc":"2.0","id":1,"result":{}}]';
expect(sanitiseJsonRpcId(input)).toBe(input);
});

it("leaves non-JSON text unchanged", () => {
const input = "some log output\n";
expect(sanitiseJsonRpcId(input)).toBe(input);
});
});
115 changes: 115 additions & 0 deletions src/utils/safe-transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* Safe stdout writer that guards against JSON-RPC id serialisation bugs.
*
* Background: the MCP SDK's Session can emit error responses where `id` is
* undefined (a notification that errored) or a non-serialisable object.
* When JSON.stringify is called on such a response it either drops the `id`
* field (undefined → omitted) or throws (non-serialisable object id).
* Both corrupt the JSON-RPC protocol and can crash the server because the
* transport's write() throws and the resulting unhandled error propagates to
* the top-level which calls cleanup() → process.exit(0).
*
* This module patches process.stdout.write before the MCP server starts so
* that every JSON string is validated and the `id` field is normalised to
* null when it would otherwise be invalid.
*/

/**
* Returns true when a JSON-RPC `id` is valid per the spec:
* string, number, or null. The JSON-RPC 2.0 spec requires responses to
* include an id that matches the request; invalid ids must be replaced with
* null so the response is always valid JSON with a present id field.
*/
export function isValidId(id: unknown): id is string | number | null {
return id === null || typeof id === "string" || typeof id === "number";
}

/**
* Check whether a string looks like a JSON-RPC 2.0 response object
* (has jsonrpc field but no method field; requests/responses are distinguished
* by the presence of "method").
*/
export function looksLikeJsonRpcResponse(text: string): boolean {
// A response has jsonrpc but no "method" field.
// Requests/notifications have "method"; responses have "result" or "error".
const hasJsonrpc = text.includes("\"jsonrpc\"");
const hasMethod = text.includes("\"method\"");
return hasJsonrpc && !hasMethod;
}

/**
* Parse and fix a JSON-RPC response string, replacing any invalid `id`
* with null. Returns the original string unchanged when it is not a
* JSON-RPC response (e.g. batch, non-JSON, or a request/notification).
*/
export function sanitiseJsonRpcId(text: string): string {
let value: unknown;
try {
value = JSON.parse(text);
} catch {
// Not JSON - leave it untouched.
return text;
}

// Only process single response objects.
if (
typeof value !== "object" ||
value === null ||
Array.isArray(value)
) {
return text;
}

const msg = value as Record<string, unknown>;

// Must be a response (has jsonrpc field and an id).
if (
!("jsonrpc" in msg) ||
!Object.prototype.hasOwnProperty.call(msg, "id")
) {
return text;
}

if (!isValidId(msg.id)) {
// Replace the invalid id with null so the response is always valid.
const fixed = { ...msg, id: null };
return JSON.stringify(fixed);
}

return text;
}

/**
* Install the stdout write guard.
* MUST be called before the MCP server connects the transport.
*/
export function installStdoutWriteGuard(): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const originalWrite = process.stdout.write.bind(process.stdout) as any;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
(process.stdout as any).write = (
chunk: unknown,
encoding?: BufferEncoding | ((err?: Error | null) => void),
callback?: (err?: Error | null) => void,
): boolean => {
let chunkStr: string;
let writeEncoding: BufferEncoding | undefined;
let writeCallback: ((err?: Error | null) => void) | undefined;

if (typeof encoding === "function") {
chunkStr = String(chunk);
writeCallback = encoding;
} else {
chunkStr = typeof chunk === "string" ? chunk : String(chunk);
writeEncoding = encoding;
writeCallback = callback;
}

const sanitised = looksLikeJsonRpcResponse(chunkStr)
? sanitiseJsonRpcId(chunkStr)
: chunkStr;

return originalWrite(sanitised, writeEncoding, writeCallback);
};
}