@hubfluencer/mcp 0.4.0 → 0.6.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.
package/src/client.ts CHANGED
@@ -6,8 +6,21 @@
6
6
  * as `Authorization: Bearer <token>`. The token is NOT a JWT — never decode it.
7
7
  */
8
8
 
9
- import { assertSafeFetchUrl } from "./core.js";
9
+ import {
10
+ assertSafeFetchUrl,
11
+ backoffDelayMs,
12
+ DEFAULT_RETRY,
13
+ isRetryableNetworkError,
14
+ isRetryableStatus,
15
+ } from "./core.js";
10
16
  import { readStoredCredentials } from "./credentials.js";
17
+ import { USER_AGENT } from "./version.js";
18
+
19
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
20
+
21
+ /** True when a poll deadline is set and already in the past. */
22
+ const deadlinePassed = (deadline: number | undefined): boolean =>
23
+ deadline !== undefined && Date.now() >= deadline;
11
24
 
12
25
  export interface HubfluencerError extends Error {
13
26
  status: number;
@@ -15,6 +28,32 @@ export interface HubfluencerError extends Error {
15
28
  body?: unknown;
16
29
  }
17
30
 
31
+ /**
32
+ * Flattens a Phoenix changeset error map (`%{field => [msgs]}`) into one readable
33
+ * line: `"validation failed — email: can't be blank; name: is too short"`. Field
34
+ * order is stabilized (alphabetical) so the message is deterministic across
35
+ * retries/tests regardless of JSON key order. Each field's messages are joined
36
+ * with ", "; fields are joined with "; ". A non-array value is coerced to its
37
+ * string form so an unexpected shape still yields detail rather than "[object
38
+ * Object]" swallowing it. Returns null when nothing usable can be extracted.
39
+ */
40
+ function composeChangesetMessage(
41
+ errors: Record<string, unknown>,
42
+ ): string | null {
43
+ const parts: string[] = [];
44
+ for (const field of Object.keys(errors).sort()) {
45
+ const val = errors[field];
46
+ const msgs = Array.isArray(val)
47
+ ? val.map((m) => String(m)).filter((m) => m.length > 0)
48
+ : val !== undefined && val !== null && String(val).length > 0
49
+ ? [String(val)]
50
+ : [];
51
+ if (msgs.length > 0) parts.push(`${field}: ${msgs.join(", ")}`);
52
+ }
53
+ if (parts.length === 0) return null;
54
+ return `validation failed — ${parts.join("; ")}`;
55
+ }
56
+
18
57
  function makeError(status: number, body: unknown): HubfluencerError {
19
58
  // The API's error envelope is not fully uniform; pull a message/code out of
20
59
  // whatever shape we got.
@@ -44,6 +83,20 @@ function makeError(status: number, body: unknown): HubfluencerError {
44
83
  message = (e.message as string) || message;
45
84
  } else if (typeof b.message === "string") {
46
85
  message = b.message;
86
+ } else if (b.errors && typeof b.errors === "object") {
87
+ // The Phoenix changeset envelope: %{errors: %{field => [msgs]}} with NO
88
+ // top-level error/message. Without this branch a 422 validation failure
89
+ // surfaces as the bare fallback ("Hubfluencer API error (HTTP 422)") with
90
+ // an undefined code and the per-field detail lost. Flatten it into a
91
+ // readable, deterministic message ("validation failed — field: msg;
92
+ // field2: msg") and give it a stable code an agent can branch on.
93
+ const composed = composeChangesetMessage(
94
+ b.errors as Record<string, unknown>,
95
+ );
96
+ if (composed) {
97
+ message = composed;
98
+ code = "validation_failed";
99
+ }
47
100
  }
48
101
  }
49
102
 
@@ -73,8 +126,25 @@ export interface RequestOptions {
73
126
  /** Sent as the `Idempotency-Key` header on chargeable POSTs. */
74
127
  idempotencyKey?: string;
75
128
  query?: Record<string, string | number | undefined>;
129
+ /**
130
+ * Absolute wall-clock deadline (epoch ms) that bounds this GET's *entire*
131
+ * retry sequence, used on the poll path. Without it, a GET can retry up to
132
+ * `DEFAULT_RETRY.attempts` times at 60s each (~180s of hard timeouts) — enough
133
+ * to push a single poll iteration well past the caller's own budget and blow
134
+ * the ~280s wait clamp that keeps a tool call under a 300s client timeout. With
135
+ * it, each attempt's timeout is capped to the remaining budget and no further
136
+ * retry is started once the deadline has passed. Ignored for non-GET methods.
137
+ */
138
+ deadline?: number;
76
139
  }
77
140
 
141
+ // Floor for a poll-path attempt's timeout so, even with almost no budget left, we
142
+ // still fire one real request (giving the last poll iteration its status read) —
143
+ // rather than a sub-second fetch that is doomed to abort. The retry-stop guards do
144
+ // the actual overshoot-bounding; this just keeps that final attempt viable.
145
+ const MIN_ATTEMPT_TIMEOUT_MS = 1_000;
146
+ const DEFAULT_ATTEMPT_TIMEOUT_MS = 60_000;
147
+
78
148
  export class HubfluencerClient {
79
149
  constructor(
80
150
  private readonly baseUrl: string,
@@ -106,52 +176,121 @@ export class HubfluencerClient {
106
176
  const headers: Record<string, string> = {
107
177
  authorization: `Bearer ${this.token}`,
108
178
  accept: "application/json",
179
+ "user-agent": USER_AGENT,
109
180
  };
110
181
  if (opts.body !== undefined) headers["content-type"] = "application/json";
111
182
  if (opts.idempotencyKey) headers["idempotency-key"] = opts.idempotencyKey;
112
183
 
184
+ // Retry only idempotent GETs. A POST/PATCH/PUT/DELETE is never auto-retried:
185
+ // a blind retry of a create/render/charge could double-spend credits or fork
186
+ // a duplicate resource. GETs are safe to replay, so one transient blip (a
187
+ // dropped connection, a 502, a brief 429) backs off and retries instead of
188
+ // failing the whole tool call — and, crucially, the GET that every poll-loop
189
+ // iteration issues (fetchStatus) rides on this too.
190
+ const canRetry = method === "GET";
191
+ const attempts = canRetry ? DEFAULT_RETRY.attempts : 1;
192
+ // A poll-path GET carries an absolute deadline that bounds the whole retry
193
+ // sequence; non-GET methods never retry, so their deadline is meaningless
194
+ // and ignored (a create/render must not be abandoned mid-flight).
195
+ const deadline = canRetry ? opts.deadline : undefined;
196
+
113
197
  // Bound every API call so a hung connection fails the tool cleanly
114
198
  // instead of blocking the agent indefinitely (fetch has no default
115
199
  // timeout). All endpoints return quickly — generation runs async server
116
- // side, so 60s is generous headroom.
117
- let res: Response;
118
- try {
119
- res = await fetch(url, {
120
- method,
121
- headers,
122
- body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
123
- signal: AbortSignal.timeout(60_000),
124
- });
125
- } catch (e) {
200
+ // side, so 60s is generous headroom. On the poll path we additionally
201
+ // shrink each attempt's timeout to whatever budget remains before the
202
+ // deadline, so the retry sequence can't overrun the caller's wait clamp.
203
+ for (let attempt = 1; ; attempt++) {
204
+ let attemptTimeoutMs = DEFAULT_ATTEMPT_TIMEOUT_MS;
205
+ if (deadline !== undefined) {
206
+ // Cap this attempt's timeout to whatever budget remains (floored at a
207
+ // small minimum so we still fire one real request when nearly out of
208
+ // budget rather than a doomed sub-second fetch). Combined with the
209
+ // retry-stop guards below — no further attempt starts once the deadline
210
+ // has passed — this bounds the whole GET to a SINGLE ~remaining-budget
211
+ // attempt near the deadline instead of stacking 3×60s of timeouts past
212
+ // it, which would blow the caller's ~280s wait clamp.
213
+ const remaining = deadline - Date.now();
214
+ attemptTimeoutMs = Math.max(
215
+ MIN_ATTEMPT_TIMEOUT_MS,
216
+ Math.min(DEFAULT_ATTEMPT_TIMEOUT_MS, remaining),
217
+ );
218
+ }
219
+
220
+ let res: Response;
221
+ try {
222
+ res = await fetch(url, {
223
+ method,
224
+ headers,
225
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
226
+ signal: AbortSignal.timeout(attemptTimeoutMs),
227
+ });
228
+ } catch (e) {
229
+ // A network-layer failure or timeout on a GET with attempts left: back
230
+ // off and retry — but never once the poll deadline has passed (a retry
231
+ // then would only push the call further past its budget). Otherwise
232
+ // wrap and throw exactly as before.
233
+ if (
234
+ attempt < attempts &&
235
+ isRetryableNetworkError(e) &&
236
+ !deadlinePassed(deadline)
237
+ ) {
238
+ await sleep(backoffDelayMs(attempt, DEFAULT_RETRY));
239
+ continue;
240
+ }
241
+ if (
242
+ e instanceof Error &&
243
+ (e.name === "TimeoutError" || e.name === "AbortError")
244
+ ) {
245
+ // Report the ACTUAL per-attempt timeout, not a hardcoded 60s: on the
246
+ // poll path the deadline can clamp this attempt to whatever budget
247
+ // remained, so a "timed out after 60s" message would misreport a
248
+ // sub-60s abort and mislead debugging.
249
+ throw new Error(
250
+ `Request to ${method} ${path} timed out after ${attemptTimeoutMs}ms.`,
251
+ );
252
+ }
253
+ throw e instanceof Error
254
+ ? new Error(`Network error on ${method} ${path}: ${e.message}`)
255
+ : e;
256
+ }
257
+
258
+ // A 5xx/429 on a GET with attempts left: drain the body (free the
259
+ // connection) and retry — unless the poll deadline has passed. The final
260
+ // attempt (or a passed deadline) falls through to makeError.
126
261
  if (
127
- e instanceof Error &&
128
- (e.name === "TimeoutError" || e.name === "AbortError")
262
+ attempt < attempts &&
263
+ isRetryableStatus(res.status) &&
264
+ !deadlinePassed(deadline)
129
265
  ) {
130
- throw new Error(`Request to ${method} ${path} timed out after 60s.`);
266
+ await res.text().catch(() => {});
267
+ await sleep(backoffDelayMs(attempt, DEFAULT_RETRY));
268
+ continue;
131
269
  }
132
- throw e instanceof Error
133
- ? new Error(`Network error on ${method} ${path}: ${e.message}`)
134
- : e;
135
- }
136
270
 
137
- if (res.status === 204) return undefined as T;
271
+ if (res.status === 204) return undefined as T;
138
272
 
139
- const text = await res.text();
140
- let parsed: unknown;
141
- if (text) {
142
- try {
143
- parsed = JSON.parse(text);
144
- } catch {
145
- parsed = text;
273
+ const text = await res.text();
274
+ let parsed: unknown;
275
+ if (text) {
276
+ try {
277
+ parsed = JSON.parse(text);
278
+ } catch {
279
+ parsed = text;
280
+ }
146
281
  }
147
- }
148
282
 
149
- if (!res.ok) throw makeError(res.status, parsed);
150
- return parsed as T;
283
+ if (!res.ok) throw makeError(res.status, parsed);
284
+ return parsed as T;
285
+ }
151
286
  }
152
287
 
153
- get<T = unknown>(path: string, query?: RequestOptions["query"]): Promise<T> {
154
- return this.request<T>("GET", path, { query });
288
+ get<T = unknown>(
289
+ path: string,
290
+ query?: RequestOptions["query"],
291
+ deadline?: number,
292
+ ): Promise<T> {
293
+ return this.request<T>("GET", path, { query, deadline });
155
294
  }
156
295
 
157
296
  post<T = unknown>(
@@ -181,6 +320,16 @@ export class HubfluencerClient {
181
320
  del<T = unknown>(path: string): Promise<T> {
182
321
  return this.request<T>("DELETE", path);
183
322
  }
323
+
324
+ /**
325
+ * The bare API origin (no trailing `/api`) — the host this client talks to.
326
+ * NOTE: this is the API host, which does NOT serve the front web app's
327
+ * no-login pages (e.g. `/review/<token>` lives on the web-app origin — see
328
+ * `reviewWebOrigin` in perf-review-assets.ts). Don't build web-app URLs on it.
329
+ */
330
+ get origin(): string {
331
+ return this.baseUrl;
332
+ }
184
333
  }
185
334
 
186
335
  /**
@@ -190,11 +339,10 @@ export class HubfluencerClient {
190
339
  */
191
340
  export function clientFromEnv(): HubfluencerClient {
192
341
  const stored = readStoredCredentials();
193
- const token = process.env.HUBFLUENCER_API_TOKEN || stored.token;
194
- const baseUrl =
195
- process.env.HUBFLUENCER_BASE_URL ||
196
- stored.base_url ||
197
- "https://hubfluencer.com";
342
+ const envToken = process.env.HUBFLUENCER_API_TOKEN;
343
+ const envBaseUrl = process.env.HUBFLUENCER_BASE_URL;
344
+ const token = envToken || stored.token;
345
+ const baseUrl = envBaseUrl || stored.base_url || "https://hubfluencer.com";
198
346
 
199
347
  if (!token) {
200
348
  throw new Error(
@@ -202,5 +350,16 @@ export function clientFromEnv(): HubfluencerClient {
202
350
  "HUBFLUENCER_API_TOKEN (create one in the app: Settings → Access tokens).",
203
351
  );
204
352
  }
353
+ // Warn (stderr only — stdout is the MCP transport) when the token comes from the
354
+ // env but the base URL is inherited from the stored credentials file rather than
355
+ // the env: a fresh env PAT can then be silently sent to a stale/other origin (e.g.
356
+ // a leftover staging base_url in credentials.json). Naming the effective base lets
357
+ // the operator catch a wrong-origin mix before it matters.
358
+ if (envToken && !envBaseUrl && stored.base_url) {
359
+ console.error(
360
+ `Warning: using HUBFLUENCER_API_TOKEN from the environment but base URL ${stored.base_url} ` +
361
+ "from the stored credentials file. Set HUBFLUENCER_BASE_URL to override.",
362
+ );
363
+ }
205
364
  return new HubfluencerClient(baseUrl, token);
206
365
  }