@buildinternet/uploads 0.11.0 → 0.12.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/dist/mcp/tools.js CHANGED
@@ -1,24 +1,13 @@
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, syncAttachmentsComment, uploadAttachments, uploadPreparedImage, uploadPuts, } from "../commands.js";
11
3
  import { resolveFrameId } from "../frame.js";
12
4
  import { resolveConfig, resolvePutDefaults, } from "../config.js";
13
- import { buildMarkdown } from "../embed.js";
14
- import { urlForGithubEmbed } from "../public-urls.js";
15
5
  import { resolvePutPrefix } from "../destinations.js";
16
- import { ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget } from "../github.js";
6
+ import { ghKeyPrefix, ghMetadataFromTarget } from "../github.js";
17
7
  import { validateMetaMap } from "../metadata.js";
18
- import { rewriteKeyExtension } from "../optimize.js";
19
- import { buildCliProvenance } from "../provenance.js";
20
8
  import { execRunner, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
21
9
  import { METADATA_DESCRIPTION, metadataProp, optPosInt, optString, optStringArray, optStringRecord, usage, } from "./args.js";
10
+ import { batchFailureMessage, ToolBatchError } from "./server.js";
22
11
  import { attachmentFromText, buildReportPayload, parseReportType, REPORT_TYPES, submitReport, validateReportMessage, } from "../report.js";
23
12
  import { resolveApiUrl } from "../config.js";
24
13
  function optBool(args, name) {
@@ -270,13 +259,18 @@ export function createUploadsMcpTools(opts) {
270
259
  },
271
260
  {
272
261
  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.",
262
+ 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
263
  inputSchema: {
275
264
  type: "object",
276
265
  properties: {
277
266
  file: {
278
267
  type: "string",
279
- description: "Path of the file to upload. Exactly one of file or contentBase64 is required.",
268
+ description: "Path of a single file to upload. Exactly one of file, files, or contentBase64 is required.",
269
+ },
270
+ files: {
271
+ type: "array",
272
+ items: { type: "string" },
273
+ description: "Paths of multiple files to upload in parallel. Returns { uploads, failures }. Cannot combine with file, contentBase64, key, or filename.",
280
274
  },
281
275
  contentBase64: {
282
276
  type: "string",
@@ -284,11 +278,11 @@ export function createUploadsMcpTools(opts) {
284
278
  },
285
279
  filename: {
286
280
  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.",
281
+ 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
282
  },
289
283
  key: {
290
284
  type: "string",
291
- description: "Explicit object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>). Cannot be combined with pr/issue.",
285
+ description: "Explicit object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>). Single file only; cannot be combined with pr/issue.",
292
286
  },
293
287
  destination: {
294
288
  type: "string",
@@ -308,7 +302,10 @@ export function createUploadsMcpTools(opts) {
308
302
  type: "string",
309
303
  description: "PR/issue/branch key segment (default: today, or UPLOADS_DEFAULT_REF). Cannot be combined with pr/issue.",
310
304
  },
311
- alt: { type: "string", description: "Alt text for the markdown (default: filename)." },
305
+ alt: {
306
+ type: "string",
307
+ description: "Alt text for the markdown (default: each file's name; with multiple files applies to all).",
308
+ },
312
309
  width: {
313
310
  type: "number",
314
311
  description: "Emit <img width=…> markdown instead of a plain image embed.",
@@ -350,14 +347,24 @@ export function createUploadsMcpTools(opts) {
350
347
  },
351
348
  async handler(args) {
352
349
  const file = optString(args, "file");
350
+ const filesArg = optStringArray(args, "files");
353
351
  const contentBase64 = optString(args, "contentBase64");
354
- if ((file === undefined) === (contentBase64 === undefined)) {
355
- usage("exactly one of file or contentBase64 is required");
352
+ if (filesArg !== undefined && filesArg.length === 0) {
353
+ usage("files must be a non-empty array of paths");
354
+ }
355
+ const multi = filesArg !== undefined;
356
+ const sources = [file !== undefined, multi, contentBase64 !== undefined];
357
+ if (sources.filter(Boolean).length !== 1) {
358
+ usage("exactly one of file, files, or contentBase64 is required");
356
359
  }
357
360
  const filenameArg = optString(args, "filename");
358
361
  if (contentBase64 !== undefined && !filenameArg) {
359
362
  usage("filename is required with contentBase64");
360
363
  }
364
+ if (multi && filenameArg)
365
+ usage("filename cannot be combined with files");
366
+ if (multi && optString(args, "key"))
367
+ usage("key cannot be combined with files");
361
368
  const target = ghTargetFromArgs(args, run);
362
369
  const wantComment = optBool(args, "comment");
363
370
  const dryRun = optBool(args, "dryRun");
@@ -396,67 +403,357 @@ export function createUploadsMcpTools(opts) {
396
403
  usage(err instanceof Error ? err.message : String(err));
397
404
  }
398
405
  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
406
  const defaults = resolvePutDefaults({ envFile: globals.envFile });
404
407
  const frameOpts = mcpFrameOptions(args);
405
408
  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
409
  const noGit = optBool(args, "noGit") || defaults.noGit === true;
415
- const result = await client.put(prepared.bytes, {
416
- filename,
417
- key,
410
+ const alt = optString(args, "alt");
411
+ const width = optPosInt(args, "width") ?? defaults.width;
412
+ const contentType = optString(args, "contentType");
413
+ const putShared = {
414
+ client,
415
+ ghTarget: target,
418
416
  prefix: resolvedPrefix ?? defaults.prefix,
419
417
  repo: optString(args, "repo") ?? defaults.repo,
420
418
  ref: refArg ?? defaults.ref,
421
- contentType: prepared.optimized ? prepared.contentType : optString(args, "contentType"),
422
419
  deriveRepoFromGit: !noGit,
420
+ contentType,
423
421
  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
- }),
422
+ optimize: optimizeOpts,
423
+ frame: frameOpts,
431
424
  metadata,
425
+ provenanceClient: "uploads-mcp",
426
+ alt,
427
+ width,
428
+ };
429
+ // Multi-file path (paths only — no base64 batch).
430
+ if (multi) {
431
+ const { uploads, failures } = await uploadPuts({
432
+ ...putShared,
433
+ files: filesArg,
434
+ });
435
+ if (uploads.length === 0 && failures.length > 0) {
436
+ throw new ToolBatchError(batchFailureMessage(failures), { uploads, failures });
437
+ }
438
+ if (wantComment && target && uploads.length > 0) {
439
+ const { comment, commentError } = await syncComment(client, target);
440
+ return { uploads, failures, comment, commentError };
441
+ }
442
+ return { uploads, failures };
443
+ }
444
+ // Single-file: contentBase64 still supported; paths go through uploadPuts.
445
+ if (contentBase64 !== undefined) {
446
+ const sourceName = filenameArg;
447
+ const bytes = new Uint8Array(Buffer.from(contentBase64, "base64"));
448
+ const { result, prepared, markdown } = await uploadPreparedImage(client, bytes, sourceName, {
449
+ frame: frameOpts,
450
+ optimize: optimizeOpts,
451
+ ghTarget: target,
452
+ key: keyArg,
453
+ prefix: resolvedPrefix ?? defaults.prefix,
454
+ repo: optString(args, "repo") ?? defaults.repo,
455
+ ref: refArg ?? defaults.ref,
456
+ contentType,
457
+ deriveRepoFromGit: !noGit,
458
+ dryRun,
459
+ metadata,
460
+ provenanceClient: "uploads-mcp",
461
+ alt: () => alt ?? sourceName,
462
+ width,
463
+ });
464
+ const optimize = {
465
+ optimized: prepared.optimized,
466
+ skippedReason: prepared.skippedReason,
467
+ originalBytes: prepared.originalBytes,
468
+ outputBytes: prepared.outputBytes,
469
+ filename: prepared.filename,
470
+ };
471
+ if (wantComment && target) {
472
+ const { comment, commentError } = await syncComment(client, target);
473
+ return { ...result, markdown, optimize, frame: prepared.frame, comment, commentError };
474
+ }
475
+ return {
476
+ ...result,
477
+ markdown,
478
+ optimize,
479
+ frame: prepared.frame,
480
+ ...(dryRun ? { dryRun: true } : {}),
481
+ };
482
+ }
483
+ const { uploads, failures, firstError } = await uploadPuts({
484
+ ...putShared,
485
+ files: [file],
486
+ nameOverride: filenameArg,
487
+ explicitKey: keyArg,
432
488
  });
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,
489
+ if (uploads.length === 0 && failures.length > 0) {
490
+ throw firstError instanceof Error ? firstError : new Error(String(firstError));
491
+ }
492
+ const u = uploads[0];
493
+ const flat = {
494
+ workspace: u.workspace,
495
+ key: u.key,
496
+ url: u.url,
497
+ embedUrl: u.embedUrl,
498
+ size: u.size,
499
+ contentType: u.contentType,
500
+ replaced: u.replaced,
501
+ markdown: u.markdown,
502
+ optimize: u.optimize,
503
+ frame: u.frame,
504
+ ...(dryRun ? { dryRun: true } : {}),
443
505
  };
444
506
  if (wantComment && target) {
445
507
  const { comment, commentError } = await syncComment(client, target);
446
- return { ...result, markdown, optimize, frame: prepared.frame, comment, commentError };
508
+ return { ...flat, comment, commentError };
447
509
  }
448
- return {
510
+ return flat;
511
+ },
512
+ },
513
+ {
514
+ name: "screenshot",
515
+ description: "Capture a URL or a local .html file and host it — a hosted, PR-embeddable image in one call. Backend `local` drives an already-installed Chrome/Chromium (dynamically loaded; unavailable in some runtimes); `remote` renders server-side via the workspace's render endpoint and counts against the monthly upload budget. Default via=auto prefers local when found, else remote. localhost/private-network URLs and .html files are local-only — via=remote (or auto falling back to remote) fails fast instead of a doomed request. Shares the put upload pipeline: optional frame, optimize-by-default, pr/issue attachment + comment, gallery, metadata. Uploads are public.",
516
+ inputSchema: {
517
+ type: "object",
518
+ properties: {
519
+ target: {
520
+ type: "string",
521
+ description: "http(s) URL, or a path to a local .html file.",
522
+ },
523
+ via: {
524
+ type: "string",
525
+ description: "Capture backend: auto (default) | local | remote.",
526
+ },
527
+ browser: {
528
+ type: "string",
529
+ description: "Explicit local browser executable path (local backend only).",
530
+ },
531
+ cdp: {
532
+ type: "string",
533
+ description: "Attach to a running Chrome via CDP instead of launching one (local backend only).",
534
+ },
535
+ viewport: {
536
+ type: "string",
537
+ description: "WIDTHxHEIGHT[@SCALEx], e.g. 1280x800@2x (default: 1280x800@2).",
538
+ },
539
+ selector: { type: "string", description: "Capture one element instead of the viewport." },
540
+ fullPage: { type: "boolean", description: "Capture the full scrollable page." },
541
+ colorScheme: {
542
+ type: "string",
543
+ description: "Emulate prefers-color-scheme: dark | light. Full media-query emulation requires via: \"local\" — the remote backend only sets the CSS color-scheme property and won't flip a page's own prefers-color-scheme queries.",
544
+ },
545
+ wait: {
546
+ type: "string",
547
+ description: 'Settle strategy: load (default) | domcontentloaded | networkidle | a millisecond count (millisecond counts are local-only — via: "local").',
548
+ },
549
+ key: {
550
+ type: "string",
551
+ description: "Explicit object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.png). Cannot be combined with pr/issue.",
552
+ },
553
+ destination: {
554
+ type: "string",
555
+ description: "Typed destination root: screenshots | gh | f. With pr/issue must be gh or omitted.",
556
+ },
557
+ prefix: {
558
+ type: "string",
559
+ description: "Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX).",
560
+ },
561
+ ...ghTargetProps("Attach to"),
562
+ repo: {
563
+ type: "string",
564
+ description: "owner/name repo segment (default: git remote, or UPLOADS_DEFAULT_REPO).",
565
+ },
566
+ ref: {
567
+ type: "string",
568
+ description: "PR/issue/branch key segment (default: today, or UPLOADS_DEFAULT_REF).",
569
+ },
570
+ alt: {
571
+ type: "string",
572
+ description: "Alt text for the markdown (default: derived filename).",
573
+ },
574
+ width: {
575
+ type: "number",
576
+ description: "Emit <img width=…> markdown instead of a plain embed.",
577
+ },
578
+ noOptimize: {
579
+ type: "boolean",
580
+ description: "Skip client-side image optimization (default: optimize to WebP).",
581
+ },
582
+ optimizeMaxEdge: {
583
+ type: "number",
584
+ description: "Max long edge in pixels when optimizing.",
585
+ },
586
+ optimizeQuality: { type: "number", description: "WebP quality 1-100 when optimizing." },
587
+ keepExif: { type: "boolean", description: "Keep EXIF/XMP/ICC when optimizing." },
588
+ ...frameProps,
589
+ noGit: { type: "boolean", description: "Don't derive the repo segment from git." },
590
+ comment: {
591
+ type: "boolean",
592
+ description: "With pr/issue: create/update the managed attachments comment (best-effort).",
593
+ },
594
+ galleryId: {
595
+ type: "string",
596
+ description: "Add the uploaded object to this public gallery.",
597
+ },
598
+ dryRun: {
599
+ type: "boolean",
600
+ description: "Capture + resolve key/URL without uploading. Not with comment or galleryId.",
601
+ },
602
+ metadata: metadataProp,
603
+ workspace: workspaceProp,
604
+ },
605
+ required: ["target"],
606
+ additionalProperties: false,
607
+ },
608
+ async handler(args) {
609
+ const targetArg = optString(args, "target");
610
+ if (!targetArg)
611
+ usage("target is required");
612
+ const viaArg = optString(args, "via") ?? "auto";
613
+ if (viaArg !== "auto" && viaArg !== "local" && viaArg !== "remote") {
614
+ usage("via must be auto, local, or remote");
615
+ }
616
+ const colorSchemeArg = optString(args, "colorScheme");
617
+ if (colorSchemeArg && colorSchemeArg !== "dark" && colorSchemeArg !== "light") {
618
+ usage("colorScheme must be dark or light");
619
+ }
620
+ const target = ghTargetFromArgs(args, run);
621
+ const wantComment = optBool(args, "comment");
622
+ const dryRun = optBool(args, "dryRun");
623
+ const keyArg = optString(args, "key");
624
+ const destArg = optString(args, "destination");
625
+ const prefixArg = optString(args, "prefix");
626
+ const refArg = optString(args, "ref");
627
+ const galleryIdArg = optString(args, "galleryId");
628
+ if (wantComment && !target)
629
+ usage("comment requires pr or issue");
630
+ if (dryRun && wantComment)
631
+ usage("dryRun cannot be combined with comment");
632
+ if (dryRun && galleryIdArg)
633
+ usage("dryRun cannot be combined with galleryId");
634
+ if (target) {
635
+ if (keyArg)
636
+ usage("key cannot be combined with pr/issue");
637
+ if (refArg)
638
+ usage("ref cannot be combined with pr/issue");
639
+ if (prefixArg)
640
+ usage("prefix cannot be combined with pr/issue");
641
+ }
642
+ const metadata = optStringRecord(args, "metadata");
643
+ if (metadata)
644
+ validateMetaMap(metadata);
645
+ let resolvedPrefix;
646
+ try {
647
+ resolvedPrefix = resolvePutPrefix({
648
+ destination: destArg,
649
+ prefix: prefixArg,
650
+ key: keyArg,
651
+ ghAttachment: Boolean(target),
652
+ });
653
+ }
654
+ catch (err) {
655
+ usage(err instanceof Error ? err.message : String(err));
656
+ }
657
+ const { config, client } = clientFor(args);
658
+ const defaults = resolvePutDefaults({ envFile: globals.envFile });
659
+ const frameOpts = mcpFrameOptions(args);
660
+ const optimizeOpts = mcpOptimizeOptions(args, defaults);
661
+ const noGit = optBool(args, "noGit") || defaults.noGit === true;
662
+ const alt = optString(args, "alt");
663
+ const width = optPosInt(args, "width") ?? defaults.width;
664
+ // Dynamic import only: keeps mcp/tools.ts (and therefore anything
665
+ // that statically imports it) free of a static reference to the
666
+ // local-backend chain. If this fails, the runtime can't do Node-side
667
+ // capture at all — point the caller at the remote backend instead.
668
+ let screenshotModule;
669
+ try {
670
+ screenshotModule = await import("../screenshot.js");
671
+ }
672
+ catch (err) {
673
+ usage(`screenshot capture is unavailable in this runtime; try via: "remote" instead (${err instanceof Error ? err.message : String(err)})`);
674
+ }
675
+ let captured;
676
+ try {
677
+ captured = await screenshotModule.captureScreenshot({
678
+ target: targetArg,
679
+ via: viaArg,
680
+ browserPath: optString(args, "browser"),
681
+ cdp: optString(args, "cdp"),
682
+ viewport: screenshotModule.parseViewport(optString(args, "viewport")),
683
+ selector: optString(args, "selector"),
684
+ fullPage: optBool(args, "fullPage"),
685
+ colorScheme: colorSchemeArg,
686
+ waitUntil: screenshotModule.parseWaitUntil(optString(args, "wait")),
687
+ apiUrl: config.apiUrl,
688
+ token: config.token,
689
+ });
690
+ }
691
+ catch (err) {
692
+ if (err instanceof Error &&
693
+ "code" in err &&
694
+ err.code === "BROWSER_NOT_FOUND" &&
695
+ viaArg === "local") {
696
+ usage(`${err.message} — try via: "remote" instead`);
697
+ }
698
+ throw err;
699
+ }
700
+ const { result, prepared, markdown } = await uploadPreparedImage(client, captured.png, captured.filename, {
701
+ frame: frameOpts,
702
+ optimize: optimizeOpts,
703
+ ghTarget: target,
704
+ key: keyArg,
705
+ prefix: resolvedPrefix ?? defaults.prefix,
706
+ repo: optString(args, "repo") ?? defaults.repo,
707
+ ref: refArg ?? defaults.ref,
708
+ deriveRepoFromGit: !noGit,
709
+ dryRun,
710
+ metadata,
711
+ provenanceClient: "uploads-mcp-screenshot",
712
+ alt: (p) => alt ?? p.filename,
713
+ width,
714
+ });
715
+ let gallery;
716
+ if (galleryIdArg) {
717
+ try {
718
+ const current = await client.getGallery(galleryIdArg);
719
+ await client.addGalleryItem(galleryIdArg, result.key, {
720
+ expectedVersion: current.version,
721
+ altText: alt ?? prepared.filename,
722
+ });
723
+ gallery = { id: galleryIdArg, url: current.url };
724
+ }
725
+ catch (err) {
726
+ gallery = {
727
+ id: galleryIdArg,
728
+ error: err instanceof Error ? err.message : String(err),
729
+ };
730
+ }
731
+ }
732
+ const flat = {
449
733
  ...result,
450
734
  markdown,
451
- optimize,
735
+ backend: captured.backend,
736
+ optimize: {
737
+ optimized: prepared.optimized,
738
+ skippedReason: prepared.skippedReason,
739
+ originalBytes: prepared.originalBytes,
740
+ outputBytes: prepared.outputBytes,
741
+ filename: prepared.filename,
742
+ },
452
743
  frame: prepared.frame,
744
+ gallery,
453
745
  ...(dryRun ? { dryRun: true } : {}),
454
746
  };
747
+ if (wantComment && target) {
748
+ const { comment, commentError } = await syncComment(client, target);
749
+ return { ...flat, comment, commentError };
750
+ }
751
+ return flat;
455
752
  },
456
753
  },
457
754
  {
458
755
  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.",
756
+ 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
757
  inputSchema: {
461
758
  type: "object",
462
759
  properties: {
@@ -523,45 +820,28 @@ export function createUploadsMcpTools(opts) {
523
820
  const metadata = { ...metaExtras, ...ghMetadataFromTarget(target) };
524
821
  if (Object.keys(metadata).length > 0)
525
822
  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
- },
823
+ const { uploads, failures } = await uploadAttachments({
824
+ client,
825
+ target,
826
+ files,
827
+ contentType,
828
+ optimize: optimizeOpts,
829
+ frame: frameOpts,
830
+ metadata,
831
+ provenanceClient: "uploads-mcp",
832
+ });
833
+ // Total failure isError with full failures[] for agents.
834
+ if (uploads.length === 0 && failures.length > 0) {
835
+ throw new ToolBatchError(batchFailureMessage(failures), {
836
+ target,
837
+ uploads,
838
+ failures,
559
839
  });
560
840
  }
561
841
  if (optBool(args, "noComment"))
562
- return { target, uploads };
842
+ return { target, uploads, failures };
563
843
  const { comment, commentError } = await syncComment(client, target);
564
- return { target, uploads, comment, commentError };
844
+ return { target, uploads, failures, comment, commentError };
565
845
  },
566
846
  },
567
847
  {
@@ -628,9 +908,28 @@ export function createUploadsMcpTools(opts) {
628
908
  return client.delete(key);
629
909
  },
630
910
  },
911
+ {
912
+ name: "get_metadata",
913
+ 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`.",
914
+ inputSchema: {
915
+ type: "object",
916
+ properties: {
917
+ key: { type: "string", description: "Object key to inspect." },
918
+ workspace: workspaceProp,
919
+ },
920
+ required: ["key"],
921
+ additionalProperties: false,
922
+ },
923
+ async handler(args) {
924
+ const key = optString(args, "key");
925
+ if (!key)
926
+ usage("key is required");
927
+ return clientFor(args).client.getMetadata(key);
928
+ },
929
+ },
631
930
  {
632
931
  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. " +
932
+ 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
933
  METADATA_DESCRIPTION +
635
934
  " Requires at least one of `set` or `delete`. Same as `uploads meta set`.",
636
935
  inputSchema: {
@@ -0,0 +1,64 @@
1
+ export type BrowserCandidateSource = "env" | "system" | "playwright-cache" | "puppeteer-cache";
2
+ export interface BrowserCandidate {
3
+ source: BrowserCandidateSource;
4
+ /** e.g. "chrome", "chromium", "chromium_headless_shell", "edge". */
5
+ kind: string;
6
+ executablePath: string;
7
+ /** Cache revision/build string, when applicable. */
8
+ revision?: string;
9
+ }
10
+ export interface DetectRoots {
11
+ platform?: NodeJS.Platform;
12
+ env?: NodeJS.ProcessEnv;
13
+ playwrightCacheDir?: string;
14
+ puppeteerCacheDir?: string;
15
+ /** Injectable for tests: overrides the fixed system-install paths. */
16
+ systemCandidates?: readonly {
17
+ kind: string;
18
+ path: string;
19
+ }[];
20
+ /** Injectable for tests: replaces fs.existsSync. */
21
+ exists?: (path: string) => boolean;
22
+ /** Injectable for tests: replaces fs.readdirSync. */
23
+ readdir?: (path: string) => string[];
24
+ }
25
+ export interface DetectResult {
26
+ /** Explicit --browser / UPLOADS_CHROME_PATH / CHROME_PATH override, if any. */
27
+ envOverride?: string;
28
+ candidates: BrowserCandidate[];
29
+ /** Best candidate by the documented ranking, or undefined if none found. */
30
+ winner?: BrowserCandidate;
31
+ }
32
+ /**
33
+ * Scan for a usable local Chromium-family executable. Pure fs/env — never
34
+ * launches a browser. Roots are injectable so tests can fake a cache layout.
35
+ */
36
+ export declare function detectLocalBrowser(roots?: DetectRoots): DetectResult;
37
+ export interface LocalCaptureOptions {
38
+ /** URL to navigate to, or a `file://` URL for local .html targets. */
39
+ url: string;
40
+ /** Explicit browser executable (--browser / UPLOADS_CHROME_PATH / CHROME_PATH). */
41
+ browserPath?: string;
42
+ /** Attach to a running Chrome instead of launching one. */
43
+ cdp?: string;
44
+ viewport: {
45
+ width: number;
46
+ height: number;
47
+ deviceScaleFactor: number;
48
+ };
49
+ selector?: string;
50
+ fullPage?: boolean;
51
+ colorScheme?: "dark" | "light";
52
+ /** "load" | "domcontentloaded" | "networkidle", or a millisecond settle delay. */
53
+ waitUntil: "load" | "domcontentloaded" | "networkidle" | number;
54
+ timeoutMs?: number;
55
+ detectRoots?: DetectRoots;
56
+ /**
57
+ * Pre-computed detection result from a caller that already scanned the
58
+ * filesystem (e.g. `auto`-routing's probe) — avoids re-running
59
+ * `detectLocalBrowser` a second time for the same capture.
60
+ */
61
+ detectResult?: DetectResult;
62
+ }
63
+ /** Capture a PNG screenshot using a local (already-installed) browser. */
64
+ export declare function captureLocal(opts: LocalCaptureOptions): Promise<Uint8Array>;