PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 All 33 releases
← All changes | includes/modules/Mcp/Mcp_Server.php +79 -6 1.1.1 → 1.3.7 View file →
@@ -67,8 +67,14 @@
67 67 }
68 68 Mcp_Rate_Limiter::clear();
69 69
70 70 $raw = $request->get_body();
71 + // Second line behind McpModule::cap_request_body(), which is the one
72 + // that runs before WordPress decodes. Kept because the pretty
73 + // front-door path reaches here without WP_REST_Server::dispatch().
74 + if ( strlen( $raw ) > Mcp_Tools::MAX_TOOL_BODY_BYTES ) {
75 + return self::error_response( null, self::INVALID_REQUEST, 'Request body is too large.', 413 );
76 + }
71 77 $msg = json_decode( $raw, true );
72 78
73 79 if ( null === $msg && JSON_ERROR_NONE !== json_last_error() ) {
74 80 return self::error_response( null, self::PARSE_ERROR, 'Parse error: body is not valid JSON.', 400 );
@@ -127,10 +133,11 @@
127 133 'capabilities' => array(
128 134 'tools' => array( 'listChanged' => false ),
129 135 ),
130 136 'serverInfo' => array(
131 - 'name' => 'xspeed',
132 - 'version' => defined( 'XSPEED_VERSION' ) ? XSPEED_VERSION : '1.0.0',
137 + 'name' => 'xspeed',
138 + 'version' => defined( 'XSPEED_VERSION' ) ? XSPEED_VERSION : '1.0.0',
139 + 'xspeedAuth' => self::$auth_kind,
133 140 ),
134 141 )
135 142 );
136 143
@@ -166,8 +173,9 @@
166 173 if ( '' === $name ) {
167 174 return self::error( $id, self::INVALID_PARAMS, 'Missing tool name.' );
168 175 }
169 176
177 + Mcp_Tools::set_channel( 'mcp' );
170 178 $result = Mcp_Tools::invoke( $name, $args );
171 179
172 180 if ( is_wp_error( $result ) ) {
173 181 // Tool-level failure is reported as a successful JSON-RPC
@@ -215,9 +223,20 @@
215 223 *
216 224 * @param \WP_REST_Request $request Incoming request.
217 225 * @return bool
218 226 */
227 + /**
228 + * Which credential authorized this request: 'site' (the pairing token)
229 + * or 'oauth'. Reported in `initialize` so xSpeed Hub stores only the
230 + * pairing token when a site syncs it. An OAuth token can be read-only
231 + * and expires within the hour; storing one would break the Hub.
232 + *
233 + * @var string
234 + */
235 + private static $auth_kind = '';
236 +
219 237 private static function authorize( \WP_REST_Request $request ): bool {
238 + self::$auth_kind = '';
220 239 $presented = self::extract_token( $request );
221 240 if ( '' === $presented ) {
222 241 return false;
223 242 }
@@ -223,20 +242,28 @@
223 242 }
224 243
225 244 // Path 1: the static per-site pairing token (Mcp_Pairing). Leave the
226 245 // tool scope override cleared so Mcp_Tools defers to the pairing
227 - // token's own read-only scope.
246 + // token's own read-only scope. Credential writes over the pairing token
247 + // stay gated on the xspeed_mcp_allow_credential_writes filter (off by
248 + // default) — clear the configure override so that default applies. (#116)
228 249 $stored = Mcp_Pairing::site_token();
229 250 if ( '' !== $stored && hash_equals( $stored, $presented ) ) {
230 251 Mcp_Tools::set_read_only_override( null );
252 + Mcp_Tools::set_configure_override( null );
253 + self::$auth_kind = 'site';
231 254 return true;
232 255 }
233 256
234 257 // Path 2: an OAuth 2.1 access token minted by Mcp_OAuth. Its own
235 - // granted scope decides read-only, independent of any pairing token.
258 + // granted scope decides read-only AND whether it may write credentials
259 + // (the explicit, opt-in `configure` scope), independent of any pairing
260 + // token.
236 261 $grant = Mcp_OAuth::validate_token( $presented );
237 262 if ( null !== $grant ) {
238 263 Mcp_Tools::set_read_only_override( Mcp_OAuth::scope_is_read_only( $grant['scope'] ) );
264 + Mcp_Tools::set_configure_override( Mcp_OAuth::scope_allows_configure( $grant['scope'] ) );
265 + self::$auth_kind = 'oauth';
239 266 return true;
240 267 }
241 268
242 269 return false;
@@ -247,10 +274,56 @@
247 274 * this site's protected-resource metadata so an OAuth-capable client
248 275 * can discover the authorization server and begin the flow.
249 276 */
250 277 private static function challenge_header(): string {
251 - $metadata_url = home_url( '/.well-known/oauth-protected-resource' );
252 - return sprintf( 'Bearer resource_metadata="%s"', $metadata_url );
278 + return sprintf( 'Bearer resource_metadata="%s"', self::metadata_url() );
279 + }
280 +
281 + /**
282 + * Where this site actually serves its protected-resource metadata.
283 + *
284 + * Prefers the canonical /.well-known/…/xspeed/mcp URL, but many hosts own that prefix
285 + * for ACME/Let's Encrypt and answer it before WordPress runs — the client
286 + * then follows a pointer to a 404 (or a redirect to the homepage) and the
287 + * OAuth flow dead-ends. RFC 9728 allows a single resource_metadata value,
288 + * so when the pretty path is not ours to serve we advertise the /wp-json
289 + * fallback, which no ACME tooling claims.
290 + */
291 + private static function metadata_url(): string {
292 + // RFC 9728 §3.1: a resource whose identifier carries a path is
293 + // discovered at the path-suffixed form. Always this one, never the
294 + // root form — even on a site where root is still ours to serve. The
295 + // challenge is what steers every re-discovery, so pointing it at the
296 + // canonical identity is what eventually moves clients onto it; and
297 + // its value must not depend on whether some other plugin happens to
298 + // be installed, or a client that cached the header would find the
299 + // URL under it change meaning. Root exists for clients that never
300 + // read this header at all. (#266)
301 + //
302 + // Built off untrailingslashit() because get_home_url() concatenates
303 + // the `home` option verbatim: with a trailing slash stored there,
304 + // home_url( '/.well-known/…' ) returns a doubled slash and the URL
305 + // 404s.
306 + $pretty = untrailingslashit( home_url( '/' ) )
307 + . '/.well-known/oauth-protected-resource/' . Mcp_Pairing::SITE_ENDPOINT_PATH;
308 +
309 + /**
310 + * Filter the advertised protected-resource metadata URL.
311 + *
312 + * @param string $pretty The canonical /.well-known/ URL.
313 + */
314 + $filtered = apply_filters( 'xspeed_mcp_resource_metadata_url', $pretty );
315 + if ( is_string( $filtered ) && '' !== $filtered && $filtered !== $pretty ) {
316 + return $filtered;
317 + }
318 +
319 + // Rewrites absent (plain permalinks, or a flush that never landed)
320 + // means the pretty URL cannot resolve at all — use the fallback.
321 + if ( ! McpModule::wellknown_rewrites_active() ) {
322 + return Mcp_Pairing::absolute( rest_url( McpModule::NS . '/mcp/.well-known/oauth-protected-resource' ) );
323 + }
324 +
325 + return $pretty;
253 326 }
254 327
255 328 /** Pull the token from Bearer or X-XSpeed-MCP-Token, Bearer wins. */
256 329 private static function extract_token( \WP_REST_Request $request ): string {