Skip to content
Open
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
122 changes: 121 additions & 1 deletion packages/agent-infra/mcp-http-server/src/startServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import express, { NextFunction, Request, Response } from 'express';
import { randomUUID } from 'node:crypto';
import { randomUUID, timingSafeEqual } from 'node:crypto';
import {
ErrorCode,
isInitializeRequest,
Expand Down Expand Up @@ -54,9 +54,93 @@ interface StartSseAndStreamableHttpMcpServerParams {
/** Routes configuration */
routes?: RoutesConfig;
logger?: Logger;
/**
* API key used for Bearer token authentication.
*
* When provided (or when the `MCP_API_KEY` environment variable is set),
* every incoming request must include a matching
* `Authorization: Bearer <apiKey>` header, otherwise it is rejected with
* a `401 Unauthorized` response.
*
* When omitted, the server keeps its previous unauthenticated behavior
* for backward compatibility, but a warning is logged — especially when
* binding to a non-localhost address.
*/
apiKey?: string;
createMcpServer: (req: RequestContext) => Promise<McpServer | Server>;
}

/**
* Compare two strings in constant time to avoid timing side-channels when
* validating the API key.
*/
function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a, 'utf8');
const bufB = Buffer.from(b, 'utf8');
if (bufA.length !== bufB.length) {
// Still run a comparison to keep timing roughly consistent.
timingSafeEqual(bufA, bufA);
return false;
}
return timingSafeEqual(bufA, bufB);
}

/**
* Build an Express middleware that enforces Bearer token authentication.
*
* Requests without a valid `Authorization: Bearer <apiKey>` header are
* rejected with a JSON-RPC formatted `401` response.
*/
function createApiKeyAuthMiddleware(
apiKey: string,
logger: Logger,
): MiddlewareFunction {
return (req: Request, res: Response, next: NextFunction) => {
const header = req.headers.authorization;

if (typeof header !== 'string' || !header.toLowerCase().startsWith('bearer ')) {
logger.warn(
`Rejected unauthenticated MCP request from ${req.ip} (missing Bearer token)`,
);
res
.status(401)
.set('WWW-Authenticate', 'Bearer realm="mcp-http-server"')
.json({
jsonrpc: '2.0',
error: {
code: ErrorCode.InvalidRequest,
message:
'Unauthorized: missing Authorization header. Expected "Authorization: Bearer <MCP_API_KEY>".',
},
id: null,
} as JSONRPCError);
return;
}

const token = header.slice('bearer '.length).trim();

if (!token || !safeEqual(token, apiKey)) {
logger.warn(
`Rejected MCP request from ${req.ip} with invalid API key`,
);
res
.status(401)
.set('WWW-Authenticate', 'Bearer realm="mcp-http-server", error="invalid_token"')
.json({
jsonrpc: '2.0',
error: {
code: ErrorCode.InvalidRequest,
message: 'Unauthorized: invalid API key.',
},
id: null,
} as JSONRPCError);
return;
}

next();
};
}

export async function startSseAndStreamableHttpMcpServer(
params: StartSseAndStreamableHttpMcpServerParams,
): Promise<McpServerEndpoint> {
Expand All @@ -68,8 +152,14 @@ export async function startSseAndStreamableHttpMcpServer(
middlewares,
routes = {},
logger = new ConsoleLogger(),
apiKey,
} = params;

// Resolve the API key from explicit params first, then from the
// `MCP_API_KEY` environment variable. This keeps the feature opt-in and
// backward compatible: when neither is set, the server runs unauthenticated.
const resolvedApiKey = apiKey ?? process.env.MCP_API_KEY;

// default routes config
const routesConfig = {
prefix: routes.prefix || '/',
Expand Down Expand Up @@ -112,6 +202,28 @@ export async function startSseAndStreamableHttpMcpServer(
next();
});

// Built-in API key authentication.
//
// When an API key is configured (via the `apiKey` param or the `MCP_API_KEY`
// environment variable), every request must present a matching
// `Authorization: Bearer <apiKey>` header. Requests without a valid token
// are rejected with `401 Unauthorized` before reaching any MCP endpoint.
if (resolvedApiKey) {
logger.info(
'API key authentication enabled for MCP HTTP server. Clients must send "Authorization: Bearer <MCP_API_KEY>".',
);
app.use(createApiKeyAuthMiddleware(resolvedApiKey, logger));
} else {
// Backward compatibility: do not block requests when no key is set, but
// surface a clear security warning so operators are aware of the risk.
logger.warn(
'SECURITY WARNING: MCP HTTP server is running without authentication. ' +
'Set the MCP_API_KEY environment variable (or pass `apiKey`) to require ' +
'a Bearer token on every request. This is strongly recommended when ' +
'binding to a non-localhost address (e.g. host="0.0.0.0").',
);
}

if (middlewares) {
middlewares.forEach((middleware) => app.use(middleware));
}
Expand Down Expand Up @@ -262,6 +374,14 @@ export async function startSseAndStreamableHttpMcpServer(
const HOST = host || '127.0.0.1';
const PORT = Number(port || process.env.PORT || 8080);

// Extra warning when binding to a network-accessible address without auth.
if (!resolvedApiKey && HOST !== '127.0.0.1' && HOST !== 'localhost') {
logger.warn(
`SECURITY WARNING: Binding MCP HTTP server to "${HOST}" without API key authentication. ` +
'Any client on the network can access all MCP tools. Set MCP_API_KEY to require a Bearer token.',
);
}

return new Promise((resolve, reject) => {
const appServer = app.listen(PORT, HOST, (error?: Error) => {
if (error) {
Expand Down
183 changes: 183 additions & 0 deletions packages/agent-infra/mcp-http-server/tests/startServer-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import getPort from 'get-port';
import { setTimeout as delay } from 'node:timers/promises';
import { expect, it, describe, beforeAll, afterAll } from 'vitest';

import { startSseAndStreamableHttpMcpServer } from '../src/startServer.js';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';

/**
* Tests for the built-in API key (Bearer token) authentication.
*
* The API key can be provided either via the `apiKey` param or via the
* `MCP_API_KEY` environment variable. When set, every request must include
* a matching `Authorization: Bearer <apiKey>` header.
*/
describe('MCP HTTP Server API Key Authentication Tests', () => {
const API_KEY = 'super-secret-test-key';
let port: number;
let serverEndpoint: { url: string; port: number; close: () => void };

beforeAll(async () => {
port = await getPort();
serverEndpoint = await startSseAndStreamableHttpMcpServer({
port,
apiKey: API_KEY,
createMcpServer: async () => {
return new McpServer(
{
name: 'auth-test-server',
version: '1.0.0',
},
{
capabilities: {},
},
);
},
});
});

afterAll(async () => {
serverEndpoint.close();
await delay(100);
});

it('should reject requests without an Authorization header (401)', async () => {
const response = await fetch(serverEndpoint.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'test-client', version: '1.0.0' },
},
}),
});

expect(response.status).toBe(401);
expect(response.headers.get('www-authenticate')).toContain('Bearer');

const error = await response.json();
expect(error).toHaveProperty('jsonrpc', '2.0');
expect(error).toHaveProperty('error.code');
expect(error.error.message).toContain('Unauthorized');
});

it('should reject requests with an invalid Bearer token (401)', async () => {
const response = await fetch(serverEndpoint.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer wrong-token',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'test-client', version: '1.0.0' },
},
}),
});

expect(response.status).toBe(401);
const error = await response.json();
expect(error.error.message).toContain('invalid API key');
});

it('should accept requests with a valid Bearer token', async () => {
const response = await fetch(serverEndpoint.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'test-client', version: '1.0.0' },
},
}),
});

// Authenticated request should reach the MCP server (200), not be
// blocked by the auth middleware (401).
expect(response.status).toBe(200);
});

it('should reject unauthenticated GET requests to the SSE endpoint (401)', async () => {
const response = await fetch(`http://localhost:${port}/sse`);
expect(response.status).toBe(401);
});
});

describe('MCP HTTP Server without API Key (backward compatibility)', () => {
let port: number;
let serverEndpoint: { url: string; port: number; close: () => void };
const originalEnv = process.env.MCP_API_KEY;

beforeAll(async () => {
// Make sure no API key is configured for this suite.
delete process.env.MCP_API_KEY;

port = await getPort();
serverEndpoint = await startSseAndStreamableHttpMcpServer({
port,
createMcpServer: async () => {
return new McpServer(
{
name: 'no-auth-test-server',
version: '1.0.0',
},
{
capabilities: {},
},
);
},
});
});

afterAll(async () => {
serverEndpoint.close();
if (originalEnv !== undefined) {
process.env.MCP_API_KEY = originalEnv;
}
await delay(100);
});

it('should allow requests without authentication when no API key is configured', async () => {
const response = await fetch(serverEndpoint.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'test-client', version: '1.0.0' },
},
}),
});

// Without an API key the server keeps its previous behavior and does
// not block requests (only logs a warning).
expect(response.status).toBe(200);
});
});