| @@ -1,11 +1,14 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | |
| 3 | 3 | namespace FluentCart\App\Modules\MCP\Tools; |
| 4 | 4 | |
| 5 | +use FluentCart\App\App; | |
| 6 | +use FluentCart\App\Helpers\Status; | |
| 5 | 7 | use FluentCart\App\Models\Order; |
| 6 | 8 | use FluentCart\App\Models\Customer; |
| 7 | 9 | use FluentCart\App\Models\Subscription; |
| 10 | +use FluentCart\App\Modules\MCP\AbilitiesRegistrar; | |
| 8 | 11 | use FluentCart\App\Modules\MCP\Support\MCPHelper; |
| 9 | 12 | use FluentCart\App\Modules\MCP\Support\PermissionGate; |
| 10 | 13 | use FluentCart\App\Services\DateTime\DateTime; |
| 11 | 14 | use FluentCart\App\Services\Permission\PermissionManager; |
| @@ -32,22 +35,32 @@ | ||
| 32 | 35 | const CACHE_TTL = 60; |
| 33 | 36 | |
| 34 | 37 | const CACHE_PREFIX = 'fluent_cart_mcp_context_'; |
| 35 | 38 | |
| 36 | - // The verified FluentCart domain enums. Hardcoded (with a filter override) | |
| 37 | - // rather than scraped, so the agent always gets the complete valid set even | |
| 38 | - // if a status currently has zero rows. | |
| 39 | + // Baseline domain enums. The status families are re-read from the canonical | |
| 40 | + // Status helper at runtime by enums() — this literal is only the fallback for | |
| 41 | + // a family Status cannot answer for. Do not hand-maintain the status lists | |
| 42 | + // here: an enum the agent trusts but the column can never hold turns every | |
| 43 | + // filter built from it into a silent zero-row result. | |
| 39 | 44 | const ENUMS = [ |
| 40 | - 'order_statuses' => ['draft', 'pending', 'on-hold', 'processing', 'completed', 'canceled', 'failed', 'refunded', 'partial-refund'], | |
| 41 | - // Kept in sync with Status::getPaymentStatuses(); 'authorized' is a valid | |
| 42 | - // persisted status (card authorized, not yet captured) and must be listed | |
| 43 | - // so clients can filter authorized orders through list-orders. | |
| 44 | - 'payment_statuses' => ['paid', 'pending', 'failed', 'refunded', 'partially_refunded', 'partially_paid', 'authorized'], | |
| 45 | + // Status::getOrderStatuses() plus PERSISTED_ONLY_ORDER_STATUSES — see that | |
| 46 | + // constant for why the canonical helper is not the whole set. | |
| 47 | + // 'partial-refund' is deliberately absent: unlike the others below, no code | |
| 48 | + // path writes it (the column COMMENT lists it, but nothing persists it). | |
| 49 | + 'order_statuses' => ['draft', 'pending', 'processing', 'completed', 'on-hold', 'canceled', 'failed', 'refunded'], | |
| 50 | + // Kept in sync with Status::getPaymentStatuses(); 'authorized' (card | |
| 51 | + // authorized, not yet captured) and 'payment_scheduled' are valid | |
| 52 | + // persisted statuses and must be listed so clients can filter them. | |
| 53 | + 'payment_statuses' => ['pending', 'paid', 'partially_paid', 'failed', 'refunded', 'partially_refunded', 'authorized', 'payment_scheduled'], | |
| 45 | 54 | // 'none' = no shipping required (e.g. digital orders); reported when the |
| 46 | 55 | // stored value is empty. It is read-only — change-order-status won't set it. |
| 47 | 56 | 'shipping_statuses' => ['none', 'unshipped', 'shipped', 'delivered', 'unshippable'], |
| 48 | 57 | 'order_types' => ['payment', 'renewal', 'subscription'], |
| 49 | - 'subscription_statuses' => ['active', 'trialing', 'paused', 'canceled', 'failing', 'expired', 'expiring', 'past_due', 'intended', 'pending', 'completed'], | |
| 58 | + 'subscription_statuses' => ['pending', 'active', 'failing', 'paused', 'expired', 'expiring', 'canceled', 'trialing', 'intended', 'past_due', 'completed'], | |
| 59 | + // installment = fixed-term split-pay plan (a lifetime license paid off in | |
| 60 | + // a finite number of charges, bill_times > 0); recurring = open-ended | |
| 61 | + // subscription (bill_times = 0). Derived from bill_times, never the title. | |
| 62 | + 'plan_types' => ['installment', 'recurring'], | |
| 50 | 63 | 'billing_intervals' => ['daily', 'weekly', 'monthly', 'quarterly', 'half_yearly', 'yearly'], |
| 51 | 64 | 'fulfillment_types' => ['physical', 'digital'], |
| 52 | 65 | 'coupon_types' => ['fixed', 'percentage'], |
| 53 | 66 | 'order_modes' => ['live', 'test'], |
| @@ -52,8 +65,87 @@ | ||
| 52 | 65 | 'coupon_types' => ['fixed', 'percentage'], |
| 53 | 66 | 'order_modes' => ['live', 'test'], |
| 54 | 67 | ]; |
| 55 | 68 | |
| 69 | + /** | |
| 70 | + * Order statuses fct_orders.status genuinely holds that Status::getOrderStatuses() | |
| 71 | + * does NOT list, because that helper answers "what may an admin SET an order to", | |
| 72 | + * not "what can this column contain". | |
| 73 | + * | |
| 74 | + * An enum is wrong in two directions, and only one of them is loud. Listing a | |
| 75 | + * value the column can never hold gives the agent a filter that silently returns | |
| 76 | + * zero rows. OMITTING a value the column does hold is worse: those rows become | |
| 77 | + * unreachable, and because the value is missing from the input_schema enum the | |
| 78 | + * call is rejected outright, so the agent cannot even discover the rows exist. | |
| 79 | + * | |
| 80 | + * Each of these is written by a core path, verified in source: | |
| 81 | + * - draft: the column DEFAULT (database/Migrations/OrdersMigrator.php). | |
| 82 | + * - pending: every store-managed renewal invoice | |
| 83 | + * (StoreManagedRenewal/Services/RenewalService.php:113). | |
| 84 | + * - refunded: the WooCommerce migrator maps wc-refunded to it | |
| 85 | + * (WooCommerceMigrator/Services/OrderMigrationService.php). | |
| 86 | + * | |
| 87 | + * So a store using store-managed renewals, or migrated from WooCommerce, has rows | |
| 88 | + * the five-value helper cannot describe. Keep this list in step with the writers, | |
| 89 | + * not with the admin dropdown. | |
| 90 | + * | |
| 91 | + * Note COD is NOT one of them: a COD checkout creates the order as 'on-hold' and | |
| 92 | + * only its payment_status is pending. Cod::maybeUpdatePayments() looks like an | |
| 93 | + * order-status writer but has no callers. | |
| 94 | + */ | |
| 95 | + const PERSISTED_ONLY_ORDER_STATUSES = ['draft', 'pending', 'refunded']; | |
| 96 | + | |
| 97 | + /** | |
| 98 | + * The enums the agent is told to trust, with every status family re-read from | |
| 99 | + * the canonical Status helper so this payload can never drift from what the | |
| 100 | + * columns actually hold (a drifted enum is worse than a missing one — the | |
| 101 | + * agent builds a valid-looking filter that always returns zero rows). | |
| 102 | + * | |
| 103 | + * Status::get*Statuses() are themselves filtered, so a Pro/add-on status | |
| 104 | + * registered through those hooks shows up here automatically. | |
| 105 | + * | |
| 106 | + * @return array | |
| 107 | + */ | |
| 108 | + public static function enums() | |
| 109 | + { | |
| 110 | + $enums = self::ENUMS; | |
| 111 | + | |
| 112 | + $live = [ | |
| 113 | + 'order_statuses' => [Status::class, 'getOrderStatuses'], | |
| 114 | + 'payment_statuses' => [Status::class, 'getPaymentStatuses'], | |
| 115 | + 'shipping_statuses' => [Status::class, 'getShippingStatuses'], | |
| 116 | + 'subscription_statuses' => [Status::class, 'getSubscriptionStatuses'], | |
| 117 | + ]; | |
| 118 | + | |
| 119 | + foreach ($live as $key => $callable) { | |
| 120 | + try { | |
| 121 | + $values = array_values(array_map('strval', array_keys((array) call_user_func($callable)))); | |
| 122 | + } catch (\Throwable $e) { | |
| 123 | + // Keep the baseline rather than shipping an empty enum: an empty | |
| 124 | + // list reads as "no valid values" and blocks every filter. | |
| 125 | + continue; | |
| 126 | + } | |
| 127 | + if (!$values) { | |
| 128 | + continue; | |
| 129 | + } | |
| 130 | + // 'none' is an MCP-only reported value (empty stored shipping status) | |
| 131 | + // that Status has no constant for — re-add it after the live overlay. | |
| 132 | + if ($key === 'shipping_statuses') { | |
| 133 | + array_unshift($values, 'none'); | |
| 134 | + } | |
| 135 | + // Statuses the column holds that the helper does not list. Unioned, not | |
| 136 | + // overwritten: getOrderStatuses() is the settable list, so overwriting | |
| 137 | + // would drop 'pending'/'draft'/'refunded' and make those real rows | |
| 138 | + // unfilterable. See PERSISTED_ONLY_ORDER_STATUSES. | |
| 139 | + if ($key === 'order_statuses') { | |
| 140 | + $values = array_merge($values, self::PERSISTED_ONLY_ORDER_STATUSES); | |
| 141 | + } | |
| 142 | + $enums[$key] = array_values(array_unique($values)); | |
| 143 | + } | |
| 144 | + | |
| 145 | + return $enums; | |
| 146 | + } | |
| 147 | + | |
| 56 | 148 | // Payment statuses that count as realized revenue. Centralized so every |
| 57 | 149 | // tool (context, reports, aggregates) agrees on what "paid" means. |
| 58 | 150 | const PAID_STATUSES = ['paid', 'partially_paid', 'partially_refunded']; |
| 59 | 151 | |
| @@ -79,9 +171,9 @@ | ||
| 79 | 171 | ], |
| 80 | 172 | |
| 81 | 173 | 'fluent-cart/list-reference-data' => [ |
| 82 | 174 | 'label' => __('List Reference Data', 'fluent-cart'), |
| 83 | - 'description' => __('On-demand lookup lists kept out of get-store-context to keep it lean: coupons, labels, gateways, tax_classes, shipping_zones, product_categories. Pass kinds[] with only what you need. Kinds your role cannot see are reported in meta.warnings, not dropped silently.', 'fluent-cart'), | |
| 175 | + 'description' => __('On-demand lookup lists kept out of get-store-context to keep it lean: coupons, labels, gateways, tax_classes, shipping_zones, product_categories. Pass kinds[] with only what you need. Kinds your role cannot see are reported in meta.warnings, not dropped silently. The coupons kind is a capped snapshot (newest 200, each with times_used) — to filter by status/code, paginate, or find usable-now coupons, use list-coupons instead.', 'fluent-cart'), | |
| 84 | 176 | 'input_schema' => [ |
| 85 | 177 | 'type' => 'object', |
| 86 | 178 | 'properties' => [ |
| 87 | 179 | 'kinds' => [ |
| @@ -133,12 +225,19 @@ | ||
| 133 | 225 | $store = [ |
| 134 | 226 | 'name' => get_bloginfo('name'), |
| 135 | 227 | 'url' => site_url(), |
| 136 | 228 | 'version' => defined('FLUENTCART_VERSION') ? FLUENTCART_VERSION : null, |
| 137 | - 'pro_active' => defined('FLUENT_CART_PRO') || defined('FLUENTCART_PRO_VERSION'), | |
| 229 | + // Must agree with the App::isProActive() check the Pro-gated paths | |
| 230 | + // (advanced_filters, get-search-schema) actually run — a false here on | |
| 231 | + // a Pro store makes an agent skip the whole advanced-search surface. | |
| 232 | + 'pro_active' => App::isProActive(), | |
| 138 | 233 | 'currency' => MCPHelper::currencyContext(), |
| 139 | 234 | 'timezone' => wp_timezone_string(), |
| 140 | 235 | 'current_time' => MCPHelper::toIso8601(DateTime::gmtNow()), |
| 236 | + // Named for the capability rather than the licence, so an agent does | |
| 237 | + // not have to infer what pro_active buys it before spending a call on | |
| 238 | + // get-search-schema (which rejects outright without Pro). | |
| 239 | + 'advanced_search' => App::isProActive() ? 'available' : 'unavailable', | |
| 141 | 240 | ]; |
| 142 | 241 | |
| 143 | 242 | // Headline stats are dashboard data: gate them on dashboard_stats/view so |
| 144 | 243 | // a narrow read role can still get context (enums, currency, permissions) |
| @@ -151,10 +250,11 @@ | ||
| 151 | 250 | [ |
| 152 | 251 | 'you' => $you, |
| 153 | 252 | 'store' => $store, |
| 154 | 253 | 'stats' => $stats, |
| 155 | - 'enums' => apply_filters('fluent_cart/mcp_enums', self::ENUMS), | |
| 254 | + 'enums' => apply_filters('fluent_cart/mcp_enums', self::enums()), | |
| 156 | 255 | 'reference_kinds' => self::referenceKinds(), |
| 256 | + 'tool_index' => self::toolIndex(), | |
| 157 | 257 | 'guidelines' => self::guidelines(), |
| 158 | 258 | ] |
| 159 | 259 | ); |
| 160 | 260 | } |
| @@ -240,15 +340,88 @@ | ||
| 240 | 340 | { |
| 241 | 341 | return ['coupons', 'labels', 'gateways', 'tax_classes', 'shipping_zones', 'product_categories']; |
| 242 | 342 | } |
| 243 | 343 | |
| 344 | + /** | |
| 345 | + * Task → tool routing table so an agent picks the right ability among ~30 | |
| 346 | + * without trial and error, grouped by intent (discovery / find / load / | |
| 347 | + * analytics / write). | |
| 348 | + * | |
| 349 | + * Derived from the LIVE registry so a newly registered tool can never | |
| 350 | + * silently go missing — each is annotated with a curated "reach for this | |
| 351 | + * when…" hint, and any tool without one still appears under its label. | |
| 352 | + * Filterable so pro / add-on tools can slot themselves in. | |
| 353 | + */ | |
| 354 | + private static function toolIndex() | |
| 355 | + { | |
| 356 | + // [category, one-line "use this when…"], keyed by ability name. | |
| 357 | + $hints = [ | |
| 358 | + 'fluent-cart/get-store-context' => ['discovery', 'Call first — identity, permissions, currency, enums, headline stats, and this index.'], | |
| 359 | + 'fluent-cart/list-reference-data' => ['discovery', 'Resolve names to ids: coupons, labels, gateways, tax classes, shipping zones, product categories.'], | |
| 360 | + 'fluent-cart/get-search-schema' => ['discovery', 'The advanced_filters reference for one entity — every filterable property, operators, value formats. Call before building an advanced search.'], | |
| 361 | + 'fluent-cart/list-orders' => ['find', 'Find orders by status / payment / customer / product / date.'], | |
| 362 | + 'fluent-cart/list-customers' => ['find', 'Find customers by name / email / location / LTV.'], | |
| 363 | + 'fluent-cart/list-products' => ['find', 'Find products by title / category / price.'], | |
| 364 | + 'fluent-cart/list-subscriptions' => ['find', 'Find subscriptions by status / plan / product; summary_only for a fast aggregate.'], | |
| 365 | + 'fluent-cart/list-coupons' => ['find', 'Find coupons by status / code, with usage counts.'], | |
| 366 | + 'fluent-cart/list-transactions' => ['find', 'The payment ledger across records — refunds last week, failed charges for dunning, one customer\'s payment history.'], | |
| 367 | + 'fluent-cart/get-inventory' => ['find', 'Products at or below their stock threshold, or out of stock.'], | |
| 368 | + 'fluent-cart/get-order' => ['load', 'One order in full; include[] transactions / refunds / addresses / coupons / subscriptions.'], | |
| 369 | + 'fluent-cart/get-order-activity' => ['load', 'The audit timeline for one order.'], | |
| 370 | + 'fluent-cart/get-customer' => ['load', 'One customer profile; include[] orders / subscriptions.'], | |
| 371 | + 'fluent-cart/get-product' => ['load', 'One product with variations; include[] sales / downloads.'], | |
| 372 | + 'fluent-cart/get-subscription' => ['load', 'One subscription; include[] transactions / labels.'], | |
| 373 | + 'fluent-cart/get-product-financials' => ['load', 'One product\'s money: one-time + installment + recurring, MRR / ARR, payment schedule.'], | |
| 374 | + 'fluent-cart/get-sales-report' => ['analytics', 'The headline revenue number for a period, against the prior period.'], | |
| 375 | + 'fluent-cart/get-sales-trend' => ['analytics', 'Revenue / order time series by hour / day / week / month.'], | |
| 376 | + 'fluent-cart/get-top-products' => ['analytics', 'Best sellers by revenue or units.'], | |
| 377 | + 'fluent-cart/get-refund-report' => ['analytics', 'Refund count, rate and amount for a period.'], | |
| 378 | + 'fluent-cart/get-upcoming-payments' => ['analytics', 'Forward renewal cohort and at-risk revenue.'], | |
| 379 | + 'fluent-cart/query-orders' => ['analytics', 'Flexible order metrics by dimension — revenue by payment_status / order_type / month.'], | |
| 380 | + 'fluent-cart/query-products' => ['analytics', 'Product-line analytics — discount / margin leakage, by product / variation / order_type.'], | |
| 381 | + 'fluent-cart/query-customers' => ['analytics', 'Customer analytics by country / state / status / cohort.'], | |
| 382 | + 'fluent-cart/query-subscriptions' => ['analytics', 'Subscription analytics — contract vs recurring value, churn basis.'], | |
| 383 | + 'fluent-cart/query-sources' => ['analytics', 'UTM attribution — revenue by source / medium / campaign.'], | |
| 384 | + 'fluent-cart/change-order-status' => ['write', 'Set an order or shipping status.'], | |
| 385 | + 'fluent-cart/add-order-note' => ['write', 'Add an internal note to an order.'], | |
| 386 | + 'fluent-cart/refund-order' => ['write', 'Refund via the gateway — call dry_run first.'], | |
| 387 | + 'fluent-cart/upsert-customer' => ['write', 'Create or update a customer.'], | |
| 388 | + 'fluent-cart/change-subscription-status' => ['write', 'Cancel a subscription — call dry_run first.'], | |
| 389 | + 'fluent-cart/manage-coupon' => ['write', 'Create, update or deactivate a coupon.'], | |
| 390 | + 'fluent-cart/apply-labels' => ['write', 'Add or remove labels on an order / customer / subscription.'], | |
| 391 | + ]; | |
| 392 | + | |
| 393 | + // Preserve intent order; empty groups are dropped below. | |
| 394 | + $index = ['discovery' => [], 'find' => [], 'load' => [], 'analytics' => [], 'write' => [], 'other' => []]; | |
| 395 | + | |
| 396 | + foreach (AbilitiesRegistrar::getDefinitions() as $name => $def) { | |
| 397 | + $category = isset($hints[$name]) ? $hints[$name][0] : 'other'; | |
| 398 | + $hint = isset($hints[$name]) ? $hints[$name][1] : (isset($def['label']) ? $def['label'] : $name); | |
| 399 | + $short = strpos($name, 'fluent-cart/') === 0 ? substr($name, strlen('fluent-cart/')) : $name; | |
| 400 | + | |
| 401 | + $index[$category][$short] = $hint; | |
| 402 | + } | |
| 403 | + | |
| 404 | + $index = array_filter($index, function ($group) { | |
| 405 | + return !empty($group); | |
| 406 | + }); | |
| 407 | + | |
| 408 | + return apply_filters('fluent_cart/mcp_tool_index', $index); | |
| 409 | + } | |
| 410 | + | |
| 244 | 411 | private static function guidelines() |
| 245 | 412 | { |
| 246 | - $default = 'Call get-store-context once per session, then use search-* tools to find records and get-* tools to load one record fully. ' | |
| 413 | + $default = 'Call get-store-context once per session. Consult the tool_index in this payload to pick the right tool for a task, then use list-* and query-* tools to find and aggregate records and get-* tools to load one record fully. ' | |
| 247 | 414 | . 'Money is returned as both a number (amount) and a formatted string (display) — quote display, compare amount. ' |
| 248 | 415 | . 'Dates are ISO-8601 UTC; pass a relative range (e.g. last_30_days) or explicit start_date/end_date to report tools. ' |
| 249 | 416 | . 'Use the exact enum values from this payload — never invent a status. ' |
| 250 | 417 | . 'Reports never sum across currencies; filter by one currency if the store has several. ' |
| 418 | + // Stated as a fact about THIS store, not a generic "requires Pro": | |
| 419 | + // an agent that reads the generic form still builds the filter and | |
| 420 | + // only discovers the gate when the call is rejected. | |
| 421 | + . (App::isProActive() | |
| 422 | + ? 'When a list tool\'s named filters cannot express a segmentation (OR groups, relative dates, per-property operators, relation properties like transactions/UTM/labels), call get-search-schema for the entity and pass advanced_filters to its list tool. ' | |
| 423 | + : 'Advanced search is UNAVAILABLE on this store (store.advanced_search = unavailable): FluentCart Pro is not active, so get-search-schema and the advanced_filters parameter will be rejected. Do not build advanced_filters — use the named filters on the list-* tools and the query-* tools for aggregation. ') | |
| 251 | 424 | . 'Writes (refund-order, change-subscription-status:cancel) require a dry_run preview first.'; |
| 252 | 425 | |
| 253 | 426 | return apply_filters('fluent_cart/mcp_guidelines', $default); |
| 254 | 427 | } |
| @@ -326,9 +499,9 @@ | ||
| 326 | 499 | { |
| 327 | 500 | try { |
| 328 | 501 | if ($kind === 'coupons' && class_exists('\FluentCart\App\Models\Coupon')) { |
| 329 | 502 | $coupons = \FluentCart\App\Models\Coupon::query() |
| 330 | - ->select(['id', 'code', 'title', 'type', 'amount', 'status']) | |
| 503 | + ->select(['id', 'code', 'title', 'type', 'amount', 'status', 'use_count']) | |
| 331 | 504 | ->orderBy('id', 'DESC') |
| 332 | 505 | ->limit(200) |
| 333 | 506 | ->get(); |
| 334 | 507 | $out = []; |
| @@ -338,14 +511,19 @@ | ||
| 338 | 511 | $amount = ($c->type === 'fixed') |
| 339 | 512 | ? 0 + \FluentCart\App\Helpers\Helper::toDecimalWithoutComma((int) $c->amount) |
| 340 | 513 | : (is_numeric($c->amount) ? 0 + $c->amount : $c->amount); |
| 341 | 514 | $out[] = [ |
| 342 | - 'id' => (int) $c->id, | |
| 343 | - 'code' => $c->code, | |
| 344 | - 'title' => $c->title, | |
| 345 | - 'type' => $c->type, | |
| 346 | - 'amount' => $amount, | |
| 347 | - 'status' => $c->status, | |
| 515 | + 'id' => (int) $c->id, | |
| 516 | + 'code' => $c->code, | |
| 517 | + 'title' => $c->title, | |
| 518 | + 'type' => $c->type, | |
| 519 | + 'amount' => $amount, | |
| 520 | + 'status' => $c->status, | |
| 521 | + // Usage count so "how many times was code X used" is | |
| 522 | + // answerable without a second call. Alias times_used matches | |
| 523 | + // list-coupons. | |
| 524 | + 'use_count' => (int) $c->use_count, | |
| 525 | + 'times_used' => (int) $c->use_count, | |
| 348 | 526 | ]; |
| 349 | 527 | } |
| 350 | 528 | return $out; |
| 351 | 529 | } |