Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
20 changes: 18 additions & 2 deletions src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,27 @@ export async function startHttp(config: AppConfig, logger: Logger): Promise<Http
const nodeHandler = toNodeHandler(mcpHandler);

const server = createServer((request, response) => {
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<void> {
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" }));
Expand Down
27 changes: 27 additions & 0 deletions tests/http-errors.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
38 changes: 38 additions & 0 deletions tests/http.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string> {
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);
});
}
Loading