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
237 changes: 236 additions & 1 deletion src/mcp/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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({
Expand Down
66 changes: 57 additions & 9 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | unknown[];

Expand Down Expand Up @@ -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");
}
}

/**
Expand Down Expand Up @@ -303,13 +312,27 @@ export class MCPServer {

createHTTPHandler(): (request: Request) => Promise<Response> {
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) {
Expand All @@ -326,7 +349,18 @@ export class MCPServer {

let rpcRequest: JSONRPCRequest;
try {
rpcRequest = await request.json();
const bodyText = await request.text();
Comment thread
ariskemper marked this conversation as resolved.
if (bodyText.length > MAX_REQUEST_BODY_SIZE) {
Comment thread
ariskemper marked this conversation as resolved.
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(
Expand All @@ -349,7 +383,7 @@ export class MCPServer {
return new Response(JSON.stringify(rpcResponse), {
headers: {
"Content-Type": "application/json",
...this.getCORSHeaders(),
...this.getCORSHeaders(requestOrigin),
},
});
};
Expand Down Expand Up @@ -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<string, string> {
private getCORSHeaders(requestOrigin?: string | null): Record<string, string> {
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,
Comment thread
ariskemper marked this conversation as resolved.
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-End-User-Id, X-Project-Id",
"Vary": "Origin",
};
}

Expand Down
Loading