/** * MCP Bearer-Token Guard (`withMCPAuth`) * * Wraps an app-owned MCP route handler so every request must present a valid * RS256 access token minted by this plugin's Stage 4 issuer before the handler * runs. On any failure it returns the spec-mandated * `401 + WWW-Authenticate: Bearer resource_metadata="..."` (RFC 9728 §5.1) that * closes the MCP discovery loop, pointing clients at the Protected Resource * Metadata document Stage 2 serves. This is the bearer-token counterpart to the * cookie/session `withOAuthValidation` wrapper. * * ── Registration (READ THIS — core auth will eat the token otherwise) ──────── * * Harper's core auth is a default-group HTTP middleware that consumes * `Authorization: Bearer` and 401s any token it can't validate as a Harper * *operation* token, stamping `WWW-Authenticate: Basic` (security/auth.ts) — NOT * the Bearer challenge MCP clients require. So withMCPAuth must own the response * for its route and core auth must not run on top of it. * * PRIMARY — register on a urlPath subroute (recommended): * * server.http(withMCPAuth(myMcpHandler), { urlPath: '/mcp' }); * * Harper's routed dispatch (server/middlewareChain.ts) runs ONLY the matched * subroute's chain and returns — the default chain (where core auth lives) * never runs for '/mcp', so the Bearer challenge can't be clobbered. This is * the same isolation the `/.well-known/*` discovery endpoints rely on to stay * unauthenticated. No `path` option and no ordering hint are needed. * * FALLBACK — register in the default group, ahead of core auth: * * server.http(withMCPAuth(myMcpHandler, { path: '/mcp' }), { before: 'authentication' }); * * When the route shares the default chain with auth (no urlPath), pass `path` * so the wrapper guards only that path and calls `next()` for everything else * (otherwise it would 401 unrelated routes), and register with * `{ before: 'authentication' }` so it runs outermost — ahead of core auth. * This mirrors the precedent in Harper's own server/static.ts. In this mode * the wrapped handler MUST terminate the request (not call `next`), or core * auth runs afterward and re-rejects the bearer token. */ import type { Logger, MCPConfig, MCPPublicKeyRecord, Request } from '../../types.ts'; import { type MCPTokenRejectedAuditPayload } from './audit.ts'; /** Minimal surface withMCPAuth needs from a key source (lets tests inject one). */ interface KeySource { getAllPublicKeys(mcpConfig?: MCPConfig): Promise; } export interface WithMCPAuthOptions { /** * Path this guard owns. Only set this for the default-group registration * (no `urlPath`): the wrapper then guards only requests whose pathname is * `path` or a sub-path of it and calls `next()` for everything else. Leave * unset for the urlPath-subroute registration, where Harper has already * scoped the route (and strips the prefix), so every request that reaches * the wrapper should be guarded. */ path?: string; /** * MCP config source, read per request so live config changes apply. Defaults * to the plugin's live config (`OAuthResource.mcpConfig`). */ getConfig?: () => MCPConfig | undefined; /** Logger. Defaults to the plugin logger (`OAuthResource.logger`). */ logger?: Logger; /** * Signing-key source. Defaults to the plugin's `MCPKeyStore` (reads the * published public keys from `harper_oauth_mcp_keys`). Injectable for tests. */ keyStore?: KeySource; /** * Custom denial handler, invoked on every rejection (mirrors * `withOAuthValidation.onValidationError`). Receives the request and a * human-readable reason. If it returns a falsy value the wrapper still * returns its default `401` — a no-return handler can never accidentally * turn a denial into a pass (fail closed by construction). */ onAuthError?: (request: Request, error: string) => any; /** * Audit sink for `oauth.mcp.token.rejected` events, invoked when a *presented* * bearer token is rejected (not for missing-token probes or the pre-token * guards). Defaults to the plugin's `emitMCPAuditEvent`. Injectable for tests. */ emitAudit?: (payload: MCPTokenRejectedAuditPayload) => void; } type HttpListener = (request: Request, next: (req: Request) => any) => any; /** * Wrap an app MCP handler with bearer-token validation. See the module header * for registration. The returned listener has the standard Harper * `(request, next)` shape, so it registers exactly where the unwrapped handler * would. */ export declare function withMCPAuth(handler: HttpListener, options?: WithMCPAuthOptions): HttpListener; export {};