PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
1.7.1 1.7.0 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 All 51 releases
← All changes | app/Modules/MCP/AbilitiesRegistrar.php +226 -26 1.4.2 → 1.7.0 View file →
@@ -2,8 +2,9 @@
2 2
3 3 namespace FluentCart\App\Modules\MCP;
4 4
5 5 use FluentCart\App\Modules\MCP\Tools\ContextTools;
6 +use FluentCart\App\Modules\MCP\Tools\SearchTools;
6 7 use FluentCart\App\Modules\MCP\Tools\OrderTools;
7 8 use FluentCart\App\Modules\MCP\Tools\CustomerTools;
8 9 use FluentCart\App\Modules\MCP\Tools\ProductTools;
9 10 use FluentCart\App\Modules\MCP\Tools\SubscriptionTools;
@@ -9,16 +10,20 @@
9 10 use FluentCart\App\Modules\MCP\Tools\SubscriptionTools;
10 11 use FluentCart\App\Modules\MCP\Tools\CouponTools;
11 12 use FluentCart\App\Modules\MCP\Tools\LabelTools;
12 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;
13 17
14 18 /**
15 19 * Single source of truth for every FluentCart MCP ability.
16 20 *
17 21 * Each tool class owns its own `definitions()` slice (schema next to code);
18 - * this class merges them, wraps every execute_callback so unhandled exceptions
19 - * become structured WP_Errors the agent can read (instead of the adapter's
20 - * generic "Tool execution failed"), and registers each as a WP ability.
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.
21 26 *
22 27 * Pro tools are NOT listed here — FluentCart Pro pushes its abilities via the
23 28 * `fluent_cart/mcp_loaded` action + `fluent_cart/mcp_ability_names` filter.
24 29 */
@@ -28,8 +33,9 @@
28 33 private static function toolClasses()
29 34 {
30 35 return [
31 36 ContextTools::class,
37 + SearchTools::class,
32 38 OrderTools::class,
33 39 CustomerTools::class,
34 40 ProductTools::class,
35 41 SubscriptionTools::class,
@@ -35,8 +41,11 @@
35 41 SubscriptionTools::class,
36 42 CouponTools::class,
37 43 LabelTools::class,
38 44 ReportTools::class,
45 + ProductFinancialsTools::class,
46 + PaymentScheduleTools::class,
47 + TransactionTools::class,
39 48 ];
40 49 }
41 50
42 51 public static function getDefinitions()
@@ -54,49 +63,240 @@
54 63
55 64 public static function register()
56 65 {
57 66 foreach (self::getDefinitions() as $name => $definition) {
58 - $args = [
59 - 'label' => $definition['label'],
60 - 'description' => $definition['description'],
61 - 'category' => 'fluent-cart',
62 - 'execute_callback' => self::wrapExecuteCallback($name, $definition['execute_callback']),
63 - 'permission_callback' => $definition['permission_callback'],
64 - 'meta' => [
65 - 'show_in_rest' => true,
66 - 'mcp' => ['public' => true],
67 - ],
68 - ];
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 + );
69 81
70 - if (!empty($definition['input_schema'])) {
71 - $args['input_schema'] = $definition['input_schema'];
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 + ]);
72 95 }
96 + }
97 + }
73 98
74 - if (!empty($definition['output_schema'])) {
75 - $args['output_schema'] = $definition['output_schema'];
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;
76 136 }
137 + }
77 138
78 - if (!empty($definition['annotations'])) {
79 - // snake_case keys; the adapter's McpAnnotationMapper translates
80 - // readonly -> readOnlyHint, destructive -> destructiveHint, etc.
81 - $args['meta']['annotations'] = $definition['annotations'];
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;
82 172 }
173 + }
83 174
84 - wp_register_ability($name, $args);
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;
85 181 }
182 +
183 + return $out;
86 184 }
87 185
88 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 + /**
89 279 * Convert any unhandled \Throwable from a tool into a structured WP_Error
90 280 * carrying the real message (and, under WP_DEBUG, the file + a short trace).
91 281 * Without this the agent only sees the adapter's generic failure surface and
92 282 * retries blindly against tools that may have partially succeeded.
93 283 */
94 - private static function wrapExecuteCallback($toolName, $callback)
284 + private static function wrapExecuteCallback($toolName, $callback, $declaredParams = [])
95 285 {
96 - return function ($params) use ($toolName, $callback) {
286 + return function ($params) use ($toolName, $callback, $declaredParams) {
97 287 try {
98 - return call_user_func($callback, $params);
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);
99 299 } catch (\Throwable $e) {
100 300 /**
101 301 * Fires when an MCP tool throws. Lets sites log/alert before the
102 302 * structured error reaches the agent.