diff --git a/CHANGELOG.md b/CHANGELOG.md index f964cf3..9b289dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,3 +5,4 @@ This project follows Semantic Versioning. Release notes describe protocol suppor ## Unreleased - Initial TypeScript MCP server template. +- HTTP: a request with a malformed `Host` header or request target no longer crashes the process. diff --git a/src/http.ts b/src/http.ts index cd72312..cd34767 100644 --- a/src/http.ts +++ b/src/http.ts @@ -14,11 +14,27 @@ export async function startHttp(config: AppConfig, logger: Logger): Promise { - void handleRequest(request, response); + handleRequest(request, response).catch((error: unknown) => { + logger.error({ error }, "HTTP request failed"); + if (response.headersSent) { + response.end(); + return; + } + response.writeHead(500, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "internal_error" })); + }); }); async function handleRequest(request: IncomingMessage, response: ServerResponse): Promise { - const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`); + // Only the path is used for routing, so the client-supplied Host header stays out of URL parsing. + let url: URL; + try { + url = new URL(request.url ?? "/", "http://localhost"); + } catch { + response.writeHead(400, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "invalid_request_target" })); + return; + } if (request.method === "GET" && url.pathname === "/healthz") { response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify({ status: "ok" })); diff --git a/tests/http-errors.test.ts b/tests/http-errors.test.ts new file mode 100644 index 0000000..b004c16 --- /dev/null +++ b/tests/http-errors.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import pino from "pino"; +import { startHttp, type HttpRuntime } from "../src/http.js"; +import { loadConfig } from "../src/config.js"; + +vi.mock("@modelcontextprotocol/node", () => ({ + toNodeHandler: () => () => Promise.reject(new Error("handler failed")), +})); + +describe("HTTP runtime failures", () => { + let runtime: HttpRuntime | undefined; + afterEach(async () => runtime?.close()); + + it("answers 500 and keeps serving when a request handler throws", async () => { + const config = loadConfig({ + MCP_TRANSPORT: "http", + MCP_PORT: "33435", + MCP_LOG_LEVEL: "silent", + }); + runtime = await startHttp(config, pino({ level: "silent" })); + const failed = await fetch("http://127.0.0.1:33435/mcp", { method: "POST", body: "{}" }); + expect(failed.status).toBe(500); + await expect(failed.json()).resolves.toEqual({ error: "internal_error" }); + const health = await fetch("http://127.0.0.1:33435/healthz"); + expect(health.status).toBe(200); + }); +}); diff --git a/tests/http.test.ts b/tests/http.test.ts index add7ac8..5416408 100644 --- a/tests/http.test.ts +++ b/tests/http.test.ts @@ -1,3 +1,4 @@ +import { connect } from "node:net"; import { afterEach, describe, expect, it } from "vitest"; import pino from "pino"; import { startHttp, type HttpRuntime } from "../src/http.js"; @@ -32,4 +33,41 @@ describe("HTTP runtime", () => { expect(response.status).toBe(404); await expect(response.json()).resolves.toEqual({ error: "not_found" }); }); + + it("keeps serving after a request with a malformed Host header", async () => { + const config = loadConfig({ + MCP_TRANSPORT: "http", + MCP_PORT: "33433", + MCP_LOG_LEVEL: "silent", + }); + runtime = await startHttp(config, pino({ level: "silent" })); + const status = await rawRequest(33433, "GET /healthz HTTP/1.1\r\nHost: a b\r\n\r\n"); + expect(status).toBe("HTTP/1.1 200 OK"); + const health = await fetch("http://127.0.0.1:33433/healthz"); + expect(health.status).toBe(200); + }); + + it("rejects an unparseable request target", async () => { + const config = loadConfig({ + MCP_TRANSPORT: "http", + MCP_PORT: "33434", + MCP_LOG_LEVEL: "silent", + }); + runtime = await startHttp(config, pino({ level: "silent" })); + const status = await rawRequest(33434, "GET http://[ HTTP/1.1\r\nHost: localhost\r\n\r\n"); + expect(status).toBe("HTTP/1.1 400 Bad Request"); + const health = await fetch("http://127.0.0.1:33434/healthz"); + expect(health.status).toBe(200); + }); }); + +function rawRequest(port: number, payload: string): Promise { + return new Promise((resolve, reject) => { + const socket = connect(port, "127.0.0.1", () => socket.write(payload)); + socket.once("data", (chunk) => { + resolve(chunk.toString("latin1").split("\r\n")[0] ?? ""); + socket.destroy(); + }); + socket.once("error", reject); + }); +}