PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / trunk
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! vtrunk
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / modules / mcp-abilities / MCP.php

MCP.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! trunk, at modules/mcp-abilities/MCP.php

271 lines 11.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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