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-core / Registry / ToolRegistry.php

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

394 lines 11.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The sole authority on which agent capabilities exist (spec 046, FR-010).
4 *
5 * Every consumer derives its capability set from here:
6 *
7 * Ability classes ──descriptor()──► ToolRegistry ──► mcp-server (always)
8 * ├────────► AbilitiesBridge (iff Abilities API)
9 * └────────► McpAdapterBridge (iff mcp-adapter)
10 *
11 * Before 046 the capability list was maintained by hand in TWO places in
12 * mcp-abilities' MCP.php (`ABILITY_IDS` and `register_abilities()`), which had to
13 * be edited in step. Adding the native server would have made three, and adding
14 * the FSI capability module a fourth. FR-011 requires exactly one declaration;
15 * test-RegistryAbilitiesParity.php fails CI if the sets ever diverge.
16 *
17 * Capability modules contribute by calling `register_classes()` from their own
18 * `init_hooks()` — mcp-core never names them, so a new capability module is
19 * additive and touches nothing here.
20 *
21 * @package Templately\Modules\McpCore\Registry
22 */
23
24 namespace Templately\Modules\McpCore\Registry;
25
26 use Templately\Modules\McpCore\Activity\ActivityLog;
27 use WP_Error;
28
29 class ToolRegistry {
30
31 /** @var self|null */
32 private static $instance = null;
33
34 /** @var ToolDescriptor[] Keyed by capability id, insertion-ordered. */
35 private $tools = [];
36
37 /**
38 * Ability class names awaiting descriptor resolution.
39 *
40 * Descriptors are built LAZILY, on first access — never at registration
41 * time. Every descriptor's label/description is wrapped in `__()`, and the
42 * bootstrap that supplies this list runs on `plugins_loaded`, which is
43 * BEFORE `init` and therefore before the plugin's textdomain is loaded
44 * (`Plugin::set_locale()` defers `load_plugin_textdomain` to `init`).
45 *
46 * Calling `__()` that early makes WordPress 6.7+ emit a
47 * `_load_textdomain_just_in_time was called incorrectly` notice. With
48 * WP_DEBUG_DISPLAY on, that notice is ECHOED during bootstrap — so headers
49 * are already sent by the time any REST response tries to set a status, and
50 * every error status silently collapses to 200 ("Cannot modify header
51 * information - headers already sent"). It breaks far more than this module.
52 *
53 * Deferring means descriptors are resolved on first use — REST dispatch,
54 * the abilities hook, the adapter hook — all of which run after `init`.
55 *
56 * @var string[]
57 */
58 private $pending_classes = [];
59
60 /** @var bool */
61 private $resolved = true;
62
63 /**
64 * Whether contributors have been asked to declare themselves this lifetime.
65 *
66 * @see self::COLLECT_ACTION
67 * @var bool
68 */
69 private $collected = false;
70
71 /**
72 * Fired once, on first registry use, so every capability module can declare
73 * its classes.
74 *
75 * Contribution is INVERTED on purpose: mcp-core names no contributor, so a
76 * module gains an agent surface by hooking this and nothing here changes.
77 * It also means the registry can always rebuild itself from scratch — which
78 * is what makes {@see self::reset()} a complete reset rather than one that
79 * silently drops whichever contributors happened to register imperatively.
80 * (That is not hypothetical: while the capability set lived in a single
81 * module, `reset()` followed by re-registering that one module's list looked
82 * like a full restore. The moment a second module started contributing, every
83 * test after a reset saw a partial registry.)
84 */
85 const COLLECT_ACTION = 'templately_mcp_register_capabilities';
86
87 public static function get_instance(): self {
88 if ( null === self::$instance ) {
89 self::$instance = new self();
90 }
91
92 return self::$instance;
93 }
94
95 /**
96 * Reset — test seam only. The next use re-fires {@see self::COLLECT_ACTION},
97 * so the rebuilt registry is identical to the booted one.
98 */
99 public static function reset(): void {
100 self::$instance = null;
101 }
102
103 /**
104 * @param ToolDescriptor $descriptor
105 * @throws \InvalidArgumentException On duplicate id (a programming error).
106 */
107 public function register( ToolDescriptor $descriptor ): void {
108 if ( isset( $this->tools[ $descriptor->id ] ) ) {
109 throw new \InvalidArgumentException(
110 sprintf( 'Capability "%s" is already registered.', $descriptor->id )
111 );
112 }
113
114 $this->tools[ $descriptor->id ] = $descriptor;
115 }
116
117 /**
118 * Queue ability classes for lazy descriptor resolution. Does NOT call
119 * `descriptor()` — see $pending_classes for why that must not happen at
120 * registration time.
121 *
122 * @param string[] $classes
123 */
124 public function register_classes( array $classes ): void {
125 foreach ( $classes as $class ) {
126 $this->pending_classes[] = $class;
127 }
128
129 $this->resolved = false;
130 }
131
132 /**
133 * Resolve queued ability classes into descriptors. Idempotent.
134 */
135 private function resolve(): void {
136 // Ask contributors to declare themselves, once per registry lifetime, and
137 // BEFORE the pending list is snapshotted below — their callbacks call
138 // register_classes(), which appends to it. `$collected` is set first so a
139 // callback that touches the registry cannot recurse.
140 if ( ! $this->collected ) {
141 $this->collected = true;
142
143 /**
144 * Declare a module's agent capabilities.
145 *
146 * @param ToolRegistry $registry Call `register_classes()` on it.
147 */
148 do_action( self::COLLECT_ACTION, $this );
149 }
150
151 if ( $this->resolved ) {
152 return;
153 }
154
155 // Set BEFORE the loop: a descriptor that somehow re-entered the registry
156 // would otherwise recurse forever.
157 $this->resolved = true;
158
159 $pending = $this->pending_classes;
160 $this->pending_classes = [];
161
162 foreach ( $pending as $class ) {
163 if ( ! method_exists( $class, 'descriptor' ) ) {
164 continue;
165 }
166
167 $descriptor = ToolDescriptor::from_array( $class::descriptor() );
168
169 if ( ! isset( $this->tools[ $descriptor->id ] ) ) {
170 $this->tools[ $descriptor->id ] = $descriptor;
171 }
172 }
173 }
174
175 /**
176 * @return ToolDescriptor[]
177 */
178 public function all(): array {
179 $this->resolve();
180
181 return $this->tools;
182 }
183
184 /**
185 * @return string[]
186 */
187 public function ids(): array {
188 $this->resolve();
189
190 return array_keys( $this->tools );
191 }
192
193 /**
194 * @param string $id
195 * @return ToolDescriptor|null
196 */
197 public function get( string $id ) {
198 $this->resolve();
199
200 return $this->tools[ $id ] ?? null;
201 }
202
203 /**
204 * Invoke a capability under a credential's access level.
205 *
206 * GATE ORDER IS LOAD-BEARING (data-model.md §2):
207 *
208 * 1. exists → unknown capability
209 * 2. access level → forbidden, naming the access level
210 * 3. permission → forbidden
211 * 4. input schema → invalid input
212 * 5. invoke
213 * 6. record activity (full-access capabilities only)
214 *
215 * Access level is checked BEFORE the permission callback so a read-only
216 * credential is told it is read-only, rather than being told it lacks a
217 * capability it actually has. Reversing 2 and 3 turns a clear "your token is
218 * read-only" into a misleading permissions error.
219 *
220 * @param string $id
221 * @param array $input
222 * @param string $access_level Credential's level (ToolDescriptor::ACCESS_*).
223 * @param string|null $credential_id For the activity record; null when unknown.
224 * @return array|WP_Error
225 */
226 public function execute( string $id, array $input, string $access_level, ?string $credential_id = null ) {
227 $descriptor = $this->get( $id );
228
229 if ( null === $descriptor ) {
230 return new WP_Error(
231 'unknown_capability',
232 sprintf(
233 /* translators: %s: capability name. */
234 __( 'Unknown capability: %s', 'templately' ),
235 $id
236 ),
237 [ 'status' => 404 ]
238 );
239 }
240
241 // 2 — access level (FR-026).
242 if ( ToolDescriptor::ACCESS_READ === $access_level && ! $descriptor->is_read_only() ) {
243 return new WP_Error(
244 'forbidden_read_only',
245 sprintf(
246 /* translators: %s: capability name. */
247 __( 'This connection is read-only and cannot use "%s". Reconnect with full access to use it.', 'templately' ),
248 $id
249 ),
250 [ 'status' => 403 ]
251 );
252 }
253
254 // 3 — the capability's own permission rule, unchanged from 041/042 (FR-019).
255 if ( ! call_user_func( $descriptor->permission_callback, $input ) ) {
256 return new WP_Error(
257 'forbidden',
258 __( 'You do not have permission to use this capability.', 'templately' ),
259 [ 'status' => 403 ]
260 );
261 }
262
263 // 4 — input shape.
264 $invalid = $this->validate_input( $descriptor, $input );
265
266 if ( $invalid instanceof WP_Error ) {
267 return $invalid;
268 }
269
270 // 5 — invoke.
271 $result = call_user_func( $descriptor->execute_callback, $input );
272
273 // 6 — record (FR-039d). Read-only invocations are not recorded, which
274 // keeps the log meaningful: it answers "what did the agent CHANGE".
275 if ( ! $descriptor->is_read_only() ) {
276 ActivityLog::record(
277 $credential_id,
278 get_current_user_id(),
279 $id,
280 ! ( $result instanceof WP_Error ),
281 $result instanceof WP_Error ? $result->get_error_message() : null
282 );
283 }
284
285 return $result;
286 }
287
288 /**
289 * Minimal JSON-Schema validation — required keys, declared types, enums, and
290 * `additionalProperties: false`. Deliberately not a full validator: the
291 * schemas here are hand-written and shallow, and every capability validates
292 * its own inputs again downstream.
293 *
294 * @param ToolDescriptor $descriptor
295 * @param array $input
296 * @return true|WP_Error
297 */
298 private function validate_input( ToolDescriptor $descriptor, array $input ) {
299 $schema = $descriptor->input_schema;
300 $properties = (array) ( $schema['properties'] ?? [] );
301 $required = (array) ( $schema['required'] ?? [] );
302
303 foreach ( $required as $key ) {
304 if ( ! array_key_exists( $key, $input ) || '' === $input[ $key ] ) {
305 return new WP_Error(
306 'invalid_input',
307 sprintf(
308 /* translators: %s: input field name. */
309 __( 'Missing required input: %s', 'templately' ),
310 $key
311 ),
312 [ 'status' => 400 ]
313 );
314 }
315 }
316
317 if ( isset( $schema['additionalProperties'] ) && false === $schema['additionalProperties'] ) {
318 $unknown = array_diff( array_keys( $input ), array_keys( $properties ) );
319
320 if ( ! empty( $unknown ) ) {
321 return new WP_Error(
322 'invalid_input',
323 sprintf(
324 /* translators: %s: comma-separated list of field names. */
325 __( 'Unknown input(s): %s', 'templately' ),
326 implode( ', ', $unknown )
327 ),
328 [ 'status' => 400 ]
329 );
330 }
331 }
332
333 foreach ( $input as $key => $value ) {
334 $spec = $properties[ $key ] ?? null;
335
336 if ( ! is_array( $spec ) ) {
337 continue;
338 }
339
340 if ( isset( $spec['enum'] ) && is_array( $spec['enum'] ) && ! in_array( $value, $spec['enum'], true ) ) {
341 return new WP_Error(
342 'invalid_input',
343 sprintf(
344 /* translators: 1: input field name, 2: comma-separated allowed values. */
345 __( 'Invalid value for "%1$s". Allowed: %2$s', 'templately' ),
346 $key,
347 implode( ', ', $spec['enum'] )
348 ),
349 [ 'status' => 400 ]
350 );
351 }
352
353 if ( isset( $spec['type'] ) && ! $this->matches_type( $value, $spec['type'] ) ) {
354 return new WP_Error(
355 'invalid_input',
356 sprintf(
357 /* translators: 1: input field name, 2: expected type. */
358 __( 'Invalid type for "%1$s". Expected %2$s.', 'templately' ),
359 $key,
360 $spec['type']
361 ),
362 [ 'status' => 400 ]
363 );
364 }
365 }
366
367 return true;
368 }
369
370 /**
371 * @param mixed $value
372 * @param string $type
373 * @return bool
374 */
375 private function matches_type( $value, string $type ): bool {
376 switch ( $type ) {
377 case 'integer':
378 return is_int( $value ) || ( is_string( $value ) && ctype_digit( $value ) );
379 case 'number':
380 return is_numeric( $value );
381 case 'string':
382 return is_string( $value );
383 case 'boolean':
384 return is_bool( $value ) || in_array( $value, [ 0, 1, '0', '1' ], true );
385 case 'array':
386 return is_array( $value );
387 case 'object':
388 return is_array( $value ) || is_object( $value );
389 default:
390 return true;
391 }
392 }
393 }
394