owner, name, when made and last used. */ const OPT_KEYS = 'admindra_mcp_keys'; /** REST namespace and route of the key endpoint. */ const NS = 'mcp'; const ROUTE = 'admindra-mcp'; /** How a key starts, so it is recognised and never mistaken for an OAuth token. */ const PREFIX = 'admmcp_'; /** At most this many keys per administrator. */ const MAX = 20; /** Clients the screen writes a configuration for. */ const CLIENTS = array( 'cursor' => 'Cursor', 'vscode' => 'VS Code', 'other' => 'AI tool', ); /** * Whether this request was signed in with a key. * * @var bool */ private static $by_key = false; /** * Hook everything up. */ public static function boot() { add_action( 'mcp_adapter_init', array( __CLASS__, 'server' ) ); add_filter( 'determine_current_user', array( __CLASS__, 'sign_in' ), 30 ); add_action( 'wp_ajax_admindra_mcp_key', array( __CLASS__, 'create' ) ); add_action( 'admin_post_admindra_mcp_key_delete', array( __CLASS__, 'delete' ) ); } /** * The key endpoint's URL. * * @return string */ public static function url() { return rest_url( self::NS . '/' . self::ROUTE ); } /** * Register the key endpoint: MCP Adapter's own HTTP transport, the same * three tools as the OAuth endpoint, and only a key at the door. * * @param object $adapter The MCP Adapter. */ public static function server( $adapter ) { if ( ! class_exists( '\WP\MCP\Transport\HttpTransport' ) || ! is_object( $adapter ) || ! method_exists( $adapter, 'create_server' ) ) { return; } $adapter->create_server( 'admindra-mcp-key', self::NS, self::ROUTE, 'AdMindra MCP', 'AdMindra MCP for AI tools that sign in with a key', ADMINDRA_MCP_VERSION, array( '\WP\MCP\Transport\HttpTransport' ), class_exists( '\WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler' ) ? '\WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler' : null, null, array( 'mcp-adapter/discover-abilities', 'mcp-adapter/get-ability-info', 'mcp-adapter/execute-ability' ), array(), array(), array( __CLASS__, 'permission' ) ); } /** * The key endpoint's door: an administrator, signed in with a key. * * @return bool */ public static function permission() { return self::$by_key && is_user_logged_in() && current_user_can( 'manage_options' ); } /** * Sign a request for the key endpoint in with its key. Any other request, * and a request somebody is signed in to already, is left alone. * * @param int|false $user_id The user WordPress found so far. * @return int|false */ public static function sign_in( $user_id ) { if ( $user_id ) { return $user_id; } // The header first: it is only read, so a request without a key (every // browser in wp-admin) never gets as far as working out the route. $key = self::presented(); if ( '' === $key || ! self::on_route() ) { return $user_id; } $keys = self::all(); $hash = self::hash( $key ); if ( ! isset( $keys[ $hash ] ) || ! get_user_by( 'id', (int) $keys[ $hash ]['user'] ) ) { return $user_id; } if ( time() - (int) ( $keys[ $hash ]['used'] ?? 0 ) > HOUR_IN_SECONDS ) { $keys[ $hash ]['used'] = time(); update_option( self::OPT_KEYS, $keys, false ); } self::$by_key = true; return (int) $keys[ $hash ]['user']; } /** * The key in this request's Authorization header, or ''. * * @return string */ private static function presented() { foreach ( array( 'HTTP_AUTHORIZATION', 'REDIRECT_HTTP_AUTHORIZATION' ) as $name ) { $header = isset( $_SERVER[ $name ] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER[ $name ] ) ) ) : ''; if ( 1 === preg_match( '/\ABearer\s+(' . self::PREFIX . '[A-Za-z0-9_-]{43})\z/D', $header, $m ) ) { return $m[1]; } } return ''; } /** * Whether this request is for the key endpoint. Read from the request * itself, not only from WordPress's parsed route: a plugin that asks who * is signed in before WordPress has parsed the request settles the user * for the whole request, and the key must count then too. * * That can be as early as plugins_loaded, before init, so nothing here may * need the rewrite rules: rest_url() reads $wp_rewrite, which does not * exist yet, and stopped every page with a fatal error (1.2.0, a plugin * calling current_user_can() on plugins_loaded). * * @return bool */ public static function on_route() { $want = '/' . self::NS . '/' . self::ROUTE; if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) { return false; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST && isset( $GLOBALS['wp']->query_vars['rest_route'] ) ) { return '/' . trim( (string) $GLOBALS['wp']->query_vars['rest_route'], '/' ) === $want; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- only compared, never stored or printed. if ( isset( $_GET['rest_route'] ) && is_string( $_GET['rest_route'] ) ) { return '/' . trim( sanitize_text_field( wp_unslash( $_GET['rest_route'] ) ), '/' ) === $want; // phpcs:ignore WordPress.Security.NonceVerification.Recommended } $path = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_parse_url( esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ), PHP_URL_PATH ) : ''; return '' !== $path && in_array( untrailingslashit( $path ), self::paths(), true ); } /** * The key endpoint's address paths on this site, from home and the REST * prefix alone: with pretty permalinks and with the index.php kind that * rest_url() would pick from the rewrite rules. * * @return string[] */ private static function paths() { $home = untrailingslashit( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ) ); $route = '/' . trim( rest_get_url_prefix(), '/' ) . '/' . self::NS . '/' . self::ROUTE; return array( $home . $route, $home . '/index.php' . $route ); } /** * A key's stored hash. * * @param string $key The key. * @return string */ private static function hash( $key ) { return hash_hmac( 'sha256', $key, wp_salt( 'auth' ) ); } /** * Every key, by hash. * * @return array[] */ private static function all() { $keys = get_option( self::OPT_KEYS, array() ); return is_array( $keys ) ? $keys : array(); } /** * A user's keys, newest first, without their hashes. * * @param int $user_id User. * @return array[] */ public static function keys( $user_id ) { $mine = array(); foreach ( self::all() as $key ) { if ( (int) ( $key['user'] ?? 0 ) === (int) $user_id ) { $mine[] = $key; } } usort( $mine, static fn( $a, $b ) => (int) $b['made'] <=> (int) $a['made'] ); return $mine; } /** * Make a key, from the settings screen. The key is in the answer and * nowhere else. */ public static function create() { check_ajax_referer( 'admindra_mcp_key' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Only an administrator can make a key.', 'admindra-mcp' ) ), 403 ); } $client = isset( $_POST['client'] ) ? sanitize_key( wp_unslash( $_POST['client'] ) ) : ''; if ( ! isset( self::CLIENTS[ $client ] ) ) { $client = 'other'; } $user = get_current_user_id(); if ( count( self::keys( $user ) ) >= self::MAX ) { wp_send_json_error( array( 'message' => __( 'You have 20 keys. Delete one you no longer use first.', 'admindra-mcp' ) ), 400 ); } $key = self::PREFIX . rtrim( strtr( base64_encode( random_bytes( 32 ) ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- encodes random bytes, not obfuscation. $id = wp_generate_uuid4(); $keys = self::all(); /* translators: %s: the AI tool, e.g. Cursor */ $name = sprintf( __( 'AdMindra MCP · %s', 'admindra-mcp' ), self::CLIENTS[ $client ] ); $keys[ self::hash( $key ) ] = array( 'id' => $id, 'user' => $user, 'name' => $name, 'made' => time(), 'used' => 0, ); update_option( self::OPT_KEYS, $keys, false ); nocache_headers(); wp_send_json_success( array( 'auth' => 'Bearer ' . $key, 'name' => $name, ) ); } /** * Delete one of your own keys. The tool loses access at once. */ public static function delete() { check_admin_referer( 'admindra_mcp_key_delete' ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to do that.', 'admindra-mcp' ), '', array( 'response' => 403 ) ); } $id = isset( $_POST['key'] ) ? sanitize_text_field( wp_unslash( $_POST['key'] ) ) : ''; $keys = self::all(); foreach ( $keys as $hash => $key ) { if ( ( $key['id'] ?? '' ) === $id && (int) $key['user'] === get_current_user_id() ) { unset( $keys[ $hash ] ); } } update_option( self::OPT_KEYS, $keys, false ); wp_safe_redirect( AdMindra_MCP::page_url() ); exit; } /** * "Cursor, VS Code and other AI tools" on the settings screen. */ public static function panel() { $name = substr( sanitize_title( get_bloginfo( 'name' ) ), 0, 40 ); echo '
' . esc_html__( 'Cursor, VS Code and other AI tools', 'admindra-mcp' ) . ''; echo '

' . esc_html__( 'These sign in with a key instead. The key opens AdMindra MCP on this site and nothing else in WordPress, with your administrator rights, until you delete it here.', 'admindra-mcp' ) . '

'; echo '
'; echo '
'; foreach ( array( 'cursor' => 'Cursor', 'vscode' => 'VS Code', 'other' => __( 'Other', 'admindra-mcp' ), ) as $id => $label ) { echo ''; } echo '
'; echo ''; echo ''; echo ''; $keys = self::keys( get_current_user_id() ); if ( $keys ) { echo ''; } echo '

' . esc_html__( 'A deleted key stops working at once. Deleting the plugin deletes every key.', 'admindra-mcp' ) . '

'; echo '
'; } }