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
← All changes | app/Modules/MCP/Tools/ContextTools.php +198 -20 1.5.1 → 1.6.5 View file →
@@ -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 }