@buildinternet/uploads 0.11.0 → 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.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Tool handler failure that still carries structuredContent (e.g. multi-file
3
+ * total failure with a `failures` array). The MCP server maps this to
4
+ * isError: true while preserving structuredContent for agents.
5
+ */
6
+ export declare class ToolBatchError extends Error {
7
+ readonly structuredContent: unknown;
8
+ constructor(message: string, structuredContent: unknown);
9
+ }
10
+ /** One-line summary of a multi-file failure list. */
11
+ export declare function batchFailureMessage(failures: readonly {
12
+ file: string;
13
+ error: {
14
+ message: string;
15
+ };
16
+ }[]): string;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Tool handler failure that still carries structuredContent (e.g. multi-file
3
+ * total failure with a `failures` array). The MCP server maps this to
4
+ * isError: true while preserving structuredContent for agents.
5
+ */
6
+ export class ToolBatchError extends Error {
7
+ structuredContent;
8
+ constructor(message, structuredContent) {
9
+ super(message);
10
+ this.name = "ToolBatchError";
11
+ this.structuredContent = structuredContent;
12
+ }
13
+ }
14
+ /** One-line summary of a multi-file failure list. */
15
+ export function batchFailureMessage(failures) {
16
+ if (failures.length === 0)
17
+ return "upload failed";
18
+ if (failures.length === 1) {
19
+ const f = failures[0];
20
+ return `${f.file}: ${f.error.message}`;
21
+ }
22
+ const lines = failures.map((f) => ` ${f.file}: ${f.error.message}`);
23
+ return `${failures.length} uploads failed:\n${lines.join("\n")}`;
24
+ }
@@ -1,4 +1,5 @@
1
1
  export { METADATA_DESCRIPTION, metadataProp, optPosInt, optString, optStringArray, optStringRecord, usage, type ToolArgs, } from "./args.js";
2
+ export { ToolBatchError, batchFailureMessage } from "./batch-error.js";
2
3
  export interface McpTool {
3
4
  name: string;
4
5
  description: string;
@@ -9,7 +9,9 @@
9
9
  */
10
10
  import { UploadsError } from "../errors.js";
11
11
  import { errorCodeFromUnknown, recordEvent } from "../telemetry.js";
12
+ import { ToolBatchError } from "./batch-error.js";
12
13
  export { METADATA_DESCRIPTION, metadataProp, optPosInt, optString, optStringArray, optStringRecord, usage, } from "./args.js";
14
+ export { ToolBatchError, batchFailureMessage } from "./batch-error.js";
13
15
  const SUPPORTED_PROTOCOL_VERSIONS = new Set(["2025-06-18", "2025-03-26", "2024-11-05"]);
14
16
  const LATEST_PROTOCOL_VERSION = "2025-06-18";
15
17
  function response(id, result) {
@@ -59,6 +61,20 @@ export function createMcpServer(opts) {
59
61
  durationMs: Date.now() - start,
60
62
  errorCode: errorCodeFromUnknown(err),
61
63
  }, { apiUrl });
64
+ // Multi-file total failure: keep structuredContent so agents see every
65
+ // per-file error, not only the first message string.
66
+ if (err instanceof ToolBatchError) {
67
+ return response(id, {
68
+ content: [
69
+ {
70
+ type: "text",
71
+ text: JSON.stringify(err.structuredContent, null, 2),
72
+ },
73
+ ],
74
+ structuredContent: err.structuredContent,
75
+ isError: true,
76
+ });
77
+ }
62
78
  return response(id, {
63
79
  content: [{ type: "text", text: toolErrorText(err) }],
64
80
  isError: true,
@@ -2,7 +2,7 @@ import type { GlobalFlags } from "../cli-args.js";
2
2
  import { type UploadsClient } from "../client.js";
3
3
  import { type UploadsClientConfig } from "../config.js";
4
4
  import { type CommandRunner } from "../github-gh.js";
5
- import type { McpTool } from "./server.js";
5
+ import { type McpTool } from "./server.js";
6
6
  export declare function createUploadsMcpTools(opts: {
7
7
  globals: GlobalFlags;
8
8
  runner?: CommandRunner;
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,7 @@ 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";
22
15
  import { attachmentFromText, buildReportPayload, parseReportType, REPORT_TYPES, submitReport, validateReportMessage, } from "../report.js";
23
16
  import { resolveApiUrl } from "../config.js";
24
17
  function optBool(args, name) {
@@ -270,13 +263,18 @@ export function createUploadsMcpTools(opts) {
270
263
  },
271
264
  {
272
265
  name: "put",
273
- 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.",
274
267
  inputSchema: {
275
268
  type: "object",
276
269
  properties: {
277
270
  file: {
278
271
  type: "string",
279
- 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.",
280
278
  },
281
279
  contentBase64: {
282
280
  type: "string",
@@ -284,11 +282,11 @@ export function createUploadsMcpTools(opts) {
284
282
  },
285
283
  filename: {
286
284
  type: "string",
287
- 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.",
288
286
  },
289
287
  key: {
290
288
  type: "string",
291
- 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.",
292
290
  },
293
291
  destination: {
294
292
  type: "string",
@@ -308,7 +306,10 @@ export function createUploadsMcpTools(opts) {
308
306
  type: "string",
309
307
  description: "PR/issue/branch key segment (default: today, or UPLOADS_DEFAULT_REF). Cannot be combined with pr/issue.",
310
308
  },
311
- 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
+ },
312
313
  width: {
313
314
  type: "number",
314
315
  description: "Emit <img width=…> markdown instead of a plain image embed.",
@@ -350,14 +351,24 @@ export function createUploadsMcpTools(opts) {
350
351
  },
351
352
  async handler(args) {
352
353
  const file = optString(args, "file");
354
+ const filesArg = optStringArray(args, "files");
353
355
  const contentBase64 = optString(args, "contentBase64");
354
- if ((file === undefined) === (contentBase64 === undefined)) {
355
- 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");
356
363
  }
357
364
  const filenameArg = optString(args, "filename");
358
365
  if (contentBase64 !== undefined && !filenameArg) {
359
366
  usage("filename is required with contentBase64");
360
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");
361
372
  const target = ghTargetFromArgs(args, run);
362
373
  const wantComment = optBool(args, "comment");
363
374
  const dryRun = optBool(args, "dryRun");
@@ -396,67 +407,130 @@ export function createUploadsMcpTools(opts) {
396
407
  usage(err instanceof Error ? err.message : String(err));
397
408
  }
398
409
  const { client } = clientFor(args);
399
- const bytes = file !== undefined
400
- ? readFileArg(file)
401
- : new Uint8Array(Buffer.from(contentBase64, "base64"));
402
- const sourceName = file !== undefined ? (filenameArg ?? basename(file)) : filenameArg;
403
410
  const defaults = resolvePutDefaults({ envFile: globals.envFile });
404
411
  const frameOpts = mcpFrameOptions(args);
405
412
  const optimizeOpts = mcpOptimizeOptions(args, defaults);
406
- const prepared = await prepareImageForUpload(bytes, sourceName, {
407
- ...frameOpts,
408
- optimize: optimizeOpts,
409
- });
410
- const filename = prepared.filename;
411
- let key = target ? ghAttachmentKey(target, filename) : keyArg;
412
- if (key && prepared.optimized)
413
- key = rewriteKeyExtension(key, filename);
414
413
  const noGit = optBool(args, "noGit") || defaults.noGit === true;
415
- const result = await client.put(prepared.bytes, {
416
- filename,
417
- 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,
418
420
  prefix: resolvedPrefix ?? defaults.prefix,
419
421
  repo: optString(args, "repo") ?? defaults.repo,
420
422
  ref: refArg ?? defaults.ref,
421
- contentType: prepared.optimized ? prepared.contentType : optString(args, "contentType"),
422
423
  deriveRepoFromGit: !noGit,
424
+ contentType,
423
425
  dryRun,
424
- provenance: buildCliProvenance({
425
- sourceName,
426
- client: "uploads-mcp",
427
- optimized: prepared.optimized,
428
- frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
429
- keepExif: optimizeOpts.keepExif === true,
430
- }),
426
+ optimize: optimizeOpts,
427
+ frame: frameOpts,
431
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,
432
506
  });
433
- const markdown = buildMarkdown(urlForGithubEmbed(result.url, result.embedUrl), {
434
- alt: optString(args, "alt") ?? sourceName,
435
- width: optPosInt(args, "width") ?? defaults.width,
436
- });
437
- const optimize = {
438
- optimized: prepared.optimized,
439
- skippedReason: prepared.skippedReason,
440
- originalBytes: prepared.originalBytes,
441
- outputBytes: prepared.outputBytes,
442
- 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 } : {}),
443
523
  };
444
524
  if (wantComment && target) {
445
525
  const { comment, commentError } = await syncComment(client, target);
446
- return { ...result, markdown, optimize, frame: prepared.frame, comment, commentError };
526
+ return { ...flat, comment, commentError };
447
527
  }
448
- return {
449
- ...result,
450
- markdown,
451
- optimize,
452
- frame: prepared.frame,
453
- ...(dryRun ? { dryRun: true } : {}),
454
- };
528
+ return flat;
455
529
  },
456
530
  },
457
531
  {
458
532
  name: "attach",
459
- 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.",
460
534
  inputSchema: {
461
535
  type: "object",
462
536
  properties: {
@@ -523,45 +597,28 @@ export function createUploadsMcpTools(opts) {
523
597
  const metadata = { ...metaExtras, ...ghMetadataFromTarget(target) };
524
598
  if (Object.keys(metadata).length > 0)
525
599
  validateMetaMap(metadata);
526
- const uploads = [];
527
- for (const file of files) {
528
- const sourceName = basename(file);
529
- const prepared = await prepareImageForUpload(readFileArg(file), sourceName, {
530
- ...frameOpts,
531
- optimize: optimizeOpts,
532
- });
533
- const result = await client.put(prepared.bytes, {
534
- filename: prepared.filename,
535
- key: ghAttachmentKey(target, prepared.filename),
536
- contentType: prepared.optimized ? prepared.contentType : contentType,
537
- provenance: buildCliProvenance({
538
- sourceName,
539
- client: "uploads-mcp",
540
- optimized: prepared.optimized,
541
- frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
542
- keepExif: optimizeOpts.keepExif === true,
543
- }),
544
- metadata,
545
- });
546
- uploads.push({
547
- ...result,
548
- markdown: buildMarkdown(urlForGithubEmbed(result.url, result.embedUrl), {
549
- alt: sourceName,
550
- }),
551
- frame: prepared.frame,
552
- optimize: {
553
- optimized: prepared.optimized,
554
- skippedReason: prepared.skippedReason,
555
- originalBytes: prepared.originalBytes,
556
- outputBytes: prepared.outputBytes,
557
- filename: prepared.filename,
558
- },
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,
559
616
  });
560
617
  }
561
618
  if (optBool(args, "noComment"))
562
- return { target, uploads };
619
+ return { target, uploads, failures };
563
620
  const { comment, commentError } = await syncComment(client, target);
564
- return { target, uploads, comment, commentError };
621
+ return { target, uploads, failures, comment, commentError };
565
622
  },
566
623
  },
567
624
  {
@@ -628,9 +685,28 @@ export function createUploadsMcpTools(opts) {
628
685
  return client.delete(key);
629
686
  },
630
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
+ },
631
707
  {
632
708
  name: "set_metadata",
633
- 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. " +
634
710
  METADATA_DESCRIPTION +
635
711
  " Requires at least one of `set` or `delete`. Same as `uploads meta set`.",
636
712
  inputSchema: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,