Replace API-key MCP auth with the goodviews OAuth 2.1 + PKCE stack: the token is bound to one account MCP URL, Inspector discovers the authorization server from RFC 9728 metadata, and GET still requires Bearer.
This skill does not scaffold ExMCP HTTP or a generic OAuth provider.
Those are /bootstrap-mcp and /boruta bootstrap.
Keep Cors-less native ExMCP transport, VaryOrigin, CacheBody, and the
Bandit SSE wrapper from /bootstrap-mcp. Overlay auth, routes, and identity.
Prerequisites
Verify before writing files:
/bootstrap-mcpHTTP stack exists:AuthPlug,VaryOrigin,SSE,SSEConn,CacheBody,MCP.Transport,MCPServeras the HttpPlug handler/boruta bootstrap(or equivalent):Boruta.Oauth, token/authorize controllers,ResourceOwners/bootstrap-accounts:accounts.slug, membership, active-account lookupphx.gen.authsession login
If any of these are missing, run that skill first and stop.
If the app still has RequestContext, AuthSessions, SSEHub, a thin
Handler/RequestHandler, or a CorsPlug that intercepts notifications,
finish the /bootstrap-mcp 1.1 rewrite first.
App Name Detection
Detect OTP app and modules from mix.exs. Replace MyApp / my_app /
MyAppWeb / my_app_web in every reference template.
What changes
GET /.well-known/oauth-protected-resource/accounts/:slug/mcp
GET /.well-known/oauth-authorization-server
POST /oauth/register
GET /oauth/authorize # login → consent → code (PKCE S256)
POST /oauth/token # code/refresh; resource must match
GET|POST|DELETE|OPTIONS /accounts/:slug/mcp
Inspector
→ 401 + WWW-Authenticate resource_metadata=…
→ GET protected-resource metadata
→ DCR (loopback PKCE client)
→ authorize + consent
→ token (resource = account MCP URL)
→ GET/POST /accounts/:slug/mcp Authorization: Bearer
| Rule | Why |
|---|---|
Resource = issuer/accounts/:slug/mcp |
RFC 8707. A token for account A cannot call account B. |
Scopes mcp:read mcp:write mcp:admin |
Independent least privilege. Admin does not imply read or write. |
| PKCE S256 only | Public MCP clients (Inspector, Claude, Cursor). |
Cookies never accepted on /mcp |
EventSource cannot send Bearer; GET still requires Bearer plus a native session. |
| Guests / strangers → generic 404 | Do not leak whether the account exists. |
| Consent remembered only for unique client identity | Shared loopback client always re-prompts. |
Hex oauth_enabled only on HTTPS issuers |
1.1.1 HttpPlug.init/1 requires absolute HTTPS :resource. HTTP localhost keeps step-up in AuthPlug. |
Phase 0: Discovery
Record:
- App modules, issuer host, login path
- Existing
/mcproute andAuthPlug(API key vs already OAuth) - Boruta controllers and
ResourceOwnersmodule - Account lookup (
get_active_account_by_slug/1or equivalent) - Membership check (
full_member?/ role inowner|admin|member) - Whether
oauth_tokensalready hasresource
Present Existing / Missing / Account lookup. Ask before replacing a
working OAuth MCP stack.
Implementation Order
Execute in this order. Read the linked file before writing those modules.
Phase 1: Config + data
:my_app, :oauthconfig (issuer, seeded public client, three MCP scopes, DCR allowlist + limits)- Migration:
oauth_tokens.resource(nullable),oauth_consentsunique on[:user_id, :client_id, :resource],oauth_rate_limits MyApp.Oauth.TokenResourceandMyApp.Oauth.ConsentMyApp.Oauth(URLs, metadata, DCR, resource validation, strictparse_scope/1)MyApp.Oauth.Provisioning— idempotent scopes + optional local client. Call it from a release task or test setup, notApplication.start/2
Phase 2: Authorization server
- Overlay Boruta authorize: require
response_type=code,code_challenge_method=S256, validresource, full membership, then consent orauthorize. Remembered consent only whenOauth.unique_identity?/1 - Overlay token: reject when
resourcedoes not match the stamped code/refresh POST /oauth/register(RFC 7591, exact vendor redirects, loopback except port, rate limits, body limit)- RFC 9728 + RFC 8414 metadata (path-inserted and root)
- Introspect JSON includes
iss,scope,exp,aud= bound resource OauthCorson discovery, DCR, and token
Phase 3: Consent
Oauth.ConsentLiveat/oauth/consent(not an account picker — theresourcequery already selected the account)
Phase 4: MCP overlay
LoadAccountplug (unknown slug → generic 404)- Rewrite
AuthPlug: Bearer access token on GET/POST/DELETE, resource match, at least one MCP scope, membership; GET SSE still uses the Bandit wrapper after Bearer MCP.Authorizationtool-to-scope policy; filtertools/listMCP.OauthAdapter— per-request Hex OAuth opts (HTTPS only)RequestContextis gone —handler_optspass user+account+scopesErrorWWW-Authenticateincludesresource_metadataand the exact missing scope(s)- Move routes from
/mcpto/accounts/:account_slug/mcp - Tools read
state.account/state.user(noaccount_idarg)
Phase 5: Tests + clients
Read references/tests.md
MCPCase.obtain_access_token/3(authorize + consent Allow + PKCE)- ConnTest: 401 challenge, GET without Bearer, wrong-account token, missing scope, initialize, notify 202
- Point Inspector at
http://localhost:4000/accounts/<slug>/mcp
Production
Same transport constraints as /bootstrap-mcp (one MCP-serving node,
:legacy_only until clients are proven, privacy-safe logs). Plus:
- Issuer and MCP URLs must be HTTPS in production
- DCR allowlist is exact URIs (plus loopback HTTP). Do not allow
*or prefix matches - Stamp
resourceon every code and access token; never skip the match - Provision scopes/clients through
mix/release, not application boot - Shared loopback client is dev/test only (
provision_local_client: truein those envs). Production startup must not create it - Log
sub+ account slug, not access tokens
See goodviews app/docs/mcp-production-runbook.md for topology, canary
gates, and DCR rate-limit alerts.
Troubleshooting
Read references/troubleshooting.md when
Inspector cannot discover OAuth, tokens work on the wrong account, GET SSE
is 401, or a write tool is hidden from tools/list.
Checklist
-
/bootstrap-mcp1.1 stack,/boruta bootstrap,/bootstrap-accountsin place -
oauth_tokens.resource+oauth_consentsunique on user+client+resource - RFC 9728 + RFC 8414 metadata routes
- DCR allowlist + loopback HTTP except port; PKCE S256 required
- Authorize stamps the code; token stamps the access token; both match
resource - Consent LiveView
#oauth-consent-allow/#oauth-consent-deny; loopback never remembered -
/accounts/:slug/mcp(old/mcpremoved) - AuthPlug: Bearer on GET/POST/DELETE + resource + MCP scopes + membership
-
handler_optsis user+account+scopes; tools do not takeaccount_id -
mcp:read/mcp:write/mcp:admin; no productionmcp:tools - Provisioning outside
Application.start/2 - ConnTest: 401 challenge, GET Bearer, wrong-account token, initialize
- Inspector Streamable HTTP against
/accounts/<slug>/mcp