PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.7.1
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.7.1
3.7.2 3.7.1 3.7.0 3.6.9 3.6.8 3.6.7 3.6.6 3.6.4 3.6.5 3.6.3 3.6.2 3.6.1 3.6.0 3.5.9 3.5.8 3.5.7 3.5.6 3.5.5 3.5.4 3.5.3 3.5.2 3.5.1 3.5.0 3.4.9 3.4.8 3.4.7 0.2.1 1.6.91 0.2.2 1.6.92 0.2.3 1.6.93 0.2.4 1.6.94 0.2.5 1.6.95 0.2.6 1.6.96 0.2.7 1.6.97 0.2.8 1.6.98 0.2.9 1.6.99 0.3.0 1.7.0 0.3.1 1.7.1 0.3.2 1.7.2 0.3.3 1.7.3 0.3.4 1.7.4 0.3.5 1.7.5 0.3.6 1.7.6 0.4.0 1.7.7 0.4.1 1.7.8 0.4.2 1.7.9 0.4.3 1.8.0 0.4.4 1.8.1 0.4.5 1.8.2 0.4.6 1.8.3 0.4.7 1.8.4 0.4.8 1.8.5 0.4.9 1.8.6 0.5.0 1.8.7 0.5.1 1.8.8 0.5.2 1.8.9 0.5.3 1.9.0 0.5.4 1.9.1 0.5.5 1.9.2 0.5.6 1.9.3 0.5.7 1.9.4 0.5.8 1.9.5 0.5.9 1.9.6 0.6.0 1.9.7 0.6.1 1.9.8 0.6.2 1.9.81 0.6.3 1.9.82 0.6.4 1.9.83 0.6.5 1.9.84 0.6.6 1.9.85 0.6.7 1.9.86 0.6.8 1.9.87 0.6.9 1.9.88 0.7.0 1.9.89 0.7.1 1.9.90 0.7.2 1.9.91 0.7.3 1.9.92 0.7.4 1.9.93 0.7.5 1.9.94 0.7.6 1.9.95 0.7.7 1.9.96 0.7.8 1.9.97 0.7.9 1.9.98 0.8.0 1.9.99 0.8.1 2.0.0 0.8.2 2.0.1 0.8.3 2.0.2 0.8.4 2.0.3 0.8.5 2.0.4 0.8.6 2.0.5 0.8.7 2.0.6 0.8.8 2.0.7 0.8.9 2.0.8 0.9.0 2.0.9 0.9.2 2.1.0 0.9.3 2.1.1 0.9.4 2.1.2 0.9.5 2.1.3 0.9.6 2.1.4 0.9.7 2.1.5 0.9.8 2.1.6 0.9.81 2.1.7 0.9.82 2.1.8 0.9.83 2.1.9 0.9.84 2.2.0 0.9.85 2.2.1 0.9.86 2.2.2 0.9.87 2.2.3 0.9.88 2.2.4 0.9.89 2.2.5 0.9.9 2.2.51 0.9.91 2.2.52 0.9.92 2.2.53 0.9.93 2.2.54 0.9.94 2.2.56 0.9.95 2.2.57 0.9.96 2.2.6 0.9.97 2.2.60 0.9.98 2.2.61 0.9.99 2.2.62 1.0.0 2.2.63 1.0.01 2.2.70 1.0.1 2.2.80 1.0.2 2.2.81 1.0.3 2.2.90 1.0.4 2.2.91 1.0.5 2.2.92 1.0.6 2.2.93 1.0.7 2.2.94 1.0.8 2.2.95 1.0.9 2.3.0 1.1.0 2.3.1 1.1.1 2.3.2 1.1.2 2.3.3 1.1.3 2.3.4 1.1.4 2.3.5 1.1.5 2.3.6 1.1.6 2.3.7 1.1.7 2.3.8 1.1.8 2.3.9 1.1.9 2.4.0 1.2.0 2.4.1 1.2.1 2.4.2 1.2.2 2.4.3 1.2.21 2.4.4 1.2.3 2.4.5 1.2.30 2.4.6 1.3.0 2.4.7 1.3.1 2.4.8 1.3.2 2.4.9 1.3.3 2.5.0 1.3.31 2.5.1 1.3.32 2.5.2 1.3.33 2.5.3 1.3.34 2.5.4 1.3.35 2.5.5 1.3.36 2.5.6 1.3.37 2.5.7 1.3.38 2.5.8 1.3.39 2.5.9 1.3.40 2.6.0 1.3.41 2.6.1 1.3.42 2.6.2 1.3.43 2.6.3 1.3.44 2.6.5 1.3.45 2.6.6 1.3.46 2.6.7 1.3.47 2.6.8 1.3.48 2.6.9 1.3.49 2.7.0 1.3.50 2.7.1 1.3.51 2.7.2 1.3.52 2.7.3 1.3.53 2.7.4 1.3.54 2.7.5 1.3.56 2.7.6 1.3.57 2.7.7 1.3.58 2.7.8 1.3.59 2.7.9 1.3.60 2.8.0 1.3.61 2.8.1 1.3.62 2.8.2 1.3.63 2.8.3 1.3.64 2.8.4 1.3.65 2.8.5 1.3.66 2.8.6 1.3.67 2.8.7 1.3.68 2.8.8 1.3.69 2.8.9 1.3.70 2.9.0 1.3.71 2.9.1 1.3.72 2.9.2 1.3.73 2.9.3 1.3.74 2.9.4 1.3.75 2.9.5 1.3.76 2.9.6 1.3.77 2.9.7 1.3.78 2.9.8 1.3.79 2.9.9 1.3.80 3.0.0 1.3.81 3.0.1 1.3.82 3.0.2 1.3.83 3.0.3 1.3.84 3.0.4 1.3.85 3.0.5 1.3.86 3.0.6 1.3.87 3.0.7 1.3.88 3.0.8 1.3.89 3.0.9 1.3.90 3.1.0 1.3.91 3.1.1 1.3.92 3.1.2 1.3.93 3.1.3 1.3.94 3.1.4 1.3.95 3.1.5 1.3.96 3.1.6 1.3.97 3.1.7 1.3.98 3.1.8 1.3.99 3.1.9 1.4.0 3.2.0 1.4.1 3.2.1 1.4.2 3.2.2 1.4.3 3.2.3 1.4.4 3.2.4 1.4.5 3.2.5 1.4.6 3.2.6 1.4.7 3.2.7 1.4.8 3.2.8 1.4.9 3.2.9 1.5.0 3.3.0 1.5.1 3.3.1 1.5.2 3.3.2 1.5.3 3.3.3 1.5.4 3.3.4 1.5.5 3.3.5 1.5.6 3.3.6 1.5.7 3.3.7 1.5.8 3.3.8 1.5.9 3.3.9 1.6.0 3.4.0 1.6.1 3.4.1 1.6.2 3.4.2 1.6.3 3.4.3 1.6.5 3.4.4 1.6.51 3.4.5 1.6.52 3.4.6 1.6.53 1.6.54 1.6.55 1.6.56 1.6.57 1.6.58 1.6.59 1.6.60 1.6.61 1.6.62 1.6.63 1.6.64 1.6.65 1.6.66 1.6.67 1.6.68 trunk 1.6.69 0.0.1 1.6.70 0.0.2 1.6.71 0.0.3 1.6.72 0.0.4 1.6.73 0.0.5 1.6.74 0.0.6 1.6.75 0.0.7 1.6.76 0.0.8 1.6.77 0.0.9 1.6.78 0.1.0 1.6.79 0.1.1 1.6.81 0.1.2 1.6.82 0.1.3 1.6.83 0.1.4 1.6.84 0.1.5 1.6.85 0.1.6 1.6.86 0.1.7 1.6.87 0.1.8 1.6.88 0.1.9 1.6.89 0.2.0 1.6.90
ai-engine / labs / mcp-oauth.php
ai-engine / labs Last commit date
mcp-core.php 1 week ago mcp-oauth.php 1 week ago mcp-rest.php 2 months ago mcp.conf 1 year ago mcp.php 2 weeks ago model-audit.php 3 weeks ago workspace-mock.html 3 weeks ago wpai-connectors.php 3 months ago wpai-gateway-availability.php 3 months ago wpai-gateway-directory.php 3 months ago wpai-gateway-image-model.php 3 months ago wpai-gateway-model.php 3 months ago wpai-gateway-providers.php 3 months ago wpai-gateway.php 3 months ago
mcp-oauth.php
1131 lines
1 <?php
2
3 if ( !defined( 'ABSPATH' ) ) {
4 exit;
5 }
6
7 /**
8 * AI Engine MCP OAuth 2.1 module.
9 *
10 * Implements OAuth 2.1 with Dynamic Client Registration (RFC 7591),
11 * PKCE (RFC 7636, S256 only), Authorization Server Metadata (RFC 8414),
12 * Protected Resource Metadata (RFC 9728), and Token Revocation (RFC 7009),
13 * matching the MCP authorization specification.
14 *
15 * This module is additive: the legacy static bearer token continues to work
16 * for developer tooling. OAuth is the consumer-facing path used by clients
17 * like Claude Desktop that drive the user through a browser authorize flow.
18 */
19 class Meow_MWAI_Labs_MCP_OAuth {
20 public const DB_VERSION = '1.0.0';
21 /**
22 * States of the `revoked` column on a token row.
23 *
24 * ROTATED exists because a refresh must not kill the access token that was issued
25 * alongside the refresh token. Clients refresh before they switch over, and some
26 * keep using the previous access token for a while afterwards. Revoking the whole
27 * row at that moment made a token with up to an hour of life left start returning
28 * 401 mid-conversation, which clients report as "this connector requires
29 * additional permissions, reconnect it" and which reconnecting never fixes,
30 * because the next refresh does the same thing again. It looks random, it is
31 * entirely server-side, and it has nothing to do with the host.
32 *
33 * A rotated row can no longer refresh, but its access token stays valid until it
34 * expires on its own. REVOKED still means what it says: both halves die at once.
35 */
36 private const TOKEN_REVOKED = 1;
37 private const TOKEN_ROTATED = 2;
38 public const ACCESS_TOKEN_TTL = 3600; // 1 hour
39 public const REFRESH_TOKEN_TTL = 2592000; // 30 days
40 public const AUTH_CODE_TTL = 60; // seconds
41 public const NONCE_ACTION = 'mwai_mcp_oauth_consent';
42
43 private $core;
44 private $mcp;
45 private $namespace = 'mcp/v1';
46 private $logging = false;
47 private $table_clients;
48 private $table_tokens;
49
50 public function __construct( $core, $mcp ) {
51 global $wpdb;
52 $this->core = $core;
53 $this->mcp = $mcp;
54 $this->logging = method_exists( $mcp, 'is_logging_enabled' ) ? $mcp->is_logging_enabled() : false;
55 $this->table_clients = $wpdb->prefix . 'mwai_mcp_oauth_clients';
56 $this->table_tokens = $wpdb->prefix . 'mwai_mcp_oauth_tokens';
57
58 $this->maybe_upgrade_db();
59
60 add_action( 'rest_api_init', [ $this, 'register_routes' ] );
61 add_filter( 'rest_post_dispatch', [ $this, 'add_www_authenticate_header' ], 10, 3 );
62 // WP's REST cookie nonce check silently downgrades cookie-authed users to guest
63 // when no X-WP-Nonce is sent. The browser-driven authorize flow needs the user's
64 // identity from the cookie without a REST nonce, so we re-validate the auth cookie
65 // for that route. CSRF is enforced separately via our own consent nonce on POST.
66 add_filter( 'rest_authentication_errors', [ $this, 'reauth_for_authorize' ], 200 );
67 // Serve well-known metadata at the host root too. RFC 9728/8414 specify the
68 // well-known URI is built by inserting /.well-known/<suffix> between the host
69 // and the path of the resource/issuer, so strict clients query the host root
70 // rather than the nested REST path. Run very early to short-circuit WP's 404.
71 add_action( 'parse_request', [ $this, 'handle_host_root_wellknown' ], 1 );
72 }
73
74 /**
75 * Serve OAuth well-known metadata from the host root. Handles all three URL
76 * shapes that clients use in the wild: bare host-root, host-root + resource
77 * path (RFC strict), and the nested REST path is already covered by the REST
78 * route registration.
79 */
80 public function handle_host_root_wellknown() {
81 $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) $_SERVER['REQUEST_URI'] : '';
82 $path = strtok( $uri, '?' );
83 if ( $path === false || strpos( $path, '/.well-known/' ) !== 0 ) {
84 return;
85 }
86 if ( strpos( $path, '/.well-known/oauth-protected-resource' ) === 0 ) {
87 if ( $this->logging ) {
88 error_log( '[AI Engine MCP OAuth] Host-root PRM hit: ' . $path );
89 }
90 $this->emit_json( $this->protected_resource_metadata() );
91 }
92 if ( strpos( $path, '/.well-known/oauth-authorization-server' ) === 0 ) {
93 if ( $this->logging ) {
94 error_log( '[AI Engine MCP OAuth] Host-root ASM hit: ' . $path );
95 }
96 $this->emit_json( $this->authorization_server_metadata() );
97 }
98 }
99
100 /**
101 * Purge the OAuth discovery URLs from whatever page cache sits in front of WordPress.
102 *
103 * A cache that stored a 404 for these paths keeps serving it long after the plugin
104 * can answer properly, and the way sites get into that state is the plugin being
105 * inactive for a moment: an update, or a manual deactivation. Our no-cache headers
106 * cannot help, because our code is not running when that 404 is produced, and the
107 * result is an MCP connection that fails intermittently for days with nothing wrong
108 * on the site. Purging the two URLs the moment we come back is the only cure.
109 *
110 * Static, and called from a real activation hook, because during activation
111 * WordPress includes the plugin long after plugins_loaded has fired, so none of the
112 * usual module instances exist yet.
113 *
114 * LiteSpeed is handled directly, since that is where this was diagnosed. Any other
115 * cache can listen to mwai_mcp_purge_discovery_urls, which carries the same list.
116 */
117 public static function purge_discovery_cache() {
118 $urls = [
119 home_url( '/.well-known/oauth-protected-resource' ),
120 home_url( '/.well-known/oauth-authorization-server' ),
121 ];
122 foreach ( $urls as $url ) {
123 do_action( 'litespeed_purge_url', $url );
124 }
125 do_action( 'mwai_mcp_purge_discovery_urls', $urls );
126 }
127
128 private function emit_json( $payload ) {
129 status_header( 200 );
130 nocache_headers();
131 header( 'Content-Type: application/json; charset=utf-8' );
132 header( 'Access-Control-Allow-Origin: *' );
133 echo wp_json_encode( $payload );
134 exit;
135 }
136
137 private function protected_resource_metadata() {
138 $issuer = rest_url( $this->namespace );
139 return [
140 'resource' => rest_url( $this->namespace . '/http' ),
141 'authorization_servers' => [ $issuer ],
142 'bearer_methods_supported' => [ 'header' ],
143 'scopes_supported' => [ 'mcp' ],
144 'resource_documentation' => 'https://meowapps.com/ai-engine/',
145 ];
146 }
147
148 private function authorization_server_metadata() {
149 $issuer = rest_url( $this->namespace );
150 return [
151 'issuer' => $issuer,
152 'authorization_endpoint' => rest_url( $this->namespace . '/oauth/authorize' ),
153 'token_endpoint' => rest_url( $this->namespace . '/oauth/token' ),
154 'registration_endpoint' => rest_url( $this->namespace . '/oauth/register' ),
155 'revocation_endpoint' => rest_url( $this->namespace . '/oauth/revoke' ),
156 'response_types_supported' => [ 'code' ],
157 'grant_types_supported' => [ 'authorization_code', 'refresh_token' ],
158 'token_endpoint_auth_methods_supported' => [ 'none', 'client_secret_basic', 'client_secret_post' ],
159 'code_challenge_methods_supported' => [ 'S256' ],
160 'scopes_supported' => [ 'mcp' ],
161 ];
162 }
163
164 public function reauth_for_authorize( $result ) {
165 // Match the RESOLVED REST route, exactly. This used to be a substring test against
166 // $_SERVER['REQUEST_URI'], which includes the query string: appending
167 // "?x=/mcp/v1/oauth/authorize" to ANY REST request made this fire, restoring the
168 // cookie user's full identity on a route that WP had deliberately downgraded to
169 // guest for lack of an X-WP-Nonce. That turned every authenticated REST endpoint
170 // into a CSRF sink, e.g. a top-level navigation to /wp/v2/users with _method=POST
171 // creating an administrator (CVE-2026-15988). WP dispatches the request using this
172 // same query var, so an exact comparison against it cannot disagree with the route
173 // that actually runs.
174 $route = isset( $GLOBALS['wp']->query_vars['rest_route'] )
175 ? (string) $GLOBALS['wp']->query_vars['rest_route'] : '';
176 if ( $route === '' ) {
177 return $result;
178 }
179 $route = '/' . trim( $route, '/' );
180 if ( $route !== '/' . $this->namespace . '/oauth/authorize' ) {
181 return $result;
182 }
183 if ( !is_user_logged_in() ) {
184 $user_id = wp_validate_auth_cookie( '', 'logged_in' );
185 if ( $user_id ) {
186 wp_set_current_user( (int) $user_id );
187 }
188 }
189 return $result;
190 }
191
192 #region DB schema
193 private function maybe_upgrade_db() {
194 if ( get_option( 'mwai_mcp_oauth_db_version' ) === self::DB_VERSION ) {
195 return;
196 }
197
198 global $wpdb;
199 $charset_collate = $wpdb->get_charset_collate();
200
201 $sql_clients = "CREATE TABLE {$this->table_clients} (
202 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
203 client_id VARCHAR(64) NOT NULL,
204 client_secret_hash VARCHAR(64) NULL,
205 client_name VARCHAR(255) NULL,
206 redirect_uris LONGTEXT NOT NULL,
207 grant_types VARCHAR(255) NOT NULL DEFAULT 'authorization_code,refresh_token',
208 token_endpoint_auth_method VARCHAR(32) NOT NULL DEFAULT 'none',
209 scope VARCHAR(255) NULL,
210 created DATETIME NOT NULL,
211 PRIMARY KEY (id),
212 UNIQUE KEY client_id (client_id)
213 ) {$charset_collate};";
214
215 $sql_tokens = "CREATE TABLE {$this->table_tokens} (
216 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
217 client_id VARCHAR(64) NOT NULL,
218 user_id BIGINT(20) UNSIGNED NOT NULL,
219 access_token_hash VARCHAR(64) NOT NULL,
220 refresh_token_hash VARCHAR(64) NULL,
221 access_expires DATETIME NOT NULL,
222 refresh_expires DATETIME NULL,
223 scope VARCHAR(255) NULL,
224 created DATETIME NOT NULL,
225 last_used DATETIME NULL,
226 revoked TINYINT(1) NOT NULL DEFAULT 0,
227 PRIMARY KEY (id),
228 KEY access_token_hash (access_token_hash),
229 KEY refresh_token_hash (refresh_token_hash),
230 KEY client_id (client_id),
231 KEY user_id (user_id)
232 ) {$charset_collate};";
233
234 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
235 dbDelta( $sql_clients );
236 dbDelta( $sql_tokens );
237
238 update_option( 'mwai_mcp_oauth_db_version', self::DB_VERSION );
239 }
240 #endregion
241
242 #region Route registration
243 public function register_routes() {
244 // RFC 9728 — Protected Resource Metadata
245 register_rest_route( $this->namespace, '/.well-known/oauth-protected-resource', [
246 'methods' => 'GET',
247 'callback' => [ $this, 'handle_resource_metadata' ],
248 'permission_callback' => '__return_true',
249 ] );
250
251 // RFC 8414 — Authorization Server Metadata
252 register_rest_route( $this->namespace, '/.well-known/oauth-authorization-server', [
253 'methods' => 'GET',
254 'callback' => [ $this, 'handle_as_metadata' ],
255 'permission_callback' => '__return_true',
256 ] );
257
258 // RFC 7591 — Dynamic Client Registration
259 register_rest_route( $this->namespace, '/oauth/register', [
260 'methods' => 'POST',
261 'callback' => [ $this, 'handle_register' ],
262 'permission_callback' => '__return_true',
263 ] );
264
265 // Authorization endpoint (browser-driven, returns HTML or 302)
266 register_rest_route( $this->namespace, '/oauth/authorize', [
267 'methods' => [ 'GET', 'POST' ],
268 'callback' => [ $this, 'handle_authorize' ],
269 'permission_callback' => '__return_true',
270 ] );
271
272 // Token endpoint
273 register_rest_route( $this->namespace, '/oauth/token', [
274 'methods' => 'POST',
275 'callback' => [ $this, 'handle_token' ],
276 'permission_callback' => '__return_true',
277 ] );
278
279 // RFC 7009 — Token Revocation
280 register_rest_route( $this->namespace, '/oauth/revoke', [
281 'methods' => 'POST',
282 'callback' => [ $this, 'handle_revoke' ],
283 'permission_callback' => '__return_true',
284 ] );
285
286 // Admin-only: list active grants
287 register_rest_route( $this->namespace, '/oauth/apps', [
288 'methods' => 'GET',
289 'callback' => [ $this, 'handle_apps_list' ],
290 'permission_callback' => function () {
291 return current_user_can( 'manage_options' );
292 },
293 ] );
294
295 // Admin-only: revoke a grant by id
296 register_rest_route( $this->namespace, '/oauth/apps/(?P<id>\d+)', [
297 'methods' => 'DELETE',
298 'callback' => [ $this, 'handle_apps_revoke' ],
299 'permission_callback' => function () {
300 return current_user_can( 'manage_options' );
301 },
302 ] );
303 }
304 #endregion
305
306 #region Discovery (well-known)
307 public function handle_resource_metadata() {
308 return new WP_REST_Response( $this->protected_resource_metadata(), 200 );
309 }
310
311 public function handle_as_metadata() {
312 return new WP_REST_Response( $this->authorization_server_metadata(), 200 );
313 }
314 #endregion
315
316 #region Dynamic Client Registration (RFC 7591)
317 public function handle_register( WP_REST_Request $request ) {
318 $body = json_decode( $request->get_body(), true );
319 if ( !is_array( $body ) ) {
320 return $this->oauth_error( 'invalid_client_metadata', 'Request body must be JSON.', 400 );
321 }
322
323 $redirect_uris = $body['redirect_uris'] ?? null;
324 if ( !is_array( $redirect_uris ) || empty( $redirect_uris ) ) {
325 return $this->oauth_error( 'invalid_redirect_uri', 'redirect_uris is required and must be a non-empty array.', 400 );
326 }
327 foreach ( $redirect_uris as $uri ) {
328 if ( !is_string( $uri ) || $uri === '' ) {
329 return $this->oauth_error( 'invalid_redirect_uri', 'Each redirect_uri must be a non-empty string.', 400 );
330 }
331 // Light validation — allow http(s) and custom schemes (desktop clients use them).
332 if ( !preg_match( '#^[a-z][a-z0-9+.\-]*://#i', $uri ) ) {
333 return $this->oauth_error( 'invalid_redirect_uri', "redirect_uri must include a scheme: {$uri}", 400 );
334 }
335 }
336
337 $auth_method = isset( $body['token_endpoint_auth_method'] ) ? (string) $body['token_endpoint_auth_method'] : 'none';
338 if ( !in_array( $auth_method, [ 'none', 'client_secret_basic', 'client_secret_post' ], true ) ) {
339 return $this->oauth_error( 'invalid_client_metadata', "Unsupported token_endpoint_auth_method: {$auth_method}", 400 );
340 }
341
342 $grant_types = $body['grant_types'] ?? [ 'authorization_code', 'refresh_token' ];
343 if ( !is_array( $grant_types ) ) {
344 $grant_types = [ 'authorization_code', 'refresh_token' ];
345 }
346 foreach ( $grant_types as $gt ) {
347 if ( !in_array( $gt, [ 'authorization_code', 'refresh_token' ], true ) ) {
348 return $this->oauth_error( 'invalid_client_metadata', "Unsupported grant_type: {$gt}", 400 );
349 }
350 }
351
352 $client_id = $this->random_token( 32 );
353 $client_secret = null;
354 $client_secret_hash = null;
355 if ( $auth_method !== 'none' ) {
356 $client_secret = $this->random_token( 48 );
357 $client_secret_hash = hash( 'sha256', $client_secret );
358 }
359
360 $client_name = isset( $body['client_name'] ) ? sanitize_text_field( (string) $body['client_name'] ) : 'Unnamed MCP Client';
361
362 global $wpdb;
363 $inserted = $wpdb->insert( $this->table_clients, [
364 'client_id' => $client_id,
365 'client_secret_hash' => $client_secret_hash,
366 'client_name' => $client_name,
367 'redirect_uris' => wp_json_encode( array_values( $redirect_uris ) ),
368 'grant_types' => implode( ',', $grant_types ),
369 'token_endpoint_auth_method' => $auth_method,
370 'scope' => 'mcp',
371 'created' => current_time( 'mysql', 1 ),
372 ] );
373 if ( !$inserted ) {
374 return $this->oauth_error( 'server_error', 'Could not persist client registration.', 500 );
375 }
376
377 if ( $this->logging ) {
378 error_log( '[AI Engine MCP OAuth] Registered client: ' . $client_name . ' (' . $client_id . ')' );
379 }
380
381 $this->prune_orphan_clients();
382
383 $response = [
384 'client_id' => $client_id,
385 'client_name' => $client_name,
386 'redirect_uris' => array_values( $redirect_uris ),
387 'grant_types' => $grant_types,
388 'token_endpoint_auth_method' => $auth_method,
389 'client_id_issued_at' => time(),
390 ];
391 if ( $client_secret !== null ) {
392 $response['client_secret'] = $client_secret;
393 $response['client_secret_expires_at'] = 0; // never
394 }
395 return new WP_REST_Response( $response, 201 );
396 }
397 #endregion
398
399 #region Authorize (browser flow)
400 public function handle_authorize( WP_REST_Request $request ) {
401 $method = $request->get_method();
402
403 if ( $method === 'POST' ) {
404 $this->handle_authorize_submit( $request );
405 exit;
406 }
407
408 // GET — render consent page or redirect to login
409 $params = [
410 'response_type' => (string) ( $request->get_param( 'response_type' ) ?? '' ),
411 'client_id' => (string) ( $request->get_param( 'client_id' ) ?? '' ),
412 'redirect_uri' => (string) ( $request->get_param( 'redirect_uri' ) ?? '' ),
413 'state' => (string) ( $request->get_param( 'state' ) ?? '' ),
414 'scope' => (string) ( $request->get_param( 'scope' ) ?? 'mcp' ),
415 'code_challenge' => (string) ( $request->get_param( 'code_challenge' ) ?? '' ),
416 'code_challenge_method' => (string) ( $request->get_param( 'code_challenge_method' ) ?? '' ),
417 // RFC 8707. ChatGPT and other current clients send this; we carry it through the
418 // consent POST so it survives to the token exchange. Deliberately not enforced:
419 // we expose exactly one resource, so a mismatch cannot widen a token's reach, and
420 // rejecting on it would break any client whose idea of the URL differs harmlessly
421 // (a trailing slash, www against apex). It is recorded, and logged when it differs.
422 'resource' => (string) ( $request->get_param( 'resource' ) ?? '' ),
423 ];
424
425 // Log the hit itself. Without this, a client that registers and then stops is
426 // indistinguishable from a client that reached the consent screen and was refused,
427 // because every branch below only renders a page in the user's browser. That
428 // ambiguity has cost several support rounds.
429 if ( $this->logging ) {
430 error_log( '[AI Engine MCP OAuth] → GET /oauth/authorize client_id='
431 . ( $params['client_id'] ?: '(none)' ) . ' redirect_uri=' . ( $params['redirect_uri'] ?: '(none)' )
432 . ' scope=' . $params['scope'] );
433 }
434
435 if ( $params['response_type'] !== 'code' ) {
436 $this->log_authorize_refusal( 'unsupported response_type: ' . ( $params['response_type'] ?: '(none)' ) );
437 $this->render_error_page( 'Unsupported response_type. Only "code" is supported.' );
438 exit;
439 }
440 if ( $params['code_challenge'] === '' || $params['code_challenge_method'] !== 'S256' ) {
441 $this->log_authorize_refusal( 'PKCE missing or not S256 (method: '
442 . ( $params['code_challenge_method'] ?: '(none)' ) . ')' );
443 $this->render_error_page( 'PKCE is required: provide code_challenge and code_challenge_method=S256.' );
444 exit;
445 }
446
447 $client = $this->get_client( $params['client_id'] );
448 if ( !$client ) {
449 $this->log_authorize_refusal( 'unknown client_id ' . ( $params['client_id'] ?: '(none)' ) );
450 $this->render_error_page( 'Unknown client_id. The client must register via Dynamic Client Registration first.' );
451 exit;
452 }
453 if ( !$this->redirect_uri_registered( $client, $params['redirect_uri'] ) ) {
454 $this->log_authorize_refusal( 'redirect_uri not registered for this client: ' . $params['redirect_uri'] );
455 $this->render_error_page( 'redirect_uri does not match any registered URI for this client.' );
456 exit;
457 }
458
459 // Authentication gate — bounce to wp-login.php if not logged in.
460 if ( !is_user_logged_in() ) {
461 $current_url = rest_url( $this->namespace . '/oauth/authorize' );
462 $current_url = add_query_arg( $params, $current_url );
463 wp_safe_redirect( wp_login_url( $current_url ) );
464 exit;
465 }
466
467 $user = wp_get_current_user();
468 // Capability gate. MCP grants administrative tool access by design; allowing a
469 // non-admin to mint an OAuth token would let them act through the MCP layer with
470 // privileges they do not hold in WordPress itself.
471 if ( !$this->user_can_authorize( $user->ID ) ) {
472 if ( $this->logging ) {
473 error_log( '[AI Engine MCP OAuth] ❌ Non-admin user ' . $user->ID . ' tried to authorize client ' . $params['client_id'] );
474 }
475 $this->render_error_page( 'Only administrators can authorize MCP applications on this site.' );
476 exit;
477 }
478
479 $this->render_consent_page( $client, $params, $user );
480 exit;
481 }
482
483 private function log_authorize_refusal( $reason ) {
484 if ( $this->logging ) {
485 error_log( '[AI Engine MCP OAuth] ❌ Authorize refused: ' . $reason );
486 }
487 }
488
489 private function handle_authorize_submit( WP_REST_Request $request ) {
490 if ( !is_user_logged_in() ) {
491 wp_safe_redirect( wp_login_url() );
492 exit;
493 }
494
495 if ( !$this->user_can_authorize( get_current_user_id() ) ) {
496 if ( $this->logging ) {
497 error_log( '[AI Engine MCP OAuth] ❌ Non-admin user ' . get_current_user_id() . ' attempted authorize submit' );
498 }
499 $this->render_error_page( 'Only administrators can authorize MCP applications on this site.' );
500 exit;
501 }
502
503 $nonce = (string) $request->get_param( '_mwai_nonce' );
504 if ( !wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
505 $this->render_error_page( 'Security check failed. Please try again from your application.' );
506 exit;
507 }
508
509 $client_id = (string) $request->get_param( 'client_id' );
510 $redirect_uri = (string) $request->get_param( 'redirect_uri' );
511 $state = (string) ( $request->get_param( 'state' ) ?? '' );
512 $code_challenge = (string) $request->get_param( 'code_challenge' );
513 $code_challenge_method = (string) $request->get_param( 'code_challenge_method' );
514 $scope = (string) ( $request->get_param( 'scope' ) ?? 'mcp' );
515 $action = (string) ( $request->get_param( 'action' ) ?? 'deny' );
516
517 $client = $this->get_client( $client_id );
518 if ( !$client || !$this->redirect_uri_registered( $client, $redirect_uri ) ) {
519 $this->render_error_page( 'Invalid client or redirect_uri.' );
520 exit;
521 }
522
523 if ( $action !== 'approve' ) {
524 $params = [ 'error' => 'access_denied', 'error_description' => 'User denied the request.' ];
525 if ( $state !== '' ) {
526 $params['state'] = $state;
527 }
528 wp_redirect( $this->append_params( $redirect_uri, $params ) );
529 exit;
530 }
531
532 // Generate authorization code and stash everything needed to mint a token later.
533 $code = $this->random_token( 48 );
534 $code_data = [
535 'client_id' => $client_id,
536 'user_id' => get_current_user_id(),
537 'redirect_uri' => $redirect_uri,
538 'code_challenge' => $code_challenge,
539 'code_challenge_method' => $code_challenge_method,
540 'scope' => $scope,
541 'resource' => (string) ( $request->get_param( 'resource' ) ?? '' ),
542 ];
543 if ( $this->logging && $code_data['resource'] !== ''
544 && $code_data['resource'] !== rest_url( $this->namespace . '/http' ) ) {
545 error_log( '[AI Engine MCP OAuth] Client asked for resource ' . $code_data['resource']
546 . ', we serve ' . rest_url( $this->namespace . '/http' ) . '. Accepted anyway.' );
547 }
548 set_transient( $this->auth_code_key( $code ), $code_data, self::AUTH_CODE_TTL );
549
550 $params = [ 'code' => $code ];
551 if ( $state !== '' ) {
552 $params['state'] = $state;
553 }
554
555 if ( $this->logging ) {
556 error_log( '[AI Engine MCP OAuth] Authorized user ' . get_current_user_id() . ' for client ' . $client_id );
557 }
558
559 wp_redirect( $this->append_params( $redirect_uri, $params ) );
560 exit;
561 }
562
563 private function auth_code_key( $code ) {
564 return 'mwai_mcp_oauth_code_' . hash( 'sha256', $code );
565 }
566 #endregion
567
568 #region Token endpoint
569 public function handle_token( WP_REST_Request $request ) {
570 $grant_type = (string) ( $request->get_param( 'grant_type' ) ?? '' );
571
572 // A failing refresh used to be completely silent, which made "the connector stops
573 // working after a while and re-authorizes itself" impossible to diagnose: every
574 // branch below returns a bare OAuth error to a client that reports it as a generic
575 // permission problem. Tokens are never logged, only a short hash prefix so two lines
576 // can be tied to the same grant.
577 if ( $this->logging ) {
578 error_log( '[AI Engine MCP OAuth] → /oauth/token grant_type=' . ( $grant_type ?: '(none)' ) );
579 }
580
581 if ( $grant_type === 'authorization_code' ) {
582 return $this->handle_token_auth_code( $request );
583 }
584 if ( $grant_type === 'refresh_token' ) {
585 return $this->handle_token_refresh( $request );
586 }
587 if ( $this->logging ) {
588 error_log( '[AI Engine MCP OAuth] ❌ Unsupported grant_type: ' . ( $grant_type ?: '(none)' ) );
589 }
590 return $this->oauth_error( 'unsupported_grant_type', 'Supported: authorization_code, refresh_token.', 400 );
591 }
592
593 /**
594 * Short, non-reversible marker for a token, so log lines can be correlated without
595 * ever writing a usable credential to disk.
596 */
597 private function token_marker( $token ) {
598 return substr( hash( 'sha256', (string) $token ), 0, 8 );
599 }
600
601 private function handle_token_auth_code( WP_REST_Request $request ) {
602 $code = (string) ( $request->get_param( 'code' ) ?? '' );
603 $redirect_uri = (string) ( $request->get_param( 'redirect_uri' ) ?? '' );
604 $code_verifier = (string) ( $request->get_param( 'code_verifier' ) ?? '' );
605 $client_id = (string) ( $request->get_param( 'client_id' ) ?? '' );
606
607 if ( $code === '' || $redirect_uri === '' || $code_verifier === '' ) {
608 return $this->oauth_error( 'invalid_request', 'Missing code, redirect_uri, or code_verifier.', 400 );
609 }
610
611 $key = $this->auth_code_key( $code );
612 $code_data = get_transient( $key );
613 if ( !is_array( $code_data ) ) {
614 return $this->oauth_error( 'invalid_grant', 'Authorization code is invalid or expired.', 400 );
615 }
616 // Single-use: delete immediately to prevent replay.
617 delete_transient( $key );
618
619 if ( $code_data['redirect_uri'] !== $redirect_uri ) {
620 return $this->oauth_error( 'invalid_grant', 'redirect_uri mismatch.', 400 );
621 }
622
623 $client = $this->get_client( $code_data['client_id'] );
624 if ( !$client ) {
625 return $this->oauth_error( 'invalid_client', 'Client not found.', 401 );
626 }
627 if ( $client_id !== '' && $client_id !== $client->client_id ) {
628 return $this->oauth_error( 'invalid_client', 'client_id mismatch.', 401 );
629 }
630 if ( !$this->authenticate_client_if_required( $client, $request ) ) {
631 return $this->oauth_error( 'invalid_client', 'Client authentication failed.', 401 );
632 }
633
634 // Verify PKCE.
635 $expected_challenge = rtrim( strtr( base64_encode( hash( 'sha256', $code_verifier, true ) ), '+/', '-_' ), '=' );
636 if ( !hash_equals( (string) $code_data['code_challenge'], $expected_challenge ) ) {
637 return $this->oauth_error( 'invalid_grant', 'PKCE verification failed.', 400 );
638 }
639
640 return $this->issue_token_pair( $client->client_id, (int) $code_data['user_id'], (string) $code_data['scope'] );
641 }
642
643 private function handle_token_refresh( WP_REST_Request $request ) {
644 $refresh_token = (string) ( $request->get_param( 'refresh_token' ) ?? '' );
645 $client_id = (string) ( $request->get_param( 'client_id' ) ?? '' );
646 if ( $refresh_token === '' ) {
647 if ( $this->logging ) {
648 error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected: no refresh_token in the request.' );
649 }
650 return $this->oauth_error( 'invalid_request', 'Missing refresh_token.', 400 );
651 }
652
653 global $wpdb;
654 $hash = hash( 'sha256', $refresh_token );
655 $marker = $this->token_marker( $refresh_token );
656 $row = $wpdb->get_row(
657 $wpdb->prepare(
658 "SELECT * FROM {$this->table_tokens} WHERE refresh_token_hash = %s AND revoked = 0 LIMIT 1",
659 $hash
660 )
661 );
662 if ( !$row ) {
663 if ( $this->logging ) {
664 // Distinguish "never existed" from "already rotated or revoked": the second is the
665 // signature of a client refreshing twice with the same token, which rotation kills.
666 $revoked = $wpdb->get_var( $wpdb->prepare(
667 "SELECT id FROM {$this->table_tokens} WHERE refresh_token_hash = %s LIMIT 1",
668 $hash
669 ) );
670 error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected for token ' . $marker . ': ' . ( $revoked
671 ? 'the grant exists but is revoked (already rotated, or revoked in Connected Apps).'
672 : 'no grant matches this refresh token.' ) );
673 }
674 return $this->oauth_error( 'invalid_grant', 'Refresh token is invalid or revoked.', 400 );
675 }
676 if ( $row->refresh_expires && strtotime( $row->refresh_expires . ' UTC' ) < time() ) {
677 if ( $this->logging ) {
678 error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected for token ' . $marker
679 . ': refresh token expired on ' . $row->refresh_expires . ' UTC.' );
680 }
681 return $this->oauth_error( 'invalid_grant', 'Refresh token expired.', 400 );
682 }
683
684 $client = $this->get_client( $row->client_id );
685 if ( !$client ) {
686 if ( $this->logging ) {
687 error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected for token ' . $marker
688 . ': client ' . $row->client_id . ' no longer exists.' );
689 }
690 return $this->oauth_error( 'invalid_client', 'Client not found.', 401 );
691 }
692 if ( $client_id !== '' && $client_id !== $client->client_id ) {
693 if ( $this->logging ) {
694 error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected for token ' . $marker
695 . ': client_id in the request does not match the one on the grant.' );
696 }
697 return $this->oauth_error( 'invalid_client', 'client_id mismatch.', 401 );
698 }
699 if ( !$this->authenticate_client_if_required( $client, $request ) ) {
700 if ( $this->logging ) {
701 error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected for token ' . $marker
702 . ': client authentication failed (method ' . $client->token_endpoint_auth_method
703 . '). If this client authenticates with client_secret_basic, check that the host'
704 . ' forwards the Authorization header on POST requests.' );
705 }
706 return $this->oauth_error( 'invalid_client', 'Client authentication failed.', 401 );
707 }
708
709 // Refresh-token rotation (OAuth 2.1 best practice): the old refresh token is
710 // spent, but the access token issued with it is NOT. See TOKEN_ROTATED.
711 $wpdb->update( $this->table_tokens, [ 'revoked' => self::TOKEN_ROTATED ], [ 'id' => $row->id ] );
712
713 if ( $this->logging ) {
714 error_log( '[AI Engine MCP OAuth] �
715 Refresh accepted for token ' . $marker
716 . ', user ' . (int) $row->user_id . ', client ' . $client->client_id
717 . '. New pair issued; the previous access token stays valid until '
718 . $row->access_expires . ' UTC.' );
719 }
720
721 return $this->issue_token_pair( $row->client_id, (int) $row->user_id, (string) $row->scope );
722 }
723
724 private function issue_token_pair( $client_id, $user_id, $scope ) {
725 global $wpdb;
726 $access_token = $this->random_token( 48 );
727 $refresh_token = $this->random_token( 48 );
728 $now = time();
729
730 $wpdb->insert( $this->table_tokens, [
731 'client_id' => $client_id,
732 'user_id' => $user_id,
733 'access_token_hash' => hash( 'sha256', $access_token ),
734 'refresh_token_hash' => hash( 'sha256', $refresh_token ),
735 'access_expires' => gmdate( 'Y-m-d H:i:s', $now + self::ACCESS_TOKEN_TTL ),
736 'refresh_expires' => gmdate( 'Y-m-d H:i:s', $now + self::REFRESH_TOKEN_TTL ),
737 'scope' => $scope,
738 'created' => gmdate( 'Y-m-d H:i:s', $now ),
739 ] );
740
741 $response = new WP_REST_Response( [
742 'access_token' => $access_token,
743 'token_type' => 'Bearer',
744 'expires_in' => self::ACCESS_TOKEN_TTL,
745 'refresh_token' => $refresh_token,
746 'scope' => $scope,
747 ], 200 );
748 $response->header( 'Cache-Control', 'no-store' );
749 $response->header( 'Pragma', 'no-cache' );
750 return $response;
751 }
752
753 private function authenticate_client_if_required( $client, WP_REST_Request $request ) {
754 if ( $client->token_endpoint_auth_method === 'none' ) {
755 return true;
756 }
757 $provided_secret = '';
758 if ( $client->token_endpoint_auth_method === 'client_secret_basic' ) {
759 $auth = $request->get_header( 'authorization' );
760 if ( $auth && preg_match( '#^Basic\s+(.+)$#i', $auth, $m ) ) {
761 $decoded = base64_decode( $m[1], true );
762 if ( $decoded && strpos( $decoded, ':' ) !== false ) {
763 [ $cid, $secret ] = explode( ':', $decoded, 2 );
764 if ( $cid === $client->client_id ) {
765 $provided_secret = $secret;
766 }
767 }
768 }
769 elseif ( isset( $_SERVER['PHP_AUTH_USER'] ) && $_SERVER['PHP_AUTH_USER'] === $client->client_id ) {
770 // Apache with mod_php performs HTTP Basic auth itself: it moves the credentials
771 // into PHP_AUTH_USER/PHP_AUTH_PW and never exposes the header, so get_header()
772 // above finds nothing even though the client sent one. A Bearer header is left
773 // alone, which is exactly why MCP tool calls keep working on these hosts while
774 // every single token refresh is rejected as invalid_client: the connection dies
775 // once the access token ages out and never comes back.
776 $provided_secret = (string) ( $_SERVER['PHP_AUTH_PW'] ?? '' );
777 }
778 }
779 else {
780 $provided_secret = (string) ( $request->get_param( 'client_secret' ) ?? '' );
781 }
782 if ( $provided_secret === '' || !$client->client_secret_hash ) {
783 return false;
784 }
785 return hash_equals( $client->client_secret_hash, hash( 'sha256', $provided_secret ) );
786 }
787 #endregion
788
789 #region Revocation
790 public function handle_revoke( WP_REST_Request $request ) {
791 $token = (string) ( $request->get_param( 'token' ) ?? '' );
792 if ( $token === '' ) {
793 // RFC 7009: return 200 even on unknown tokens to avoid information leakage.
794 return new WP_REST_Response( null, 200 );
795 }
796 global $wpdb;
797 $hash = hash( 'sha256', $token );
798 $wpdb->query( $wpdb->prepare(
799 "UPDATE {$this->table_tokens} SET revoked = 1 WHERE access_token_hash = %s OR refresh_token_hash = %s",
800 $hash,
801 $hash
802 ) );
803 return new WP_REST_Response( null, 200 );
804 }
805 #endregion
806
807 #region Capability gate
808 /**
809 * Whether a user is allowed to authorize an OAuth client and to use an OAuth
810 * access token against the MCP endpoint. Defaults to administrator only,
811 * matching the documented MCP access model. The filter exists so the planned
812 * multi-user MCP work can broaden this safely once per-token capability
813 * scoping lands; until then, allowing a non-admin here re-opens CVE-class
814 * privilege escalation through tools like wp_create_user.
815 *
816 * Test manage_options, not the 'administrator' role name. Passing a role name
817 * to user_can() only matches when that exact key sits in the user's
818 * capabilities meta, so admin-equivalent accounts (custom roles, caps granted
819 * individually, or a plugin filtering user_has_cap) were refused at the
820 * consent screen while every wp-admin settings page loaded fine for them.
821 * manage_options keeps the privilege-escalation fix intact: editors and below
822 * do not hold it, and multisite super admins pass via WP_User::has_cap().
823 */
824 public function user_can_authorize( $user_id ) {
825 $user_id = (int) $user_id;
826 $allowed = $user_id > 0 && user_can( $user_id, 'manage_options' );
827 return (bool) apply_filters( 'mwai_mcp_oauth_user_can_authorize', $allowed, $user_id );
828 }
829 #endregion
830
831 #region Token validation (called from MCP auth path)
832 /**
833 * Validate an access token for protected resource access.
834 * Returns [ 'user_id' => N, 'client_id' => '...', 'scope' => '...' ] on success, null on failure.
835 * Also touches last_used so the admin UI can show recent activity.
836 */
837 public function validate_token( $token ) {
838 if ( !is_string( $token ) || $token === '' ) {
839 return null;
840 }
841 global $wpdb;
842 $hash = hash( 'sha256', $token );
843 // Rotated grants still serve their access token; only an explicit revocation
844 // kills it on the spot.
845 $row = $wpdb->get_row(
846 $wpdb->prepare(
847 "SELECT t.*, c.client_name FROM {$this->table_tokens} t
848 LEFT JOIN {$this->table_clients} c ON c.client_id = t.client_id
849 WHERE t.access_token_hash = %s AND t.revoked <> %d LIMIT 1",
850 $hash,
851 self::TOKEN_REVOKED
852 )
853 );
854 // A rejected token used to be silent, which made "it works, then it doesn't"
855 // reports impossible to answer: nothing anywhere said why. Each branch below
856 // names the reason, and the marker lets one client's calls be followed across
857 // a log without ever writing a usable credential to disk.
858 if ( !$row ) {
859 if ( $this->logging ) {
860 $marker = $this->token_marker( $token );
861 $revoked = $wpdb->get_var( $wpdb->prepare(
862 "SELECT id FROM {$this->table_tokens} WHERE access_token_hash = %s LIMIT 1",
863 $hash
864 ) );
865 error_log( '[AI Engine MCP OAuth] ❌ Access token ' . $marker . ' rejected: ' . ( $revoked
866 ? 'this grant was revoked (from Connected Apps, or by the client signing out).'
867 : 'no grant matches this token. The client is using a credential this site never issued, or one whose grant has been deleted.' ) );
868 }
869 return null;
870 }
871 if ( strtotime( $row->access_expires . ' UTC' ) < time() ) {
872 if ( $this->logging ) {
873 error_log( '[AI Engine MCP OAuth] ❌ Access token ' . $this->token_marker( $token )
874 . ' rejected: it expired on ' . $row->access_expires . ' UTC. The client should refresh it.' );
875 }
876 return null;
877 }
878 // Touch last_used (non-blocking, single UPDATE).
879 $wpdb->update(
880 $this->table_tokens,
881 [ 'last_used' => current_time( 'mysql', 1 ) ],
882 [ 'id' => $row->id ]
883 );
884 return [
885 'user_id' => (int) $row->user_id,
886 'client_id' => $row->client_id,
887 'client_name' => $row->client_name,
888 'scope' => $row->scope,
889 ];
890 }
891 #endregion
892
893 #region Admin: list / revoke grants
894 public function handle_apps_list() {
895 global $wpdb;
896 $rows = $wpdb->get_results(
897 "SELECT t.id, t.client_id, t.user_id, t.created, t.last_used, t.access_expires, t.refresh_expires, t.revoked,
898 c.client_name
899 FROM {$this->table_tokens} t
900 LEFT JOIN {$this->table_clients} c ON c.client_id = t.client_id
901 WHERE t.revoked = 0
902 ORDER BY t.created DESC"
903 );
904 $out = [];
905 foreach ( $rows as $r ) {
906 $user = get_userdata( (int) $r->user_id );
907 $out[] = [
908 'id' => (int) $r->id,
909 'client_id' => $r->client_id,
910 'client_name' => $r->client_name ?: 'Unknown app',
911 'user_id' => (int) $r->user_id,
912 'user_login' => $user ? $user->user_login : 'deleted',
913 'user_display' => $user ? $user->display_name : 'Deleted user',
914 'created' => $r->created,
915 'last_used' => $r->last_used,
916 'access_expires' => $r->access_expires,
917 'refresh_expires' => $r->refresh_expires,
918 ];
919 }
920 return new WP_REST_Response( [ 'apps' => $out ], 200 );
921 }
922
923 public function handle_apps_revoke( WP_REST_Request $request ) {
924 $id = (int) $request->get_param( 'id' );
925 if ( $id <= 0 ) {
926 return new WP_REST_Response( [ 'error' => 'Invalid id.' ], 400 );
927 }
928 global $wpdb;
929 $wpdb->update( $this->table_tokens, [ 'revoked' => 1 ], [ 'id' => $id ] );
930 return new WP_REST_Response( [ 'revoked' => true ], 200 );
931 }
932 #endregion
933
934 #region Helpers
935 /**
936 * Drop client registrations that never led to anything.
937 *
938 * A client row is created before the user approves anything, so every abandoned
939 * connection attempt, every diagnostic and every reconnect leaves one behind, and
940 * nothing ever removed them. One user finished a debugging session with a dozen and
941 * no way to clear them short of SQL.
942 *
943 * Only rows with no grant at all, older than a month, are removed: a registration
944 * still waiting for its consent screen after that long is not coming back, and
945 * anything the user actually authorized is untouched whatever its age. This runs on
946 * registration, the only moment new rows appear, so it needs no schedule.
947 */
948 private function prune_orphan_clients() {
949 global $wpdb;
950 $wpdb->query( $wpdb->prepare(
951 "DELETE c FROM {$this->table_clients} c
952 LEFT JOIN {$this->table_tokens} t ON t.client_id = c.client_id
953 WHERE t.id IS NULL AND c.created < %s",
954 gmdate( 'Y-m-d H:i:s', time() - 30 * DAY_IN_SECONDS )
955 ) );
956 }
957
958 private function get_client( $client_id ) {
959 if ( !is_string( $client_id ) || $client_id === '' ) {
960 return null;
961 }
962 global $wpdb;
963 return $wpdb->get_row( $wpdb->prepare(
964 "SELECT * FROM {$this->table_clients} WHERE client_id = %s LIMIT 1",
965 $client_id
966 ) );
967 }
968
969 private function redirect_uri_registered( $client, $redirect_uri ) {
970 if ( !$client || !is_string( $redirect_uri ) || $redirect_uri === '' ) {
971 return false;
972 }
973 $registered = json_decode( $client->redirect_uris, true );
974 if ( !is_array( $registered ) ) {
975 return false;
976 }
977 foreach ( $registered as $uri ) {
978 if ( hash_equals( (string) $uri, $redirect_uri ) ) {
979 return true;
980 }
981 }
982 return false;
983 }
984
985 private function append_params( $url, $params ) {
986 $sep = strpos( $url, '?' ) === false ? '?' : '&';
987 return $url . $sep . http_build_query( $params );
988 }
989
990 private function random_token( $bytes = 32 ) {
991 return bin2hex( random_bytes( (int) $bytes ) );
992 }
993
994 private function oauth_error( $code, $description, $status = 400 ) {
995 $response = new WP_REST_Response( [
996 'error' => $code,
997 'error_description' => $description,
998 ], $status );
999 $response->header( 'Cache-Control', 'no-store' );
1000 $response->header( 'Pragma', 'no-cache' );
1001 return $response;
1002 }
1003
1004 /**
1005 * Add WWW-Authenticate header to 401 responses on the protected MCP route,
1006 * pointing clients at the resource metadata document so they can discover
1007 * the authorization server automatically.
1008 */
1009 public function add_www_authenticate_header( $response, $server, $request ) {
1010 if ( !( $response instanceof WP_HTTP_Response ) ) {
1011 return $response;
1012 }
1013 $route = $request instanceof WP_REST_Request ? $request->get_route() : '';
1014 if ( $route !== '/' . $this->namespace . '/http' ) {
1015 return $response;
1016 }
1017 $status = $response->get_status();
1018 if ( $status !== 401 && $status !== 403 ) {
1019 return $response;
1020 }
1021 $resource_metadata = rest_url( $this->namespace . '/.well-known/oauth-protected-resource' );
1022 $response->header(
1023 'WWW-Authenticate',
1024 sprintf( 'Bearer realm="MCP", resource_metadata="%s"', $resource_metadata )
1025 );
1026 return $response;
1027 }
1028 #endregion
1029
1030 #region HTML rendering (consent + error pages)
1031 private function render_consent_page( $client, $params, $user ) {
1032 $nonce = wp_create_nonce( self::NONCE_ACTION );
1033 $action_url = rest_url( $this->namespace . '/oauth/authorize' );
1034 $site_name = get_bloginfo( 'name' );
1035 $client_name = $client->client_name ?: 'Unnamed MCP Client';
1036 $role_label = $this->describe_user_role( $user );
1037
1038 status_header( 200 );
1039 nocache_headers();
1040 header( 'Content-Type: text/html; charset=utf-8' );
1041
1042 $hidden_fields = [
1043 'client_id' => $params['client_id'],
1044 'redirect_uri' => $params['redirect_uri'],
1045 'state' => $params['state'],
1046 'scope' => $params['scope'],
1047 'code_challenge' => $params['code_challenge'],
1048 'code_challenge_method' => $params['code_challenge_method'],
1049 'resource' => $params['resource'],
1050 '_mwai_nonce' => $nonce,
1051 ];
1052
1053 echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">';
1054 echo '<meta name="viewport" content="width=device-width, initial-scale=1">';
1055 echo '<title>' . esc_html( sprintf( 'Authorize %s', $client_name ) ) . '</title>';
1056 echo $this->consent_styles();
1057 echo '</head><body><main class="mwai-oauth-card">';
1058
1059 echo '<h1>Authorize this app</h1>';
1060 echo '<p class="mwai-oauth-app"><strong>' . esc_html( $client_name ) . '</strong> wants to connect to <strong>' . esc_html( $site_name ) . '</strong>.</p>';
1061
1062 echo '<div class="mwai-oauth-meta">';
1063 echo '<div><span class="mwai-oauth-label">Signed in as</span><span class="mwai-oauth-value">' . esc_html( $user->display_name ) . ' (' . esc_html( $user->user_login ) . ')</span></div>';
1064 echo '<div><span class="mwai-oauth-label">Permissions</span><span class="mwai-oauth-value">' . esc_html( $role_label ) . '</span></div>';
1065 echo '</div>';
1066
1067 echo '<p class="mwai-oauth-note">The app will be able to call MCP tools using your account. You can revoke access at any time from AI Engine settings.</p>';
1068
1069 echo '<form method="POST" action="' . esc_url( $action_url ) . '">';
1070 foreach ( $hidden_fields as $name => $value ) {
1071 echo '<input type="hidden" name="' . esc_attr( $name ) . '" value="' . esc_attr( $value ) . '">';
1072 }
1073 echo '<div class="mwai-oauth-buttons">';
1074 echo '<button type="submit" name="action" value="approve" class="mwai-oauth-approve">Approve</button>';
1075 echo '<button type="submit" name="action" value="deny" class="mwai-oauth-deny">Deny</button>';
1076 echo '</div>';
1077 echo '</form>';
1078
1079 echo '</main></body></html>';
1080 }
1081
1082 private function render_error_page( $message ) {
1083 status_header( 400 );
1084 nocache_headers();
1085 header( 'Content-Type: text/html; charset=utf-8' );
1086 echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">';
1087 echo '<title>Authorization error</title>';
1088 echo $this->consent_styles();
1089 echo '</head><body><main class="mwai-oauth-card">';
1090 echo '<h1>Authorization error</h1>';
1091 echo '<p class="mwai-oauth-note">' . esc_html( $message ) . '</p>';
1092 echo '</main></body></html>';
1093 }
1094
1095 private function describe_user_role( $user ) {
1096 if ( !$user || empty( $user->roles ) ) {
1097 return 'No role';
1098 }
1099 $role = $user->roles[0];
1100 $names = [
1101 'administrator' => 'Administrator (full access)',
1102 'editor' => 'Editor',
1103 'author' => 'Author',
1104 'contributor' => 'Contributor',
1105 'subscriber' => 'Subscriber',
1106 ];
1107 return $names[ $role ] ?? ucfirst( $role );
1108 }
1109
1110 private function consent_styles() {
1111 return '<style>
1112 body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f1f2f5; color: #1d2330; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 20px; }
1113 .mwai-oauth-card { background: #fff; border-radius: 12px; box-shadow: 0 12px 40px rgba(0,0,0,0.08); padding: 36px 36px 28px; max-width: 440px; width: 100%; }
1114 .mwai-oauth-card h1 { font-size: 22px; margin: 0 0 16px; font-weight: 600; }
1115 .mwai-oauth-app { font-size: 15px; line-height: 1.5; margin: 0 0 24px; }
1116 .mwai-oauth-meta { background: #f7f8fa; border-radius: 8px; padding: 14px 16px; margin-bottom: 20px; }
1117 .mwai-oauth-meta > div { display: flex; justify-content: space-between; align-items: baseline; padding: 6px 0; font-size: 14px; }
1118 .mwai-oauth-label { color: #6b7280; }
1119 .mwai-oauth-value { color: #1d2330; font-weight: 500; text-align: right; }
1120 .mwai-oauth-note { font-size: 13px; color: #6b7280; line-height: 1.5; margin: 0 0 24px; }
1121 .mwai-oauth-buttons { display: flex; gap: 10px; }
1122 .mwai-oauth-buttons button { flex: 1; padding: 11px 14px; border-radius: 8px; border: 1px solid transparent; font-size: 14px; font-weight: 600; cursor: pointer; transition: background .15s; }
1123 .mwai-oauth-approve { background: #2271b1; color: #fff; }
1124 .mwai-oauth-approve:hover { background: #135e96; }
1125 .mwai-oauth-deny { background: #fff; color: #1d2330; border-color: #d0d4da; }
1126 .mwai-oauth-deny:hover { background: #f1f2f5; }
1127 </style>';
1128 }
1129 #endregion
1130 }
1131