settings = $settings; $this->token = new Oorank_Mcp_Token(); $this->tools = new Oorank_Mcp_Tools( $settings, $this->token ); $this->server = new Oorank_Mcp_Server( $this->tools ); } /** * Register hooks. */ public function hooks() { add_action( 'init', array( $this, 'register_rewrites' ) ); add_filter( 'query_vars', array( $this, 'add_query_var' ) ); add_action( 'parse_request', array( $this, 'maybe_serve' ) ); add_action( 'rest_api_init', array( $this, 'register_routes' ) ); add_action( 'wp_loaded', array( $this->tools, 'maybe_register_abilities' ) ); } /** * Whether the owner has switched MCP on. * * @return bool */ public function enabled() { $settings = $this->settings->get(); return ! empty( $settings['mcp']['enabled'] ); } /** * The token store, for the admin controller. * * @return Oorank_Mcp_Token */ public function token() { return $this->token; } /** * The tool registry. * * @return Oorank_Mcp_Tools */ public function tools() { return $this->tools; } /** * Register the pretty endpoint. Registered even when MCP is off so that * turning it on does not need a rewrite flush; the handler is what checks. */ public function register_rewrites() { add_rewrite_rule( '^' . self::PATH . '/?$', 'index.php?' . self::QUERY_VAR . '=1', 'top' ); } /** * Whitelist the query var. * * @param string[] $vars Registered query vars. * @return string[] */ public function add_query_var( $vars ) { $vars[] = self::QUERY_VAR; return $vars; } /** * Serve the pretty endpoint. * * Handled at parse_request rather than template_redirect: there is no * template involved, and the earlier exit keeps the main query, the theme * and every plugin that hooks rendering out of a machine transaction. * * @param WP $wp Current WordPress environment. */ public function maybe_serve( $wp ) { if ( empty( $wp->query_vars[ self::QUERY_VAR ] ) ) { return; } if ( ! $this->enabled() ) { // Off means absent, and absent means 404 — not the front page, // which is what dropping the query var alone would serve, and not // 403, which would confirm there is an endpoint here to find. $wp->query_vars = array( 'error' => '404' ); return; } $this->send( $this->respond() ); } /** * Register the REST fallback, plus the admin's own token routes. */ public function register_routes() { if ( $this->enabled() ) { register_rest_route( Oorank_Rest::NAMESPACE, '/mcp', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'rest_mcp' ), // Authentication is the bearer token, checked in the // callback so the refusal is a JSON-RPC error a client can // read rather than a REST envelope it cannot. 'permission_callback' => '__return_true', ) ); } register_rest_route( Oorank_Rest::NAMESPACE, '/mcp/status', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_status' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( Oorank_Rest::NAMESPACE, '/mcp/token', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'rest_issue_token' ), 'permission_callback' => array( $this, 'can_manage' ), ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'rest_revoke_token' ), 'permission_callback' => array( $this, 'can_manage' ), ), ) ); } /** * Permission callback for the admin routes. * * @return bool */ public function can_manage() { return Oorank_Roles::can(); } /** * REST fallback handler. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function rest_mcp( WP_REST_Request $request ) { $response = $this->respond( $request->get_body(), $request->get_header( 'authorization' ) ); $rest = new WP_REST_Response( $response['body'], $response['status'] ); if ( 401 === $response['status'] ) { $rest->header( 'WWW-Authenticate', 'Bearer realm="Oorank MCP"' ); } return $rest; } /** * GET /mcp/status — everything the admin screen needs, no secret in it. * * @return WP_REST_Response */ public function rest_status() { return rest_ensure_response( $this->status() ); } /** * The connection's state, for the admin UI. * * @return array */ public function status() { $info = $this->token->info(); $user = $info['user_id'] ? get_userdata( $info['user_id'] ) : null; return array( 'enabled' => $this->enabled(), 'endpoint' => $this->endpoint(), 'fallback' => rest_url( Oorank_Rest::NAMESPACE . '/mcp' ), 'pretty' => (bool) get_option( 'permalink_structure' ), 'token' => array( 'issued' => $info['issued'], 'hint' => $info['hint'], 'created' => $info['created'], 'last_used' => $info['last_used'], 'user' => $user ? $user->display_name : '', ), // Generated from the table, so the screen cannot drift from what // the server actually offers. 'abilities' => wp_list_pluck( $this->tools->listing(), 'description', 'title' ), ); } /** * The connection URL a client is given. * * @return string */ public function endpoint() { return get_option( 'permalink_structure' ) ? home_url( '/' . self::PATH ) : rest_url( Oorank_Rest::NAMESPACE . '/mcp' ); } /** * POST /mcp/token — issue or rotate. The plaintext is returned once and * never again; only its digest is stored. * * @return WP_REST_Response */ public function rest_issue_token() { $token = $this->token->generate(); return rest_ensure_response( array( 'token' => $token, 'status' => $this->status(), ) ); } /** * DELETE /mcp/token — revoke. * * @return WP_REST_Response */ public function rest_revoke_token() { $this->token->revoke(); return rest_ensure_response( array( 'revoked' => true, 'status' => $this->status(), ) ); } /** * Authenticate, decode and dispatch one request. * * @param string|null $body Raw body. Read from the input stream when null. * @param string|null $header Authorization header. Read from $_SERVER when null. * @return array{status:int, body:array|null} */ private function respond( $body = null, $header = null ) { if ( null === $header ) { $header = isset( $_SERVER['HTTP_AUTHORIZATION'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) ) : ''; } $user_id = $this->token->verify( $this->bearer( (string) $header ) ); if ( ! $user_id ) { return array( 'status' => 401, 'body' => $this->server->error( null, Oorank_Mcp_Server::INVALID_REQUEST, __( 'A valid connection token is required.', 'oorank' ) ), ); } // Everything downstream asks current_user_can(). The token's reach is // the issuing user's reach, no more. wp_set_current_user( $user_id ); if ( null === $body ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- bearer token authenticated above; this is a machine endpoint with no browser session or form. $body = file_get_contents( 'php://input' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading the request body, not a remote resource. } $message = json_decode( (string) $body, true ); if ( null === $message && JSON_ERROR_NONE !== json_last_error() ) { return array( 'status' => 400, 'body' => $this->server->error( null, Oorank_Mcp_Server::PARSE_ERROR, __( 'Request body is not valid JSON.', 'oorank' ) ), ); } $response = $this->server->handle( $message ); // A notification is answered with no body at all, per JSON-RPC. return array( 'status' => null === $response ? 204 : 200, 'body' => $response, ); } /** * Pull the token out of an Authorization header. * * @param string $header Header value. * @return string */ private function bearer( $header ) { if ( 0 !== stripos( $header, 'bearer ' ) ) { return ''; } return trim( substr( $header, 7 ) ); } /** * Emit a response for the rewrite endpoint and stop. * * @param array $response Status and body. */ private function send( array $response ) { nocache_headers(); status_header( $response['status'] ); if ( 401 === $response['status'] ) { header( 'WWW-Authenticate: Bearer realm="Oorank MCP"' ); } if ( null === $response['body'] ) { exit; } header( 'Content-Type: application/json; charset=utf-8' ); echo wp_json_encode( $response['body'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); exit; } }