fix(01-05): enforce MCP health read scope

- require health:read or wildcard for MCP read capability
- reject insufficient scopes before downstream gates and server construction
This commit is contained in:
Marc-André Bombeck committed 2026-07-29 13:47:46 +02:00
1 parent 8ca6ebd773
commit 20a6731c8b
3 files changed
+23 -8

No files matched your search

+13 -1
View File
@@ -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<Response> {
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 `<userId>:<tokenId>` credential binding.
const rl = await checkMcpRateLimit(ctx.binding);
if (!rl.allowed) {
+2 -2
View File
@@ -28,7 +28,7 @@ export interface McpAuthContext {
scopes: string[];
/** `<user_id>:<token_id>` — 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),
};
}
+8 -5
View File
@@ -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)
);
}
/**