@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,573 @@
1
+ /**
2
+ * Environment tools: environment.discover, environment.validate
3
+ *
4
+ * Closes the setup loop — single-call environment snapshot and pre-flight checks
5
+ * for agentic workflows that need to understand what they're working with.
6
+ */
7
+ import { z } from "zod";
8
+ import { validateConfig } from "./config.js";
9
+ import { ToolError } from "./errors.js";
10
+ // ---------------------------------------------------------------------------
11
+ // Input schemas
12
+ // ---------------------------------------------------------------------------
13
+ export const EnvironmentDiscoverInputSchema = z.object({
14
+ include: z
15
+ .array(z.enum([
16
+ "retailer",
17
+ "locations",
18
+ "networks",
19
+ "catalogues",
20
+ "workflows",
21
+ "settings",
22
+ "modules",
23
+ "users",
24
+ ]))
25
+ .default(["retailer", "locations", "networks", "catalogues"])
26
+ .describe("Sections to include in the discovery snapshot."),
27
+ });
28
+ export const EnvironmentValidateInputSchema = z.object({
29
+ checks: z
30
+ .array(z.enum([
31
+ "auth",
32
+ "retailer",
33
+ "locations",
34
+ "inventory",
35
+ "workflows",
36
+ "settings",
37
+ "modules",
38
+ ]))
39
+ .default(["auth", "retailer", "locations"])
40
+ .describe("Pre-flight checks to run."),
41
+ });
42
+ // ---------------------------------------------------------------------------
43
+ // Tool definitions (JSON Schema for MCP)
44
+ // ---------------------------------------------------------------------------
45
+ export const ENVIRONMENT_TOOL_DEFINITIONS = [
46
+ {
47
+ name: "environment.discover",
48
+ description: [
49
+ "Full environment snapshot in one call.",
50
+ "",
51
+ "Returns everything an agent needs to understand the Fluent environment:",
52
+ "- Retailer: id, ref, tradingName, status",
53
+ "- Locations: all locations with type, status, ref",
54
+ "- Networks: all networks with locations",
55
+ "- Catalogues: inventory + product + virtual catalogues",
56
+ "- Workflows: deployed workflow names and versions",
57
+ "- Settings: all retailer-scoped settings",
58
+ "- Modules: deployed modules",
59
+ "- Users: current authenticated user",
60
+ "",
61
+ "Each section is opt-in via 'include' array. Default: retailer, locations, networks, catalogues.",
62
+ "Returns refs, IDs, statuses, types — enough to construct valid mutations.",
63
+ "",
64
+ "LIMITATIONS:",
65
+ "- Locations and settings return first 100 items only (no auto-pagination).",
66
+ "- Workflows section uses transition API probe — may miss workflows with no user actions at initial state.",
67
+ " Use 'fluent workflow list' via CLI for definitive workflow listing.",
68
+ ].join("\n"),
69
+ inputSchema: {
70
+ type: "object",
71
+ properties: {
72
+ include: {
73
+ type: "array",
74
+ items: {
75
+ type: "string",
76
+ enum: [
77
+ "retailer",
78
+ "locations",
79
+ "networks",
80
+ "catalogues",
81
+ "workflows",
82
+ "settings",
83
+ "modules",
84
+ "users",
85
+ ],
86
+ },
87
+ description: "Sections to include. Default: retailer, locations, networks, catalogues.",
88
+ },
89
+ },
90
+ additionalProperties: false,
91
+ },
92
+ },
93
+ {
94
+ name: "environment.validate",
95
+ description: [
96
+ "Pre-flight environment validation.",
97
+ "",
98
+ "Runs configurable checks before E2E tests or deployments:",
99
+ "- auth: token valid, permissions sufficient",
100
+ "- retailer: exists, active",
101
+ "- locations: at least one warehouse exists",
102
+ "- inventory: at least one product with stock",
103
+ "- workflows: key workflows deployed (ORDER, FULFILMENT)",
104
+ "- settings: critical settings exist",
105
+ "- modules: expected modules deployed",
106
+ "",
107
+ "Returns pass/fail per check with severity and actionable messages.",
108
+ ].join("\n"),
109
+ inputSchema: {
110
+ type: "object",
111
+ properties: {
112
+ checks: {
113
+ type: "array",
114
+ items: {
115
+ type: "string",
116
+ enum: [
117
+ "auth",
118
+ "retailer",
119
+ "locations",
120
+ "inventory",
121
+ "workflows",
122
+ "settings",
123
+ "modules",
124
+ ],
125
+ },
126
+ description: "Checks to run. Default: auth, retailer, locations.",
127
+ },
128
+ },
129
+ additionalProperties: false,
130
+ },
131
+ },
132
+ ];
133
+ // ---------------------------------------------------------------------------
134
+ // Discovery queries
135
+ // ---------------------------------------------------------------------------
136
+ const QUERIES = {
137
+ retailer: `query {
138
+ me {
139
+ id username primaryEmail type status
140
+ primaryRetailer { id ref tradingName status primaryEmail }
141
+ }
142
+ }`,
143
+ locations: `query($cursor: String) {
144
+ locations(first: 100, after: $cursor) {
145
+ edges { cursor node { id ref status type name supportPhoneNumber defaultCarrier createdOn } }
146
+ pageInfo { hasNextPage }
147
+ }
148
+ }`,
149
+ networks: `query {
150
+ networks(first: 100) {
151
+ edges { node { id ref status type createdOn locations(first: 50) { edges { node { id ref } } } } }
152
+ pageInfo { hasNextPage }
153
+ }
154
+ }`,
155
+ catalogues_inventory: `query {
156
+ inventoryCatalogues(first: 50) {
157
+ edges { node { id ref status type name createdOn } }
158
+ }
159
+ }`,
160
+ catalogues_product: `query {
161
+ productCatalogues(first: 50) {
162
+ edges { node { id ref status type name createdOn } }
163
+ }
164
+ }`,
165
+ catalogues_virtual: `query {
166
+ virtualCatalogues(first: 50) {
167
+ edges { node { id ref status type inventoryCatalogueRef productCatalogueRef createdOn } }
168
+ }
169
+ }`,
170
+ settings: `query($cursor: String) {
171
+ settings(first: 100, after: $cursor) {
172
+ edges { cursor node { id name value context contextId createdOn updatedOn } }
173
+ pageInfo { hasNextPage }
174
+ }
175
+ }`,
176
+ users: `query {
177
+ me {
178
+ id username primaryEmail type status
179
+ roles { role { name permissions { name } } }
180
+ primaryRetailer { id ref tradingName }
181
+ primaryLocation { id ref name type }
182
+ }
183
+ }`,
184
+ };
185
+ function requireEnvClient(ctx) {
186
+ if (!ctx.client) {
187
+ throw new ToolError("CONFIG_ERROR", "SDK client is not available. Run config.validate and fix auth/base URL.");
188
+ }
189
+ return ctx.client;
190
+ }
191
+ async function queryGraphQL(client, query, variables) {
192
+ const payload = {
193
+ query,
194
+ ...(variables
195
+ ? { variables: variables }
196
+ : {}),
197
+ };
198
+ const response = await client.graphql(payload);
199
+ return response?.data ?? {};
200
+ }
201
+ function extractNodes(connection) {
202
+ if (!connection || typeof connection !== "object")
203
+ return [];
204
+ const edges = connection.edges;
205
+ if (!Array.isArray(edges))
206
+ return [];
207
+ return edges
208
+ .map((edge) => {
209
+ if (!edge || typeof edge !== "object")
210
+ return null;
211
+ return edge.node;
212
+ })
213
+ .filter((n) => n !== null);
214
+ }
215
+ /**
216
+ * Handle environment.discover tool call.
217
+ */
218
+ export async function handleEnvironmentDiscover(args, ctx) {
219
+ const parsed = EnvironmentDiscoverInputSchema.parse(args);
220
+ const client = requireEnvClient(ctx);
221
+ const result = {};
222
+ for (const section of parsed.include) {
223
+ try {
224
+ switch (section) {
225
+ case "retailer":
226
+ case "users": {
227
+ const data = await queryGraphQL(client, QUERIES[section]);
228
+ const me = data.me;
229
+ if (section === "retailer") {
230
+ result.retailer = me?.primaryRetailer ?? null;
231
+ result.user = {
232
+ id: me?.id,
233
+ username: me?.username,
234
+ type: me?.type,
235
+ status: me?.status,
236
+ };
237
+ }
238
+ else {
239
+ result.users = me;
240
+ }
241
+ break;
242
+ }
243
+ case "locations": {
244
+ const data = await queryGraphQL(client, QUERIES.locations, {
245
+ cursor: null,
246
+ });
247
+ const locationNodes = extractNodes(data.locations);
248
+ const locPageInfo = data.locations?.pageInfo;
249
+ result.locations = locationNodes;
250
+ if (locPageInfo?.hasNextPage) {
251
+ result.locationsNote = `Showing first ${locationNodes.length} locations. Use graphql.queryAll for complete list.`;
252
+ }
253
+ break;
254
+ }
255
+ case "networks": {
256
+ const data = await queryGraphQL(client, QUERIES.networks);
257
+ result.networks = extractNodes(data.networks);
258
+ break;
259
+ }
260
+ case "catalogues": {
261
+ const [invData, prodData, virtData] = await Promise.all([
262
+ queryGraphQL(client, QUERIES.catalogues_inventory),
263
+ queryGraphQL(client, QUERIES.catalogues_product),
264
+ queryGraphQL(client, QUERIES.catalogues_virtual),
265
+ ]);
266
+ result.catalogues = {
267
+ inventory: extractNodes(invData.inventoryCatalogues),
268
+ product: extractNodes(prodData.productCatalogues),
269
+ virtual: extractNodes(virtData.virtualCatalogues),
270
+ };
271
+ break;
272
+ }
273
+ case "workflows": {
274
+ // Workflows are not directly queryable via standard GraphQL.
275
+ // We use the workflow.transitions API to discover deployed workflow types.
276
+ const retailerId = ctx.config.retailerId;
277
+ if (retailerId) {
278
+ try {
279
+ // Query transitions for common entity types to discover workflows
280
+ const entityTypes = ["ORDER", "FULFILMENT", "ARTICLE"];
281
+ const workflowInfo = [];
282
+ for (const entityType of entityTypes) {
283
+ try {
284
+ const transitions = await client.getTransitions({
285
+ triggers: [{ type: entityType, retailerId }],
286
+ });
287
+ if (transitions) {
288
+ workflowInfo.push({
289
+ entityType,
290
+ hasWorkflow: true,
291
+ transitions: transitions,
292
+ });
293
+ }
294
+ }
295
+ catch {
296
+ workflowInfo.push({
297
+ entityType,
298
+ hasWorkflow: false,
299
+ });
300
+ }
301
+ }
302
+ result.workflows = workflowInfo;
303
+ }
304
+ catch {
305
+ result.workflows = { error: "Unable to discover workflows" };
306
+ }
307
+ }
308
+ else {
309
+ result.workflows = {
310
+ note: "retailerId required for workflow discovery",
311
+ };
312
+ }
313
+ break;
314
+ }
315
+ case "settings": {
316
+ const data = await queryGraphQL(client, QUERIES.settings, {
317
+ cursor: null,
318
+ });
319
+ const settingNodes = extractNodes(data.settings);
320
+ const settPageInfo = data.settings?.pageInfo;
321
+ result.settings = settingNodes;
322
+ if (settPageInfo?.hasNextPage) {
323
+ result.settingsNote = `Showing first ${settingNodes.length} settings. Use graphql.queryAll for complete list.`;
324
+ }
325
+ break;
326
+ }
327
+ case "modules": {
328
+ // Modules discoverable via plugin.list
329
+ try {
330
+ const plugins = await client.getPlugins();
331
+ if (plugins && typeof plugins === "object") {
332
+ const pluginMap = plugins;
333
+ const moduleNames = new Set();
334
+ for (const key of Object.keys(pluginMap)) {
335
+ // Keys are like ACCOUNT.context.RuleName — extract module context
336
+ const parts = key.split(".");
337
+ if (parts.length >= 2)
338
+ moduleNames.add(parts[1]);
339
+ }
340
+ result.modules = {
341
+ pluginCount: Object.keys(pluginMap).length,
342
+ contexts: [...moduleNames],
343
+ };
344
+ }
345
+ }
346
+ catch {
347
+ result.modules = { error: "Unable to fetch module list" };
348
+ }
349
+ break;
350
+ }
351
+ }
352
+ }
353
+ catch (error) {
354
+ result[section] = {
355
+ error: error instanceof Error ? error.message : String(error),
356
+ };
357
+ }
358
+ }
359
+ return { ok: true, ...result };
360
+ }
361
+ /**
362
+ * Handle environment.validate tool call.
363
+ */
364
+ export async function handleEnvironmentValidate(args, ctx) {
365
+ const parsed = EnvironmentValidateInputSchema.parse(args);
366
+ const checks = [];
367
+ for (const check of parsed.checks) {
368
+ try {
369
+ switch (check) {
370
+ case "auth": {
371
+ const validation = validateConfig(ctx.config);
372
+ checks.push({
373
+ name: "auth",
374
+ passed: validation.isReadyForApiCalls,
375
+ message: validation.isReadyForApiCalls
376
+ ? `Authenticated via ${validation.authStrategy}`
377
+ : `Auth not ready: ${validation.errors.join(", ")}`,
378
+ severity: validation.isReadyForApiCalls ? "info" : "error",
379
+ });
380
+ break;
381
+ }
382
+ case "retailer": {
383
+ if (!ctx.client) {
384
+ checks.push({
385
+ name: "retailer",
386
+ passed: false,
387
+ message: "Cannot verify retailer — SDK client not available",
388
+ severity: "error",
389
+ });
390
+ break;
391
+ }
392
+ const data = await queryGraphQL(ctx.client, QUERIES.retailer);
393
+ const me = data.me;
394
+ const retailer = me?.primaryRetailer;
395
+ if (retailer) {
396
+ checks.push({
397
+ name: "retailer",
398
+ passed: retailer.status === "ACTIVE",
399
+ message: `Retailer ${retailer.ref} (${retailer.tradingName}): ${retailer.status}`,
400
+ severity: retailer.status === "ACTIVE" ? "info" : "warning",
401
+ });
402
+ }
403
+ else {
404
+ checks.push({
405
+ name: "retailer",
406
+ passed: false,
407
+ message: "No primary retailer found for authenticated user",
408
+ severity: "error",
409
+ });
410
+ }
411
+ break;
412
+ }
413
+ case "locations": {
414
+ if (!ctx.client) {
415
+ checks.push({
416
+ name: "locations",
417
+ passed: false,
418
+ message: "Cannot verify locations — SDK client not available",
419
+ severity: "error",
420
+ });
421
+ break;
422
+ }
423
+ const data = await queryGraphQL(ctx.client, QUERIES.locations, {
424
+ cursor: null,
425
+ });
426
+ const locations = extractNodes(data.locations);
427
+ const warehouses = locations.filter((l) => l.type === "WAREHOUSE" || l.type === "STORE");
428
+ checks.push({
429
+ name: "locations",
430
+ passed: warehouses.length > 0,
431
+ message: `${locations.length} location(s) found, ${warehouses.length} warehouse/store`,
432
+ severity: warehouses.length > 0 ? "info" : "warning",
433
+ });
434
+ break;
435
+ }
436
+ case "inventory": {
437
+ if (!ctx.client) {
438
+ checks.push({
439
+ name: "inventory",
440
+ passed: false,
441
+ message: "Cannot verify inventory — SDK client not available",
442
+ severity: "error",
443
+ });
444
+ break;
445
+ }
446
+ const invQuery = `query {
447
+ inventoryPositions(first: 5) {
448
+ edges { node { id ref onHand type status } }
449
+ }
450
+ }`;
451
+ const data = await queryGraphQL(ctx.client, invQuery);
452
+ const positions = extractNodes(data.inventoryPositions);
453
+ const withStock = positions.filter((p) => typeof p.onHand === "number" && p.onHand > 0);
454
+ checks.push({
455
+ name: "inventory",
456
+ passed: withStock.length > 0,
457
+ message: `${positions.length} inventory position(s) sampled, ${withStock.length} with stock > 0`,
458
+ severity: withStock.length > 0 ? "info" : "warning",
459
+ });
460
+ break;
461
+ }
462
+ case "workflows": {
463
+ if (!ctx.client || !ctx.config.retailerId) {
464
+ checks.push({
465
+ name: "workflows",
466
+ passed: false,
467
+ message: "Cannot verify workflows — client or retailerId missing",
468
+ severity: "warning",
469
+ });
470
+ break;
471
+ }
472
+ const entityTypes = ["ORDER", "FULFILMENT"];
473
+ let deployedCount = 0;
474
+ const missing = [];
475
+ for (const entityType of entityTypes) {
476
+ try {
477
+ const transitions = await ctx.client.getTransitions({
478
+ triggers: [
479
+ { type: entityType, retailerId: ctx.config.retailerId },
480
+ ],
481
+ });
482
+ if (transitions)
483
+ deployedCount++;
484
+ }
485
+ catch {
486
+ missing.push(entityType);
487
+ }
488
+ }
489
+ checks.push({
490
+ name: "workflows",
491
+ passed: missing.length === 0,
492
+ message: missing.length === 0
493
+ ? `${deployedCount} workflow type(s) verified (${entityTypes.join(", ")})`
494
+ : `Missing workflows for: ${missing.join(", ")}`,
495
+ severity: missing.length === 0 ? "info" : "warning",
496
+ });
497
+ break;
498
+ }
499
+ case "settings": {
500
+ if (!ctx.client) {
501
+ checks.push({
502
+ name: "settings",
503
+ passed: false,
504
+ message: "Cannot verify settings — SDK client not available",
505
+ severity: "warning",
506
+ });
507
+ break;
508
+ }
509
+ const data = await queryGraphQL(ctx.client, QUERIES.settings, {
510
+ cursor: null,
511
+ });
512
+ const settings = extractNodes(data.settings);
513
+ checks.push({
514
+ name: "settings",
515
+ passed: settings.length > 0,
516
+ message: `${settings.length} setting(s) found in environment`,
517
+ severity: settings.length > 0 ? "info" : "warning",
518
+ });
519
+ break;
520
+ }
521
+ case "modules": {
522
+ if (!ctx.client) {
523
+ checks.push({
524
+ name: "modules",
525
+ passed: false,
526
+ message: "Cannot verify modules — SDK client not available",
527
+ severity: "warning",
528
+ });
529
+ break;
530
+ }
531
+ try {
532
+ const plugins = await ctx.client.getPlugins();
533
+ const count = plugins
534
+ ? Object.keys(plugins).length
535
+ : 0;
536
+ checks.push({
537
+ name: "modules",
538
+ passed: count > 0,
539
+ message: `${count} registered rule(s) found`,
540
+ severity: count > 0 ? "info" : "warning",
541
+ });
542
+ }
543
+ catch {
544
+ checks.push({
545
+ name: "modules",
546
+ passed: false,
547
+ message: "Unable to fetch plugin registry",
548
+ severity: "warning",
549
+ });
550
+ }
551
+ break;
552
+ }
553
+ }
554
+ }
555
+ catch (error) {
556
+ checks.push({
557
+ name: check,
558
+ passed: false,
559
+ message: error instanceof Error ? error.message : String(error),
560
+ severity: "error",
561
+ });
562
+ }
563
+ }
564
+ const allPassed = checks.every((c) => c.passed);
565
+ return {
566
+ ok: true,
567
+ ready: allPassed,
568
+ checks,
569
+ summary: allPassed
570
+ ? `All ${checks.length} check(s) passed.`
571
+ : `${checks.filter((c) => !c.passed).length} of ${checks.length} check(s) failed.`,
572
+ };
573
+ }