@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,361 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Response shaping: local analysis before LLM
3
+ // ---------------------------------------------------------------------------
4
+ /** Protected keys that are never stripped, even when empty/null. */
5
+ const PROTECTED_KEYS = new Set([
6
+ "ok",
7
+ "error",
8
+ "code",
9
+ "message",
10
+ "retryable",
11
+ "id",
12
+ "ref",
13
+ "status",
14
+ ]);
15
+ /**
16
+ * Default budget: 50 000 chars (~12.5k tokens).
17
+ * Set FLUENT_RESPONSE_BUDGET_CHARS=0 to disable (unlimited).
18
+ */
19
+ const DEFAULT_BUDGET_CHARS = 50_000;
20
+ export function loadResponseBudget() {
21
+ const raw = process.env.FLUENT_RESPONSE_BUDGET_CHARS?.trim();
22
+ // Explicit "0" disables budget; absence uses sensible default
23
+ const maxChars = raw !== undefined
24
+ ? envNumber("FLUENT_RESPONSE_BUDGET_CHARS", DEFAULT_BUDGET_CHARS, 0)
25
+ : DEFAULT_BUDGET_CHARS;
26
+ const maxArrayElements = envNumber("FLUENT_RESPONSE_MAX_ARRAY", 50, 1);
27
+ const sampleSize = envNumber("FLUENT_RESPONSE_SAMPLE_SIZE", 3, 1);
28
+ return {
29
+ maxChars,
30
+ maxArrayElements: maxChars > 0 ? maxArrayElements : Infinity,
31
+ sampleSize,
32
+ stripEmpty: maxChars > 0,
33
+ };
34
+ }
35
+ function envNumber(name, fallback, min) {
36
+ const raw = process.env[name]?.trim();
37
+ if (!raw)
38
+ return fallback;
39
+ const n = Number(raw);
40
+ if (!Number.isFinite(n) || n < min)
41
+ return fallback;
42
+ return n;
43
+ }
44
+ /**
45
+ * Shape a payload to fit within a character budget.
46
+ *
47
+ * Strategy (in order):
48
+ * 1. Strip null/undefined/empty-string values (keeps protected keys).
49
+ * 2. Auto-summarize arrays that exceed maxArrayElements: instead of truncating
50
+ * (which gives the LLM incomplete data), produce a COMPLETE summary with
51
+ * record count, field inventory, value distribution, and sample records.
52
+ * 3. Auto-summarize objects with more keys than maxArrayElements (same approach).
53
+ * 4. If still over budget after pass 1, progressively tighten thresholds
54
+ * (halve maxArrayElements and sampleSize) and re-shape up to 3 times.
55
+ * 5. Returns payload unchanged if no budget is set or it already fits.
56
+ *
57
+ * The budget is enforced as a HARD cap. After all passes, if the response
58
+ * still exceeds the budget, a final truncation with _budgetExceeded marker
59
+ * ensures the response never blows out the LLM context window.
60
+ */
61
+ export function shapeResponse(payload, budget) {
62
+ const original = JSON.stringify(payload);
63
+ const originalChars = original.length;
64
+ if (budget.maxChars <= 0 || originalChars <= budget.maxChars) {
65
+ return {
66
+ shaped: payload,
67
+ meta: {
68
+ originalChars,
69
+ shapedChars: originalChars,
70
+ reductionPercent: 0,
71
+ summarizedArrays: 0,
72
+ strippedFields: 0,
73
+ },
74
+ };
75
+ }
76
+ // Iterative shaping: progressively tighten thresholds until within budget
77
+ const totalStats = { summarizedArrays: 0, strippedFields: 0 };
78
+ let currentBudget = { ...budget };
79
+ let shaped = payload;
80
+ let shapedStr;
81
+ const MAX_PASSES = 3;
82
+ for (let pass = 0; pass < MAX_PASSES; pass++) {
83
+ const stats = { summarizedArrays: 0, strippedFields: 0 };
84
+ shaped = deepShape(shaped, currentBudget, stats);
85
+ shapedStr = JSON.stringify(shaped);
86
+ totalStats.summarizedArrays += stats.summarizedArrays;
87
+ totalStats.strippedFields += stats.strippedFields;
88
+ if (shapedStr.length <= budget.maxChars)
89
+ break;
90
+ // Tighten for next pass: halve thresholds (min 5 elements, 1 sample)
91
+ currentBudget = {
92
+ ...currentBudget,
93
+ maxArrayElements: Math.max(5, Math.floor(currentBudget.maxArrayElements / 2)),
94
+ sampleSize: Math.max(1, Math.floor(currentBudget.sampleSize / 2)),
95
+ };
96
+ }
97
+ shapedStr = JSON.stringify(shaped);
98
+ // Final safety net: if still over budget, truncate with marker
99
+ if (shapedStr.length > budget.maxChars) {
100
+ const marker = `,"_budgetExceeded":true,"_originalChars":${originalChars},"_budgetChars":${budget.maxChars}}`;
101
+ // Reserve space for the marker at the end
102
+ const truncateAt = budget.maxChars - marker.length;
103
+ if (truncateAt > 100) {
104
+ shapedStr = shapedStr.slice(0, truncateAt) + marker;
105
+ try {
106
+ shaped = JSON.parse(shapedStr);
107
+ }
108
+ catch {
109
+ // If truncated JSON is invalid, wrap in an envelope
110
+ shaped = {
111
+ _budgetExceeded: true,
112
+ _originalChars: originalChars,
113
+ _budgetChars: budget.maxChars,
114
+ _note: "Response exceeded budget after all shaping passes. Use targeted queries (compact mode, filters, specific IDs) to get smaller responses.",
115
+ };
116
+ shapedStr = JSON.stringify(shaped);
117
+ }
118
+ }
119
+ }
120
+ const shapedChars = shapedStr.length;
121
+ return {
122
+ shaped,
123
+ meta: {
124
+ originalChars,
125
+ shapedChars,
126
+ reductionPercent: Math.round(((originalChars - shapedChars) / originalChars) * 100),
127
+ summarizedArrays: totalStats.summarizedArrays,
128
+ strippedFields: totalStats.strippedFields,
129
+ },
130
+ };
131
+ }
132
+ function deepShape(value, budget, stats) {
133
+ if (value === null || value === undefined)
134
+ return value;
135
+ if (typeof value !== "object")
136
+ return value;
137
+ if (Array.isArray(value)) {
138
+ if (value.length > budget.maxArrayElements) {
139
+ return summarizeArray(value, budget.sampleSize, stats);
140
+ }
141
+ return value.map((item) => deepShape(item, budget, stats));
142
+ }
143
+ // Object
144
+ const obj = value;
145
+ const keys = Object.keys(obj);
146
+ // Summarize large objects (e.g., plugin.list with 597 rule keys)
147
+ if (keys.length > budget.maxArrayElements && !hasProtectedStructure(obj)) {
148
+ return summarizeObject(obj, keys, budget.sampleSize, stats);
149
+ }
150
+ const out = {};
151
+ for (const [key, val] of Object.entries(obj)) {
152
+ const shapedVal = deepShape(val, budget, stats);
153
+ if (budget.stripEmpty && !PROTECTED_KEYS.has(key)) {
154
+ if (shapedVal === null || shapedVal === undefined || shapedVal === "") {
155
+ stats.strippedFields++;
156
+ continue;
157
+ }
158
+ }
159
+ out[key] = shapedVal;
160
+ }
161
+ return out;
162
+ }
163
+ /**
164
+ * Check if an object is a top-level response envelope that should NOT be summarized.
165
+ * Envelopes have 'ok' key or very few keys — they wrap the real data.
166
+ */
167
+ function hasProtectedStructure(obj) {
168
+ if ("ok" in obj)
169
+ return true;
170
+ if ("error" in obj)
171
+ return true;
172
+ if ("data" in obj)
173
+ return true; // GraphQL response wrapper
174
+ // Small objects (≤10 keys) are likely structured responses, not large data maps
175
+ if (Object.keys(obj).length <= 10)
176
+ return true;
177
+ return false;
178
+ }
179
+ /**
180
+ * Summarize a large object (many keys) like we summarize arrays.
181
+ *
182
+ * Produces:
183
+ * - _summarized: true
184
+ * - totalKeys: number of keys
185
+ * - keysSample: first N key names
186
+ * - sample: first N key-value pairs (as object)
187
+ * - valueShape: field names found in value objects (if values are objects)
188
+ */
189
+ function summarizeObject(obj, keys, sampleSize, stats) {
190
+ stats.summarizedArrays++;
191
+ const summary = {
192
+ _summarized: true,
193
+ totalKeys: keys.length,
194
+ keysSample: keys.slice(0, Math.min(20, keys.length)),
195
+ };
196
+ // Check if values are objects — extract their field structure
197
+ const firstVal = obj[keys[0]];
198
+ if (firstVal && typeof firstVal === "object" && !Array.isArray(firstVal)) {
199
+ const fieldSet = new Set();
200
+ // Sample first 50 values for field discovery
201
+ const sampleKeys = keys.slice(0, 50);
202
+ for (const k of sampleKeys) {
203
+ const v = obj[k];
204
+ if (v && typeof v === "object" && !Array.isArray(v)) {
205
+ for (const field of Object.keys(v)) {
206
+ fieldSet.add(field);
207
+ }
208
+ }
209
+ }
210
+ summary.valueFields = Array.from(fieldSet).sort();
211
+ }
212
+ // Include first N full entries as sample
213
+ const sampleObj = {};
214
+ for (let i = 0; i < sampleSize && i < keys.length; i++) {
215
+ sampleObj[keys[i]] = obj[keys[i]];
216
+ }
217
+ summary.sample = sampleObj;
218
+ return summary;
219
+ }
220
+ /**
221
+ * Auto-summarize an array instead of truncating it.
222
+ *
223
+ * Produces a COMPLETE analytical summary:
224
+ * - totalCount: exact record count
225
+ * - fields: all field names across records (if objects)
226
+ * - sample: first N records for LLM to see actual data shape
227
+ * - distribution: value counts for key categorical fields (status, type, name, entityType)
228
+ *
229
+ * The LLM gets a full, accurate picture without incomplete raw data.
230
+ */
231
+ function summarizeArray(arr, sampleSize, stats) {
232
+ stats.summarizedArrays++;
233
+ const summary = {
234
+ _summarized: true,
235
+ totalCount: arr.length,
236
+ };
237
+ // If array contains objects, extract field names and distributions
238
+ const firstObj = arr.find((item) => item !== null && typeof item === "object" && !Array.isArray(item));
239
+ if (firstObj) {
240
+ // Collect all field names across all records
241
+ const fieldSet = new Set();
242
+ for (const item of arr) {
243
+ if (item && typeof item === "object" && !Array.isArray(item)) {
244
+ for (const key of Object.keys(item)) {
245
+ fieldSet.add(key);
246
+ }
247
+ }
248
+ }
249
+ summary.fields = Array.from(fieldSet).sort();
250
+ // Build value distributions for categorical fields
251
+ const categoricalKeys = ["status", "type", "name", "entityType",
252
+ "eventStatus", "category", "subtype", "eventType"];
253
+ const distributions = {};
254
+ for (const key of categoricalKeys) {
255
+ if (!fieldSet.has(key))
256
+ continue;
257
+ const counts = {};
258
+ for (const item of arr) {
259
+ if (item && typeof item === "object" && !Array.isArray(item)) {
260
+ const val = item[key];
261
+ if (val !== null && val !== undefined) {
262
+ const strVal = String(val);
263
+ counts[strVal] = (counts[strVal] ?? 0) + 1;
264
+ }
265
+ }
266
+ }
267
+ if (Object.keys(counts).length > 0) {
268
+ distributions[key] = counts;
269
+ }
270
+ }
271
+ if (Object.keys(distributions).length > 0) {
272
+ summary.distributions = distributions;
273
+ }
274
+ // Sample records (first N)
275
+ summary.sample = arr.slice(0, sampleSize);
276
+ }
277
+ else {
278
+ // Array of primitives or mixed — just show sample
279
+ summary.sample = arr.slice(0, sampleSize);
280
+ }
281
+ return summary;
282
+ }
283
+ /**
284
+ * Detect Relay connection pattern in a GraphQL response and return a summary.
285
+ * Returns null if no connection pattern is detected.
286
+ */
287
+ export function summarizeConnection(response, sampleSize = 3) {
288
+ if (!response || typeof response !== "object")
289
+ return null;
290
+ const data = response.data;
291
+ if (!data || typeof data !== "object")
292
+ return null;
293
+ // Find the first key in data that has an edges array with node objects
294
+ for (const [key, val] of Object.entries(data)) {
295
+ if (!val || typeof val !== "object")
296
+ continue;
297
+ const connection = val;
298
+ const edges = connection.edges;
299
+ if (!Array.isArray(edges))
300
+ continue;
301
+ // Verify at least one edge has a node
302
+ const nodes = edges
303
+ .map((edge) => {
304
+ if (!edge || typeof edge !== "object")
305
+ return null;
306
+ return edge.node;
307
+ })
308
+ .filter((node) => node !== null && node !== undefined && typeof node === "object");
309
+ if (nodes.length === 0 && edges.length > 0)
310
+ continue;
311
+ const pageInfo = connection.pageInfo;
312
+ const hasMore = pageInfo?.hasNextPage === true;
313
+ const fields = nodes.length > 0 ? Object.keys(nodes[0]) : [];
314
+ const sample = nodes.slice(0, sampleSize);
315
+ return {
316
+ connectionKey: key,
317
+ recordCount: nodes.length,
318
+ fields,
319
+ sample,
320
+ hasMore,
321
+ };
322
+ }
323
+ return null;
324
+ }
325
+ /**
326
+ * Aggregate raw events into a compact analysis.
327
+ */
328
+ export function analyzeEvents(results, hasMore) {
329
+ const groups = new Map();
330
+ let minTime = null;
331
+ let maxTime = null;
332
+ const statusCounts = {};
333
+ for (const evt of results) {
334
+ const name = String(evt.name ?? "unknown");
335
+ const ctx = evt.context;
336
+ const entityType = String(ctx?.entityType ?? "unknown");
337
+ const eventStatus = String(evt.eventStatus ?? "unknown");
338
+ const time = typeof evt.generatedOn === "string" ? evt.generatedOn : null;
339
+ const key = `${name}|${entityType}|${eventStatus}`;
340
+ const existing = groups.get(key);
341
+ if (existing)
342
+ existing.count++;
343
+ else
344
+ groups.set(key, { name, entityType, eventStatus, count: 1 });
345
+ statusCounts[eventStatus] = (statusCounts[eventStatus] ?? 0) + 1;
346
+ if (time) {
347
+ if (!minTime || time < minTime)
348
+ minTime = time;
349
+ if (!maxTime || time > maxTime)
350
+ maxTime = time;
351
+ }
352
+ }
353
+ const sorted = Array.from(groups.values()).sort((a, b) => b.count - a.count);
354
+ return {
355
+ totalCount: results.length,
356
+ hasMore,
357
+ timeRange: { from: minTime, to: maxTime },
358
+ statusBreakdown: statusCounts,
359
+ groups: sorted,
360
+ };
361
+ }
@@ -0,0 +1,237 @@
1
+ import { exec as execCb } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { createClient, createClientFromProfile, } from "@fluentcommerce/fc-connect-sdk";
4
+ import { hasOAuthConfig } from "./config.js";
5
+ import { createFluentClientAdapter } from "./fluent-client.js";
6
+ import { ToolError } from "./errors.js";
7
+ /**
8
+ * SDK bootstrap and auth strategy resolution.
9
+ *
10
+ * Priority:
11
+ * 1) Fluent profile via createClientFromProfile (when enabled)
12
+ * 2) OAuth (SDK-native)
13
+ * 3) TOKEN_COMMAND (bridge)
14
+ * 4) Static token (bridge)
15
+ */
16
+ const exec = promisify(execCb);
17
+ /**
18
+ * Disable SDK-level retries and centralize retry policy in FluentClientAdapter.
19
+ * This lets us enforce "no retry" on non-idempotent operations.
20
+ *
21
+ * SDK v0.1.54+ validates delay ranges:
22
+ * baseRetryDelayMs: 100–60000
23
+ * maxRetryDelayMs: 1000–60000
24
+ * maxAuthRetryDelayMs: 1000–60000
25
+ *
26
+ * With maxRetries=0 these values are never used, but must pass validation.
27
+ */
28
+ const SDK_RETRY_ATTEMPTS = 0;
29
+ const SDK_BASE_RETRY_DELAY_MS = 100;
30
+ const SDK_MAX_RETRY_DELAY_MS = 1000;
31
+ /** TTL for tokens obtained via command or OAuth refresh cycle. */
32
+ const TOKEN_TTL_MS = 55 * 60 * 1000;
33
+ const TOKEN_EXPIRY_BUFFER_SECONDS = 300;
34
+ /**
35
+ * TTL for static tokens (FLUENT_ACCESS_TOKEN).
36
+ * Keep this intentionally long because there is no local refresh mechanism.
37
+ * The API will still reject expired tokens server-side with 401.
38
+ */
39
+ const STATIC_TOKEN_TTL_MS = 365 * 24 * 60 * 60 * 1000;
40
+ function asMutableTokenClient(client) {
41
+ if (!client || typeof client !== "object")
42
+ return null;
43
+ const rec = client;
44
+ // Verify at least one expected mutable property exists or can be set.
45
+ // accessToken and tokenExpiry may not exist yet (they get assigned),
46
+ // but getAccessToken should be present on a real SDK client.
47
+ if (typeof rec.getAccessToken !== "function" &&
48
+ typeof rec.accessToken !== "string" &&
49
+ !("accessToken" in rec)) {
50
+ return null;
51
+ }
52
+ return client;
53
+ }
54
+ /**
55
+ * Parse command output into a token string.
56
+ * Supports:
57
+ * - plain text token
58
+ * - JSON with access_token
59
+ * - JSON with token
60
+ */
61
+ export function parseTokenFromCommandOutput(stdout) {
62
+ const trimmed = stdout.trim();
63
+ if (!trimmed) {
64
+ throw new ToolError("AUTH_ERROR", "TOKEN_COMMAND returned empty output.");
65
+ }
66
+ try {
67
+ const parsed = JSON.parse(trimmed);
68
+ if (typeof parsed.access_token === "string")
69
+ return parsed.access_token;
70
+ if (typeof parsed.token === "string")
71
+ return parsed.token;
72
+ }
73
+ catch {
74
+ // Not JSON: treat stdout as a raw token.
75
+ }
76
+ return trimmed;
77
+ }
78
+ async function resolveTokenFromCommand(command, timeoutMs) {
79
+ try {
80
+ const { stdout } = await exec(command, {
81
+ maxBuffer: 1024 * 1024,
82
+ timeout: timeoutMs,
83
+ });
84
+ return parseTokenFromCommandOutput(stdout);
85
+ }
86
+ catch (error) {
87
+ const message = error instanceof Error ? error.message : String(error);
88
+ throw new ToolError("AUTH_ERROR", `TOKEN_COMMAND failed: ${message}`, {
89
+ retryable: false,
90
+ cause: error,
91
+ });
92
+ }
93
+ }
94
+ function applyTokenBridge(client, token, ttlMs = TOKEN_TTL_MS) {
95
+ const mutable = asMutableTokenClient(client);
96
+ if (!mutable) {
97
+ throw new ToolError("SDK_ERROR", "Cannot inject access token into SDK client object.");
98
+ }
99
+ try {
100
+ mutable.accessToken = token;
101
+ mutable.tokenExpiry = new Date(Date.now() + ttlMs);
102
+ }
103
+ catch (error) {
104
+ throw new ToolError("SDK_ERROR", "Cannot assign token fields on SDK client.", {
105
+ cause: error,
106
+ });
107
+ }
108
+ // Runtime cast verification: ensure token assignment actually succeeded.
109
+ if (mutable.accessToken !== token) {
110
+ throw new ToolError("SDK_ERROR", "Cannot inject access token into SDK client object.");
111
+ }
112
+ }
113
+ /**
114
+ * Keeps TOKEN_COMMAND integrations working until the SDK exposes a first-class
115
+ * command/vault auth provider.
116
+ */
117
+ function patchTokenRefreshBridge(client, command, timeoutMs) {
118
+ const mutable = asMutableTokenClient(client);
119
+ const original = mutable?.getAccessToken?.bind(mutable);
120
+ if (!mutable || !original)
121
+ return;
122
+ mutable.getAccessToken = async () => {
123
+ if (mutable.tokenExpiry && mutable.tokenExpiry > new Date() && mutable.accessToken) {
124
+ return mutable.accessToken;
125
+ }
126
+ const fresh = await resolveTokenFromCommand(command, timeoutMs);
127
+ mutable.accessToken = fresh;
128
+ mutable.tokenExpiry = new Date(Date.now() + TOKEN_TTL_MS);
129
+ return fresh;
130
+ };
131
+ }
132
+ function applyRetailerOverride(client, retailerId) {
133
+ if (!retailerId || !client || typeof client !== "object")
134
+ return;
135
+ const rec = client;
136
+ if (typeof rec.setRetailerId === "function") {
137
+ rec.setRetailerId(retailerId);
138
+ }
139
+ }
140
+ export async function initSDKClient(config) {
141
+ const baseUrl = config.baseUrl;
142
+ if (!baseUrl && !config.useProfileClientFactory) {
143
+ console.error("[sdk-client] FLUENT_BASE_URL is missing; SDK client disabled.");
144
+ return null;
145
+ }
146
+ try {
147
+ // Path 1: Fluent CLI profile via SDK profile client factory.
148
+ if (config.useProfileClientFactory && config.profileName) {
149
+ const client = await createClientFromProfile(config.profileName, {
150
+ retailer: config.profileRetailer ?? undefined,
151
+ timeout: config.requestTimeoutMs,
152
+ retryConfig: {
153
+ maxRetries: SDK_RETRY_ATTEMPTS,
154
+ maxAuthRetries: SDK_RETRY_ATTEMPTS,
155
+ baseRetryDelayMs: SDK_BASE_RETRY_DELAY_MS,
156
+ maxRetryDelayMs: SDK_MAX_RETRY_DELAY_MS,
157
+ maxAuthRetryDelayMs: SDK_MAX_RETRY_DELAY_MS,
158
+ tokenExpiryBufferSeconds: TOKEN_EXPIRY_BUFFER_SECONDS,
159
+ },
160
+ });
161
+ applyRetailerOverride(client, config.retailerId);
162
+ return createFluentClientAdapter(client, config);
163
+ }
164
+ if (!baseUrl) {
165
+ console.error("[sdk-client] FLUENT_BASE_URL is missing; SDK client disabled.");
166
+ return null;
167
+ }
168
+ // Path 2: OAuth credentials.
169
+ if (hasOAuthConfig(config)) {
170
+ const client = await createClient({
171
+ config: {
172
+ baseUrl,
173
+ clientId: config.clientId ?? undefined,
174
+ clientSecret: config.clientSecret ?? undefined,
175
+ username: config.username ?? undefined,
176
+ password: config.password ?? undefined,
177
+ retailerId: config.retailerId ?? undefined,
178
+ timeout: config.requestTimeoutMs,
179
+ retryAttempts: SDK_RETRY_ATTEMPTS,
180
+ retryDelay: SDK_BASE_RETRY_DELAY_MS,
181
+ },
182
+ });
183
+ return createFluentClientAdapter(client, config);
184
+ }
185
+ // Path 3: command-based token retrieval (vault, secret manager, etc.).
186
+ if (config.tokenCommand) {
187
+ const token = await resolveTokenFromCommand(config.tokenCommand, config.tokenCommandTimeoutMs);
188
+ const client = await createClient({
189
+ config: {
190
+ baseUrl,
191
+ retailerId: config.retailerId ?? undefined,
192
+ timeout: config.requestTimeoutMs,
193
+ retryAttempts: SDK_RETRY_ATTEMPTS,
194
+ retryDelay: SDK_BASE_RETRY_DELAY_MS,
195
+ },
196
+ });
197
+ applyTokenBridge(client, token);
198
+ patchTokenRefreshBridge(client, config.tokenCommand, config.tokenCommandTimeoutMs);
199
+ return createFluentClientAdapter(client, config);
200
+ }
201
+ // Path 4: static bearer token.
202
+ if (config.envAccessToken) {
203
+ console.warn("[sdk-client] Using static FLUENT_ACCESS_TOKEN. " +
204
+ "No automatic refresh is available — token will expire server-side " +
205
+ "according to its original grant TTL. Prefer OAuth credentials for " +
206
+ "long-running sessions.");
207
+ const client = await createClient({
208
+ config: {
209
+ baseUrl,
210
+ retailerId: config.retailerId ?? undefined,
211
+ timeout: config.requestTimeoutMs,
212
+ retryAttempts: SDK_RETRY_ATTEMPTS,
213
+ retryDelay: SDK_BASE_RETRY_DELAY_MS,
214
+ },
215
+ });
216
+ applyTokenBridge(client, config.envAccessToken, STATIC_TOKEN_TTL_MS);
217
+ return createFluentClientAdapter(client, config);
218
+ }
219
+ // Path 5: no auth. Client can still be useful for read-only local checks,
220
+ // but API tools will fail until auth is configured.
221
+ const client = await createClient({
222
+ config: {
223
+ baseUrl,
224
+ retailerId: config.retailerId ?? undefined,
225
+ timeout: config.requestTimeoutMs,
226
+ retryAttempts: SDK_RETRY_ATTEMPTS,
227
+ retryDelay: SDK_BASE_RETRY_DELAY_MS,
228
+ },
229
+ });
230
+ return createFluentClientAdapter(client, config);
231
+ }
232
+ catch (error) {
233
+ const message = error instanceof Error ? error.message : String(error);
234
+ console.error(`[sdk-client] failed to initialize SDK client: ${message}`);
235
+ return null;
236
+ }
237
+ }