ID : 0; }); add_filter('rest_pre_serve_request', [$this, 'serve'], 10, 3); add_action('rest_api_init', function (): void { register_rest_route(McpConfig::NS, McpConfig::ROUTE, [ 'methods' => \WP_REST_Server::ALLMETHODS, 'callback' => [$this, 'handle'], 'permission_callback' => [$this, 'permit'], ]); }); } /** @return true|\WP_Error */ public function permit(\WP_REST_Request $request): bool|\WP_Error { if ($request->get_method() !== 'POST') { return new \WP_Error('aiwend_mcp_method', __('Use POST.', 'aiwend'), ['status' => 405]); } $origin = (string) $request->get_header('origin'); if ($origin !== '' && !self::sameOrigin($origin)) { return new \WP_Error('aiwend_mcp_origin', __('This origin is not allowed.', 'aiwend'), ['status' => 403]); } $user = get_current_user_id(); if ($user <= 0 || $user !== self::$appPasswordUser) { return new \WP_Error('aiwend_mcp_unauthenticated', __('Authenticate with an application password.', 'aiwend'), ['status' => 401]); } if (!current_user_can(Capabilities::USE_MCP)) { return new \WP_Error('aiwend_mcp_forbidden', __('You do not have permission to use this endpoint.', 'aiwend'), ['status' => 403]); } return true; } public function handle(\WP_REST_Request $request): \WP_REST_Response { $server = McpConfig::server($this->plugin, McpConfig::catalog($this->plugin)); $body = (string) $request->get_body(); if (strlen($body) > self::MAX_BODY) { return $this->wrap($server->error(413, null, -32600, 'Request too large')); } if (!str_starts_with(strtolower((string) $request->get_header('content_type')), 'application/json')) { return $this->wrap($server->error(415, null, -32600, 'Content-Type must be application/json')); } if (!$this->allowed(get_current_blog_id(), get_current_user_id())) { return $this->wrap($server->error(429, null, -32000, 'Too many requests')); } $headers = []; foreach (['mcp-protocol-version', 'mcp-method', 'mcp-name'] as $h) { $v = $request->get_header($h); if (is_string($v) && $v !== '') { $headers[$h] = $v; } } try { $response = $server->handle($this->plugin->currentPrincipal(), $headers, $body); } catch (\Throwable) { // never echo an exception: a tool or another plugin may have put a path, a query or a secret in it $response = $server->error(500, null, -32603, 'Internal error'); } return $this->wrap($response); } /** Serves the exact JSON-RPC text (and an empty body for a 202). */ public function serve($served, $result, $request) { if ($served || !$result instanceof McpRestResponse) { return $served; } echo $result->raw; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- the response body is JSON-RPC text built by McpResponse::json() (application/json), not HTML; HTML escaping would corrupt it return true; } private function wrap(McpResponse $r): McpRestResponse { $out = new McpRestResponse($r->body, $r->status); $out->raw = $r->json(); $out->header('Content-Type', 'application/json; charset=utf-8'); $out->header('Cache-Control', 'no-store'); $out->header('X-Content-Type-Options', 'nosniff'); return $out; } private static function sameOrigin(string $origin): bool { $host = strtolower((string) wp_parse_url($origin, PHP_URL_HOST)); $port = (int) wp_parse_url($origin, PHP_URL_PORT); foreach ([home_url(), site_url()] as $own) { if ($host !== '' && $host === strtolower((string) wp_parse_url($own, PHP_URL_HOST)) && $port === (int) wp_parse_url($own, PHP_URL_PORT)) { return true; } } return false; } private function allowed(int $site, int $user): bool { $limit = static fn(string $filter, int $default): int => max(0, (int) apply_filters($filter, $default)); $l = new FixedWindowLimiter('get_transient', 'set_transient', null, static fn(string $k): bool => self::lock($k), static function (string $k): void { self::unlock($k); }); return $l->allow('aiwend_mcp_u_' . $site . '_' . $user, $limit('aiwend_mcp_user_limit', 60), 60) && $l->allow('aiwend_mcp_s_' . $site, $limit('aiwend_mcp_site_limit', 300), 60); } private static function lock(string $key): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- a named database lock cannot use the WordPress API or a cache; the statement is prepared $held = $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 2)', 'aiwend_' . substr(md5($key), 0, 32))); return $held === null || (int) $held === 1; } private static function unlock(string $key): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- releases the named lock taken above $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', 'aiwend_' . substr(md5($key), 0, 32))); } }