Skip to content

MCP auth: OAuth 2.1 + DCR + scopes (closes #45) - #59

Merged
lezama merged 1 commit into
mainfrom
ship/issue-45-oauth-mcp
May 18, 2026
Merged

MCP auth: OAuth 2.1 + DCR + scopes (closes #45)#59
lezama merged 1 commit into
mainfrom
ship/issue-45-oauth-mcp

Conversation

@lezama

@lezama lezama commented May 17, 2026

Copy link
Copy Markdown
Owner

Closes #45

What ships

  • OAuth 2.1 authorization server mounted under /wp-json/openclawp/v1/oauth/:
    • GET /authorize — interactive consent screen, PKCE-required (S256 default, plain tolerated).
    • POST /tokenauthorization_code grant with PKCE verifier; client_secret_basic / client_secret_post / none (public client).
    • POST /introspect — RFC 7662, requires client auth.
    • POST /revoke — RFC 7009, always 200 (no enumeration).
    • POST /register — RFC 7591 Dynamic Client Registration. By default DCR may only request mcp:read / mcp:write (override via openclawp_oauth_allowed_dcr_scopes filter).
    • GET /.well-known/oauth-authorization-server — discovery document.
  • Scope -> effect tiers:
    • mcp:read -> read
    • mcp:write -> read + write
    • mcp:destructive -> read + write + destructive
    • mcp:external -> all four
  • Two-layered scope enforcement in OpenclaWP_Mcp_Rest:
    • tools/list is filtered so clients only see what they can call.
    • tools/call hard-gates per-call and returns insufficient_scope when denied.
  • Tokens stored hashed (SHA-256). Plaintext is shown to the caller exactly once at issue time and never persisted. The CPT-backed store is consistent with the existing OpenclaWP_Mcp_Server_Store pattern.
  • Connected Clients admin page (openclaWP -> Connected Clients) lists clients, their scopes, last-used timestamp, and access tokens with revoke buttons. DCR-self-registered clients show up the same way.
  • Audience binding — every token carries the MCP server slug it was issued for; mismatched audience is rejected at request time.

Legacy migration

Legacy bearer-token auth remains live behind an opt-in switch (one minor version):

define( 'OPENCLAWP_MCP_LEGACY_AUTH', true );   // or env var OPENCLAWP_MCP_LEGACY_AUTH=1

When the flag is set, the per-server hashed bearer (from OpenclaWP_Mcp_Server_Store::verify_token) still works, and an error_log() deprecation warning is emitted on every request. With the flag off (the default), legacy bearers are rejected.

Assumed API contracts for the dependent issues

This PR doesn't wait for #40 (effect-tagged abilities) or #42 (mcp-adapter migration). Documented assumptions:

  • From Tool confirmation gate with "always allow" memory #40: OpenclaWP_Oauth_Scope::effect_for_ability( $name ) resolves an ability's effect tier. Today it runs a name-based heuristic (delete-*/destroy-* -> destructive, update-*/create-* -> write, send-whatsapp/send-sms -> external, default read) and passes that through the openclawp_ability_effect filter. When Tool confirmation gate with "always allow" memory #40 lands, the registrar should hook that filter (or extend OpenclaWP_Oauth_Scope::effect_for_ability to read ability->get_meta('effect') directly) so the explicit metadata wins over the heuristic. No call-site changes needed downstream — the contract is (string $ability_name) -> string $effect.
  • From Migrate per-agent MCP server to Abilities + official mcp-adapter #42: this PR continues to mount its REST permission callback on the existing OpenclaWP_Mcp_Rest route. When Migrate per-agent MCP server to Abilities + official mcp-adapter #42 swaps the JSON-RPC body handling to delegate to the official WordPress/mcp-adapter, the only requirement is that the adapter's request entry point either reuses OpenclaWP_Mcp_Rest::check_permission or calls into OpenclaWP_Oauth_Store::find_token_by_value( $bearer, KIND_ACCESS ) itself and consults OpenclaWP_Oauth_Scope::scopes_permit_effect( $scopes, $effect ) before invoking an ability. The token table is the source of truth for both paths.

Merge order doesn't matter — #40 / #42 can land before or after.

Dependency-weight analysis (league/oauth2-server)

Evaluated league/oauth2-server as the issue suggested. Not adopted in v1:

  • It pulls ~12 transitive packages (psr/http-message, psr/http-server-handler, psr/http-factory, defuse/php-encryption, lcobucci/jwt, lcobucci/clock, lcobucci/parser, lcobucci/signer, nyholm/psr7, nyholm/psr7-server, paragonie/random_compat, symfony/polyfill-php83). ~1.5MB in vendor/, no Composer prefixing — collides if a host site has a different psr/http-message version.
  • We need a narrow slice: authorization_code + PKCE + DCR + introspection + revocation. The bespoke implementation here is ~900 lines of PHP across 4 well-scoped classes, easy to audit, no PSR-7 / JWT machinery.
  • The library expects you to hand it PSR-7 requests; the WP REST API gives you WP_REST_Request. Bridging both ways added more code than the bespoke server.
  • Refresh-token rotation (deferred to follow-up) is the only feature where the library would have saved work; we'll re-evaluate when we add it.

The trade-off is documented in OpenclaWP_Oauth_Server's class-level docblock.

Deferred to follow-up (out of v1 scope)

  • Refresh-token rotation — issue scope mentions it, but it's only useful once the chosen access-token TTL (currently 1h) is short enough that the UX requires automatic refresh. With 1h tokens and the consent UX, the value-add for v1 is marginal.
  • /.well-known/oauth-protected-resource — the MCP server side of the discovery story. The authorization server doc is live; the protected-resource doc that points clients back at the AS can be a follow-up since current MCP clients (Claude Desktop, Cursor, VS Code MCP) all support DCR + the AS discovery doc alone.
  • Per-(user, client) memory — the issue mentions remembering consent across sessions. Today every /authorize shows the consent screen. Adding a remembered-consent table is a small follow-up.

Validation

  • php -l clean on all changed files.
  • vendor/bin/phpunit --testsuite unit -> 44 tests / 91 assertions (existing 33 + 11 new in OauthScopeTest).
  • npm run lint clean (no JS changes).
  • tests/smoke.php extended with a full OAuth flow integration test:
    1. fetches the discovery doc
    2. DCRs a public client
    3. mints an mcp:read token via the full auth-code + PKCE exchange
    4. introspects it
    5. lists tools — asserts the read-scoped tool surfaces and the write tool is hidden
    6. calls the write tool with mcp:read -> asserts insufficient_scope
    7. calls the read tool with mcp:read -> asserts success
    8. mints mcp:write -> asserts the write tool now succeeds
    9. revokes the write token and asserts tools/list is rejected (401)

Test plan

  • vendor/bin/phpunit --testsuite unit passes
  • studio wp eval-file tests/smoke.php passes inside a Studio site with openclaWP active
  • Open Claude Desktop, point at the MCP endpoint, complete DCR + consent for mcp:read -> see only read-effect tools in the catalog
  • Try invoking a delete-* ability with that token -> client reports the tool isn't available, server logs insufficient_scope
  • Visit openclaWP -> Connected Clients, revoke the client's token -> next call from Claude returns 401 within a minute
  • Set OPENCLAWP_MCP_LEGACY_AUTH=1 and re-test with the legacy bearer from OpenclaWP_Mcp_Server_Store -> still works, error_log shows the deprecation

Adds an OAuth 2.1 authorization server for the per-agent MCP endpoint, plus
scope-based ability gating, RFC 7591 Dynamic Client Registration, RFC 7662
introspection, RFC 7009 revocation, and a Connected Clients admin page. The
legacy bearer-token auth path stays live behind OPENCLAWP_MCP_LEGACY_AUTH for
one minor version (logs a deprecation per request).

Scope -> effect tiers:
  mcp:read         -> read
  mcp:write        -> read + write
  mcp:destructive  -> read + write + destructive
  mcp:external     -> read + write + destructive + external

Scope enforcement is two-layered: tools/list is filtered so clients only see
what they can call, and tools/call hard-gates per-call. Tokens are stored
hashed (SHA-256); plaintext is shown once.

Refresh-token rotation and .well-known/oauth-protected-resource are deferred
to a follow-up — issue body documents the cut.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lezama
lezama force-pushed the ship/issue-45-oauth-mcp branch from 81a86d9 to 399b1cd Compare May 18, 2026 11:11
@lezama
lezama merged commit fc79a8b into main May 18, 2026
5 of 6 checks passed
@lezama
lezama deleted the ship/issue-45-oauth-mcp branch May 18, 2026 11:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OAuth 2.1 + Dynamic Client Registration for the MCP endpoint

1 participant