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

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

352 lines 13.2 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 // Money
89 // -----------------------------------------------------------------
90
91 /**
92 * Full money object for detail views. The agent never does cents math and
93 * never mis-renders: `amount` is the decimal number for comparisons,
94 * `display` is the ready-to-quote string.
95 *
96 * @param int|null $cents
97 * @param string|null $currencyCode Falls back to the store currency.
98 */
99 public static function money($cents, $currencyCode = null)
100 {
101 $cents = (int) $cents;
102 // Normalize to uppercase ISO-4217 — gateway values can arrive lowercase
103 // (Stripe stores "usd"); the agent should always see "USD".
104 $code = strtoupper($currencyCode ? $currencyCode : self::currencyCode());
105
106 return [
107 'amount' => Helper::toDecimalWithoutComma($cents),
108 'amount_cents' => $cents,
109 'currency' => $code,
110 'display' => self::displayAmount($cents, $code),
111 ];
112 }
113
114 /**
115 * Formatted, agent-readable money string. Helper::toDecimal HTML-encodes the
116 * currency sign (e.g. "&#36;19.99"); we decode it so the agent sees "$19.99".
117 */
118 public static function displayAmount($cents, $currencyCode = null)
119 {
120 $code = $currencyCode ? $currencyCode : self::currencyCode();
121
122 return html_entity_decode(Helper::toDecimal((int) $cents, true, $code), ENT_QUOTES, 'UTF-8');
123 }
124
125 /**
126 * Compact money for list rows: just the decimal number. The currency lives
127 * once in meta.currency, so we don't repeat it on every row (token saving).
128 * Only fall back to the full object when a result set spans currencies.
129 */
130 public static function moneyCompact($cents)
131 {
132 return Helper::toDecimalWithoutComma((int) $cents);
133 }
134
135 public static function currencyCode()
136 {
137 $code = CurrencySettings::get('currency');
138
139 return $code ? $code : 'USD';
140 }
141
142 /**
143 * Full currency descriptor for get-store-context, so the agent can format
144 * money itself when it wants to (zero-decimal currencies, separators, etc.).
145 */
146 public static function currencyContext()
147 {
148 $settings = CurrencySettings::get();
149 if (!is_array($settings)) {
150 $settings = [];
151 }
152
153 $code = strtoupper(isset($settings['currency']) ? $settings['currency'] : 'USD');
154
155 // Derive decimals exactly how Helper::toDecimal does: 2 places, or 0 for
156 // a zero-decimal currency. Reading a stored decimal_points key drifted
157 // from the actual money formatting (reported 0 for USD while amounts
158 // rendered with 2 places).
159 $isZeroDecimal = (bool) Helper::shopConfig('is_zero_decimal');
160
161 return [
162 'code' => $code,
163 'sign' => isset($settings['currency_sign']) ? $settings['currency_sign'] : '$',
164 'position' => isset($settings['currency_position']) ? $settings['currency_position'] : 'before',
165 'decimal_points' => $isZeroDecimal ? 0 : 2,
166 'is_zero_decimal' => $isZeroDecimal,
167 'example' => Helper::toDecimal(123456, true, $code),
168 ];
169 }
170
171 // -----------------------------------------------------------------
172 // Dates
173 // -----------------------------------------------------------------
174
175 /**
176 * Normalize any stored datetime to an ISO-8601 UTC string. Accepts a
177 * DateTime, a {date,timezone} object, or a Y-m-d H:i:s string (DB values
178 * are GMT). Returns null for empty input so the key stays present.
179 */
180 public static function toIso8601($value)
181 {
182 if (!$value) {
183 return null;
184 }
185
186 if ($value instanceof \DateTimeInterface) {
187 return $value->format('c');
188 }
189
190 if (is_object($value) && isset($value->date)) {
191 $tz = isset($value->timezone) ? $value->timezone : 'UTC';
192 return (new \DateTime($value->date, new \DateTimeZone($tz)))->format('c');
193 }
194
195 if (is_string($value)) {
196 // MySQL zero-dates ('0000-00-00 00:00:00') are truthy strings but not
197 // real dates; DateTime underflows them to year -0001 and emits a
198 // misleading '-001-11-30...'. Treat them as empty.
199 if (strpos($value, '0000-00-00') === 0) {
200 return null;
201 }
202 try {
203 $dt = new \DateTime($value, new \DateTimeZone('UTC'));
204 // Guard any other underflow to a non-positive year.
205 if ((int) $dt->format('Y') < 1) {
206 return null;
207 }
208 return $dt->format('c');
209 } catch (\Exception $e) {
210 return null;
211 }
212 }
213
214 return null;
215 }
216
217 // -----------------------------------------------------------------
218 // Text
219 // -----------------------------------------------------------------
220
221 /** Strip HTML/markup to clean plain text — agents reason better over text than markup. */
222 public static function htmlToText($html)
223 {
224 if (!$html) {
225 return '';
226 }
227
228 $text = wp_strip_all_tags((string) $html);
229 $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
230 $text = preg_replace('/\s+/', ' ', $text);
231
232 return trim($text);
233 }
234
235 /** Truncated preview for list rows so descriptions/notes don't blow context. */
236 public static function preview($html, $chars = self::PREVIEW_CHARS)
237 {
238 $text = self::htmlToText($html);
239 if (mb_strlen($text) > $chars) {
240 return mb_substr($text, 0, $chars) . '…';
241 }
242
243 return $text;
244 }
245
246 // -----------------------------------------------------------------
247 // Pagination
248 // -----------------------------------------------------------------
249
250 /**
251 * Clamp page/per_page from agent input. Defaults small (15) and caps at 100
252 * so a careless `per_page: 5000` can never flood the context window.
253 *
254 * $maxPerPage lets a specific tool raise its own ceiling above the shared
255 * default (e.g. compact subscription rows tolerate 200) without lifting the
256 * cap for every other list tool. It is itself clamped to MAX_PER_PAGE so a
257 * caller can never push past the global guardrail.
258 *
259 * @return array{page:int, per_page:int}
260 */
261 public static function pagination($params, $defaultPerPage = 15, $maxPerPage = self::MAX_PER_PAGE)
262 {
263 $page = isset($params['page']) ? (int) $params['page'] : 1;
264 $perPage = isset($params['per_page']) ? (int) $params['per_page'] : $defaultPerPage;
265
266 $max = ($maxPerPage > self::HARD_MAX_PER_PAGE) ? self::HARD_MAX_PER_PAGE : (int) $maxPerPage;
267
268 if ($page < 1) {
269 $page = 1;
270 }
271 if ($perPage < 1) {
272 $perPage = $defaultPerPage;
273 }
274 if ($perPage > $max) {
275 $perPage = $max;
276 }
277
278 return ['page' => $page, 'per_page' => $perPage];
279 }
280
281 /**
282 * Build the meta.page block from a FluentCart Paginator (which exposes
283 * current_page / per_page / total / last_page). Gives the agent everything
284 * it needs to decide whether to fetch the next page.
285 */
286 public static function pagingMeta($paginator)
287 {
288 if (is_object($paginator) && method_exists($paginator, 'total')) {
289 $current = method_exists($paginator, 'currentPage') ? (int) $paginator->currentPage() : 1;
290 $perPage = method_exists($paginator, 'perPage') ? (int) $paginator->perPage() : 0;
291 $total = (int) $paginator->total();
292 $last = method_exists($paginator, 'lastPage') ? (int) $paginator->lastPage() : 1;
293 } else {
294 $arr = is_array($paginator) ? $paginator : (array) $paginator;
295 $current = isset($arr['current_page']) ? (int) $arr['current_page'] : 1;
296 $perPage = isset($arr['per_page']) ? (int) $arr['per_page'] : 0;
297 $total = isset($arr['total']) ? (int) $arr['total'] : 0;
298 $last = isset($arr['last_page']) ? (int) $arr['last_page'] : 1;
299 }
300
301 return [
302 'page' => [
303 'current' => $current,
304 'per_page' => $perPage,
305 'total' => $total,
306 'pages' => $last,
307 'has_more' => $current < $last,
308 ],
309 ];
310 }
311
312 /** Total row count from a Paginator (uses ->total() method; array fallback). */
313 public static function paginatorTotal($paginator)
314 {
315 if (is_object($paginator) && method_exists($paginator, 'total')) {
316 return (int) $paginator->total();
317 }
318 $arr = is_array($paginator) ? $paginator : (array) $paginator;
319 return isset($arr['total']) ? (int) $arr['total'] : 0;
320 }
321
322 /** Pull the row models out of a Paginator regardless of its concrete shape. */
323 public static function paginatorItems($paginator)
324 {
325 if (is_object($paginator) && method_exists($paginator, 'items')) {
326 return $paginator->items();
327 }
328
329 $arr = is_array($paginator) ? $paginator : (array) $paginator;
330
331 return isset($arr['data']) ? $arr['data'] : [];
332 }
333
334 // -----------------------------------------------------------------
335 // People / labels
336 // -----------------------------------------------------------------
337
338 /** "First Last <email>" style name from a customer/person-ish model. */
339 public static function personName($model)
340 {
341 if (!$model) {
342 return null;
343 }
344
345 $first = isset($model->first_name) ? $model->first_name : '';
346 $last = isset($model->last_name) ? $model->last_name : '';
347 $name = trim($first . ' ' . $last);
348
349 return $name !== '' ? $name : null;
350 }
351 }
352