MCP OAuth Stage 5: withMCPAuth wrapper
Sub-issue of #86. Plugin-provided guard for app-owned MCP routes. Validates bearer tokens, enforces audience binding, and returns the spec-compliant 401 + WWW-Authenticate that closes the discovery loop.
Context
The plugin doesn't own the MCP endpoint itself — the app does (server.http('/mcp', appHandler)). But the spec-mandated 401 + WWW-Authenticate: Bearer resource_metadata="..." contract is non-trivial and security-sensitive; every Harper-MCP app should NOT reimplement it. withMCPAuth wraps the app's handler and enforces it once, centrally. This is the symmetric counterpart to the existing withOAuthValidation wrapper used for human-OAuth-protected routes.
What this adds
withMCPAuth(handler, options?) wrapper exported from @harperfast/oauth. Apps use it as:
server.http('/mcp', withMCPAuth(myMcpHandler));
- Bearer-token extraction from
Authorization: Bearer <jwt> header. Query-string tokens REJECTED per MCP spec.
- JWT verification against the public key(s) from
harper_oauth_mcp_keys (via Stage 4's tokenIssuer.ts).
- Audience validation — JWT
aud claim must equal the configured canonical resource URI (mcp.resource). Mismatch → 401.
- Expiration / not-before validation per RFC 7519.
- Spec-compliant 401 response on any failure:
401 Unauthorized with WWW-Authenticate: Bearer resource_metadata="<absolute URL to PRM>", pointing at Stage 2's /.well-known/oauth-protected-resource.
- Attaches verified token claims to the request as
request.mcp = { sub, client_id, aud, scope } so the app handler can authorize per-user.
Spec requirements (all MUST, MCP 2025-06-18)
- Server MUST respond with
401 + WWW-Authenticate: Bearer resource_metadata="..." on unauthenticated MCP requests (RFC 9728 §5.1)
- Server MUST validate access tokens were issued for them specifically (RFC 8707 §2 — audience binding)
Authorization: Bearer only — no query-string tokens (RFC 6750)
- Token signature, expiration, audience all required
Acceptance
Dependencies
Out of scope
🤖 Generated with Claude Code
MCP OAuth Stage 5:
withMCPAuthwrapperSub-issue of #86. Plugin-provided guard for app-owned MCP routes. Validates bearer tokens, enforces audience binding, and returns the spec-compliant
401 + WWW-Authenticatethat closes the discovery loop.Context
The plugin doesn't own the MCP endpoint itself — the app does (
server.http('/mcp', appHandler)). But the spec-mandated401 + WWW-Authenticate: Bearer resource_metadata="..."contract is non-trivial and security-sensitive; every Harper-MCP app should NOT reimplement it.withMCPAuthwraps the app's handler and enforces it once, centrally. This is the symmetric counterpart to the existingwithOAuthValidationwrapper used for human-OAuth-protected routes.What this adds
withMCPAuth(handler, options?)wrapper exported from@harperfast/oauth. Apps use it as:Authorization: Bearer <jwt>header. Query-string tokens REJECTED per MCP spec.harper_oauth_mcp_keys(via Stage 4'stokenIssuer.ts).audclaim must equal the configured canonical resource URI (mcp.resource). Mismatch → 401.401 UnauthorizedwithWWW-Authenticate: Bearer resource_metadata="<absolute URL to PRM>", pointing at Stage 2's/.well-known/oauth-protected-resource.request.mcp = { sub, client_id, aud, scope }so the app handler can authorize per-user.Spec requirements (all MUST, MCP 2025-06-18)
401 + WWW-Authenticate: Bearer resource_metadata="..."on unauthenticated MCP requests (RFC 9728 §5.1)Authorization: Beareronly — no query-string tokens (RFC 6750)Acceptance
withMCPAuth(handler, options?)exported from@harperfast/oauthAuthorizationheader) returns401+WWW-Authenticate: Bearer resource_metadata="<canonical PRM URL>"Authorization(notBearer <token>) returns 401 with sameWWW-Authenticateexpreturns 401auddoesn't matchmcp.resourcereturns 401request.mcp = { sub, client_id, aud, scope }populated; underlying handler invokedWWW-Authenticateheader value matches the PRM URL Stage 2 servesdocs/configuration.mdDependencies
tokenIssuer.ts)Out of scope
🤖 Generated with Claude Code