register_classes( self::ABILITY_CLASSES ); } /** * Ability IDs this module contributes. Derived from the descriptors so it * can never drift from ABILITY_CLASSES. * * @return string[] */ public static function ability_ids(): array { $registry = ToolRegistry::get_instance(); $ids = []; foreach ( self::ABILITY_CLASSES as $class ) { $descriptor = $registry->get( $class::ID ); if ( null !== $descriptor ) { $ids[] = $descriptor->id; } } return $ids; } /** * Validate an MCP OAuth token's format before it is ever used to build * a transient key or read back from `$_GET['redirect-to']`. Matches * wp_generate_password(32, false)'s charset (alphanumeric only) — the * exact generator AuthLoginWithGoogleAbility::execute() uses. This also * happens to be what distinguishes our token from a genuine * `redirect-to` value (always path/URL-shaped, never a bare 32-char * alnum string) — see intercept_mcp_oauth_callback(). Raw, unvalidated * request data must never be interpolated into an option/transient key. * * @param mixed $token * @return bool */ public static function is_valid_oauth_state( $token ): bool { return is_string( $token ) && 1 === preg_match( '/^[A-Za-z0-9]{32}$/', $token ); } /** * Intercept an MCP-initiated Google OAuth callback and show the raw * `api_key` directly to the user instead of letting * Plugin::google_login_handler() (priority 10, same `init` hook) * auto-consume it via Login::login(). * * `templately/auth-login-with-google` (AuthLoginWithGoogleAbility) runs * headlessly — there is no browser cookie session at URL-generation * time to attribute a connection to, and the browser completing the * OAuth redirect may not be logged into wp-admin either. Rather than * trying to bridge attribution into that ambient session (the previous * approach here — see git history / this module's CLAUDE.md), this instead * skips Plugin::google_login_handler() entirely for MCP-initiated logins and * shows the api_key on-screen for the user to copy back to the agent, * which then calls the ALREADY-EXISTING `templately/auth-login-with-api-key` * ability in its own genuinely-authenticated MCP session — where * get_current_user_id() resolves correctly with no bridging needed at * all. templately-backend's own docs confirm the api_key returned here * is the same permanent, reusable API key `connectWithApiKey` expects * (not a one-time exchange token) — see this module's CLAUDE.md. * * AuthLoginWithGoogleAbility::execute() marks a login as MCP-initiated * via a one-time, 5-minute transient keyed by a token it generates * itself and passes as `google_auth_url()`'s `$redirect_to` param, * which gets folded into the `site_url` sent to app.templately.com. * Live testing (and templately-backend's own docs) confirm `site_url`'s * contents come back intact on redirect, unlike the URL's own separate * `state` param (cached server-side for the Google round-trip only, * never echoed back to the site — do not key off that). * * When the browser lands back on this site with `templately_google_login` * + `redirect-to` matching our token format AND a live transient, this * callback (priority 5, before Plugin's priority 10) renders the * api_key/error page and exits — Plugin::google_login_handler() never * runs at all for this request. No-ops silently (leaving `$_GET` * untouched, letting Plugin's handler run as normal) when `redirect-to` * isn't present, doesn't match the token format, or has no matching * transient — the normal case for a human clicking "Connect" from an * already-authenticated wp-admin session, or a genuine editor-path * redirect. This must never change behavior for those existing flows. * * @return void */ public function intercept_mcp_oauth_callback(): void { $token = self::find_mcp_oauth_token(); if ( null === $token ) { return; } // One-time use — replay protection. delete_transient( self::OAUTH_TOKEN_TRANSIENT_PREFIX . $token ); $api_key = ! empty( $_GET['api_key'] ) ? sanitize_text_field( wp_unslash( $_GET['api_key'] ) ) : ''; $error = ! empty( $_GET['error'] ) ? sanitize_text_field( wp_unslash( $_GET['error'] ) ) : ''; $this->render_mcp_oauth_key_page( $api_key, $error ); exit; } /** * Whether the current request is an MCP-initiated OAuth callback with a * still-live transient, and if so, the token itself. Split out from * intercept_mcp_oauth_callback() — a pure read-only check (no * transient deletion, no rendering, no exit) so it unit-tests cleanly. * Returns null for: a non-OAuth-callback request, a missing/malformed * `redirect-to` (including a genuine path-shaped one — never matches * the 32-char alnum format), or an expired/already-consumed transient. * * @return string|null */ public static function find_mcp_oauth_token(): ?string { if ( empty( $_GET['templately_google_login'] ) || empty( $_GET['redirect-to'] ) ) { return null; } $token = sanitize_text_field( wp_unslash( $_GET['redirect-to'] ) ); if ( ! self::is_valid_oauth_state( $token ) ) { return null; } if ( empty( get_transient( self::OAUTH_TOKEN_TRANSIENT_PREFIX . $token ) ) ) { return null; } return $token; } /** * Render a minimal, standalone HTML page (no wp-admin chrome needed — * this never depends on any wp-admin session) showing the api_key for * the user to copy back to the agent, or the error if Google/Templately * sign-in failed. Not unit-tested (headers + exit side effects); the * decision to call it lives in intercept_mcp_oauth_callback(), which is. * * @param string $api_key * @param string $error * @return void */ private function render_mcp_oauth_key_page( string $api_key, string $error ): void { nocache_headers(); header( 'Content-Type: text/html; charset=utf-8' ); if ( '' !== $api_key ) { $title = __( 'Templately Sign-In Complete', 'templately' ); $body = sprintf( '

%1$s

%2$s

%3$s

', esc_html__( 'Copy this key and give it to the agent to finish connecting:', 'templately' ), esc_html( $api_key ), esc_html__( 'You can close this tab afterward.', 'templately' ) ); } else { $title = __( 'Templately Sign-In Failed', 'templately' ); $message = '' !== $error ? sprintf( /* translators: %s: error returned by Google/Templately */ __( 'Sign-in failed: %s', 'templately' ), $error ) : __( 'Sign-in failed. Please try again.', 'templately' ); $body = sprintf( '

%s

', esc_html( $message ) ); } printf( '%1$s

%1$s

%2$s', esc_html( $title ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $body is built above as sprintf('

%s

', esc_html($message)). $body ); } }