diff --git a/includes/class-openclawp-mcp-admin.php b/includes/class-openclawp-mcp-admin.php index a29c391..ad1adf8 100644 --- a/includes/class-openclawp-mcp-admin.php +++ b/includes/class-openclawp-mcp-admin.php @@ -16,9 +16,14 @@ final class OpenclaWP_Mcp_Admin { public const PAGE_SLUG = 'openclawp-mcp-servers'; + public const ACTION_REGENERATE = 'openclawp_mcp_regenerate_token'; + public const ACTION_ACKNOWLEDGE = 'openclawp_mcp_acknowledge_token'; + public static function register(): void { add_action( 'admin_menu', array( __CLASS__, 'register_submenu' ), 20 ); add_action( 'admin_init', array( __CLASS__, 'handle_post' ) ); + add_action( 'admin_post_' . self::ACTION_REGENERATE, array( __CLASS__, 'handle_regenerate_token' ) ); + add_action( 'admin_post_' . self::ACTION_ACKNOWLEDGE, array( __CLASS__, 'handle_acknowledge_token' ) ); } public static function register_submenu(): void { @@ -89,18 +94,61 @@ public static function handle_post(): void { // phpcs:enable } + /** + * Slug-keyed regenerate routed through `admin-post.php`. Mirrors the + * post-create disclosure flow: rotate the bearer, then redirect back + * to the list view with `?regenerated=` so the next render + * shows the new token + client config snippets. + */ + public static function handle_regenerate_token(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have permission to regenerate this token.', 'openclawp' ), 403 ); + } + // phpcs:disable WordPress.Security.NonceVerification.Missing + $slug = isset( $_REQUEST['slug'] ) ? sanitize_title( wp_unslash( (string) $_REQUEST['slug'] ) ) : ''; + // phpcs:enable + check_admin_referer( self::ACTION_REGENERATE . '_' . $slug ); + + $token = OpenclaWP_Mcp_Server_Store::regenerate_token( $slug ); + if ( null === $token ) { + self::redirect( array( 'error' => 'unknown_slug' ) ); + } + self::redirect( array( 'regenerated' => $slug ) ); + } + + /** + * "I've saved this" — purge the flash transient so subsequent + * refreshes can no longer reveal the plaintext token, then bounce + * back to the list view. + */ + public static function handle_acknowledge_token(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have permission to acknowledge this token.', 'openclawp' ), 403 ); + } + // phpcs:disable WordPress.Security.NonceVerification.Missing + $post_id = isset( $_REQUEST['post_id'] ) ? (int) $_REQUEST['post_id'] : 0; + // phpcs:enable + check_admin_referer( self::ACTION_ACKNOWLEDGE . '_' . $post_id ); + + if ( $post_id > 0 ) { + OpenclaWP_Mcp_Server_Store::acknowledge_token( $post_id ); + } + self::redirect(); + } + public static function render_page(): void { // phpcs:disable WordPress.Security.NonceVerification.Recommended - $action = isset( $_GET['action'] ) ? sanitize_key( (string) $_GET['action'] ) : ''; - $created = isset( $_GET['created'] ) ? (int) $_GET['created'] : 0; - $rotated = isset( $_GET['rotated'] ) ? (int) $_GET['rotated'] : 0; - $error = isset( $_GET['error'] ) ? sanitize_key( (string) $_GET['error'] ) : ''; + $action = isset( $_GET['action'] ) ? sanitize_key( (string) $_GET['action'] ) : ''; + $created = isset( $_GET['created'] ) ? (int) $_GET['created'] : 0; + $rotated = isset( $_GET['rotated'] ) ? (int) $_GET['rotated'] : 0; + $regenerated = isset( $_GET['regenerated'] ) ? sanitize_title( wp_unslash( (string) $_GET['regenerated'] ) ) : ''; + $error = isset( $_GET['error'] ) ? sanitize_key( (string) $_GET['error'] ) : ''; // phpcs:enable echo '
'; echo '

' . esc_html__( 'openclaWP — MCP Servers', 'openclawp' ) . '

'; echo '

' . esc_html( - __( 'Let external AI clients like Claude Code, Cursor, or VS Code call one of your agent\'s tools. Each MCP server gets its own URL and a bearer token shown once on creation — copy it then.', 'openclawp' ) + __( 'Let external AI clients like Claude Code, Cursor, or VS Code call one of your agent\'s tools. Each server gets its own URL and a bearer token. Tokens are recoverable for 15 minutes after creation or regeneration — after that, regenerate to get a fresh one.', 'openclawp' ) ) . '

'; if ( OpenclaWP_Bootstrap::legacy_mcp_enabled() ) { @@ -116,10 +164,16 @@ public static function render_page(): void { ); } if ( $created > 0 ) { - self::render_token_flash( $created, __( 'Server created. Copy this bearer token now — it will not be shown again:', 'openclawp' ) ); + self::render_token_disclosure( $created, __( 'Server created. Copy this bearer token now — it stays recoverable on this page for 15 minutes:', 'openclawp' ) ); } if ( $rotated > 0 ) { - self::render_token_flash( $rotated, __( 'Token rotated. Copy the new bearer — the previous token is no longer valid:', 'openclawp' ) ); + self::render_token_disclosure( $rotated, __( 'Token regenerated. Copy the new bearer — the previous token is no longer valid:', 'openclawp' ) ); + } + if ( '' !== $regenerated ) { + $server = OpenclaWP_Mcp_Server_Store::find_by_slug( $regenerated ); + if ( null !== $server ) { + self::render_token_disclosure( $server->ID, __( 'Token regenerated. Copy the new bearer — the previous token is no longer valid:', 'openclawp' ) ); + } } if ( 'new' === $action ) { @@ -162,6 +216,17 @@ private static function render_list(): void { $enabled = OpenclaWP_Mcp_Server_Store::is_enabled( $post ); $last4 = OpenclaWP_Mcp_Server_Store::token_last4( $post ); + $regenerate_url = wp_nonce_url( + add_query_arg( + array( + 'action' => self::ACTION_REGENERATE, + 'slug' => $post->post_name, + ), + admin_url( 'admin-post.php' ) + ), + self::ACTION_REGENERATE . '_' . $post->post_name + ); + echo ''; echo '' . esc_html( $post->post_title ) . '
' . esc_html( $post->post_name ) . ''; echo '' . esc_html( $adapter_endpoint ) . ''; @@ -170,13 +235,11 @@ private static function render_list(): void { } echo ''; echo '' . esc_html( OpenclaWP_Mcp_Server_Store::agent_slug( $post ) ) . ''; - echo 'op_…' . esc_html( $last4 ) . ' '; - self::action_button( 'rotate', $post->ID, __( 'Rotate', 'openclawp' ), 'button-link' ); - echo ''; + echo 'op_…' . esc_html( $last4 ) . ''; echo '' . ( $enabled ? esc_html__( 'enabled', 'openclawp' ) : '' . esc_html__( 'disabled', 'openclawp' ) . '' ) . ''; echo ''; self::action_button( 'toggle', $post->ID, $enabled ? __( 'Disable', 'openclawp' ) : __( 'Enable', 'openclawp' ), 'button-secondary', array( 'enabled' => $enabled ? '' : '1' ) ); - echo ' '; + echo ' ' . esc_html__( 'Regenerate token', 'openclawp' ) . ' '; self::action_button( 'delete', $post->ID, __( 'Delete', 'openclawp' ), 'button-link-delete' ); echo ''; echo ''; @@ -229,16 +292,122 @@ private static function render_create(): void { post_name; + $endpoint = rest_url( OpenclaWP_Mcp_Rest::NAMESPACE . '/mcp-adapter/' . $slug ); + $ack_url = admin_url( 'admin-post.php' ); + $ack_nonce = self::ACTION_ACKNOWLEDGE . '_' . $post_id; + echo '

' . esc_html( $intro ) . '

'; echo '

' . esc_html( $token ) . '

'; + echo '

' . esc_html__( 'Recoverable for 15 minutes after creation or regeneration. Acknowledging below purges it immediately.', 'openclawp' ) . '

'; + + self::render_client_snippets( $slug, $endpoint, $token ); + + echo '
' . esc_html__( "I've saved this — go to the server list", 'openclawp' ) . ''; + echo '
'; + wp_nonce_field( $ack_nonce ); + echo ''; + echo ''; + echo ''; + echo '
'; echo '
'; } + /** + * Render copy-pasteable config snippets for the named clients in + * the page subtitle. Stacked cards (no JS dependency beyond the + * inline Copy button) so we don't drag in a tabs bundle. + */ + private static function render_client_snippets( string $slug, string $endpoint, string $token ): void { + $snippets = array( + array( + 'label' => __( 'Claude Code (.mcp.json)', 'openclawp' ), + 'language' => 'json', + 'body' => self::snippet_claude_code( $slug, $endpoint, $token ), + ), + array( + 'label' => __( 'Cursor (.cursor/mcp.json)', 'openclawp' ), + 'language' => 'json', + 'body' => self::snippet_cursor( $slug, $endpoint, $token ), + ), + array( + 'label' => __( 'VS Code (Continue / Cline) — JSON', 'openclawp' ), + 'language' => 'json', + 'body' => self::snippet_vscode( $slug, $endpoint, $token ), + ), + ); + + echo '
'; + echo '

' . esc_html__( 'Client config snippets', 'openclawp' ) . '

'; + + foreach ( $snippets as $i => $snippet ) { + $dom_id = 'openclawp-mcp-snippet-' . (int) $i . '-' . sanitize_html_class( $slug ); + echo '
'; + echo '

'; + echo '' . esc_html( $snippet['label'] ) . ''; + printf( + '', + esc_attr( + sprintf( + 'var el=document.getElementById(%s);if(el){navigator.clipboard.writeText(el.textContent).then(function(){this.textContent=%s}.bind(this))}return false;', + (string) wp_json_encode( $dom_id ), + (string) wp_json_encode( __( 'Copied', 'openclawp' ) ) + ) + ), + esc_html__( 'Copy', 'openclawp' ) + ); + echo '

'; + echo '
';
+			echo esc_html( $snippet['body'] );
+			echo '
'; + echo '
'; + } + + echo '

' . esc_html__( 'VS Code MCP support varies by extension (Continue, Cline, MCP Inspector, etc.); the JSON above matches the most common HTTP-transport shape — adjust the wrapper key if your extension uses a different schema.', 'openclawp' ) . '

'; + echo '
'; + } + + private static function snippet_claude_code( string $slug, string $endpoint, string $token ): string { + return (string) wp_json_encode( + array( + 'mcpServers' => array( + $slug => array( + 'transport' => 'http', + 'url' => $endpoint, + 'headers' => array( 'Authorization' => 'Bearer ' . $token ), + ), + ), + ), + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES + ); + } + + private static function snippet_cursor( string $slug, string $endpoint, string $token ): string { + // Same shape as Claude Code — Cursor's .cursor/mcp.json mirrors the + // `mcpServers` map convention. + return self::snippet_claude_code( $slug, $endpoint, $token ); + } + + private static function snippet_vscode( string $slug, string $endpoint, string $token ): string { + return self::snippet_claude_code( $slug, $endpoint, $token ); + } + private static function action_button( string $action, int $post_id, string $label, string $css_class, array $extra = array() ): void { printf( '
', diff --git a/includes/class-openclawp-mcp-server-store.php b/includes/class-openclawp-mcp-server-store.php index 34335b3..3972ad9 100644 --- a/includes/class-openclawp-mcp-server-store.php +++ b/includes/class-openclawp-mcp-server-store.php @@ -9,9 +9,11 @@ * enabled/disabled (`publish` / `draft`) so we don't need an extra meta * lookup at request time. * - * The plaintext bearer token is shown to the admin exactly once via a - * flash transient — only its `wp_hash_password()` hash + last four chars - * persist. Regenerate produces a new token + new hash and immediately + * The plaintext bearer token is recoverable for 15 minutes after create + * or regenerate via a per-user flash transient — only its + * `wp_hash_password()` hash + last four chars persist. Acknowledging the + * disclosure (or letting the transient expire) purges the plaintext; + * regenerate then produces a new token + new hash and immediately * invalidates the old one. * * @package OpenclaWP @@ -28,6 +30,14 @@ final class OpenclaWP_Mcp_Server_Store { public const META_TOKEN_HASH = '_openclawp_mcp_token_hash'; public const META_TOKEN_LAST4 = '_openclawp_mcp_token_last4'; + /** + * How long the post-create / post-regenerate plaintext token stays + * recoverable to the admin who triggered it. Long enough to copy + * into a config file even after an accidental refresh, short enough + * not to be a meaningful security hole. + */ + public const TOKEN_FLASH_TTL = 15 * MINUTE_IN_SECONDS; + public static function register_post_type(): void { register_post_type( self::POST_TYPE, @@ -141,6 +151,18 @@ public static function rotate_token( int $post_id ): string { return $token; } + /** + * Slug-keyed wrapper around `rotate_token()`. Returns the new + * plaintext token, or null when the slug does not resolve. + */ + public static function regenerate_token( string $slug ): ?string { + $post = self::find_by_slug( $slug ); + if ( null === $post ) { + return null; + } + return self::rotate_token( $post->ID ); + } + public static function toggle_enabled( int $post_id, bool $enabled ): bool { $new_status = $enabled ? 'publish' : 'draft'; $result = wp_update_post( @@ -194,7 +216,10 @@ public static function is_enabled( \WP_Post $post ): bool { /** * Stash a plaintext token in a per-user flash transient so the admin - * page can show it once after create / rotate. TTL = 60 s. + * page can recover it after a create / regenerate. TTL is + * `TOKEN_FLASH_TTL` so an accidental refresh isn't terminal — long + * enough to copy into a config file, short enough that an + * unattended browser tab isn't a meaningful exposure. */ public static function flash_token( int $post_id, string $token ): void { $user_id = get_current_user_id(); @@ -204,24 +229,53 @@ public static function flash_token( int $post_id, string $token ): void { set_transient( self::flash_key( $user_id, $post_id ), $token, - 60 + self::TOKEN_FLASH_TTL ); } - public static function pop_flashed_token( int $post_id ): ?string { + /** + * Non-destructive read. The token stays in the transient (until it + * expires or the admin explicitly acknowledges) so refreshing the + * disclosure page keeps showing it. + */ + public static function peek_flashed_token( int $post_id ): ?string { $user_id = get_current_user_id(); if ( $user_id <= 0 ) { return null; } - $key = self::flash_key( $user_id, $post_id ); - $value = get_transient( $key ); + $value = get_transient( self::flash_key( $user_id, $post_id ) ); if ( false === $value ) { return null; } - delete_transient( $key ); return (string) $value; } + /** + * Admin confirmed they've saved the token — purge the plaintext so + * subsequent refreshes can no longer reveal it. + */ + public static function acknowledge_token( int $post_id ): void { + $user_id = get_current_user_id(); + if ( $user_id <= 0 ) { + return; + } + delete_transient( self::flash_key( $user_id, $post_id ) ); + } + + /** + * Legacy single-use accessor. Retained so older callers keep + * working; new code should pair `peek_flashed_token()` with + * `acknowledge_token()` so accidental refreshes aren't terminal. + */ + public static function pop_flashed_token( int $post_id ): ?string { + $value = self::peek_flashed_token( $post_id ); + if ( null === $value ) { + return null; + } + self::acknowledge_token( $post_id ); + return $value; + } + private static function flash_key( int $user_id, int $post_id ): string { return sprintf( '_openclawp_mcp_token_flash_%d_%d', $user_id, $post_id ); }