@buildinternet/uploads 0.10.1 → 0.11.1

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/dist/mcp/tools.js CHANGED
@@ -1,13 +1,5 @@
1
- /**
2
- * MCP tool set mirroring the CLI commands (put, attach, list, delete,
3
- * usage, reconcile, purge_expired, comment, health, doctor). Config is
4
- * resolved fresh per tool call so a
5
- * per-call `workspace` argument behaves like the CLI's --workspace flag, and
6
- * a missing token surfaces as a tool error rather than a startup failure.
7
- */
8
- import { basename } from "node:path";
9
1
  import { createUploadsClient } from "../client.js";
10
- import { buildDoctorReport, makeGhTarget, prepareImageForUpload, readFileArg, syncAttachmentsComment, } from "../commands.js";
2
+ import { buildDoctorReport, makeGhTarget, prepareImageForUpload, syncAttachmentsComment, uploadAttachments, uploadPuts, } from "../commands.js";
11
3
  import { resolveFrameId } from "../frame.js";
12
4
  import { resolveConfig, resolvePutDefaults, } from "../config.js";
13
5
  import { buildMarkdown } from "../embed.js";
@@ -19,6 +11,9 @@ import { rewriteKeyExtension } from "../optimize.js";
19
11
  import { buildCliProvenance } from "../provenance.js";
20
12
  import { execRunner, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
21
13
  import { METADATA_DESCRIPTION, metadataProp, optPosInt, optString, optStringArray, optStringRecord, usage, } from "./args.js";
14
+ import { batchFailureMessage, ToolBatchError } from "./server.js";
15
+ import { attachmentFromText, buildReportPayload, parseReportType, REPORT_TYPES, submitReport, validateReportMessage, } from "../report.js";
16
+ import { resolveApiUrl } from "../config.js";
22
17
  function optBool(args, name) {
23
18
  const v = args[name];
24
19
  if (v === undefined || v === null)
@@ -268,13 +263,18 @@ export function createUploadsMcpTools(opts) {
268
263
  },
269
264
  {
270
265
  name: "put",
271
- description: "Upload a file to uploads.sh and get a public URL plus GitHub-ready embed markdown. Returns `url` (durable CDN) and `embedUrl` (same object, freshness-oriented host for GitHub Camo — prefer this in PR/issue markdown). The returned `markdown` already uses embedUrl when available. Pass `file` or `contentBase64` + `filename`; with `pr`/`issue` the key is stable and `comment` syncs the managed attachments comment. All uploads are public; pr/issue keys are predictable, so upload only non-sensitive media.",
266
+ description: "Upload one or more files to uploads.sh and get public URL(s) plus GitHub-ready embed markdown. Single-file: pass `file` or `contentBase64`+`filename` (flat result with `url`/`embedUrl`/`markdown`). Multi-file: pass `files` (paths; parallel; returns `uploads`+`failures`). Prefer `embedUrl` in PR/issue markdown. With `pr`/`issue` keys are stable and `comment` syncs the managed attachments comment. All uploads are public; pr/issue keys are predictable upload only non-sensitive media.",
272
267
  inputSchema: {
273
268
  type: "object",
274
269
  properties: {
275
270
  file: {
276
271
  type: "string",
277
- description: "Path of the file to upload. Exactly one of file or contentBase64 is required.",
272
+ description: "Path of a single file to upload. Exactly one of file, files, or contentBase64 is required.",
273
+ },
274
+ files: {
275
+ type: "array",
276
+ items: { type: "string" },
277
+ description: "Paths of multiple files to upload in parallel. Returns { uploads, failures }. Cannot combine with file, contentBase64, key, or filename.",
278
278
  },
279
279
  contentBase64: {
280
280
  type: "string",
@@ -282,11 +282,11 @@ export function createUploadsMcpTools(opts) {
282
282
  },
283
283
  filename: {
284
284
  type: "string",
285
- description: "Filename for contentBase64 content (drives the key and content type). With `file`, overrides the key's leaf (clean name) while keeping the pr/default path.",
285
+ description: "Filename for contentBase64 content (drives the key and content type). With single `file`, overrides the key's leaf (clean name) while keeping the pr/default path.",
286
286
  },
287
287
  key: {
288
288
  type: "string",
289
- description: "Explicit object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>). Cannot be combined with pr/issue.",
289
+ description: "Explicit object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>). Single file only; cannot be combined with pr/issue.",
290
290
  },
291
291
  destination: {
292
292
  type: "string",
@@ -306,7 +306,10 @@ export function createUploadsMcpTools(opts) {
306
306
  type: "string",
307
307
  description: "PR/issue/branch key segment (default: today, or UPLOADS_DEFAULT_REF). Cannot be combined with pr/issue.",
308
308
  },
309
- alt: { type: "string", description: "Alt text for the markdown (default: filename)." },
309
+ alt: {
310
+ type: "string",
311
+ description: "Alt text for the markdown (default: each file's name; with multiple files applies to all).",
312
+ },
310
313
  width: {
311
314
  type: "number",
312
315
  description: "Emit <img width=…> markdown instead of a plain image embed.",
@@ -348,14 +351,24 @@ export function createUploadsMcpTools(opts) {
348
351
  },
349
352
  async handler(args) {
350
353
  const file = optString(args, "file");
354
+ const filesArg = optStringArray(args, "files");
351
355
  const contentBase64 = optString(args, "contentBase64");
352
- if ((file === undefined) === (contentBase64 === undefined)) {
353
- usage("exactly one of file or contentBase64 is required");
356
+ if (filesArg !== undefined && filesArg.length === 0) {
357
+ usage("files must be a non-empty array of paths");
358
+ }
359
+ const multi = filesArg !== undefined;
360
+ const sources = [file !== undefined, multi, contentBase64 !== undefined];
361
+ if (sources.filter(Boolean).length !== 1) {
362
+ usage("exactly one of file, files, or contentBase64 is required");
354
363
  }
355
364
  const filenameArg = optString(args, "filename");
356
365
  if (contentBase64 !== undefined && !filenameArg) {
357
366
  usage("filename is required with contentBase64");
358
367
  }
368
+ if (multi && filenameArg)
369
+ usage("filename cannot be combined with files");
370
+ if (multi && optString(args, "key"))
371
+ usage("key cannot be combined with files");
359
372
  const target = ghTargetFromArgs(args, run);
360
373
  const wantComment = optBool(args, "comment");
361
374
  const dryRun = optBool(args, "dryRun");
@@ -394,67 +407,130 @@ export function createUploadsMcpTools(opts) {
394
407
  usage(err instanceof Error ? err.message : String(err));
395
408
  }
396
409
  const { client } = clientFor(args);
397
- const bytes = file !== undefined
398
- ? readFileArg(file)
399
- : new Uint8Array(Buffer.from(contentBase64, "base64"));
400
- const sourceName = file !== undefined ? (filenameArg ?? basename(file)) : filenameArg;
401
410
  const defaults = resolvePutDefaults({ envFile: globals.envFile });
402
411
  const frameOpts = mcpFrameOptions(args);
403
412
  const optimizeOpts = mcpOptimizeOptions(args, defaults);
404
- const prepared = await prepareImageForUpload(bytes, sourceName, {
405
- ...frameOpts,
406
- optimize: optimizeOpts,
407
- });
408
- const filename = prepared.filename;
409
- let key = target ? ghAttachmentKey(target, filename) : keyArg;
410
- if (key && prepared.optimized)
411
- key = rewriteKeyExtension(key, filename);
412
413
  const noGit = optBool(args, "noGit") || defaults.noGit === true;
413
- const result = await client.put(prepared.bytes, {
414
- filename,
415
- key,
414
+ const alt = optString(args, "alt");
415
+ const width = optPosInt(args, "width") ?? defaults.width;
416
+ const contentType = optString(args, "contentType");
417
+ const putShared = {
418
+ client,
419
+ ghTarget: target,
416
420
  prefix: resolvedPrefix ?? defaults.prefix,
417
421
  repo: optString(args, "repo") ?? defaults.repo,
418
422
  ref: refArg ?? defaults.ref,
419
- contentType: prepared.optimized ? prepared.contentType : optString(args, "contentType"),
420
423
  deriveRepoFromGit: !noGit,
424
+ contentType,
421
425
  dryRun,
422
- provenance: buildCliProvenance({
423
- sourceName,
424
- client: "uploads-mcp",
425
- optimized: prepared.optimized,
426
- frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
427
- keepExif: optimizeOpts.keepExif === true,
428
- }),
426
+ optimize: optimizeOpts,
427
+ frame: frameOpts,
429
428
  metadata,
429
+ provenanceClient: "uploads-mcp",
430
+ alt,
431
+ width,
432
+ };
433
+ // Multi-file path (paths only — no base64 batch).
434
+ if (multi) {
435
+ const { uploads, failures } = await uploadPuts({
436
+ ...putShared,
437
+ files: filesArg,
438
+ });
439
+ if (uploads.length === 0 && failures.length > 0) {
440
+ throw new ToolBatchError(batchFailureMessage(failures), { uploads, failures });
441
+ }
442
+ if (wantComment && target && uploads.length > 0) {
443
+ const { comment, commentError } = await syncComment(client, target);
444
+ return { uploads, failures, comment, commentError };
445
+ }
446
+ return { uploads, failures };
447
+ }
448
+ // Single-file: contentBase64 still supported; paths go through uploadPuts.
449
+ if (contentBase64 !== undefined) {
450
+ const sourceName = filenameArg;
451
+ const bytes = new Uint8Array(Buffer.from(contentBase64, "base64"));
452
+ const prepared = await prepareImageForUpload(bytes, sourceName, {
453
+ ...frameOpts,
454
+ optimize: optimizeOpts,
455
+ });
456
+ const filename = prepared.filename;
457
+ let key = target ? ghAttachmentKey(target, filename) : keyArg;
458
+ if (key && prepared.optimized)
459
+ key = rewriteKeyExtension(key, filename);
460
+ const result = await client.put(prepared.bytes, {
461
+ filename,
462
+ key,
463
+ prefix: resolvedPrefix ?? defaults.prefix,
464
+ repo: optString(args, "repo") ?? defaults.repo,
465
+ ref: refArg ?? defaults.ref,
466
+ contentType: prepared.optimized ? prepared.contentType : contentType,
467
+ deriveRepoFromGit: !noGit,
468
+ dryRun,
469
+ provenance: buildCliProvenance({
470
+ sourceName,
471
+ client: "uploads-mcp",
472
+ optimized: prepared.optimized,
473
+ frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
474
+ keepExif: optimizeOpts.keepExif === true,
475
+ }),
476
+ metadata,
477
+ });
478
+ const markdown = buildMarkdown(urlForGithubEmbed(result.url, result.embedUrl), {
479
+ alt: alt ?? sourceName,
480
+ width,
481
+ });
482
+ const optimize = {
483
+ optimized: prepared.optimized,
484
+ skippedReason: prepared.skippedReason,
485
+ originalBytes: prepared.originalBytes,
486
+ outputBytes: prepared.outputBytes,
487
+ filename: prepared.filename,
488
+ };
489
+ if (wantComment && target) {
490
+ const { comment, commentError } = await syncComment(client, target);
491
+ return { ...result, markdown, optimize, frame: prepared.frame, comment, commentError };
492
+ }
493
+ return {
494
+ ...result,
495
+ markdown,
496
+ optimize,
497
+ frame: prepared.frame,
498
+ ...(dryRun ? { dryRun: true } : {}),
499
+ };
500
+ }
501
+ const { uploads, failures, firstError } = await uploadPuts({
502
+ ...putShared,
503
+ files: [file],
504
+ nameOverride: filenameArg,
505
+ explicitKey: keyArg,
430
506
  });
431
- const markdown = buildMarkdown(urlForGithubEmbed(result.url, result.embedUrl), {
432
- alt: optString(args, "alt") ?? sourceName,
433
- width: optPosInt(args, "width") ?? defaults.width,
434
- });
435
- const optimize = {
436
- optimized: prepared.optimized,
437
- skippedReason: prepared.skippedReason,
438
- originalBytes: prepared.originalBytes,
439
- outputBytes: prepared.outputBytes,
440
- filename: prepared.filename,
507
+ if (uploads.length === 0 && failures.length > 0) {
508
+ throw firstError instanceof Error ? firstError : new Error(String(firstError));
509
+ }
510
+ const u = uploads[0];
511
+ const flat = {
512
+ workspace: u.workspace,
513
+ key: u.key,
514
+ url: u.url,
515
+ embedUrl: u.embedUrl,
516
+ size: u.size,
517
+ contentType: u.contentType,
518
+ replaced: u.replaced,
519
+ markdown: u.markdown,
520
+ optimize: u.optimize,
521
+ frame: u.frame,
522
+ ...(dryRun ? { dryRun: true } : {}),
441
523
  };
442
524
  if (wantComment && target) {
443
525
  const { comment, commentError } = await syncComment(client, target);
444
- return { ...result, markdown, optimize, frame: prepared.frame, comment, commentError };
526
+ return { ...flat, comment, commentError };
445
527
  }
446
- return {
447
- ...result,
448
- markdown,
449
- optimize,
450
- frame: prepared.frame,
451
- ...(dryRun ? { dryRun: true } : {}),
452
- };
528
+ return flat;
453
529
  },
454
530
  },
455
531
  {
456
532
  name: "attach",
457
- description: "Upload one or more files as stable PR/issue attachments and maintain a single managed GitHub comment listing them. Each upload returns `url`, `embedUrl` (when dual-host applies), and `markdown` (uses embedUrl for GitHub). With no pr/issue, targets the pull request for the current branch. Attachments are public and keys are predictable; upload only non-sensitive media.",
533
+ description: "Upload one or more files as stable PR/issue attachments (in parallel) and maintain a managed GitHub comment. Returns `uploads` and `failures` (one bad file does not abort the batch). Each success has `url`, `embedUrl`, and `markdown` (prefer embedUrl for GitHub). With no pr/issue, targets the current branch PR. Attachments are public and keys are predictable; upload only non-sensitive media.",
458
534
  inputSchema: {
459
535
  type: "object",
460
536
  properties: {
@@ -521,45 +597,28 @@ export function createUploadsMcpTools(opts) {
521
597
  const metadata = { ...metaExtras, ...ghMetadataFromTarget(target) };
522
598
  if (Object.keys(metadata).length > 0)
523
599
  validateMetaMap(metadata);
524
- const uploads = [];
525
- for (const file of files) {
526
- const sourceName = basename(file);
527
- const prepared = await prepareImageForUpload(readFileArg(file), sourceName, {
528
- ...frameOpts,
529
- optimize: optimizeOpts,
530
- });
531
- const result = await client.put(prepared.bytes, {
532
- filename: prepared.filename,
533
- key: ghAttachmentKey(target, prepared.filename),
534
- contentType: prepared.optimized ? prepared.contentType : contentType,
535
- provenance: buildCliProvenance({
536
- sourceName,
537
- client: "uploads-mcp",
538
- optimized: prepared.optimized,
539
- frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
540
- keepExif: optimizeOpts.keepExif === true,
541
- }),
542
- metadata,
543
- });
544
- uploads.push({
545
- ...result,
546
- markdown: buildMarkdown(urlForGithubEmbed(result.url, result.embedUrl), {
547
- alt: sourceName,
548
- }),
549
- frame: prepared.frame,
550
- optimize: {
551
- optimized: prepared.optimized,
552
- skippedReason: prepared.skippedReason,
553
- originalBytes: prepared.originalBytes,
554
- outputBytes: prepared.outputBytes,
555
- filename: prepared.filename,
556
- },
600
+ const { uploads, failures } = await uploadAttachments({
601
+ client,
602
+ target,
603
+ files,
604
+ contentType,
605
+ optimize: optimizeOpts,
606
+ frame: frameOpts,
607
+ metadata,
608
+ provenanceClient: "uploads-mcp",
609
+ });
610
+ // Total failure isError with full failures[] for agents.
611
+ if (uploads.length === 0 && failures.length > 0) {
612
+ throw new ToolBatchError(batchFailureMessage(failures), {
613
+ target,
614
+ uploads,
615
+ failures,
557
616
  });
558
617
  }
559
618
  if (optBool(args, "noComment"))
560
- return { target, uploads };
619
+ return { target, uploads, failures };
561
620
  const { comment, commentError } = await syncComment(client, target);
562
- return { target, uploads, comment, commentError };
621
+ return { target, uploads, failures, comment, commentError };
563
622
  },
564
623
  },
565
624
  {
@@ -626,9 +685,28 @@ export function createUploadsMcpTools(opts) {
626
685
  return client.delete(key);
627
686
  },
628
687
  },
688
+ {
689
+ name: "get_metadata",
690
+ description: "Read an object's queryable custom metadata (D1 key-value pairs, not R2 provenance). Returns `{ metadata }` (empty when none). Object must exist. Same as `uploads meta get`.",
691
+ inputSchema: {
692
+ type: "object",
693
+ properties: {
694
+ key: { type: "string", description: "Object key to inspect." },
695
+ workspace: workspaceProp,
696
+ },
697
+ required: ["key"],
698
+ additionalProperties: false,
699
+ },
700
+ async handler(args) {
701
+ const key = optString(args, "key");
702
+ if (!key)
703
+ usage("key is required");
704
+ return clientFor(args).client.getMetadata(key);
705
+ },
706
+ },
629
707
  {
630
708
  name: "set_metadata",
631
- description: "Merge-set and/or delete an object's queryable custom metadata (D1-backed key-value pairs; distinct from the R2 provenance headers put on upload). `set` pairs win over `delete` when a key appears in both. " +
709
+ description: "Merge-set and/or delete an object's queryable custom metadata (D1 key-value pairs, not R2 provenance). `set` wins over `delete` for the same key. " +
632
710
  METADATA_DESCRIPTION +
633
711
  " Requires at least one of `set` or `delete`. Same as `uploads meta set`.",
634
712
  inputSchema: {
@@ -773,5 +851,87 @@ export function createUploadsMcpTools(opts) {
773
851
  return buildDoctorReport(config, client);
774
852
  },
775
853
  },
854
+ {
855
+ name: "report",
856
+ description: "Send an explicit diagnostic report to the uploads team (message + optional text log). " +
857
+ "Only call this when the user asked to submit feedback, a bug report, or error logs — " +
858
+ "never automatically. Do not include tokens, secrets, or private file contents. " +
859
+ "Same as `uploads report`.",
860
+ inputSchema: {
861
+ type: "object",
862
+ properties: {
863
+ message: {
864
+ type: "string",
865
+ description: "Short description of the problem (required, 5–4000 chars).",
866
+ },
867
+ type: {
868
+ type: "string",
869
+ description: `One of: ${REPORT_TYPES.join(", ")} (default: other).`,
870
+ },
871
+ contact: {
872
+ type: "string",
873
+ description: "Optional contact for follow-up (email or handle).",
874
+ },
875
+ command: {
876
+ type: "string",
877
+ description: "Command that failed (e.g. put) — name only, no paths or args.",
878
+ },
879
+ errorCode: {
880
+ type: "string",
881
+ description: "Optional UploadsError code (e.g. KEY_POLICY).",
882
+ },
883
+ attachmentText: {
884
+ type: "string",
885
+ description: "Optional text log/trace body the user consented to send (max 256 KiB). Not a file path.",
886
+ },
887
+ attachmentFilename: {
888
+ type: "string",
889
+ description: "Filename label for attachmentText (default: trace.txt).",
890
+ },
891
+ },
892
+ required: ["message"],
893
+ additionalProperties: false,
894
+ },
895
+ async handler(args) {
896
+ const messageRaw = optString(args, "message");
897
+ if (!messageRaw)
898
+ usage("message is required");
899
+ const validated = validateReportMessage(messageRaw);
900
+ if (!validated.ok)
901
+ usage(validated.error);
902
+ const typeRaw = optString(args, "type");
903
+ if (typeRaw && !parseReportType(typeRaw)) {
904
+ usage(`type must be one of: ${REPORT_TYPES.join(", ")}`);
905
+ }
906
+ const type = parseReportType(typeRaw) ?? "other";
907
+ let attachment;
908
+ const attachmentText = optString(args, "attachmentText");
909
+ if (attachmentText) {
910
+ try {
911
+ attachment = attachmentFromText(attachmentText, optString(args, "attachmentFilename") ?? "trace.txt");
912
+ }
913
+ catch (err) {
914
+ usage(err instanceof Error ? err.message : String(err));
915
+ }
916
+ }
917
+ const payload = buildReportPayload(validated.message, {
918
+ type,
919
+ contact: optString(args, "contact"),
920
+ surface: "mcp",
921
+ command: optString(args, "command"),
922
+ errorCode: optString(args, "errorCode"),
923
+ attachment,
924
+ });
925
+ const apiUrl = resolveApiUrl(globals);
926
+ const result = await submitReport(payload, { apiUrl });
927
+ if (!result.ok)
928
+ usage(`couldn't send report: ${result.error}`);
929
+ return {
930
+ ok: true,
931
+ id: result.id,
932
+ hasAttachment: result.hasAttachment,
933
+ };
934
+ },
935
+ },
776
936
  ];
777
937
  }
@@ -0,0 +1,68 @@
1
+ export declare const REPORT_TYPES: readonly ["bug", "error", "idea", "other"];
2
+ export type ReportType = (typeof REPORT_TYPES)[number];
3
+ export declare const MIN_REPORT_MESSAGE = 5;
4
+ export declare const MAX_REPORT_MESSAGE = 4000;
5
+ export declare const MAX_REPORT_ATTACHMENT_BYTES: number;
6
+ export interface ReportAttachment {
7
+ filename: string;
8
+ contentType: string;
9
+ body: string;
10
+ }
11
+ export interface ReportPayload {
12
+ message: string;
13
+ type: ReportType;
14
+ contact?: string;
15
+ surface: "cli" | "mcp";
16
+ cliVersion: string;
17
+ clientKind: string;
18
+ agentName?: string;
19
+ anonId?: string;
20
+ os: string;
21
+ arch: string;
22
+ runtime: string;
23
+ command?: string;
24
+ errorCode?: string;
25
+ attachment?: ReportAttachment;
26
+ }
27
+ export interface SubmitReportOptions {
28
+ apiUrl?: string;
29
+ fetchImpl?: typeof fetch;
30
+ }
31
+ export type ValidateMessageResult = {
32
+ ok: true;
33
+ message: string;
34
+ } | {
35
+ ok: false;
36
+ error: string;
37
+ };
38
+ export declare function validateReportMessage(raw: string): ValidateMessageResult;
39
+ export declare function parseReportType(raw: string | undefined): ReportType | undefined;
40
+ /**
41
+ * Load a local text file as an attachment. Rejects oversized / missing files.
42
+ * Callers must have obtained the path from explicit user input (`--file`).
43
+ */
44
+ export declare function loadReportAttachment(path: string): ReportAttachment;
45
+ /** Build attachment from an in-memory string (MCP / piped text). */
46
+ export declare function attachmentFromText(body: string, filename?: string, contentType?: string): ReportAttachment;
47
+ export declare function buildReportPayload(message: string, opts?: {
48
+ type?: ReportType;
49
+ contact?: string;
50
+ surface?: "cli" | "mcp";
51
+ command?: string;
52
+ errorCode?: string;
53
+ attachment?: ReportAttachment;
54
+ }, deps?: {
55
+ version?: string;
56
+ dataDir?: string;
57
+ includeAnonId?: boolean;
58
+ }): ReportPayload;
59
+ export type SubmitReportResult = {
60
+ ok: true;
61
+ id: string;
62
+ hasAttachment: boolean;
63
+ } | {
64
+ ok: false;
65
+ error: string;
66
+ };
67
+ export declare function submitReport(payload: ReportPayload, opts?: SubmitReportOptions): Promise<SubmitReportResult>;
68
+ export declare function reportFallbackHint(): string;