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
5 changes: 4 additions & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,10 @@ stream eagerly and so cannot tell the two apart.
builder and embarc want the same server without the CLI around it: `createTaskMcpServer`
(the tool surface, over any transport), `startMcpHttpServer` (`node:http`),
`McpSessionRouter` (the Streamable HTTP session map, for a host that already has a web
framework) and `authorizeBearer`. It is built on the SDK's low-level `Server` rather than
framework) and `authorizeBearer`. The token property above is the CLI's own only in how it
generates one: `startMcpHttpServer` requires `token`, and a host serving unauthenticated
has to write `null` — refused outright on a wildcard bind, where nothing else decides who
may run a task. It is built on the SDK's low-level `Server` rather than
`McpServer` because tasks describe themselves in JSON Schema and `registerTool` takes only
Zod — going through it would mean converting a schema to Zod and back to publish it.

Expand Down
2 changes: 1 addition & 1 deletion examples/cli/src/commands/mcpServe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,6 @@ describe("resolveServeToken", () => {
});

it("serves without a token only when --no-auth said so", () => {
expect(resolveServeToken({ auth: false }, { [MCP_TOKEN_ENV]: "from-env" })).toBeUndefined();
expect(resolveServeToken({ auth: false }, { [MCP_TOKEN_ENV]: "from-env" })).toBeNull();
});
});
8 changes: 4 additions & 4 deletions examples/cli/src/commands/mcpServe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,11 @@ interface McpServeOptions {
}

/**
* The bearer token this server will require, or `undefined` for none.
* The bearer token this server will require, or `null` for none.
*
* A pinned token wins over a generated one because a client config has to hold
* the same value across restarts, and the environment wins over nothing at all
* — but only `--no-auth` reaches `undefined`. Falling through to an
* — but only `--no-auth` reaches `null`. Falling through to an
* unauthenticated server because no token was supplied is exactly the accident
* this generates one to prevent — so an empty `--token` or an empty variable
* falls through to a generated token rather than to the empty string, which is
Expand All @@ -62,8 +62,8 @@ interface McpServeOptions {
export function resolveServeToken(
opts: { readonly auth: boolean; readonly token?: string },
env: Readonly<Record<string, string | undefined>> = process.env
): string | undefined {
if (!opts.auth) return undefined;
): string | null {
if (!opts.auth) return null;
return opts.token || env[MCP_TOKEN_ENV] || generateBearerToken();
}

Expand Down
3 changes: 2 additions & 1 deletion packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ import { createTaskMcpServer, generateBearerToken, startMcpHttpServer } from "@w
const handle = await startMcpHttpServer({
port: 8788,
host: "127.0.0.1",
// `undefined` serves unauthenticated, which is a decision, never a default.
// Required. `null` serves unauthenticated, which has to be said out loud —
// and is refused outright on a wildcard bind.
token: generateBearerToken(),
createServer: () => createTaskMcpServer({ name: "my-app", version: "1.0.0" }),
});
Expand Down
46 changes: 40 additions & 6 deletions packages/mcp/src/server/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,16 @@ export interface StartMcpHttpServerArgs {
/** Path the MCP endpoint answers on. Defaults to {@link DEFAULT_MCP_PATH}. */
readonly path?: string;
/**
* Bearer token every request must present.
* Bearer token every request must present, or `null` to serve without
* authentication.
*
* `undefined` serves without authentication — a deliberate choice for a host
* that has its own gate in front, never a default worth falling into.
* Required, and `null` rather than an omission, because every tool this
* server offers executes a task. A host that never states the choice would
* otherwise publish task execution to whatever reaches the port, and nothing
* in the request path can tell that apart from a host that meant to.
* `null` is refused for a wildcard bind — see {@link startMcpHttpServer}.
*/
readonly token?: string | undefined;
readonly token: string | null;
/** One MCP server instance per client session. */
readonly createServer: () => McpServerInstance;
/**
Expand Down Expand Up @@ -75,6 +79,34 @@ function isWildcardHost(host: string): boolean {
return host === "" || host === "0.0.0.0" || host === "::" || host === "[::]";
}

/**
* Refuses a server that would run tasks for whoever reaches the port.
*
* `undefined` cannot be spelled through the types and is checked anyway: an
* untyped caller still arrives here with the field omitted, and that used to
* mean "serve unauthenticated".
*
* A wildcard bind additionally refuses `null` outright. It is reachable under
* every name and address the machine answers to, and {@link resolveAllowedHosts}
* can derive no `Host` allow-list from it either, so nothing at all would be
* left deciding who gets in. Naming the interface to bind is how an
* unauthenticated server on a reachable address is asked for.
*/
function assertAuthChoice(token: string | null | undefined, host: string): void {
if (token === undefined) {
throw new Error(
"startMcpHttpServer requires `token`: a bearer token every request must present, " +
"or `null` to serve task execution without authentication."
);
}
if (token === null && isWildcardHost(host.toLowerCase())) {
throw new Error(
`startMcpHttpServer refuses to serve unauthenticated on the wildcard bind "${host}": ` +
"every tool it offers runs a task. Pass a token, or bind the one interface it should answer on."
);
}
}

/**
* The `Host` values to answer to, or `undefined` for "do not check".
*
Expand Down Expand Up @@ -260,14 +292,16 @@ async function serve(
export async function startMcpHttpServer(
args: StartMcpHttpServerArgs
): Promise<McpHttpServerHandle> {
assertAuthChoice(args.token, args.host);
const token = args.token ?? undefined;
const path = args.path ?? DEFAULT_MCP_PATH;
const router = new McpSessionRouter({
createTransport: (hooks) => new StreamableHTTPServerTransport(hooks),
createServer: args.createServer,
});
const ctx: RequestContext = {
path,
token: args.token,
token,
allowedHosts: resolveAllowedHosts(args.host, args.allowedHosts),
maxBodyBytes: args.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES,
router,
Expand Down Expand Up @@ -302,7 +336,7 @@ export async function startMcpHttpServer(
return {
server,
url: `http://${displayHost}:${port}${path}`,
token: args.token,
token,
sessionCount: () => router.size,
close: async () => {
await router.closeAll();
Expand Down
44 changes: 36 additions & 8 deletions packages/mcp/src/server/__tests__/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,7 @@ class StepTask extends Task<Record<string, never>, { done: boolean }> {

const TASKS = [GreetTask, StepTask] as unknown as AnyTaskConstructor[];

const open = async (
token: string | undefined,
maxBodyBytes?: number
): Promise<McpHttpServerHandle> =>
const open = async (token: string | null, maxBodyBytes?: number): Promise<McpHttpServerHandle> =>
startMcpHttpServer({
port: 0,
host: "127.0.0.1",
Expand Down Expand Up @@ -333,13 +330,17 @@ describe("startMcpHttpServer, bound to a wildcard", () => {
await startMcpHttpServer({
port: 0,
host: "0.0.0.0",
token: undefined,
token: TOKEN,
createServer: () => createTaskMcpServer({ name: "test", version: "1.0.0", tasks: TASKS }),
})
);
const response = await rawPost(
`http://127.0.0.1:${new URL(handle.url).port}${new URL(handle.url).pathname}`,
{ Host: "192.168.1.10", "content-type": "application/json" },
{
Host: "192.168.1.10",
Authorization: `Bearer ${TOKEN}`,
"content-type": "application/json",
},
"{}"
);
expect(response.status).not.toBe(403);
Expand All @@ -350,7 +351,7 @@ describe("startMcpHttpServer, bound to a wildcard", () => {
await startMcpHttpServer({
port: 0,
host: "0.0.0.0",
token: undefined,
token: TOKEN,
allowedHosts: ["mcp.internal"],
createServer: () => createTaskMcpServer({ name: "test", version: "1.0.0", tasks: TASKS }),
})
Expand All @@ -366,11 +367,38 @@ describe("startMcpHttpServer, bound to a wildcard", () => {

describe("startMcpHttpServer, unauthenticated", () => {
it("serves without a token when the host turned authentication off", async () => {
const handle = track(await open(undefined));
const handle = track(await open(null));
expect(handle.token).toBeUndefined();
const client = track(await connect(handle, undefined));

const { tools } = await client.listTools();
expect(tools.map((tool) => tool.name)).toEqual(["GreetTask", "StepTask"]);
});

it("refuses to start when the caller never stated an auth choice", async () => {
// Every tool here executes a task, so an omitted `token` must not be the
// same thing as an explicit opt-out. The types say so; an untyped caller
// (this package is published, and JS consumers exist) arrives with the
// field missing and has to be turned away at run time too.
const args = {
port: 0,
host: "127.0.0.1",
createServer: () => createTaskMcpServer({ name: "test", version: "1.0.0", tasks: TASKS }),
} as unknown as Parameters<typeof startMcpHttpServer>[0];
await expect(startMcpHttpServer(args)).rejects.toThrow(/requires `token`/);
});

it("refuses an unauthenticated wildcard bind", async () => {
// A wildcard answers on every address the machine has, and
// `resolveAllowedHosts` derives no Host allow-list from it — so with no
// token there is nothing left deciding who may run tasks here.
await expect(
startMcpHttpServer({
port: 0,
host: "0.0.0.0",
token: null,
createServer: () => createTaskMcpServer({ name: "test", version: "1.0.0", tasks: TASKS }),
})
).rejects.toThrow(/unauthenticated/);
});
});
2 changes: 1 addition & 1 deletion packages/mcp/src/server/__tests__/elicitation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ const open = async (elicitation?: boolean): Promise<McpHttpServerHandle> =>
startMcpHttpServer({
port: 0,
host: "127.0.0.1",
token: undefined,
token: null,
createServer: () =>
createTaskMcpServer({ name: "test", version: "1.0.0", tasks: TASKS, elicitation }),
});
Expand Down
Loading