PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.4
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 1.1.5 1.1.6 1.1.7 All 30 releases
← All changes | includes/modules/Mcp/Mcp_Server.php +63 -5 1.0.81.3.4 View file →
@@ -166,8 +166,9 @@
166 166 if ( '' === $name ) {
167 167 return self::error( $id, self::INVALID_PARAMS, 'Missing tool name.' );
168 168 }
169 169
170 + Mcp_Tools::set_channel( 'mcp' );
170 171 $result = Mcp_Tools::invoke( $name, $args );
171 172
172 173 if ( is_wp_error( $result ) ) {
173 174 // Tool-level failure is reported as a successful JSON-RPC
@@ -186,8 +187,13 @@
186 187 )
187 188 );
188 189 }
189 190
191 + // A Cli_Bridge-backed tool reports command failure as ok:false inside
192 + // the payload. Without this, the envelope said isError:false and an
193 + // agent read "Could not connect to Redis" as a success.
194 + $failed = is_array( $result ) && array_key_exists( 'ok', $result ) && false === $result['ok'];
195 +
190 196 return self::result(
191 197 $id,
192 198 array(
193 199 'content' => array(
@@ -195,9 +201,9 @@
195 201 'type' => 'text',
196 202 'text' => wp_json_encode( $result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ),
197 203 ),
198 204 ),
199 - 'isError' => false,
205 + 'isError' => $failed,
200 206 )
201 207 );
202 208 }
203 209
@@ -218,20 +224,26 @@
218 224 }
219 225
220 226 // Path 1: the static per-site pairing token (Mcp_Pairing). Leave the
221 227 // tool scope override cleared so Mcp_Tools defers to the pairing
222 - // token's own read-only scope.
228 + // token's own read-only scope. Credential writes over the pairing token
229 + // stay gated on the xspeed_mcp_allow_credential_writes filter (off by
230 + // default) — clear the configure override so that default applies. (#116)
223 231 $stored = Mcp_Pairing::site_token();
224 232 if ( '' !== $stored && hash_equals( $stored, $presented ) ) {
225 233 Mcp_Tools::set_read_only_override( null );
234 + Mcp_Tools::set_configure_override( null );
226 235 return true;
227 236 }
228 237
229 238 // Path 2: an OAuth 2.1 access token minted by Mcp_OAuth. Its own
230 - // granted scope decides read-only, independent of any pairing token.
239 + // granted scope decides read-only AND whether it may write credentials
240 + // (the explicit, opt-in `configure` scope), independent of any pairing
241 + // token.
231 242 $grant = Mcp_OAuth::validate_token( $presented );
232 243 if ( null !== $grant ) {
233 244 Mcp_Tools::set_read_only_override( Mcp_OAuth::scope_is_read_only( $grant['scope'] ) );
245 + Mcp_Tools::set_configure_override( Mcp_OAuth::scope_allows_configure( $grant['scope'] ) );
234 246 return true;
235 247 }
236 248
237 249 return false;
@@ -242,10 +254,56 @@
242 254 * this site's protected-resource metadata so an OAuth-capable client
243 255 * can discover the authorization server and begin the flow.
244 256 */
245 257 private static function challenge_header(): string {
246 - $metadata_url = home_url( '/.well-known/oauth-protected-resource' );
247 - return sprintf( 'Bearer resource_metadata="%s"', $metadata_url );
258 + return sprintf( 'Bearer resource_metadata="%s"', self::metadata_url() );
259 + }
260 +
261 + /**
262 + * Where this site actually serves its protected-resource metadata.
263 + *
264 + * Prefers the canonical /.well-known/…/xspeed/mcp URL, but many hosts own that prefix
265 + * for ACME/Let's Encrypt and answer it before WordPress runs — the client
266 + * then follows a pointer to a 404 (or a redirect to the homepage) and the
267 + * OAuth flow dead-ends. RFC 9728 allows a single resource_metadata value,
268 + * so when the pretty path is not ours to serve we advertise the /wp-json
269 + * fallback, which no ACME tooling claims.
270 + */
271 + private static function metadata_url(): string {
272 + // RFC 9728 §3.1: a resource whose identifier carries a path is
273 + // discovered at the path-suffixed form. Always this one, never the
274 + // root form — even on a site where root is still ours to serve. The
275 + // challenge is what steers every re-discovery, so pointing it at the
276 + // canonical identity is what eventually moves clients onto it; and
277 + // its value must not depend on whether some other plugin happens to
278 + // be installed, or a client that cached the header would find the
279 + // URL under it change meaning. Root exists for clients that never
280 + // read this header at all. (#266)
281 + //
282 + // Built off untrailingslashit() because get_home_url() concatenates
283 + // the `home` option verbatim: with a trailing slash stored there,
284 + // home_url( '/.well-known/…' ) returns a doubled slash and the URL
285 + // 404s.
286 + $pretty = untrailingslashit( home_url( '/' ) )
287 + . '/.well-known/oauth-protected-resource/' . Mcp_Pairing::SITE_ENDPOINT_PATH;
288 +
289 + /**
290 + * Filter the advertised protected-resource metadata URL.
291 + *
292 + * @param string $pretty The canonical /.well-known/ URL.
293 + */
294 + $filtered = apply_filters( 'xspeed_mcp_resource_metadata_url', $pretty );
295 + if ( is_string( $filtered ) && '' !== $filtered && $filtered !== $pretty ) {
296 + return $filtered;
297 + }
298 +
299 + // Rewrites absent (plain permalinks, or a flush that never landed)
300 + // means the pretty URL cannot resolve at all — use the fallback.
301 + if ( ! McpModule::wellknown_rewrites_active() ) {
302 + return Mcp_Pairing::absolute( rest_url( McpModule::NS . '/mcp/.well-known/oauth-protected-resource' ) );
303 + }
304 +
305 + return $pretty;
248 306 }
249 307
250 308 /** Pull the token from Bearer or X-XSpeed-MCP-Token, Bearer wins. */
251 309 private static function extract_token( \WP_REST_Request $request ): string {