@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/uploads.ts CHANGED
@@ -12,10 +12,39 @@
12
12
  * are confined to HUBFLUENCER_INPUT_DIR (or cwd) and an extension allowlist.
13
13
  */
14
14
 
15
+ import { createReadStream, type ReadStream } from "node:fs";
15
16
  import { open, readFile, stat } from "node:fs/promises";
16
17
  import { basename, extname, isAbsolute, resolve, sep } from "node:path";
17
18
  import type { HubfluencerClient } from "./client.js";
18
- import { assertSafeFetchUrl } from "./core.js";
19
+ import {
20
+ assertSafeFetchUrl,
21
+ backoffDelayMs,
22
+ DEFAULT_RETRY,
23
+ isRetryableNetworkError,
24
+ } from "./core.js";
25
+ import { USER_AGENT } from "./version.js";
26
+
27
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
28
+
29
+ /**
30
+ * Progress sink for the upload phase, bound by the caller to the MCP progress
31
+ * helper. Lives as a plain callback (not the SDK's `reportProgress`) so uploads.ts
32
+ * stays free of the index.ts dependency — index.ts boots a live stdio server at
33
+ * module load. Best-effort: index.ts's reportProgress already swallows its own
34
+ * failures, so this never needs to.
35
+ */
36
+ export type UploadProgress = (
37
+ completed: number,
38
+ total: number,
39
+ message: string,
40
+ ) => Promise<void> | void;
41
+
42
+ // How many extra attempts a single multipart part gets after its first PUT
43
+ // fails: the part URL is re-signed before each retry (the old presign may have
44
+ // expired, or the failure may be specific to that signed URL). 2 retries = 3
45
+ // total PUT attempts per part, mirroring DEFAULT_RETRY.attempts for GETs. A part
46
+ // PUT is idempotent (same bytes, same object/part), so re-uploading it is safe.
47
+ const PART_RETRIES = 2;
19
48
 
20
49
  // Server-returned presign/download URLs are normally on R2/S3 over https. Allow
21
50
  // loopback http only when the API base itself is local (dev MinIO/localstack),
@@ -29,9 +58,10 @@ const ALLOW_LOOPBACK_FETCH =
29
58
  // Mirror the server-side constants (editor_upload.ex / editor_asset_controller.ex)
30
59
  // so we fail fast client-side with a clear message instead of after a wasted PUT.
31
60
  const MAX_VIDEO_BYTES = 500_000_000; // EditorUpload.@max_upload_bytes
32
- const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // editor_asset @max_image_size
33
- const MAX_LOGO_BYTES = 20 * 1024 * 1024; // logos go through the image inspector too
61
+ const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // editor_asset @max_image_size (logos share this inspector)
34
62
  const MULTIPART_THRESHOLD = 50 * 1024 * 1024; // @multipart_min_size — at/above this, use multipart
63
+ const MAX_CATALOG_VIDEO_DURATION_SECONDS = 60; // CatalogAsset video validator
64
+ type FilePutBody = { filePath: string; size: number };
35
65
 
36
66
  const VIDEO_EXT_MIME: Record<string, string> = {
37
67
  mp4: "video/mp4",
@@ -51,6 +81,22 @@ const IMAGE_EXT_MIME: Record<string, string> = {
51
81
  export const VIDEO_EXTS = Object.keys(VIDEO_EXT_MIME);
52
82
  export const IMAGE_EXTS = Object.keys(IMAGE_EXT_MIME);
53
83
 
84
+ // The reusable ASSET CATALOG (catalog_asset.ex) accepts BOTH images and videos:
85
+ // image/jpeg,image/png (≤20 MiB) + video/mp4,quicktime,webm,x-matroska (≤500 MB).
86
+ // One combined ext→mime map so a single upload primitive covers the catalog; the
87
+ // per-type byte cap is resolved by `catalogMaxBytes` below, mirroring
88
+ // CatalogAsset.max_upload_bytes/1 so we fail fast client-side.
89
+ const CATALOG_EXT_MIME: Record<string, string> = {
90
+ ...IMAGE_EXT_MIME,
91
+ ...VIDEO_EXT_MIME,
92
+ };
93
+ export const CATALOG_ASSET_EXTS = Object.keys(CATALOG_EXT_MIME);
94
+
95
+ /** The catalog byte cap for a resolved mime — 20 MiB for images, 500 MB for video. */
96
+ function catalogMaxBytes(mime: string): number {
97
+ return mime.startsWith("video/") ? MAX_VIDEO_BYTES : MAX_IMAGE_BYTES;
98
+ }
99
+
54
100
  export interface ResolvedFile {
55
101
  path: string;
56
102
  ext: string;
@@ -95,6 +141,27 @@ export function planMultipartParts(
95
141
  return parts;
96
142
  }
97
143
 
144
+ /**
145
+ * Guards a multipart part read: `fh.read` can return fewer bytes than requested
146
+ * (the file shrank/was truncated under us, or the plan disagrees with the file's
147
+ * real size). A short read would leave the tail of the part buffer zero-filled
148
+ * and upload a silently-corrupt part (the confirm still succeeds — the asset is
149
+ * garbage), so fail loudly instead. Pure + total so the check is unit-testable
150
+ * without the filesystem or the network, like `planMultipartParts`.
151
+ */
152
+ export function assertFullRead(
153
+ bytesRead: number,
154
+ len: number,
155
+ partNumber: number,
156
+ offset: number,
157
+ ): void {
158
+ if (bytesRead !== len) {
159
+ throw new Error(
160
+ `Short read on part ${partNumber}: got ${bytesRead} of ${len} bytes at offset ${offset} (file changed under us?).`,
161
+ );
162
+ }
163
+ }
164
+
98
165
  /**
99
166
  * Confines a model-supplied read path to an allowlisted input base and an
100
167
  * extension allowlist, then stats it. Base = HUBFLUENCER_INPUT_DIR if set, else
@@ -146,49 +213,87 @@ export async function resolveReadPath(
146
213
  return { path: target, ext, mime, size };
147
214
  }
148
215
 
216
+ /**
217
+ * Validates a model-supplied video read path with the SAME ext allowlist + size
218
+ * cap `uploadVideoFile` will use, without leaking the underlying constants. Lets
219
+ * a caller fail fast on an invalid path BEFORE a side effect (e.g. creating a
220
+ * draft project) instead of orphaning it when `uploadVideoFile` later rejects.
221
+ */
222
+ export async function resolveVideoReadPath(
223
+ filePath: string,
224
+ ): Promise<ResolvedFile> {
225
+ return resolveReadPath(filePath, VIDEO_EXT_MIME, MAX_VIDEO_BYTES);
226
+ }
227
+
149
228
  /**
150
229
  * PUTs bytes to a presigned S3/R2 URL. `contentType` is sent ONLY when the URL
151
230
  * signed it (single-object presign does — see S3Client.presign_put's
152
231
  * query_params; multipart part presign does NOT, so callers omit it there to
153
- * avoid sending an unsigned header). Returns the object/part ETag from the
154
- * response (needed to complete a multipart upload).
232
+ * avoid sending an unsigned header). `contentLength` is supplied for file streams
233
+ * so large catalog uploads use a file-backed body without relying on chunked
234
+ * transfer. Returns the object/part ETag from the response (needed to complete a
235
+ * multipart upload).
155
236
  */
156
237
  export async function putToPresignedUrl(
157
238
  url: string,
158
- body: Buffer,
239
+ body: Buffer | FilePutBody,
159
240
  contentType: string | undefined,
160
241
  timeoutMs = 300_000,
242
+ contentLength?: number,
161
243
  ): Promise<string | undefined> {
162
244
  // The presigned URL comes back in API JSON — refuse to PUT bytes to an
163
245
  // insecure or private/internal host (SSRF / cleartext exfil of file bytes).
164
246
  assertSafeFetchUrl(url, { allowLoopback: ALLOW_LOOPBACK_FETCH });
165
- const headers: Record<string, string> = {};
247
+ // user-agent is not part of the presigned SignedHeaders, so S3/R2 ignores it
248
+ // for signature purposes — safe to send on both single-object and part PUTs.
249
+ const headers: Record<string, string> = { "user-agent": USER_AGENT };
166
250
  if (contentType) headers["content-type"] = contentType;
167
- // Pass an exact ArrayBuffer slice — the generic Uint8Array<ArrayBufferLike>
168
- // type doesn't resolve cleanly against fetch's BodyInit union, but a plain
169
- // ArrayBuffer is unambiguous (and this is a one-time copy of a bounded chunk).
170
- const ab = body.buffer.slice(
171
- body.byteOffset,
172
- body.byteOffset + body.byteLength,
173
- ) as ArrayBuffer;
174
- let resp: Response;
175
- try {
176
- resp = await fetch(url, {
251
+ let fetchBody: BodyInit;
252
+ let fetchInit: RequestInit & { duplex?: "half" };
253
+ let stream: ReadStream | undefined;
254
+ if (Buffer.isBuffer(body)) {
255
+ // Pass an exact ArrayBuffer slice — the generic Uint8Array<ArrayBufferLike>
256
+ // type doesn't resolve cleanly against fetch's BodyInit union, but a plain
257
+ // ArrayBuffer is unambiguous (and this is a one-time copy of a bounded chunk).
258
+ fetchBody = body.buffer.slice(
259
+ body.byteOffset,
260
+ body.byteOffset + body.byteLength,
261
+ ) as ArrayBuffer;
262
+ fetchInit = { method: "PUT", headers, body: fetchBody };
263
+ } else {
264
+ stream = createReadStream(body.filePath);
265
+ headers["content-length"] = String(contentLength ?? body.size);
266
+ fetchInit = {
177
267
  method: "PUT",
178
268
  headers,
179
- body: ab,
180
- signal: AbortSignal.timeout(timeoutMs),
181
- });
269
+ body: stream as unknown as BodyInit,
270
+ duplex: "half",
271
+ };
272
+ }
273
+ fetchInit.signal = AbortSignal.timeout(timeoutMs);
274
+ let resp: Response;
275
+ try {
276
+ resp = await fetch(url, fetchInit);
182
277
  } catch (e) {
278
+ stream?.destroy();
279
+ // A connection-level blip or timeout on a PUT is transient — re-wrap it with
280
+ // the `retryable` marker so a caller (uploadPartWithRetry) can re-sign and
281
+ // retry the part. The friendly message would otherwise erase the original
282
+ // error name isRetryableNetworkError keys on.
183
283
  if (
184
284
  e instanceof Error &&
185
285
  (e.name === "TimeoutError" || e.name === "AbortError")
186
286
  ) {
187
- throw new Error(`Upload PUT timed out after ${timeoutMs / 1000}s.`);
287
+ throw markRetryable(`Upload PUT timed out after ${timeoutMs / 1000}s.`);
188
288
  }
189
- throw e instanceof Error ? new Error(`Upload PUT failed: ${e.message}`) : e;
289
+ throw e instanceof Error
290
+ ? markRetryable(`Upload PUT failed: ${e.message}`)
291
+ : e;
190
292
  }
191
293
  if (!resp.ok) {
294
+ // An HTTP-status rejection from S3 (bad/expired signature, 4xx) is NOT
295
+ // marked retryable — re-uploading the same bytes would just be rejected the
296
+ // same way. Only the transient connection failures above are retried.
192
297
  const text = await resp.text().catch(() => "");
193
298
  throw new Error(
194
299
  `Upload PUT rejected (HTTP ${resp.status})${text ? `: ${text.slice(0, 200)}` : ""}.`,
@@ -197,6 +302,11 @@ export async function putToPresignedUrl(
197
302
  return resp.headers.get("etag") ?? undefined;
198
303
  }
199
304
 
305
+ /** An Error carrying the `retryable` marker isRetryableNetworkError honours. */
306
+ function markRetryable(message: string): Error {
307
+ return Object.assign(new Error(message), { retryable: true });
308
+ }
309
+
200
310
  interface PresignVideoResponse {
201
311
  data: { upload_id: number | string; presigned_url: string; s3_key: string };
202
312
  }
@@ -232,7 +342,11 @@ export async function uploadVideoFile(
232
342
  client: HubfluencerClient,
233
343
  slug: string,
234
344
  filePath: string,
235
- opts: { fitMode?: string; productDescription?: string } = {},
345
+ opts: {
346
+ fitMode?: string;
347
+ productDescription?: string;
348
+ onProgress?: UploadProgress;
349
+ } = {},
236
350
  ): Promise<UploadedVideo> {
237
351
  const file = await resolveReadPath(filePath, VIDEO_EXT_MIME, MAX_VIDEO_BYTES);
238
352
  const filename = basename(file.path);
@@ -240,11 +354,17 @@ export async function uploadVideoFile(
240
354
  const product_description = opts.productDescription;
241
355
 
242
356
  if (file.size >= MULTIPART_THRESHOLD) {
243
- return uploadVideoMultipart(client, slug, file, {
244
- filename,
245
- fit_mode,
246
- product_description,
247
- });
357
+ return uploadVideoMultipart(
358
+ client,
359
+ slug,
360
+ file,
361
+ {
362
+ filename,
363
+ fit_mode,
364
+ product_description,
365
+ },
366
+ opts.onProgress,
367
+ );
248
368
  }
249
369
 
250
370
  const presign = await client.post<PresignVideoResponse>(
@@ -259,20 +379,73 @@ export async function uploadVideoFile(
259
379
  );
260
380
  const { upload_id, presigned_url } = presign.data;
261
381
  const buf = await readFile(file.path);
382
+ // A small file is one PUT — report it as a single 1-of-1 step so the caller
383
+ // still gets a progress beat during the transfer.
384
+ await opts.onProgress?.(0, 1, `uploading ${filename}`);
262
385
  // Content-Type MUST equal the presigned mime exactly — confirm HEADs the
263
386
  // object and 422s on mismatch (editor/uploads.ex).
264
387
  await putToPresignedUrl(presigned_url, buf, file.mime);
388
+ await opts.onProgress?.(1, 1, `uploaded ${filename}, confirming`);
265
389
  const confirmed = await client.post<ConfirmUploadResponse>(
266
390
  `/editor/${slug}/uploads/${upload_id}/confirm`,
267
391
  );
268
392
  return { upload_id, status: confirmed.data?.status ?? "processing" };
269
393
  }
270
394
 
395
+ /**
396
+ * PUTs one multipart part, re-signing the part URL before each retry. A part PUT
397
+ * is idempotent (same bytes → same object part), so a transient network failure
398
+ * or an expired presign is safe to retry: we ask the server for a FRESH signed
399
+ * URL each attempt (the previous one may have timed out) and re-PUT. After the
400
+ * final attempt the error propagates so the caller aborts the whole upload.
401
+ * Returns the part's ETag (needed to complete the multipart upload).
402
+ */
403
+ async function uploadPartWithRetry(
404
+ client: HubfluencerClient,
405
+ slug: string,
406
+ ids: { upload_id: number | string; s3_upload_id: string },
407
+ part_number: number,
408
+ chunk: Buffer,
409
+ ): Promise<string> {
410
+ for (let attempt = 1; ; attempt++) {
411
+ try {
412
+ const sign = await client.post<SignPartResponse>(
413
+ `/editor/${slug}/uploads/multipart/sign-part`,
414
+ {
415
+ upload_id: ids.upload_id,
416
+ s3_upload_id: ids.s3_upload_id,
417
+ part_number,
418
+ },
419
+ );
420
+ // No content-type here — the part presign does not sign one.
421
+ const etag = await putToPresignedUrl(
422
+ sign.data.presigned_url,
423
+ chunk,
424
+ undefined,
425
+ );
426
+ if (!etag) {
427
+ throw new Error(`Part ${part_number} returned no ETag from S3.`);
428
+ }
429
+ return etag;
430
+ } catch (e) {
431
+ // Retry only a transient network/timeout failure, and only while attempts
432
+ // remain. A definite rejection (e.g. a 4xx from S3, or a sign-part error
433
+ // the client already classified as non-retryable) propagates immediately.
434
+ if (attempt <= PART_RETRIES && isRetryableNetworkError(e)) {
435
+ await sleep(backoffDelayMs(attempt, DEFAULT_RETRY));
436
+ continue;
437
+ }
438
+ throw e;
439
+ }
440
+ }
441
+ }
442
+
271
443
  async function uploadVideoMultipart(
272
444
  client: HubfluencerClient,
273
445
  slug: string,
274
446
  file: ResolvedFile,
275
447
  meta: { filename: string; fit_mode?: string; product_description?: string },
448
+ onProgress?: UploadProgress,
276
449
  ): Promise<UploadedVideo> {
277
450
  const init = await client.post<MultipartInitResponse>(
278
451
  `/editor/${slug}/uploads/multipart/init`,
@@ -294,26 +467,35 @@ async function uploadVideoMultipart(
294
467
  `Multipart plan mismatch: server expects ${parts_count} parts, computed ${plan.length} for ${file.size} bytes at ${part_size}/part.`,
295
468
  );
296
469
  }
470
+ await onProgress?.(
471
+ 0,
472
+ plan.length,
473
+ `uploading ${meta.filename} in ${plan.length} parts`,
474
+ );
297
475
  const fh = await open(file.path, "r");
298
476
  const parts: Array<{ part_number: number; etag: string }> = [];
299
477
  try {
300
478
  for (const { part_number, offset, len } of plan) {
301
479
  const chunk = Buffer.alloc(len);
302
- await fh.read(chunk, 0, len, offset);
303
- const sign = await client.post<SignPartResponse>(
304
- `/editor/${slug}/uploads/multipart/sign-part`,
305
- { upload_id, s3_upload_id, part_number },
306
- );
307
- // No content-type here — the part presign does not sign one.
308
- const etag = await putToPresignedUrl(
309
- sign.data.presigned_url,
480
+ // Verify the read filled the whole part — a short read would upload a
481
+ // zero-padded, silently-corrupt part (see assertFullRead).
482
+ const { bytesRead } = await fh.read(chunk, 0, len, offset);
483
+ assertFullRead(bytesRead, len, part_number, offset);
484
+ const etag = await uploadPartWithRetry(
485
+ client,
486
+ slug,
487
+ { upload_id, s3_upload_id },
488
+ part_number,
310
489
  chunk,
311
- undefined,
312
490
  );
313
- if (!etag) {
314
- throw new Error(`Part ${part_number} returned no ETag from S3.`);
315
- }
316
491
  parts.push({ part_number, etag });
492
+ // Report after each part lands, so a long large-file upload shows steady
493
+ // forward progress (N of M parts) rather than a single silent stall.
494
+ await onProgress?.(
495
+ part_number,
496
+ plan.length,
497
+ `uploaded part ${part_number}/${plan.length}`,
498
+ );
317
499
  }
318
500
  const done = await client.post<ConfirmUploadResponse>(
319
501
  `/editor/${slug}/uploads/multipart/complete`,
@@ -390,5 +572,116 @@ export async function uploadShortPoster(
390
572
  return { s3_key };
391
573
  }
392
574
 
393
- export const LOGO_MAX_BYTES = MAX_LOGO_BYTES;
394
- export const IMAGE_MAX_BYTES = MAX_IMAGE_BYTES;
575
+ interface CatalogPresignResponse {
576
+ data: {
577
+ asset_id: number | string;
578
+ presigned_url: string;
579
+ s3_key: string;
580
+ expires_in_seconds?: number;
581
+ };
582
+ }
583
+
584
+ export interface UploadedCatalogAsset {
585
+ asset_id: number | string;
586
+ status: string;
587
+ media_type: string;
588
+ mime_type: string;
589
+ filename: string;
590
+ size_bytes: number;
591
+ }
592
+
593
+ /**
594
+ * Uploads a local file to the reusable ASSET CATALOG (catalog_asset.ex) and
595
+ * confirms it: presign (`POST /assets/presign`) → PUT the bytes to the signed
596
+ * URL → confirm (`POST /assets/:id/confirm`). Reuses the same path-confinement +
597
+ * SSRF-safe PUT primitives as the editor uploads (`resolveReadPath`,
598
+ * `putToPresignedUrl`); the ONLY difference from the editor asset flow is the
599
+ * envelope shape (asset_id + a separate `/assets/:id/confirm`).
600
+ *
601
+ * The catalog presign signs the content-type (S3Client.presign_put content_type),
602
+ * so — unlike a multipart part — the PUT MUST send it; the confirm HEADs the
603
+ * object and 422s on a mismatch. For a VIDEO the confirm needs a
604
+ * `duration_seconds` (the server rejects a video with no/over-long duration,
605
+ * `:invalid_video_duration`); the caller passes it through `opts`. Returns the
606
+ * confirmed asset's id + status + media/mime so the tool can report it.
607
+ */
608
+ export async function uploadCatalogAsset(
609
+ client: HubfluencerClient,
610
+ filePath: string,
611
+ opts: {
612
+ description?: string;
613
+ width?: number;
614
+ height?: number;
615
+ durationSeconds?: number;
616
+ } = {},
617
+ ): Promise<UploadedCatalogAsset> {
618
+ // Resolve against the larger (video) ceiling first so a valid image/video
619
+ // path passes the confinement + extension check, then enforce the true
620
+ // per-type cap (images are capped lower) with a precise message.
621
+ const file = await resolveReadPath(
622
+ filePath,
623
+ CATALOG_EXT_MIME,
624
+ MAX_VIDEO_BYTES,
625
+ );
626
+ const cap = catalogMaxBytes(file.mime);
627
+ if (file.size > cap) {
628
+ throw new Error(
629
+ `${file.path} is ${file.size} bytes — over the ${cap}-byte cap for ${file.mime} catalog uploads.`,
630
+ );
631
+ }
632
+ if (file.mime.startsWith("video/")) {
633
+ const duration = opts.durationSeconds;
634
+ if (
635
+ typeof duration !== "number" ||
636
+ !Number.isFinite(duration) ||
637
+ duration <= 0 ||
638
+ duration > MAX_CATALOG_VIDEO_DURATION_SECONDS
639
+ ) {
640
+ throw new Error(
641
+ `Catalog video uploads require durationSeconds to be greater than 0 and no more than ${MAX_CATALOG_VIDEO_DURATION_SECONDS} seconds.`,
642
+ );
643
+ }
644
+ }
645
+ const filename = basename(file.path);
646
+ const presign = await client.post<CatalogPresignResponse>("/assets/presign", {
647
+ filename,
648
+ mime_type: file.mime,
649
+ size_bytes: file.size,
650
+ });
651
+ const { asset_id, presigned_url } = presign.data;
652
+ // Content-Type MUST equal the presigned mime exactly — the catalog presign
653
+ // signs it and confirm HEADs the object, 422ing on mismatch.
654
+ if (file.size >= MULTIPART_THRESHOLD) {
655
+ await putToPresignedUrl(
656
+ presigned_url,
657
+ { filePath: file.path, size: file.size },
658
+ file.mime,
659
+ 300_000,
660
+ file.size,
661
+ );
662
+ } else {
663
+ const buf = await readFile(file.path);
664
+ await putToPresignedUrl(presigned_url, buf, file.mime);
665
+ }
666
+ const confirmBody: Record<string, unknown> = {};
667
+ if (typeof opts.description === "string" && opts.description.trim() !== "")
668
+ confirmBody.description = opts.description;
669
+ if (typeof opts.width === "number") confirmBody.width = opts.width;
670
+ if (typeof opts.height === "number") confirmBody.height = opts.height;
671
+ if (typeof opts.durationSeconds === "number")
672
+ confirmBody.duration_seconds = opts.durationSeconds;
673
+ const confirmed = await client.post<{ data: Record<string, unknown> }>(
674
+ `/assets/${asset_id}/confirm`,
675
+ confirmBody,
676
+ );
677
+ const data = confirmed.data ?? {};
678
+ return {
679
+ asset_id,
680
+ status: typeof data.status === "string" ? data.status : "ready",
681
+ media_type: typeof data.media_type === "string" ? data.media_type : "",
682
+ mime_type: typeof data.mime_type === "string" ? data.mime_type : file.mime,
683
+ filename: typeof data.filename === "string" ? data.filename : filename,
684
+ size_bytes:
685
+ typeof data.size_bytes === "number" ? data.size_bytes : file.size,
686
+ };
687
+ }
package/src/version.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Single source of truth for the package version.
3
+ *
4
+ * Imported from package.json so the version is declared in exactly one place —
5
+ * the McpServer handshake, the outbound `User-Agent`, and the published package
6
+ * can never drift apart. `bun build --target node` inlines this JSON into the
7
+ * single-file bundle (no runtime file read), and `tsc` resolves it via
8
+ * `resolveJsonModule`. A plain default import (no `with { type: "json" }`
9
+ * attribute) is used deliberately: import attributes require `module: nodenext`,
10
+ * but this package targets `Node16`.
11
+ */
12
+
13
+ import pkg from "../package.json";
14
+
15
+ export const VERSION: string = pkg.version;
16
+
17
+ /** The `User-Agent` sent on every outbound HTTP request (API + S3 + download). */
18
+ export const USER_AGENT = `hubfluencer-mcp/${VERSION}`;