diff --git a/README.md b/README.md index c2d835a..241ded3 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ options: - `--port`: Specify the port to listen on (default: 8080) - `--debug`: Enable debug logging - `--shell`: Spawn the server via the user's shell +- `--apiKey`: API key for authenticating requests (uses X-API-Key header) ### Passing arguments to the wrapped command @@ -83,6 +84,64 @@ npx mcp-proxy --port 8080 --stateless --server stream tsx server.js - **Request isolation**: When you need complete independence between requests - **Simple deployments**: When you don't need to maintain connection state +### API Key Authentication + +MCP Proxy supports optional API key authentication to secure your endpoints. When enabled, clients must provide a valid API key in the `X-API-Key` header to access the proxy. + +#### Enabling Authentication + +Authentication is disabled by default for backward compatibility. To enable it, provide an API key via: + +**Command-line:** +```bash +npx mcp-proxy --port 8080 --apiKey "your-secret-key" tsx server.js +``` + +**Environment variable:** +```bash +export MCP_PROXY_API_KEY="your-secret-key" +npx mcp-proxy --port 8080 tsx server.js +``` + +#### Client Configuration + +Clients must include the API key in the `X-API-Key` header: + +```typescript +// For streamable HTTP transport +const transport = new StreamableHTTPClientTransport( + new URL('http://localhost:8080/mcp'), + { + headers: { + 'X-API-Key': 'your-secret-key' + } + } +); + +// For SSE transport +const transport = new SSEClientTransport( + new URL('http://localhost:8080/sse'), + { + headers: { + 'X-API-Key': 'your-secret-key' + } + } +); +``` + +#### Exempt Endpoints + +The following endpoints do not require authentication: +- `/ping` - Health check endpoint +- `OPTIONS` requests - CORS preflight requests + +#### Security Notes + +- **Use HTTPS in production**: API keys should only be transmitted over secure connections +- **Keep keys secure**: Never commit API keys to version control +- **Generate strong keys**: Use cryptographically secure random strings for API keys +- **Rotate keys regularly**: Change API keys periodically for better security + ### Node.js SDK The Node.js SDK provides several utilities that are used to create a proxy. @@ -137,6 +196,7 @@ Options: - `sseEndpoint`: SSE endpoint path (default: "/sse", set to null to disable) - `streamEndpoint`: Streamable HTTP endpoint path (default: "/mcp", set to null to disable) - `stateless`: Enable stateless mode for HTTP streamable transport (default: false) +- `apiKey`: API key for authenticating requests (optional) - `onConnect`: Callback when a server connects (optional) - `onClose`: Callback when a server disconnects (optional) - `onUnhandledRequest`: Callback for unhandled HTTP requests (optional) diff --git a/src/authentication.test.ts b/src/authentication.test.ts new file mode 100644 index 0000000..1cfaff7 --- /dev/null +++ b/src/authentication.test.ts @@ -0,0 +1,117 @@ +import { IncomingMessage } from "http"; +import { describe, expect, it } from "vitest"; + +import { AuthenticationMiddleware } from "./authentication.js"; + +describe("AuthenticationMiddleware", () => { + const createMockRequest = (headers: Record = {}): IncomingMessage => { + // Simulate Node.js http module behavior which converts all header names to lowercase + const lowercaseHeaders: Record = {}; + for (const [key, value] of Object.entries(headers)) { + lowercaseHeaders[key.toLowerCase()] = value; + } + return { + headers: lowercaseHeaders, + } as IncomingMessage; + }; + + describe("when no auth is configured", () => { + it("should allow all requests", () => { + const middleware = new AuthenticationMiddleware({}); + const req = createMockRequest(); + + expect(middleware.validateRequest(req)).toBe(true); + }); + + it("should allow requests even with headers", () => { + const middleware = new AuthenticationMiddleware({}); + const req = createMockRequest({ "x-api-key": "some-key" }); + + expect(middleware.validateRequest(req)).toBe(true); + }); + }); + + describe("X-API-Key validation", () => { + const apiKey = "test-api-key-123"; + + it("should accept valid API key", () => { + const middleware = new AuthenticationMiddleware({ apiKey }); + const req = createMockRequest({ "x-api-key": apiKey }); + + expect(middleware.validateRequest(req)).toBe(true); + }); + + it("should reject missing API key", () => { + const middleware = new AuthenticationMiddleware({ apiKey }); + const req = createMockRequest(); + + expect(middleware.validateRequest(req)).toBe(false); + }); + + it("should reject incorrect API key", () => { + const middleware = new AuthenticationMiddleware({ apiKey }); + const req = createMockRequest({ "x-api-key": "wrong-key" }); + + expect(middleware.validateRequest(req)).toBe(false); + }); + + it("should reject empty API key", () => { + const middleware = new AuthenticationMiddleware({ apiKey }); + const req = createMockRequest({ "x-api-key": "" }); + + expect(middleware.validateRequest(req)).toBe(false); + }); + + it("should be case-insensitive for header names", () => { + const middleware = new AuthenticationMiddleware({ apiKey }); + const req = createMockRequest({ "X-API-KEY": apiKey }); + + expect(middleware.validateRequest(req)).toBe(true); + }); + + it("should work with mixed case header names", () => { + const middleware = new AuthenticationMiddleware({ apiKey }); + const req = createMockRequest({ "X-Api-Key": apiKey }); + + expect(middleware.validateRequest(req)).toBe(true); + }); + + it("should handle array headers (if multiple same headers)", () => { + const middleware = new AuthenticationMiddleware({ apiKey }); + const req = { + headers: { + "x-api-key": [apiKey, "another-key"], + }, + } as unknown as IncomingMessage; + + // Should fail because header is an array, not a string + expect(middleware.validateRequest(req)).toBe(false); + }); + }); + + describe("getUnauthorizedResponse", () => { + it("should return proper unauthorized response", () => { + const middleware = new AuthenticationMiddleware({ apiKey: "test" }); + const response = middleware.getUnauthorizedResponse(); + + expect(response.headers["Content-Type"]).toBe("application/json"); + + const body = JSON.parse(response.body); + expect(body.error.code).toBe(401); + expect(body.error.message).toBe("Unauthorized: Invalid or missing API key"); + expect(body.jsonrpc).toBe("2.0"); + expect(body.id).toBe(null); + }); + + it("should have consistent format regardless of configuration", () => { + const middleware1 = new AuthenticationMiddleware({}); + const middleware2 = new AuthenticationMiddleware({ apiKey: "test" }); + + const response1 = middleware1.getUnauthorizedResponse(); + const response2 = middleware2.getUnauthorizedResponse(); + + expect(response1.headers).toEqual(response2.headers); + expect(response1.body).toEqual(response2.body); + }); + }); +}); \ No newline at end of file diff --git a/src/authentication.ts b/src/authentication.ts new file mode 100644 index 0000000..2784c6a --- /dev/null +++ b/src/authentication.ts @@ -0,0 +1,43 @@ +import type { IncomingMessage } from "http"; + +export interface AuthConfig { + apiKey?: string; +} + +export class AuthenticationMiddleware { + constructor(private config: AuthConfig = {}) {} + + getUnauthorizedResponse() { + return { + body: JSON.stringify({ + error: { + code: 401, + message: "Unauthorized: Invalid or missing API key", + }, + id: null, + jsonrpc: "2.0", + }), + headers: { + "Content-Type": "application/json", + }, + }; + } + + validateRequest(req: IncomingMessage): boolean { + // No auth required if no API key configured (backward compatibility) + if (!this.config.apiKey) { + return true; + } + + // Check X-API-Key header (case-insensitive) + // Node.js http module automatically converts all header names to lowercase + const apiKey = req.headers["x-api-key"]; + + if (!apiKey || typeof apiKey !== "string") { + return false; + } + + return apiKey === this.config.apiKey; + } +} + diff --git a/src/bin/mcp-proxy.ts b/src/bin/mcp-proxy.ts index 81b0c14..7c6bd3d 100644 --- a/src/bin/mcp-proxy.ts +++ b/src/bin/mcp-proxy.ts @@ -38,6 +38,10 @@ const argv = await yargs(hideBin(process.argv)) "populate--": true, }) .options({ + apiKey: { + describe: "API key for authenticating requests (uses X-API-Key header)", + type: "string", + }, debug: { default: false, describe: "Enable debug logging", @@ -160,6 +164,7 @@ const proxy = async () => { }; const server = await startHTTPServer({ + apiKey: argv.apiKey, createServer, eventStore: new InMemoryEventStore(), host: argv.host, diff --git a/src/startHTTPServer.test.ts b/src/startHTTPServer.test.ts index d51994d..bc0ff15 100644 --- a/src/startHTTPServer.test.ts +++ b/src/startHTTPServer.test.ts @@ -327,3 +327,377 @@ it("supports stateless HTTP streamable transport", async () => { // Note: in stateless mode, onClose behavior may differ since there's no persistent session await delay(100); }); + +it("allows requests when no auth is configured", async () => { + const stdioTransport = new StdioClientTransport({ + args: ["src/fixtures/simple-stdio-server.ts"], + command: "tsx", + }); + + const stdioClient = new Client( + { + name: "mcp-proxy", + version: "1.0.0", + }, + { + capabilities: {}, + }, + ); + + await stdioClient.connect(stdioTransport); + + const serverVersion = stdioClient.getServerVersion() as { + name: string; + version: string; + }; + + const serverCapabilities = stdioClient.getServerCapabilities() as { + capabilities: Record; + }; + + const port = await getRandomPort(); + + const httpServer = await startHTTPServer({ + // No apiKey configured + createServer: async () => { + const mcpServer = new Server(serverVersion, { + capabilities: serverCapabilities, + }); + + await proxyServer({ + client: stdioClient, + server: mcpServer, + serverCapabilities, + }); + + return mcpServer; + }, + port, + }); + + const streamClient = new Client( + { + name: "stream-client", + version: "1.0.0", + }, + { + capabilities: {}, + }, + ); + + // Connect without any authentication header + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + ); + + await streamClient.connect(transport); + + // Should be able to make requests without auth + const result = await streamClient.listResources(); + expect(result).toEqual({ + resources: [ + { + name: "Example Resource", + uri: "file:///example.txt", + }, + ], + }); + + await streamClient.close(); + await httpServer.close(); + await stdioClient.close(); +}); + +it("rejects requests without API key when auth is enabled", async () => { + const stdioTransport = new StdioClientTransport({ + args: ["src/fixtures/simple-stdio-server.ts"], + command: "tsx", + }); + + const stdioClient = new Client( + { + name: "mcp-proxy", + version: "1.0.0", + }, + { + capabilities: {}, + }, + ); + + await stdioClient.connect(stdioTransport); + + const serverVersion = stdioClient.getServerVersion() as { + name: string; + version: string; + }; + + const serverCapabilities = stdioClient.getServerCapabilities() as { + capabilities: Record; + }; + + const port = await getRandomPort(); + + const httpServer = await startHTTPServer({ + apiKey: "test-api-key-123", // API key configured + createServer: async () => { + const mcpServer = new Server(serverVersion, { + capabilities: serverCapabilities, + }); + + await proxyServer({ + client: stdioClient, + server: mcpServer, + serverCapabilities, + }); + + return mcpServer; + }, + port, + }); + + // Try to connect without authentication header + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + ); + + const streamClient = new Client( + { + name: "stream-client", + version: "1.0.0", + }, + { + capabilities: {}, + }, + ); + + // Connection should fail due to missing auth + await expect(streamClient.connect(transport)).rejects.toThrow(); + + await httpServer.close(); + await stdioClient.close(); +}); + +it("accepts requests with valid API key", async () => { + const stdioTransport = new StdioClientTransport({ + args: ["src/fixtures/simple-stdio-server.ts"], + command: "tsx", + }); + + const stdioClient = new Client( + { + name: "mcp-proxy", + version: "1.0.0", + }, + { + capabilities: {}, + }, + ); + + await stdioClient.connect(stdioTransport); + + const serverVersion = stdioClient.getServerVersion() as { + name: string; + version: string; + }; + + const serverCapabilities = stdioClient.getServerCapabilities() as { + capabilities: Record; + }; + + const port = await getRandomPort(); + const apiKey = "test-api-key-123"; + + const httpServer = await startHTTPServer({ + apiKey, + createServer: async () => { + const mcpServer = new Server(serverVersion, { + capabilities: serverCapabilities, + }); + + await proxyServer({ + client: stdioClient, + server: mcpServer, + serverCapabilities, + }); + + return mcpServer; + }, + port, + }); + + // Connect with proper authentication header + const transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + { + requestInit: { + headers: { + "X-API-Key": apiKey, + }, + }, + }, + ); + + const streamClient = new Client( + { + name: "stream-client", + version: "1.0.0", + }, + { + capabilities: {}, + }, + ); + + await streamClient.connect(transport); + + // Should be able to make requests with valid auth + const result = await streamClient.listResources(); + expect(result).toEqual({ + resources: [ + { + name: "Example Resource", + uri: "file:///example.txt", + }, + ], + }); + + await streamClient.close(); + await httpServer.close(); + await stdioClient.close(); +}); + +it("works with SSE transport and authentication", async () => { + const stdioTransport = new StdioClientTransport({ + args: ["src/fixtures/simple-stdio-server.ts"], + command: "tsx", + }); + + const stdioClient = new Client( + { + name: "mcp-proxy", + version: "1.0.0", + }, + { + capabilities: {}, + }, + ); + + await stdioClient.connect(stdioTransport); + + const serverVersion = stdioClient.getServerVersion() as { + name: string; + version: string; + }; + + const serverCapabilities = stdioClient.getServerCapabilities() as { + capabilities: Record; + }; + + const port = await getRandomPort(); + const apiKey = "test-api-key-456"; + + const httpServer = await startHTTPServer({ + apiKey, + createServer: async () => { + const mcpServer = new Server(serverVersion, { + capabilities: serverCapabilities, + }); + + await proxyServer({ + client: stdioClient, + server: mcpServer, + serverCapabilities, + }); + + return mcpServer; + }, + port, + }); + + // Connect with proper authentication header for SSE + const transport = new SSEClientTransport( + new URL(`http://localhost:${port}/sse`), + { + requestInit: { + headers: { + "X-API-Key": apiKey, + }, + }, + }, + ); + + const sseClient = new Client( + { + name: "sse-client", + version: "1.0.0", + }, + { + capabilities: {}, + }, + ); + + await sseClient.connect(transport); + + // Should be able to make requests with valid auth + const result = await sseClient.listResources(); + expect(result).toEqual({ + resources: [ + { + name: "Example Resource", + uri: "file:///example.txt", + }, + ], + }); + + await sseClient.close(); + await httpServer.close(); + await stdioClient.close(); +}); + +it("does not require auth for /ping endpoint", async () => { + const port = await getRandomPort(); + const apiKey = "test-api-key-789"; + + const httpServer = await startHTTPServer({ + apiKey, + createServer: async () => { + const mcpServer = new Server( + { name: "test", version: "1.0.0" }, + { capabilities: {} }, + ); + return mcpServer; + }, + port, + }); + + // Test /ping without auth header + const response = await fetch(`http://localhost:${port}/ping`); + expect(response.status).toBe(200); + expect(await response.text()).toBe("pong"); + + await httpServer.close(); +}); + +it("does not require auth for OPTIONS requests", async () => { + const port = await getRandomPort(); + const apiKey = "test-api-key-999"; + + const httpServer = await startHTTPServer({ + apiKey, + createServer: async () => { + const mcpServer = new Server( + { name: "test", version: "1.0.0" }, + { capabilities: {} }, + ); + return mcpServer; + }, + port, + }); + + // Test OPTIONS without auth header + const response = await fetch(`http://localhost:${port}/mcp`, { + method: "OPTIONS", + }); + expect(response.status).toBe(204); + + await httpServer.close(); +}); diff --git a/src/startHTTPServer.ts b/src/startHTTPServer.ts index 6743a7a..f94705f 100644 --- a/src/startHTTPServer.ts +++ b/src/startHTTPServer.ts @@ -8,6 +8,7 @@ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import http from "http"; import { randomUUID } from "node:crypto"; +import { AuthenticationMiddleware } from "./authentication.js"; import { InMemoryEventStore } from "./InMemoryEventStore.js"; export type SSEServer = { @@ -455,6 +456,7 @@ const handleSSERequest = async ({ }; export const startHTTPServer = async ({ + apiKey, createServer, enableJsonResponse, eventStore, @@ -467,6 +469,7 @@ export const startHTTPServer = async ({ stateless, streamEndpoint = "/mcp", }: { + apiKey?: string; createServer: (request: http.IncomingMessage) => Promise; enableJsonResponse?: boolean; eventStore?: EventStore; @@ -492,6 +495,8 @@ export const startHTTPServer = async ({ } > = {}; + const authMiddleware = new AuthenticationMiddleware({ apiKey }); + /** * @author https://dev.classmethod.jp/articles/mcp-sse/ */ @@ -521,6 +526,14 @@ export const startHTTPServer = async ({ return; } + // Check authentication for all other endpoints + if (!authMiddleware.validateRequest(req)) { + const authResponse = authMiddleware.getUnauthorizedResponse(); + res.writeHead(401, authResponse.headers); + res.end(authResponse.body); + return; + } + if ( sseEndpoint && (await handleSSERequest({