@hubfluencer/mcp 0.5.0 → 0.7.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/README.md +190 -32
- package/dist/index.js +2835 -144
- package/package.json +9 -1
- package/src/campaign.ts +797 -0
- package/src/client.ts +195 -36
- package/src/core.ts +633 -24
- package/src/index.ts +4809 -1596
- package/src/output-schemas.ts +360 -0
- package/src/perf-review-assets.ts +470 -0
- package/src/series-calendar.ts +351 -0
- package/src/uploads.ts +335 -42
- package/src/version.ts +18 -0
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 {
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
(
|
|
262
|
+
attempt < attempts &&
|
|
263
|
+
isRetryableStatus(res.status) &&
|
|
264
|
+
!deadlinePassed(deadline)
|
|
129
265
|
) {
|
|
130
|
-
|
|
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
|
-
|
|
271
|
+
if (res.status === 204) return undefined as T;
|
|
138
272
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
150
|
-
|
|
283
|
+
if (!res.ok) throw makeError(res.status, parsed);
|
|
284
|
+
return parsed as T;
|
|
285
|
+
}
|
|
151
286
|
}
|
|
152
287
|
|
|
153
|
-
get<T = unknown>(
|
|
154
|
-
|
|
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
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
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
|
}
|