PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.3
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.3
1.6.6 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 All 49 releases
fluent-cart / app / Modules / MCP / AbilitiesRegistrar.php

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

286 lines 11.6 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\OrderTools;
7 use FluentCart\App\Modules\MCP\Tools\CustomerTools;
8 use FluentCart\App\Modules\MCP\Tools\ProductTools;
9 use FluentCart\App\Modules\MCP\Tools\SubscriptionTools;
10 use FluentCart\App\Modules\MCP\Tools\CouponTools;
11 use FluentCart\App\Modules\MCP\Tools\LabelTools;
12 use FluentCart\App\Modules\MCP\Tools\ReportTools;
13 use FluentCart\App\Modules\MCP\Tools\ProductFinancialsTools;
14 use FluentCart\App\Modules\MCP\Tools\PaymentScheduleTools;
15 use FluentCart\App\Modules\MCP\Tools\TransactionTools;
16
17 /**
18 * Single source of truth for every FluentCart MCP ability.
19 *
20 * Each tool class owns its own `definitions()` slice (schema next to code);
21 * this class merges them, wraps every execute_callback so unhandled exceptions
22 * become structured WP_Errors the agent can read (instead of the adapter's
23 * generic "Tool execution failed"), and registers each as a WP ability.
24 *
25 * Pro tools are NOT listed here — FluentCart Pro pushes its abilities via the
26 * `fluent_cart/mcp_loaded` action + `fluent_cart/mcp_ability_names` filter.
27 */
28 class AbilitiesRegistrar
29 {
30 /** Tool classes that expose a static definitions() method. */
31 private static function toolClasses()
32 {
33 return [
34 ContextTools::class,
35 OrderTools::class,
36 CustomerTools::class,
37 ProductTools::class,
38 SubscriptionTools::class,
39 CouponTools::class,
40 LabelTools::class,
41 ReportTools::class,
42 ProductFinancialsTools::class,
43 PaymentScheduleTools::class,
44 TransactionTools::class,
45 ];
46 }
47
48 public static function getDefinitions()
49 {
50 $defs = [];
51
52 foreach (self::toolClasses() as $class) {
53 if (class_exists($class) && method_exists($class, 'definitions')) {
54 $defs = array_merge($defs, (array) $class::definitions());
55 }
56 }
57
58 return $defs;
59 }
60
61 public static function register()
62 {
63 foreach (self::getDefinitions() as $name => $definition) {
64 try {
65 self::registerAbility($name, $definition);
66 } catch (\Throwable $e) {
67 // Registration runs on wp_abilities_api_init, which the adapter
68 // fires lazily from INSIDE our own create_server() call — so an
69 // uncaught throw here doesn't just drop this one ability: it
70 // aborts every later callback on the action (other plugins'
71 // abilities included) and kills the FluentCart MCP server
72 // itself, 404ing the endpoint. One malformed definition must
73 // never take the whole surface down: skip it, log it, move on.
74 fluent_cart_error_log(
75 'MCP ability registration failed: ' . $name,
76 get_class($e) . ': ' . $e->getMessage() . ' at ' . basename($e->getFile()) . ':' . $e->getLine()
77 );
78
79 /**
80 * Fires when a single MCP ability fails to register. The
81 * remaining abilities still register; this lets sites alert on
82 * the gap.
83 *
84 * @since 1.0.0
85 *
86 * @param array $context { exception: \Throwable, ability: string }
87 */
88 do_action('fluent_cart/mcp_ability_registration_failed', [
89 'exception' => $e,
90 'ability' => $name,
91 ]);
92 }
93 }
94 }
95
96 /**
97 * Register one ability definition with the Abilities API. Kept separate
98 * from register() so its try/catch stays a thin skip-and-continue shell.
99 */
100 private static function registerAbility($name, $definition)
101 {
102 // Cast before array_keys: no-arg tools declare properties as
103 // stdClass (so the schema serializes as {} not []), which
104 // array_keys() rejects on PHP 8 with a TypeError.
105 $declaredParams = isset($definition['input_schema']['properties'])
106 ? array_keys((array) $definition['input_schema']['properties'])
107 : [];
108
109 $args = [
110 'label' => $definition['label'],
111 'description' => $definition['description'],
112 'category' => 'fluent-cart',
113 'execute_callback' => self::wrapExecuteCallback($name, $definition['execute_callback'], $declaredParams),
114 'permission_callback' => $definition['permission_callback'],
115 'meta' => [
116 'show_in_rest' => true,
117 'mcp' => ['public' => true],
118 ],
119 ];
120
121 if (!empty($definition['input_schema'])) {
122 $args['input_schema'] = $definition['input_schema'];
123 }
124
125 if (!empty($definition['output_schema'])) {
126 $args['output_schema'] = $definition['output_schema'];
127 }
128
129 if (!empty($definition['annotations'])) {
130 $mapped = self::mapAnnotations($definition['annotations']);
131 if (!empty($mapped)) {
132 $args['meta']['annotations'] = $mapped;
133 }
134 }
135
136 wp_register_ability($name, $args);
137 }
138
139 /**
140 * Translate a tool's readable snake_case behavior hints into the MCP tool
141 * annotation keys clients actually read.
142 *
143 * Tool classes declare intent as readonly / destructive / idempotent /
144 * open_world / title. The MCP spec names them readOnlyHint / destructiveHint
145 * / idempotentHint / openWorldHint, and the WP MCP adapter forwards
146 * meta.annotations VERBATIM (it does not translate), so an unmapped
147 * 'readonly' key would never reach a client as a real hint. Unknown keys
148 * (e.g. a stray 'bulk') are dropped rather than emitted as noise a client
149 * cannot act on.
150 *
151 * @param array $annotations snake_case behavior hints from the tool definition
152 * @return array MCP-standard annotation keys
153 */
154 private static function mapAnnotations($annotations)
155 {
156 $map = [
157 'readonly' => 'readOnlyHint',
158 'destructive' => 'destructiveHint',
159 'idempotent' => 'idempotentHint',
160 'open_world' => 'openWorldHint',
161 ];
162
163 $out = [];
164 foreach ((array) $annotations as $key => $value) {
165 if ($key === 'title') {
166 $out['title'] = (string) $value;
167 } elseif (isset($map[$key])) {
168 $out[$map[$key]] = (bool) $value;
169 }
170 }
171
172 // A read-only tool cannot be destructive. destructiveHint defaults to
173 // true when absent (MCP spec), so state it explicitly for read tools —
174 // otherwise a client gating on destructiveHint would treat every report
175 // as dangerous.
176 if (!empty($out['readOnlyHint']) && !isset($out['destructiveHint'])) {
177 $out['destructiveHint'] = false;
178 }
179
180 return $out;
181 }
182
183 /**
184 * Portable params an agent naturally carries from one tool to a sibling but
185 * which only some tools accept. input_schema sets no additionalProperties, so
186 * an unsupported one is silently ignored and the agent gets a full,
187 * wrong-shaped result with no signal it wasn't filtered. We surface exactly
188 * these as meta.warnings. We deliberately do NOT warn on every unknown key:
189 * that risks false positives against a param a tool reads but doesn't declare,
190 * and would turn a typo into noise instead of a helpful correction.
191 */
192 const PORTABLE_PARAMS = ['product_id', 'variation_id', 'summary_only', 'fields', 'mode'];
193
194 /**
195 * Append a meta.warnings entry for each portable param the caller passed that
196 * this tool does not declare (and therefore ignored). Untouched when the
197 * result isn't a success envelope (e.g. a WP_Error) or nothing was ignored,
198 * so a tool's own warnings (list-reference-data) are preserved.
199 *
200 * @param mixed $result the tool's return value
201 * @param mixed $params the raw input params
202 * @param array $declaredParams input_schema property names this tool declares
203 * @return mixed
204 */
205 private static function annotateIgnoredParams($result, $params, $declaredParams)
206 {
207 if (!is_array($result) || !isset($result['meta']) || !is_array($result['meta']) || !is_array($params)) {
208 return $result;
209 }
210
211 $ignored = [];
212 foreach (self::PORTABLE_PARAMS as $p) {
213 if (array_key_exists($p, $params) && !in_array($p, $declaredParams, true)) {
214 $ignored[] = $p;
215 }
216 }
217
218 if (empty($ignored)) {
219 return $result;
220 }
221
222 $warnings = (isset($result['meta']['warnings']) && is_array($result['meta']['warnings']))
223 ? $result['meta']['warnings']
224 : [];
225
226 foreach ($ignored as $p) {
227 $warnings[] = sprintf(
228 /* translators: %1$s: the parameter name that was ignored */
229 __('The "%1$s" parameter is not supported by this tool and was ignored — the result is not filtered by it. Check the tool schema for the parameters this tool accepts.', 'fluent-cart'),
230 $p
231 );
232 }
233
234 $result['meta']['warnings'] = $warnings;
235
236 return $result;
237 }
238
239 /**
240 * Convert any unhandled \Throwable from a tool into a structured WP_Error
241 * carrying the real message (and, under WP_DEBUG, the file + a short trace).
242 * Without this the agent only sees the adapter's generic failure surface and
243 * retries blindly against tools that may have partially succeeded.
244 */
245 private static function wrapExecuteCallback($toolName, $callback, $declaredParams = [])
246 {
247 return function ($params) use ($toolName, $callback, $declaredParams) {
248 try {
249 $result = call_user_func($callback, $params);
250 return self::annotateIgnoredParams($result, $params, $declaredParams);
251 } catch (\Throwable $e) {
252 /**
253 * Fires when an MCP tool throws. Lets sites log/alert before the
254 * structured error reaches the agent.
255 *
256 * @since 1.0.0
257 *
258 * @param array $context { exception: \Throwable, tool: string, params: mixed }
259 */
260 do_action('fluent_cart/mcp_tool_exception', [
261 'exception' => $e,
262 'tool' => $toolName,
263 'params' => $params,
264 ]);
265
266 // Unexpected exceptions are treated as transient (retryable):
267 // the agent may legitimately retry once.
268 $details = ['tool' => $toolName, 'exception' => get_class($e), 'retryable' => true];
269
270 // File/line/trace help an operator debug, but this payload is
271 // forwarded to the remote agent/LLM — raw paths would leak the
272 // server's filesystem layout. So it's off by default and opt-in
273 // only; full detail is always available server-side via the
274 // action above. When enabled, the file is reduced to a basename.
275 $exposeDetails = apply_filters('fluent_cart/mcp_expose_error_details', false);
276 if ($exposeDetails) {
277 $details['file'] = basename($e->getFile()) . ':' . $e->getLine();
278 $details['trace'] = array_slice(explode("\n", $e->getTraceAsString()), 0, 5);
279 }
280
281 return \FluentCart\App\Modules\MCP\Support\MCPHelper::error('tool_failed', $e->getMessage(), $details);
282 }
283 };
284 }
285 }
286