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
1 change: 1 addition & 0 deletions docs/contracts/mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions docs/contracts/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
3 changes: 3 additions & 0 deletions public/openapi.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -1530,6 +1530,9 @@
}
}
},
"413": {
"description": "Response 413"
},
"429": {
"description": "Error response",
"headers": {
Expand Down
57 changes: 46 additions & 11 deletions src/lib/api/routes/mcp-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/lib/api/routes/mcp-request-logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export function logMcpTransportResponse({
durationMs: number;
phase:
| "auth-rejected"
| "body-rejected"
| "handled"
| "origin-rejected"
| "rate-limit-rejected";
Expand Down
1 change: 1 addition & 0 deletions src/lib/api/routes/mcp-response-bookkeeping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function recordAndLogMcpResponse(input: {
};
phase:
| "auth-rejected"
| "body-rejected"
| "handled"
| "origin-rejected"
| "rate-limit-rejected";
Expand Down
16 changes: 9 additions & 7 deletions src/lib/api/routes/openapi.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
}
16 changes: 14 additions & 2 deletions src/lib/auth/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ function createAuthInstance() {
type BetterAuthInstance = ReturnType<typeof createAuthInstance>;

let authInstance: BetterAuthInstance | undefined;
const sessionByHeaders = new WeakMap<Headers, Promise<AppSession | null>>();

function getAuthInstance(): BetterAuthInstance {
if (!authInstance) {
Expand Down Expand Up @@ -44,6 +45,17 @@ export const betterAuthInstance = new Proxy({} as BetterAuthInstance, {
export async function getSessionFromHeaders(
headers: Headers,
): Promise<AppSession | null> {
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;
}
}
5 changes: 0 additions & 5 deletions src/lib/graphql/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion src/lib/mcp/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/lib/mcp/observability-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
5 changes: 4 additions & 1 deletion src/lib/mcp/observability.ts
Original file line number Diff line number Diff line change
@@ -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";
82 changes: 82 additions & 0 deletions src/lib/mcp/request-body.ts
Original file line number Diff line number Diff line change
@@ -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"),
};
}
}
26 changes: 12 additions & 14 deletions src/lib/mcp/tool-scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
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) {
Expand All @@ -239,6 +226,17 @@ export async function extractMcpToolCallNamesFromRequest(
return names;
}

export async function extractMcpToolCallNamesFromRequest(
request: Request,
): Promise<string[]> {
if (request.method !== "POST") return [];
try {
return extractMcpToolCallNames(await request.clone().json());
} catch {
return [];
}
}

export async function extractMcpToolNamesFromRequest(
request: Request,
): Promise<string[]> {
Expand Down
1 change: 1 addition & 0 deletions src/lib/metrics/analytics-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type McpTransportAnalyticsInput = {
path: string;
phase:
| "auth-rejected"
| "body-rejected"
| "handled"
| "origin-rejected"
| "rate-limit-rejected";
Expand Down
1 change: 1 addition & 0 deletions src/routes/api/mcp/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Loading