@fluentcommerce/fluent-mcp-extn 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -988,7 +988,7 @@ With `outputDir`, each workflow is saved as `{TYPE}-{SUBTYPE}.json` (e.g., `ORDE
988
988
 
989
989
  | Tool | Description |
990
990
  |---|---|
991
- | `setting_get` | Fetch settings by name (`%` wildcards supported), optionally save to local file to keep large JSON out of LLM context |
991
+ | `setting_get` | Fetch settings by name (`%` wildcards supported). Auto-paginates wildcard queries via edge cursors (up to 500 records). Warns when results are truncated. Optionally save to local file to keep large JSON out of LLM context. |
992
992
  | `setting_upsert` | Create or update a setting with upsert semantics — queries existing by name + context + contextId first |
993
993
  | `setting_bulkUpsert` | Batch create/update up to 50 settings with per-setting error handling |
994
994
 
@@ -473,7 +473,15 @@ export async function handleEventList(args, ctx) {
473
473
  const analysis = analyzeEvents(events.results, events.hasMore ?? false);
474
474
  return { ok: true, analyze: true, ...analysis };
475
475
  }
476
- return { ok: true, events };
476
+ const hasMore = events.hasMore ?? false;
477
+ return {
478
+ ok: true,
479
+ events,
480
+ ...(hasMore ? {
481
+ _truncated: true,
482
+ _truncationWarning: `More events exist beyond this page. Use 'start' parameter to fetch the next page, or narrow filters (entityType, from/to, eventStatus) to reduce results.`,
483
+ } : {}),
484
+ };
477
485
  }
478
486
  /**
479
487
  * Handle event_flowInspect tool call.
@@ -493,6 +501,7 @@ export async function handleEventFlowInspect(args, ctx) {
493
501
  if (parsed.rootEntityId !== undefined) {
494
502
  baseParams["context.rootEntityId"] = parsed.rootEntityId;
495
503
  }
504
+ let _paginationTruncated = false;
496
505
  const fetchPaged = async (extraParams) => {
497
506
  const all = [];
498
507
  let page = 1;
@@ -505,8 +514,10 @@ export async function handleEventFlowInspect(args, ctx) {
505
514
  };
506
515
  const result = await client.getEvents(reqParams);
507
516
  const rows = Array.isArray(result?.results) ? result.results : [];
508
- if (rows.length === 0)
517
+ if (rows.length === 0) {
518
+ hasMore = false;
509
519
  break;
520
+ }
510
521
  for (const row of rows) {
511
522
  const rec = asRecord(row);
512
523
  if (rec)
@@ -515,6 +526,8 @@ export async function handleEventFlowInspect(args, ctx) {
515
526
  hasMore = result.hasMore ?? rows.length >= 500;
516
527
  page += 1;
517
528
  }
529
+ if (hasMore)
530
+ _paginationTruncated = true;
518
531
  return all;
519
532
  };
520
533
  const orchestrationEvents = await fetchPaged({
@@ -920,6 +933,10 @@ export async function handleEventFlowInspect(args, ctx) {
920
933
  window: { from: parsed.from ?? null, to: parsed.to ?? null },
921
934
  paging: { maxPages: parsed.maxPages, pageSize: 500 },
922
935
  compact: parsed.compact,
936
+ ...(_paginationTruncated ? {
937
+ _truncated: true,
938
+ _truncationWarning: `Results truncated at ${parsed.maxPages} pages (${parsed.maxPages * 500} events max). Increase maxPages to fetch more, or narrow the time window.`,
939
+ } : {}),
923
940
  };
924
941
  // ========= COMPACT MODE =========
925
942
  if (parsed.compact) {
@@ -147,6 +147,34 @@ function requireClient(ctx) {
147
147
  }
148
148
  return ctx.client;
149
149
  }
150
+ function analyzePaginationLimits(pagination, limits) {
151
+ if (!pagination)
152
+ return { truncated: false };
153
+ const totalPages = typeof pagination.totalPages === "number" ? pagination.totalPages : null;
154
+ const totalRecords = typeof pagination.totalRecords === "number" ? pagination.totalRecords : null;
155
+ const pagesReached = typeof pagination.pagesFetched === "number"
156
+ ? pagination.pagesFetched >= limits.maxPages
157
+ : totalPages !== null
158
+ ? totalPages >= limits.maxPages
159
+ : false;
160
+ const recordsReached = typeof pagination.recordsFetched === "number"
161
+ ? pagination.recordsFetched >= limits.maxRecords
162
+ : totalRecords !== null
163
+ ? totalRecords >= limits.maxRecords
164
+ : false;
165
+ if (!pagesReached && !recordsReached) {
166
+ return { truncated: false };
167
+ }
168
+ const reasons = [];
169
+ if (pagesReached)
170
+ reasons.push(`page cap ${limits.maxPages}`);
171
+ if (recordsReached)
172
+ reasons.push(`record cap ${limits.maxRecords}`);
173
+ return {
174
+ truncated: true,
175
+ warning: `Auto-pagination may be truncated by the ${reasons.join(" and ")}. Narrow the query or raise the limit if you need a complete result.`,
176
+ };
177
+ }
150
178
  // ---------------------------------------------------------------------------
151
179
  // Handlers
152
180
  // ---------------------------------------------------------------------------
@@ -190,13 +218,41 @@ export async function handleGraphQLQueryAll(args, ctx) {
190
218
  };
191
219
  const result = await client.graphqlPaginated(payload);
192
220
  const pagination = result.extensions?.autoPagination ?? null;
221
+ const paginationInfo = pagination && typeof pagination === "object"
222
+ ? pagination
223
+ : null;
224
+ const paginationAnalysis = analyzePaginationLimits(paginationInfo, {
225
+ maxPages: parsed.maxPages,
226
+ maxRecords: parsed.maxRecords,
227
+ });
193
228
  if (parsed.summarize) {
194
229
  const summary = summarizeConnection(result);
195
230
  if (summary) {
196
- return { ok: true, summarized: true, ...summary, pagination };
231
+ return {
232
+ ok: true,
233
+ summarized: true,
234
+ ...summary,
235
+ pagination,
236
+ ...(paginationAnalysis.truncated
237
+ ? {
238
+ _truncated: true,
239
+ _truncationWarning: paginationAnalysis.warning,
240
+ }
241
+ : {}),
242
+ };
197
243
  }
198
244
  }
199
- return { ok: true, response: result, pagination };
245
+ return {
246
+ ok: true,
247
+ response: result,
248
+ pagination,
249
+ ...(paginationAnalysis.truncated
250
+ ? {
251
+ _truncated: true,
252
+ _truncationWarning: paginationAnalysis.warning,
253
+ }
254
+ : {}),
255
+ };
200
256
  }
201
257
  /**
202
258
  * Handle graphql_batchMutate tool call.
@@ -10,6 +10,7 @@ import { GraphQLIntrospectionService, } from "@fluentcommerce/fc-connect-sdk";
10
10
  import { ToolError } from "./errors.js";
11
11
  import { classifyMutationOperations, listMutationCatalog, } from "./mutation-taxonomy.js";
12
12
  import { parseWindowMs, recommendStep } from "./time-window.js";
13
+ const MAX_METRICS_RANGE_POINTS = 5_000;
13
14
  // ---------------------------------------------------------------------------
14
15
  // Input schemas
15
16
  // ---------------------------------------------------------------------------
@@ -204,6 +205,13 @@ export const METRICS_TOOL_DEFINITIONS = [
204
205
  // ---------------------------------------------------------------------------
205
206
  // Math helpers
206
207
  // ---------------------------------------------------------------------------
208
+ function parseIsoTimestamp(value, field) {
209
+ const parsed = new Date(value);
210
+ if (Number.isNaN(parsed.getTime())) {
211
+ throw new ToolError("VALIDATION_ERROR", `metrics_query range ${field} must be a valid ISO-8601 timestamp.`);
212
+ }
213
+ return parsed;
214
+ }
207
215
  function round1(value) {
208
216
  return Math.round(value * 10) / 10;
209
217
  }
@@ -1022,6 +1030,7 @@ async function aggregateEventsFromApi(client, params) {
1022
1030
  return {
1023
1031
  totalEvents: allEvents.length,
1024
1032
  totalPages: page - 1,
1033
+ truncated: hasMore,
1025
1034
  statusBreakdown: Object.fromEntries(statusCounts),
1026
1035
  groups,
1027
1036
  uniqueNames: new Set(allEvents.map((e) => e.name)),
@@ -1064,6 +1073,28 @@ export async function handleMetricsQuery(args, ctx) {
1064
1073
  if (parsed.type === "range" && (!parsed.start || !parsed.end || !parsed.step)) {
1065
1074
  throw new ToolError("VALIDATION_ERROR", "Range queries require start, end, and step.");
1066
1075
  }
1076
+ if (parsed.type === "range") {
1077
+ const start = parseIsoTimestamp(parsed.start, "start");
1078
+ const end = parseIsoTimestamp(parsed.end, "end");
1079
+ const durationMs = end.getTime() - start.getTime();
1080
+ if (durationMs <= 0) {
1081
+ throw new ToolError("VALIDATION_ERROR", "metrics_query range end must be after start.");
1082
+ }
1083
+ const stepMs = parseWindowMs(parsed.step);
1084
+ const estimatedPoints = Math.ceil(durationMs / stepMs);
1085
+ if (estimatedPoints > MAX_METRICS_RANGE_POINTS) {
1086
+ const hint = recommendStep(durationMs, Math.min(500, MAX_METRICS_RANGE_POINTS));
1087
+ throw new ToolError("VALIDATION_ERROR", `Range query would request about ${estimatedPoints} points, which exceeds the safety limit of ${MAX_METRICS_RANGE_POINTS}. Increase step to at least ${hint.step}.`, {
1088
+ details: {
1089
+ estimatedPoints,
1090
+ maxPoints: MAX_METRICS_RANGE_POINTS,
1091
+ recommendedStep: hint.step,
1092
+ recommendedStepMs: hint.stepMs,
1093
+ recommendedEstimatedPoints: hint.estimatedPoints,
1094
+ },
1095
+ });
1096
+ }
1097
+ }
1067
1098
  const client = requireClient(ctx);
1068
1099
  const response = await client.queryPrometheus(parsed);
1069
1100
  const vectors = extractPrometheusVectors(response);
@@ -1092,6 +1123,7 @@ export async function handleMetricsHealthCheck(args, ctx) {
1092
1123
  let statusBreakdown = {};
1093
1124
  let totalEvents = 0;
1094
1125
  let topEvents = [];
1126
+ let eventApiTruncated = false;
1095
1127
  try {
1096
1128
  // Query 1: status breakdown
1097
1129
  const statusResponse = await client.queryPrometheus({
@@ -1154,6 +1186,7 @@ export async function handleMetricsHealthCheck(args, ctx) {
1154
1186
  });
1155
1187
  totalEvents = agg.totalEvents;
1156
1188
  statusBreakdown = agg.statusBreakdown;
1189
+ eventApiTruncated = agg.truncated;
1157
1190
  if (parsed.includeTopEvents) {
1158
1191
  topEvents = rankTopEvents(agg.groups, agg.totalEvents, parsed.topN);
1159
1192
  }
@@ -1230,6 +1263,10 @@ export async function handleMetricsHealthCheck(args, ctx) {
1230
1263
  ok: true,
1231
1264
  healthy,
1232
1265
  source,
1266
+ ...(eventApiTruncated ? {
1267
+ _truncated: true,
1268
+ _truncationWarning: "Event API results truncated — counts may be approximate. Narrow the time window for accuracy.",
1269
+ } : {}),
1233
1270
  summary: {
1234
1271
  window,
1235
1272
  totalEvents,
@@ -1268,6 +1305,7 @@ export async function handleMetricsSloReport(args, ctx) {
1268
1305
  let pendingEvents = 0;
1269
1306
  let runtimeP95Seconds = null;
1270
1307
  let inflightP95Seconds = null;
1308
+ let eventApiTruncated = false;
1271
1309
  try {
1272
1310
  const [totalResponse, failedResponse, noMatchResponse, pendingResponse, runtimeP95Response, inflightP95Response,] = await Promise.all([
1273
1311
  client.queryPrometheus({
@@ -1316,6 +1354,7 @@ export async function handleMetricsSloReport(args, ctx) {
1316
1354
  pendingEvents = agg.statusBreakdown["PENDING"] ?? 0;
1317
1355
  runtimeP95Seconds = null;
1318
1356
  inflightP95Seconds = null;
1357
+ eventApiTruncated = agg.truncated;
1319
1358
  }
1320
1359
  let topFailingEvents;
1321
1360
  if (parsed.includeTopFailingEvents) {
@@ -1401,6 +1440,10 @@ export async function handleMetricsSloReport(args, ctx) {
1401
1440
  ok: true,
1402
1441
  source,
1403
1442
  healthy: findings.length === 0,
1443
+ ...(eventApiTruncated ? {
1444
+ _truncated: true,
1445
+ _truncationWarning: "Event API results truncated — counts may be approximate. Narrow the time window or increase maxPages for accuracy.",
1446
+ } : {}),
1404
1447
  summary: {
1405
1448
  window,
1406
1449
  timeWindow: { from, to },
@@ -1837,6 +1880,10 @@ export async function handleMetricsTopEvents(args, ctx) {
1837
1880
  const failedCount = agg.statusBreakdown["FAILED"] ?? 0;
1838
1881
  return {
1839
1882
  ok: true,
1883
+ ...(agg.truncated ? {
1884
+ _truncated: true,
1885
+ _truncationWarning: `Event API results truncated at ${agg.totalPages} pages (${agg.totalEvents} events fetched). Counts may be approximate. Narrow the time window or increase maxPages for accuracy.`,
1886
+ } : {}),
1840
1887
  analytics: {
1841
1888
  timeWindow: { from: parsed.from, to: toTime },
1842
1889
  totalEvents: agg.totalEvents,
@@ -63,9 +63,9 @@ export const SettingGetInputSchema = z.object({
63
63
  .number()
64
64
  .int()
65
65
  .min(1)
66
- .max(100)
67
- .default(10)
68
- .describe("Max results (default: 10, max: 100)."),
66
+ .max(500)
67
+ .default(25)
68
+ .describe("Max results per page (default: 25, max: 500). Auto-paginates wildcard queries using edge cursors."),
69
69
  digest: z
70
70
  .boolean()
71
71
  .default(false)
@@ -320,7 +320,10 @@ function digestSetting(setting) {
320
320
  digest,
321
321
  };
322
322
  }
323
- function buildSettingGetResponse(parsed, settings, cacheMeta) {
323
+ function buildSettingGetResponse(parsed, settings, cacheMeta, truncated = false) {
324
+ const truncationWarning = truncated
325
+ ? `Results truncated at ${settings.length} settings. More exist on the server. Use graphql_queryAll with a settings query to fetch all, or narrow your filter.`
326
+ : undefined;
324
327
  if (settings.length === 0) {
325
328
  return {
326
329
  ok: true,
@@ -370,6 +373,7 @@ function buildSettingGetResponse(parsed, settings, cacheMeta) {
370
373
  count: settings.length,
371
374
  savedTo: outputDir,
372
375
  files: savedFiles,
376
+ ...(truncationWarning ? { warning: truncationWarning } : {}),
373
377
  ...(cacheMeta ? { _cache: cacheMeta } : {}),
374
378
  settings: settings.map((s) => ({
375
379
  id: s.id,
@@ -387,6 +391,7 @@ function buildSettingGetResponse(parsed, settings, cacheMeta) {
387
391
  ok: true,
388
392
  count: settings.length,
389
393
  digested: true,
394
+ ...(truncationWarning ? { warning: truncationWarning } : {}),
390
395
  ...(cacheMeta ? { _cache: cacheMeta } : {}),
391
396
  settings: settings.map((s) => digestSetting(s)),
392
397
  };
@@ -394,6 +399,7 @@ function buildSettingGetResponse(parsed, settings, cacheMeta) {
394
399
  return {
395
400
  ok: true,
396
401
  count: settings.length,
402
+ ...(truncationWarning ? { warning: truncationWarning } : {}),
397
403
  ...(cacheMeta ? { _cache: cacheMeta } : {}),
398
404
  settings: settings.map((s) => ({
399
405
  id: s.id,
@@ -565,7 +571,7 @@ export async function handleSettingGet(args, ctx) {
565
571
  if (cacheable && ctx.cache) {
566
572
  const cached = await ctx.cache.get(cacheKey);
567
573
  if (cached.hit && Array.isArray(cached.data)) {
568
- return buildSettingGetResponse(parsed, cached.data, { hit: true, ageMs: cached.ageMs ?? 0 });
574
+ return buildSettingGetResponse(parsed, cached.data, { hit: true, ageMs: cached.ageMs ?? 0 }, false);
569
575
  }
570
576
  }
571
577
  // Build variables — only include context/contextId if provided
@@ -585,10 +591,11 @@ export async function handleSettingGet(args, ctx) {
585
591
  else if (parsed.context === "ACCOUNT") {
586
592
  variables.contextId = [0];
587
593
  }
588
- // Build query — include lobValue for full content
589
- const query = `query GetSettings($name: [String!], $context: [String!], $contextId: [Int!], $first: Int) {
590
- settings(name: $name, context: $context, contextId: $contextId, first: $first) {
594
+ // Build query — edge cursors for Fluent-style pagination
595
+ const query = `query GetSettings($name: [String!], $context: [String!], $contextId: [Int!], $first: Int, $after: String) {
596
+ settings(name: $name, context: $context, contextId: $contextId, first: $first, after: $after) {
591
597
  edges {
598
+ cursor
592
599
  node {
593
600
  id
594
601
  name
@@ -599,20 +606,49 @@ export async function handleSettingGet(args, ctx) {
599
606
  valueType
600
607
  }
601
608
  }
609
+ pageInfo {
610
+ hasNextPage
611
+ }
602
612
  }
603
613
  }`;
604
- const response = await client.graphql({
605
- query,
606
- variables: variables,
607
- });
608
- const data = response?.data;
609
- const connection = data?.settings;
610
- const edges = (connection?.edges ?? []);
611
- const settings = edges.map((e) => e.node);
614
+ // Auto-paginate wildcard queries using edge cursors; single-page for exact names
615
+ const MAX_TOTAL = 500;
616
+ const isWildcard = isWildcardSettingName(parsed.name);
617
+ const allSettings = [];
618
+ let cursor = null;
619
+ let hasNextPage = true;
620
+ while (hasNextPage && allSettings.length < MAX_TOTAL) {
621
+ const pageVars = { ...variables };
622
+ if (cursor)
623
+ pageVars.after = cursor;
624
+ const response = await client.graphql({
625
+ query,
626
+ variables: pageVars,
627
+ });
628
+ const data = response?.data;
629
+ const connection = data?.settings;
630
+ const edges = (connection?.edges ?? []);
631
+ const pageInfo = connection?.pageInfo;
632
+ for (const edge of edges) {
633
+ if (allSettings.length >= MAX_TOTAL)
634
+ break;
635
+ allSettings.push(edge.node);
636
+ }
637
+ hasNextPage = Boolean(pageInfo?.hasNextPage) && edges.length > 0;
638
+ // Fluent pagination: cursor lives on the edge, not in pageInfo
639
+ if (hasNextPage && edges.length > 0) {
640
+ cursor = edges[edges.length - 1].cursor ?? null;
641
+ }
642
+ // Non-wildcard: one page is enough
643
+ if (!isWildcard)
644
+ break;
645
+ }
646
+ const settings = allSettings;
647
+ const truncated = hasNextPage && allSettings.length >= MAX_TOTAL;
612
648
  if (cacheable && ctx.cache) {
613
649
  await ctx.cache.set(cacheKey, "setting", settings);
614
650
  }
615
- return buildSettingGetResponse(parsed, settings, cacheable && ctx.cache ? { hit: false, stored: true } : undefined);
651
+ return buildSettingGetResponse(parsed, settings, cacheable && ctx.cache ? { hit: false, stored: true } : undefined, truncated);
616
652
  }
617
653
  /**
618
654
  * Handle setting_upsert tool call.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fluentcommerce/fluent-mcp-extn",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "[Experimental] MCP (Model Context Protocol) extension server for Fluent Commerce. Exposes event dispatch, transition actions, GraphQL execution, Prometheus metrics, batch ingestion, and webhook validation as MCP tools.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",