PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / app / Modules / MCP / Support / MCPHelper.php

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

468 lines 18.1 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\Support;
4
5 use FluentCart\App\Helpers\Helper;
6 use FluentCart\App\Services\DateTime\DateTime;
7 use FluentCart\Api\CurrencySettings;
8
9 /**
10 * Shared formatting + validation utilities for the FluentCart MCP module.
11 *
12 * Mirrors FluentCRM's MCPHelper role: every tool funnels its output through
13 * here so responses are uniform, token-lean, and safe for an AI agent to
14 * reason over. Three rules this file enforces everywhere:
15 *
16 * 1. Money leaves the boundary exactly once, never as raw cents. Detail
17 * views get {amount, amount_cents, currency, display}; list rows get a
18 * compact decimal + a shared meta.currency (see money() vs moneyCompact()).
19 * 2. Dates are ISO-8601 UTC strings — never the raw DB datetime, never a
20 * timezone-ambiguous value.
21 * 3. Every successful tool returns the same envelope: a one-line `summary`
22 * the agent can quote, the `data`, and `meta` (schema_version, paging,
23 * currency, warnings, truncation). Errors return WP_Error so the adapter
24 * surfaces them as isError results the agent can self-correct against.
25 */
26 class MCPHelper
27 {
28 const SCHEMA_VERSION = '1.0';
29
30 // Default per-tool ceiling. Individual tools may opt up to HARD_MAX_PER_PAGE
31 // when their rows are compact (see pagination()'s $maxPerPage).
32 const MAX_PER_PAGE = 100;
33
34 // Absolute ceiling no tool can exceed, however large a per_page it requests.
35 const HARD_MAX_PER_PAGE = 200;
36
37 const PREVIEW_CHARS = 150;
38
39 /**
40 * The canonical success envelope. Returning an array (not echoing) lets the
41 * MCP Adapter serialize it into structuredContent + a text digest.
42 *
43 * @param string $summary One human-readable line. The agent quotes this; it
44 * should answer the question, not restate the schema.
45 * @param mixed $data The payload.
46 * @param array $meta Merged into the meta block (paging, range, etc.).
47 */
48 public static function envelope($summary, $data, array $meta = [])
49 {
50 $base = [
51 'schema_version' => self::SCHEMA_VERSION,
52 'generated_at' => self::toIso8601(DateTime::gmtNow()),
53 'currency' => self::currencyCode(),
54 ];
55
56 return [
57 'summary' => $summary,
58 'data' => $data,
59 'meta' => array_merge($base, $meta),
60 ];
61 }
62
63 /**
64 * Structured, self-correcting error. `code` is a stable machine string the
65 * agent can branch on; `message` says what went wrong + what was expected;
66 * `details` can carry hint / required_permission / current_state / next_tool.
67 */
68 public static function error($code, $message, array $details = [])
69 {
70 // The MCP adapter forwards only the WP_Error *message* to the agent — it
71 // drops error_data. So we encode a structured envelope INTO the message
72 // as JSON, mirroring our success payloads, so the agent can branch on a
73 // stable `code`, see which `fields` were at fault, read a `hint`/
74 // `next_step`, and know whether retrying the identical call could succeed
75 // (`retryable`, default false). Humans read error.message.
76 $error = array_merge([
77 'code' => $code,
78 'message' => $message,
79 'retryable' => false,
80 ], $details);
81
82 $json = wp_json_encode(['error' => $error]);
83
84 return new \WP_Error($code, $json !== false ? $json : $message, $details);
85 }
86
87 // -----------------------------------------------------------------
88 // Output schema (advertised to clients; not validated server-side)
89 // -----------------------------------------------------------------
90
91 /**
92 * JSON Schema fragment for the full money object returned by money(). Inlined
93 * by each tool's output_schema (rather than a $ref) so it never depends on the
94 * client validator resolving $defs across JSON-Schema draft versions.
95 *
96 * @return array
97 */
98 public static function moneyDef()
99 {
100 // Field docs live in the object description (once), not per-property: this
101 // object is inlined many times across an output_schema (10x on the sales
102 // report alone), so per-field descriptions multiply into real token cost
103 // for names that are already self-explanatory.
104 return [
105 'type' => 'object',
106 'description' => 'Money: amount (decimal), amount_cents (integer, smallest unit), currency (ISO 4217), display (formatted string, e.g. "$19.99").',
107 'properties' => [
108 'amount' => ['type' => 'number'],
109 'amount_cents' => ['type' => 'integer'],
110 'currency' => ['type' => 'string'],
111 'display' => ['type' => 'string'],
112 ],
113 ];
114 }
115
116 /**
117 * JSON Schema for the shared meta block. Permissive (extra keys allowed) so a
118 * tool can add its own meta (mode, date_basis, page, warnings, …) without a
119 * client that validates structuredContent tripping on the extras.
120 *
121 * @param array $extraProps additional documented meta properties for this tool
122 * @return array
123 */
124 public static function metaSchema(array $extraProps = [])
125 {
126 return [
127 'type' => 'object',
128 'description' => 'Envelope metadata: schema version, currency, plus per-tool keys (date_basis, mode, page, warnings).',
129 'properties' => array_merge([
130 'schema_version' => ['type' => 'string'],
131 'generated_at' => ['type' => 'string', 'description' => 'ISO-8601 UTC timestamp.'],
132 'currency' => ['type' => 'string', 'description' => 'ISO 4217 store currency for reference.'],
133 'warnings' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Non-fatal notes, e.g. an ignored parameter.'],
134 ], $extraProps),
135 ];
136 }
137
138 /**
139 * Wrap a `data` schema in the canonical { summary, data, meta } envelope every
140 * tool returns.
141 *
142 * `data` keys are DESCRIBED but not required: the fields[] projection may prune
143 * them and summary_only omits records, so a client should treat declared data
144 * keys as optional. The adapter advertises this to the model but does not
145 * validate results against it, so it is documentation, not a runtime gate.
146 *
147 * @param array $dataSchema JSON Schema for the tool's data payload
148 * @param array $metaProps extra documented meta properties
149 * @return array
150 */
151 public static function envelopeSchema(array $dataSchema, array $metaProps = [])
152 {
153 return [
154 'type' => 'object',
155 'properties' => [
156 'summary' => ['type' => 'string', 'description' => 'One-line, human-readable answer — quotable verbatim.'],
157 'data' => $dataSchema,
158 'meta' => self::metaSchema($metaProps),
159 ],
160 'required' => ['summary', 'data', 'meta'],
161 ];
162 }
163
164 // -----------------------------------------------------------------
165 // Money
166 // -----------------------------------------------------------------
167
168 /**
169 * Full money object for detail views. The agent never does cents math and
170 * never mis-renders: `amount` is the decimal number for comparisons,
171 * `display` is the ready-to-quote string.
172 *
173 * @param int|null $cents
174 * @param string|null $currencyCode Falls back to the store currency.
175 */
176 public static function money($cents, $currencyCode = null)
177 {
178 $cents = (int) $cents;
179 // Normalize to uppercase ISO-4217 — gateway values can arrive lowercase
180 // (Stripe stores "usd"); the agent should always see "USD".
181 $code = strtoupper($currencyCode ? $currencyCode : self::currencyCode());
182
183 return [
184 'amount' => Helper::toDecimalWithoutComma($cents),
185 'amount_cents' => $cents,
186 'currency' => $code,
187 'display' => self::displayAmount($cents, $code),
188 ];
189 }
190
191 /**
192 * Formatted, agent-readable money string. Helper::toDecimal HTML-encodes the
193 * currency sign (e.g. "&#36;19.99"); we decode it so the agent sees "$19.99".
194 */
195 public static function displayAmount($cents, $currencyCode = null)
196 {
197 $code = $currencyCode ? $currencyCode : self::currencyCode();
198
199 return html_entity_decode(Helper::toDecimal((int) $cents, true, $code), ENT_QUOTES, 'UTF-8');
200 }
201
202 /**
203 * Compact money for list rows: just the decimal number. The currency lives
204 * once in meta.currency, so we don't repeat it on every row (token saving).
205 * Only fall back to the full object when a result set spans currencies.
206 */
207 public static function moneyCompact($cents)
208 {
209 return Helper::toDecimalWithoutComma((int) $cents);
210 }
211
212 public static function currencyCode()
213 {
214 $code = CurrencySettings::get('currency');
215
216 return $code ? $code : 'USD';
217 }
218
219 /**
220 * Full currency descriptor for get-store-context, so the agent can format
221 * money itself when it wants to (zero-decimal currencies, separators, etc.).
222 */
223 public static function currencyContext()
224 {
225 $settings = CurrencySettings::get();
226 if (!is_array($settings)) {
227 $settings = [];
228 }
229
230 $code = strtoupper(isset($settings['currency']) ? $settings['currency'] : 'USD');
231
232 // Derive decimals exactly how Helper::toDecimal does: 2 places, or 0 for
233 // a zero-decimal currency. Reading a stored decimal_points key drifted
234 // from the actual money formatting (reported 0 for USD while amounts
235 // rendered with 2 places).
236 $isZeroDecimal = (bool) Helper::shopConfig('is_zero_decimal');
237
238 return [
239 'code' => $code,
240 'sign' => isset($settings['currency_sign']) ? $settings['currency_sign'] : '$',
241 'position' => isset($settings['currency_position']) ? $settings['currency_position'] : 'before',
242 'decimal_points' => $isZeroDecimal ? 0 : 2,
243 'is_zero_decimal' => $isZeroDecimal,
244 'example' => Helper::toDecimal(123456, true, $code),
245 ];
246 }
247
248 // -----------------------------------------------------------------
249 // Dates
250 // -----------------------------------------------------------------
251
252 /**
253 * Normalize any stored datetime to an ISO-8601 UTC string. Accepts a
254 * DateTime, a {date,timezone} object, or a Y-m-d H:i:s string (DB values
255 * are GMT). Returns null for empty input so the key stays present.
256 */
257 public static function toIso8601($value)
258 {
259 if (!$value) {
260 return null;
261 }
262
263 if ($value instanceof \DateTimeInterface) {
264 return $value->format('c');
265 }
266
267 if (is_object($value) && isset($value->date)) {
268 $tz = isset($value->timezone) ? $value->timezone : 'UTC';
269 return (new \DateTime($value->date, new \DateTimeZone($tz)))->format('c');
270 }
271
272 if (is_string($value)) {
273 // MySQL zero-dates ('0000-00-00 00:00:00') are truthy strings but not
274 // real dates; DateTime underflows them to year -0001 and emits a
275 // misleading '-001-11-30...'. Treat them as empty.
276 if (strpos($value, '0000-00-00') === 0) {
277 return null;
278 }
279 try {
280 $dt = new \DateTime($value, new \DateTimeZone('UTC'));
281 // Guard any other underflow to a non-positive year.
282 if ((int) $dt->format('Y') < 1) {
283 return null;
284 }
285 return $dt->format('c');
286 } catch (\Exception $e) {
287 return null;
288 }
289 }
290
291 return null;
292 }
293
294 // -----------------------------------------------------------------
295 // Text
296 // -----------------------------------------------------------------
297
298 /** Strip HTML/markup to clean plain text — agents reason better over text than markup. */
299 public static function htmlToText($html)
300 {
301 if (!$html) {
302 return '';
303 }
304
305 $text = wp_strip_all_tags((string) $html);
306 $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
307 $text = preg_replace('/\s+/', ' ', $text);
308
309 return trim($text);
310 }
311
312 /** Truncated preview for list rows so descriptions/notes don't blow context. */
313 public static function preview($html, $chars = self::PREVIEW_CHARS)
314 {
315 $text = self::htmlToText($html);
316 if (mb_strlen($text) > $chars) {
317 return mb_substr($text, 0, $chars) . '';
318 }
319
320 return $text;
321 }
322
323 // -----------------------------------------------------------------
324 // Pagination
325 // -----------------------------------------------------------------
326
327 /**
328 * Clamp page/per_page from agent input. Defaults small (15) and caps at 100
329 * so a careless `per_page: 5000` can never flood the context window.
330 *
331 * $maxPerPage lets a specific tool raise its own ceiling above the shared
332 * default (e.g. compact subscription rows tolerate 200) without lifting the
333 * cap for every other list tool. It is itself clamped to MAX_PER_PAGE so a
334 * caller can never push past the global guardrail.
335 *
336 * @return array{page:int, per_page:int}
337 */
338 public static function pagination($params, $defaultPerPage = 15, $maxPerPage = self::MAX_PER_PAGE)
339 {
340 $page = isset($params['page']) ? (int) $params['page'] : 1;
341 $perPage = isset($params['per_page']) ? (int) $params['per_page'] : $defaultPerPage;
342
343 $max = ($maxPerPage > self::HARD_MAX_PER_PAGE) ? self::HARD_MAX_PER_PAGE : (int) $maxPerPage;
344
345 if ($page < 1) {
346 $page = 1;
347 }
348 if ($perPage < 1) {
349 $perPage = $defaultPerPage;
350 }
351 if ($perPage > $max) {
352 $perPage = $max;
353 }
354
355 return ['page' => $page, 'per_page' => $perPage];
356 }
357
358 /**
359 * Build the meta.page block from a FluentCart Paginator (which exposes
360 * current_page / per_page / total / last_page). Gives the agent everything
361 * it needs to decide whether to fetch the next page.
362 */
363 public static function pagingMeta($paginator)
364 {
365 if (is_object($paginator) && method_exists($paginator, 'total')) {
366 $current = method_exists($paginator, 'currentPage') ? (int) $paginator->currentPage() : 1;
367 $perPage = method_exists($paginator, 'perPage') ? (int) $paginator->perPage() : 0;
368 $total = (int) $paginator->total();
369 $last = method_exists($paginator, 'lastPage') ? (int) $paginator->lastPage() : 1;
370 } else {
371 $arr = is_array($paginator) ? $paginator : (array) $paginator;
372 $current = isset($arr['current_page']) ? (int) $arr['current_page'] : 1;
373 $perPage = isset($arr['per_page']) ? (int) $arr['per_page'] : 0;
374 $total = isset($arr['total']) ? (int) $arr['total'] : 0;
375 $last = isset($arr['last_page']) ? (int) $arr['last_page'] : 1;
376 }
377
378 return [
379 'page' => [
380 'current' => $current,
381 'per_page' => $perPage,
382 'total' => $total,
383 'pages' => $last,
384 'has_more' => $current < $last,
385 ],
386 ];
387 }
388
389 /** Total row count from a Paginator (uses ->total() method; array fallback). */
390 public static function paginatorTotal($paginator)
391 {
392 if (is_object($paginator) && method_exists($paginator, 'total')) {
393 return (int) $paginator->total();
394 }
395 $arr = is_array($paginator) ? $paginator : (array) $paginator;
396 return isset($arr['total']) ? (int) $arr['total'] : 0;
397 }
398
399 /** Pull the row models out of a Paginator regardless of its concrete shape. */
400 public static function paginatorItems($paginator)
401 {
402 if (is_object($paginator) && method_exists($paginator, 'items')) {
403 return $paginator->items();
404 }
405
406 $arr = is_array($paginator) ? $paginator : (array) $paginator;
407
408 return isset($arr['data']) ? $arr['data'] : [];
409 }
410
411 // -----------------------------------------------------------------
412 // People / labels
413 // -----------------------------------------------------------------
414
415 // -----------------------------------------------------------------
416 // Field selection
417 // -----------------------------------------------------------------
418
419 /**
420 * Project a record down to a caller-requested subset of top-level keys, to
421 * shrink heavy payloads. Returns the record UNCHANGED when $fields is empty
422 * or not an array (the default, backward-compatible behavior). Only keys that
423 * actually exist are kept, in the record's own order; unknown requested keys
424 * are ignored. Keys in $alwaysKeep (the record's identifier) are retained
425 * regardless so a projected record is never anonymous.
426 *
427 * @param array $row
428 * @param mixed $fields array of key names, or null/non-array for "all"
429 * @param array $alwaysKeep keys to keep even if not requested (e.g. the id)
430 */
431 public static function pickFields($row, $fields, array $alwaysKeep = [])
432 {
433 if (empty($fields) || !is_array($fields)) {
434 return $row;
435 }
436
437 $wanted = [];
438 foreach ($alwaysKeep as $k) {
439 $wanted[$k] = true;
440 }
441 foreach ($fields as $f) {
442 $wanted[(string) $f] = true;
443 }
444
445 $out = [];
446 foreach ($row as $key => $val) {
447 if (isset($wanted[$key])) {
448 $out[$key] = $val;
449 }
450 }
451 return $out;
452 }
453
454 /** "First Last <email>" style name from a customer/person-ish model. */
455 public static function personName($model)
456 {
457 if (!$model) {
458 return null;
459 }
460
461 $first = isset($model->first_name) ? $model->first_name : '';
462 $last = isset($model->last_name) ? $model->last_name : '';
463 $name = trim($first . ' ' . $last);
464
465 return $name !== '' ? $name : null;
466 }
467 }
468