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