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 / wp-abilities-api / REST / Connection.php

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

379 lines 13.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Onboarding support for the mcp-adapter transport — REST endpoints + localized
4 * data for the Settings → MCP tab.
5 *
6 * Scoped to the ADAPTER path specifically, which is why it lives beside the
7 * bridges rather than beside the capabilities: everything here exists to get a
8 * site from "the mcp-adapter plugin is not installed" to "an agent can reach the
9 * adapter's server". It is:
10 * - injects `window.templately.mcp` so the React tab knows the adapter's
11 * endpoint URL, WP-CLI paths, and current adapter/app-password availability;
12 * - mints a WordPress Application Password for the current admin (the Basic-Auth
13 * credential the adapter's HTTP transport needs), returned base64-encoded and
14 * shown once;
15 * - installs + activates the separately-distributed "MCP Adapter" plugin
16 * (wp.org if it ever lands there, else the GitHub latest-release built asset).
17 *
18 * The BUILT-IN server needs none of this — it has no external plugin to install
19 * and issues its own credentials. Its connection management is a separate
20 * surface in `modules/mcp-server/REST/Connections.php`.
21 *
22 * @package Templately\Modules\WpAbilitiesApi\REST
23 */
24
25 namespace Templately\Modules\WpAbilitiesApi\REST;
26
27 use Templately\Modules\McpCore\Support\Permissions;
28 use Templately\Modules\WpAbilitiesApi\Adapters\McpAdapterBridge;
29 use Templately\Utils\Base;
30 use WP_Application_Passwords;
31 use WP_Error;
32 use WP_REST_Request;
33 use WP_REST_Response;
34
35 class Connection extends Base {
36
37 /** Main-file path of the MCP Adapter plugin (folder/main-file). */
38 const ADAPTER_PLUGIN_FILE = 'mcp-adapter/mcp-adapter.php';
39
40 /** wp.org slug — tried first (not published there today, kept forward-compatible). */
41 const ADAPTER_WPORG_SLUG = 'mcp-adapter';
42
43 /** GitHub latest-release API — the canonical distribution (ships a built `mcp-adapter.zip`). */
44 const ADAPTER_GH_RELEASES = 'https://api.github.com/repos/WordPress/mcp-adapter/releases/latest';
45
46 public function __construct() {
47 add_filter( 'templately_admin_localized_data', [ $this, 'inject_localized_data' ] );
48 add_action( 'rest_api_init', [ $this, 'register_routes' ] );
49 }
50
51 /**
52 * Expose the data the MCP tab needs on `window.templately.mcp`.
53 *
54 * @param array $data
55 * @return array
56 */
57 public function inject_localized_data( $data ) {
58 $user = wp_get_current_user();
59
60 $data['mcp'] = [
61 'adapter_active' => McpAdapterBridge::is_available(),
62 'adapter_installed' => $this->is_adapter_installed(),
63 'app_passwords_available' => $user->exists() && wp_is_application_passwords_available() && wp_is_application_passwords_available_for_user( $user ),
64 'can_install' => $this->can_install_adapter(),
65 'endpoint' => get_rest_url( null, McpAdapterBridge::NAMESPACE . '/' . McpAdapterBridge::ROUTE ),
66 'server_id' => McpAdapterBridge::SERVER_ID,
67 'wp_path' => untrailingslashit( ABSPATH ),
68 'in_container' => self::is_containerized(),
69 'wp_user' => $user->user_login,
70 'is_ssl' => is_ssl(),
71 'docs_url' => 'https://github.com/WordPress/mcp-adapter#readme',
72 ];
73
74 return $data;
75 }
76
77 public function register_routes(): void {
78 register_rest_route(
79 'templately/v1',
80 '/mcp/app-password',
81 [
82 'methods' => 'POST',
83 'callback' => [ $this, 'create_app_password' ],
84 'permission_callback' => [ Permissions::class, 'can_use_abilities' ],
85 'args' => [
86 'name' => [
87 'type' => 'string',
88 'required' => false,
89 'sanitize_callback' => 'sanitize_text_field',
90 ],
91 ],
92 ]
93 );
94
95 register_rest_route(
96 'templately/v1',
97 '/mcp/install-adapter',
98 [
99 'methods' => 'POST',
100 'callback' => [ $this, 'install_adapter' ],
101 'permission_callback' => [ $this, 'can_install_adapter' ],
102 ]
103 );
104
105 register_rest_route(
106 'templately/v1',
107 '/mcp/status',
108 [
109 'methods' => 'GET',
110 'callback' => [ $this, 'get_status' ],
111 'permission_callback' => [ Permissions::class, 'can_use_abilities' ],
112 ]
113 );
114 }
115
116 /**
117 * Whether the current user may install/activate the MCP Adapter plugin.
118 * DISALLOW_FILE_MODS (set on locked-down/managed sites) hard-blocks it.
119 *
120 * @return bool
121 */
122 public function can_install_adapter(): bool {
123 if ( defined( 'DISALLOW_FILE_MODS' ) && DISALLOW_FILE_MODS ) {
124 return false;
125 }
126 return current_user_can( 'install_plugins' ) && current_user_can( 'activate_plugins' );
127 }
128
129 /**
130 * Fresh availability snapshot — the tab polls this after an install to
131 * flip from the "install" CTA to the "connect" instructions without a reload.
132 */
133 public function get_status(): WP_REST_Response {
134 $user = wp_get_current_user();
135
136 return new WP_REST_Response(
137 [
138 'adapter_active' => McpAdapterBridge::is_available(),
139 'adapter_installed' => $this->is_adapter_installed(),
140 'app_passwords_available' => $user->exists() && wp_is_application_passwords_available() && wp_is_application_passwords_available_for_user( $user ),
141 'can_install' => $this->can_install_adapter(),
142 ]
143 );
144 }
145
146 /**
147 * Mint a WordPress Application Password for the current admin and return the
148 * base64 Basic-Auth credential the MCP HTTP transport needs. The plaintext
149 * password is returned ONCE (WordPress never stores it in the clear) — the
150 * client must surface it immediately and not persist it server-side.
151 *
152 * @param WP_REST_Request $request
153 * @return WP_REST_Response|WP_Error
154 */
155 public function create_app_password( WP_REST_Request $request ) {
156 $user = wp_get_current_user();
157
158 if ( ! $user->exists() ) {
159 return new WP_Error( 'templately_mcp_no_user', __( 'No authenticated user.', 'templately' ), [ 'status' => 401 ] );
160 }
161
162 if ( ! wp_is_application_passwords_available() || ! wp_is_application_passwords_available_for_user( $user ) ) {
163 return new WP_Error(
164 'templately_mcp_app_pw_unavailable',
165 __( 'Application Passwords are not available for your account. They require an HTTPS connection.', 'templately' ),
166 [ 'status' => 400 ]
167 );
168 }
169
170 $name = $request->get_param( 'name' );
171 if ( empty( $name ) ) {
172 $name = __( 'Templately MCP (AI agent)', 'templately' );
173 }
174
175 // Reuse the single named slot instead of piling up duplicates: WordPress can't
176 // return an already-created password's plaintext, so "reuse" here means revoke
177 // any prior password WE created under this exact name before minting the fresh
178 // one — the site keeps exactly one active Templately-MCP credential, never a
179 // growing list from repeated clicks. (The React tab additionally avoids
180 // re-minting at all once it already holds a credential this session.)
181 $existing = WP_Application_Passwords::get_user_application_passwords( $user->ID );
182 foreach ( is_array( $existing ) ? $existing : [] as $item ) {
183 if ( isset( $item['name'], $item['uuid'] ) && $item['name'] === $name ) {
184 WP_Application_Passwords::delete_application_password( $user->ID, $item['uuid'] );
185 }
186 }
187
188 $created = WP_Application_Passwords::create_new_application_password( $user->ID, [ 'name' => $name ] );
189 if ( is_wp_error( $created ) ) {
190 return $created;
191 }
192
193 $password = $created[0]; // plaintext — shown once.
194 $item = is_array( $created[1] ?? null ) ? $created[1] : [];
195
196 return new WP_REST_Response(
197 [
198 'username' => $user->user_login,
199 'password' => $password,
200 'base64' => base64_encode( $user->user_login . ':' . $password ),
201 'uuid' => $item['uuid'] ?? '',
202 'name' => $item['name'] ?? $name,
203 ]
204 );
205 }
206
207 /**
208 * Install (if needed) and activate the MCP Adapter plugin.
209 *
210 * @return WP_REST_Response|WP_Error
211 */
212 public function install_adapter() {
213 if ( ! function_exists( 'get_plugins' ) ) {
214 require_once ABSPATH . 'wp-admin/includes/plugin.php';
215 }
216
217 // Already installed → just activate.
218 if ( $this->is_adapter_installed() ) {
219 $activated = $this->activate_adapter();
220 if ( is_wp_error( $activated ) ) {
221 return $activated;
222 }
223 return new WP_REST_Response( [ 'installed' => true, 'active' => true, 'source' => 'existing' ] );
224 }
225
226 require_once ABSPATH . 'wp-admin/includes/file.php';
227 require_once ABSPATH . 'wp-admin/includes/misc.php';
228 require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
229 if ( ! class_exists( '\WP_Upgrader' ) ) {
230 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
231 }
232 if ( ! class_exists( '\WP_Ajax_Upgrader_Skin' ) ) {
233 require_once ABSPATH . 'wp-admin/includes/class-wp-ajax-upgrader-skin.php';
234 }
235
236 $resolved = $this->resolve_adapter_download_url();
237 if ( is_wp_error( $resolved ) ) {
238 return $resolved;
239 }
240
241 $skin = new \WP_Ajax_Upgrader_Skin();
242 $upgrader = new \Plugin_Upgrader( $skin );
243 $result = $upgrader->install( $resolved['url'] );
244
245 if ( is_wp_error( $result ) ) {
246 return $result;
247 }
248 if ( is_wp_error( $skin->result ) ) {
249 return $skin->result;
250 }
251 if ( $skin->get_errors()->has_errors() ) {
252 return $skin->get_errors();
253 }
254 if ( true !== $result ) {
255 return new WP_Error( 'templately_mcp_install_failed', __( 'MCP Adapter installation failed.', 'templately' ), [ 'status' => 500 ] );
256 }
257
258 $activated = $this->activate_adapter();
259 if ( is_wp_error( $activated ) ) {
260 return $activated;
261 }
262
263 return new WP_REST_Response( [ 'installed' => true, 'active' => true, 'source' => $resolved['source'] ] );
264 }
265
266 /**
267 * Resolve a downloadable MCP Adapter zip: wp.org first (forward-compatible),
268 * then the GitHub latest-release built asset (the canonical source today).
269 * The GitHub *source* archive is intentionally avoided — it omits the bundled
270 * `vendor/` deps the plugin needs; only the release ASSET zip is complete.
271 *
272 * @return array{url:string,source:string}|WP_Error
273 */
274 private function resolve_adapter_download_url() {
275 if ( ! function_exists( 'plugins_api' ) ) {
276 require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
277 }
278
279 $info = plugins_api(
280 'plugin_information',
281 [ 'slug' => self::ADAPTER_WPORG_SLUG, 'fields' => [ 'download_link' => true ] ]
282 );
283 if ( ! is_wp_error( $info ) && ! empty( $info->download_link ) ) {
284 return [ 'url' => $info->download_link, 'source' => 'wordpress.org' ];
285 }
286
287 // NOTE: this is GitHub's API, not the Templately cloud, so it deliberately
288 // does NOT go through ResponseNormalizer (spec 043). That normalizer maps
289 // Templately's `statusText` vocabulary, applies the site
290 // verification/disconnection side-effects and returns Templately error
291 // codes — none of which mean anything for a foreign host. Handled locally
292 // and on purpose; a future "route everything through the normalizer" sweep
293 // should skip this one.
294 $response = wp_remote_get(
295 self::ADAPTER_GH_RELEASES,
296 [
297 'timeout' => 20,
298 'headers' => [
299 'Accept' => 'application/vnd.github+json',
300 'User-Agent' => 'Templately/' . ( defined( 'TEMPLATELY_VERSION' ) ? TEMPLATELY_VERSION : '1.0.0' ),
301 ],
302 ]
303 );
304 if ( is_wp_error( $response ) ) {
305 return $response;
306 }
307
308 $code = wp_remote_retrieve_response_code( $response );
309 if ( 200 !== (int) $code ) {
310 return new WP_Error(
311 'templately_mcp_github_http',
312 sprintf( __( 'Could not reach GitHub to download the MCP Adapter (HTTP %d).', 'templately' ), (int) $code ),
313 [ 'status' => 502 ]
314 );
315 }
316
317 $body = json_decode( wp_remote_retrieve_body( $response ), true );
318 $assets = ( is_array( $body ) && isset( $body['assets'] ) && is_array( $body['assets'] ) ) ? $body['assets'] : [];
319 foreach ( $assets as $asset ) {
320 $asset_name = $asset['name'] ?? '';
321 $asset_url = $asset['browser_download_url'] ?? '';
322 if ( $asset_url && '.zip' === substr( strtolower( $asset_name ), -4 ) ) {
323 return [ 'url' => $asset_url, 'source' => 'github' ];
324 }
325 }
326
327 return new WP_Error(
328 'templately_mcp_no_asset',
329 __( 'No downloadable MCP Adapter release was found on GitHub.', 'templately' ),
330 [ 'status' => 502 ]
331 );
332 }
333
334 /**
335 * Best-effort detection of a container (Docker/Podman) runtime. When true, the
336 * WP-CLI STDIO command's `--path` (ABSPATH) is the path *inside* the container —
337 * not a path that exists on the agent's host — so the UI warns and steers the
338 * user to the HTTP method (or to run `wp` inside the container).
339 *
340 * @return bool
341 */
342 public static function is_containerized(): bool {
343 if ( file_exists( '/.dockerenv' ) || file_exists( '/run/.containerenv' ) ) {
344 return true;
345 }
346 // cgroup fingerprint (Linux hosts) — cheap and only read once per page load.
347 $cgroup = '/proc/1/cgroup';
348 if ( is_readable( $cgroup ) ) {
349 $contents = @file_get_contents( $cgroup );
350 if ( is_string( $contents ) && preg_match( '/docker|kubepods|containerd|podman/i', $contents ) ) {
351 return true;
352 }
353 }
354 return false;
355 }
356
357 private function is_adapter_installed(): bool {
358 if ( ! function_exists( 'get_plugins' ) ) {
359 require_once ABSPATH . 'wp-admin/includes/plugin.php';
360 }
361 $plugins = get_plugins();
362 return isset( $plugins[ self::ADAPTER_PLUGIN_FILE ] );
363 }
364
365 /**
366 * @return true|WP_Error
367 */
368 private function activate_adapter() {
369 if ( ! function_exists( 'activate_plugin' ) ) {
370 require_once ABSPATH . 'wp-admin/includes/plugin.php';
371 }
372 if ( is_plugin_active( self::ADAPTER_PLUGIN_FILE ) ) {
373 return true;
374 }
375 $result = activate_plugin( self::ADAPTER_PLUGIN_FILE );
376 return is_wp_error( $result ) ? $result : true;
377 }
378 }
379