Skip to content

Commit fc79a8b

Browse files
lezamaclaude
andauthored
MCP auth: OAuth 2.1 + DCR + scopes (closes #45) (#59)
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>
1 parent c1a600d commit fc79a8b

8 files changed

Lines changed: 2256 additions & 13 deletions

includes/class-openclawp-bootstrap.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ public static function init(): void {
3333
add_action( 'init', array( 'OpenclaWP_Decisions_Store', 'register_post_type' ), 5 );
3434
add_action( 'init', array( 'OpenclaWP_Custom_Tools_Store', 'register_post_type' ), 5 );
3535
add_action( 'init', array( 'OpenclaWP_Knowledge_Base_Schema', 'maybe_install' ), 5 );
36+
add_action( 'init', array( 'OpenclaWP_Oauth_Store', 'register_post_types' ), 5 );
3637
add_action( 'init', array( __CLASS__, 'register_blocks' ), 10 );
3738
OpenclaWP_Agent_Registrar::register();
3839
OpenclaWP_Routine_Registrar::register();
@@ -55,6 +56,7 @@ public static function init(): void {
5556
if ( self::legacy_mcp_enabled() ) {
5657
OpenclaWP_Mcp_Rest::register();
5758
}
59+
OpenclaWP_Oauth_Server::register();
5860
OpenclaWP_Rest::register();
5961
OpenclaWP_Decisions_Rest::register();
6062
OpenclaWP_Agenttic_Bridge::register();
@@ -72,6 +74,7 @@ public static function init(): void {
7274
OpenclaWP_Decisions_Admin::register();
7375
OpenclaWP_Custom_Tools_Admin::register();
7476
OpenclaWP_Knowledge_Base_Admin::register();
77+
OpenclaWP_Oauth_Admin::register();
7578
}
7679

7780
/**

includes/class-openclawp-mcp-rest.php

Lines changed: 127 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,21 @@
77
* `initialize`, `tools/list`, `tools/call`. Resource and prompt
88
* primitives return JSON-RPC error -32601 with a follow-up pointer.
99
*
10-
* Auth: bearer token compared against the per-server hash from
11-
* `OpenclaWP_Mcp_Server_Store`. Admins logged into wp-admin with
12-
* `manage_options` are allowed through without a token so a quick
13-
* curl/browser test from the same session works.
10+
* Auth (default — issue #45):
11+
* - OAuth 2.1 bearer token issued by `OpenclaWP_Oauth_Server`.
12+
* - Token must be active, unexpired, unrevoked, and bound to this MCP
13+
* server slug (audience binding — analogue of RFC 8707).
14+
* - Token scope decides which abilities the client may call; the
15+
* `tools/list` response is filtered to match (clients only see what
16+
* they can call) and `tools/call` is hard-gated per-call.
17+
*
18+
* Auth (legacy — opt-in via `define( 'OPENCLAWP_MCP_LEGACY_AUTH', true )`):
19+
* - Bearer token compared against the per-server hash from
20+
* `OpenclaWP_Mcp_Server_Store`. Logs a deprecation `error_log()` per
21+
* request so the site owner notices the warning in real time.
22+
*
23+
* Admins logged into wp-admin with `manage_options` are allowed through
24+
* without a token so a curl from the same session works.
1425
*
1526
* @package OpenclaWP
1627
*/
@@ -67,7 +78,38 @@ public static function check_permission( \WP_REST_Request $request ): bool {
6778
if ( '' === $presented ) {
6879
return false;
6980
}
70-
return OpenclaWP_Mcp_Server_Store::verify_token( $server, $presented );
81+
82+
// OAuth 2.1 path (default).
83+
$token = OpenclaWP_Oauth_Store::find_token_by_value( $presented, OpenclaWP_Oauth_Store::KIND_ACCESS );
84+
if ( null !== $token ) {
85+
// Audience binding: token must match this MCP server slug.
86+
if ( OpenclaWP_Oauth_Store::token_mcp_server_slug( $token ) !== $server->post_name ) {
87+
return false;
88+
}
89+
OpenclaWP_Oauth_Store::touch_token( $token );
90+
$request->set_param( '_openclawp_oauth_token_id', $token->ID );
91+
return true;
92+
}
93+
94+
// Legacy bearer path — opt-in via constant.
95+
if ( self::legacy_auth_enabled() ) {
96+
if ( OpenclaWP_Mcp_Server_Store::verify_token( $server, $presented ) ) {
97+
// Surface a deprecation warning per the migration plan.
98+
error_log( 'openclaWP: legacy bearer MCP auth in use (set OPENCLAWP_MCP_LEGACY_AUTH=false once OAuth is rolled out)' );
99+
$request->set_param( '_openclawp_oauth_legacy', 1 );
100+
return true;
101+
}
102+
}
103+
104+
return false;
105+
}
106+
107+
private static function legacy_auth_enabled(): bool {
108+
if ( defined( 'OPENCLAWP_MCP_LEGACY_AUTH' ) ) {
109+
return (bool) constant( 'OPENCLAWP_MCP_LEGACY_AUTH' );
110+
}
111+
$env = getenv( 'OPENCLAWP_MCP_LEGACY_AUTH' );
112+
return false !== $env && '' !== $env && '0' !== $env && 'false' !== strtolower( (string) $env );
71113
}
72114

73115
/**
@@ -102,7 +144,7 @@ public static function handle( \WP_REST_Request $request ) {
102144
$is_notification = ! array_key_exists( 'id', $rpc );
103145

104146
return self::with_deprecation_headers(
105-
self::dispatch( $server, $id, $method, $params, $is_notification )
147+
self::dispatch( $server, $id, $method, $params, $is_notification, $request )
106148
);
107149
}
108150

@@ -147,7 +189,7 @@ private static function log_deprecation_once( \WP_REST_Request $request ): void
147189
}
148190
}
149191

150-
private static function dispatch( \WP_Post $server, $id, string $method, array $params, bool $is_notification ): \WP_REST_Response {
192+
private static function dispatch( \WP_Post $server, $id, string $method, array $params, bool $is_notification, \WP_REST_Request $request ): \WP_REST_Response {
151193
try {
152194
switch ( $method ) {
153195
case 'initialize':
@@ -157,10 +199,10 @@ private static function dispatch( \WP_Post $server, $id, string $method, array $
157199
return new \WP_REST_Response( null, 202 );
158200

159201
case 'tools/list':
160-
return self::ok( $id, self::tools_list_result( $server ) );
202+
return self::ok( $id, self::tools_list_result( $server, $request ) );
161203

162204
case 'tools/call':
163-
$result = self::tools_call_result( $server, $params );
205+
$result = self::tools_call_result( $server, $params, $request );
164206
if ( $result instanceof \WP_REST_Response ) {
165207
return $result;
166208
}
@@ -207,20 +249,66 @@ private static function initialize_result( \WP_Post $server ): array {
207249
);
208250
}
209251

210-
private static function tools_list_result( \WP_Post $server ): array {
252+
private static function tools_list_result( \WP_Post $server, ?\WP_REST_Request $request = null ): array {
211253
$agent_slug = OpenclaWP_Mcp_Server_Store::agent_slug( $server );
212254
$agent = function_exists( 'wp_get_agent' ) ? wp_get_agent( $agent_slug ) : null;
213255
if ( null === $agent ) {
214256
return array( 'tools' => array() );
215257
}
216258
$allowlist = OpenclaWP_Mcp_Server_Store::tool_allowlist( $server );
217-
return array( 'tools' => OpenclaWP_Mcp_Tool_Translator::translate( $agent, $allowlist ) );
259+
$tools = OpenclaWP_Mcp_Tool_Translator::translate( $agent, $allowlist );
260+
261+
// Filter by token scope. If the caller is an admin session or legacy
262+
// bearer client, show everything.
263+
$scopes = self::request_scopes( $request );
264+
if ( null === $scopes ) {
265+
return array( 'tools' => $tools );
266+
}
267+
268+
$resolved = OpenclaWP_Tools_Resolver::for_agent( $agent );
269+
$name_map = (array) ( $resolved['name_to_ability'] ?? array() );
270+
$filtered = array();
271+
foreach ( $tools as $tool ) {
272+
$ability_name = (string) ( $name_map[ $tool['name'] ] ?? '' );
273+
if ( '' === $ability_name ) {
274+
// Subagent delegations route through `agents/chat`. They inherit
275+
// the parent's effect tier — treat as write to be conservative.
276+
$effect = OpenclaWP_Oauth_Scope::EFFECT_WRITE;
277+
} else {
278+
$effect = OpenclaWP_Oauth_Scope::effect_for_ability( $ability_name );
279+
}
280+
if ( OpenclaWP_Oauth_Scope::scopes_permit_effect( $scopes, $effect ) ) {
281+
$filtered[] = $tool;
282+
}
283+
}
284+
return array( 'tools' => $filtered );
285+
}
286+
287+
/**
288+
* Return the granted scopes for the current request, or null when the
289+
* caller wasn't authenticated via OAuth (admin session / legacy bearer).
290+
*
291+
* @return array<int, string>|null
292+
*/
293+
private static function request_scopes( ?\WP_REST_Request $request ): ?array {
294+
if ( null === $request ) {
295+
return null;
296+
}
297+
$token_id = (int) $request->get_param( '_openclawp_oauth_token_id' );
298+
if ( $token_id <= 0 ) {
299+
return null;
300+
}
301+
$token = get_post( $token_id );
302+
if ( ! $token instanceof \WP_Post ) {
303+
return null;
304+
}
305+
return OpenclaWP_Oauth_Store::token_scopes( $token );
218306
}
219307

220308
/**
221309
* @return array{content:array, isError:bool}|\WP_REST_Response
222310
*/
223-
private static function tools_call_result( \WP_Post $server, array $params ) {
311+
private static function tools_call_result( \WP_Post $server, array $params, ?\WP_REST_Request $request = null ) {
224312
$name = isset( $params['name'] ) ? (string) $params['name'] : '';
225313
if ( '' === $name ) {
226314
return new \WP_REST_Response(
@@ -248,6 +336,33 @@ private static function tools_call_result( \WP_Post $server, array $params ) {
248336
);
249337
}
250338

339+
// Scope enforcement (OAuth path only — admin sessions / legacy bearer
340+
// bypass scopes since they had no scope grant).
341+
$scopes = self::request_scopes( $request );
342+
if ( null !== $scopes ) {
343+
$name_map = (array) ( $resolved['name_to_ability'] ?? array() );
344+
$ability_name = (string) ( $name_map[ $name ] ?? '' );
345+
$effect = '' === $ability_name
346+
? OpenclaWP_Oauth_Scope::EFFECT_WRITE
347+
: OpenclaWP_Oauth_Scope::effect_for_ability( $ability_name );
348+
if ( ! OpenclaWP_Oauth_Scope::scopes_permit_effect( $scopes, $effect ) ) {
349+
return array(
350+
'isError' => true,
351+
'content' => array(
352+
array(
353+
'type' => 'text',
354+
'text' => sprintf(
355+
'insufficient_scope: tool `%s` requires effect `%s`, your token grants scopes [%s]',
356+
$name,
357+
$effect,
358+
implode( ' ', $scopes )
359+
),
360+
),
361+
),
362+
);
363+
}
364+
}
365+
251366
$executor = new OpenclaWP_Tool_Executor(
252367
(array) ( $resolved['name_to_ability'] ?? array() ),
253368
(array) ( $resolved['delegate_targets'] ?? array() )

0 commit comments

Comments
 (0)