| @@ -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; |
| @@ -17,11 +18,12 @@ | ||
| 17 | 18 | /** |
| 18 | 19 | * Single source of truth for every FluentCart MCP ability. |
| 19 | 20 | * |
| 20 | 21 | * 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. | |
| 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. | |
| 24 | 26 | * |
| 25 | 27 | * Pro tools are NOT listed here — FluentCart Pro pushes its abilities via the |
| 26 | 28 | * `fluent_cart/mcp_loaded` action + `fluent_cart/mcp_ability_names` filter. |
| 27 | 29 | */ |
| @@ -31,8 +33,9 @@ | ||
| 31 | 33 | private static function toolClasses() |
| 32 | 34 | { |
| 33 | 35 | return [ |
| 34 | 36 | ContextTools::class, |
| 37 | + SearchTools::class, | |
| 35 | 38 | OrderTools::class, |
| 36 | 39 | CustomerTools::class, |
| 37 | 40 | ProductTools::class, |
| 38 | 41 | SubscriptionTools::class, |
| @@ -180,43 +183,80 @@ | ||
| 180 | 183 | return $out; |
| 181 | 184 | } |
| 182 | 185 | |
| 183 | 186 | /** |
| 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. | |
| 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 | |
| 191 | 203 | */ |
| 192 | - const PORTABLE_PARAMS = ['product_id', 'variation_id', 'summary_only', 'fields', 'mode']; | |
| 204 | + private static function rejectUnknownParams($toolName, $params, $declaredParams) | |
| 205 | + { | |
| 206 | + if (!is_array($params)) { | |
| 207 | + return null; | |
| 208 | + } | |
| 193 | 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 | + | |
| 194 | 234 | /** |
| 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. | |
| 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. | |
| 199 | 242 | * |
| 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 | |
| 243 | + * @param mixed $result the tool's return value | |
| 244 | + * @param mixed $params the raw input params | |
| 203 | 245 | * @return mixed |
| 204 | 246 | */ |
| 205 | - private static function annotateIgnoredParams($result, $params, $declaredParams) | |
| 247 | + private static function annotateClampedPerPage($result, $params) | |
| 206 | 248 | { |
| 207 | - if (!is_array($result) || !isset($result['meta']) || !is_array($result['meta']) || !is_array($params)) { | |
| 249 | + if ( | |
| 250 | + !is_array($params) || !isset($params['per_page']) | |
| 251 | + || !is_array($result) || !isset($result['meta']['page']['per_page']) | |
| 252 | + ) { | |
| 208 | 253 | return $result; |
| 209 | 254 | } |
| 210 | 255 | |
| 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)) { | |
| 256 | + $requested = (int) $params['per_page']; | |
| 257 | + $actual = (int) $result['meta']['page']['per_page']; | |
| 258 | + if ($actual < 1 || $requested <= $actual) { | |
| 219 | 259 | return $result; |
| 220 | 260 | } |
| 221 | 261 | |
| 222 | 262 | $warnings = (isset($result['meta']['warnings']) && is_array($result['meta']['warnings'])) |
| @@ -222,15 +262,14 @@ | ||
| 222 | 262 | $warnings = (isset($result['meta']['warnings']) && is_array($result['meta']['warnings'])) |
| 223 | 263 | ? $result['meta']['warnings'] |
| 224 | 264 | : []; |
| 225 | 265 | |
| 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 | - } | |
| 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 | + ); | |
| 233 | 272 | |
| 234 | 273 | $result['meta']['warnings'] = $warnings; |
| 235 | 274 | |
| 236 | 275 | return $result; |
| @@ -245,10 +284,19 @@ | ||
| 245 | 284 | private static function wrapExecuteCallback($toolName, $callback, $declaredParams = []) |
| 246 | 285 | { |
| 247 | 286 | return function ($params) use ($toolName, $callback, $declaredParams) { |
| 248 | 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 | + | |
| 249 | 297 | $result = call_user_func($callback, $params); |
| 250 | - return self::annotateIgnoredParams($result, $params, $declaredParams); | |
| 298 | + return self::annotateClampedPerPage($result, $params); | |
| 251 | 299 | } catch (\Throwable $e) { |
| 252 | 300 | /** |
| 253 | 301 | * Fires when an MCP tool throws. Lets sites log/alert before the |
| 254 | 302 | * structured error reaches the agent. |