PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
fluent-cart / app / Modules / MCP / AbilitiesRegistrar.php

AbilitiesRegistrar.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.5, at app/Modules/MCP/AbilitiesRegistrar.php

334 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 namespace FluentCart\App\Modules\MCP;
4
5 use FluentCart\App\Modules\MCP\Tools\ContextTools;
6 use FluentCart\App\Modules\MCP\Tools\SearchTools;
7 use FluentCart\App\Modules\MCP\Tools\OrderTools;
8 use FluentCart\App\Modules\MCP\Tools\CustomerTools;
9 use FluentCart\App\Modules\MCP\Tools\ProductTools;
10 use FluentCart\App\Modules\MCP\Tools\SubscriptionTools;
11 use FluentCart\App\Modules\MCP\Tools\CouponTools;
12 use FluentCart\App\Modules\MCP\Tools\LabelTools;
13 use FluentCart\App\Modules\MCP\Tools\ReportTools;
14 use FluentCart\App\Modules\MCP\Tools\ProductFinancialsTools;
15 use FluentCart\App\Modules\MCP\Tools\PaymentScheduleTools;
16 use FluentCart\App\Modules\MCP\Tools\TransactionTools;
17
18 /**
19 * Single source of truth for every FluentCart MCP ability.
20 *
21 * Each tool class owns its own `definitions()` slice (schema next to code);
22 * this class merges them, wraps every execute_callback — rejecting undeclared
23 * input params up front and converting unhandled exceptions into structured
24 * WP_Errors the agent can read (instead of the adapter's generic "Tool
25 * execution failed") — and registers each as a WP ability.
26 *
27 * Pro tools are NOT listed here — FluentCart Pro pushes its abilities via the
28 * `fluent_cart/mcp_loaded` action + `fluent_cart/mcp_ability_names` filter.
29 */
30 class AbilitiesRegistrar
31 {
32 /** Tool classes that expose a static definitions() method. */
33 private static function toolClasses()
34 {
35 return [
36 ContextTools::class,
37 SearchTools::class,
38 OrderTools::class,
39 CustomerTools::class,
40 ProductTools::class,
41 SubscriptionTools::class,
42 CouponTools::class,
43 LabelTools::class,
44 ReportTools::class,
45 ProductFinancialsTools::class,
46 PaymentScheduleTools::class,
47 TransactionTools::class,
48 ];
49 }
50
51 public static function getDefinitions()
52 {
53 $defs = [];
54
55 foreach (self::toolClasses() as $class) {
56 if (class_exists($class) && method_exists($class, 'definitions')) {
57 $defs = array_merge($defs, (array) $class::definitions());
58 }
59 }
60
61 return $defs;
62 }
63
64 public static function register()
65 {
66 foreach (self::getDefinitions() as $name => $definition) {
67 try {
68 self::registerAbility($name, $definition);
69 } catch (\Throwable $e) {
70 // Registration runs on wp_abilities_api_init, which the adapter
71 // fires lazily from INSIDE our own create_server() call — so an
72 // uncaught throw here doesn't just drop this one ability: it
73 // aborts every later callback on the action (other plugins'
74 // abilities included) and kills the FluentCart MCP server
75 // itself, 404ing the endpoint. One malformed definition must
76 // never take the whole surface down: skip it, log it, move on.
77 fluent_cart_error_log(
78 'MCP ability registration failed: ' . $name,
79 get_class($e) . ': ' . $e->getMessage() . ' at ' . basename($e->getFile()) . ':' . $e->getLine()
80 );
81
82 /**
83 * Fires when a single MCP ability fails to register. The
84 * remaining abilities still register; this lets sites alert on
85 * the gap.
86 *
87 * @since 1.0.0
88 *
89 * @param array $context { exception: \Throwable, ability: string }
90 */
91 do_action('fluent_cart/mcp_ability_registration_failed', [
92 'exception' => $e,
93 'ability' => $name,
94 ]);
95 }
96 }
97 }
98
99 /**
100 * Register one ability definition with the Abilities API. Kept separate
101 * from register() so its try/catch stays a thin skip-and-continue shell.
102 */
103 private static function registerAbility($name, $definition)
104 {
105 // Cast before array_keys: no-arg tools declare properties as
106 // stdClass (so the schema serializes as {} not []), which
107 // array_keys() rejects on PHP 8 with a TypeError.
108 $declaredParams = isset($definition['input_schema']['properties'])
109 ? array_keys((array) $definition['input_schema']['properties'])
110 : [];
111
112 $args = [
113 'label' => $definition['label'],
114 'description' => $definition['description'],
115 'category' => 'fluent-cart',
116 'execute_callback' => self::wrapExecuteCallback($name, $definition['execute_callback'], $declaredParams),
117 'permission_callback' => $definition['permission_callback'],
118 'meta' => [
119 'show_in_rest' => true,
120 'mcp' => ['public' => true],
121 ],
122 ];
123
124 if (!empty($definition['input_schema'])) {
125 $args['input_schema'] = $definition['input_schema'];
126 }
127
128 if (!empty($definition['output_schema'])) {
129 $args['output_schema'] = $definition['output_schema'];
130 }
131
132 if (!empty($definition['annotations'])) {
133 $mapped = self::mapAnnotations($definition['annotations']);
134 if (!empty($mapped)) {
135 $args['meta']['annotations'] = $mapped;
136 }
137 }
138
139 wp_register_ability($name, $args);
140 }
141
142 /**
143 * Translate a tool's readable snake_case behavior hints into the MCP tool
144 * annotation keys clients actually read.
145 *
146 * Tool classes declare intent as readonly / destructive / idempotent /
147 * open_world / title. The MCP spec names them readOnlyHint / destructiveHint
148 * / idempotentHint / openWorldHint, and the WP MCP adapter forwards
149 * meta.annotations VERBATIM (it does not translate), so an unmapped
150 * 'readonly' key would never reach a client as a real hint. Unknown keys
151 * (e.g. a stray 'bulk') are dropped rather than emitted as noise a client
152 * cannot act on.
153 *
154 * @param array $annotations snake_case behavior hints from the tool definition
155 * @return array MCP-standard annotation keys
156 */
157 private static function mapAnnotations($annotations)
158 {
159 $map = [
160 'readonly' => 'readOnlyHint',
161 'destructive' => 'destructiveHint',
162 'idempotent' => 'idempotentHint',
163 'open_world' => 'openWorldHint',
164 ];
165
166 $out = [];
167 foreach ((array) $annotations as $key => $value) {
168 if ($key === 'title') {
169 $out['title'] = (string) $value;
170 } elseif (isset($map[$key])) {
171 $out[$map[$key]] = (bool) $value;
172 }
173 }
174
175 // A read-only tool cannot be destructive. destructiveHint defaults to
176 // true when absent (MCP spec), so state it explicitly for read tools —
177 // otherwise a client gating on destructiveHint would treat every report
178 // as dangerous.
179 if (!empty($out['readOnlyHint']) && !isset($out['destructiveHint'])) {
180 $out['destructiveHint'] = false;
181 }
182
183 return $out;
184 }
185
186 /**
187 * Reject any input param this tool does not declare, instead of executing
188 * with it silently dropped. input_schema sets no additionalProperties, so an
189 * unknown key would otherwise pass validation and the agent would get a
190 * full, plausible-looking result that is NOT filtered the way it asked —
191 * the worst failure mode for an agent (a wrong number reads as a right one;
192 * an error is recoverable). Sibling tools also name overlapping concepts
193 * differently (list-orders: created_after/type; query-orders: start_date/
194 * order_type dimension), so carried-over names are a common, realistic slip,
195 * not a rare typo. The error lists the accepted params so the agent can
196 * self-correct in one step — richer than the schema validator's message,
197 * which is why this is enforced here rather than via additionalProperties.
198 *
199 * @param string $toolName
200 * @param mixed $params the raw input params
201 * @param array $declaredParams input_schema property names this tool declares
202 * @return \WP_Error|null null when all params are declared
203 */
204 private static function rejectUnknownParams($toolName, $params, $declaredParams)
205 {
206 if (!is_array($params)) {
207 return null;
208 }
209
210 $unknown = [];
211 foreach (array_keys($params) as $key) {
212 if (!in_array((string) $key, $declaredParams, true)) {
213 $unknown[] = (string) $key;
214 }
215 }
216
217 if (empty($unknown)) {
218 return null;
219 }
220
221 return \FluentCart\App\Modules\MCP\Support\MCPHelper::error(
222 'unknown_param',
223 sprintf(
224 /* translators: 1: rejected parameter names, 2: tool name, 3: accepted parameter names */
225 __('Unknown parameter(s) [%1$s] — %2$s does not support them, and running without them would return a result that is NOT filtered the way you asked. Accepted parameters: [%3$s]. Rename or remove the unknown parameter(s) and retry.', 'fluent-cart'),
226 implode(', ', $unknown),
227 $toolName,
228 $declaredParams ? implode(', ', $declaredParams) : __('none — this tool takes no parameters', 'fluent-cart')
229 ),
230 ['fields' => $unknown, 'accepted' => $declaredParams, 'tool' => $toolName]
231 );
232 }
233
234 /**
235 * Append a meta.warnings entry when a requested per_page exceeded the tool's
236 * ceiling and was clamped (the descriptions state each cap, but a stated cap
237 * still deserves a runtime signal — the agent asked for 500 rows and must
238 * know it got a 100-row page, not the full set). Detected generically by
239 * comparing the request against meta.page.per_page, so every list and
240 * query tool is covered without per-tool changes. Untouched when the result
241 * isn't a paged success envelope.
242 *
243 * @param mixed $result the tool's return value
244 * @param mixed $params the raw input params
245 * @return mixed
246 */
247 private static function annotateClampedPerPage($result, $params)
248 {
249 if (
250 !is_array($params) || !isset($params['per_page'])
251 || !is_array($result) || !isset($result['meta']['page']['per_page'])
252 ) {
253 return $result;
254 }
255
256 $requested = (int) $params['per_page'];
257 $actual = (int) $result['meta']['page']['per_page'];
258 if ($actual < 1 || $requested <= $actual) {
259 return $result;
260 }
261
262 $warnings = (isset($result['meta']['warnings']) && is_array($result['meta']['warnings']))
263 ? $result['meta']['warnings']
264 : [];
265
266 $warnings[] = sprintf(
267 /* translators: 1: requested per_page, 2: the maximum this tool returned */
268 __('per_page %1$d exceeds this tool\'s maximum; %2$d rows per page were returned. Use meta.page (total/pages/has_more) to page through the rest.', 'fluent-cart'),
269 $requested,
270 $actual
271 );
272
273 $result['meta']['warnings'] = $warnings;
274
275 return $result;
276 }
277
278 /**
279 * Convert any unhandled \Throwable from a tool into a structured WP_Error
280 * carrying the real message (and, under WP_DEBUG, the file + a short trace).
281 * Without this the agent only sees the adapter's generic failure surface and
282 * retries blindly against tools that may have partially succeeded.
283 */
284 private static function wrapExecuteCallback($toolName, $callback, $declaredParams = [])
285 {
286 return function ($params) use ($toolName, $callback, $declaredParams) {
287 try {
288 // Before executing: an undeclared param means the caller asked for
289 // a filter this tool can't apply — error out rather than return a
290 // confidently wrong (unfiltered) result. Runs before the callback
291 // so write tools never partially execute on a malformed call.
292 $unknownError = self::rejectUnknownParams($toolName, $params, $declaredParams);
293 if ($unknownError !== null) {
294 return $unknownError;
295 }
296
297 $result = call_user_func($callback, $params);
298 return self::annotateClampedPerPage($result, $params);
299 } catch (\Throwable $e) {
300 /**
301 * Fires when an MCP tool throws. Lets sites log/alert before the
302 * structured error reaches the agent.
303 *
304 * @since 1.0.0
305 *
306 * @param array $context { exception: \Throwable, tool: string, params: mixed }
307 */
308 do_action('fluent_cart/mcp_tool_exception', [
309 'exception' => $e,
310 'tool' => $toolName,
311 'params' => $params,
312 ]);
313
314 // Unexpected exceptions are treated as transient (retryable):
315 // the agent may legitimately retry once.
316 $details = ['tool' => $toolName, 'exception' => get_class($e), 'retryable' => true];
317
318 // File/line/trace help an operator debug, but this payload is
319 // forwarded to the remote agent/LLM — raw paths would leak the
320 // server's filesystem layout. So it's off by default and opt-in
321 // only; full detail is always available server-side via the
322 // action above. When enabled, the file is reduced to a basename.
323 $exposeDetails = apply_filters('fluent_cart/mcp_expose_error_details', false);
324 if ($exposeDetails) {
325 $details['file'] = basename($e->getFile()) . ':' . $e->getLine();
326 $details['trace'] = array_slice(explode("\n", $e->getTraceAsString()), 0, 5);
327 }
328
329 return \FluentCart\App\Modules\MCP\Support\MCPHelper::error('tool_failed', $e->getMessage(), $details);
330 }
331 };
332 }
333 }
334