@buildinternet/uploads 0.41.1 → 0.42.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.
@@ -79,4 +79,6 @@ export declare function flagBool(flags: CommandFlags["flags"], name: string): bo
79
79
  export declare function flagValues(flags: CommandFlags["flags"], name: string): string[];
80
80
  /** Command-level workspace override (`--workspace` / `-w`). */
81
81
  export declare function commandWorkspace(flags: CommandFlags["flags"]): string | undefined;
82
- export declare function flagInt(flags: CommandFlags["flags"], name: string, label: string): number | undefined;
82
+ export declare function flagInt(flags: CommandFlags["flags"], name: string, label: string, options?: {
83
+ allowZero?: boolean;
84
+ }): number | undefined;
package/dist/cli-args.js CHANGED
@@ -198,16 +198,18 @@ export function flagValues(flags, name) {
198
198
  export function commandWorkspace(flags) {
199
199
  return flagString(flags, "--workspace") ?? flagString(flags, "-w");
200
200
  }
201
- export function flagInt(flags, name, label) {
201
+ export function flagInt(flags, name, label, options) {
202
+ const allowZero = options?.allowZero ?? false;
203
+ const kind = allowZero ? "non-negative" : "positive";
202
204
  const raw = flagString(flags, name);
203
205
  if (raw === undefined)
204
206
  return undefined;
205
207
  if (!/^\d+$/.test(raw)) {
206
- throw new UsageError(`invalid ${label}: must be a positive integer (got ${raw})`);
208
+ throw new UsageError(`invalid ${label}: must be a ${kind} integer (got ${raw})`);
207
209
  }
208
210
  const n = Number.parseInt(raw, 10);
209
- if (!Number.isFinite(n) || n <= 0) {
210
- throw new UsageError(`invalid ${label}: must be a positive integer (got ${raw})`);
211
+ if (!Number.isFinite(n) || (allowZero ? n < 0 : n <= 0)) {
212
+ throw new UsageError(`invalid ${label}: must be a ${kind} integer (got ${raw})`);
211
213
  }
212
214
  return n;
213
215
  }
@@ -56,6 +56,7 @@ export const SCREENSHOT_FLAGS = [
56
56
  "--viewport",
57
57
  "--selector",
58
58
  "--full-page",
59
+ "--max-height",
59
60
  "--dark",
60
61
  "--light",
61
62
  "--wait",
@@ -13,7 +13,7 @@ import { parseMetaFlags, validateMetaMap } from "../metadata.js";
13
13
  import { mergeDerivedMeta } from "../metadata-vocab.js";
14
14
  import { writeSidecarMeta } from "../sidecar.js";
15
15
  import { readStdin, writeJson, writeStdout } from "../io.js";
16
- import { assertHideSelector, captureScreenshot, parseViewport, parseWaitUntil, } from "../screenshot.js";
16
+ import { assertHideSelector, captureScreenshot, clipHintText, DEFAULT_FULL_PAGE_MAX_HEIGHT, parseViewport, parseWaitUntil, } from "../screenshot.js";
17
17
  const SCREENSHOT_HELP = `uploads screenshot <target> [options]
18
18
 
19
19
  Capture a URL or a local .html file and host it — a hosted, PR-embeddable
@@ -59,6 +59,10 @@ Options:
59
59
  --viewport <WxH[@Sx]> Size + device scale factor (default: 1280x800@2)
60
60
  --selector <css> Capture one element instead of the viewport
61
61
  --full-page Capture the full scrollable page
62
+ --max-height <px> Cap on full-page capture height in CSS px (default: ${DEFAULT_FULL_PAGE_MAX_HEIGHT},
63
+ or 0 for uncapped). A page over the cap is clipped, with a note printed
64
+ to stderr and a --format json \`hint\`. Requires --full-page. Applied on
65
+ both --via local and --via remote so behavior matches.
62
66
  --dark / --light Emulate prefers-color-scheme (full media-query emulation on --via local
63
67
  only; --via remote just sets the CSS color-scheme property, so a page's
64
68
  own prefers-color-scheme queries won't flip)
@@ -182,6 +186,14 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
182
186
  const viewport = parseViewport(flagString(parsed.flags, "--viewport"));
183
187
  const selector = flagString(parsed.flags, "--selector");
184
188
  const fullPage = flagBool(parsed.flags, "--full-page");
189
+ // Unlike every other --…-px flag, 0 is a valid (uncapped) value here, not
190
+ // an error — flagInt's allowZero option covers it.
191
+ const maxHeightFlag = flagInt(parsed.flags, "--max-height", "--max-height", {
192
+ allowZero: true,
193
+ });
194
+ if (maxHeightFlag !== undefined && !fullPage) {
195
+ throw new UsageError("--max-height requires --full-page");
196
+ }
185
197
  const colorScheme = colorSchemeFromFlags(parsed.flags);
186
198
  const waitUntil = parseWaitUntil(flagString(parsed.flags, "--wait"));
187
199
  const hide = flagValues(parsed.flags, "--hide");
@@ -387,6 +399,7 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
387
399
  viewport,
388
400
  selector,
389
401
  fullPage,
402
+ maxHeight: maxHeightFlag,
390
403
  colorScheme,
391
404
  waitUntil,
392
405
  hide,
@@ -403,6 +416,14 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
403
416
  });
404
417
  if (logHuman)
405
418
  process.stderr.write(`>> captured via ${captured.backend} backend\n`);
419
+ // Full-page height cap note (issue #652): printed regardless of --format
420
+ // (only --quiet suppresses it) since it's directly actionable info about
421
+ // the image that was just captured, same as the upload-tail warnings below.
422
+ const clipHint = captured.capped?.clipped
423
+ ? clipHintText(captured.capped.maxHeightPx, "--max-height")
424
+ : undefined;
425
+ if (clipHint && !ctx.quiet)
426
+ process.stderr.write(`${clipHint}\n`);
406
427
  // Resolve selectors + render annotations before the frame/optimize/upload
407
428
  // pipeline runs — everything downstream (the --out write, the sidecar
408
429
  // hash, and the upload itself) should see the annotated bytes.
@@ -543,9 +564,11 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
543
564
  process.stderr.write(`${bindingWarning}\n`);
544
565
  process.stderr.write("\n");
545
566
  }
546
- // One JSON `hint` slot (mirrors bare put): the binding warning is more
547
- // actionable than the generic staging note, so it wins when both fire; a
548
- // replaced-object note (issue #618) is the lowest priority of the three
567
+ // One JSON `hint` slot (mirrors bare put): the clip note (issue #652) wins
568
+ // first it's about the just-captured image itself, more immediately
569
+ // actionable than the other three, which are about upload/staging
570
+ // mechanics. Then the binding warning, more actionable than the generic
571
+ // staging note; a replaced-object note (issue #618) is lowest priority —
549
572
  // it only surfaces when nothing else already claimed the slot. Since state
550
573
  // folds into the derived key, replaced + state means a same-side re-capture,
551
574
  // which is the intended replace-in-place flow — word it as informational,
@@ -553,7 +576,7 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
553
576
  const replacedHint = result.replaced && explicitMeta.state
554
577
  ? `re-capture replaced the previous state=${explicitMeta.state} object at ${result.key} — expected for repeat captures of the same URL + state`
555
578
  : undefined;
556
- const jsonHint = bindingWarning ?? stagingNote ?? replacedHint;
579
+ const jsonHint = clipHint ?? bindingWarning ?? stagingNote ?? replacedHint;
557
580
  switch (format) {
558
581
  case "json":
559
582
  await writeJson({
@@ -3,7 +3,9 @@ export declare function usage(msg: string): never;
3
3
  export declare function optString(args: ToolArgs, name: string): string | undefined;
4
4
  /** A boolean flag argument; missing/null reads as `false`. */
5
5
  export declare function optBool(args: ToolArgs, name: string): boolean;
6
- export declare function optPosInt(args: ToolArgs, name: string): number | undefined;
6
+ export declare function optPosInt(args: ToolArgs, name: string, options?: {
7
+ allowZero?: boolean;
8
+ }): number | undefined;
7
9
  /** A JSON-object argument of string→string pairs (e.g. a `metadata` or `filters` param). */
8
10
  export declare function optStringRecord(args: ToolArgs, name: string): Record<string, string> | undefined;
9
11
  /** A JSON-array argument of strings (e.g. a `delete` or `files` param). */
package/dist/mcp/args.js CHANGED
@@ -25,12 +25,13 @@ export function optBool(args, name) {
25
25
  usage(`${name} must be a boolean`);
26
26
  return v;
27
27
  }
28
- export function optPosInt(args, name) {
28
+ export function optPosInt(args, name, options) {
29
+ const allowZero = options?.allowZero ?? false;
29
30
  const v = args[name];
30
31
  if (v === undefined || v === null)
31
32
  return undefined;
32
- if (typeof v !== "number" || !Number.isInteger(v) || v <= 0) {
33
- usage(`${name} must be a positive integer`);
33
+ if (typeof v !== "number" || !Number.isInteger(v) || (allowZero ? v < 0 : v <= 0)) {
34
+ usage(`${name} must be a ${allowZero ? "non-negative" : "positive"} integer`);
34
35
  }
35
36
  return v;
36
37
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * JSON Schema for MCP tool `structuredContent`. The SDK validates successful
3
+ * results against the advertised `outputSchema`, so every property a handler
4
+ * actually returns must be listed. Roots are always `type: object` — a
5
+ * oneOf/anyOf root is treated as non-object by the 2025-era codec and wraps
6
+ * the result as `{ result: … }`.
7
+ */
8
+ export type JsonSchema = Record<string, unknown>;
9
+ /** Hosted + stdio `comment` / `put.comment` — all PostCommentResult variants. */
10
+ export declare const commentResultSchema: JsonSchema;
11
+ /** Single-file put (flat) or multi-file `{ uploads, failures }`, plus optional comment/promote extras. */
12
+ export declare const putResultSchema: JsonSchema;
13
+ export declare const listResultSchema: JsonSchema;
14
+ export declare const deleteResultSchema: JsonSchema;
15
+ export declare const metadataResultSchema: JsonSchema;
16
+ export declare const findFilesResultSchema: JsonSchema;
17
+ /** `meta keys` or `meta values <key>`. */
18
+ export declare const metadataFacetsResultSchema: JsonSchema;
19
+ export declare const repoLinkStatusResultSchema: JsonSchema;
20
+ export declare const usageResultSchema: JsonSchema;
21
+ export declare const reconcileResultSchema: JsonSchema;
22
+ export declare const purgeExpiredResultSchema: JsonSchema;
23
+ export declare const healthResultSchema: JsonSchema;
24
+ export declare const promoteToolResultSchema: JsonSchema;
25
+ export declare const galleryResultSchema: JsonSchema;
26
+ export declare const galleryFindResultSchema: JsonSchema;
27
+ /** Hosted catalog — every tool must have an entry. */
28
+ export declare const hostedOutputSchemas: Record<string, JsonSchema>;
29
+ /** Shared-shape stdio tools. Hosted-only tools (`promote`, `repo_link_status`) omitted. */
30
+ export declare const stdioOutputSchemas: Record<string, JsonSchema>;
31
+ export declare function withOutputSchemas<T extends {
32
+ name: string;
33
+ }>(tools: T[], schemas: Record<string, JsonSchema>, opts: {
34
+ required: boolean;
35
+ }): Array<T & {
36
+ outputSchema?: JsonSchema;
37
+ }>;
@@ -0,0 +1,338 @@
1
+ function objectSchema(properties, required = []) {
2
+ return {
3
+ type: "object",
4
+ properties,
5
+ ...(required.length > 0 ? { required } : {}),
6
+ additionalProperties: false,
7
+ };
8
+ }
9
+ const stringMap = {
10
+ type: "object",
11
+ additionalProperties: { type: "string" },
12
+ };
13
+ const nullableString = { type: ["string", "null"] };
14
+ const nullableNumber = { type: ["number", "null"] };
15
+ /** Hosted + stdio `comment` / `put.comment` — all PostCommentResult variants. */
16
+ export const commentResultSchema = objectSchema({
17
+ posted: { type: "boolean" },
18
+ reason: {
19
+ type: "string",
20
+ enum: [
21
+ "app_unconfigured",
22
+ "not_installed",
23
+ "not_authorized",
24
+ "actor_not_authorized",
25
+ "unavailable",
26
+ "forbidden",
27
+ ],
28
+ },
29
+ message: { type: "string" },
30
+ fixUrl: { type: "string" },
31
+ required: { type: "array", items: { type: "string" } },
32
+ action: { type: "string", enum: ["skipped", "created", "updated"] },
33
+ count: { type: "number" },
34
+ commentUrl: { type: "string" },
35
+ }, ["posted"]);
36
+ const promoteResultSchema = objectSchema({
37
+ promoted: { type: "array", items: { type: "string" } },
38
+ skipped: {
39
+ type: "array",
40
+ items: objectSchema({ key: { type: "string" }, reason: { type: "string" } }, [
41
+ "key",
42
+ "reason",
43
+ ]),
44
+ },
45
+ }, ["promoted", "skipped"]);
46
+ const putObjectFields = {
47
+ key: { type: "string" },
48
+ url: nullableString,
49
+ embedUrl: nullableString,
50
+ size: { type: "number" },
51
+ contentType: { type: "string" },
52
+ replaced: { type: "boolean" },
53
+ markdown: { type: "string" },
54
+ provenance: stringMap,
55
+ metadata: stringMap,
56
+ visibility: { type: "string" },
57
+ };
58
+ /** Hosted `PostCommentResult` or stdio `AttachmentsCommentResult` (`via`/`action`). */
59
+ const putCommentSchema = objectSchema({
60
+ posted: { type: "boolean" },
61
+ reason: {
62
+ type: "string",
63
+ enum: [
64
+ "app_unconfigured",
65
+ "not_installed",
66
+ "not_authorized",
67
+ "actor_not_authorized",
68
+ "unavailable",
69
+ "forbidden",
70
+ ],
71
+ },
72
+ message: { type: "string" },
73
+ fixUrl: { type: "string" },
74
+ required: { type: "array", items: { type: "string" } },
75
+ action: { type: "string", enum: ["skipped", "created", "updated"] },
76
+ count: { type: "number" },
77
+ commentUrl: { type: "string" },
78
+ via: { type: "string", enum: ["bot", "gh"] },
79
+ });
80
+ const putExtras = {
81
+ comment: putCommentSchema,
82
+ commentError: { type: "string" },
83
+ promotion: promoteResultSchema,
84
+ promoteError: { type: "string" },
85
+ };
86
+ const putFailure = objectSchema({
87
+ file: { type: "string" },
88
+ error: objectSchema({
89
+ message: { type: "string" },
90
+ code: { type: "string" },
91
+ status: { type: "number" },
92
+ }, ["message"]),
93
+ }, ["file", "error"]);
94
+ /** Single-file put (flat) or multi-file `{ uploads, failures }`, plus optional comment/promote extras. */
95
+ export const putResultSchema = objectSchema({
96
+ workspace: { type: "string" },
97
+ ...putObjectFields,
98
+ file: { type: "string" },
99
+ uploads: {
100
+ type: "array",
101
+ items: {
102
+ type: "object",
103
+ properties: { file: { type: "string" }, ...putObjectFields },
104
+ additionalProperties: true,
105
+ },
106
+ },
107
+ failures: { type: "array", items: putFailure },
108
+ ...putExtras,
109
+ // stdio put adds client-side optimize/frame provenance and dry-run flags.
110
+ optimize: { type: "object", additionalProperties: true },
111
+ frame: { type: "object", additionalProperties: true },
112
+ dryRun: { type: "boolean" },
113
+ hint: { type: "string" },
114
+ });
115
+ export const listResultSchema = objectSchema({
116
+ items: {
117
+ type: "array",
118
+ items: objectSchema({
119
+ key: { type: "string" },
120
+ url: nullableString,
121
+ embedUrl: nullableString,
122
+ size: { type: "number" },
123
+ contentType: { type: "string" },
124
+ uploaded: { type: "string" },
125
+ visibility: { type: "string" },
126
+ pageUrl: { type: "string" },
127
+ }),
128
+ },
129
+ cursor: nullableString,
130
+ prefixes: { type: "array", items: { type: "string" } },
131
+ });
132
+ export const deleteResultSchema = objectSchema({
133
+ key: { type: "string" },
134
+ deleted: { type: "boolean" },
135
+ dryRun: { type: "boolean" },
136
+ }, ["key"]);
137
+ export const metadataResultSchema = objectSchema({ metadata: stringMap }, ["metadata"]);
138
+ export const findFilesResultSchema = objectSchema({
139
+ items: {
140
+ type: "array",
141
+ items: objectSchema({
142
+ key: { type: "string" },
143
+ url: nullableString,
144
+ metadata: stringMap,
145
+ }, ["key"]),
146
+ },
147
+ cursor: nullableString,
148
+ truncated: { type: "boolean" },
149
+ }, ["items"]);
150
+ /** `meta keys` or `meta values <key>`. */
151
+ export const metadataFacetsResultSchema = objectSchema({
152
+ keys: {
153
+ type: "array",
154
+ items: objectSchema({
155
+ key: { type: "string" },
156
+ count: { type: "number" },
157
+ distinctValues: { type: "number" },
158
+ }, ["key", "count", "distinctValues"]),
159
+ },
160
+ truncated: { type: "boolean" },
161
+ key: { type: "string" },
162
+ values: {
163
+ type: "array",
164
+ items: objectSchema({ value: { type: "string" }, count: { type: "number" } }, [
165
+ "value",
166
+ "count",
167
+ ]),
168
+ },
169
+ });
170
+ export const repoLinkStatusResultSchema = objectSchema({ binding: { type: "string", enum: ["self", "other", "none"] } }, ["binding"]);
171
+ export const usageResultSchema = objectSchema({
172
+ workspace: { type: "string" },
173
+ bytes: { type: "number" },
174
+ objects: { type: "number" },
175
+ uploadsInPeriod: { type: "number" },
176
+ periodStart: { type: "string" },
177
+ updatedAt: { type: "string" },
178
+ maxStorageBytes: { type: "number" },
179
+ storageRemainingBytes: { type: "number" },
180
+ maxUploadsPerPeriod: { type: "number" },
181
+ uploadsRemaining: { type: "number" },
182
+ });
183
+ export const reconcileResultSchema = objectSchema({
184
+ workspace: { type: "string" },
185
+ bytes: { type: "number" },
186
+ objects: { type: "number" },
187
+ previous: objectSchema({ bytes: { type: "number" }, objects: { type: "number" } }, [
188
+ "bytes",
189
+ "objects",
190
+ ]),
191
+ changed: { type: "boolean" },
192
+ usage: usageResultSchema,
193
+ unprefixedBucket: { type: "boolean" },
194
+ });
195
+ export const purgeExpiredResultSchema = objectSchema({
196
+ skipped: { type: "boolean" },
197
+ reason: { type: "string" },
198
+ workspace: { type: "string" },
199
+ retentionDays: { type: "number" },
200
+ cutoff: { type: "string" },
201
+ deleted: { type: "number" },
202
+ freedBytes: { type: "number" },
203
+ keys: { type: "array", items: { type: "string" } },
204
+ keysTruncated: { type: "boolean" },
205
+ reconcile: reconcileResultSchema,
206
+ });
207
+ export const healthResultSchema = objectSchema({
208
+ ok: { type: "boolean" },
209
+ apiUrl: { type: "string" },
210
+ });
211
+ export const promoteToolResultSchema = objectSchema({
212
+ promotion: promoteResultSchema,
213
+ comment: commentResultSchema,
214
+ commentError: { type: "string" },
215
+ }, ["promotion"]);
216
+ const galleryItemSchema = objectSchema({
217
+ id: { type: "string" },
218
+ objectKey: { type: "string" },
219
+ filename: { type: "string" },
220
+ position: { type: "number" },
221
+ caption: nullableString,
222
+ altText: nullableString,
223
+ createdAt: { type: "string" },
224
+ status: { type: "string", enum: ["available", "missing", "withheld"] },
225
+ url: nullableString,
226
+ embedUrl: nullableString,
227
+ pageUrl: { type: "string" },
228
+ contentType: nullableString,
229
+ size: nullableNumber,
230
+ uploaded: nullableString,
231
+ modified: nullableString,
232
+ posterUrl: { type: "string" },
233
+ videoDimensions: {
234
+ type: "object",
235
+ additionalProperties: true,
236
+ },
237
+ });
238
+ const galleryReferenceSchema = objectSchema({
239
+ id: { type: "string" },
240
+ provider: { type: "string" },
241
+ resourceType: { type: "string" },
242
+ coordinate: { type: "string" },
243
+ canonicalUrl: nullableString,
244
+ createdAt: { type: "string" },
245
+ title: { type: "string" },
246
+ kind: { type: "string", enum: ["pull", "issue"] },
247
+ });
248
+ export const galleryResultSchema = objectSchema({
249
+ id: { type: "string" },
250
+ url: { type: "string" },
251
+ workspace: { type: "string" },
252
+ title: { type: "string" },
253
+ description: nullableString,
254
+ visibility: { type: "string" },
255
+ coverItemId: nullableString,
256
+ version: { type: "number" },
257
+ createdAt: { type: "string" },
258
+ updatedAt: { type: "string" },
259
+ items: { type: "array", items: galleryItemSchema },
260
+ itemCount: { type: "number" },
261
+ references: { type: "array", items: galleryReferenceSchema },
262
+ });
263
+ export const galleryFindResultSchema = objectSchema({
264
+ galleries: { type: "array", items: galleryResultSchema },
265
+ nextCursor: nullableString,
266
+ });
267
+ /** Hosted catalog — every tool must have an entry. */
268
+ export const hostedOutputSchemas = {
269
+ gallery_create: galleryResultSchema,
270
+ gallery_get: galleryResultSchema,
271
+ gallery_add: galleryItemSchema,
272
+ gallery_link: galleryReferenceSchema,
273
+ gallery_find_by_reference: galleryFindResultSchema,
274
+ put: putResultSchema,
275
+ list: listResultSchema,
276
+ delete: deleteResultSchema,
277
+ comment: commentResultSchema,
278
+ promote: promoteToolResultSchema,
279
+ get_metadata: metadataResultSchema,
280
+ set_metadata: metadataResultSchema,
281
+ find_files: findFilesResultSchema,
282
+ list_metadata_keys: metadataFacetsResultSchema,
283
+ repo_link_status: repoLinkStatusResultSchema,
284
+ usage: usageResultSchema,
285
+ reconcile: reconcileResultSchema,
286
+ purge_expired: purgeExpiredResultSchema,
287
+ health: healthResultSchema,
288
+ };
289
+ /** Shared-shape stdio tools. Hosted-only tools (`promote`, `repo_link_status`) omitted. */
290
+ export const stdioOutputSchemas = {
291
+ gallery_create: galleryResultSchema,
292
+ gallery_get: galleryResultSchema,
293
+ gallery_add: galleryItemSchema,
294
+ gallery_link: galleryReferenceSchema,
295
+ gallery_find_by_reference: galleryFindResultSchema,
296
+ put: putResultSchema,
297
+ list: listResultSchema,
298
+ delete: deleteResultSchema,
299
+ comment: objectSchema({
300
+ posted: { type: "boolean" },
301
+ reason: { type: "string" },
302
+ message: { type: "string" },
303
+ fixUrl: { type: "string" },
304
+ required: { type: "array", items: { type: "string" } },
305
+ action: { type: "string", enum: ["skipped", "created", "updated"] },
306
+ count: { type: "number" },
307
+ commentUrl: { type: "string" },
308
+ repo: { type: "string" },
309
+ kind: { type: "string" },
310
+ num: { type: "number" },
311
+ via: { type: "string", enum: ["bot", "gh"] },
312
+ }),
313
+ get_metadata: metadataResultSchema,
314
+ set_metadata: metadataResultSchema,
315
+ find_files: findFilesResultSchema,
316
+ list_metadata_keys: metadataFacetsResultSchema,
317
+ usage: usageResultSchema,
318
+ reconcile: reconcileResultSchema,
319
+ purge_expired: purgeExpiredResultSchema,
320
+ health: healthResultSchema,
321
+ report: objectSchema({
322
+ ok: { type: "boolean" },
323
+ id: { type: "string" },
324
+ hasAttachment: { type: "boolean" },
325
+ }, ["ok"]),
326
+ };
327
+ export function withOutputSchemas(tools, schemas, opts) {
328
+ return tools.map((tool) => {
329
+ const outputSchema = schemas[tool.name];
330
+ if (!outputSchema) {
331
+ if (opts.required) {
332
+ throw new Error(`missing output schema for MCP tool ${tool.name}`);
333
+ }
334
+ return tool;
335
+ }
336
+ return { ...tool, outputSchema };
337
+ });
338
+ }
@@ -22,11 +22,63 @@ export { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, metadataArgWithCa
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";
26
+ /** MCP tool safety hints. Required so tools/list advertises them for review. */
27
+ export interface McpToolAnnotations {
28
+ readOnlyHint: boolean;
29
+ destructiveHint: boolean;
30
+ openWorldHint: boolean;
31
+ }
32
+ /** Lookup / list / health. Does not change workspace or public state. */
33
+ export declare const mcpRead: McpToolAnnotations;
34
+ /** Creates or updates a public object, gallery, or comment without deleting. */
35
+ export declare const mcpWritePublic: McpToolAnnotations;
36
+ /** Deletes or overwrites a public object or a public GitHub comment. */
37
+ export declare const mcpDestroyPublic: McpToolAnnotations;
38
+ /** Mutates first-party / internal state only (ledger, reports). */
39
+ export declare const mcpWriteInternal: McpToolAnnotations;
40
+ /**
41
+ * Per-tool auth policy for ChatGPT / Codex plugin review. Advertised on
42
+ * tools/list as `_meta.securitySchemes` (the SDK has no first-class field).
43
+ */
44
+ export type McpSecurityScheme = {
45
+ type: "noauth";
46
+ } | {
47
+ type: "oauth2";
48
+ scopes: string[];
49
+ };
50
+ export declare const mcpOAuthRead: McpSecurityScheme[];
51
+ export declare const mcpOAuthWrite: McpSecurityScheme[];
52
+ export declare const mcpOAuthDelete: McpSecurityScheme[];
53
+ /** Authenticated, no particular file scope (hosted `health`). */
54
+ export declare const mcpOAuthAny: McpSecurityScheme[];
55
+ /** Callable without a token (stdio `health`). */
56
+ export declare const mcpNoAuth: McpSecurityScheme[];
57
+ /**
58
+ * Thrown when a presented token is missing a required scope. wrapHandler
59
+ * turns this into a tool error that carries `_meta["mcp/www_authenticate"]`
60
+ * so ChatGPT can prompt a re-consent.
61
+ */
62
+ export declare class McpAuthError extends Error {
63
+ readonly challenge: string;
64
+ constructor(message: string, challenge: string);
65
+ }
66
+ /** Build an insufficient_scope challenge pointing at this resource's metadata. */
67
+ export declare function insufficientScopeError(resourceMetadataUrl: string, scope: string): McpAuthError;
25
68
  export interface McpTool {
26
69
  name: string;
27
70
  description: string;
71
+ /** Short label for tools/list. Falls back to `name` when omitted. */
72
+ title?: string;
73
+ annotations: McpToolAnnotations;
74
+ securitySchemes: McpSecurityScheme[];
28
75
  /** Hand-written JSON Schema for the tool's arguments. */
29
76
  inputSchema: Record<string, unknown>;
77
+ /**
78
+ * Hand-written JSON Schema for successful `structuredContent`. Required
79
+ * whenever the handler returns structured data (OpenAI Scan Tools).
80
+ */
81
+ outputSchema?: Record<string, unknown>;
30
82
  handler: (args: Record<string, unknown>) => Promise<unknown>;
31
83
  }
32
84
  export declare function createMcpServer(opts: {
@@ -25,6 +25,58 @@ export { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, metadataArgWithCa
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. */
30
+ export const mcpRead = {
31
+ readOnlyHint: true,
32
+ destructiveHint: false,
33
+ openWorldHint: false,
34
+ };
35
+ /** Creates or updates a public object, gallery, or comment without deleting. */
36
+ export const mcpWritePublic = {
37
+ readOnlyHint: false,
38
+ destructiveHint: false,
39
+ openWorldHint: true,
40
+ };
41
+ /** Deletes or overwrites a public object or a public GitHub comment. */
42
+ export const mcpDestroyPublic = {
43
+ readOnlyHint: false,
44
+ destructiveHint: true,
45
+ openWorldHint: true,
46
+ };
47
+ /** Mutates first-party / internal state only (ledger, reports). */
48
+ export const mcpWriteInternal = {
49
+ readOnlyHint: false,
50
+ destructiveHint: false,
51
+ openWorldHint: false,
52
+ };
53
+ function oauth(scopes) {
54
+ return [{ type: "oauth2", scopes }];
55
+ }
56
+ export const mcpOAuthRead = oauth(["files:read"]);
57
+ export const mcpOAuthWrite = oauth(["files:write"]);
58
+ export const mcpOAuthDelete = oauth(["files:delete"]);
59
+ /** Authenticated, no particular file scope (hosted `health`). */
60
+ export const mcpOAuthAny = oauth([]);
61
+ /** Callable without a token (stdio `health`). */
62
+ export const mcpNoAuth = [{ type: "noauth" }];
63
+ /**
64
+ * Thrown when a presented token is missing a required scope. wrapHandler
65
+ * turns this into a tool error that carries `_meta["mcp/www_authenticate"]`
66
+ * so ChatGPT can prompt a re-consent.
67
+ */
68
+ export class McpAuthError extends Error {
69
+ challenge;
70
+ constructor(message, challenge) {
71
+ super(message);
72
+ this.name = "McpAuthError";
73
+ this.challenge = challenge;
74
+ }
75
+ }
76
+ /** Build an insufficient_scope challenge pointing at this resource's metadata. */
77
+ export function insufficientScopeError(resourceMetadataUrl, scope) {
78
+ return new McpAuthError(`forbidden: requires ${scope} scope`, `Bearer resource_metadata="${resourceMetadataUrl}", error="insufficient_scope", error_description="This tool requires the ${scope} scope"`);
79
+ }
28
80
  /**
29
81
  * The tool catalog is fixed for the lifetime of a deploy, so a generous
30
82
  * freshness hint is honest. `private` rather than `public` because the list is
@@ -75,6 +127,13 @@ function wrapHandler(tool, apiUrl) {
75
127
  isError: true,
76
128
  };
77
129
  }
130
+ if (err instanceof McpAuthError) {
131
+ return {
132
+ content: [{ type: "text", text: err.message }],
133
+ isError: true,
134
+ _meta: { "mcp/www_authenticate": [err.challenge] },
135
+ };
136
+ }
78
137
  return { content: [{ type: "text", text: toolErrorText(err) }], isError: true };
79
138
  }
80
139
  };
@@ -86,8 +145,14 @@ export function createMcpServer(opts) {
86
145
  });
87
146
  for (const tool of tools) {
88
147
  server.registerTool(tool.name, {
148
+ ...(tool.title ? { title: tool.title } : {}),
89
149
  description: tool.description,
90
150
  inputSchema: fromJsonSchema(tool.inputSchema, validator),
151
+ ...(tool.outputSchema
152
+ ? { outputSchema: fromJsonSchema(tool.outputSchema, validator) }
153
+ : {}),
154
+ annotations: tool.annotations,
155
+ _meta: { securitySchemes: tool.securitySchemes },
91
156
  }, wrapHandler(tool, apiUrl));
92
157
  }
93
158
  return server;
package/dist/mcp/tools.js CHANGED
@@ -10,7 +10,7 @@ import { validateMetaMap } from "../metadata.js";
10
10
  import { mergeDerivedMeta } from "../metadata-vocab.js";
11
11
  import { execRunner, ghMetadataFromTargetWithTitle, resolveCurrentBranch, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
12
12
  import { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, metadataArgWithCanonical, metadataProp, optBool, optPosInt, optString, optStringArray, optStringRecord, stateProp, usage, } from "./args.js";
13
- import { batchFailureMessage, ToolBatchError } from "./server.js";
13
+ import { batchFailureMessage, mcpDestroyPublic, mcpNoAuth, mcpOAuthAny, mcpOAuthDelete, mcpOAuthRead, mcpOAuthWrite, mcpRead, mcpWriteInternal, mcpWritePublic, stdioOutputSchemas, withOutputSchemas, ToolBatchError, } from "./server.js";
14
14
  import { attachmentFromText, buildReportPayload, parseReportType, REPORT_TYPES, submitReport, validateReportMessage, } from "../report.js";
15
15
  import { resolveApiUrl } from "../config.js";
16
16
  function mcpOptimizeOptions(args, defaults) {
@@ -130,9 +130,12 @@ export function createUploadsMcpTools(opts) {
130
130
  }
131
131
  return { comment, commentError };
132
132
  };
133
- return [
133
+ const tools = [
134
134
  {
135
135
  name: "gallery_create",
136
+ title: "Create gallery",
137
+ annotations: mcpWritePublic,
138
+ securitySchemes: mcpOAuthWrite,
136
139
  description: "Create a public ordered media gallery in the workspace. The returned canonical URL is safe to give users, but anyone who knows it can view the gallery and its media.",
137
140
  inputSchema: {
138
141
  type: "object",
@@ -154,6 +157,9 @@ export function createUploadsMcpTools(opts) {
154
157
  },
155
158
  {
156
159
  name: "gallery_get",
160
+ title: "Get gallery",
161
+ annotations: mcpRead,
162
+ securitySchemes: mcpOAuthRead,
157
163
  description: "Get a workspace-owned gallery, including ordered media and its canonical public URL. Gallery media is public to anyone with the URL.",
158
164
  inputSchema: {
159
165
  type: "object",
@@ -171,6 +177,9 @@ export function createUploadsMcpTools(opts) {
171
177
  },
172
178
  {
173
179
  name: "gallery_add",
180
+ title: "Add gallery item",
181
+ annotations: mcpWritePublic,
182
+ securitySchemes: mcpOAuthWrite,
174
183
  description: "Add one existing, publicly served workspace object to a gallery. Reads the latest gallery version before writing, so the optimistic API version is handled safely. Does not upload or delete the object.",
175
184
  inputSchema: {
176
185
  type: "object",
@@ -200,6 +209,9 @@ export function createUploadsMcpTools(opts) {
200
209
  },
201
210
  {
202
211
  name: "gallery_link",
212
+ title: "Link gallery",
213
+ annotations: mcpWritePublic,
214
+ securitySchemes: mcpOAuthWrite,
203
215
  description: "Link a gallery to an external reference. References use provider-neutral fields; github currently accepts owner/repo#number or a strict GitHub issue/PR URL. No GitHub credentials or API calls are used.",
204
216
  inputSchema: {
205
217
  type: "object",
@@ -227,6 +239,9 @@ export function createUploadsMcpTools(opts) {
227
239
  },
228
240
  {
229
241
  name: "gallery_find_by_reference",
242
+ title: "Find galleries",
243
+ annotations: mcpRead,
244
+ securitySchemes: mcpOAuthRead,
230
245
  description: "Find workspace galleries linked to an external reference. Returns gallery summaries and canonical public URLs without contacting the provider.",
231
246
  inputSchema: {
232
247
  type: "object",
@@ -254,6 +269,9 @@ export function createUploadsMcpTools(opts) {
254
269
  },
255
270
  {
256
271
  name: "put",
272
+ title: "Upload file",
273
+ annotations: mcpDestroyPublic,
274
+ securitySchemes: mcpOAuthWrite,
257
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.",
258
276
  inputSchema: {
259
277
  type: "object",
@@ -558,6 +576,9 @@ export function createUploadsMcpTools(opts) {
558
576
  },
559
577
  {
560
578
  name: "screenshot",
579
+ title: "Capture screenshot",
580
+ annotations: mcpDestroyPublic,
581
+ securitySchemes: mcpOAuthWrite,
561
582
  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.",
562
583
  inputSchema: {
563
584
  type: "object",
@@ -584,6 +605,12 @@ export function createUploadsMcpTools(opts) {
584
605
  },
585
606
  selector: { type: "string", description: "Capture one element instead of the viewport." },
586
607
  fullPage: { type: "boolean", description: "Capture the full scrollable page." },
608
+ maxHeight: {
609
+ type: "number",
610
+ description: "Cap on full-page capture height in CSS px (default: 5000, 0 = uncapped). A page over " +
611
+ "the cap is clipped, with a `hint` in the result. Requires fullPage. Applied on both " +
612
+ "via: local and via: remote so behavior matches.",
613
+ },
587
614
  colorScheme: {
588
615
  type: "string",
589
616
  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.",
@@ -678,6 +705,12 @@ export function createUploadsMcpTools(opts) {
678
705
  if (colorSchemeArg && colorSchemeArg !== "dark" && colorSchemeArg !== "light") {
679
706
  usage("colorScheme must be dark or light");
680
707
  }
708
+ const fullPageArg = optBool(args, "fullPage");
709
+ // Unlike other px args, 0 is valid here (uncapped) — optPosInt's
710
+ // allowZero option covers it.
711
+ const maxHeightArg = optPosInt(args, "maxHeight", { allowZero: true });
712
+ if (maxHeightArg !== undefined && !fullPageArg)
713
+ usage("maxHeight requires fullPage");
681
714
  const target = ghTargetFromArgs(args, run);
682
715
  const wantComment = optBool(args, "comment");
683
716
  const dryRun = optBool(args, "dryRun");
@@ -778,7 +811,8 @@ export function createUploadsMcpTools(opts) {
778
811
  cdp: optString(args, "cdp"),
779
812
  viewport,
780
813
  selector: optString(args, "selector"),
781
- fullPage: optBool(args, "fullPage"),
814
+ fullPage: fullPageArg,
815
+ maxHeight: maxHeightArg,
782
816
  colorScheme: colorSchemeArg,
783
817
  waitUntil: screenshotModule.parseWaitUntil(optString(args, "wait")),
784
818
  hide: optStringArray(args, "hide"),
@@ -863,6 +897,11 @@ export function createUploadsMcpTools(opts) {
863
897
  frame: prepared.frame,
864
898
  gallery,
865
899
  ...(dryRun ? { dryRun: true } : {}),
900
+ // Full-page height cap note (issue #652), mirrors the CLI's stderr
901
+ // note + `hint` field.
902
+ ...(captured.capped?.clipped
903
+ ? { hint: screenshotModule.clipHintText(captured.capped.maxHeightPx, "maxHeight") }
904
+ : {}),
866
905
  };
867
906
  if (wantComment && target) {
868
907
  const { comment, commentError } = await syncComment(client, target, config.workspace);
@@ -873,6 +912,9 @@ export function createUploadsMcpTools(opts) {
873
912
  },
874
913
  {
875
914
  name: "attach",
915
+ title: "Attach to GitHub",
916
+ annotations: mcpDestroyPublic,
917
+ securitySchemes: mcpOAuthWrite,
876
918
  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.",
877
919
  inputSchema: {
878
920
  type: "object",
@@ -971,6 +1013,9 @@ export function createUploadsMcpTools(opts) {
971
1013
  },
972
1014
  {
973
1015
  name: "list",
1016
+ title: "List files",
1017
+ annotations: mcpRead,
1018
+ securitySchemes: mcpOAuthRead,
974
1019
  description: "List uploaded objects in the workspace, filtered by key prefix or by a PR/issue's attachments. Paginate with cursor, or set all to fetch every page.",
975
1020
  inputSchema: {
976
1021
  type: "object",
@@ -1028,6 +1073,9 @@ export function createUploadsMcpTools(opts) {
1028
1073
  },
1029
1074
  {
1030
1075
  name: "staged",
1076
+ title: "List staged files",
1077
+ annotations: mcpRead,
1078
+ securitySchemes: mcpOAuthRead,
1031
1079
  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.",
1032
1080
  inputSchema: {
1033
1081
  type: "object",
@@ -1053,6 +1101,9 @@ export function createUploadsMcpTools(opts) {
1053
1101
  },
1054
1102
  {
1055
1103
  name: "delete",
1104
+ title: "Delete file",
1105
+ annotations: mcpDestroyPublic,
1106
+ securitySchemes: mcpOAuthDelete,
1056
1107
  description: "Delete an uploaded object by key. Set dryRun to preview without deleting.",
1057
1108
  inputSchema: {
1058
1109
  type: "object",
@@ -1079,6 +1130,9 @@ export function createUploadsMcpTools(opts) {
1079
1130
  },
1080
1131
  {
1081
1132
  name: "get_metadata",
1133
+ title: "Get metadata",
1134
+ annotations: mcpRead,
1135
+ securitySchemes: mcpOAuthRead,
1082
1136
  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`.",
1083
1137
  inputSchema: {
1084
1138
  type: "object",
@@ -1098,6 +1152,9 @@ export function createUploadsMcpTools(opts) {
1098
1152
  },
1099
1153
  {
1100
1154
  name: "set_metadata",
1155
+ title: "Set metadata",
1156
+ annotations: mcpWritePublic,
1157
+ securitySchemes: mcpOAuthWrite,
1101
1158
  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. " +
1102
1159
  METADATA_DESCRIPTION +
1103
1160
  " Requires at least one of `set` or `delete`. Same as `uploads meta set`.",
@@ -1133,6 +1190,9 @@ export function createUploadsMcpTools(opts) {
1133
1190
  },
1134
1191
  {
1135
1192
  name: "find_files",
1193
+ title: "Find files",
1194
+ annotations: mcpRead,
1195
+ securitySchemes: mcpOAuthRead,
1136
1196
  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, and optional `truncated`. Same as `uploads find k=v...` / `uploads find --name <term>`.",
1137
1197
  inputSchema: {
1138
1198
  type: "object",
@@ -1173,6 +1233,9 @@ export function createUploadsMcpTools(opts) {
1173
1233
  },
1174
1234
  {
1175
1235
  name: "list_metadata_keys",
1236
+ title: "List metadata keys",
1237
+ annotations: mcpRead,
1238
+ securitySchemes: mcpOAuthRead,
1176
1239
  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>`).",
1177
1240
  inputSchema: {
1178
1241
  type: "object",
@@ -1193,6 +1256,9 @@ export function createUploadsMcpTools(opts) {
1193
1256
  },
1194
1257
  {
1195
1258
  name: "usage",
1259
+ title: "Show usage",
1260
+ annotations: mcpRead,
1261
+ securitySchemes: mcpOAuthRead,
1196
1262
  description: "Workspace storage and monthly upload counters (and remaining headroom when budgets are configured). Same as `uploads usage`.",
1197
1263
  inputSchema: {
1198
1264
  type: "object",
@@ -1206,6 +1272,9 @@ export function createUploadsMcpTools(opts) {
1206
1272
  },
1207
1273
  {
1208
1274
  name: "reconcile",
1275
+ title: "Reconcile usage",
1276
+ annotations: mcpWriteInternal,
1277
+ securitySchemes: mcpOAuthWrite,
1209
1278
  description: "Rebuild usage ledger bytes/objects from storage (source of truth). Preserves the monthly upload counter. Requires files:write. Same as `uploads reconcile`.",
1210
1279
  inputSchema: {
1211
1280
  type: "object",
@@ -1219,6 +1288,9 @@ export function createUploadsMcpTools(opts) {
1219
1288
  },
1220
1289
  {
1221
1290
  name: "purge_expired",
1291
+ title: "Purge expired files",
1292
+ annotations: mcpDestroyPublic,
1293
+ securitySchemes: mcpOAuthDelete,
1222
1294
  description: "Delete objects older than the workspace retentionDays setting, then reconcile. Skips if retention is unset. Requires files:delete. Same as `uploads purge-expired`.",
1223
1295
  inputSchema: {
1224
1296
  type: "object",
@@ -1232,6 +1304,9 @@ export function createUploadsMcpTools(opts) {
1232
1304
  },
1233
1305
  {
1234
1306
  name: "comment",
1307
+ title: "Sync attachments comment",
1308
+ annotations: mcpDestroyPublic,
1309
+ securitySchemes: mcpOAuthWrite,
1235
1310
  description: "Create or update the managed attachments comment on a GitHub PR or issue, listing everything uploaded for it. Posts as uploads-sh[bot] when the GitHub App is installed on the repo; otherwise via local gh auth. Edits its own prior comment in place and never touches other comments.",
1236
1311
  inputSchema: {
1237
1312
  type: "object",
@@ -1255,6 +1330,9 @@ export function createUploadsMcpTools(opts) {
1255
1330
  },
1256
1331
  {
1257
1332
  name: "health",
1333
+ title: "Check health",
1334
+ annotations: mcpRead,
1335
+ securitySchemes: mcpNoAuth,
1258
1336
  description: "Check uploads.sh API liveness. No auth or arguments required.",
1259
1337
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
1260
1338
  async handler(args) {
@@ -1265,6 +1343,9 @@ export function createUploadsMcpTools(opts) {
1265
1343
  },
1266
1344
  {
1267
1345
  name: "doctor",
1346
+ title: "Diagnose setup",
1347
+ annotations: mcpRead,
1348
+ securitySchemes: mcpOAuthAny,
1268
1349
  description: "Diagnose the configuration: API health, token auth, and workspace/token alignment. Returns the same report as `uploads doctor --json`, including hints.",
1269
1350
  inputSchema: {
1270
1351
  type: "object",
@@ -1278,6 +1359,9 @@ export function createUploadsMcpTools(opts) {
1278
1359
  },
1279
1360
  {
1280
1361
  name: "report",
1362
+ title: "Send diagnostic report",
1363
+ annotations: mcpWriteInternal,
1364
+ securitySchemes: mcpOAuthWrite,
1281
1365
  description: "Send an explicit diagnostic report to the uploads team (message + optional text log). " +
1282
1366
  "Only call this when the user asked to submit feedback, a bug report, or error logs — " +
1283
1367
  "never automatically. Do not include tokens, secrets, or private file contents. " +
@@ -1359,4 +1443,5 @@ export function createUploadsMcpTools(opts) {
1359
1443
  },
1360
1444
  },
1361
1445
  ];
1446
+ return withOutputSchemas(tools, stdioOutputSchemas, { required: false });
1362
1447
  }
@@ -48,6 +48,13 @@ export interface LocalCaptureOptions {
48
48
  };
49
49
  selector?: string;
50
50
  fullPage?: boolean;
51
+ /**
52
+ * Cap (CSS px) on full-page capture height (issue #652). Only consulted
53
+ * when `fullPage` is true; 0 or undefined means uncapped. When the page's
54
+ * measured scroll height exceeds this, the capture is clipped to exactly
55
+ * this height instead of the full scroll height.
56
+ */
57
+ maxHeightPx?: number;
51
58
  colorScheme?: "dark" | "light";
52
59
  /** "load" | "domcontentloaded" | "networkidle", or a millisecond settle delay. */
53
60
  waitUntil: "load" | "domcontentloaded" | "networkidle" | number;
@@ -86,6 +93,16 @@ export interface MeasuredBox {
86
93
  interface EvaluatablePage {
87
94
  evaluate<T>(fn: (selectors: string[]) => T, arg: string[]): Promise<T>;
88
95
  }
96
+ /**
97
+ * Measures the page's full scrollable height in CSS px — the same quantity
98
+ * a `fullPage: true` screenshot would otherwise capture in its entirety.
99
+ * `document.documentElement.scrollHeight` is the standard measure; `body`'s
100
+ * is included too since some pages (quirks-mode, non-standard layouts) only
101
+ * grow one or the other.
102
+ */
103
+ export declare function measureFullPageHeight(page: {
104
+ evaluate<T>(fn: () => T): Promise<T>;
105
+ }): Promise<number>;
89
106
  /**
90
107
  * Measures each selector's getBoundingClientRect on the page in a single
91
108
  * `page.evaluate` round-trip, scaling CSS pixels to device (raster) pixels so
@@ -99,5 +116,6 @@ export declare function measureSelectorBoxes(page: EvaluatablePage, selectors: r
99
116
  export declare function captureLocal(opts: LocalCaptureOptions): Promise<{
100
117
  png: Uint8Array;
101
118
  measures?: Record<string, MeasuredBox>;
119
+ clipped?: boolean;
102
120
  }>;
103
121
  export {};
@@ -203,6 +203,16 @@ export function detectLocalBrowser(roots = {}) {
203
203
  const winner = [...candidates].toSorted((a, b) => rank(a) - rank(b))[0];
204
204
  return { envOverride, candidates, winner };
205
205
  }
206
+ /**
207
+ * Measures the page's full scrollable height in CSS px — the same quantity
208
+ * a `fullPage: true` screenshot would otherwise capture in its entirety.
209
+ * `document.documentElement.scrollHeight` is the standard measure; `body`'s
210
+ * is included too since some pages (quirks-mode, non-standard layouts) only
211
+ * grow one or the other.
212
+ */
213
+ export async function measureFullPageHeight(page) {
214
+ return page.evaluate(() => Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight ?? 0));
215
+ }
206
216
  /**
207
217
  * Measures each selector's getBoundingClientRect on the page in a single
208
218
  * `page.evaluate` round-trip, scaling CSS pixels to device (raster) pixels so
@@ -349,11 +359,33 @@ export async function captureLocal(opts) {
349
359
  const measures = opts.measureSelectors && opts.measureSelectors.length > 0
350
360
  ? await measureSelectorBoxes(page, opts.measureSelectors, opts.viewport.deviceScaleFactor)
351
361
  : undefined;
352
- const png = opts.selector
353
- ? await page.locator(opts.selector).screenshot({ timeout: opts.timeoutMs ?? 30_000 })
354
- : await page.screenshot({ fullPage: opts.fullPage === true });
362
+ // Full-page height cap (issue #652): measure the actual scroll height
363
+ // first so a page under the cap keeps its normal exact-content-height
364
+ // fullPage capture — only a page that genuinely exceeds the cap gets
365
+ // clipped to it. `clip` (CSS px, captures beyond the current viewport)
366
+ // is the mechanism; it can't be combined with `fullPage: true`.
367
+ let clipped = false;
368
+ let png;
369
+ if (opts.selector) {
370
+ png = await page.locator(opts.selector).screenshot({ timeout: opts.timeoutMs ?? 30_000 });
371
+ }
372
+ else if (opts.fullPage && opts.maxHeightPx && opts.maxHeightPx > 0) {
373
+ const fullHeight = await measureFullPageHeight(page);
374
+ if (fullHeight > opts.maxHeightPx) {
375
+ clipped = true;
376
+ png = await page.screenshot({
377
+ clip: { x: 0, y: 0, width: opts.viewport.width, height: opts.maxHeightPx },
378
+ });
379
+ }
380
+ else {
381
+ png = await page.screenshot({ fullPage: true });
382
+ }
383
+ }
384
+ else {
385
+ png = await page.screenshot({ fullPage: opts.fullPage === true });
386
+ }
355
387
  // Buffer extends Uint8Array — return it as-is rather than copying.
356
- return { png, measures };
388
+ return { png, measures, clipped };
357
389
  }
358
390
  finally {
359
391
  await browser.close();
@@ -10,6 +10,14 @@ export interface RemoteRenderRequest {
10
10
  };
11
11
  selector?: string;
12
12
  fullPage?: boolean;
13
+ /**
14
+ * Cap (CSS px) on full-page capture height (issue #652). Only sent when
15
+ * `fullPage` is true and the cap is active (nonzero); the server applies a
16
+ * best-effort clamp (see apps/api's render.ts) and reports whether it
17
+ * actually kicked in via the `X-Uploads-Full-Page-Clipped` response header,
18
+ * surfaced back on the result as `clipped`.
19
+ */
20
+ maxHeight?: number;
13
21
  colorScheme?: "dark" | "light";
14
22
  waitUntil?: "load" | "domcontentloaded" | "networkidle" | number;
15
23
  /** CSS selectors hidden (display:none) server-side before capture. */
@@ -27,5 +35,14 @@ export interface RemoteRenderOptions {
27
35
  fetchImpl?: typeof fetch;
28
36
  timeoutMs?: number;
29
37
  }
30
- /** POST the render request; resolves with raw PNG bytes on 200. */
31
- export declare function captureRemote(body: RemoteRenderRequest, opts: RemoteRenderOptions): Promise<Uint8Array>;
38
+ export interface RemoteRenderResult {
39
+ png: Uint8Array;
40
+ /**
41
+ * True when the server's best-effort full-page height clamp actually
42
+ * kicked in (read from the `X-Uploads-Full-Page-Clipped` response header).
43
+ * Always false when `maxHeight` wasn't sent.
44
+ */
45
+ clipped: boolean;
46
+ }
47
+ /** POST the render request; resolves with raw PNG bytes (+ clip status) on 200. */
48
+ export declare function captureRemote(body: RemoteRenderRequest, opts: RemoteRenderOptions): Promise<RemoteRenderResult>;
@@ -32,7 +32,7 @@ function mapRenderError(status, message, code) {
32
32
  return new UploadsError(message, "RATE_LIMITED", status);
33
33
  return new UploadsError(message, "API_ERROR", status);
34
34
  }
35
- /** POST the render request; resolves with raw PNG bytes on 200. */
35
+ /** POST the render request; resolves with raw PNG bytes (+ clip status) on 200. */
36
36
  export async function captureRemote(body, opts) {
37
37
  if (body.html !== undefined) {
38
38
  const htmlBytes = new TextEncoder().encode(body.html).byteLength;
@@ -65,7 +65,7 @@ export async function captureRemote(body, opts) {
65
65
  if (bytes.byteLength === 0) {
66
66
  throw new UploadsError("render endpoint returned an empty response", "RENDER_FAILED");
67
67
  }
68
- return bytes;
68
+ return { png: bytes, clipped: res.headers.get("x-uploads-full-page-clipped") === "true" };
69
69
  }
70
70
  catch (err) {
71
71
  if (err instanceof UploadsError)
@@ -25,6 +25,16 @@ export interface ScreenshotViewport {
25
25
  deviceScaleFactor: number;
26
26
  }
27
27
  export declare const DEFAULT_SCREENSHOT_VIEWPORT: ScreenshotViewport;
28
+ /**
29
+ * Default cap (CSS px) on `--full-page` capture height (issue #652). Picked
30
+ * from the 4000-6000px range the issue proposed: generous enough to cover a
31
+ * long single-viewport marketing/docs page without clipping, but well short
32
+ * of the multi-thousand-entry-list case that motivated this (a 53-entry
33
+ * changelog page, PR #651) — those still clip, with a note pointing at
34
+ * `--max-height` to raise or remove the cap. `0` (via `--max-height 0`) means
35
+ * uncapped, for the rare case the full strip is genuinely wanted.
36
+ */
37
+ export declare const DEFAULT_FULL_PAGE_MAX_HEIGHT = 5000;
28
38
  /** Parses `WIDTHxHEIGHT[@SCALEx]`, e.g. "1280x800", "1280x800@2x", "1280x800@2". */
29
39
  export declare function parseViewport(raw: string | undefined): ScreenshotViewport;
30
40
  /** Parses `--wait`: "load" | "domcontentloaded" | "networkidle" | a millisecond count. */
@@ -63,6 +73,13 @@ export interface CaptureScreenshotOptions {
63
73
  viewport?: ScreenshotViewport;
64
74
  selector?: string;
65
75
  fullPage?: boolean;
76
+ /**
77
+ * Cap (CSS px) on `--full-page` capture height. Only meaningful with
78
+ * `fullPage: true`; ignored otherwise. `undefined` applies
79
+ * `DEFAULT_FULL_PAGE_MAX_HEIGHT`; `0` means uncapped. Wired through both
80
+ * capture backends so behavior matches regardless of `via` (issue #652).
81
+ */
82
+ maxHeight?: number;
66
83
  colorScheme?: "dark" | "light";
67
84
  waitUntil?: WaitUntil;
68
85
  /**
@@ -104,6 +121,8 @@ export interface CaptureScreenshotOptions {
104
121
  viewport: ScreenshotViewport;
105
122
  selector?: string;
106
123
  fullPage?: boolean;
124
+ /** CSS px cap on full-page height; 0 or undefined = uncapped. */
125
+ maxHeightPx?: number;
107
126
  colorScheme?: "dark" | "light";
108
127
  waitUntil: WaitUntil;
109
128
  hide?: string[];
@@ -117,6 +136,8 @@ export interface CaptureScreenshotOptions {
117
136
  }) => Promise<{
118
137
  png: Uint8Array;
119
138
  measures?: Record<string, MeasuredBox>;
139
+ /** Present when `fullPage` + a positive `maxHeightPx` were given. */
140
+ clipped?: boolean;
120
141
  }>;
121
142
  /** Injectable for tests: replaces the remote capture implementation. */
122
143
  captureRemoteImpl?: typeof captureRemote;
@@ -127,7 +148,23 @@ export interface CaptureScreenshotResult {
127
148
  backend: "local" | "remote";
128
149
  /** Present when `measureSelectors` was given and the local backend ran. */
129
150
  measures?: Record<string, MeasuredBox>;
151
+ /**
152
+ * Present when `fullPage` was requested with a nonzero max-height cap
153
+ * (explicit or default) — regardless of backend. `clipped` tells the
154
+ * caller whether the cap actually kicked in, so it can print the
155
+ * "exceeds Npx; clipped" note and JSON `hint` (issue #652).
156
+ */
157
+ capped?: {
158
+ maxHeightPx: number;
159
+ clipped: boolean;
160
+ };
130
161
  }
162
+ /**
163
+ * The "clipped by the max-height cap" note shared by the CLI's stderr
164
+ * message and the MCP tool's JSON `hint` field (issue #652) — identical
165
+ * except for how each surface names the flag to raise the cap.
166
+ */
167
+ export declare function clipHintText(maxHeightPx: number, flagName: string): string;
131
168
  /**
132
169
  * Folds a `--state` value into an auto-derived filename's stem, e.g.
133
170
  * `localhost-docs-mcp.png` + "before" → `localhost-docs-mcp-before.png`. This
@@ -48,6 +48,16 @@ export const DEFAULT_SCREENSHOT_VIEWPORT = {
48
48
  height: 800,
49
49
  deviceScaleFactor: 2,
50
50
  };
51
+ /**
52
+ * Default cap (CSS px) on `--full-page` capture height (issue #652). Picked
53
+ * from the 4000-6000px range the issue proposed: generous enough to cover a
54
+ * long single-viewport marketing/docs page without clipping, but well short
55
+ * of the multi-thousand-entry-list case that motivated this (a 53-entry
56
+ * changelog page, PR #651) — those still clip, with a note pointing at
57
+ * `--max-height` to raise or remove the cap. `0` (via `--max-height 0`) means
58
+ * uncapped, for the rare case the full strip is genuinely wanted.
59
+ */
60
+ export const DEFAULT_FULL_PAGE_MAX_HEIGHT = 5000;
51
61
  /** Parses `WIDTHxHEIGHT[@SCALEx]`, e.g. "1280x800", "1280x800@2x", "1280x800@2". */
52
62
  export function parseViewport(raw) {
53
63
  if (!raw)
@@ -146,6 +156,14 @@ export function classifyTarget(target) {
146
156
  }
147
157
  return { kind: "html-file", path: abs, html: readFileSync(abs, "utf8") };
148
158
  }
159
+ /**
160
+ * The "clipped by the max-height cap" note shared by the CLI's stderr
161
+ * message and the MCP tool's JSON `hint` field (issue #652) — identical
162
+ * except for how each surface names the flag to raise the cap.
163
+ */
164
+ export function clipHintText(maxHeightPx, flagName) {
165
+ return `full page exceeds ${maxHeightPx}px; clipped — use ${flagName} to raise`;
166
+ }
149
167
  /** Derives a filename from a URL (host+path) or the source .html filename. */
150
168
  function deriveFilename(target) {
151
169
  if (target.kind === "html-file") {
@@ -225,6 +243,14 @@ export async function captureScreenshot(opts) {
225
243
  for (const sel of opts.hide ?? [])
226
244
  assertHideSelector(sel);
227
245
  const hide = [...(opts.hide ?? []), ...(autoHideDevTools ? DEV_TOOLBAR_SELECTORS : [])];
246
+ // Full-page height cap (issue #652): only meaningful with fullPage. 0
247
+ // (explicit --max-height 0) means uncapped; undefined applies the default.
248
+ const effectiveMaxHeight = opts.fullPage ? (opts.maxHeight ?? DEFAULT_FULL_PAGE_MAX_HEIGHT) : 0;
249
+ // Shared by both backends below — only meaningful when fullPage capped at
250
+ // a positive height; `clipped` is the one thing that differs per backend.
251
+ const cappedFrom = (clipped) => opts.fullPage && effectiveMaxHeight > 0
252
+ ? { maxHeightPx: effectiveMaxHeight, clipped }
253
+ : undefined;
228
254
  // Populated only when auto-routing actually probes the filesystem, so it
229
255
  // can be threaded into captureLocalImpl below to avoid a second scan.
230
256
  let detected;
@@ -284,6 +310,7 @@ export async function captureScreenshot(opts) {
284
310
  viewport,
285
311
  selector: opts.selector,
286
312
  fullPage: opts.fullPage,
313
+ maxHeightPx: effectiveMaxHeight,
287
314
  colorScheme: opts.colorScheme,
288
315
  waitUntil,
289
316
  hide,
@@ -294,7 +321,8 @@ export async function captureScreenshot(opts) {
294
321
  detectRoots: opts.detectRoots,
295
322
  detectResult: detected,
296
323
  });
297
- return { png: localResult.png, filename, backend, measures: localResult.measures };
324
+ const capped = cappedFrom(localResult.clipped === true);
325
+ return { png: localResult.png, filename, backend, measures: localResult.measures, capped };
298
326
  }
299
327
  if (target.kind === "html-file") {
300
328
  const bytes = new TextEncoder().encode(target.html).byteLength;
@@ -303,15 +331,17 @@ export async function captureScreenshot(opts) {
303
331
  }
304
332
  }
305
333
  const captureRemoteImpl = opts.captureRemoteImpl ?? captureRemote;
306
- const png = await captureRemoteImpl({
334
+ const remoteResult = await captureRemoteImpl({
307
335
  ...(target.kind === "html-file" ? { html: target.html } : { url: target.url }),
308
336
  viewport,
309
337
  selector: opts.selector,
310
338
  fullPage: opts.fullPage,
339
+ ...(opts.fullPage && effectiveMaxHeight > 0 ? { maxHeight: effectiveMaxHeight } : {}),
311
340
  colorScheme: opts.colorScheme,
312
341
  waitUntil,
313
342
  ...(hide.length > 0 ? { hide } : {}),
314
343
  ...(opts.reducedMotion ? { reducedMotion: true } : {}),
315
344
  }, { apiUrl: opts.apiUrl, token: opts.token });
316
- return { png, filename, backend };
345
+ const capped = cappedFrom(remoteResult.clipped === true);
346
+ return { png: remoteResult.png, filename, backend, capped };
317
347
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.41.1",
3
+ "version": "0.42.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,
@@ -38,7 +38,7 @@
38
38
  "node": ">=22"
39
39
  },
40
40
  "peerDependencies": {
41
- "files-sdk": "^2.2.3"
41
+ "files-sdk": "^2.2.4"
42
42
  },
43
43
  "peerDependenciesMeta": {
44
44
  "files-sdk": {
@@ -48,7 +48,7 @@
48
48
  "devDependencies": {
49
49
  "@types/node": "^26.1.0",
50
50
  "ai": "^6.0.0",
51
- "files-sdk": "^2.2.3",
51
+ "files-sdk": "^2.2.4",
52
52
  "typescript": "^7.0.2",
53
53
  "vitest": "^4.1.10"
54
54
  },