| 1 |
<?php |
| 2 |
/** |
| 3 |
* Templately's template-library agent capabilities (specs 041, 046). |
| 4 |
* |
| 5 |
* This class does two things and nothing else: |
| 6 |
* |
| 7 |
* 1. Hands this module's ability classes to the shared registry in mcp-core. |
| 8 |
* 2. Owns the Google-OAuth api-key handoff page, which is a concern of |
| 9 |
* {@see Abilities\AuthLoginWithGoogleAbility} specifically. |
| 10 |
* |
| 11 |
* It used to also decide whether MCP was available at all, register the |
| 12 |
* WordPress Abilities API category/abilities, and create the mcp-adapter server. |
| 13 |
* Since 046 the registry is the source of truth and those consumers are optional |
| 14 |
* bridges living in `modules/wp-abilities-api/`, so none of that belongs here — |
| 15 |
* and critically, none of it gates anything: the native server in |
| 16 |
* `modules/mcp-server/` serves these capabilities on any supported WordPress, |
| 17 |
* with or without the Abilities API (core 6.9+) or the mcp-adapter plugin. |
| 18 |
* Templately supports WordPress 5.0+, so before 046 every capability here was |
| 19 |
* unreachable on the large majority of sites running this plugin. |
| 20 |
* |
| 21 |
* @package Templately\Modules\McpAbilities |
| 22 |
*/ |
| 23 |
|
| 24 |
namespace Templately\Modules\McpAbilities; |
| 25 |
|
| 26 |
use Templately\Modules\McpAbilities\Abilities\AuthLoginWithApiKeyAbility; |
| 27 |
use Templately\Modules\McpAbilities\Abilities\AuthLoginWithGoogleAbility; |
| 28 |
use Templately\Modules\McpAbilities\Abilities\AuthLogoutAbility; |
| 29 |
use Templately\Modules\McpAbilities\Abilities\AuthStatusAbility; |
| 30 |
use Templately\Modules\McpAbilities\Abilities\DiscoverTemplatesAbility; |
| 31 |
use Templately\Modules\McpAbilities\Abilities\GetTemplateDetailAbility; |
| 32 |
use Templately\Modules\McpAbilities\Abilities\ImportTemplateIntoPostAbility; |
| 33 |
use Templately\Modules\McpAbilities\Abilities\ImportTemplateLibraryAbility; |
| 34 |
use Templately\Modules\McpAbilities\Abilities\ImportTemplatePageAbility; |
| 35 |
use Templately\Modules\McpCore\Registry\ToolRegistry; |
| 36 |
use Templately\Utils\Base; |
| 37 |
|
| 38 |
class MCP extends Base { |
| 39 |
|
| 40 |
/** |
| 41 |
* The ONE list. Adding a capability to this module means adding its class |
| 42 |
* here and nothing else (FR-011) — this replaced the two hand-maintained |
| 43 |
* lists (`ABILITY_IDS` and `register_abilities()`) that previously had to be |
| 44 |
* edited in step, and which a third and fourth consumer would have doubled |
| 45 |
* again. |
| 46 |
* |
| 47 |
* @var string[] |
| 48 |
*/ |
| 49 |
const ABILITY_CLASSES = [ |
| 50 |
DiscoverTemplatesAbility::class, |
| 51 |
GetTemplateDetailAbility::class, |
| 52 |
ImportTemplatePageAbility::class, |
| 53 |
ImportTemplateLibraryAbility::class, |
| 54 |
ImportTemplateIntoPostAbility::class, |
| 55 |
AuthStatusAbility::class, |
| 56 |
AuthLoginWithApiKeyAbility::class, |
| 57 |
AuthLoginWithGoogleAbility::class, |
| 58 |
AuthLogoutAbility::class, |
| 59 |
]; |
| 60 |
|
| 61 |
/** |
| 62 |
* Transient key prefix marking an MCP-initiated Google OAuth login as |
| 63 |
* in-flight (see intercept_mcp_oauth_callback()). Keyed by the token |
| 64 |
* AuthLoginWithGoogleAbility::execute() generates and passes as |
| 65 |
* Http::google_auth_url()'s `$redirect_to` param — NOT the URL's own |
| 66 |
* `state` param, which templately-backend's own docs confirm is cached |
| 67 |
* server-side for the Google round-trip only and never echoed back to |
| 68 |
* the site (see this module's CLAUDE.md). |
| 69 |
*/ |
| 70 |
const OAUTH_TOKEN_TRANSIENT_PREFIX = 'templately_mcp_oauth_'; |
| 71 |
|
| 72 |
public function __construct() { |
| 73 |
add_action( ToolRegistry::COLLECT_ACTION, [ $this, 'register_capabilities' ] ); |
| 74 |
|
| 75 |
// Priority 5 — must run BEFORE Plugin::google_login_handler() (priority |
| 76 |
// 10, registered in Plugin.php's constructor) so an MCP-initiated |
| 77 |
// login's api_key is intercepted and shown to the user instead of |
| 78 |
// being auto-consumed by that handler. |
| 79 |
add_action( 'init', [ $this, 'intercept_mcp_oauth_callback' ], 5 ); |
| 80 |
} |
| 81 |
|
| 82 |
/** |
| 83 |
* Contribute this module's capabilities to the shared registry. |
| 84 |
* |
| 85 |
* Hooked rather than called directly so the registry can rebuild itself from |
| 86 |
* every contributor at any time (see ToolRegistry::COLLECT_ACTION). Hands over |
| 87 |
* CLASS NAMES only — descriptors resolve lazily on first use. Resolving them |
| 88 |
* eagerly would call each descriptor's `__()` during `plugins_loaded`, i.e. |
| 89 |
* before `init` loads the textdomain, which makes WP 6.7+ emit a "textdomain |
| 90 |
* triggered too early" notice. Under WP_DEBUG_DISPLAY that notice is echoed |
| 91 |
* during bootstrap and every later REST error status collapses to 200, |
| 92 |
* because headers are already sent. See ToolRegistry::$pending_classes. |
| 93 |
* |
| 94 |
* @param ToolRegistry $registry |
| 95 |
* @return void |
| 96 |
*/ |
| 97 |
public function register_capabilities( $registry ): void { |
| 98 |
$registry->register_classes( self::ABILITY_CLASSES ); |
| 99 |
} |
| 100 |
|
| 101 |
/** |
| 102 |
* Ability IDs this module contributes. Derived from the descriptors so it |
| 103 |
* can never drift from ABILITY_CLASSES. |
| 104 |
* |
| 105 |
* @return string[] |
| 106 |
*/ |
| 107 |
public static function ability_ids(): array { |
| 108 |
$registry = ToolRegistry::get_instance(); |
| 109 |
$ids = []; |
| 110 |
|
| 111 |
foreach ( self::ABILITY_CLASSES as $class ) { |
| 112 |
$descriptor = $registry->get( $class::ID ); |
| 113 |
|
| 114 |
if ( null !== $descriptor ) { |
| 115 |
$ids[] = $descriptor->id; |
| 116 |
} |
| 117 |
} |
| 118 |
|
| 119 |
return $ids; |
| 120 |
} |
| 121 |
|
| 122 |
/** |
| 123 |
* Validate an MCP OAuth token's format before it is ever used to build |
| 124 |
* a transient key or read back from `$_GET['redirect-to']`. Matches |
| 125 |
* wp_generate_password(32, false)'s charset (alphanumeric only) — the |
| 126 |
* exact generator AuthLoginWithGoogleAbility::execute() uses. This also |
| 127 |
* happens to be what distinguishes our token from a genuine |
| 128 |
* `redirect-to` value (always path/URL-shaped, never a bare 32-char |
| 129 |
* alnum string) — see intercept_mcp_oauth_callback(). Raw, unvalidated |
| 130 |
* request data must never be interpolated into an option/transient key. |
| 131 |
* |
| 132 |
* @param mixed $token |
| 133 |
* @return bool |
| 134 |
*/ |
| 135 |
public static function is_valid_oauth_state( $token ): bool { |
| 136 |
return is_string( $token ) && 1 === preg_match( '/^[A-Za-z0-9]{32}$/', $token ); |
| 137 |
} |
| 138 |
|
| 139 |
/** |
| 140 |
* Intercept an MCP-initiated Google OAuth callback and show the raw |
| 141 |
* `api_key` directly to the user instead of letting |
| 142 |
* Plugin::google_login_handler() (priority 10, same `init` hook) |
| 143 |
* auto-consume it via Login::login(). |
| 144 |
* |
| 145 |
* `templately/auth-login-with-google` (AuthLoginWithGoogleAbility) runs |
| 146 |
* headlessly — there is no browser cookie session at URL-generation |
| 147 |
* time to attribute a connection to, and the browser completing the |
| 148 |
* OAuth redirect may not be logged into wp-admin either. Rather than |
| 149 |
* trying to bridge attribution into that ambient session (the previous |
| 150 |
* approach here — see git history / this module's CLAUDE.md), this instead |
| 151 |
* skips Plugin::google_login_handler() entirely for MCP-initiated logins and |
| 152 |
* shows the api_key on-screen for the user to copy back to the agent, |
| 153 |
* which then calls the ALREADY-EXISTING `templately/auth-login-with-api-key` |
| 154 |
* ability in its own genuinely-authenticated MCP session — where |
| 155 |
* get_current_user_id() resolves correctly with no bridging needed at |
| 156 |
* all. templately-backend's own docs confirm the api_key returned here |
| 157 |
* is the same permanent, reusable API key `connectWithApiKey` expects |
| 158 |
* (not a one-time exchange token) — see this module's CLAUDE.md. |
| 159 |
* |
| 160 |
* AuthLoginWithGoogleAbility::execute() marks a login as MCP-initiated |
| 161 |
* via a one-time, 5-minute transient keyed by a token it generates |
| 162 |
* itself and passes as `google_auth_url()`'s `$redirect_to` param, |
| 163 |
* which gets folded into the `site_url` sent to app.templately.com. |
| 164 |
* Live testing (and templately-backend's own docs) confirm `site_url`'s |
| 165 |
* contents come back intact on redirect, unlike the URL's own separate |
| 166 |
* `state` param (cached server-side for the Google round-trip only, |
| 167 |
* never echoed back to the site — do not key off that). |
| 168 |
* |
| 169 |
* When the browser lands back on this site with `templately_google_login` |
| 170 |
* + `redirect-to` matching our token format AND a live transient, this |
| 171 |
* callback (priority 5, before Plugin's priority 10) renders the |
| 172 |
* api_key/error page and exits — Plugin::google_login_handler() never |
| 173 |
* runs at all for this request. No-ops silently (leaving `$_GET` |
| 174 |
* untouched, letting Plugin's handler run as normal) when `redirect-to` |
| 175 |
* isn't present, doesn't match the token format, or has no matching |
| 176 |
* transient — the normal case for a human clicking "Connect" from an |
| 177 |
* already-authenticated wp-admin session, or a genuine editor-path |
| 178 |
* redirect. This must never change behavior for those existing flows. |
| 179 |
* |
| 180 |
* @return void |
| 181 |
*/ |
| 182 |
public function intercept_mcp_oauth_callback(): void { |
| 183 |
$token = self::find_mcp_oauth_token(); |
| 184 |
|
| 185 |
if ( null === $token ) { |
| 186 |
return; |
| 187 |
} |
| 188 |
|
| 189 |
// One-time use — replay protection. |
| 190 |
delete_transient( self::OAUTH_TOKEN_TRANSIENT_PREFIX . $token ); |
| 191 |
|
| 192 |
$api_key = ! empty( $_GET['api_key'] ) ? sanitize_text_field( wp_unslash( $_GET['api_key'] ) ) : ''; |
| 193 |
$error = ! empty( $_GET['error'] ) ? sanitize_text_field( wp_unslash( $_GET['error'] ) ) : ''; |
| 194 |
|
| 195 |
$this->render_mcp_oauth_key_page( $api_key, $error ); |
| 196 |
exit; |
| 197 |
} |
| 198 |
|
| 199 |
/** |
| 200 |
* Whether the current request is an MCP-initiated OAuth callback with a |
| 201 |
* still-live transient, and if so, the token itself. Split out from |
| 202 |
* intercept_mcp_oauth_callback() — a pure read-only check (no |
| 203 |
* transient deletion, no rendering, no exit) so it unit-tests cleanly. |
| 204 |
* Returns null for: a non-OAuth-callback request, a missing/malformed |
| 205 |
* `redirect-to` (including a genuine path-shaped one — never matches |
| 206 |
* the 32-char alnum format), or an expired/already-consumed transient. |
| 207 |
* |
| 208 |
* @return string|null |
| 209 |
*/ |
| 210 |
public static function find_mcp_oauth_token(): ?string { |
| 211 |
if ( empty( $_GET['templately_google_login'] ) || empty( $_GET['redirect-to'] ) ) { |
| 212 |
return null; |
| 213 |
} |
| 214 |
|
| 215 |
$token = sanitize_text_field( wp_unslash( $_GET['redirect-to'] ) ); |
| 216 |
|
| 217 |
if ( ! self::is_valid_oauth_state( $token ) ) { |
| 218 |
return null; |
| 219 |
} |
| 220 |
|
| 221 |
if ( empty( get_transient( self::OAUTH_TOKEN_TRANSIENT_PREFIX . $token ) ) ) { |
| 222 |
return null; |
| 223 |
} |
| 224 |
|
| 225 |
return $token; |
| 226 |
} |
| 227 |
|
| 228 |
/** |
| 229 |
* Render a minimal, standalone HTML page (no wp-admin chrome needed — |
| 230 |
* this never depends on any wp-admin session) showing the api_key for |
| 231 |
* the user to copy back to the agent, or the error if Google/Templately |
| 232 |
* sign-in failed. Not unit-tested (headers + exit side effects); the |
| 233 |
* decision to call it lives in intercept_mcp_oauth_callback(), which is. |
| 234 |
* |
| 235 |
* @param string $api_key |
| 236 |
* @param string $error |
| 237 |
* @return void |
| 238 |
*/ |
| 239 |
private function render_mcp_oauth_key_page( string $api_key, string $error ): void { |
| 240 |
nocache_headers(); |
| 241 |
header( 'Content-Type: text/html; charset=utf-8' ); |
| 242 |
|
| 243 |
if ( '' !== $api_key ) { |
| 244 |
$title = __( 'Templately Sign-In Complete', 'templately' ); |
| 245 |
$body = sprintf( |
| 246 |
'<p>%1$s</p><p style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:1.1em;background:#f0f0f1;padding:12px 16px;border-radius:6px;display:inline-block;word-break:break-all;">%2$s</p><p>%3$s</p>', |
| 247 |
esc_html__( 'Copy this key and give it to the agent to finish connecting:', 'templately' ), |
| 248 |
esc_html( $api_key ), |
| 249 |
esc_html__( 'You can close this tab afterward.', 'templately' ) |
| 250 |
); |
| 251 |
} else { |
| 252 |
$title = __( 'Templately Sign-In Failed', 'templately' ); |
| 253 |
$message = '' !== $error |
| 254 |
? sprintf( |
| 255 |
/* translators: %s: error returned by Google/Templately */ |
| 256 |
__( 'Sign-in failed: %s', 'templately' ), |
| 257 |
$error |
| 258 |
) |
| 259 |
: __( 'Sign-in failed. Please try again.', 'templately' ); |
| 260 |
$body = sprintf( '<p>%s</p>', esc_html( $message ) ); |
| 261 |
} |
| 262 |
|
| 263 |
printf( |
| 264 |
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>%1$s</title></head><body style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;text-align:center;padding:80px 20px;"><h1>%1$s</h1>%2$s</body></html>', |
| 265 |
esc_html( $title ), |
| 266 |
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $body is built above as sprintf('<p>%s</p>', esc_html($message)). |
| 267 |
$body |
| 268 |
); |
| 269 |
} |
| 270 |
} |
| 271 |
|