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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ MCP_PORT=3333
MCP_PATH=/mcp
MCP_LOG_LEVEL=info
MCP_HTTP_JSON_RESPONSE=false
MCP_ALLOWED_HOSTS=localhost,127.0.0.1,[::1]
MCP_ALLOWED_ORIGINS=localhost,127.0.0.1,[::1]
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ This project follows Semantic Versioning. Release notes describe protocol suppor

- Initial TypeScript MCP server template.
- HTTP: a request with a malformed `Host` header or request target no longer crashes the process.
- HTTP: the MCP endpoint validates `Host` and `Origin` headers against `MCP_ALLOWED_HOSTS` and `MCP_ALLOWED_ORIGINS` (localhost by default) to block DNS rebinding.
20 changes: 12 additions & 8 deletions docs/configuration.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
# Configuration

| Variable | Default | Description |
| ------------------------ | ----------- | ----------------------------------- |
| `MCP_TRANSPORT` | `stdio` | `stdio` or `http` |
| `MCP_HOST` | `127.0.0.1` | HTTP bind address |
| `MCP_PORT` | `3333` | HTTP port |
| `MCP_PATH` | `/mcp` | Streamable HTTP endpoint |
| `MCP_LOG_LEVEL` | `info` | Pino log level |
| `MCP_HTTP_JSON_RESPONSE` | `false` | Return terminal JSON instead of SSE |
| Variable | Default | Description |
| ------------------------ | --------------------------- | ------------------------------------------------- |
| `MCP_TRANSPORT` | `stdio` | `stdio` or `http` |
| `MCP_HOST` | `127.0.0.1` | HTTP bind address |
| `MCP_PORT` | `3333` | HTTP port |
| `MCP_PATH` | `/mcp` | Streamable HTTP endpoint |
| `MCP_LOG_LEVEL` | `info` | Pino log level |
| `MCP_HTTP_JSON_RESPONSE` | `false` | Return terminal JSON instead of SSE |
| `MCP_ALLOWED_HOSTS` | `localhost,127.0.0.1,[::1]` | Comma-separated `Host` names the endpoint accepts |
| `MCP_ALLOWED_ORIGINS` | `localhost,127.0.0.1,[::1]` | Comma-separated browser `Origin` hostnames |

Command-line values override environment values for transport, host, and port.

The MCP endpoint answers `403` when the `Host` header or a browser's `Origin` header names a hostname outside these lists. This blocks DNS rebinding attacks against servers on a developer's machine. Entries are hostnames without scheme or port; write IPv6 addresses in brackets. Requests without an `Origin` header, which is how non-browser MCP clients connect, are not affected by the origin list. When the server is reached through a public name, add that name to `MCP_ALLOWED_HOSTS`, and to `MCP_ALLOWED_ORIGINS` if browsers call it. Health and readiness routes are not restricted.
2 changes: 1 addition & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

The included container runs the HTTP transport as an unprivileged user. It exposes port 3333 and reports health through `/healthz`.

Public deployments need an authentication and authorization policy suited to their tools. Put TLS at the edge, limit accepted origins and hosts, rate-limit expensive handlers, and keep the default loopback binding during local development.
Public deployments need an authentication and authorization policy suited to their tools. Put TLS at the edge, set `MCP_ALLOWED_HOSTS` and `MCP_ALLOWED_ORIGINS` to the names clients use (see [Configuration](configuration.md)), rate-limit expensive handlers, and keep the default loopback binding during local development.

Build and run:

Expand Down
13 changes: 13 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { z } from "zod";

const booleanFromString = z.preprocess((value) => value === true || value === "true", z.boolean());
const hostnameList = z.string().transform((value) =>
value
.split(",")
.map((item) => item.trim())
.filter(Boolean),
);
const localhostNames = ["localhost", "127.0.0.1", "[::1]"];

const environmentSchema = z.object({
MCP_TRANSPORT: z.enum(["stdio", "http"]).default("stdio"),
Expand All @@ -11,6 +18,8 @@ const environmentSchema = z.object({
.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"])
.default("info"),
MCP_HTTP_JSON_RESPONSE: booleanFromString.default(false),
MCP_ALLOWED_HOSTS: hostnameList.default(() => [...localhostNames]),
MCP_ALLOWED_ORIGINS: hostnameList.default(() => [...localhostNames]),
});

export type AppConfig = {
Expand All @@ -20,6 +29,8 @@ export type AppConfig = {
path: string;
logLevel: z.infer<typeof environmentSchema>["MCP_LOG_LEVEL"];
httpJsonResponse: boolean;
allowedHosts: string[];
allowedOrigins: string[];
};

export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppConfig {
Expand All @@ -35,5 +46,7 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon
path: parsed.data.MCP_PATH,
logLevel: parsed.data.MCP_LOG_LEVEL,
httpJsonResponse: parsed.data.MCP_HTTP_JSON_RESPONSE,
allowedHosts: parsed.data.MCP_ALLOWED_HOSTS,
allowedOrigins: parsed.data.MCP_ALLOWED_ORIGINS,
};
}
19 changes: 18 additions & 1 deletion src/http.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { createMcpHandler } from "@modelcontextprotocol/server";
import {
createMcpHandler,
validateHostHeader,
validateOriginHeader,
} from "@modelcontextprotocol/server";
import { toNodeHandler, type NodeIncomingMessageLike } from "@modelcontextprotocol/node";
import type { Logger } from "pino";
import type { AppConfig } from "./config.js";
Expand Down Expand Up @@ -55,6 +59,19 @@ export async function startHttp(config: AppConfig, logger: Logger): Promise<Http
response.end(JSON.stringify({ error: "missing_method" }));
return;
}
// DNS rebinding protection: a browser can reach a loopback server through an attacker's
// domain, so only listed Host names and browser Origins may use the MCP endpoint.
const host = validateHostHeader(request.headers.host, config.allowedHosts);
const origin = validateOriginHeader(request.headers.origin, config.allowedOrigins);
const rejection = !host.ok ? host.message : !origin.ok ? origin.message : undefined;
if (rejection) {
logger.warn({ host: request.headers.host, origin: request.headers.origin }, rejection);
response.writeHead(403, { "content-type": "application/json" });
response.end(
JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: rejection }, id: null }),
);
return;
}
await nodeHandler(request as unknown as NodeIncomingMessageLike, response);
}

Expand Down
8 changes: 8 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,17 @@ describe("loadConfig", () => {
path: "/mcp",
logLevel: "info",
httpJsonResponse: false,
allowedHosts: ["localhost", "127.0.0.1", "[::1]"],
allowedOrigins: ["localhost", "127.0.0.1", "[::1]"],
});
});

it("parses comma-separated host and origin allowlists", () => {
expect(
loadConfig({ MCP_ALLOWED_HOSTS: "mcp.example.com, localhost", MCP_ALLOWED_ORIGINS: "" }),
).toMatchObject({ allowedHosts: ["mcp.example.com", "localhost"], allowedOrigins: [] });
});

it("accepts HTTP configuration", () => {
expect(
loadConfig({ MCP_TRANSPORT: "http", MCP_PORT: "4400", MCP_HTTP_JSON_RESPONSE: "true" }),
Expand Down
72 changes: 72 additions & 0 deletions tests/http.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { request as httpRequest } from "node:http";
import { connect } from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import pino from "pino";
Expand Down Expand Up @@ -59,6 +60,43 @@ describe("HTTP runtime", () => {
const health = await fetch("http://127.0.0.1:33434/healthz");
expect(health.status).toBe(200);
});

it("rejects MCP requests from hosts and origins outside the allowlist", async () => {
const config = loadConfig({
MCP_TRANSPORT: "http",
MCP_PORT: "33436",
MCP_LOG_LEVEL: "silent",
});
runtime = await startHttp(config, pino({ level: "silent" }));
const rebound = await postMcp(33436, { host: "attacker.example:33436" });
const crossOrigin = await postMcp(33436, {
host: "127.0.0.1:33436",
origin: "https://attacker.example",
});
const local = await postMcp(33436, {
host: "localhost:33436",
origin: "http://localhost:5173",
});
expect(rebound.status).toBe(403);
expect(rebound.body).toContain("Invalid Host: attacker.example");
expect(crossOrigin.status).toBe(403);
expect(crossOrigin.body).toContain("Invalid Origin: attacker.example");
expect(local.status).not.toBe(403);
});

it("accepts hosts added to the allowlist", async () => {
const config = loadConfig({
MCP_TRANSPORT: "http",
MCP_PORT: "33437",
MCP_LOG_LEVEL: "silent",
MCP_ALLOWED_HOSTS: "mcp.example.com",
});
runtime = await startHttp(config, pino({ level: "silent" }));
const allowed = await postMcp(33437, { host: "mcp.example.com" });
const loopback = await postMcp(33437, { host: "127.0.0.1:33437" });
expect(allowed.status).not.toBe(403);
expect(loopback.status).toBe(403);
});
});

function rawRequest(port: number, payload: string): Promise<string> {
Expand All @@ -71,3 +109,37 @@ function rawRequest(port: number, payload: string): Promise<string> {
socket.once("error", reject);
});
}

function postMcp(
port: number,
headers: Record<string, string>,
): Promise<{ status: number; body: string }> {
const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping" });
return new Promise((resolve, reject) => {
const request = httpRequest(
{
host: "127.0.0.1",
port,
path: "/mcp",
method: "POST",
headers: {
...headers,
accept: "application/json, text/event-stream",
"content-type": "application/json",
},
},
(response) => {
let text = "";
response.setEncoding("utf8");
response.on("data", (chunk: string) => {
text += chunk;
});
response.on("end", () => {
resolve({ status: response.statusCode ?? 0, body: text });
});
},
);
request.once("error", reject);
request.end(body);
});
}
Loading