diff --git a/src/app/mcp/route.ts b/src/app/mcp/route.ts index db3b25980..ae4a7dceb 100644 --- a/src/app/mcp/route.ts +++ b/src/app/mcp/route.ts @@ -45,7 +45,11 @@ */ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; -import { createMcpServer, resolveMcpAuthContext } from "@/lib/mcp"; +import { + createMcpServer, + resolveMcpAuthContext, + SCOPE_HEALTH_READ, +} from "@/lib/mcp"; import { withBackgroundEvent } from "@/lib/logging/background"; import { annotate } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; @@ -163,6 +167,14 @@ async function handleMcp(request: Request): Promise { return unauthorized(request); } + // Authorization is distinct from credential validity. A write-only or + // unrelated bearer is still a real token, but it must not reach rate + // limiting, module discovery, server construction, or any health reader. + if (!ctx.canRead) { + annotate({ action: { name: "mcp.auth.insufficient_scope" } }); + return unauthorized(request, SCOPE_HEALTH_READ); + } + // 2. RATE LIMIT — per `:` credential binding. const rl = await checkMcpRateLimit(ctx.binding); if (!rl.allowed) { diff --git a/src/lib/mcp/auth.ts b/src/lib/mcp/auth.ts index 7ed23f852..f144931d8 100644 --- a/src/lib/mcp/auth.ts +++ b/src/lib/mcp/auth.ts @@ -28,7 +28,7 @@ export interface McpAuthContext { scopes: string[]; /** `:` — the session binding (REQ-SEC-11). */ binding: string; - /** Whether this session may read (always true for a valid token). */ + /** Whether this session may read (requires `health:read` or wildcard). */ canRead: boolean; /** Whether this session may write (requires `health:write`; gates the live write tools). */ canWrite: boolean; @@ -61,7 +61,7 @@ export async function resolveMcpAuthContext( tokenId, scopes: permissions, binding: sessionBinding(user.id, tokenId), - canRead: tokenAllowsRead(), + canRead: tokenAllowsRead(permissions), canWrite: tokenAllowsWrite(permissions), }; } diff --git a/src/lib/mcp/scopes.ts b/src/lib/mcp/scopes.ts index 45087e151..7a6d15249 100644 --- a/src/lib/mcp/scopes.ts +++ b/src/lib/mcp/scopes.ts @@ -28,12 +28,15 @@ export const SCOPE_HEALTH_READ = "health:read"; export const SCOPE_HEALTH_WRITE = "health:write"; /** - * Whether a token's scopes admit the read surface. Any valid token qualifies — - * the underlying reads are unscoped — so this is permissive by design and exists - * to document the contract and to give a single place to tighten later. + * Whether a token's scopes admit the read surface. Reads require the explicit + * `health:read` grant or the wildcard; write and unrelated grants never imply + * read access. */ -export function tokenAllowsRead(): boolean { - return true; +export function tokenAllowsRead(permissions: readonly string[]): boolean { + return ( + permissions.includes(SCOPE_WILDCARD) || + permissions.includes(SCOPE_HEALTH_READ) + ); } /**