@buildinternet/uploads 0.48.0 → 0.49.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.
@@ -20,7 +20,7 @@ export declare const repoLinkStatusResultSchema: JsonSchema;
20
20
  export declare const usageResultSchema: JsonSchema;
21
21
  export declare const reconcileResultSchema: JsonSchema;
22
22
  export declare const purgeExpiredResultSchema: JsonSchema;
23
- export declare const healthResultSchema: JsonSchema;
23
+ export declare const whoamiResultSchema: JsonSchema;
24
24
  export declare const promoteToolResultSchema: JsonSchema;
25
25
  export declare const galleryResultSchema: JsonSchema;
26
26
  export declare const galleryFindResultSchema: JsonSchema;
@@ -223,10 +223,14 @@ export const purgeExpiredResultSchema = objectSchema({
223
223
  keysTruncated: { type: "boolean" },
224
224
  reconcile: reconcileResultSchema,
225
225
  });
226
- export const healthResultSchema = objectSchema({
226
+ export const whoamiResultSchema = objectSchema({
227
227
  ok: { type: "boolean" },
228
+ workspace: { type: "string" },
229
+ scopes: { type: "array", items: { type: "string" } },
230
+ userId: nullableString,
231
+ signedIn: { type: "boolean" },
228
232
  apiUrl: { type: "string" },
229
- });
233
+ }, ["ok", "workspace"]);
230
234
  export const promoteToolResultSchema = objectSchema({
231
235
  // `promotion` is optional (issue #702): a `keys`-only call (no `branch`)
232
236
  // never runs the branch sweep, so there's nothing to report under it.
@@ -307,7 +311,7 @@ export const hostedOutputSchemas = {
307
311
  usage: usageResultSchema,
308
312
  reconcile: reconcileResultSchema,
309
313
  purge_expired: purgeExpiredResultSchema,
310
- health: healthResultSchema,
314
+ whoami: whoamiResultSchema,
311
315
  };
312
316
  /** Shared-shape stdio tools. Hosted-only tools (`promote`, `repo_link_status`) omitted. */
313
317
  export const stdioOutputSchemas = {
@@ -340,7 +344,7 @@ export const stdioOutputSchemas = {
340
344
  usage: usageResultSchema,
341
345
  reconcile: reconcileResultSchema,
342
346
  purge_expired: purgeExpiredResultSchema,
343
- health: healthResultSchema,
347
+ whoami: whoamiResultSchema,
344
348
  report: objectSchema({
345
349
  ok: { type: "boolean" },
346
350
  id: { type: "string" },
@@ -17,19 +17,19 @@
17
17
  * The stdio transport comes from `@modelcontextprotocol/server/stdio`; logs
18
18
  * must never go to stdout.
19
19
  */
20
- import { McpServer, type jsonSchemaValidator } from "@modelcontextprotocol/server";
21
- export { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, metadataArgWithCanonical, metadataProp, stateProp, optBool, optPosInt, optString, optStringArray, optStringRecord, usage, type ToolArgs, } from "./args.js";
20
+ import { McpServer, type Implementation, type jsonSchemaValidator } from "@modelcontextprotocol/server";
21
+ export { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, METADATA_PATH_CUE, MCP_EXAMPLE_PNG_BASE64, metadataArgWithCanonical, metadataProp, stateProp, optBool, optPosInt, optString, optStringArray, optStringRecord, usage, type ToolArgs, } from "./args.js";
22
22
  export { ToolBatchError, batchFailureMessage } from "./batch-error.js";
23
23
  export { mapBounded } from "../async.js";
24
24
  export { McpServer, type jsonSchemaValidator };
25
- export { commentResultSchema, deleteResultSchema, findFilesResultSchema, galleryFindResultSchema, galleryResultSchema, healthResultSchema, hostedOutputSchemas, listResultSchema, metadataFacetsResultSchema, metadataResultSchema, promoteToolResultSchema, purgeExpiredResultSchema, putResultSchema, reconcileResultSchema, repoLinkStatusResultSchema, stdioOutputSchemas, usageResultSchema, withOutputSchemas, } from "./output-schemas.js";
25
+ export { commentResultSchema, deleteResultSchema, findFilesResultSchema, galleryFindResultSchema, galleryResultSchema, hostedOutputSchemas, listResultSchema, metadataFacetsResultSchema, metadataResultSchema, promoteToolResultSchema, purgeExpiredResultSchema, putResultSchema, reconcileResultSchema, repoLinkStatusResultSchema, stdioOutputSchemas, usageResultSchema, withOutputSchemas, } from "./output-schemas.js";
26
26
  /** MCP tool safety hints. Required so tools/list advertises them for review. */
27
27
  export interface McpToolAnnotations {
28
28
  readOnlyHint: boolean;
29
29
  destructiveHint: boolean;
30
30
  openWorldHint: boolean;
31
31
  }
32
- /** Lookup / list / health. Does not change workspace or public state. */
32
+ /** Lookup / list / whoami. Does not change workspace or public state. */
33
33
  export declare const mcpRead: McpToolAnnotations;
34
34
  /** Creates or updates a public object, gallery, or comment without deleting. */
35
35
  export declare const mcpWritePublic: McpToolAnnotations;
@@ -50,9 +50,9 @@ export type McpSecurityScheme = {
50
50
  export declare const mcpOAuthRead: McpSecurityScheme[];
51
51
  export declare const mcpOAuthWrite: McpSecurityScheme[];
52
52
  export declare const mcpOAuthDelete: McpSecurityScheme[];
53
- /** Authenticated, no particular file scope (hosted `health`). */
53
+ /** Authenticated, no particular file scope (hosted `whoami`). */
54
54
  export declare const mcpOAuthAny: McpSecurityScheme[];
55
- /** Callable without a token (stdio `health`). */
55
+ /** Callable without a token (stdio `whoami` when unsigned-in). */
56
56
  export declare const mcpNoAuth: McpSecurityScheme[];
57
57
  /**
58
58
  * Thrown when a presented token is missing a required scope. wrapHandler
@@ -81,11 +81,14 @@ export interface McpTool {
81
81
  outputSchema?: Record<string, unknown>;
82
82
  handler: (args: Record<string, unknown>) => Promise<unknown>;
83
83
  }
84
+ /**
85
+ * Brand mark on `serverInfo.icons` (MCP `Icon`, spec 2026-07-28). PNG is the
86
+ * type clients that render icons MUST support. The file is the site's
87
+ * apple-touch-icon (180×180 pixel chevron), already public on uploads.sh.
88
+ */
89
+ export declare const MCP_SERVER_ICONS: NonNullable<Implementation["icons"]>;
84
90
  export declare function createMcpServer(opts: {
85
- serverInfo: {
86
- name: string;
87
- version: string;
88
- };
91
+ serverInfo: Implementation;
89
92
  tools: McpTool[];
90
93
  /** API base for telemetry (honors uploads --api-url). */
91
94
  apiUrl?: string;
@@ -21,12 +21,12 @@ import { fromJsonSchema, McpServer, } from "@modelcontextprotocol/server";
21
21
  import { UploadsError } from "../errors.js";
22
22
  import { errorCodeFromUnknown, recordEvent } from "../telemetry.js";
23
23
  import { ToolBatchError } from "./batch-error.js";
24
- export { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, metadataArgWithCanonical, metadataProp, stateProp, optBool, optPosInt, optString, optStringArray, optStringRecord, usage, } from "./args.js";
24
+ export { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, METADATA_PATH_CUE, MCP_EXAMPLE_PNG_BASE64, metadataArgWithCanonical, metadataProp, stateProp, optBool, optPosInt, optString, optStringArray, optStringRecord, usage, } from "./args.js";
25
25
  export { ToolBatchError, batchFailureMessage } from "./batch-error.js";
26
26
  export { mapBounded } from "../async.js";
27
27
  export { McpServer };
28
- export { commentResultSchema, deleteResultSchema, findFilesResultSchema, galleryFindResultSchema, galleryResultSchema, healthResultSchema, hostedOutputSchemas, listResultSchema, metadataFacetsResultSchema, metadataResultSchema, promoteToolResultSchema, purgeExpiredResultSchema, putResultSchema, reconcileResultSchema, repoLinkStatusResultSchema, stdioOutputSchemas, usageResultSchema, withOutputSchemas, } from "./output-schemas.js";
29
- /** Lookup / list / health. Does not change workspace or public state. */
28
+ export { commentResultSchema, deleteResultSchema, findFilesResultSchema, galleryFindResultSchema, galleryResultSchema, hostedOutputSchemas, listResultSchema, metadataFacetsResultSchema, metadataResultSchema, promoteToolResultSchema, purgeExpiredResultSchema, putResultSchema, reconcileResultSchema, repoLinkStatusResultSchema, stdioOutputSchemas, usageResultSchema, withOutputSchemas, } from "./output-schemas.js";
29
+ /** Lookup / list / whoami. Does not change workspace or public state. */
30
30
  export const mcpRead = {
31
31
  readOnlyHint: true,
32
32
  destructiveHint: false,
@@ -56,9 +56,9 @@ function oauth(scopes) {
56
56
  export const mcpOAuthRead = oauth(["files:read"]);
57
57
  export const mcpOAuthWrite = oauth(["files:write"]);
58
58
  export const mcpOAuthDelete = oauth(["files:delete"]);
59
- /** Authenticated, no particular file scope (hosted `health`). */
59
+ /** Authenticated, no particular file scope (hosted `whoami`). */
60
60
  export const mcpOAuthAny = oauth([]);
61
- /** Callable without a token (stdio `health`). */
61
+ /** Callable without a token (stdio `whoami` when unsigned-in). */
62
62
  export const mcpNoAuth = [{ type: "noauth" }];
63
63
  /**
64
64
  * Thrown when a presented token is missing a required scope. wrapHandler
@@ -138,6 +138,18 @@ function wrapHandler(tool, apiUrl) {
138
138
  }
139
139
  };
140
140
  }
141
+ /**
142
+ * Brand mark on `serverInfo.icons` (MCP `Icon`, spec 2026-07-28). PNG is the
143
+ * type clients that render icons MUST support. The file is the site's
144
+ * apple-touch-icon (180×180 pixel chevron), already public on uploads.sh.
145
+ */
146
+ export const MCP_SERVER_ICONS = [
147
+ {
148
+ src: "https://uploads.sh/apple-touch-icon.png",
149
+ mimeType: "image/png",
150
+ sizes: ["180x180"],
151
+ },
152
+ ];
141
153
  export function createMcpServer(opts) {
142
154
  const { serverInfo, tools, apiUrl, validator } = opts;
143
155
  const server = new McpServer(serverInfo, {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * MCP tool set mirroring the CLI commands (put, attach, list, delete,
3
- * usage, reconcile, purge_expired, comment, health, doctor). Config is
3
+ * usage, reconcile, purge_expired, comment, whoami, doctor). Config is
4
4
  * resolved fresh per tool call so a
5
5
  * per-call `workspace` argument behaves like the CLI's --workspace flag, and
6
6
  * a missing token surfaces as a tool error rather than a startup failure.
package/dist/mcp/tools.js CHANGED
@@ -3,13 +3,14 @@ import { buildDoctorReport, ghListPrefixes, ghMergedList, makeGhTarget, mergeSta
3
3
  import { resolveFrameId } from "../frame.js";
4
4
  import { resolveConfig, resolvePutDefaults, } from "../config.js";
5
5
  import { resolvePutPrefix } from "../destinations.js";
6
+ import { fetchUploadSource, resolveUploadFilename } from "../fetch-upload-source.js";
6
7
  import { ghKeyPrefix, ghPrivateKeyPrefix } from "../github.js";
7
8
  import { safeCaptureFacts } from "../capture-facts.js";
8
9
  import { deriveRepoSlugFromGit } from "../keys.js";
9
10
  import { validateMetaMap } from "../metadata.js";
10
11
  import { mergeDerivedMeta } from "../metadata-vocab.js";
11
12
  import { execRunner, ghMetadataFromTargetWithTitle, resolveCurrentBranch, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
12
- import { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, metadataArgWithCanonical, metadataProp, optBool, optPosInt, optString, optStringArray, optStringRecord, stateProp, usage, } from "./args.js";
13
+ import { appProp, canonicalMetaFromArgs, METADATA_PATH_CUE, metadataArgWithCanonical, metadataProp, optBool, optPosInt, optString, optStringArray, optStringRecord, stateProp, usage, } from "./args.js";
13
14
  import { batchFailureMessage, mcpDestroyPublic, mcpNoAuth, mcpOAuthAny, mcpOAuthDelete, mcpOAuthRead, mcpOAuthWrite, mcpRead, mcpWriteInternal, mcpWritePublic, stdioOutputSchemas, withOutputSchemas, ToolBatchError, } from "./server.js";
14
15
  import { attachmentFromText, buildReportPayload, parseReportType, REPORT_TYPES, submitReport, validateReportMessage, } from "../report.js";
15
16
  import { resolveApiUrl } from "../config.js";
@@ -272,30 +273,34 @@ export function createUploadsMcpTools(opts) {
272
273
  title: "Upload file",
273
274
  annotations: mcpDestroyPublic,
274
275
  securitySchemes: mcpOAuthWrite,
275
- 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.",
276
+ description: "Upload one or more files and get a public URL plus GitHub-ready markdown. Prefer `embedUrl` in GitHub markdown. Pass `contentUrl` for a public HTTPS file, or http://localhost on this machine, instead of a local path. With `pr`/`issue`, keys are stable and the managed comment is synced. All uploads are public.",
276
277
  inputSchema: {
277
278
  type: "object",
278
279
  properties: {
279
280
  file: {
280
281
  type: "string",
281
- description: "Path of a single file to upload. Exactly one of file, files, or contentBase64 is required.",
282
+ description: "Path of a single file to upload. Exactly one of file, files, contentBase64, or contentUrl is required.",
282
283
  },
283
284
  files: {
284
285
  type: "array",
285
286
  items: { type: "string" },
286
- description: "Paths of multiple files to upload in parallel. Returns { uploads, failures }. Cannot combine with file, contentBase64, key, or filename.",
287
+ description: "Paths of multiple files to upload in parallel. Returns { uploads, failures }. Cannot combine with file, contentBase64, contentUrl, key, or filename.",
287
288
  },
288
289
  contentBase64: {
289
290
  type: "string",
290
291
  description: "Base64-encoded file content for in-memory uploads; requires filename.",
291
292
  },
293
+ contentUrl: {
294
+ type: "string",
295
+ description: "URL to fetch and upload. Public HTTPS, or http://localhost / 127.0.0.1 / *.localhost on this machine. Filename is optional when the URL path has a leaf. Other private/internal hosts are rejected. Exactly one of file, files, contentBase64, or contentUrl.",
296
+ },
292
297
  filename: {
293
298
  type: "string",
294
- 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.",
299
+ description: "Filename for contentBase64/contentUrl (drives the key and content type). With single `file`, overrides the key's leaf (clean name) while keeping the pr/default path.",
295
300
  },
296
301
  key: {
297
302
  type: "string",
298
- description: "Explicit object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>). Single file only; cannot be combined with pr/issue.",
303
+ description: "Override the object key. Single file only; cannot combine with `pr`/`issue`.",
299
304
  },
300
305
  destination: {
301
306
  type: "string",
@@ -351,7 +356,7 @@ export function createUploadsMcpTools(opts) {
351
356
  },
352
357
  comment: {
353
358
  type: "boolean",
354
- description: "With pr/issue (or auto-detected PR context): create or update the managed attachments comment. Posts as uploads-sh[bot] when the GitHub App is installed on the repo; otherwise via local gh auth (best-effort).",
359
+ description: "With `pr`/`issue` (or an auto-detected PR): create or update the managed attachments comment. Best-effort.",
355
360
  },
356
361
  dryRun: {
357
362
  type: "boolean",
@@ -359,7 +364,7 @@ export function createUploadsMcpTools(opts) {
359
364
  },
360
365
  replace: {
361
366
  type: "boolean",
362
- description: "Allow overwriting an existing object on a strict (non-gh/) key: explicit key, or the default put path. Default false — an existing object there is refused (key_exists) unless this is true or UPLOADS_OVERWRITE=1 is set in the server's environment. No effect on pr/issue keys, which always overwrite.",
367
+ description: "Overwrite an existing object on a non-`gh/` key. Default false (or true if UPLOADS_OVERWRITE=1). No effect on `pr`/`issue` keys, which always overwrite.",
363
368
  },
364
369
  metadata: metadataProp,
365
370
  state: stateProp,
@@ -367,18 +372,34 @@ export function createUploadsMcpTools(opts) {
367
372
  workspace: workspaceProp,
368
373
  },
369
374
  additionalProperties: false,
375
+ examples: [
376
+ { file: "./after.png", pr: 12, state: "after" },
377
+ { file: "./after.png", branch: "feat/settings", state: "after" },
378
+ { files: ["./before.png", "./after.png"], pr: 12 },
379
+ {
380
+ contentUrl: "https://cdn.example/settings-after.png",
381
+ pr: 12,
382
+ state: "after",
383
+ },
384
+ ],
370
385
  },
371
386
  async handler(args) {
372
387
  const file = optString(args, "file");
373
388
  const filesArg = optStringArray(args, "files");
374
389
  const contentBase64 = optString(args, "contentBase64");
390
+ const contentUrl = optString(args, "contentUrl");
375
391
  if (filesArg !== undefined && filesArg.length === 0) {
376
392
  usage("files must be a non-empty array of paths");
377
393
  }
378
394
  const multi = filesArg !== undefined;
379
- const sources = [file !== undefined, multi, contentBase64 !== undefined];
395
+ const sources = [
396
+ file !== undefined,
397
+ multi,
398
+ contentBase64 !== undefined,
399
+ contentUrl !== undefined,
400
+ ];
380
401
  if (sources.filter(Boolean).length !== 1) {
381
- usage("exactly one of file, files, or contentBase64 is required");
402
+ usage("exactly one of file, files, contentBase64, or contentUrl is required");
382
403
  }
383
404
  const filenameArg = optString(args, "filename");
384
405
  if (contentBase64 !== undefined && !filenameArg) {
@@ -548,10 +569,29 @@ export function createUploadsMcpTools(opts) {
548
569
  }
549
570
  return { uploads, failures, ...(hint ? { hint } : {}) };
550
571
  }
551
- // Single-file: contentBase64 still supported; paths go through uploadPuts.
552
- if (contentBase64 !== undefined) {
553
- const sourceName = filenameArg;
554
- const bytes = new Uint8Array(Buffer.from(contentBase64, "base64"));
572
+ // Single-file: contentBase64 / contentUrl; paths go through uploadPuts.
573
+ if (contentBase64 !== undefined || contentUrl !== undefined) {
574
+ let bytes;
575
+ let sourceName;
576
+ if (contentUrl !== undefined) {
577
+ try {
578
+ sourceName = resolveUploadFilename(contentUrl, filenameArg, "contentUrl", {
579
+ allowLoopback: true,
580
+ });
581
+ }
582
+ catch (err) {
583
+ usage(err instanceof Error ? err.message : String(err));
584
+ }
585
+ bytes = await fetchUploadSource(contentUrl, {
586
+ label: "contentUrl",
587
+ userAgent: "uploads.sh/mcp",
588
+ allowLoopback: true,
589
+ });
590
+ }
591
+ else {
592
+ sourceName = filenameArg;
593
+ bytes = new Uint8Array(Buffer.from(contentBase64, "base64"));
594
+ }
555
595
  const { result, prepared, markdown } = await uploadPreparedImage(client, bytes, sourceName, {
556
596
  frame: frameOpts,
557
597
  optimize: optimizeOpts,
@@ -639,7 +679,7 @@ export function createUploadsMcpTools(opts) {
639
679
  title: "Capture screenshot",
640
680
  annotations: mcpDestroyPublic,
641
681
  securitySchemes: mcpOAuthWrite,
642
- 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.",
682
+ description: "Capture a URL or local HTML file and host it. Shares put's attach, comment, and metadata options. `via=local` needs Chrome; `via=remote` renders server-side. localhost URLs are local-only.",
643
683
  inputSchema: {
644
684
  type: "object",
645
685
  properties: {
@@ -756,6 +796,10 @@ export function createUploadsMcpTools(opts) {
756
796
  },
757
797
  required: ["target"],
758
798
  additionalProperties: false,
799
+ examples: [
800
+ { target: "http://localhost:4321/settings", pr: 12, state: "after" },
801
+ { target: "http://localhost:4321/settings", fullPage: true, state: "empty" },
802
+ ],
759
803
  },
760
804
  async handler(args) {
761
805
  const targetArg = optString(args, "target");
@@ -1055,17 +1099,17 @@ export function createUploadsMcpTools(opts) {
1055
1099
  description: "Keep EXIF/XMP/ICC when optimizing (default: strip for privacy on public embeds).",
1056
1100
  },
1057
1101
  ...frameProps,
1058
- metadata: {
1059
- ...metadataProp,
1060
- description: "Extra queryable metadata (key→value), merged with the automatic gh.repo/gh.kind/gh.number/gh.ref pairs — a gh.* pair here loses to the resolved target's own gh.* value. " +
1061
- METADATA_DESCRIPTION,
1062
- },
1102
+ metadata: metadataProp,
1063
1103
  state: stateProp,
1064
1104
  app: appProp,
1065
1105
  workspace: workspaceProp,
1066
1106
  },
1067
1107
  required: ["files"],
1068
1108
  additionalProperties: false,
1109
+ examples: [
1110
+ { files: ["./after.png"], pr: 12, state: "after" },
1111
+ { files: ["./before.png", "./after.png"], pr: 12 },
1112
+ ],
1069
1113
  },
1070
1114
  async handler(args) {
1071
1115
  const files = optStringArray(args, "files");
@@ -1181,7 +1225,7 @@ export function createUploadsMcpTools(opts) {
1181
1225
  title: "List staged files",
1182
1226
  annotations: mcpRead,
1183
1227
  securitySchemes: mcpOAuthRead,
1184
- description: "Read-only view of what's staged for a git branch (attach --branch / bare put on a non-default branch) and whether it will auto-attach once a PR opens. One list call against the branch staging prefix plus a repo-binding check (files:read only). Returns { repo, branch, files, binding }; binding.state is self/other/none/unknown and binding.autoAttach is true only for self.",
1228
+ description: "List files staged for a git branch and whether they will auto-attach when a PR opens. Returns `{ repo, branch, files, binding }`.",
1185
1229
  inputSchema: {
1186
1230
  type: "object",
1187
1231
  properties: {
@@ -1238,7 +1282,7 @@ export function createUploadsMcpTools(opts) {
1238
1282
  title: "Get metadata",
1239
1283
  annotations: mcpRead,
1240
1284
  securitySchemes: mcpOAuthRead,
1241
- 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`.",
1285
+ description: "Read the queryable tags on one file. Returns `{ metadata }` (empty when none). Same as `uploads meta get`.",
1242
1286
  inputSchema: {
1243
1287
  type: "object",
1244
1288
  properties: {
@@ -1260,14 +1304,15 @@ export function createUploadsMcpTools(opts) {
1260
1304
  title: "Set metadata",
1261
1305
  annotations: mcpWritePublic,
1262
1306
  securitySchemes: mcpOAuthWrite,
1263
- 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. " +
1264
- METADATA_DESCRIPTION +
1265
- " Requires at least one of `set` or `delete`. Same as `uploads meta set`.",
1307
+ description: "Set or delete queryable tags on an existing file. `set` wins over `delete` for the same key. Requires `set` and/or `delete`. Same as `uploads meta set`.",
1266
1308
  inputSchema: {
1267
1309
  type: "object",
1268
1310
  properties: {
1269
1311
  key: { type: "string", description: "Object key to update." },
1270
- set: { ...metadataProp, description: "Keys to set/overwrite. " + METADATA_DESCRIPTION },
1312
+ set: {
1313
+ ...metadataProp,
1314
+ description: "Keys to set or overwrite. " + METADATA_PATH_CUE,
1315
+ },
1271
1316
  delete: {
1272
1317
  type: "array",
1273
1318
  items: { type: "string" },
@@ -1277,6 +1322,7 @@ export function createUploadsMcpTools(opts) {
1277
1322
  },
1278
1323
  required: ["key"],
1279
1324
  additionalProperties: false,
1325
+ examples: [{ key: "screenshots/settings.png", set: { path: "/settings", state: "after" } }],
1280
1326
  },
1281
1327
  async handler(args) {
1282
1328
  const key = optString(args, "key");
@@ -1298,13 +1344,15 @@ export function createUploadsMcpTools(opts) {
1298
1344
  title: "Find files",
1299
1345
  annotations: mcpRead,
1300
1346
  securitySchemes: mcpOAuthRead,
1301
- description: "Find objects whose queryable custom metadata matches ALL of `filters` (ANDed equality) and/or whose key contains `name` (case-insensitive substring). At least one of `filters` or `name` is required. Returns each match's key, public URL, full metadata map, optional `truncated`, and a `cursor` to pass back for the next page (null when there are none; set `all` to follow it for you). Same as `uploads find k=v...` / `uploads find --name <term>`.",
1347
+ description: "Search files by metadata (`filters`) and/or filename substring (`name`). At least one is required. Same as `uploads find`.",
1302
1348
  inputSchema: {
1303
1349
  type: "object",
1304
1350
  properties: {
1305
1351
  filters: {
1306
1352
  ...metadataProp,
1307
- description: "Metadata equality filters (optional when `name` is set). " + METADATA_DESCRIPTION,
1353
+ description: "Equality filters; all must match. " +
1354
+ METADATA_PATH_CUE +
1355
+ " Optional when `name` is set.",
1308
1356
  },
1309
1357
  name: {
1310
1358
  type: "string",
@@ -1326,6 +1374,7 @@ export function createUploadsMcpTools(opts) {
1326
1374
  workspace: workspaceProp,
1327
1375
  },
1328
1376
  additionalProperties: false,
1377
+ examples: [{ filters: { path: "/settings", state: "after" } }, { name: "hero.png" }],
1329
1378
  },
1330
1379
  async handler(args) {
1331
1380
  const filters = optStringRecord(args, "filters") ?? {};
@@ -1355,7 +1404,7 @@ export function createUploadsMcpTools(opts) {
1355
1404
  title: "List metadata keys",
1356
1405
  annotations: mcpRead,
1357
1406
  securitySchemes: mcpOAuthRead,
1358
- description: "List the distinct queryable metadata keys present in the workspace, with file counts and distinct-value counts. Use this to discover what is filterable before calling find_files — keys are user/agent-defined, not a fixed schema. Same as `uploads meta keys`. Pass optional `key` to list that key's values instead (`uploads meta values <key>`).",
1407
+ description: "List metadata keys in the workspace (with counts). Pass `key` to list that key's values instead. Use before `find_files`. Same as `uploads meta keys`.",
1359
1408
  inputSchema: {
1360
1409
  type: "object",
1361
1410
  properties: {
@@ -1434,6 +1483,7 @@ export function createUploadsMcpTools(opts) {
1434
1483
  workspace: workspaceProp,
1435
1484
  },
1436
1485
  additionalProperties: false,
1486
+ examples: [{ pr: 12 }],
1437
1487
  },
1438
1488
  async handler(args) {
1439
1489
  const target = ghTargetFromArgs(args, run);
@@ -1448,16 +1498,32 @@ export function createUploadsMcpTools(opts) {
1448
1498
  },
1449
1499
  },
1450
1500
  {
1451
- name: "health",
1452
- title: "Check health",
1501
+ name: "whoami",
1502
+ title: "Who am I",
1453
1503
  annotations: mcpRead,
1454
1504
  securitySchemes: mcpNoAuth,
1455
- description: "Check uploads.sh API liveness. No auth or arguments required.",
1505
+ description: "Show the active uploads.sh identity: workspace, API URL, and token scopes. Use this to learn which workspace you're talking to. A successful result also means the API is up. For a full setup diagnosis, use `doctor`.",
1456
1506
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
1457
1507
  async handler(args) {
1458
1508
  const { config, client } = await clientFor(args, false);
1459
- const result = await client.health();
1460
- return { ...result, apiUrl: config.apiUrl };
1509
+ const health = await client.health();
1510
+ const signedIn = Boolean(config.token);
1511
+ let scopes;
1512
+ if (signedIn) {
1513
+ try {
1514
+ scopes = (await client.usage()).scopes;
1515
+ }
1516
+ catch {
1517
+ // Workspace and API URL are still useful if usage is unavailable.
1518
+ }
1519
+ }
1520
+ return {
1521
+ ok: health.ok,
1522
+ signedIn,
1523
+ workspace: config.workspace,
1524
+ apiUrl: config.apiUrl,
1525
+ ...(scopes ? { scopes } : {}),
1526
+ };
1461
1527
  },
1462
1528
  },
1463
1529
  {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Literal-form private/loopback/link-local host checks. Mirrors the API's
3
+ * `isPrivateRenderTarget` so CLI `--via remote` and `put --url` reject the
4
+ * same targets the render/fetch endpoints would.
5
+ *
6
+ * Accepts a bare hostname or an IPv6 literal with its brackets still attached
7
+ * (as returned by `new URL(...).hostname`, e.g. `"[::1]"`).
8
+ */
9
+ /**
10
+ * Loopback only: `localhost`, `*.localhost`, `127.0.0.0/8`, `::1`.
11
+ * Not RFC1918, not link-local, not `.internal` — those stay blocked even on the CLI.
12
+ */
13
+ export declare function isLoopbackHost(hostname: string): boolean;
14
+ export declare function isPrivateOrLocalHost(hostname: string): boolean;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Literal-form private/loopback/link-local host checks. Mirrors the API's
3
+ * `isPrivateRenderTarget` so CLI `--via remote` and `put --url` reject the
4
+ * same targets the render/fetch endpoints would.
5
+ *
6
+ * Accepts a bare hostname or an IPv6 literal with its brackets still attached
7
+ * (as returned by `new URL(...).hostname`, e.g. `"[::1]"`).
8
+ */
9
+ /** IPv4 loopback/private/link-local ranges. */
10
+ const PRIVATE_IPV4_RE = /^(127\.\d+\.\d+\.\d+|0\.0\.0\.0|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|169\.254\.\d+\.\d+)$/;
11
+ /** Hostname forms treated as local/private regardless of DNS resolution. */
12
+ const PRIVATE_HOSTNAME_RE = /^((.+\.)?localhost|.+\.local|.+\.internal)$/i;
13
+ /** IPv6 unique local addresses, fc00::/7 (RFC 4193). */
14
+ const IPV6_ULA_RE = /^f[cd][0-9a-f]{2}:/i;
15
+ /** IPv6 link-local addresses, fe80::/10. */
16
+ const IPV6_LINK_LOCAL_RE = /^fe[89ab][0-9a-f]:/i;
17
+ function isPrivateIPv4(host) {
18
+ return PRIVATE_IPV4_RE.test(host);
19
+ }
20
+ function stripBrackets(hostname) {
21
+ return /^\[.+\]$/.test(hostname) ? hostname.slice(1, -1) : hostname;
22
+ }
23
+ /** IPv4-mapped IPv6 tail (`::ffff:127.0.0.1` or `::ffff:7f00:1`) as a dotted quad. */
24
+ function mappedIpv4(host) {
25
+ const mapped = /^::ffff:(.+)$/i.exec(host);
26
+ if (!mapped)
27
+ return undefined;
28
+ const rest = mapped[1];
29
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(rest))
30
+ return rest;
31
+ const hex = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(rest);
32
+ if (!hex)
33
+ return undefined;
34
+ const hi = Number.parseInt(hex[1], 16);
35
+ const lo = Number.parseInt(hex[2], 16);
36
+ const a = (hi >> 8) & 0xff;
37
+ const b = hi & 0xff;
38
+ const c = (lo >> 8) & 0xff;
39
+ const d = lo & 0xff;
40
+ return `${a}.${b}.${c}.${d}`;
41
+ }
42
+ function isLoopbackIpv4(host) {
43
+ return /^127\.\d+\.\d+\.\d+$/.test(host);
44
+ }
45
+ /**
46
+ * Loopback only: `localhost`, `*.localhost`, `127.0.0.0/8`, `::1`.
47
+ * Not RFC1918, not link-local, not `.internal` — those stay blocked even on the CLI.
48
+ */
49
+ export function isLoopbackHost(hostname) {
50
+ const host = stripBrackets(hostname);
51
+ if (host === "localhost" || host.toLowerCase().endsWith(".localhost"))
52
+ return true;
53
+ if (host === "::1")
54
+ return true;
55
+ if (isLoopbackIpv4(host))
56
+ return true;
57
+ const mapped = mappedIpv4(host);
58
+ return mapped !== undefined && isLoopbackIpv4(mapped);
59
+ }
60
+ export function isPrivateOrLocalHost(hostname) {
61
+ const host = stripBrackets(hostname);
62
+ if (isPrivateIPv4(host))
63
+ return true;
64
+ if (PRIVATE_HOSTNAME_RE.test(host))
65
+ return true;
66
+ if (host === "::1" || host === "::")
67
+ return true;
68
+ if (IPV6_ULA_RE.test(host))
69
+ return true;
70
+ if (IPV6_LINK_LOCAL_RE.test(host))
71
+ return true;
72
+ // IPv4-mapped IPv6, e.g. "::ffff:10.0.0.1" or "::ffff:a00:1" — private iff
73
+ // the mapped IPv4 quad is private.
74
+ const mapped = /^::ffff:(.+)$/i.exec(host);
75
+ if (mapped) {
76
+ const rest = mapped[1];
77
+ if (isPrivateIPv4(rest))
78
+ return true;
79
+ const hex = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(rest);
80
+ if (hex) {
81
+ const hi = Number.parseInt(hex[1], 16);
82
+ const lo = Number.parseInt(hex[2], 16);
83
+ const a = (hi >> 8) & 0xff;
84
+ const b = hi & 0xff;
85
+ const c = (lo >> 8) & 0xff;
86
+ const d = lo & 0xff;
87
+ if (isPrivateIPv4(`${a}.${b}.${c}.${d}`))
88
+ return true;
89
+ }
90
+ }
91
+ return false;
92
+ }
@@ -1,5 +1,7 @@
1
+ import { isPrivateOrLocalHost } from "./private-host.js";
1
2
  import { captureRemote } from "./screenshot-remote.js";
2
3
  import type { DetectRoots } from "./screenshot-local.js";
4
+ export { isPrivateOrLocalHost };
3
5
  export type ScreenshotBackend = "auto" | "local" | "remote";
4
6
  export type WaitUntil = "load" | "domcontentloaded" | "networkidle" | number;
5
7
  /**
@@ -48,14 +50,6 @@ export type ScreenshotTarget = {
48
50
  path: string;
49
51
  html: string;
50
52
  };
51
- /**
52
- * True for localhost / private-network / link-local hosts — only reachable
53
- * by the local backend. Accepts a bare hostname or an IPv6 literal with its
54
- * brackets still attached (as returned by `new URL(...).hostname`, e.g.
55
- * `"[::1]"`). Mirrors the server's `isPrivateRenderTarget` so `--via remote`
56
- * fails fast for the same targets the render endpoint itself would reject.
57
- */
58
- export declare function isPrivateOrLocalHost(hostname: string): boolean;
59
53
  /** Classifies a CLI target: http(s) URL, or a path to a local .html file. */
60
54
  export declare function classifyTarget(target: string): ScreenshotTarget;
61
55
  /** A measured element box in device (raster) pixels — CSS pixels × deviceScaleFactor. */