$server Server row from mcp-manager's * MCP Servers table. * @param string $client_id The freshly-generated OAuth client_id. * @param string $client_secret The freshly-generated OAuth client_secret. * @return string HTML block, escaped where necessary. * @since 0.9.2 */ public function get_setup_instructions( array $server, string $client_id, string $client_secret ): string { unset( $client_secret ); // Rendered separately behind the Reveal control — see docblock. $mcp_url = self::mcp_url_for_server( $server ); // The CLIENT_SECRET placeholder is intentionally an untranslated literal: // it sits inside a JSON string, where a translation containing a quote // would produce an invalid snippet. $snippet = sprintf( "{\n \"mcpServers\": {\n \"WordPress\": {\n \"url\": \"%s\",\n \"auth\": {\n \"CLIENT_ID\": \"%s\",\n \"CLIENT_SECRET\": \"PASTE_CLIENT_SECRET_HERE\"\n }\n }\n }\n}", $mcp_url, $client_id ); ob_start(); ?>
__( 'Cursor wants to connect to your site', 'acrossai-mcp-manager' ), 'subtitle' => __( 'This will let Cursor access the MCP tools this server exposes, acting as your WordPress user.', 'acrossai-mcp-manager' ), 'permissions_bullets' => array( __( 'Read and act on the tools exposed on this MCP server', 'acrossai-mcp-manager' ), __( 'Access the WordPress data your user account has permission to see', 'acrossai-mcp-manager' ), __( 'Connect until you revoke access from the Connectors/Integrations tab', 'acrossai-mcp-manager' ), ), ); } /** * Claim DCR-registered clients that are Cursor. * * Three checks, strongest evidence first, per * ARC-DCR-ATTRIBUTION-BY-REDIRECT-HOST: * * 1. Redirect URI host is Cursor-owned — covers the web / Cursor Agents * surface. Cryptographically meaningful: that host is where the * authorization code actually lands. * 2. Redirect URI is the desktop loopback callback. A bare `localhost` * host match would be far too broad, so this matches port AND path * as well — which makes it *narrower* than a host match, not looser, * and so consistent with the spirit of that decision. * 3. Whole-word `cursor` in the self-declared client_name. Weakest and * last, and never `str_contains`. * * If Cursor ever moves off port 8787, check 2 stops firing and attribution * degrades to check 3 rather than breaking outright. * * @param string $client_name DCR-submitted client_name. * @param array $redirect_uris DCR-submitted redirect_uris. * @return bool * @since 0.9.2 */ public function matches_dcr_client( string $client_name, array $redirect_uris ): bool { if ( self::dcr_matches_by_host( $redirect_uris, self::DCR_HOSTS ) ) { return true; } if ( self::matches_desktop_loopback( $redirect_uris ) ) { return true; } return (bool) preg_match( '/\bcursor\b/i', $client_name ); } /** * Whether any redirect URI is Cursor's desktop loopback callback — * loopback host AND port 8787 AND path `/callback`. * * All three components are required. Matching on the loopback host alone * would sweep up every other native OAuth client on the site. * * wp_parse_url returns IPv6 hosts bracketed (`[::1]`); normalize by * stripping the brackets, matching dcr_matches_by_host()'s treatment. * * @param array $redirect_uris DCR-submitted redirect_uris. * @return bool * @since 0.9.2 */ private static function matches_desktop_loopback( array $redirect_uris ): bool { foreach ( $redirect_uris as $uri ) { if ( ! is_string( $uri ) || '' === $uri ) { continue; } $parts = wp_parse_url( $uri ); if ( ! is_array( $parts ) ) { continue; } $host = strtolower( trim( (string) ( $parts['host'] ?? '' ), '[]' ) ); if ( ! in_array( $host, array( 'localhost', '127.0.0.1', '::1' ), true ) ) { continue; } if ( self::DESKTOP_LOOPBACK_PORT !== (int) ( $parts['port'] ?? 0 ) ) { continue; } if ( self::DESKTOP_LOOPBACK_PATH === (string) ( $parts['path'] ?? '' ) ) { return true; } } return false; } /** * Setup instructions for this connector, as a spec for the shared * renderer. * * The per-surface walkthroughs that used to live here moved to the * documentation site; what stays is the fast path plus anything the * operator cannot get anywhere else, such as a value derived from this * site. See {@see AbstractConnectorProfile::render_setup_panel()} for * the spec keys. * * Cursor uses Dynamic Client Registration, so no credentials need to be * pasted anywhere on the usual path. * * @param string $mcp_url MCP endpoint URL. * @return array Spec for the shared panel renderer. * @since 0.9.2 */ protected function get_setup_spec( string $mcp_url ): array { $spec = array( 'name' => 'Cursor', 'lead' => __( 'Cursor runs on your machine, so it can reach local sites that hosted assistants cannot.', 'acrossai-mcp-manager' ), 'open_label' => __( 'Open Cursor settings', 'acrossai-mcp-manager' ), 'open_url' => 'https://cursor.com/settings', 'steps' => array( array( 'text' => __( 'In Cursor, open Settings > MCP & Integrations', 'acrossai-mcp-manager' ) ), array( 'text' => __( 'Click New MCP Server to open the "Add a Custom MCP" dialog', 'acrossai-mcp-manager' ) ), array( 'text' => __( 'Fill in Name', 'acrossai-mcp-manager' ), 'copy' => $this->get_connector_name(), 'note' => __( 'Anything works — the name is only so you recognise this server in your list. This one matches the site.', 'acrossai-mcp-manager' ), ), array( 'text' => __( 'Leave Type set to URL, not Command', 'acrossai-mcp-manager' ) ), array( 'text' => __( 'Paste the MCP endpoint URL into Server URL', 'acrossai-mcp-manager' ), 'url' => true, ), array( 'text' => __( 'Leave Headers empty, click Add MCP, then approve the consent screen', 'acrossai-mcp-manager' ), 'note' => __( 'No headers are needed — Cursor registers itself and signs in through your browser.', 'acrossai-mcp-manager' ), 'open' => true, ), ), 'plan' => __( 'Custom MCP servers need a paid Cursor plan — the free Hobby plan does not include them.', 'acrossai-mcp-manager' ), 'docs_url' => 'https://acrossai.co/docs/connect-cursor-troubleshooting/', ); return $spec; } // render_tab_section is inherited from // AbstractConnectorProfile::render_default_card — the shared CSS + JS // bundle handles card layout, Generate/Regenerate buttons, and copy // behavior. This class is pure metadata: get_slug + get_name + // get_icon_url + get_redirect_uri_whitelist + get_setup_instructions + // get_consent_branding + matches_dcr_client + get_mcp_url_setup_html. }