@fluentcommerce/fluent-mcp-extn 0.2.0 → 0.3.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.
@@ -7,6 +7,8 @@
7
7
  * event outcomes without live execution.
8
8
  */
9
9
  import { z } from "zod";
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
10
12
  import { ToolError } from "./errors.js";
11
13
  // ---------------------------------------------------------------------------
12
14
  // Input schemas
@@ -40,6 +42,42 @@ export const WorkflowDiffInputSchema = z.object({
40
42
  .default("summary")
41
43
  .describe("Output format: summary (default), detailed, or mermaid"),
42
44
  });
45
+ export const WorkflowGetInputSchema = z.object({
46
+ entityType: z
47
+ .string()
48
+ .describe("Workflow entity type (e.g., ORDER, FULFILMENT, INVENTORY_CATALOGUE, RETURN_ORDER, BILLING_ACCOUNT, VIRTUAL_CATALOGUE, PRODUCT_CATALOGUE, CONTROL_GROUP, PAYMENT, WAVE, CONSIGNMENT, ARTICLE)."),
49
+ entitySubtype: z
50
+ .string()
51
+ .describe("Workflow entity subtype (e.g., HD, DEFAULT, MASTER, BASE, CC, CUSTOMER, REFUND, PAYMENT)."),
52
+ retailerId: z
53
+ .string()
54
+ .optional()
55
+ .describe("Target retailer ID. Falls back to FLUENT_RETAILER_ID."),
56
+ version: z
57
+ .string()
58
+ .optional()
59
+ .describe("Specific workflow version to fetch. Omit for latest."),
60
+ outputFile: z
61
+ .string()
62
+ .optional()
63
+ .describe("Optional file path to save the full workflow JSON. " +
64
+ "When provided, writes workflow to file and returns only summary " +
65
+ "(name, version, status/ruleset counts) — keeps large workflow JSON out of the LLM context. " +
66
+ "Parent directories are created automatically."),
67
+ });
68
+ export const WorkflowListInputSchema = z.object({
69
+ retailerId: z
70
+ .string()
71
+ .optional()
72
+ .describe("Target retailer ID. Falls back to FLUENT_RETAILER_ID."),
73
+ outputDir: z
74
+ .string()
75
+ .optional()
76
+ .describe("Optional directory path to download ALL workflows (latest version each) as individual JSON files. " +
77
+ "Each file is named <ENTITY_TYPE>-<ENTITY_SUBTYPE>.json (e.g., ORDER-HD.json). " +
78
+ "Returns only summary metadata — full workflow JSON is NOT included in the response. " +
79
+ "Reads from live server (NOT workflowlog cache). Parent directories are created automatically."),
80
+ });
43
81
  export const WorkflowSimulateInputSchema = z.object({
44
82
  workflow: z
45
83
  .union([z.string(), z.record(z.string(), z.unknown())])
@@ -64,6 +102,91 @@ export const WorkflowSimulateInputSchema = z.object({
64
102
  // Tool definitions (JSON Schema for MCP)
65
103
  // ---------------------------------------------------------------------------
66
104
  export const WORKFLOW_TOOL_DEFINITIONS = [
105
+ {
106
+ name: "workflow.get",
107
+ description: [
108
+ "Fetch a workflow definition by entity type and subtype from the Fluent REST API.",
109
+ "",
110
+ "Calls GET /api/v4.1/workflow/{retailerId}/{entityType}::{entitySubtype}/",
111
+ "Returns the full workflow JSON including statuses, rulesets, rules, and triggers.",
112
+ "",
113
+ "NOTE: This fetches a specific workflow by name, which works even when the list",
114
+ "endpoint returns 401 (insufficient permissions for listing).",
115
+ "",
116
+ "KNOWN ENTITY TYPES:",
117
+ "ORDER, FULFILMENT, INVENTORY_CATALOGUE, RETURN_ORDER, BILLING_ACCOUNT,",
118
+ "VIRTUAL_CATALOGUE, PRODUCT_CATALOGUE, CONTROL_GROUP, PAYMENT, WAVE,",
119
+ "CONSIGNMENT, ARTICLE, LOCATION, NETWORK, CUSTOMER, CREDIT_MEMO",
120
+ "",
121
+ "COMMON SUBTYPES:",
122
+ "HD, CC, DEFAULT, MASTER, BASE, CUSTOMER, REFUND, PAYMENT, HD_WH, HD_MP",
123
+ "",
124
+ "RESPONSE includes: name, version, entityType, entitySubtype, statuses, rulesets,",
125
+ "and a summary with status/ruleset/rule counts.",
126
+ "",
127
+ "Use outputFile to save large workflow JSON to disk instead of returning inline.",
128
+ "Returns only summary (name, version, counts) when outputFile is set.",
129
+ ].join("\n"),
130
+ inputSchema: {
131
+ type: "object",
132
+ properties: {
133
+ entityType: {
134
+ type: "string",
135
+ description: "Workflow entity type (e.g., ORDER, FULFILMENT, INVENTORY_CATALOGUE)",
136
+ },
137
+ entitySubtype: {
138
+ type: "string",
139
+ description: "Workflow entity subtype (e.g., HD, DEFAULT, MASTER)",
140
+ },
141
+ retailerId: {
142
+ type: "string",
143
+ description: "Target retailer ID.",
144
+ },
145
+ version: {
146
+ type: "string",
147
+ description: "Specific workflow version. Omit for latest.",
148
+ },
149
+ outputFile: {
150
+ type: "string",
151
+ description: "File path to save workflow JSON. Returns summary only (not full JSON) when set.",
152
+ },
153
+ },
154
+ required: ["entityType", "entitySubtype"],
155
+ additionalProperties: false,
156
+ },
157
+ },
158
+ {
159
+ name: "workflow.list",
160
+ description: [
161
+ "List all workflows for a retailer from the Fluent REST API.",
162
+ "",
163
+ "Calls GET /api/v4.1/workflow?retailerId={retailerId}",
164
+ "Returns a list of workflow summaries (name, version, entityType, entitySubtype, status).",
165
+ "",
166
+ "When outputDir is provided, downloads ALL workflows (latest version each) as individual",
167
+ "JSON files to the directory. Each file is named <TYPE>-<SUBTYPE>.json (e.g., ORDER-HD.json).",
168
+ "Returns only summary metadata — full JSON stays on disk, not in the response.",
169
+ "Reads from LIVE server, not the workflowlog cache.",
170
+ "",
171
+ "NOTE: This endpoint may return 401 on some accounts where the user lacks listing",
172
+ "permissions. In that case, use workflow.get to fetch a specific workflow by name.",
173
+ ].join("\n"),
174
+ inputSchema: {
175
+ type: "object",
176
+ properties: {
177
+ retailerId: {
178
+ type: "string",
179
+ description: "Target retailer ID.",
180
+ },
181
+ outputDir: {
182
+ type: "string",
183
+ description: "Directory to save all workflows as individual JSON files. Returns summary only when set.",
184
+ },
185
+ },
186
+ required: [],
187
+ additionalProperties: false,
188
+ },
189
+ },
67
190
  {
68
191
  name: "workflow.upload",
69
192
  description: [
@@ -750,3 +873,166 @@ export async function handleWorkflowSimulate(args, _ctx) {
750
873
  limitations,
751
874
  };
752
875
  }
876
+ /**
877
+ * Handle workflow.get tool call.
878
+ * Fetches a specific workflow by entityType::entitySubtype via REST API.
879
+ */
880
+ export async function handleWorkflowGet(args, ctx) {
881
+ const parsed = WorkflowGetInputSchema.parse(args);
882
+ const retailerId = parsed.retailerId ?? ctx.config.retailerId;
883
+ if (!retailerId) {
884
+ throw new ToolError("VALIDATION_ERROR", "retailerId is required. Set FLUENT_RETAILER_ID or pass retailerId.");
885
+ }
886
+ const client = requireWorkflowClient(ctx);
887
+ const workflowName = `${parsed.entityType}::${parsed.entitySubtype}`;
888
+ const response = await client.getWorkflow(retailerId, parsed.entityType, parsed.entitySubtype, parsed.version);
889
+ // Extract summary info from the workflow
890
+ const wf = response;
891
+ if (!wf || typeof wf !== "object") {
892
+ throw new ToolError("VALIDATION_ERROR", `No workflow found for ${workflowName} on retailer ${retailerId}.`);
893
+ }
894
+ const statuses = (wf.statuses ?? wf.subStatuses);
895
+ const rulesets = wf.rulesets;
896
+ const totalRules = (rulesets ?? []).reduce((sum, rs) => {
897
+ const rules = rs.rules;
898
+ return sum + (rules?.length ?? 0);
899
+ }, 0);
900
+ const summary = {
901
+ statuses: statuses?.length ?? 0,
902
+ rulesets: rulesets?.length ?? 0,
903
+ totalRules,
904
+ };
905
+ // If outputFile is provided, write workflow to file and return summary only
906
+ if (parsed.outputFile) {
907
+ const fileContent = JSON.stringify(wf, null, 2);
908
+ const dir = path.dirname(parsed.outputFile);
909
+ fs.mkdirSync(dir, { recursive: true });
910
+ fs.writeFileSync(parsed.outputFile, fileContent, "utf-8");
911
+ return {
912
+ ok: true,
913
+ workflowName: wf.name ?? workflowName,
914
+ version: wf.version,
915
+ entityType: wf.entityType ?? parsed.entityType,
916
+ entitySubtype: wf.entitySubtype ?? parsed.entitySubtype,
917
+ retailerId,
918
+ summary,
919
+ savedTo: parsed.outputFile,
920
+ sizeBytes: Buffer.byteLength(fileContent, "utf-8"),
921
+ message: `Workflow saved to ${parsed.outputFile} (${Buffer.byteLength(fileContent, "utf-8")} bytes). Full JSON NOT included in response.`,
922
+ };
923
+ }
924
+ return {
925
+ ok: true,
926
+ workflowName: wf.name ?? workflowName,
927
+ version: wf.version,
928
+ entityType: wf.entityType ?? parsed.entityType,
929
+ entitySubtype: wf.entitySubtype ?? parsed.entitySubtype,
930
+ retailerId,
931
+ summary,
932
+ workflow: wf,
933
+ };
934
+ }
935
+ /**
936
+ * Handle workflow.list tool call.
937
+ * Lists all workflows for a retailer via REST API.
938
+ */
939
+ export async function handleWorkflowList(args, ctx) {
940
+ const parsed = WorkflowListInputSchema.parse(args);
941
+ const retailerId = parsed.retailerId ?? ctx.config.retailerId;
942
+ if (!retailerId) {
943
+ throw new ToolError("VALIDATION_ERROR", "retailerId is required. Set FLUENT_RETAILER_ID or pass retailerId.");
944
+ }
945
+ const client = requireWorkflowClient(ctx);
946
+ const response = await client.listWorkflows(retailerId);
947
+ // Response can be: bare array, paginated { results: [...] }, or unwrapped from { data: ... }
948
+ let workflows;
949
+ if (Array.isArray(response)) {
950
+ workflows = response;
951
+ }
952
+ else if (response && typeof response === "object" && "results" in response) {
953
+ workflows = response.results;
954
+ }
955
+ else {
956
+ workflows = [];
957
+ }
958
+ // Deduplicate: group by name, keep only latest version
959
+ const latestByName = new Map();
960
+ for (const wf of workflows) {
961
+ const w = wf;
962
+ const name = w.name;
963
+ if (!name)
964
+ continue;
965
+ const existing = latestByName.get(name);
966
+ if (!existing || Number(w.version) > Number(existing.version)) {
967
+ latestByName.set(name, w);
968
+ }
969
+ }
970
+ const latest = [...latestByName.values()].map((w) => ({
971
+ name: w.name,
972
+ version: w.version,
973
+ entityType: w.entityType,
974
+ entitySubtype: w.entitySubtype,
975
+ status: w.status,
976
+ createdOn: w.createdOn,
977
+ }));
978
+ // If outputDir is provided, download each workflow's full JSON to individual files
979
+ if (parsed.outputDir && latest.length > 0) {
980
+ fs.mkdirSync(parsed.outputDir, { recursive: true });
981
+ const savedFiles = [];
982
+ const errors = [];
983
+ for (const wf of latest) {
984
+ const entityType = wf.entityType;
985
+ const entitySubtype = wf.entitySubtype;
986
+ if (!entityType || !entitySubtype)
987
+ continue;
988
+ try {
989
+ const fullWf = await client.getWorkflow(retailerId, entityType, entitySubtype);
990
+ if (fullWf && typeof fullWf === "object") {
991
+ const fileContent = JSON.stringify(fullWf, null, 2);
992
+ const fileName = `${entityType}-${entitySubtype}.json`;
993
+ const filePath = path.join(parsed.outputDir, fileName);
994
+ fs.writeFileSync(filePath, fileContent, "utf-8");
995
+ savedFiles.push({
996
+ name: wf.name,
997
+ file: filePath,
998
+ sizeBytes: Buffer.byteLength(fileContent, "utf-8"),
999
+ version: wf.version,
1000
+ });
1001
+ }
1002
+ }
1003
+ catch (err) {
1004
+ errors.push({
1005
+ name: `${entityType}::${entitySubtype}`,
1006
+ error: err instanceof Error ? err.message : String(err),
1007
+ });
1008
+ }
1009
+ }
1010
+ const totalBytes = savedFiles.reduce((s, f) => s + f.sizeBytes, 0);
1011
+ return {
1012
+ ok: true,
1013
+ retailerId,
1014
+ totalVersions: workflows.length,
1015
+ uniqueWorkflows: latest.length,
1016
+ savedTo: parsed.outputDir,
1017
+ filesWritten: savedFiles.length,
1018
+ totalSizeBytes: totalBytes,
1019
+ files: savedFiles,
1020
+ ...(errors.length > 0 ? { errors } : {}),
1021
+ message: `${savedFiles.length} workflow(s) saved to ${parsed.outputDir}/ (${(totalBytes / 1024).toFixed(1)} KB total). ` +
1022
+ `Full JSON NOT included in response.` +
1023
+ (errors.length > 0
1024
+ ? ` ${errors.length} workflow(s) failed to download.`
1025
+ : ""),
1026
+ };
1027
+ }
1028
+ return {
1029
+ ok: true,
1030
+ retailerId,
1031
+ totalVersions: workflows.length,
1032
+ uniqueWorkflows: latest.length,
1033
+ workflows: latest,
1034
+ note: workflows.length === 0
1035
+ ? "No workflows found. The list endpoint may return 401 on some accounts — try workflow.get with a specific entityType and entitySubtype instead."
1036
+ : undefined,
1037
+ };
1038
+ }
@@ -1,100 +1,100 @@
1
- # Contributing Guide
2
-
3
- This guide defines how to safely change `fluent-mcp-extn` while keeping
4
- behavior predictable for all clients.
5
-
6
- ## 1) Core principles
7
-
8
- - Keep the extension **client-agnostic** (no client-specific names in code/docs).
9
- - Prefer **typed boundaries** and avoid unscoped `any`.
10
- - Keep tool responses consistent:
11
- - success: `{ "ok": true, ... }`
12
- - failure: `{ "ok": false, "error": { ... } }`
13
- - Route Fluent API calls through `FluentClientAdapter` only.
14
-
15
- ## 2) Local setup
16
-
17
- ```bash
18
- npm install
19
- npm run build
20
- npm test
21
- ```
22
-
23
- ## 3) Change workflow
24
-
25
- 1. Make focused changes.
26
- 2. Add or update tests.
27
- 3. Run `npm run build`.
28
- 4. Run `npm test`.
29
- 5. Run `npm run e2e:smoke -- --skip-real-send` when touching tool runtime (requires `FLUENT_BASE_URL` + auth env, or `--profile`).
30
- 6. Update docs if tool behavior/config changed.
31
-
32
- ## 4) Adding a new tool (required checklist)
33
-
34
- When adding a tool, update all of the following:
35
-
36
- 1. `src/tools.ts`
37
- - add zod schema
38
- - add `TOOL_DEFINITIONS` entry
39
- - add handler branch
40
- 2. `src/index.ts`
41
- - add tool name to startup log list
42
- 3. tests
43
- - add/adjust unit tests in `test/`
44
- 4. docs
45
- - update `docs/TOOL_REFERENCE.md`
46
- - update `docs/E2E_TESTING.md` if smoke flow changes
47
-
48
- ## 5) Error handling standards
49
-
50
- - Throw `ToolError` for domain/runtime failures.
51
- - Do not return raw thrown errors directly.
52
- - Let `toToolFailure()` convert unknown errors into normalized error payloads.
53
- - Use specific error codes where possible (`AUTH_ERROR`, `VALIDATION_ERROR`, etc.).
54
-
55
- ## 6) Resilience standards
56
-
57
- - Keep retry/timeout policy centralized via:
58
- - `withTimeout()`
59
- - `withRetry()`
60
- - Avoid per-tool custom retry loops.
61
- - Use config-driven values (`FLUENT_REQUEST_TIMEOUT_MS`, retry env vars).
62
- - Preserve idempotency boundaries:
63
- - read operations can use retry/backoff
64
- - non-idempotent write operations must not auto-retry
65
- - Keep SDK retry disabled and adapter retry as the single control plane.
66
-
67
- ## 7) Security and secrets
68
-
69
- - Never log secrets or token values.
70
- - Use redacted summaries via `toSafeConfigSummary()`.
71
- - Prefer profile, `TOKEN_COMMAND`, or OAuth over static tokens in shared environments.
72
-
73
- ## 8) Documentation standards
74
-
75
- - Keep docs concise and task-oriented.
76
- - Include at least one valid request example when changing tool inputs.
77
- - Update all cross-links in `README.md` when adding new docs.
78
- - For behavior changes, update all affected docs end-to-end:
79
- - `README.md`
80
- - `docs/TOOL_REFERENCE.md`
81
- - `docs/RUNBOOK.md`
82
- - `docs/E2E_TESTING.md`
83
-
84
- ## 9) Pull request quality bar
85
-
86
- A change is ready only if:
87
-
88
- - build passes
89
- - tests pass
90
- - smoke runner passes (or is intentionally skipped with rationale)
91
- - no lint errors introduced
92
- - docs updated for behavior/interface changes
93
- - no client-specific wording added
94
-
95
- ## 10) CI expectations
96
-
97
- - CI workflow file: `.github/workflows/ci.yml`
98
- - `build-and-test` must pass for every PR.
99
- - `e2e-smoke` is manual (`workflow_dispatch`) and requires repository secrets.
100
-
1
+ # Contributing Guide
2
+
3
+ This guide defines how to safely change `fluent-mcp-extn` while keeping
4
+ behavior predictable for all clients.
5
+
6
+ ## 1) Core principles
7
+
8
+ - Keep the extension **client-agnostic** (no client-specific names in code/docs).
9
+ - Prefer **typed boundaries** and avoid unscoped `any`.
10
+ - Keep tool responses consistent:
11
+ - success: `{ "ok": true, ... }`
12
+ - failure: `{ "ok": false, "error": { ... } }`
13
+ - Route Fluent API calls through `FluentClientAdapter` only.
14
+
15
+ ## 2) Local setup
16
+
17
+ ```bash
18
+ npm install
19
+ npm run build
20
+ npm test
21
+ ```
22
+
23
+ ## 3) Change workflow
24
+
25
+ 1. Make focused changes.
26
+ 2. Add or update tests.
27
+ 3. Run `npm run build`.
28
+ 4. Run `npm test`.
29
+ 5. Run `npm run e2e:smoke -- --skip-real-send` when touching tool runtime (requires `FLUENT_BASE_URL` + auth env, or `--profile`).
30
+ 6. Update docs if tool behavior/config changed.
31
+
32
+ ## 4) Adding a new tool (required checklist)
33
+
34
+ When adding a tool, update all of the following:
35
+
36
+ 1. `src/tools.ts`
37
+ - add zod schema
38
+ - add `TOOL_DEFINITIONS` entry
39
+ - add handler branch
40
+ 2. `src/index.ts`
41
+ - add tool name to startup log list
42
+ 3. tests
43
+ - add/adjust unit tests in `test/`
44
+ 4. docs
45
+ - update `docs/TOOL_REFERENCE.md`
46
+ - update `docs/E2E_TESTING.md` if smoke flow changes
47
+
48
+ ## 5) Error handling standards
49
+
50
+ - Throw `ToolError` for domain/runtime failures.
51
+ - Do not return raw thrown errors directly.
52
+ - Let `toToolFailure()` convert unknown errors into normalized error payloads.
53
+ - Use specific error codes where possible (`AUTH_ERROR`, `VALIDATION_ERROR`, etc.).
54
+
55
+ ## 6) Resilience standards
56
+
57
+ - Keep retry/timeout policy centralized via:
58
+ - `withTimeout()`
59
+ - `withRetry()`
60
+ - Avoid per-tool custom retry loops.
61
+ - Use config-driven values (`FLUENT_REQUEST_TIMEOUT_MS`, retry env vars).
62
+ - Preserve idempotency boundaries:
63
+ - read operations can use retry/backoff
64
+ - non-idempotent write operations must not auto-retry
65
+ - Keep SDK retry disabled and adapter retry as the single control plane.
66
+
67
+ ## 7) Security and secrets
68
+
69
+ - Never log secrets or token values.
70
+ - Use redacted summaries via `toSafeConfigSummary()`.
71
+ - Prefer profile, `TOKEN_COMMAND`, or OAuth over static tokens in shared environments.
72
+
73
+ ## 8) Documentation standards
74
+
75
+ - Keep docs concise and task-oriented.
76
+ - Include at least one valid request example when changing tool inputs.
77
+ - Update all cross-links in `README.md` when adding new docs.
78
+ - For behavior changes, update all affected docs end-to-end:
79
+ - `README.md`
80
+ - `docs/TOOL_REFERENCE.md`
81
+ - `docs/RUNBOOK.md`
82
+ - `docs/E2E_TESTING.md`
83
+
84
+ ## 9) Pull request quality bar
85
+
86
+ A change is ready only if:
87
+
88
+ - build passes
89
+ - tests pass
90
+ - smoke runner passes (or is intentionally skipped with rationale)
91
+ - no lint errors introduced
92
+ - docs updated for behavior/interface changes
93
+ - no client-specific wording added
94
+
95
+ ## 10) CI expectations
96
+
97
+ - CI workflow file: `.github/workflows/ci.yml`
98
+ - `build-and-test` must pass for every PR.
99
+ - `e2e-smoke` is manual (`workflow_dispatch`) and requires repository secrets.
100
+