PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 All 128 releases
extendify / app / Mcp / Surface.php

Surface.php in Extendify 3.2.2, at app/Mcp/Surface.php

457 lines 14.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * The tools a connection is offered, and the calls they make.
5 */
6
7 namespace Extendify\Mcp;
8
9 defined('ABSPATH') || die('No direct access.');
10
11 /**
12 * The model supplies arguments and never a path. Abilities a plugin registers
13 * after we ship are reachable without a plugin release.
14 */
15 class Surface
16 {
17 // phpcs:disable PSR12.Properties.ConstantVisibility.NotFound
18 /**
19 * Model APIs cap a tool name at 64 characters.
20 */
21 const MAX_NAME = 64;
22
23 /**
24 * A client validates the schema it is handed, and a plugin may write keys JSON Schema lacks.
25 */
26 const SCHEMA_KEYWORDS = [
27 'additionalProperties',
28 'anyOf',
29 'const',
30 'default',
31 'description',
32 'enum',
33 'exclusiveMaximum',
34 'exclusiveMinimum',
35 'format',
36 'items',
37 'maxItems',
38 'maxLength',
39 'maximum',
40 'minItems',
41 'minLength',
42 'minimum',
43 'multipleOf',
44 'oneOf',
45 'pattern',
46 'properties',
47 'required',
48 'title',
49 'type',
50 'uniqueItems',
51 ];
52
53 const HINTS = [
54 'readonly' => 'readOnlyHint',
55 'destructive' => 'destructiveHint',
56 'idempotent' => 'idempotentHint',
57 ];
58 // phpcs:enable PSR12.Properties.ConstantVisibility.NotFound
59
60 /**
61 * @param array $grants - What the connection is allowed to do.
62 * @return array
63 */
64 public static function tools(array $grants)
65 {
66 $tools = [];
67 foreach (self::offered($grants) as $tool) {
68 $tools[] = $tool['definition'];
69 }
70
71 return $tools;
72 }
73
74 /**
75 * @param array $params - The tools/call params.
76 * @param array $grants - What the connection is allowed to do.
77 * @param array $connection - The connection the call arrived on.
78 * @return array|\WP_Error
79 */
80 public static function call(array $params, array $grants, array $connection = [])
81 {
82 $name = (string) ($params['name'] ?? '');
83 $started = microtime(true);
84 $outcome = ['outcome' => 'error', 'error' => ''];
85
86 try {
87 $offered = self::offered($grants);
88 if (!isset($offered[$name])) {
89 $outcome = ['outcome' => 'refused', 'error' => sprintf('Unknown tool: %s', $name)];
90
91 return new \WP_Error('extendify_mcp_unknown_tool', $outcome['error']);
92 }
93
94 $tool = $offered[$name];
95 $arguments = is_array($params['arguments'] ?? null) ? $params['arguments'] : [];
96
97 $answered = Guard::during(function () use ($tool, $arguments) {
98 return isset($tool['handler'])
99 ? self::handled($tool, $arguments)
100 : self::answer(\rest_do_request(self::abilityRequest($tool, $arguments)), $tool);
101 });
102 $outcome = self::outcome($answered);
103
104 return $answered;
105 } catch (Refused $refused) {
106 $outcome = ['outcome' => 'refused', 'error' => $refused->getMessage()];
107
108 return self::problem($refused->getMessage());
109 } catch (\Throwable $failed) {
110 // Uncaught, this is WordPress's critical-error page, which no client can parse.
111 $outcome = ['outcome' => 'error', 'error' => $failed->getMessage()];
112
113 return self::problem(sprintf('%s failed: %s', $name, $failed->getMessage()));
114 } finally {
115 $outcome['duration'] = microtime(true) - $started;
116 Log::write($connection, $name, $outcome);
117 }
118 }
119
120 /**
121 * @param mixed $answered - What the tool answered with.
122 * @return array
123 */
124 private static function outcome($answered)
125 {
126 if (!is_array($answered) || empty($answered['isError'])) {
127 return ['outcome' => 'ok', 'error' => ''];
128 }
129
130 return ['outcome' => 'error', 'error' => (string) ($answered['content'][0]['text'] ?? '')];
131 }
132
133 /**
134 * @param array $grants - What the connection is allowed to do.
135 * @return array
136 */
137 public static function offered(array $grants)
138 {
139 $offered = [];
140 foreach (array_merge(self::written($grants), self::abilities($grants)) as $tool) {
141 $name = $tool['definition']['name'];
142 if (!isset($offered[$name])) {
143 $offered[$name] = $tool;
144 }
145 }
146
147 return $offered;
148 }
149
150 /**
151 * @param array $grants - What the connection is allowed to do.
152 * @return array
153 */
154 private static function written(array $grants)
155 {
156 $tools = [];
157 foreach (Tools::all() as $name => $tool) {
158 if (!in_array($tool['mode'], $grants, true)) {
159 continue;
160 }
161
162 if (!Allowed::forTool($name, $tool['mode'])) {
163 continue;
164 }
165
166 // Clients treat a tool without readOnlyHint as a write and ask before every call.
167 $definition = [
168 'name' => $name,
169 'description' => $tool['description'],
170 'inputSchema' => $tool['inputSchema'],
171 'annotations' => ['readOnlyHint' => $tool['mode'] === Grants::READ] + ($tool['annotations'] ?? []),
172 ];
173
174 $tools[] = [
175 'mode' => $tool['mode'],
176 'handler' => $tool['handler'],
177 'definition' => $definition,
178 ];
179 }
180
181 return $tools;
182 }
183
184 /**
185 * @param array $tool - The tool being called.
186 * @param array $arguments - The arguments the client called with.
187 * @return array
188 */
189 private static function handled(array $tool, array $arguments)
190 {
191 $arguments = self::valid($tool['definition']['inputSchema'], $arguments);
192 if (\is_wp_error($arguments)) {
193 return self::problem($arguments->get_error_message());
194 }
195
196 $answered = call_user_func([Handlers::class, $tool['handler']], $arguments);
197
198 return \is_wp_error($answered) ? self::problem($answered->get_error_message()) : self::json($answered);
199 }
200
201 /**
202 * A model that reads why its arguments were turned down can correct itself.
203 *
204 * @param array $schema - The tool's input schema.
205 * @param array $arguments - The arguments the client called with.
206 * @return array|\WP_Error
207 */
208 private static function valid(array $schema, array $arguments)
209 {
210 // A client needs {} for a tool taking nothing, and the validator fatals indexing a stdClass.
211 $schema['properties'] = (array) ($schema['properties'] ?? []);
212 foreach ($schema['properties'] as $key => $property) {
213 if (!isset($arguments[$key]) && array_key_exists('default', (array) $property)) {
214 $arguments[$key] = $property['default'];
215 }
216 }
217
218 $checked = \rest_validate_value_from_schema($arguments, $schema, 'arguments');
219 if (\is_wp_error($checked)) {
220 return $checked;
221 }
222
223 return (array) \rest_sanitize_value_from_schema($arguments, $schema, 'arguments');
224 }
225
226 /**
227 * @param string $message - What went wrong, for the model to read.
228 * @return array
229 */
230 private static function problem($message)
231 {
232 return ['content' => [['type' => 'text', 'text' => $message]], 'isError' => true];
233 }
234
235 /**
236 * @param array $grants - What the connection is allowed to do.
237 * @return array
238 */
239 private static function abilities(array $grants)
240 {
241 if (!function_exists('wp_get_abilities')) {
242 return [];
243 }
244
245 $tools = [];
246 foreach (\wp_get_abilities() as $ability) {
247 if (!$ability->get_meta_item('show_in_rest')) {
248 continue;
249 }
250
251 $annotations = (array) $ability->get_meta_item('annotations');
252 $mode = Allowed::forAbility($ability->get_name(), $annotations);
253 if ($mode === null || !in_array($mode, $grants, true)) {
254 continue;
255 }
256
257 $tools[] = self::ability($ability, $annotations, $mode);
258 }
259
260 return $tools;
261 }
262
263 /**
264 * The run controller insists on the method an ability's annotations imply.
265 *
266 * @param \WP_Ability $ability - The registered ability.
267 * @param array $annotations - Its meta annotations.
268 * @param string $mode - The grant it needs.
269 * @return array
270 */
271 private static function ability($ability, array $annotations, $mode)
272 {
273 $method = 'POST';
274 if (!empty($annotations['readonly'])) {
275 $method = 'GET';
276 } elseif (!empty($annotations['destructive']) && !empty($annotations['idempotent'])) {
277 $method = 'DELETE';
278 }
279
280 // An ability may branch its whole input with oneOf rather than name properties.
281 $schema = self::normalize($ability->get_input_schema());
282 $schema['type'] = 'object';
283
284 $definition = [
285 'name' => self::name($ability->get_name()),
286 'description' => $ability->get_description(),
287 'inputSchema' => $schema,
288 ];
289 $output = self::normalize($ability->get_output_schema());
290 if (($output['type'] ?? '') === 'object') {
291 $definition['outputSchema'] = $output;
292 }
293
294 $hints = self::hints($annotations);
295 if ($hints) {
296 $definition['annotations'] = $hints;
297 }
298
299 return [
300 'mode' => $mode,
301 'method' => $method,
302 'ability' => $ability->get_name(),
303 'definition' => $definition,
304 ];
305 }
306
307 /**
308 * WordPress leaves an annotation null, and an absent hint takes MCP's default.
309 *
310 * @param array $annotations - The ability's meta annotations.
311 * @return array
312 */
313 private static function hints(array $annotations)
314 {
315 $hints = [];
316 foreach (self::HINTS as $annotation => $hint) {
317 if (isset($annotations[$annotation])) {
318 $hints[$hint] = (bool) $annotations[$annotation];
319 }
320 }
321
322 return $hints;
323 }
324
325 /**
326 * The run controller reads input from the query except on a POST.
327 *
328 * @param array $tool - The tool being called.
329 * @param array $arguments - The arguments the client called with.
330 * @return \WP_REST_Request
331 */
332 private static function abilityRequest(array $tool, array $arguments)
333 {
334 $request = new \WP_REST_Request($tool['method'], '/wp-abilities/v1/abilities/' . $tool['ability'] . '/run');
335 if ($tool['method'] !== 'POST') {
336 $request->set_query_params(['input' => $arguments]);
337
338 return $request;
339 }
340
341 $request->set_header('Content-Type', 'application/json');
342 $request->set_body(\wp_json_encode(['input' => (object) $arguments]));
343
344 return $request;
345 }
346
347 /**
348 * @param \WP_REST_Response $response - What the REST server answered.
349 * @param array $tool - The tool that was called.
350 * @return array
351 */
352 private static function answer($response, array $tool)
353 {
354 $data = $response->get_data();
355 $answer = self::json($data);
356 if ($response->is_error()) {
357 $answer['isError'] = true;
358
359 return $answer;
360 }
361
362 // A tool that named an output schema owes the client a result shaped like it.
363 if (isset($tool['definition']['outputSchema']) && (is_array($data) || is_object($data))) {
364 $answer['structuredContent'] = (object) $data;
365 }
366
367 return $answer;
368 }
369
370 /**
371 * @param mixed $data - What the tool answered with.
372 * @return array
373 */
374 private static function json($data)
375 {
376 $text = \wp_json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
377
378 return ['content' => [['type' => 'text', 'text' => $text]]];
379 }
380
381 /**
382 * @param mixed $schema - A schema as a plugin registered it.
383 * @return array
384 */
385 private static function normalize($schema)
386 {
387 if (!is_array($schema)) {
388 return [];
389 }
390
391 $kept = array_intersect_key($schema, array_flip(self::SCHEMA_KEYWORDS));
392 if (isset($kept['required']) && !is_array($kept['required'])) {
393 unset($kept['required']);
394 }
395
396 if (isset($kept['properties']) && is_array($kept['properties'])) {
397 $kept = array_merge($kept, self::describe($kept));
398 }
399
400 if (isset($kept['items'])) {
401 $kept['items'] = self::normalize($kept['items']);
402 }
403
404 foreach (['oneOf', 'anyOf'] as $branch) {
405 if (isset($kept[$branch]) && is_array($kept[$branch])) {
406 $kept[$branch] = array_map([self::class, 'normalize'], $kept[$branch]);
407 }
408 }
409
410 return $kept;
411 }
412
413 /**
414 * A plugin may spell required on the property, as a REST arg does; a client wants it on the object.
415 *
416 * @param array $schema - A schema whose properties need normalizing.
417 * @return array
418 */
419 private static function describe(array $schema)
420 {
421 $properties = [];
422 $required = isset($schema['required']) ? $schema['required'] : [];
423 foreach ($schema['properties'] as $name => $property) {
424 if (!is_array($property)) {
425 continue;
426 }
427
428 if (!empty($property['required'])) {
429 $required[] = (string) $name;
430 }
431
432 $properties[$name] = self::normalize($property);
433 }
434
435 $described = ['properties' => (object) $properties];
436 if ($required) {
437 $described['required'] = array_values(array_unique($required));
438 }
439
440 return $described;
441 }
442
443 /**
444 * @param string $subject - The ability name.
445 * @return string
446 */
447 private static function name($subject)
448 {
449 $name = strtolower(trim(preg_replace('/[^A-Za-z0-9]+/', '_', $subject), '_'));
450 if (strlen($name) <= self::MAX_NAME) {
451 return $name;
452 }
453
454 return substr($name, 0, self::MAX_NAME - 9) . '_' . substr(md5($name), 0, 8);
455 }
456 }
457