PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / 3.8.0
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! v3.8.0
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-server / REST / Connections.php

Connections.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! 3.8.0, at modules/mcp-server/REST/Connections.php

307 lines 10.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Connection management for the built-in MCP server (spec 046, FR-020, FR-039c,
4 * FR-039g, FR-046).
5 *
6 * Mints, lists, re-scopes and revokes the credentials an agent presents to the
7 * built-in server, and reports the delegated (OAuth) connections alongside them
8 * so one screen answers "who can reach this site".
9 *
10 * Named `Connections` (plural) rather than `Connection`: `wp-abilities-api` owns
11 * a `REST/Connection.php` too, and the two are unrelated — that one onboards the
12 * separately-installed mcp-adapter plugin (installs it, mints an Application
13 * Password); this one manages THIS server's own credential list. Distinct
14 * namespaces would keep them from colliding in PHP, but not in a reader's head.
15 *
16 * No new admin screen is introduced — the tab attaches to the EXISTING settings
17 * page through its tab-injection filter.
18 *
19 * @package Templately\Modules\McpServer\REST
20 */
21
22 namespace Templately\Modules\McpServer\REST;
23
24 use Templately\Modules\McpCore\Activity\ActivityLog;
25 use Templately\Modules\McpCore\Registry\ToolDescriptor;
26 use Templately\Modules\McpCore\Registry\ToolRegistry;
27 use Templately\Modules\McpServer\Auth\Credentials;
28 use Templately\Modules\McpServer\Auth\OAuth\RecordStore;
29 use Templately\Modules\McpServer\Server\HttpTransport;
30 use Templately\Utils\Base;
31 use WP_Error;
32 use WP_REST_Request;
33 use WP_REST_Response;
34
35 class Connections extends Base {
36
37 public function __construct() {
38 add_action( 'rest_api_init', [ $this, 'register_routes' ] );
39 add_filter( 'templately_admin_localized_data', [ $this, 'inject_localized_data' ] );
40 }
41
42 public function register_routes(): void {
43 $base = '/mcp/connection';
44
45 register_rest_route(
46 HttpTransport::NAMESPACE,
47 $base,
48 [
49 [
50 'methods' => 'GET',
51 'callback' => [ $this, 'get_connection' ],
52 'permission_callback' => [ $this, 'can_manage' ],
53 ],
54 [
55 'methods' => 'POST',
56 'callback' => [ $this, 'create_credential' ],
57 'permission_callback' => [ $this, 'can_manage' ],
58 ],
59 ]
60 );
61
62 register_rest_route(
63 HttpTransport::NAMESPACE,
64 $base . '/(?P<id>[A-Za-z0-9_]+)',
65 [
66 [
67 'methods' => 'POST',
68 'callback' => [ $this, 'update_credential' ],
69 'permission_callback' => [ $this, 'can_manage' ],
70 // REJECT an absent or misspelled level rather than coercing it.
71 // `Credentials::normalize_level()` resolves anything that is not
72 // exactly "read" to FULL — correct for a token an administrator
73 // is deliberately minting, dangerous here, where the same call
74 // now also edits a DELEGATED grant. Without this, a retried or
75 // malformed POST silently promotes a read-only agent connection
76 // to full write access and answers 200 as if that were intended.
77 'args' => [
78 'access_level' => [
79 'required' => true,
80 'type' => 'string',
81 'enum' => [ ToolDescriptor::ACCESS_READ, ToolDescriptor::ACCESS_FULL ],
82 ],
83 ],
84 ],
85 [
86 'methods' => 'DELETE',
87 'callback' => [ $this, 'revoke_credential' ],
88 'permission_callback' => [ $this, 'can_manage' ],
89 ],
90 ]
91 );
92
93 register_rest_route(
94 HttpTransport::NAMESPACE,
95 $base . '/revoke-all',
96 [
97 'methods' => 'POST',
98 'callback' => [ $this, 'revoke_all' ],
99 'permission_callback' => [ $this, 'can_manage' ],
100 ]
101 );
102 }
103
104 /**
105 * Administrator OF THIS SITE — never network administrator (FR-039c), so a
106 * site owner on multisite can manage their own connection.
107 *
108 * @return bool
109 */
110 public function can_manage(): bool {
111 return is_user_logged_in() && current_user_can( 'manage_options' );
112 }
113
114 /**
115 * @return WP_REST_Response
116 */
117 public function get_connection(): WP_REST_Response {
118 return new WP_REST_Response(
119 [
120 'endpoint' => rest_url( HttpTransport::NAMESPACE . '/mcp' ),
121 'pretty_url' => home_url( '/templately/mcp' ),
122 'connected' => Credentials::site_has_any(),
123 'credentials' => self::all_connections(),
124 'activity' => ActivityLog::recent( 25 ),
125 'tools' => self::tool_summary(),
126 // Everything this server serves through a REWRITE — OAuth discovery,
127 // the approval screen, the pretty endpoint alias — is unreachable
128 // while permalinks are plain, because no rewrite rule runs at all.
129 // The bearer-credential flow below is unaffected (the REST route is
130 // still reachable as `?rest_route=`), so the tab warns instead of
131 // blocking. Without this an administrator on a default-permalink
132 // site sees a connect flow that simply never completes, with nothing
133 // on screen explaining why.
134 'pretty_permalinks' => '' !== (string) get_option( 'permalink_structure' ),
135 ],
136 200
137 );
138 }
139
140 /**
141 * Every connection this site holds, from BOTH credential systems.
142 *
143 * There are two: pairing tokens an administrator creates here
144 * (`Credentials`), and delegated grants an application obtains through
145 * OAuth (`RecordStore`). The list previously showed only the first, so a
146 * site connected the way ChatGPT and Claude connect — the URL-only flow this
147 * whole OAuth surface exists to serve — reported "No connections yet" while
148 * an agent was actively using it. Nothing was wrong with the connection; it
149 * was simply invisible, and therefore un-revokable from the UI.
150 *
151 * Pairing tokens carry `source: 'token'` and delegated grants
152 * `source: 'oauth'`, which is what the table keys its per-row affordances off.
153 *
154 * @return array
155 */
156 private static function all_connections(): array {
157 $tokens = array_map(
158 static function ( $record ) {
159 $record['source'] = 'token';
160
161 return $record;
162 },
163 Credentials::list_public()
164 );
165
166 return array_merge( $tokens, RecordStore::list_connections() );
167 }
168
169 /**
170 * The capabilities an agent can call, for the Settings → AI Agents "Tools"
171 * view. Read from the registry so it can never drift from what the server
172 * actually serves (FR-010).
173 *
174 * Exposes `access_level` — the security control — NOT the advisory
175 * `annotations.readonly`, which can legitimately disagree with it (see
176 * ToolDescriptor). Showing the annotation here would tell an administrator
177 * that `auth-login-with-google` is read-only when a read-only credential is
178 * in fact refused it.
179 *
180 * @return array
181 */
182 private static function tool_summary(): array {
183 $tools = [];
184
185 foreach ( ToolRegistry::get_instance()->all() as $descriptor ) {
186 $tools[] = [
187 'id' => $descriptor->id,
188 'label' => $descriptor->label,
189 'description' => $descriptor->description,
190 'access_level' => $descriptor->access_level,
191 ];
192 }
193
194 return $tools;
195 }
196
197 /**
198 * The ONLY moment the secret exists (FR-021). It is never returned again.
199 *
200 * @param WP_REST_Request $request
201 * @return WP_REST_Response
202 */
203 public function create_credential( WP_REST_Request $request ): WP_REST_Response {
204 $name = sanitize_text_field( (string) $request->get_param( 'name' ) );
205 $level = Credentials::normalize_level( (string) $request->get_param( 'access_level' ) );
206
207 $created = Credentials::create( $name, get_current_user_id(), $level );
208
209 return new WP_REST_Response(
210 [
211 'id' => $created['id'],
212 // Shown once, then unrecoverable — only its hash is stored.
213 'secret' => $created['secret'],
214 'credentials' => self::all_connections(),
215 ],
216 201
217 );
218 }
219
220 /**
221 * @param WP_REST_Request $request
222 * @return WP_REST_Response|WP_Error
223 */
224 public function update_credential( WP_REST_Request $request ) {
225 $id = (string) $request->get_param( 'id' );
226 $level = Credentials::normalize_level( (string) $request->get_param( 'access_level' ) );
227
228 // A delegated grant is edited in its own store — and can be widened to
229 // full access from here WITHOUT the user disconnecting and re-approving,
230 // which is otherwise the only remedy when a client asks for read only.
231 $client_id = RecordStore::client_id_from_public_id( $id );
232
233 $updated = '' !== $client_id
234 ? RecordStore::set_client_access_level( $client_id, $level )
235 : Credentials::set_access_level( $id, $level );
236
237 if ( ! $updated ) {
238 return new WP_Error( 'not_found', __( 'Connection not found.', 'templately' ), [ 'status' => 404 ] );
239 }
240
241 return new WP_REST_Response( [ 'credentials' => self::all_connections() ], 200 );
242 }
243
244 /**
245 * @param WP_REST_Request $request
246 * @return WP_REST_Response|WP_Error
247 */
248 public function revoke_credential( WP_REST_Request $request ) {
249 $id = (string) $request->get_param( 'id' );
250
251 $client_id = RecordStore::client_id_from_public_id( $id );
252
253 $revoked = '' !== $client_id
254 ? RecordStore::revoke_client( $client_id )
255 : Credentials::revoke( $id );
256
257 if ( ! $revoked ) {
258 return new WP_Error( 'not_found', __( 'Connection not found.', 'templately' ), [ 'status' => 404 ] );
259 }
260
261 return new WP_REST_Response( [ 'credentials' => self::all_connections() ], 200 );
262 }
263
264 /**
265 * @return WP_REST_Response
266 */
267 public function revoke_all(): WP_REST_Response {
268 Credentials::revoke_all();
269
270 return new WP_REST_Response(
271 [
272 'credentials' => [],
273 'connected' => false,
274 ],
275 200
276 );
277 }
278
279 /**
280 * Tell the settings SPA the tab exists and where the endpoint lives.
281 *
282 * Keyed `mcpServer`, NOT `mcp`: `wp-abilities-api`'s own Connection class
283 * writes `mcp` for the adapter-onboarding tab, and whichever filter ran last
284 * would otherwise silently overwrite the other — leaving one of the two tabs
285 * permanently unregistered, since each bundle self-gates on its own key.
286 *
287 * @param array $data
288 * @return array
289 */
290 public function inject_localized_data( $data ) {
291 if ( ! is_array( $data ) ) {
292 return $data;
293 }
294
295 $data['mcpServer'] = [
296 'endpoint' => rest_url( HttpTransport::NAMESPACE . '/mcp' ),
297 'connected' => Credentials::site_has_any(),
298 'accessLevels' => [
299 ToolDescriptor::ACCESS_READ => __( 'Read-only', 'templately' ),
300 ToolDescriptor::ACCESS_FULL => __( 'Full access', 'templately' ),
301 ],
302 ];
303
304 return $data;
305 }
306 }
307