From bb84de40ae9cabf63746f2043e885d057ad19475 Mon Sep 17 00:00:00 2001 From: TianKai Ma Date: Sat, 18 Jul 2026 23:06:33 +0800 Subject: [PATCH] fix(api): remove redundant request hot-path work --- docs/contracts/mcp.json | 1 + docs/contracts/openapi.json | 1 + public/openapi.generated.json | 3 + src/lib/api/routes/mcp-request-handler.ts | 57 ++++++++++--- src/lib/api/routes/mcp-request-logging.ts | 1 + .../api/routes/mcp-response-bookkeeping.ts | 1 + src/lib/api/routes/openapi.ts | 16 ++-- src/lib/auth/core.ts | 16 +++- src/lib/graphql/security.ts | 5 -- src/lib/mcp/auth.ts | 11 ++- src/lib/mcp/observability-summary.ts | 4 + src/lib/mcp/observability.ts | 5 +- src/lib/mcp/request-body.ts | 82 +++++++++++++++++++ src/lib/mcp/tool-scopes.ts | 26 +++--- src/lib/metrics/analytics-engine.ts | 1 + src/routes/api/mcp/+server.ts | 1 + tests/e2e/src/app/api/mcp/transport.test.ts | 39 +++++++++ tests/e2e/src/app/api/openapi/test.ts | 1 + tests/unit/auth-core-session-cache.test.ts | 54 ++++++++++++ tests/unit/mcp-request-body.test.ts | 59 +++++++++++++ tests/unit/mcp-request-rate-limit.test.ts | 74 ++++++++++++++++- tests/unit/openapi-route.test.ts | 18 ++++ 22 files changed, 433 insertions(+), 43 deletions(-) create mode 100644 src/lib/mcp/request-body.ts create mode 100644 tests/unit/auth-core-session-cache.test.ts create mode 100644 tests/unit/mcp-request-body.test.ts create mode 100644 tests/unit/openapi-route.test.ts diff --git a/docs/contracts/mcp.json b/docs/contracts/mcp.json index a6d469c42..891519e00 100644 --- a/docs/contracts/mcp.json +++ b/docs/contracts/mcp.json @@ -17,6 +17,7 @@ "actionable-errors": "Validation and common not-found payloads prefer plain-language messages and may include a hint that points to the next useful tool or query to recover.", "resource-bound-access-token": "MCP transport requests must present a resource-bound Bearer token for /api/mcp. JWT access tokens minted with the canonical MCP resource are accepted; opaque tokens without a resource indicator are rejected. Omitted-resource refresh grants are normalized to MCP only when the stored grant includes MCP-compatible feature scopes and the canonical MCP resource approval.", "complete-batch-scope-enforcement": "Every feature scope declared by each requested tool is required across the complete JSON-RPC batch. A feature:write grant also satisfies feature:read for the same feature, while the legacy mcp:tools scope retains compatibility access.", + "bounded-auth-first-request-body": "MCP transport requests authenticate the resource-bound Bearer token before parsing JSON-RPC. Authenticated JSON request bodies are streamed with a 64 KiB limit, parsed once, and shared by scope enforcement, observability, rate limiting, and the SDK transport; larger bodies return 413 before tool handling.", "transport-observability": "MCP transport handling emits production-safe structured request/response logs and Cloudflare Analytics Engine datapoints with JSON-RPC method summaries, tool names, argument keys, auth/origin phase, status, duration, and registered tool count; it never logs bearer tokens, cookies, or tool argument values.", "mutation-rate-limits": "After MCP bearer authentication and before SDK tool handling, every actual mutation tools/call entry is checked against the same per-host, per-user, canonical feature-action Cloudflare budgets as REST: 60 standard writes per minute or 10 batch writes per minute. A JSON-RPC batch is rejected as a whole before any tool side effect when any check exceeds the budget (429) or the Cloudflare binding is unavailable (503); both responses include Retry-After: 60.", "rate-limit-accuracy-boundary": "MCP mutation throttles are per-Cloudflare-location, permissive, eventually consistent abuse controls rather than global exact quotas. The protocol does not promise remaining/reset counters, and read-only tools do not consume mutation budgets.", diff --git a/docs/contracts/openapi.json b/docs/contracts/openapi.json index ca6f2a95e..dce6cc24c 100644 --- a/docs/contracts/openapi.json +++ b/docs/contracts/openapi.json @@ -14,6 +14,7 @@ "security-schemes": "Generated OpenAPI derives operation security from contract route auth: user REST operations use scoped bearer/session alternatives, admin REST operations use session-cookie auth only, MCP uses bearer-only auth, internal operational endpoints use internal bearer auth, and personal calendar feed export also allows feed tokens.", "generated-client-required": "CLI and Bot business calls must use generated OpenAPI clients and generated request/response types whenever the server OpenAPI covers the endpoint. Allowed exceptions are explicit raw request escape hatches, presigned upload object PUTs, OAuth protocol plumbing, external school/QQ/NapCat APIs, and documented non-OpenAPI endpoints such as OAuth userinfo fallback.", "openapi-drift-gate": "After server OpenAPI changes, regenerate public/openapi.generated.json and sync api/openapi.json plus generated Go clients in CLI and Bot before publishing. Cross-repo CI should fail if copied OpenAPI files drift from the server-generated contract.", + "openapi-document-cache": "GET /api/openapi serves one build-time serialized document per Worker isolate and allows public clients and edge caches to reuse it for five minutes.", "rest-observability": "REST API handling records production-safe structured request-start/request-finish logs with request ID, method, status, auth mode, duration, and normalized route template; when the Cloudflare Analytics Engine binding is present it also writes sanitized request finish/error datapoints with normalized route, method, status class, auth mode, and duration; it does not log request bodies, credentials, raw query strings, or high-cardinality resource IDs.", "authenticated-mutation-rate-limits": "Authenticated end-user and admin REST mutations use Cloudflare rate-limit bindings keyed by deployment host, user, and canonical feature action. Standard writes allow 60 checks per minute; batch writes allow 10. REST and MCP share the same feature-action keys. The gate runs after authentication and before mutation. Exceeded budgets return 429; missing or failing Cloudflare bindings fail closed with 503. Both responses include Retry-After: 60. Host-native development bypasses the gate explicitly.", "dashboard-rate-limit-fallbacks": "For the dashboard pin HTML form, rate-limit rejection skips the write and redirects with an error marker; JSON/API callers receive 429 or 503 directly. Dashboard link visits are best-effort analytics writes: rejection skips click recording but preserves the target redirect.", diff --git a/public/openapi.generated.json b/public/openapi.generated.json index a11ccd35b..26d12d044 100644 --- a/public/openapi.generated.json +++ b/public/openapi.generated.json @@ -1519,6 +1519,9 @@ } } }, + "413": { + "description": "Response 413" + }, "429": { "description": "Error response", "headers": { diff --git a/src/lib/api/routes/mcp-request-handler.ts b/src/lib/api/routes/mcp-request-handler.ts index 2f73de066..b3aa8c656 100644 --- a/src/lib/api/routes/mcp-request-handler.ts +++ b/src/lib/api/routes/mcp-request-handler.ts @@ -3,7 +3,7 @@ import { rateLimitResponse } from "@/lib/api/helpers"; import { logAppEvent } from "@/lib/log/app-logger"; import { logOAuthDebug, oauthDebugCorrelationId } from "@/lib/log/oauth-debug"; import { - extractMcpToolCallNamesFromRequest, + extractMcpToolCallNames, getMcpWriteRateLimitAction, getMcpWriteRateLimitTier, isMcpWriteTool, @@ -46,10 +46,10 @@ export async function handleMcpRequest(request: Request) { return originError; } - const toolCallNames = await extractMcpToolCallNamesFromRequest(request); - const toolNames = Array.from(new Set(toolCallNames)); - const { authenticateMcpRequest } = await import("@/lib/mcp/auth"); - const authResult = await authenticateMcpRequest(request, toolNames); + const { authenticateMcpRequest, authorizeMcpToolScopes } = await import( + "@/lib/mcp/auth" + ); + const authResult = await authenticateMcpRequest(request); if ("response" in authResult) { const res = authResult.response; const www = res.headers.get("www-authenticate"); @@ -67,12 +67,46 @@ export async function handleMcpRequest(request: Request) { return withMcpCors(request, res); } - const { summarizeMcpJsonRpcRequest } = await import( - "@/lib/mcp/observability" - ); - rpcSummary = await summarizeMcpJsonRpcRequest(request); + const { readMcpJsonBodyWithinLimit } = await import("@/lib/mcp/request-body"); + const bodyResult = await readMcpJsonBodyWithinLimit(request); + if ("response" in bodyResult) { + recordAndLogMcpResponse({ + context: logContext, + request, + phase: "body-rejected", + rpcSummary, + status: bodyResult.response.status, + start, + }); + return withMcpCors(request, bodyResult.response); + } + + const toolCallNames = extractMcpToolCallNames(bodyResult.body); + const toolNames = Array.from(new Set(toolCallNames)); + const toolAuthResult = authorizeMcpToolScopes(authResult.authInfo, toolNames); + if ("response" in toolAuthResult) { + const res = toolAuthResult.response; + const www = res.headers.get("www-authenticate"); + recordAndLogMcpResponse({ + authFailureDiagnostics: toolAuthResult.authFailureDiagnostics, + context: logContext, + request, + phase: "auth-rejected", + rpcSummary, + status: res.status, + start, + wwwAuthenticatePrefix: www ? www.slice(0, 120) : null, + }); + return withMcpCors(request, res); + } + + const { summarizeMcpJsonRpcBody } = await import("@/lib/mcp/observability"); + rpcSummary = + bodyResult.body === undefined + ? null + : summarizeMcpJsonRpcBody(bodyResult.body); - const userId = authResult.authInfo.extra?.userId; + const userId = toolAuthResult.authInfo.extra?.userId; if (typeof userId === "string" && userId.length > 0) { for (const toolName of toolCallNames) { if (!isMcpWriteTool(toolName)) continue; @@ -120,7 +154,8 @@ export async function handleMcpRequest(request: Request) { await server.connect(transport); const res = await transport.handleRequest(request, { - authInfo: authResult.authInfo, + authInfo: toolAuthResult.authInfo, + parsedBody: bodyResult.body, }); recordAndLogMcpResponse({ context: logContext, diff --git a/src/lib/api/routes/mcp-request-logging.ts b/src/lib/api/routes/mcp-request-logging.ts index 5967b8d20..64d0296a2 100644 --- a/src/lib/api/routes/mcp-request-logging.ts +++ b/src/lib/api/routes/mcp-request-logging.ts @@ -53,6 +53,7 @@ export function logMcpTransportResponse({ durationMs: number; phase: | "auth-rejected" + | "body-rejected" | "handled" | "origin-rejected" | "rate-limit-rejected"; diff --git a/src/lib/api/routes/mcp-response-bookkeeping.ts b/src/lib/api/routes/mcp-response-bookkeeping.ts index 01eca9c88..b5b335695 100644 --- a/src/lib/api/routes/mcp-response-bookkeeping.ts +++ b/src/lib/api/routes/mcp-response-bookkeeping.ts @@ -14,6 +14,7 @@ export function recordAndLogMcpResponse(input: { }; phase: | "auth-rejected" + | "body-rejected" | "handled" | "origin-rejected" | "rate-limit-rejected"; diff --git a/src/lib/api/routes/openapi.ts b/src/lib/api/routes/openapi.ts index 62306cd93..372071ac6 100644 --- a/src/lib/api/routes/openapi.ts +++ b/src/lib/api/routes/openapi.ts @@ -1,10 +1,12 @@ -import { handleRouteError, jsonResponse } from "@/lib/api/helpers"; import openApiSpec from "../../../../public/openapi.generated.json"; -export async function getOpenApiRoute() { - try { - return jsonResponse(openApiSpec); - } catch (error) { - return handleRouteError("Failed to read generated OpenAPI document", error); - } +const OPENAPI_BODY = JSON.stringify(openApiSpec); + +export function getOpenApiRoute() { + return new Response(OPENAPI_BODY, { + headers: { + "cache-control": "public, max-age=300", + "content-type": "application/json; charset=utf-8", + }, + }); } diff --git a/src/lib/auth/core.ts b/src/lib/auth/core.ts index 89834ad4e..35397b297 100644 --- a/src/lib/auth/core.ts +++ b/src/lib/auth/core.ts @@ -9,6 +9,7 @@ function createAuthInstance() { type BetterAuthInstance = ReturnType; let authInstance: BetterAuthInstance | undefined; +const sessionByHeaders = new WeakMap>(); function getAuthInstance(): BetterAuthInstance { if (!authInstance) { @@ -44,6 +45,17 @@ export const betterAuthInstance = new Proxy({} as BetterAuthInstance, { export async function getSessionFromHeaders( headers: Headers, ): Promise { - const session = await getAuthInstance().api.getSession({ headers }); - return session ? mapAppSession(session) : null; + const cached = sessionByHeaders.get(headers); + if (cached) return cached; + + const pending = getAuthInstance() + .api.getSession({ headers }) + .then((session) => (session ? mapAppSession(session) : null)); + sessionByHeaders.set(headers, pending); + try { + return await pending; + } catch (error) { + sessionByHeaders.delete(headers); + throw error; + } } diff --git a/src/lib/graphql/security.ts b/src/lib/graphql/security.ts index 9997ff02a..12240f977 100644 --- a/src/lib/graphql/security.ts +++ b/src/lib/graphql/security.ts @@ -64,11 +64,6 @@ const operationCostPlugin: Plugin = { export function createGraphqlSecurityPlugins(production: boolean) { const armor = new EnvelopArmor({ blockFieldSuggestion: { enabled: true }, - costLimit: { - errorMessage: "Query cost limit exceeded.", - maxCost: GRAPHQL_LIMITS.cost, - exposeLimits: false, - }, maxAliases: { n: GRAPHQL_LIMITS.aliases, exposeLimits: false, diff --git a/src/lib/mcp/auth.ts b/src/lib/mcp/auth.ts index ce3197cb6..aa528158a 100644 --- a/src/lib/mcp/auth.ts +++ b/src/lib/mcp/auth.ts @@ -142,6 +142,15 @@ export async function authenticateMcpRequest( }; } + return authorizeMcpToolScopes(authInfo, toolName); +} + +export function authorizeMcpToolScopes( + authInfo: AuthInfo, + toolName?: string | string[], +): + | { authInfo: AuthInfo } + | { authFailureDiagnostics: McpAuthFailureDiagnostics; response: Response } { const requiredScopes = getRequiredMcpScopes(toolName); const hasLegacyMcpToolsScope = authInfo.scopes.includes( LEGACY_MCP_TOOLS_SCOPE, @@ -156,7 +165,7 @@ export async function authenticateMcpRequest( const diagnostics: McpAuthFailureDiagnostics = { authFailureKind: "missing_required_tool_scope", authHeaderKind: "bearer", - authTokenFormat: tokenFormat(token), + authTokenFormat: tokenFormat(authInfo.token), requiredScopeCount: requiredScopes.length, scopeCount: authInfo.scopes.length, tokenResourceMatchesMcp: true, diff --git a/src/lib/mcp/observability-summary.ts b/src/lib/mcp/observability-summary.ts index 8a17ff8a5..142cdfb38 100644 --- a/src/lib/mcp/observability-summary.ts +++ b/src/lib/mcp/observability-summary.ts @@ -38,6 +38,10 @@ export async function summarizeMcpJsonRpcRequest( return emptySummary("invalid-json"); } + return summarizeMcpJsonRpcBody(body); +} + +export function summarizeMcpJsonRpcBody(body: unknown): McpRequestSummary { const messages = Array.isArray(body) ? body : [body]; if (messages.length === 0) { return emptySummary("empty"); diff --git a/src/lib/mcp/observability.ts b/src/lib/mcp/observability.ts index ba51495da..34a4597b8 100644 --- a/src/lib/mcp/observability.ts +++ b/src/lib/mcp/observability.ts @@ -1,2 +1,5 @@ -export { summarizeMcpJsonRpcRequest } from "@/lib/mcp/observability-summary"; +export { + summarizeMcpJsonRpcBody, + summarizeMcpJsonRpcRequest, +} from "@/lib/mcp/observability-summary"; export type { McpRequestSummary } from "@/lib/mcp/observability-types"; diff --git a/src/lib/mcp/request-body.ts b/src/lib/mcp/request-body.ts new file mode 100644 index 000000000..212e5c8f5 --- /dev/null +++ b/src/lib/mcp/request-body.ts @@ -0,0 +1,82 @@ +export const MCP_REQUEST_BODY_LIMIT_BYTES = 64 * 1024; + +function jsonRpcErrorResponse(status: number, code: number, message: string) { + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + error: { code, message }, + id: null, + }), + { + status, + headers: { "content-type": "application/json; charset=utf-8" }, + }, + ); +} + +export async function readMcpJsonBodyWithinLimit( + request: Request, +): Promise<{ body: unknown } | { response: Response }> { + if ( + request.method !== "POST" || + !request.headers.get("content-type")?.includes("application/json") + ) { + return { body: undefined }; + } + + const declaredLength = request.headers.get("content-length"); + if ( + declaredLength && + /^\d+$/.test(declaredLength) && + Number(declaredLength) > MCP_REQUEST_BODY_LIMIT_BYTES + ) { + return { + response: jsonRpcErrorResponse( + 413, + -32000, + `Request body must not exceed ${MCP_REQUEST_BODY_LIMIT_BYTES} bytes`, + ), + }; + } + + if (!request.body) { + return { + response: jsonRpcErrorResponse(400, -32700, "Parse error: Invalid JSON"), + }; + } + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MCP_REQUEST_BODY_LIMIT_BYTES) { + await reader.cancel(); + return { + response: jsonRpcErrorResponse( + 413, + -32000, + `Request body must not exceed ${MCP_REQUEST_BODY_LIMIT_BYTES} bytes`, + ), + }; + } + chunks.push(value); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + + try { + return { body: JSON.parse(new TextDecoder().decode(bytes)) }; + } catch { + return { + response: jsonRpcErrorResponse(400, -32700, "Parse error: Invalid JSON"), + }; + } +} diff --git a/src/lib/mcp/tool-scopes.ts b/src/lib/mcp/tool-scopes.ts index d2ad94572..290cbe775 100644 --- a/src/lib/mcp/tool-scopes.ts +++ b/src/lib/mcp/tool-scopes.ts @@ -214,20 +214,7 @@ function isToolCallMessage(value: unknown): value is McpJsonRpcMessage & { * The request is cloned before reading so the original body stream remains * available for downstream transport handling. */ -export async function extractMcpToolCallNamesFromRequest( - request: Request, -): Promise { - if (request.method !== "POST") { - return []; - } - - let body: unknown; - try { - body = await request.clone().json(); - } catch { - return []; - } - +export function extractMcpToolCallNames(body: unknown): string[] { const messages = Array.isArray(body) ? body : [body]; const names: string[] = []; for (const message of messages) { @@ -239,6 +226,17 @@ export async function extractMcpToolCallNamesFromRequest( return names; } +export async function extractMcpToolCallNamesFromRequest( + request: Request, +): Promise { + if (request.method !== "POST") return []; + try { + return extractMcpToolCallNames(await request.clone().json()); + } catch { + return []; + } +} + export async function extractMcpToolNamesFromRequest( request: Request, ): Promise { diff --git a/src/lib/metrics/analytics-engine.ts b/src/lib/metrics/analytics-engine.ts index a6915df54..df522e176 100644 --- a/src/lib/metrics/analytics-engine.ts +++ b/src/lib/metrics/analytics-engine.ts @@ -16,6 +16,7 @@ type McpTransportAnalyticsInput = { path: string; phase: | "auth-rejected" + | "body-rejected" | "handled" | "origin-rejected" | "rate-limit-rejected"; diff --git a/src/routes/api/mcp/+server.ts b/src/routes/api/mcp/+server.ts index 576f7ddc3..061653e74 100644 --- a/src/routes/api/mcp/+server.ts +++ b/src/routes/api/mcp/+server.ts @@ -20,6 +20,7 @@ export const GET: RequestHandler = ({ request }) => mcpGetRoute(request); * @response 200 * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema + * @response 413 * @response 429:openApiErrorSchema * @response 503:openApiErrorSchema */ diff --git a/tests/e2e/src/app/api/mcp/transport.test.ts b/tests/e2e/src/app/api/mcp/transport.test.ts index d52aeb401..fb3b99c28 100644 --- a/tests/e2e/src/app/api/mcp/transport.test.ts +++ b/tests/e2e/src/app/api/mcp/transport.test.ts @@ -44,6 +44,45 @@ test.describe("/api/mcp - 传输与授权", () => { await expect(response.json()).resolves.toEqual({ error: "invalid_token" }); }); + test("/api/mcp 在认证后拒绝超过 64 KiB 的请求体", async ({ + page, + request, + }) => { + const oversizedBody = JSON.stringify("x".repeat(65 * 1024)); + const headers = { + "Content-Type": "application/json", + "MCP-Protocol-Version": "2025-03-26", + }; + + const unauthenticatedResponse = await request.post("/api/mcp", { + data: oversizedBody, + headers, + }); + expect(unauthenticatedResponse.status()).toBe(401); + + const resource = `${PLAYWRIGHT_BASE_URL}/api/mcp`; + await signInAsDebugUser(page, "/"); + const { accessToken } = await issueAccessToken(page, request, { + scope: MCP_CLIENT_SCOPE, + clientScopes: MCP_CLIENT_SCOPES, + resource, + }); + + const authenticatedResponse = await request.post("/api/mcp", { + data: oversizedBody, + headers: { + ...headers, + Authorization: `Bearer ${accessToken}`, + }, + }); + expect(authenticatedResponse.status()).toBe(413); + await expect(authenticatedResponse.json()).resolves.toMatchObject({ + error: { code: -32000 }, + id: null, + jsonrpc: "2.0", + }); + }); + test("/api/mcp stateless transport does not hold a GET SSE stream", async ({ request, }) => { diff --git a/tests/e2e/src/app/api/openapi/test.ts b/tests/e2e/src/app/api/openapi/test.ts index fb5061d7b..b8cc9a593 100644 --- a/tests/e2e/src/app/api/openapi/test.ts +++ b/tests/e2e/src/app/api/openapi/test.ts @@ -28,6 +28,7 @@ test.describe("GET /api/openapi - OpenAPI 规范", () => { test("返回有效的 OpenAPI 3.0.0 规范", async ({ request }) => { const response = await request.get("/api/openapi"); expect(response.status()).toBe(200); + expect(response.headers()["cache-control"]).toBe("public, max-age=300"); const body = (await response.json()) as { openapi?: string; info?: { title?: string; version?: string }; diff --git a/tests/unit/auth-core-session-cache.test.ts b/tests/unit/auth-core-session-cache.test.ts new file mode 100644 index 000000000..3c69b92f5 --- /dev/null +++ b/tests/unit/auth-core-session-cache.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getSessionMock = vi.fn(); + +vi.mock("better-auth", () => ({ + betterAuth: () => ({ api: { getSession: getSessionMock } }), +})); + +vi.mock("@/lib/auth/better-auth-options", () => ({ + buildBetterAuthOptions: () => ({}), +})); + +const rawSession = { + session: { id: "session-1" }, + user: { + id: "user-1", + email: "user@example.test", + name: "User", + }, +}; + +describe("request-scoped session resolution", () => { + beforeEach(() => { + vi.resetModules(); + getSessionMock.mockReset(); + }); + + it("shares one Better Auth lookup for the same request headers", async () => { + getSessionMock.mockResolvedValue(rawSession); + const { getSessionFromHeaders } = await import("@/lib/auth/core"); + const headers = new Headers({ + cookie: "better-auth.session_token=session-token", + }); + + const [first, second] = await Promise.all([ + getSessionFromHeaders(headers), + getSessionFromHeaders(headers), + ]); + + expect(first?.user.id).toBe("user-1"); + expect(second).toEqual(first); + expect(getSessionMock).toHaveBeenCalledOnce(); + }); + + it("does not share sessions across different request header objects", async () => { + getSessionMock.mockResolvedValue(rawSession); + const { getSessionFromHeaders } = await import("@/lib/auth/core"); + + await getSessionFromHeaders(new Headers({ cookie: "session-token" })); + await getSessionFromHeaders(new Headers({ cookie: "session-token" })); + + expect(getSessionMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/unit/mcp-request-body.test.ts b/tests/unit/mcp-request-body.test.ts new file mode 100644 index 000000000..d739e7c8f --- /dev/null +++ b/tests/unit/mcp-request-body.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { + MCP_REQUEST_BODY_LIMIT_BYTES, + readMcpJsonBodyWithinLimit, +} from "@/lib/mcp/request-body"; + +function post(body: string, headers: HeadersInit = {}) { + return new Request("https://life.example/api/mcp", { + method: "POST", + headers: { + "content-type": "application/json", + ...headers, + }, + body, + }); +} + +describe("bounded MCP request bodies", () => { + it("parses a valid JSON body once within the limit", async () => { + const result = await readMcpJsonBodyWithinLimit( + post(JSON.stringify({ jsonrpc: "2.0", method: "tools/list" })), + ); + + expect(result).toEqual({ + body: { jsonrpc: "2.0", method: "tools/list" }, + }); + }); + + it("rejects a declared oversized body before reading it", async () => { + const result = await readMcpJsonBodyWithinLimit( + post("{}", { + "content-length": String(MCP_REQUEST_BODY_LIMIT_BYTES + 1), + }), + ); + + expect("response" in result && result.response.status).toBe(413); + }); + + it("rejects a streamed oversized body without content-length", async () => { + const result = await readMcpJsonBodyWithinLimit( + post(`"${"x".repeat(MCP_REQUEST_BODY_LIMIT_BYTES)}"`), + ); + + expect("response" in result && result.response.status).toBe(413); + }); + + it("returns the SDK-compatible parse error shape for invalid JSON", async () => { + const result = await readMcpJsonBodyWithinLimit(post("{")); + + expect("response" in result && result.response.status).toBe(400); + if ("response" in result) { + await expect(result.response.json()).resolves.toMatchObject({ + error: { code: -32700, message: "Parse error: Invalid JSON" }, + id: null, + jsonrpc: "2.0", + }); + } + }); +}); diff --git a/tests/unit/mcp-request-rate-limit.test.ts b/tests/unit/mcp-request-rate-limit.test.ts index 6209238ef..f0f889010 100644 --- a/tests/unit/mcp-request-rate-limit.test.ts +++ b/tests/unit/mcp-request-rate-limit.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const { authenticateMcpRequestMock, + authorizeMcpToolScopesMock, checkUserMutationRateLimitMock, connectMock, handleTransportRequestMock, @@ -10,6 +11,7 @@ const { transportConstructorMock, } = vi.hoisted(() => ({ authenticateMcpRequestMock: vi.fn(), + authorizeMcpToolScopesMock: vi.fn(), checkUserMutationRateLimitMock: vi.fn(), connectMock: vi.fn(), handleTransportRequestMock: vi.fn(), @@ -33,9 +35,11 @@ vi.mock( vi.mock("@/lib/mcp/auth", () => ({ authenticateMcpRequest: authenticateMcpRequestMock, + authorizeMcpToolScopes: authorizeMcpToolScopesMock, })); vi.mock("@/lib/mcp/observability", () => ({ + summarizeMcpJsonRpcBody: summarizeMcpJsonRpcRequestMock, summarizeMcpJsonRpcRequest: summarizeMcpJsonRpcRequestMock, })); @@ -79,6 +83,9 @@ describe("MCP mutation rate limits", () => { beforeEach(() => { vi.resetModules(); authenticateMcpRequestMock.mockReset(); + authorizeMcpToolScopesMock + .mockReset() + .mockImplementation((authInfo) => ({ authInfo })); checkUserMutationRateLimitMock.mockReset(); connectMock.mockReset().mockResolvedValue(undefined); handleTransportRequestMock.mockReset().mockResolvedValue( @@ -87,7 +94,7 @@ describe("MCP mutation rate limits", () => { }), ); recordAndLogMcpResponseMock.mockReset(); - summarizeMcpJsonRpcRequestMock.mockReset().mockResolvedValue({ + summarizeMcpJsonRpcRequestMock.mockReset().mockReturnValue({ methodCounts: { "tools/call": 2 }, toolCallCounts: { create_my_todo: 2 }, }); @@ -147,6 +154,11 @@ describe("MCP mutation rate limits", () => { expect(transportConstructorMock).not.toHaveBeenCalled(); expect(connectMock).not.toHaveBeenCalled(); expect(handleTransportRequestMock).not.toHaveBeenCalled(); + expect(authenticateMcpRequestMock).toHaveBeenCalledWith(request); + expect(authorizeMcpToolScopesMock).toHaveBeenCalledWith( + expect.objectContaining({ extra: { userId: "user-1" } }), + ["create_my_todo"], + ); expect(recordAndLogMcpResponseMock).toHaveBeenCalledWith( expect.objectContaining({ phase: "rate-limit-rejected", @@ -159,7 +171,7 @@ describe("MCP mutation rate limits", () => { }); it("does not consume mutation budgets for read-only tools", async () => { - summarizeMcpJsonRpcRequestMock.mockResolvedValue({ + summarizeMcpJsonRpcRequestMock.mockReturnValue({ methodCounts: { "tools/call": 1 }, toolCallCounts: { list_my_todos: 1 }, }); @@ -184,5 +196,63 @@ describe("MCP mutation rate limits", () => { expect(transportConstructorMock).toHaveBeenCalledOnce(); expect(connectMock).toHaveBeenCalledOnce(); expect(handleTransportRequestMock).toHaveBeenCalledOnce(); + expect(handleTransportRequestMock).toHaveBeenCalledWith( + request, + expect.objectContaining({ + parsedBody: expect.objectContaining({ + method: "tools/call", + params: expect.objectContaining({ name: "list_my_todos" }), + }), + }), + ); + }); + + it("rejects unauthenticated oversized requests before inspecting the body", async () => { + authenticateMcpRequestMock.mockResolvedValue({ + authFailureDiagnostics: { authFailureKind: "missing_bearer" }, + response: new Response(JSON.stringify({ error: "invalid_token" }), { + status: 401, + }), + }); + const request = new Request("https://life.example/api/mcp", { + method: "POST", + headers: { + "content-length": String(65 * 1024), + "content-type": "application/json", + }, + body: "{}", + }); + + const { handleMcpRequest } = await import( + "@/lib/api/routes/mcp-request-handler" + ); + const response = await handleMcpRequest(request); + + expect(response.status).toBe(401); + expect(authorizeMcpToolScopesMock).not.toHaveBeenCalled(); + expect(transportConstructorMock).not.toHaveBeenCalled(); + }); + + it("rejects authenticated oversized requests before scope or SDK handling", async () => { + const request = new Request("https://life.example/api/mcp", { + method: "POST", + headers: { + "content-length": String(65 * 1024), + "content-type": "application/json", + }, + body: "{}", + }); + + const { handleMcpRequest } = await import( + "@/lib/api/routes/mcp-request-handler" + ); + const response = await handleMcpRequest(request); + + expect(response.status).toBe(413); + expect(authorizeMcpToolScopesMock).not.toHaveBeenCalled(); + expect(transportConstructorMock).not.toHaveBeenCalled(); + expect(recordAndLogMcpResponseMock).toHaveBeenCalledWith( + expect.objectContaining({ phase: "body-rejected", status: 413 }), + ); }); }); diff --git a/tests/unit/openapi-route.test.ts b/tests/unit/openapi-route.test.ts new file mode 100644 index 000000000..e66a506f1 --- /dev/null +++ b/tests/unit/openapi-route.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { getOpenApiRoute } from "@/lib/api/routes/openapi"; + +describe("OpenAPI document route", () => { + it("serves the build-time document with public cache headers", async () => { + const response = getOpenApiRoute(); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("public, max-age=300"); + expect(response.headers.get("content-type")).toBe( + "application/json; charset=utf-8", + ); + await expect(response.json()).resolves.toMatchObject({ + openapi: "3.0.0", + info: { title: "Life@USTC API" }, + }); + }); +});