@fluentcommerce/fluent-mcp-extn 0.1.0

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.
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Test tools: test.assert
3
+ *
4
+ * Entity state assertion with optional polling for agentic verify loops.
5
+ */
6
+ import { z } from "zod";
7
+ import { ToolError } from "./errors.js";
8
+ import { getEntityMeta, SUPPORTED_ENTITY_TYPES } from "./entity-registry.js";
9
+ // ---------------------------------------------------------------------------
10
+ // Input schemas
11
+ // ---------------------------------------------------------------------------
12
+ export const TestAssertInputSchema = z.object({
13
+ entityType: z.string().min(1),
14
+ id: z.string().optional().describe("Entity ID (preferred)"),
15
+ ref: z.string().optional().describe("Entity ref (fallback)"),
16
+ assertions: z.object({
17
+ status: z.string().optional().describe("Expected entity status"),
18
+ type: z.string().optional().describe("Expected entity type"),
19
+ subtype: z.string().optional().describe("Expected entity subtype"),
20
+ attributes: z
21
+ .record(z.string(), z.unknown())
22
+ .optional()
23
+ .describe("Expected attribute key-value pairs"),
24
+ edges: z
25
+ .record(z.string(), z.object({
26
+ minCount: z
27
+ .number()
28
+ .int()
29
+ .min(0)
30
+ .optional()
31
+ .describe("Minimum expected edge count"),
32
+ maxCount: z
33
+ .number()
34
+ .int()
35
+ .min(0)
36
+ .optional()
37
+ .describe("Maximum expected edge count"),
38
+ status: z
39
+ .string()
40
+ .optional()
41
+ .describe("Expected status on related entities"),
42
+ }))
43
+ .optional()
44
+ .describe("Assertions on related entity edges"),
45
+ }),
46
+ poll: z
47
+ .boolean()
48
+ .default(false)
49
+ .describe("If true, retry assertions until they pass or timeout."),
50
+ timeoutMs: z
51
+ .number()
52
+ .int()
53
+ .min(1000)
54
+ .max(300000)
55
+ .default(60000)
56
+ .describe("Polling timeout in ms (default: 60000)."),
57
+ intervalMs: z
58
+ .number()
59
+ .int()
60
+ .min(1000)
61
+ .max(30000)
62
+ .default(5000)
63
+ .describe("Polling interval in ms (default: 5000)."),
64
+ });
65
+ // ---------------------------------------------------------------------------
66
+ // Tool definitions (JSON Schema for MCP)
67
+ // ---------------------------------------------------------------------------
68
+ export const TEST_TOOL_DEFINITIONS = [
69
+ {
70
+ name: "test.assert",
71
+ description: [
72
+ "Assert entity state matches expectations with optional polling.",
73
+ "",
74
+ "Deep assertion on entity fields, attributes, and edge counts/statuses.",
75
+ "Human-readable failure messages.",
76
+ "",
77
+ "POLLING MODE: Set poll=true to retry until assertions pass or timeout.",
78
+ "Useful after sending events when state changes are async.",
79
+ "",
80
+ "ASSERTIONS:",
81
+ "- status: exact match on entity status",
82
+ "- type: exact match on entity type",
83
+ "- subtype: exact match on entity subtype",
84
+ "- attributes: key-value pairs that must be present on the entity",
85
+ "- edges: min/max count and status on related entities",
86
+ "",
87
+ "EXAMPLE: Assert order is BOOKED with at least 1 fulfilment:",
88
+ '{ entityType: "ORDER", ref: "HD-001", assertions: { status: "BOOKED", edges: { fulfilments: { minCount: 1 } } } }',
89
+ ].join("\n"),
90
+ inputSchema: {
91
+ type: "object",
92
+ properties: {
93
+ entityType: {
94
+ type: "string",
95
+ description: "Entity type (ORDER, FULFILMENT, etc.)",
96
+ },
97
+ id: {
98
+ type: "string",
99
+ description: "Entity ID (preferred)",
100
+ },
101
+ ref: {
102
+ type: "string",
103
+ description: "Entity ref (fallback)",
104
+ },
105
+ assertions: {
106
+ type: "object",
107
+ properties: {
108
+ status: { type: "string", description: "Expected status" },
109
+ type: { type: "string", description: "Expected type" },
110
+ subtype: { type: "string", description: "Expected subtype" },
111
+ attributes: {
112
+ type: "object",
113
+ additionalProperties: true,
114
+ description: "Expected attribute key-value pairs",
115
+ },
116
+ edges: {
117
+ type: "object",
118
+ additionalProperties: {
119
+ type: "object",
120
+ properties: {
121
+ minCount: { type: "integer", minimum: 0 },
122
+ maxCount: { type: "integer", minimum: 0 },
123
+ status: { type: "string" },
124
+ },
125
+ },
126
+ description: "Assertions on entity edges",
127
+ },
128
+ },
129
+ description: "Assertion conditions",
130
+ },
131
+ poll: {
132
+ type: "boolean",
133
+ description: "Enable polling mode (retry until pass or timeout)",
134
+ },
135
+ timeoutMs: {
136
+ type: "integer",
137
+ minimum: 1000,
138
+ maximum: 300000,
139
+ description: "Polling timeout in ms (default: 60000)",
140
+ },
141
+ intervalMs: {
142
+ type: "integer",
143
+ minimum: 1000,
144
+ maximum: 30000,
145
+ description: "Polling interval in ms (default: 5000)",
146
+ },
147
+ },
148
+ required: ["entityType", "assertions"],
149
+ additionalProperties: false,
150
+ },
151
+ },
152
+ ];
153
+ function buildEntityQuery(entityType, lookupById, edgeNames) {
154
+ const meta = getEntityMeta(entityType);
155
+ if (!meta) {
156
+ throw new ToolError("VALIDATION_ERROR", `Unsupported entity type: "${entityType}". Supported: ${SUPPORTED_ENTITY_TYPES.join(", ")}`);
157
+ }
158
+ const fields = [...meta.defaultFields];
159
+ // Add attributes edge for attribute assertions
160
+ if (!fields.includes("attributes")) {
161
+ fields.push("attributes { name type value }");
162
+ }
163
+ // Add requested edges
164
+ for (const edge of edgeNames) {
165
+ if (meta.edges.includes(edge)) {
166
+ fields.push(`${edge} { edges { node { id ref status type } } }`);
167
+ }
168
+ }
169
+ const fieldSelection = fields.join("\n ");
170
+ if (lookupById) {
171
+ return `query GetEntity($id: ID!) {
172
+ ${meta.queryById}(id: $id) {
173
+ ${fieldSelection}
174
+ }
175
+ }`;
176
+ }
177
+ return `query SearchEntity($ref: [String]) {
178
+ ${meta.queryRoot}(ref: $ref, first: 1) {
179
+ edges {
180
+ node {
181
+ ${fieldSelection}
182
+ }
183
+ }
184
+ }
185
+ }`;
186
+ }
187
+ function evaluateAssertions(entity, assertions) {
188
+ const failures = [];
189
+ // Status assertion
190
+ if (assertions.status !== undefined) {
191
+ if (entity.status !== assertions.status) {
192
+ failures.push({
193
+ field: "status",
194
+ expected: assertions.status,
195
+ actual: entity.status,
196
+ message: `Expected status "${assertions.status}" but got "${entity.status}"`,
197
+ });
198
+ }
199
+ }
200
+ // Type assertion
201
+ if (assertions.type !== undefined) {
202
+ if (entity.type !== assertions.type) {
203
+ failures.push({
204
+ field: "type",
205
+ expected: assertions.type,
206
+ actual: entity.type,
207
+ message: `Expected type "${assertions.type}" but got "${entity.type}"`,
208
+ });
209
+ }
210
+ }
211
+ // Subtype assertion
212
+ if (assertions.subtype !== undefined) {
213
+ if (entity.subtype !== assertions.subtype) {
214
+ failures.push({
215
+ field: "subtype",
216
+ expected: assertions.subtype,
217
+ actual: entity.subtype,
218
+ message: `Expected subtype "${assertions.subtype}" but got "${entity.subtype}"`,
219
+ });
220
+ }
221
+ }
222
+ // Attribute assertions
223
+ if (assertions.attributes) {
224
+ const entityAttrs = entity.attributes;
225
+ const attrMap = new Map();
226
+ if (Array.isArray(entityAttrs)) {
227
+ for (const attr of entityAttrs) {
228
+ const rec = attr;
229
+ if (rec?.name)
230
+ attrMap.set(rec.name, rec.value);
231
+ }
232
+ }
233
+ else if (entityAttrs && typeof entityAttrs === "object") {
234
+ for (const [key, val] of Object.entries(entityAttrs)) {
235
+ attrMap.set(key, val);
236
+ }
237
+ }
238
+ for (const [key, expectedValue] of Object.entries(assertions.attributes)) {
239
+ const actualValue = attrMap.get(key);
240
+ if (actualValue === undefined) {
241
+ failures.push({
242
+ field: `attributes.${key}`,
243
+ expected: expectedValue,
244
+ actual: undefined,
245
+ message: `Attribute "${key}" not found on entity`,
246
+ });
247
+ }
248
+ else if (JSON.stringify(actualValue) !== JSON.stringify(expectedValue)) {
249
+ failures.push({
250
+ field: `attributes.${key}`,
251
+ expected: expectedValue,
252
+ actual: actualValue,
253
+ message: `Attribute "${key}": expected ${JSON.stringify(expectedValue)} but got ${JSON.stringify(actualValue)}`,
254
+ });
255
+ }
256
+ }
257
+ }
258
+ // Edge assertions
259
+ if (assertions.edges) {
260
+ for (const [edgeName, edgeAssert] of Object.entries(assertions.edges)) {
261
+ const edgeData = entity[edgeName];
262
+ const edges = edgeData?.edges;
263
+ const nodes = (edges ?? [])
264
+ .map((e) => e?.node)
265
+ .filter(Boolean);
266
+ if (edgeAssert.minCount !== undefined && nodes.length < edgeAssert.minCount) {
267
+ failures.push({
268
+ field: `edges.${edgeName}.count`,
269
+ expected: `>= ${edgeAssert.minCount}`,
270
+ actual: nodes.length,
271
+ message: `Expected at least ${edgeAssert.minCount} ${edgeName} but found ${nodes.length}`,
272
+ });
273
+ }
274
+ if (edgeAssert.maxCount !== undefined && nodes.length > edgeAssert.maxCount) {
275
+ failures.push({
276
+ field: `edges.${edgeName}.count`,
277
+ expected: `<= ${edgeAssert.maxCount}`,
278
+ actual: nodes.length,
279
+ message: `Expected at most ${edgeAssert.maxCount} ${edgeName} but found ${nodes.length}`,
280
+ });
281
+ }
282
+ if (edgeAssert.status !== undefined) {
283
+ const nonMatching = nodes.filter((n) => n.status !== edgeAssert.status);
284
+ if (nonMatching.length > 0) {
285
+ failures.push({
286
+ field: `edges.${edgeName}.status`,
287
+ expected: edgeAssert.status,
288
+ actual: nonMatching.map((n) => n.status),
289
+ message: `Expected all ${edgeName} to have status "${edgeAssert.status}" but ${nonMatching.length} do not`,
290
+ });
291
+ }
292
+ }
293
+ }
294
+ }
295
+ return failures;
296
+ }
297
+ function requireTestClient(ctx) {
298
+ if (!ctx.client) {
299
+ throw new ToolError("CONFIG_ERROR", "SDK client is not available. Run config.validate and fix auth/base URL.");
300
+ }
301
+ return ctx.client;
302
+ }
303
+ async function fetchEntity(client, entityType, id, ref, edgeNames) {
304
+ const meta = getEntityMeta(entityType);
305
+ if (!meta)
306
+ return null;
307
+ const lookupById = !!id;
308
+ const query = buildEntityQuery(entityType, lookupById, edgeNames);
309
+ const variables = lookupById
310
+ ? { id }
311
+ : { ref: [ref] };
312
+ const response = await client.graphql({
313
+ query,
314
+ variables: variables,
315
+ });
316
+ const data = response?.data;
317
+ if (lookupById) {
318
+ return data?.[meta.queryById] ?? null;
319
+ }
320
+ const connection = data?.[meta.queryRoot];
321
+ const edges = connection?.edges;
322
+ return edges?.[0]?.node ?? null;
323
+ }
324
+ function sleep(ms) {
325
+ return new Promise((resolve) => setTimeout(resolve, ms));
326
+ }
327
+ /**
328
+ * Handle test.assert tool call.
329
+ */
330
+ export async function handleTestAssert(args, ctx) {
331
+ const parsed = TestAssertInputSchema.parse(args);
332
+ if (!parsed.id && !parsed.ref) {
333
+ throw new ToolError("VALIDATION_ERROR", "Either id or ref must be provided for entity lookup.");
334
+ }
335
+ const client = requireTestClient(ctx);
336
+ const edgeNames = parsed.assertions.edges
337
+ ? Object.keys(parsed.assertions.edges)
338
+ : [];
339
+ const startTime = Date.now();
340
+ let attempts = 0;
341
+ let lastFailures = [];
342
+ let entity = null;
343
+ do {
344
+ attempts++;
345
+ entity = await fetchEntity(client, parsed.entityType, parsed.id, parsed.ref, edgeNames);
346
+ if (!entity) {
347
+ if (!parsed.poll || Date.now() - startTime >= parsed.timeoutMs) {
348
+ return {
349
+ ok: true,
350
+ passed: false,
351
+ found: false,
352
+ entityType: parsed.entityType,
353
+ lookupBy: parsed.id ? "id" : "ref",
354
+ lookupValue: parsed.id ?? parsed.ref,
355
+ attempts,
356
+ message: "Entity not found",
357
+ };
358
+ }
359
+ await sleep(parsed.intervalMs);
360
+ continue;
361
+ }
362
+ lastFailures = evaluateAssertions(entity, parsed.assertions);
363
+ if (lastFailures.length === 0) {
364
+ return {
365
+ ok: true,
366
+ passed: true,
367
+ entityType: parsed.entityType,
368
+ entity,
369
+ attempts,
370
+ durationMs: Date.now() - startTime,
371
+ };
372
+ }
373
+ if (!parsed.poll || Date.now() - startTime >= parsed.timeoutMs) {
374
+ break;
375
+ }
376
+ await sleep(parsed.intervalMs);
377
+ } while (Date.now() - startTime < parsed.timeoutMs);
378
+ return {
379
+ ok: true,
380
+ passed: false,
381
+ entityType: parsed.entityType,
382
+ entity,
383
+ failures: lastFailures,
384
+ attempts,
385
+ durationMs: Date.now() - startTime,
386
+ message: `${lastFailures.length} assertion(s) failed after ${attempts} attempt(s)`,
387
+ };
388
+ }