$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.
}