PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / 3.8.0
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! v3.8.0
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / modules / mcp-server / Server / HttpTransport.php

HttpTransport.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! 3.8.0, at modules/mcp-server/Server/HttpTransport.php

542 lines 18.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The built-in MCP endpoint (spec 044, FR-001, FR-007, FR-008, FR-041).
4 *
5 * Routes:
6 * POST /wp-json/templately/v1/mcp JSON-RPC
7 * POST /templately/mcp pretty alias (rewrite)
8 * GET /.well-known/oauth-protected-resource discovery
9 * GET /.well-known/oauth-authorization-server discovery
10 * GET|POST /templately/authorize approval screen (front-end page)
11 *
12 * No collision with the mcp-adapter's `/wp-json/templately/mcp` (namespace
13 * `templately`, route `mcp`): this lives under `templately/v1`. Both may serve
14 * the same capability set concurrently (FR-007).
15 *
16 * Registered with register_rest_route() DIRECTLY rather than through
17 * API::register_endpoint(), which force-injects its own `_permission_check`
18 * and would fight AuthManager.
19 *
20 * @package Templately\Modules\McpServer\Server
21 */
22
23 namespace Templately\Modules\McpServer\Server;
24
25 use Templately\Modules\McpServer\Auth\AuthManager;
26 use Templately\Modules\McpServer\Auth\Credentials;
27 use Templately\Modules\McpServer\Auth\FailedAuthLimiter;
28 use Templately\Modules\McpServer\Auth\OAuth\ConsentScreen;
29 use Templately\Modules\McpServer\Auth\OAuth\OAuthServer;
30 use Templately\Modules\McpCore\Registry\ToolDescriptor;
31 use Templately\Utils\Base;
32 use WP_Error;
33 use WP_REST_Request;
34 use WP_REST_Response;
35
36 class HttpTransport extends Base {
37
38 const NAMESPACE = 'templately/v1';
39 const ROUTE = '/mcp';
40
41 /** Rewrite rules this feature owns. Used by the self-healing flush check. */
42 const REWRITES = [
43 '^templately/mcp/?$' => 'index.php?templately_mcp=1',
44 '^templately/authorize/?$' => 'index.php?templately_mcp_authorize=1',
45
46 // NOTE: the BARE `/.well-known/oauth-protected-resource` and
47 // `/.well-known/oauth-authorization-server` are deliberately NOT claimed.
48 //
49 // Those are one global slot per origin, and the documents we would serve
50 // there are not even valid at that address: RFC 8414 §3.3 requires the
51 // returned `issuer` to be identical to the issuer whose metadata was
52 // retrieved, and RFC 9728 the same for `resource`. Ours are
53 // `<origin>/templately` and `<origin>/wp-json/templately/v1/mcp`, so a
54 // strict client fetching the bare path would rightly reject the answer.
55 //
56 // Claiming them anyway is what let two MCP plugins fight over discovery.
57 // Nothing needs them: the 401 challenge names the resource-specific
58 // metadata URL directly (AuthManager::challenge_header), that document
59 // names the scoped issuer, and the issuer resolves to its own slot below.
60
61 // RFC 9728 §3.1: a client appends the RESOURCE PATH to the well-known
62 // prefix, so an MCP client configured with
63 // `…/wp-json/templately/v1/mcp` actually requests
64 // `/.well-known/oauth-protected-resource/wp-json/templately/v1/mcp`.
65 // Serving only the bare path above meant that request fell through to
66 // whatever else on the site claimed `.well-known` — on a site that also
67 // runs another MCP plugin, that plugin answered FOR THIS RESOURCE and
68 // handed the client its own authorization endpoint, so approval happened
69 // somewhere else entirely. Observed live with ChatGPT.
70 //
71 // Deliberately matched to THIS plugin's own resource paths rather than a
72 // wildcard: a broad rule would hijack the neighbour's discovery exactly
73 // as ours was hijacked.
74 '^\.well-known/oauth-(protected-resource|authorization-server)/.*templately/(?:v1/)?mcp/?$'
75 => 'index.php?templately_mcp_wellknown=$matches[1]',
76
77 // The PATH-SCOPED issuer's own metadata slot. RFC 8414 §3.1 builds the
78 // URL for an issuer with a path by inserting the well-known segment
79 // between host and path, so issuer `https://site/templately` publishes at
80 // `/.well-known/oauth-authorization-server/templately`.
81 //
82 // This is the slot that makes coexistence possible: the bare
83 // `/.well-known/oauth-authorization-server` is a SINGLE global slot per
84 // origin, which two MCP plugins cannot both own — we were winning it by
85 // rewrite-registration order, not by right.
86 '^\.well-known/oauth-authorization-server/templately/?$'
87 => 'index.php?templately_mcp_wellknown=authorization-server',
88 ];
89
90 /**
91 * Routes that speak a FOREIGN protocol and must never be enveloped.
92 *
93 * Matched exactly, not by prefix: `/mcp/connection*` shares the `/mcp` prefix
94 * but is an ordinary Templately REST route consumed by our own settings SPA,
95 * so it MUST keep the envelope.
96 */
97 const PROTOCOL_ROUTES = [
98 '/' . self::NAMESPACE . '/mcp', // JSON-RPC 2.0
99 '/' . self::NAMESPACE . '/mcp/oauth/register', // RFC 7591
100 '/' . self::NAMESPACE . '/mcp/oauth/token', // RFC 6749 §5
101 ];
102
103 /** @var array|null Auth context resolved in the permission callback. */
104 private $context = null;
105
106 public function __construct() {
107 add_action( 'rest_api_init', [ $this, 'register_routes' ] );
108 // Keep the spec-043 envelope off this module's protocol routes — see
109 // exempt_protocol_routes(). Registered in the constructor (plugins_loaded)
110 // so it is in place before any request is dispatched.
111 add_filter( 'templately_rest_envelope_owns_route', [ $this, 'exempt_protocol_routes' ], 10, 2 );
112 // WP_Error data does not become response headers on its own, and the
113 // WWW-Authenticate pointer is what lets a URL-only client bootstrap
114 // (FR-029) — without it a 401 is a dead end.
115 // Priority 20, NOT 10: core's own `rest_send_allow_header` is also on
116 // `rest_post_dispatch` at 10, and this class is constructed on
117 // `plugins_loaded` — i.e. registered first — so at equal priority ours
118 // would run before core's and be overwritten by it.
119 add_filter( 'rest_post_dispatch', [ $this, 'attach_auth_headers' ], 20, 3 );
120 add_action( 'init', [ $this, 'add_rewrites' ] );
121 add_filter( 'query_vars', [ $this, 'add_query_vars' ] );
122 add_action( 'parse_request', [ $this, 'maybe_handle_pretty_request' ] );
123 }
124
125 public function register_routes(): void {
126 register_rest_route(
127 self::NAMESPACE,
128 self::ROUTE,
129 [
130 [
131 'methods' => 'POST',
132 'callback' => [ $this, 'handle' ],
133 'permission_callback' => [ $this, 'authorize' ],
134 ],
135 [
136 // GET/DELETE are the streamable-HTTP session verbs: GET opens an
137 // SSE stream, DELETE terminates a session. This server is
138 // stateless and serves no SSE (spec 044, Out of Scope), and the
139 // protocol says a server that does not offer a stream at this
140 // endpoint MUST answer 405 — not 404.
141 //
142 // Without this, WordPress answers an unmatched method with a
143 // generic `rest_no_route` 404, which reads as "this feature is
144 // not installed" to anyone who pastes the endpoint into a
145 // browser. 405 says "right address, wrong verb".
146 'methods' => 'GET, DELETE',
147 'callback' => [ $this, 'method_not_allowed' ],
148 'permission_callback' => '__return_true',
149 ],
150 ]
151 );
152
153 OAuthServer::register_routes();
154 }
155
156 /**
157 * @return WP_REST_Response
158 */
159 public function method_not_allowed(): WP_REST_Response {
160 $response = new WP_REST_Response(
161 JsonRpc::error(
162 null,
163 JsonRpc::INVALID_REQUEST,
164 __( 'This MCP endpoint accepts POST only. Configure it in an MCP client rather than opening it in a browser.', 'templately' )
165 ),
166 405
167 );
168
169 // NOTE: `Allow` is (re)set in attach_auth_headers(), not here — the REST
170 // server overwrites it after dispatch with the matched route's own
171 // methods, which would advertise "GET, DELETE": precisely the verbs that
172 // are NOT allowed.
173 //
174 // Still advertise where to authenticate, so a client probing with GET can
175 // discover the flow instead of dead-ending.
176 $response->header( 'WWW-Authenticate', AuthManager::challenge_header() );
177
178 return $response;
179 }
180
181 /**
182 * Preconditions + identity, in a load-bearing order (contracts/mcp-endpoint.md):
183 *
184 * 1. lockout — BEFORE any secret comparison, so a locked source
185 * cannot use the endpoint as a guessing oracle
186 * 2. content type — reject non-agent submissions
187 * 3. origin — reject cross-site browser submissions
188 * 4. inert check — refuse while the site holds no credential
189 * 5. credential — resolve and establish the acting user
190 *
191 * @param WP_REST_Request $request
192 * @return true|WP_Error
193 */
194 public function authorize( WP_REST_Request $request ) {
195 if ( FailedAuthLimiter::is_locked() ) {
196 return new WP_Error(
197 'too_many_requests',
198 __( 'Too many failed authentication attempts. Try again later.', 'templately' ),
199 [
200 'status' => 429,
201 'retry_after' => FailedAuthLimiter::retry_after(),
202 ]
203 );
204 }
205
206 $content_type = (string) $request->get_header( 'content-type' );
207
208 if ( '' !== $content_type && false === stripos( $content_type, 'application/json' ) ) {
209 return new WP_Error(
210 'unsupported_media_type',
211 __( 'This endpoint accepts application/json only.', 'templately' ),
212 [ 'status' => 415 ]
213 );
214 }
215
216 $origin = (string) $request->get_header( 'origin' );
217
218 if ( '' !== $origin && ! $this->origin_is_same_site( $origin ) ) {
219 return new WP_Error(
220 'forbidden_origin',
221 __( 'Cross-origin requests are not accepted by this endpoint.', 'templately' ),
222 [ 'status' => 403 ]
223 );
224 }
225
226 // Inert until an administrator explicitly connects (FR-023).
227 if ( ! Credentials::site_has_any() ) {
228 return $this->unauthorized( __( 'This site has no agent connection configured.', 'templately' ) );
229 }
230
231 $context = AuthManager::resolve();
232
233 if ( null === $context ) {
234 return $this->unauthorized( __( 'Invalid or missing connection credential.', 'templately' ) );
235 }
236
237 $this->context = $context;
238
239 return true;
240 }
241
242 /**
243 * @param string $message
244 * @return WP_Error
245 */
246 private function unauthorized( string $message ): WP_Error {
247 return new WP_Error(
248 'unauthorized',
249 $message,
250 [
251 'status' => 401,
252 'www_authenticate' => AuthManager::challenge_header(),
253 ]
254 );
255 }
256
257 /**
258 * @param string $origin
259 * @return bool
260 */
261 private function origin_is_same_site( string $origin ): bool {
262 $origin_host = wp_parse_url( $origin, PHP_URL_HOST );
263 $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
264
265 return is_string( $origin_host ) && is_string( $site_host )
266 && strtolower( $origin_host ) === strtolower( $site_host );
267 }
268
269 /**
270 * @param WP_REST_Request $request
271 * @return WP_REST_Response
272 */
273 public function handle( WP_REST_Request $request ) {
274 $body = $request->get_body();
275 $decoded = json_decode( $body, true );
276
277 if ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) {
278 return new WP_REST_Response(
279 JsonRpc::error( null, JsonRpc::PARSE_ERROR, __( 'Could not parse request body as JSON.', 'templately' ) ),
280 200
281 );
282 }
283
284 // Batching was removed in the supported protocol revision (FR-004). The
285 // reference implementation still accepts it.
286 if ( ! JsonRpc::is_valid_request( $decoded ) ) {
287 return new WP_REST_Response(
288 JsonRpc::error(
289 null,
290 JsonRpc::INVALID_REQUEST,
291 is_array( $decoded ) && isset( $decoded[0] )
292 ? __( 'JSON-RPC batching is not supported.', 'templately' )
293 : __( 'Not a valid JSON-RPC request.', 'templately' )
294 ),
295 200
296 );
297 }
298
299 $access_level = (string) ( $this->context['access_level'] ?? ToolDescriptor::ACCESS_FULL );
300 $credential_id = $this->context['credential_id'] ?? null;
301
302 $response = McpServer::dispatch( $decoded, $access_level, $credential_id );
303
304 // Notification — accepted, no body (FR-002).
305 if ( null === $response ) {
306 return new WP_REST_Response( null, 202 );
307 }
308
309 return new WP_REST_Response( $response, 200 );
310 }
311
312 /**
313 * Refuse the spec-043 envelope on the three routes that answer to a protocol
314 * this plugin does not own.
315 *
316 * `RestEnvelope` claims the whole `templately/v1` namespace, and these routes
317 * live in it — so without this every JSON-RPC reply ships as
318 * `{"success":true,"data":{"jsonrpc":"2.0",…}}` and every token response as
319 * `{"success":true,"data":{"access_token":…}}`. Neither is parseable by the
320 * clients they are for, and an OAuth error body loses the RFC-required `error`
321 * field to this plugin's own error vocabulary.
322 *
323 * The damage is not only to the body. The envelope rebuilds a WP_Error's data
324 * bag from a fixed key set, dropping `www_authenticate` — which
325 * `attach_auth_headers()` reads back to emit the `WWW-Authenticate` challenge.
326 * That challenge is how a URL-only client discovers where to authenticate
327 * (FR-029), so losing it dead-ends the whole delegated-approval flow.
328 *
329 * None of this was visible in the test suite: every MCP test drives the
330 * endpoint with `rest_do_request()`, which does not run `rest_post_dispatch`,
331 * so the envelope never applied there. It applies over real HTTP, on exactly
332 * the endpoint the settings screen and the docs tell users to configure.
333 *
334 * @param bool $owned
335 * @param string $route
336 * @return bool
337 */
338 public function exempt_protocol_routes( $owned, $route ) {
339 return $owned && ! in_array( untrailingslashit( (string) $route ), self::PROTOCOL_ROUTES, true );
340 }
341
342 /**
343 * Promote auth metadata from the WP_Error payload into real headers.
344 *
345 * @param WP_REST_Response $response
346 * @param mixed $server
347 * @param WP_REST_Request $request
348 * @return WP_REST_Response
349 */
350 public function attach_auth_headers( $response, $server, $request ) {
351 if ( ! $response instanceof WP_REST_Response || ! $request instanceof WP_REST_Request ) {
352 return $response;
353 }
354
355 if ( 0 !== strpos( (string) $request->get_route(), '/' . self::NAMESPACE . '/mcp' ) ) {
356 return $response;
357 }
358
359 // Correct the Allow header the REST server writes after dispatch: it lists
360 // the matched handler's methods (GET, DELETE) when those are exactly the
361 // ones being refused. POST is what a caller should use.
362 if ( 405 === $response->get_status() ) {
363 $response->header( 'Allow', 'POST', true );
364 }
365
366 $data = $response->get_data();
367
368 if ( ! is_array( $data ) || empty( $data['data'] ) || ! is_array( $data['data'] ) ) {
369 return $response;
370 }
371
372 if ( ! empty( $data['data']['www_authenticate'] ) ) {
373 $response->header( 'WWW-Authenticate', (string) $data['data']['www_authenticate'] );
374 }
375
376 if ( ! empty( $data['data']['retry_after'] ) ) {
377 $response->header( 'Retry-After', (string) (int) $data['data']['retry_after'] );
378 }
379
380 return $response;
381 }
382
383 // ---------------------------------------------------------------- rewrites
384
385 public function add_rewrites(): void {
386 foreach ( self::REWRITES as $pattern => $target ) {
387 add_rewrite_rule( $pattern, $target, 'top' );
388 }
389
390 $this->maybe_flush_rewrites();
391 }
392
393 /**
394 * Self-healing flush (FR-008). Compares EVERY rule this feature needs, not
395 * just one — a single-rule check is exactly what let the reference
396 * implementation ship a state where discovery 404'd permanently because an
397 * earlier partial flush had left one rule present and the rest missing.
398 *
399 * @return void
400 */
401 private function maybe_flush_rewrites(): void {
402 $rules = get_option( 'rewrite_rules' );
403
404 if ( ! is_array( $rules ) ) {
405 return;
406 }
407
408 foreach ( array_keys( self::REWRITES ) as $pattern ) {
409 if ( ! isset( $rules[ $pattern ] ) ) {
410 flush_rewrite_rules( false );
411
412 return;
413 }
414 }
415 }
416
417 /**
418 * @param array $vars
419 * @return array
420 */
421 public function add_query_vars( array $vars ): array {
422 $vars[] = 'templately_mcp';
423 $vars[] = 'templately_mcp_authorize';
424 $vars[] = 'templately_mcp_wellknown';
425
426 return $vars;
427 }
428
429 /**
430 * Serve the pretty paths. The JSON-RPC alias synthesizes a WP_REST_Request
431 * and reuses the same handler rather than duplicating dispatch.
432 *
433 * @param \WP $wp
434 * @return void
435 */
436 public function maybe_handle_pretty_request( $wp ): void {
437 $vars = isset( $wp->query_vars ) && is_array( $wp->query_vars ) ? $wp->query_vars : [];
438
439 if ( ! empty( $vars['templately_mcp_wellknown'] ) ) {
440 OAuthServer::serve_discovery( (string) $vars['templately_mcp_wellknown'] );
441
442 return;
443 }
444
445 // The approval screen must be a normal front-end page, NOT a REST route:
446 // a browser returning from wp-login carries a cookie but no REST nonce,
447 // so inside REST it does not read as logged in and the login redirect
448 // loops forever (FR-032).
449 if ( ! empty( $vars['templately_mcp_authorize'] ) ) {
450 ConsentScreen::render();
451
452 return;
453 }
454
455 if ( empty( $vars['templately_mcp'] ) ) {
456 return;
457 }
458
459 // POST only. This path bypasses the REST server's own method routing, so
460 // without an explicit check a GET navigation reached the handler.
461 if ( 'POST' !== strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) ) ) ) {
462 $this->emit_error(
463 new WP_Error(
464 'method_not_allowed',
465 __( 'This MCP endpoint accepts POST only.', 'templately' ),
466 [ 'status' => 405 ]
467 )
468 );
469 }
470
471 $request = new WP_REST_Request( 'POST', '/' . self::NAMESPACE . self::ROUTE );
472
473 // The REAL content type, not a hardcoded one. Hardcoding it made
474 // authorize()'s content-type gate — documented above as step 2 of a
475 // load-bearing order — a no-op on this route: it could never observe
476 // anything but application/json, so the whole check was dead code here
477 // and cross-origin protection rested on the Origin check alone.
478 $request->set_header(
479 'content-type',
480 isset( $_SERVER['CONTENT_TYPE'] )
481 ? sanitize_text_field( wp_unslash( $_SERVER['CONTENT_TYPE'] ) )
482 : ''
483 );
484
485 foreach ( [ 'authorization', 'origin' ] as $header ) {
486 $key = 'HTTP_' . strtoupper( str_replace( '-', '_', $header ) );
487
488 if ( ! empty( $_SERVER[ $key ] ) ) {
489 $request->set_header( $header, sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) );
490 }
491 }
492
493 $request->set_body( file_get_contents( 'php://input' ) );
494
495 $permission = $this->authorize( $request );
496
497 if ( is_wp_error( $permission ) ) {
498 $this->emit_error( $permission );
499 }
500
501 $response = $this->handle( $request );
502
503 status_header( $response->get_status() );
504 header( 'Content-Type: application/json; charset=utf-8' );
505 nocache_headers();
506
507 $data = $response->get_data();
508
509 if ( null !== $data ) {
510 echo wp_json_encode( $data );
511 }
512
513 exit;
514 }
515
516 /**
517 * @param WP_Error $error
518 * @return void
519 */
520 private function emit_error( WP_Error $error ): void {
521 $data = $error->get_error_data();
522 $status = (int) ( $data['status'] ?? 401 );
523
524 status_header( $status );
525 header( 'Content-Type: application/json; charset=utf-8' );
526
527 if ( ! empty( $data['www_authenticate'] ) ) {
528 header( 'WWW-Authenticate: ' . $data['www_authenticate'] );
529 }
530
531 if ( ! empty( $data['retry_after'] ) ) {
532 header( 'Retry-After: ' . (int) $data['retry_after'] );
533 }
534
535 echo wp_json_encode(
536 JsonRpc::error( null, JsonRpc::UNAUTHORIZED, $error->get_error_message() )
537 );
538
539 exit;
540 }
541 }
542