diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index 1fba7840fe..2faca2a6d4 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -89,7 +89,10 @@ describe("mcp/server", () => { const handler = server.createHTTPHandler(); const response = await handler( - new Request("http://localhost/mcp", { method: "OPTIONS" }), + new Request("http://localhost/mcp", { + method: "OPTIONS", + headers: { "Origin": "https://example.com" }, + }), ); assertEquals(response.status, 204); @@ -99,6 +102,238 @@ describe("mcp/server", () => { assertStringIncludes(allowHeaders, "X-Project-Id"); }); + describe("bearer auth", () => { + it("rejects requests when bearer auth has no validate function", async () => { + const server = createMCPServer({ + enabled: true, + auth: { type: "bearer" }, + }); + + const handler = server.createHTTPHandler(); + const response = await handler( + new Request("http://localhost/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer some-token", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }), + ); + + assertEquals(response.status, 401); + }); + + it("rejects requests without Authorization header", async () => { + const server = createMCPServer({ + enabled: true, + auth: { type: "bearer", validate: async (token: string) => token === "valid" }, + }); + + const handler = server.createHTTPHandler(); + const response = await handler( + new Request("http://localhost/mcp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }), + ); + + assertEquals(response.status, 401); + }); + + it("accepts requests with valid bearer token", async () => { + const server = createMCPServer({ + enabled: true, + auth: { type: "bearer", validate: async (token: string) => token === "valid-token" }, + }); + + const handler = server.createHTTPHandler(); + const response = await handler( + new Request("http://localhost/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer valid-token", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }), + ); + + assertEquals(response.status, 200); + }); + + it("rejects requests with invalid bearer token", async () => { + const server = createMCPServer({ + enabled: true, + auth: { type: "bearer", validate: async (token: string) => token === "valid-token" }, + }); + + const handler = server.createHTTPHandler(); + const response = await handler( + new Request("http://localhost/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer wrong-token", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }), + ); + + assertEquals(response.status, 401); + }); + }); + + describe("request body size limit", () => { + it("rejects requests with Content-Length exceeding 1MB", async () => { + const server = createMCPServer({ enabled: true }); + const handler = server.createHTTPHandler(); + + const response = await handler( + new Request("http://localhost/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": "2000000", + }, + body: "{}", + }), + ); + + assertEquals(response.status, 413); + const body = await response.json(); + assertEquals(body.error.message, "Request body too large"); + }); + + it("rejects requests with body exceeding 1MB even without Content-Length", async () => { + const server = createMCPServer({ enabled: true }); + const handler = server.createHTTPHandler(); + + const largeBody = "x".repeat(1_048_577); + const response = await handler( + new Request("http://localhost/mcp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: largeBody, + }), + ); + + assertEquals(response.status, 413); + const body = await response.json(); + assertEquals(body.error.message, "Request body too large"); + }); + + it("accepts requests within the 1MB limit", async () => { + const server = createMCPServer({ enabled: true }); + const handler = server.createHTTPHandler(); + + const response = await handler( + new Request("http://localhost/mcp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }), + ); + + assertEquals(response.status, 200); + }); + }); + + describe("CORS origin matching", () => { + it("returns CORS headers when request Origin matches configured origins", async () => { + const server = createMCPServer({ + enabled: true, + cors: { enabled: true, origins: ["https://a.com", "https://b.com"] }, + }); + + const handler = server.createHTTPHandler(); + const response = await handler( + new Request("http://localhost/mcp", { + method: "OPTIONS", + headers: { "Origin": "https://b.com" }, + }), + ); + + assertEquals(response.status, 204); + assertEquals(response.headers.get("Access-Control-Allow-Origin"), "https://b.com"); + assertEquals(response.headers.get("Vary"), "Origin"); + }); + + it("returns no CORS headers when request Origin does not match", async () => { + const server = createMCPServer({ + enabled: true, + cors: { enabled: true, origins: ["https://allowed.com"] }, + }); + + const handler = server.createHTTPHandler(); + const response = await handler( + new Request("http://localhost/mcp", { + method: "OPTIONS", + headers: { "Origin": "https://evil.com" }, + }), + ); + + assertEquals(response.status, 204); + assertEquals(response.headers.get("Access-Control-Allow-Origin"), null); + }); + + it("returns no CORS headers when no origins configured", async () => { + const server = createMCPServer({ + enabled: true, + cors: { enabled: true }, + }); + + const handler = server.createHTTPHandler(); + const response = await handler( + new Request("http://localhost/mcp", { + method: "OPTIONS", + headers: { "Origin": "https://example.com" }, + }), + ); + + assertEquals(response.status, 204); + assertEquals(response.headers.get("Access-Control-Allow-Origin"), null); + }); + + it("returns no CORS headers when CORS is disabled", async () => { + const server = createMCPServer({ enabled: true }); + + const handler = server.createHTTPHandler(); + const response = await handler( + new Request("http://localhost/mcp", { + method: "OPTIONS", + headers: { "Origin": "https://example.com" }, + }), + ); + + assertEquals(response.status, 204); + assertEquals(response.headers.get("Access-Control-Allow-Origin"), null); + }); + + it("includes CORS headers on POST responses when Origin matches", async () => { + const server = createMCPServer({ + enabled: true, + cors: { enabled: true, origins: ["https://example.com"] }, + }); + + const handler = server.createHTTPHandler(); + const response = await handler( + new Request("http://localhost/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Origin": "https://example.com", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }), + ); + + assertEquals(response.status, 200); + assertEquals(response.headers.get("Access-Control-Allow-Origin"), "https://example.com"); + }); + }); + it("retries loading integrations on subsequent tools/list after a failed fetch", async () => { const server = createMCPServer({ enabled: true }); server.setIntegrationLoader({ diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 969a094ae7..fe421686cc 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -11,6 +11,11 @@ import { VERSION } from "#veryfront/utils/version.ts"; import { validateContentType } from "#veryfront/security/input-validation/limits.ts"; import { VeryfrontError } from "#veryfront/security/input-validation/errors.ts"; import type { IntegrationRuntimeConfig } from "../integrations/types.ts"; +import { logger as baseLogger } from "#veryfront/utils"; + +const logger = baseLogger.component("mcp-server"); + +const MAX_REQUEST_BODY_SIZE = 1_048_576; // 1 MB type JSONRPCParams = Record | unknown[]; @@ -50,6 +55,10 @@ export class MCPServer { constructor(config: MCPServerConfig) { this.config = config; + + if (!config.auth || config.auth.type === "none") { + logger.warn("MCP server has no authentication configured — all requests will be accepted"); + } } /** @@ -303,13 +312,27 @@ export class MCPServer { createHTTPHandler(): (request: Request) => Promise { return async (request: Request) => { - if (request.method === "OPTIONS") return this.handleCORS(); + const requestOrigin = request.headers.get("Origin"); + if (request.method === "OPTIONS") return this.handleCORS(requestOrigin); if (this.config.auth?.type && this.config.auth.type !== "none") { const authorized = await this.validateAuth(request); if (!authorized) return new Response("Unauthorized", { status: 401 }); } + // Enforce request body size limit (fast path via Content-Length header) + const contentLength = request.headers.get("content-length"); + if (contentLength && Number(contentLength) > MAX_REQUEST_BODY_SIZE) { + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: null, + error: { code: -32600, message: "Request body too large" }, + }), + { status: 413, headers: { "Content-Type": "application/json" } }, + ); + } + try { validateContentType(request, "application/json"); } catch (error) { @@ -326,7 +349,18 @@ export class MCPServer { let rpcRequest: JSONRPCRequest; try { - rpcRequest = await request.json(); + const bodyText = await request.text(); + if (bodyText.length > MAX_REQUEST_BODY_SIZE) { + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: null, + error: { code: -32600, message: "Request body too large" }, + }), + { status: 413, headers: { "Content-Type": "application/json" } }, + ); + } + rpcRequest = JSON.parse(bodyText) as JSONRPCRequest; } catch (_) { // expected: malformed JSON in request body return new Response( @@ -349,7 +383,7 @@ export class MCPServer { return new Response(JSON.stringify(rpcResponse), { headers: { "Content-Type": "application/json", - ...this.getCORSHeaders(), + ...this.getCORSHeaders(requestOrigin), }, }); }; @@ -383,24 +417,38 @@ export class MCPServer { if (auth.type !== "bearer") return false; const token = authHeader.replace("Bearer ", ""); - if (!auth.validate) return false; + + // When bearer auth is configured without a validate function, reject all requests + if (!auth.validate) { + logger.warn("Bearer auth configured without validate function — rejecting request"); + return false; + } return await auth.validate(token); } - private handleCORS(): Response { - return new Response(null, { status: 204, headers: this.getCORSHeaders() }); + private handleCORS(requestOrigin?: string | null): Response { + return new Response(null, { status: 204, headers: this.getCORSHeaders(requestOrigin) }); } - private getCORSHeaders(): Record { + private getCORSHeaders(requestOrigin?: string | null): Record { if (!this.config.cors?.enabled) return {}; - const origin = this.config.cors.origins?.[0] ?? "*"; + const origins = this.config.cors.origins; + if (!origins || origins.length === 0) return {}; + + // Match request origin against the configured origins list + const matchedOrigin = requestOrigin && origins.includes(requestOrigin) + ? requestOrigin + : undefined; + + if (!matchedOrigin) return {}; return { - "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Origin": matchedOrigin, "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization, X-End-User-Id, X-Project-Id", + "Vary": "Origin", }; }