@chrischall/gemini-mcp 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bundle.js CHANGED
@@ -459,15 +459,15 @@ var init_parseUtil = __esm({
459
459
  message: issueData.message
460
460
  };
461
461
  }
462
- let errorMessage = "";
462
+ let errorMessage2 = "";
463
463
  const maps = errorMaps.filter((m) => !!m).slice().reverse();
464
464
  for (const map2 of maps) {
465
- errorMessage = map2(fullIssue, { data, defaultError: errorMessage }).message;
465
+ errorMessage2 = map2(fullIssue, { data, defaultError: errorMessage2 }).message;
466
466
  }
467
467
  return {
468
468
  ...issueData,
469
469
  path: fullPath,
470
- message: errorMessage
470
+ message: errorMessage2
471
471
  };
472
472
  };
473
473
  ParseStatus = class _ParseStatus {
@@ -20948,19 +20948,19 @@ var init_Refs = __esm({
20948
20948
  });
20949
20949
 
20950
20950
  // node_modules/zod-to-json-schema/dist/esm/errorMessages.js
20951
- function addErrorMessage(res, key, errorMessage, refs) {
20951
+ function addErrorMessage(res, key, errorMessage2, refs) {
20952
20952
  if (!refs?.errorMessages)
20953
20953
  return;
20954
- if (errorMessage) {
20954
+ if (errorMessage2) {
20955
20955
  res.errorMessage = {
20956
20956
  ...res.errorMessage,
20957
- [key]: errorMessage
20957
+ [key]: errorMessage2
20958
20958
  };
20959
20959
  }
20960
20960
  }
20961
- function setResponseValueAndErrors(res, key, value, errorMessage, refs) {
20961
+ function setResponseValueAndErrors(res, key, value, errorMessage2, refs) {
20962
20962
  res[key] = value;
20963
- addErrorMessage(res, key, errorMessage, refs);
20963
+ addErrorMessage(res, key, errorMessage2, refs);
20964
20964
  }
20965
20965
  var init_errorMessages = __esm({
20966
20966
  "node_modules/zod-to-json-schema/dist/esm/errorMessages.js"() {
@@ -22582,8 +22582,8 @@ var init_protocol = __esm({
22582
22582
  if (queuedMessage.type === "response") {
22583
22583
  resolver(message);
22584
22584
  } else {
22585
- const errorMessage = message;
22586
- const error51 = new McpError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data);
22585
+ const errorMessage2 = message;
22586
+ const error51 = new McpError(errorMessage2.error.code, errorMessage2.error.message, errorMessage2.error.data);
22587
22587
  resolver(error51);
22588
22588
  }
22589
22589
  } else {
@@ -30763,23 +30763,23 @@ var init_server2 = __esm({
30763
30763
  const wrappedHandler = async (request, extra) => {
30764
30764
  const validatedRequest = safeParse2(CallToolRequestSchema, request);
30765
30765
  if (!validatedRequest.success) {
30766
- const errorMessage = validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error);
30767
- throw new McpError(ErrorCode.InvalidParams, `Invalid tools/call request: ${errorMessage}`);
30766
+ const errorMessage2 = validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error);
30767
+ throw new McpError(ErrorCode.InvalidParams, `Invalid tools/call request: ${errorMessage2}`);
30768
30768
  }
30769
30769
  const { params } = validatedRequest.data;
30770
30770
  const result = await Promise.resolve(handler(request, extra));
30771
30771
  if (params.task) {
30772
30772
  const taskValidationResult = safeParse2(CreateTaskResultSchema, result);
30773
30773
  if (!taskValidationResult.success) {
30774
- const errorMessage = taskValidationResult.error instanceof Error ? taskValidationResult.error.message : String(taskValidationResult.error);
30775
- throw new McpError(ErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`);
30774
+ const errorMessage2 = taskValidationResult.error instanceof Error ? taskValidationResult.error.message : String(taskValidationResult.error);
30775
+ throw new McpError(ErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage2}`);
30776
30776
  }
30777
30777
  return taskValidationResult.data;
30778
30778
  }
30779
30779
  const validationResult = safeParse2(CallToolResultSchema, result);
30780
30780
  if (!validationResult.success) {
30781
- const errorMessage = validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error);
30782
- throw new McpError(ErrorCode.InvalidParams, `Invalid tools/call result: ${errorMessage}`);
30781
+ const errorMessage2 = validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error);
30782
+ throw new McpError(ErrorCode.InvalidParams, `Invalid tools/call result: ${errorMessage2}`);
30783
30783
  }
30784
30784
  return validationResult.data;
30785
30785
  };
@@ -31380,12 +31380,12 @@ var init_mcp = __esm({
31380
31380
  * @param errorMessage - The error message.
31381
31381
  * @returns The tool error result.
31382
31382
  */
31383
- createToolError(errorMessage) {
31383
+ createToolError(errorMessage2) {
31384
31384
  return {
31385
31385
  content: [
31386
31386
  {
31387
31387
  type: "text",
31388
- text: errorMessage
31388
+ text: errorMessage2
31389
31389
  }
31390
31390
  ],
31391
31391
  isError: true
@@ -31403,8 +31403,8 @@ var init_mcp = __esm({
31403
31403
  const parseResult = await safeParseAsync2(schemaToParse, args);
31404
31404
  if (!parseResult.success) {
31405
31405
  const error51 = "error" in parseResult ? parseResult.error : "Unknown error";
31406
- const errorMessage = getParseErrorMessage(error51);
31407
- throw new McpError(ErrorCode.InvalidParams, `Input validation error: Invalid arguments for tool ${toolName}: ${errorMessage}`);
31406
+ const errorMessage2 = getParseErrorMessage(error51);
31407
+ throw new McpError(ErrorCode.InvalidParams, `Input validation error: Invalid arguments for tool ${toolName}: ${errorMessage2}`);
31408
31408
  }
31409
31409
  return parseResult.data;
31410
31410
  }
@@ -31428,8 +31428,8 @@ var init_mcp = __esm({
31428
31428
  const parseResult = await safeParseAsync2(outputObj, result.structuredContent);
31429
31429
  if (!parseResult.success) {
31430
31430
  const error51 = "error" in parseResult ? parseResult.error : "Unknown error";
31431
- const errorMessage = getParseErrorMessage(error51);
31432
- throw new McpError(ErrorCode.InvalidParams, `Output validation error: Invalid structured content for tool ${toolName}: ${errorMessage}`);
31431
+ const errorMessage2 = getParseErrorMessage(error51);
31432
+ throw new McpError(ErrorCode.InvalidParams, `Output validation error: Invalid structured content for tool ${toolName}: ${errorMessage2}`);
31433
31433
  }
31434
31434
  }
31435
31435
  /**
@@ -31641,8 +31641,8 @@ var init_mcp = __esm({
31641
31641
  const parseResult = await safeParseAsync2(argsObj, request.params.arguments);
31642
31642
  if (!parseResult.success) {
31643
31643
  const error51 = "error" in parseResult ? parseResult.error : "Unknown error";
31644
- const errorMessage = getParseErrorMessage(error51);
31645
- throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for prompt ${request.params.name}: ${errorMessage}`);
31644
+ const errorMessage2 = getParseErrorMessage(error51);
31645
+ throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for prompt ${request.params.name}: ${errorMessage2}`);
31646
31646
  }
31647
31647
  const args = parseResult.data;
31648
31648
  const cb = prompt.callback;
@@ -32099,59 +32099,6 @@ var init_stdio2 = __esm({
32099
32099
  }
32100
32100
  });
32101
32101
 
32102
- // node_modules/@chrischall/mcp-utils/dist/server/index.js
32103
- async function createMcpServer(opts) {
32104
- const server = new McpServer({ name: opts.name, version: opts.version });
32105
- if (opts.banner !== void 0) {
32106
- console.error(opts.banner);
32107
- }
32108
- const deps = opts.deps;
32109
- for (const register of opts.tools) {
32110
- await register(server, deps);
32111
- }
32112
- return server;
32113
- }
32114
- function withGracefulShutdown(server, opts = {}) {
32115
- const shouldExit = opts.exit ?? true;
32116
- let shuttingDown = false;
32117
- const handler = (signal) => {
32118
- if (shuttingDown)
32119
- return;
32120
- shuttingDown = true;
32121
- void (async () => {
32122
- try {
32123
- if (opts.onSignal)
32124
- await opts.onSignal(signal);
32125
- await server.close();
32126
- } catch (err) {
32127
- console.error(`[mcp-utils] error during graceful shutdown on ${signal}: ${err instanceof Error ? err.message : String(err)}`);
32128
- } finally {
32129
- if (shouldExit)
32130
- process.exit(0);
32131
- }
32132
- })();
32133
- };
32134
- process.on("SIGINT", () => handler("SIGINT"));
32135
- process.on("SIGTERM", () => handler("SIGTERM"));
32136
- }
32137
- async function runMcp(opts) {
32138
- const server = await createMcpServer(opts);
32139
- const shutdown = opts.shutdown ?? true;
32140
- if (shutdown !== false) {
32141
- withGracefulShutdown(server, shutdown === true ? {} : shutdown);
32142
- }
32143
- const spec = opts.transport ?? "stdio";
32144
- const transport = spec === "stdio" ? new StdioServerTransport() : spec;
32145
- await server.connect(transport);
32146
- return server;
32147
- }
32148
- var init_server3 = __esm({
32149
- "node_modules/@chrischall/mcp-utils/dist/server/index.js"() {
32150
- init_mcp();
32151
- init_stdio2();
32152
- }
32153
- });
32154
-
32155
32102
  // node_modules/@chrischall/mcp-utils/dist/errors/index.js
32156
32103
  function redactSecrets(text) {
32157
32104
  return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
@@ -32211,12 +32158,95 @@ function textResult(data) {
32211
32158
  content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
32212
32159
  };
32213
32160
  }
32161
+ function errorResult(message) {
32162
+ return {
32163
+ content: [{ type: "text", text: redactSecrets(message) }],
32164
+ isError: true
32165
+ };
32166
+ }
32214
32167
  var init_response = __esm({
32215
32168
  "node_modules/@chrischall/mcp-utils/dist/response/index.js"() {
32216
32169
  init_errors4();
32217
32170
  }
32218
32171
  });
32219
32172
 
32173
+ // node_modules/@chrischall/mcp-utils/dist/server/index.js
32174
+ function hintResultOrRethrow(err) {
32175
+ if (err instanceof McpToolError && err.hint) {
32176
+ return errorResult(`${err.message}
32177
+
32178
+ Hint: ${err.hint}`);
32179
+ }
32180
+ throw err;
32181
+ }
32182
+ function surfaceToolHints(server) {
32183
+ const register = server.registerTool.bind(server);
32184
+ server.registerTool = (name, config2, cb) => register(name, config2, (...args) => {
32185
+ let result;
32186
+ try {
32187
+ result = cb(...args);
32188
+ } catch (err) {
32189
+ return hintResultOrRethrow(err);
32190
+ }
32191
+ return result instanceof Promise ? result.catch(hintResultOrRethrow) : result;
32192
+ });
32193
+ }
32194
+ async function createMcpServer(opts) {
32195
+ const server = new McpServer({ name: opts.name, version: opts.version });
32196
+ if (opts.surfaceHints !== false)
32197
+ surfaceToolHints(server);
32198
+ if (opts.banner !== void 0) {
32199
+ console.error(opts.banner);
32200
+ }
32201
+ const deps = opts.deps;
32202
+ for (const register of opts.tools) {
32203
+ await register(server, deps);
32204
+ }
32205
+ return server;
32206
+ }
32207
+ function withGracefulShutdown(server, opts = {}) {
32208
+ const shouldExit = opts.exit ?? true;
32209
+ let shuttingDown = false;
32210
+ const handler = (signal) => {
32211
+ if (shuttingDown)
32212
+ return;
32213
+ shuttingDown = true;
32214
+ void (async () => {
32215
+ try {
32216
+ if (opts.onSignal)
32217
+ await opts.onSignal(signal);
32218
+ await server.close();
32219
+ } catch (err) {
32220
+ console.error(`[mcp-utils] error during graceful shutdown on ${signal}: ${err instanceof Error ? err.message : String(err)}`);
32221
+ } finally {
32222
+ if (shouldExit)
32223
+ process.exit(0);
32224
+ }
32225
+ })();
32226
+ };
32227
+ process.on("SIGINT", () => handler("SIGINT"));
32228
+ process.on("SIGTERM", () => handler("SIGTERM"));
32229
+ }
32230
+ async function runMcp(opts) {
32231
+ const server = await createMcpServer(opts);
32232
+ const shutdown = opts.shutdown ?? true;
32233
+ if (shutdown !== false) {
32234
+ withGracefulShutdown(server, shutdown === true ? {} : shutdown);
32235
+ }
32236
+ const spec = opts.transport ?? "stdio";
32237
+ const transport = spec === "stdio" ? new StdioServerTransport() : spec;
32238
+ await server.connect(transport);
32239
+ return server;
32240
+ }
32241
+ var init_server3 = __esm({
32242
+ "node_modules/@chrischall/mcp-utils/dist/server/index.js"() {
32243
+ init_mcp();
32244
+ init_stdio2();
32245
+ init_errors4();
32246
+ init_response();
32247
+ }
32248
+ });
32249
+
32220
32250
  // node_modules/@chrischall/mcp-utils/dist/config/index.js
32221
32251
  function readEnvVar(key, opts = {}) {
32222
32252
  const env = opts.env ?? process.env;
@@ -32838,7 +32868,7 @@ async function loadStdioDotenv() {
32838
32868
  }
32839
32869
 
32840
32870
  // src/version.ts
32841
- var VERSION = "1.7.0";
32871
+ var VERSION = "1.9.0";
32842
32872
 
32843
32873
  // src/errors.ts
32844
32874
  init_dist();
@@ -33078,15 +33108,15 @@ function fileNameFromUrl(url2, fallback) {
33078
33108
  function describeUrl(current, requested) {
33079
33109
  return current === requested ? current : `${current} (redirected from ${requested})`;
33080
33110
  }
33081
- async function readCapped(res, maxBytes, url2, requested) {
33111
+ async function readCapped(res, maxBytes, url2, requested, subject) {
33082
33112
  const declared = Number(res.headers.get("content-length"));
33083
33113
  if (Number.isFinite(declared) && declared > maxBytes) {
33084
- throw tooBig(url2, requested, declared, maxBytes);
33114
+ throw tooBig(url2, requested, declared, maxBytes, false, subject);
33085
33115
  }
33086
33116
  const reader = res.body?.getReader();
33087
33117
  if (!reader) {
33088
33118
  const buf = new Uint8Array(await res.arrayBuffer());
33089
- if (buf.byteLength > maxBytes) throw tooBig(url2, requested, buf.byteLength, maxBytes);
33119
+ if (buf.byteLength > maxBytes) throw tooBig(url2, requested, buf.byteLength, maxBytes, false, subject);
33090
33120
  return buf;
33091
33121
  }
33092
33122
  const chunks = [];
@@ -33100,7 +33130,7 @@ async function readCapped(res, maxBytes, url2, requested) {
33100
33130
  if (total > maxBytes) {
33101
33131
  await reader.cancel().catch(() => {
33102
33132
  });
33103
- throw tooBig(url2, requested, total, maxBytes, true);
33133
+ throw tooBig(url2, requested, total, maxBytes, true, subject);
33104
33134
  }
33105
33135
  chunks.push(value);
33106
33136
  }
@@ -33127,12 +33157,18 @@ function fetchFailure(err, current, requested, timeoutMs) {
33127
33157
  { hint: "Check the URL is reachable from the server and serves the image without authentication." }
33128
33158
  );
33129
33159
  }
33130
- function tooBig(url2, requested, size, max, partial2 = false) {
33160
+ function tooBig(url2, requested, size, max, partial2 = false, subject) {
33161
+ const what = subject ?? IMAGE_CAP_SUBJECT;
33131
33162
  return new McpToolError(
33132
- `Image at ${describeUrl(url2, requested)} is ${partial2 ? "over" : ""} ${formatMb(size)}, above the ${formatMb(max)} limit for images_url.`,
33133
- { hint: "Resize or re-encode the image, or upload it yourself with gemini_upload_file and pass the returned file_uri." }
33163
+ `${what.noun} at ${describeUrl(url2, requested)} is ${partial2 ? "over " : ""}${formatMb(size)}, above the ${formatMb(max)} limit${what.limitFor ? ` for ${what.limitFor}` : ""}.`,
33164
+ { hint: what.hint }
33134
33165
  );
33135
33166
  }
33167
+ var IMAGE_CAP_SUBJECT = {
33168
+ noun: "Image",
33169
+ limitFor: "images_url",
33170
+ hint: "Resize or re-encode the image, or upload it yourself with gemini_upload_file and pass the returned file_uri."
33171
+ };
33136
33172
  async function fetchRemoteImage(requestedUrl, opts = {}) {
33137
33173
  const doFetch = opts.fetchImpl ?? fetch;
33138
33174
  const maxBytes = opts.maxBytes ?? IMAGE_URL_MAX_BYTES;
@@ -33577,6 +33613,27 @@ function buildZip(entries, modifiedAt = new Date(Date.UTC(1980, 0, 1))) {
33577
33613
  var DEFAULT_SINK = createDiskSink();
33578
33614
  var ASPECT_RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9", "1:4", "4:1", "1:8", "8:1"];
33579
33615
  var IMAGE_SIZES = ["512", "1K", "2K", "4K"];
33616
+ var ORIENTATIONS = ["landscape", "portrait", "square"];
33617
+ var ORIENTATION_RATIO = {
33618
+ landscape: "16:9",
33619
+ portrait: "9:16",
33620
+ square: "1:1"
33621
+ };
33622
+ function resolveAspectRatio(opts, supported) {
33623
+ if (opts.aspect_ratio) return opts.aspect_ratio;
33624
+ if (!opts.orientation) return void 0;
33625
+ const ratio = ORIENTATION_RATIO[opts.orientation];
33626
+ if (!supported.includes(ratio)) {
33627
+ throw new McpToolError(
33628
+ `orientation "${opts.orientation}" (${ratio}) is not available for this model, which supports ${supported.join(" and ")}. Ask for ${supported.map((r) => `"${r}"`).join(" or ")} via aspect_ratio, or choose another orientation.`,
33629
+ { hint: `Supported aspect ratios: ${supported.join(", ")}.` }
33630
+ );
33631
+ }
33632
+ return ratio;
33633
+ }
33634
+ var orientationSchema = external_exports.enum(ORIENTATIONS).optional().describe(
33635
+ 'Shape of the output, in plain terms: "landscape" (wide, 16:9), "portrait" (tall, 9:16) or "square" (1:1). Use this for a request phrased as landscape/portrait/vertical/horizontal. For any other proportion \u2014 35mm photo (3:2), print (4:3), social (4:5), cinematic (21:9) \u2014 name it with aspect_ratio instead, which overrides this when both are given.'
33636
+ );
33580
33637
  var MODEL_CHOICE_GUIDE = "gemini-3.1-flash-image (Nano Banana 2) is the versatile generalist workhorse \u2014 balances speed with state-of-the-art 4K generation, world knowledge, and reliable text rendering; excels at multi-reference-image processing and consistency. gemini-3-pro-image (Nano Banana Pro) is the premium choice for the most complex visual tasks \u2014 highest world knowledge, advanced localization, accurate brand consistency, precision creative control. gemini-3.1-flash-lite-image (Nano Banana 2 Lite) is the fastest/cheapest for simple tasks (1K only, no search grounding).";
33581
33638
  var timeoutMsSchema = external_exports.number().int().positive().optional().describe(
33582
33639
  "Upstream request timeout in ms for this call (default: $GEMINI_TIMEOUT_MS, else 60000 \u2014 or 120000 when image_size is 4K, which routinely runs past 60s)"
@@ -33673,7 +33730,8 @@ var sharedImageSchema = {
33673
33730
  // (`/models/${model}:generateContent`), so slashes/colons/queries must be
33674
33731
  // rejected to keep a crafted value from escaping the path segment.
33675
33732
  model: external_exports.string().regex(/^[\w.-]+$/, 'must be a bare model id (letters, digits, ".", "_", "-")').optional().describe(`Model id override (default: server default; see gemini_list_models). ${MODEL_CHOICE_GUIDE}`),
33676
- aspect_ratio: external_exports.enum(ASPECT_RATIOS).optional().describe("Output aspect ratio"),
33733
+ aspect_ratio: external_exports.enum(ASPECT_RATIOS).optional().describe("Exact output aspect ratio. For a plain landscape/portrait/square request, `orientation` is the shorthand; this wins if both are given."),
33734
+ orientation: orientationSchema,
33677
33735
  image_size: external_exports.enum(IMAGE_SIZES).optional().describe("Output resolution (512 = 0.5K, Flash-only)"),
33678
33736
  output_dir: external_exports.string().optional().describe("Directory to write images to (default: $GEMINI_OUTPUT_DIR or cwd)"),
33679
33737
  inline: external_exports.boolean().optional().describe("Return base64 images inline instead of writing to disk"),
@@ -33757,6 +33815,7 @@ function timeoutRiskHint(opts) {
33757
33815
  function buildMeta(model, seed, opts) {
33758
33816
  const meta3 = { model, seed };
33759
33817
  if (opts.aspect_ratio) meta3.aspect_ratio = opts.aspect_ratio;
33818
+ if (opts.orientation) meta3.orientation = opts.orientation;
33760
33819
  if (opts.image_size) meta3.image_size = opts.image_size;
33761
33820
  return meta3;
33762
33821
  }
@@ -34120,6 +34179,7 @@ var JobRegistry = class {
34120
34179
  status: entry.status,
34121
34180
  createdAt: entry.createdAt,
34122
34181
  updatedAt,
34182
+ ...entry.interactionId !== void 0 ? { interactionId: entry.interactionId } : {},
34123
34183
  ...entry.error ? { error: entry.error } : {},
34124
34184
  ...entry.status === "done" && entry.result ? persistableResult(entry.result) : {}
34125
34185
  };
@@ -34205,8 +34265,30 @@ var JobRegistry = class {
34205
34265
  }
34206
34266
  const jobId = randomUUID();
34207
34267
  this.evict(now);
34208
- const promise2 = work();
34209
- const entry = { jobId, toolName, fingerprint, idempotencyKey, status: "running", promise: promise2, createdAt: now };
34268
+ let entry;
34269
+ let earlyInteractionId;
34270
+ const ctx = {
34271
+ reportInteraction: (interactionId) => {
34272
+ if (!entry) {
34273
+ earlyInteractionId = interactionId;
34274
+ return;
34275
+ }
34276
+ if (entry.interactionId === interactionId) return;
34277
+ entry.interactionId = interactionId;
34278
+ if (this.store) this.track(this.writeRecord(entry));
34279
+ }
34280
+ };
34281
+ const promise2 = work(ctx);
34282
+ entry = {
34283
+ jobId,
34284
+ toolName,
34285
+ fingerprint,
34286
+ idempotencyKey,
34287
+ status: "running",
34288
+ promise: promise2,
34289
+ createdAt: now,
34290
+ ...earlyInteractionId !== void 0 ? { interactionId: earlyInteractionId } : {}
34291
+ };
34210
34292
  this.jobs.set(jobId, entry);
34211
34293
  if (idempotencyKey !== void 0) this.byKey.set(idempotencyKey, jobId);
34212
34294
  this.runningByFingerprint.set(fingerprint, jobId);
@@ -34266,6 +34348,45 @@ var JobRegistry = class {
34266
34348
  * the unknown-id path — the poll tool cannot be used to read another user's
34267
34349
  * result even if they know the uuid.
34268
34350
  */
34351
+ /**
34352
+ * The durable record for `jobId`, if there is one — including the
34353
+ * executor-lost rewrite. Read-only view for a caller that wants to act on a
34354
+ * record's contents (see `tools/jobs.ts` recovering an interaction id) rather
34355
+ * than just render it.
34356
+ */
34357
+ async peekRecord(jobId) {
34358
+ return this.store ? await this.store.get(jobId) : void 0;
34359
+ }
34360
+ /**
34361
+ * Adopt a result produced OUTSIDE this registry — recovery reading a lost
34362
+ * job's output back off the upstream interaction (`tools/jobs.ts`).
34363
+ *
34364
+ * Without this the job stays `failed` and every later poll re-enters
34365
+ * recovery: another interaction fetch, another media download, and another
34366
+ * copy on disk (`…-2.mp4`, `…-3.mp4`) or in R2, with nothing telling the
34367
+ * caller they are duplicates. The ordinary done path is idempotent, so this
34368
+ * one has to be too.
34369
+ */
34370
+ async adoptResult(jobId, toolName, result, interactionId) {
34371
+ const now = this.now();
34372
+ let entry = this.jobs.get(jobId);
34373
+ if (!entry) {
34374
+ entry = {
34375
+ jobId,
34376
+ toolName,
34377
+ fingerprint: `recovered:${jobId}`,
34378
+ status: "done",
34379
+ promise: Promise.resolve(result),
34380
+ createdAt: now
34381
+ };
34382
+ this.jobs.set(jobId, entry);
34383
+ }
34384
+ entry.status = "done";
34385
+ entry.result = result;
34386
+ entry.settledAt = now;
34387
+ if (interactionId !== void 0) entry.interactionId = interactionId;
34388
+ if (this.store) await this.writeRecord(entry);
34389
+ }
34269
34390
  async getResult(jobId) {
34270
34391
  const entry = this.jobs.get(jobId);
34271
34392
  if (!entry) {
@@ -34369,10 +34490,11 @@ function registerGenerateTools(server, client2) {
34369
34490
  if (gate) return gate;
34370
34491
  const count = args.count ?? 1;
34371
34492
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
34493
+ const aspectRatio = resolveAspectRatio(args, ASPECT_RATIOS);
34372
34494
  const fingerprint = fingerprintRequest("gemini_image_generate", {
34373
34495
  model,
34374
34496
  prompt: args.prompt,
34375
- aspect_ratio: args.aspect_ratio,
34497
+ aspect_ratio: aspectRatio,
34376
34498
  image_size: args.image_size,
34377
34499
  thinking_level: args.thinking_level,
34378
34500
  google_search: args.google_search,
@@ -34408,7 +34530,7 @@ function registerGenerateTools(server, client2) {
34408
34530
  prompt,
34409
34531
  images: refInputs.length > 0 ? refInputs : void 0,
34410
34532
  model: args.model,
34411
- aspectRatio: args.aspect_ratio,
34533
+ aspectRatio,
34412
34534
  imageSize: args.image_size,
34413
34535
  seed: seed + i,
34414
34536
  thinkingLevel: args.thinking_level,
@@ -34428,7 +34550,7 @@ function registerGenerateTools(server, client2) {
34428
34550
  }
34429
34551
  });
34430
34552
  if (named.length === 0) throw firstError;
34431
- const meta3 = buildMeta(model, seed, args);
34553
+ const meta3 = buildMeta(model, seed, { ...args, aspect_ratio: aspectRatio });
34432
34554
  if (capturedText) meta3.text = capturedText;
34433
34555
  if (capturedGrounding) meta3.grounding = capturedGrounding;
34434
34556
  if (report) meta3.image_inputs = report;
@@ -34470,10 +34592,11 @@ function registerGenerateTools(server, client2) {
34470
34592
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
34471
34593
  if (gate) return gate;
34472
34594
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
34595
+ const aspectRatio = resolveAspectRatio(args, ASPECT_RATIOS);
34473
34596
  const fingerprint = fingerprintRequest("gemini_image_edit", {
34474
34597
  model,
34475
34598
  prompt: args.prompt,
34476
- aspect_ratio: args.aspect_ratio,
34599
+ aspect_ratio: aspectRatio,
34477
34600
  image_size: args.image_size,
34478
34601
  thinking_level: args.thinking_level,
34479
34602
  google_search: args.google_search,
@@ -34497,7 +34620,7 @@ function registerGenerateTools(server, client2) {
34497
34620
  prompt,
34498
34621
  images: inputs,
34499
34622
  model: args.model,
34500
- aspectRatio: args.aspect_ratio,
34623
+ aspectRatio,
34501
34624
  imageSize: args.image_size,
34502
34625
  seed,
34503
34626
  thinkingLevel: args.thinking_level,
@@ -34505,7 +34628,7 @@ function registerGenerateTools(server, client2) {
34505
34628
  timeoutMs: args.timeout_ms
34506
34629
  }));
34507
34630
  const { images: [img], text } = result;
34508
- const meta3 = buildMeta(model, seed, args);
34631
+ const meta3 = buildMeta(model, seed, { ...args, aspect_ratio: aspectRatio });
34509
34632
  if (text) meta3.text = text;
34510
34633
  if (result.grounding) meta3.grounding = result.grounding;
34511
34634
  if (report) meta3.image_inputs = report;
@@ -34554,13 +34677,14 @@ function registerSetTools(server, client2) {
34554
34677
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local master image input(s) to the Gemini API", "/v1beta/models/{model}:generateContent", args.master_images);
34555
34678
  if (gate) return gate;
34556
34679
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
34680
+ const aspectRatio = resolveAspectRatio(args, ASPECT_RATIOS);
34557
34681
  const fingerprint = fingerprintRequest("gemini_image_set", {
34558
34682
  model,
34559
34683
  master_prompt: args.master_prompt,
34560
34684
  scenes: args.scenes,
34561
34685
  count: args.count,
34562
34686
  reference_mode: args.reference_mode,
34563
- aspect_ratio: args.aspect_ratio,
34687
+ aspect_ratio: aspectRatio,
34564
34688
  image_size: args.image_size,
34565
34689
  thinking_level: args.thinking_level,
34566
34690
  google_search: args.google_search,
@@ -34575,7 +34699,7 @@ function registerSetTools(server, client2) {
34575
34699
  });
34576
34700
  return client2.session.jobs.dispatch({ toolName: "gemini_image_set", fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
34577
34701
  const seed = pickSeed(args.seed);
34578
- const cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search, timeoutMs: args.timeout_ms };
34702
+ const cfg = { model: args.model, aspectRatio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search, timeoutMs: args.timeout_ms };
34579
34703
  const slug = args.basename ? baseName(args.basename) : slugify2(args.master_prompt);
34580
34704
  const charRefs = await resolveCharacterRefs(client2, args.characters, args.style);
34581
34705
  const masterPrompt = composePrompt(args.master_prompt, charRefs);
@@ -34635,7 +34759,7 @@ function registerSetTools(server, client2) {
34635
34759
  return result;
34636
34760
  });
34637
34761
  const masterText = masterResult.text;
34638
- const meta3 = buildMeta(model, seed, args);
34762
+ const meta3 = buildMeta(model, seed, { ...args, aspect_ratio: aspectRatio });
34639
34763
  if (masterText) meta3.text = masterText;
34640
34764
  if (masterResult.grounding) meta3.grounding = masterResult.grounding;
34641
34765
  if (report) meta3.image_inputs = report;
@@ -35010,6 +35134,21 @@ var UPLOAD_BASE_URL = "https://generativelanguage.googleapis.com/upload/v1beta";
35010
35134
  var FILE_MAX_BYTES = 2 * 1024 ** 3;
35011
35135
  var FILE_POLL_INTERVAL_MS = 2e3;
35012
35136
  var FILE_POLL_MAX_ATTEMPTS = 150;
35137
+ var INTERACTION_POLL_INTERVAL_MS = 3e3;
35138
+ var TERMINAL_INTERACTION_STATUSES = /* @__PURE__ */ new Set([
35139
+ "completed",
35140
+ "failed",
35141
+ "cancelled",
35142
+ "incomplete",
35143
+ "budget_exceeded"
35144
+ ]);
35145
+ var MEDIA_URI_HOST = "generativelanguage.googleapis.com";
35146
+ var MEDIA_DOWNLOAD_MAX_BYTES = 128 * 1024 * 1024;
35147
+ var MEDIA_CAP_SUBJECT = {
35148
+ noun: "Generated media",
35149
+ hint: "Ask for a shorter clip or a lower resolution \u2014 the file itself is still in the Files API for ~48h if you need it another way."
35150
+ };
35151
+ var INTERACTION_ID_RE = /^[\w-]+$/;
35013
35152
  var CHAIN_404_RETRY_BUDGET_MS = 12e4;
35014
35153
  var CHAIN_404_RETRY_BASE_MS = 2e3;
35015
35154
  var CHAIN_404_RETRY_MAX_SLEEP_MS = 3e4;
@@ -35018,18 +35157,20 @@ function resolveChainRetryBudgetMs() {
35018
35157
  return Number.isFinite(env) && env >= 0 ? env : CHAIN_404_RETRY_BUDGET_MS;
35019
35158
  }
35020
35159
  var ChainedRequest404Error = class extends McpToolError {
35021
- constructor(previousInteractionId, upstreamMessage, retries, opts) {
35160
+ constructor(previousInteractionId, upstreamMessage, retries, opts, chainExists, verdict) {
35022
35161
  super(
35023
- `Gemini returned HTTP 404 for a chained request (previous_interaction_id: "${previousInteractionId}"), retried ${retries.attempts}\xD7 over ~${Math.round(retries.waitedMs / 1e3)}s for store lag. Upstream said: ${upstreamMessage}. This does not necessarily mean the interaction expired \u2014 an unknown model id or an expired files/\u2026 uri returns the same generic 404. (For reference: interactions are retained 55 days on the paid tier, 1 day on the free tier, and are scoped to the API key that created them.)`,
35162
+ `Gemini returned HTTP 404 for a chained request (previous_interaction_id: "${previousInteractionId}"), retried ${retries.attempts}\xD7 over ~${Math.round(retries.waitedMs / 1e3)}s for store lag. Upstream said: ${upstreamMessage}. ` + (verdict ?? "This does not necessarily mean the interaction expired \u2014 an unknown model id or an expired files/\u2026 uri returns the same generic 404. ") + "(For reference: interactions are retained 55 days on the paid tier, 1 day on the free tier, and are scoped to the API key that created them.)",
35024
35163
  opts
35025
35164
  );
35026
35165
  this.previousInteractionId = previousInteractionId;
35027
35166
  this.upstreamMessage = upstreamMessage;
35028
35167
  this.retries = retries;
35168
+ this.chainExists = chainExists;
35029
35169
  }
35030
35170
  previousInteractionId;
35031
35171
  upstreamMessage;
35032
35172
  retries;
35173
+ chainExists;
35033
35174
  };
35034
35175
  function generatePart(img) {
35035
35176
  return img.uri ? { file_data: { file_uri: img.uri, mime_type: img.mimeType } } : { inline_data: { mime_type: img.mimeType, data: img.base64 } };
@@ -35469,31 +35610,52 @@ var GeminiClient = class {
35469
35610
  hint: "The request may have been blocked by safety filters \u2014 try rephrasing the prompt."
35470
35611
  });
35471
35612
  }
35472
- return { id: data.id, images, text, grounding };
35613
+ return { id: data.id, images: await this.downloadAll(images), text, grounding };
35473
35614
  }
35474
35615
  /**
35475
- * Generate video via the omni model (Interactions API, inline delivery). Same
35476
- * plumbing as {@link interact} — text/image input parts, a `video`
35477
- * response_format, optional `video_config.task`, and `previous_interaction_id`
35478
- * for edits. Preview model: shapes are docs-derived; parsing stays tolerant.
35616
+ * Generate video via the omni model (Interactions API). Same plumbing as
35617
+ * {@link interact} — text/image input parts, a `video` response_format,
35618
+ * optional `video_config.task`, and `previous_interaction_id` for edits.
35619
+ *
35620
+ * Delivered by uri unless the caller says otherwise, so the clip is not
35621
+ * capped by the ~4MB inline limit; the bytes are downloaded here either way.
35622
+ * Verified live 2026-08-22 end to end (a real MP4 off the wire), which is
35623
+ * why this path no longer carries the "docs-derived" caveat that music
35624
+ * still does.
35479
35625
  */
35480
35626
  async generateVideo(opts) {
35481
35627
  const model = opts.model ?? DEFAULT_VIDEO_MODEL;
35482
35628
  const inputParts = [{ type: "text", text: opts.input }];
35483
35629
  for (const img of opts.images ?? []) inputParts.push(interactPart(img));
35484
- const responseFormat = { type: "video", delivery: "inline" };
35485
- if (opts.aspectRatio) responseFormat.aspect_ratio = opts.aspectRatio;
35486
- const body = { model, input: inputParts, response_format: responseFormat };
35487
- if (opts.task) body.generation_config = { video_config: { task: opts.task } };
35488
- if (opts.previousInteractionId !== void 0) body.previous_interaction_id = opts.previousInteractionId;
35489
- const data = await this.postInteraction(body, opts);
35630
+ const delivery = opts.delivery ?? "uri";
35631
+ const buildBody = (withDelivery) => {
35632
+ const responseFormat = { type: "video" };
35633
+ if (withDelivery) responseFormat.delivery = delivery;
35634
+ if (opts.aspectRatio) responseFormat.aspect_ratio = opts.aspectRatio;
35635
+ const body2 = { model, input: inputParts, response_format: responseFormat };
35636
+ if (opts.task) body2.generation_config = { video_config: { task: opts.task } };
35637
+ if (opts.previousInteractionId !== void 0) body2.previous_interaction_id = opts.previousInteractionId;
35638
+ if (opts.background === true) body2.background = true;
35639
+ return body2;
35640
+ };
35641
+ const timeoutMs = resolveTimeoutMs(opts.timeoutMs, void 0);
35642
+ let body = buildBody(true);
35643
+ let started;
35644
+ try {
35645
+ started = await this.startInteraction(body, opts, timeoutMs);
35646
+ } catch (err) {
35647
+ if (!isDeliveryUnsupported(err)) throw err;
35648
+ body = buildBody(false);
35649
+ started = await this.startInteraction(body, opts, timeoutMs);
35650
+ }
35651
+ const data = await this.settleInteraction(started, body, timeoutMs);
35490
35652
  const { videos, text } = this.extractInteraction(data);
35491
35653
  if (videos.length === 0) {
35492
35654
  throw new McpToolError("Gemini returned no video", {
35493
- hint: "The request may have been blocked by a safety filter, or the clip exceeded the ~4MB inline limit \u2014 try a shorter/simpler prompt or a different aspect ratio."
35655
+ hint: "The request may have been blocked by a safety filter \u2014 try a shorter/simpler prompt or a different aspect ratio."
35494
35656
  });
35495
35657
  }
35496
- return { id: data.id, videos, text };
35658
+ return { id: data.id, videos: await this.downloadAll(videos), text };
35497
35659
  }
35498
35660
  /**
35499
35661
  * Generate music via a Lyria model (Interactions API, inline audio). Reuses
@@ -35507,6 +35669,7 @@ var GeminiClient = class {
35507
35669
  if (opts.audioFormat) responseFormat.audio_format = opts.audioFormat;
35508
35670
  const body = { model, input: inputParts, response_format: responseFormat };
35509
35671
  if (opts.previousInteractionId !== void 0) body.previous_interaction_id = opts.previousInteractionId;
35672
+ if (opts.background === true) body.background = true;
35510
35673
  const data = await this.postInteraction(body, opts);
35511
35674
  const { audios, text } = this.extractInteraction(data);
35512
35675
  if (audios.length === 0) {
@@ -35514,11 +35677,37 @@ var GeminiClient = class {
35514
35677
  hint: "The request may have been blocked by a safety filter \u2014 try rephrasing the prompt."
35515
35678
  });
35516
35679
  }
35517
- return { id: data.id, audios, text };
35680
+ return { id: data.id, audios: await this.downloadAll(audios), text };
35518
35681
  }
35519
- /** POST `/interactions` with the chained-404 retry. Shared by all media. */
35682
+ /**
35683
+ * POST `/interactions`, wait out the store lag on a chained 404, and — when
35684
+ * the request was backgrounded — poll until the interaction reaches a
35685
+ * terminal state. Shared by all media.
35686
+ */
35520
35687
  async postInteraction(body, opts) {
35521
- const { interactionsApi } = this.apisFor(resolveTimeoutMs(opts.timeoutMs, opts.imageSize));
35688
+ const timeoutMs = resolveTimeoutMs(opts.timeoutMs, opts.imageSize);
35689
+ return await this.settleInteraction(await this.startInteraction(body, opts, timeoutMs), body, timeoutMs);
35690
+ }
35691
+ /**
35692
+ * The POST half: issue the request and announce the id.
35693
+ *
35694
+ * Split from the wait so a caller can retry the POST — and ONLY the POST —
35695
+ * without the retry predicate ever seeing an error from after the generation
35696
+ * started. Past this point the work is running and billable.
35697
+ */
35698
+ async startInteraction(body, opts, timeoutMs) {
35699
+ const started = await this.postInteractionOnce(body, opts, timeoutMs);
35700
+ if (started.id) opts.onInteractionStarted?.(started.id);
35701
+ return started;
35702
+ }
35703
+ /** The wait half: poll a backgrounded request to a terminal state. */
35704
+ async settleInteraction(started, body, timeoutMs) {
35705
+ if (body.background !== true) return started;
35706
+ return await this.awaitInteraction(started, timeoutMs);
35707
+ }
35708
+ /** The POST itself, with the chained-404 retry + diagnosis. */
35709
+ async postInteractionOnce(body, opts, timeoutMs) {
35710
+ const { interactionsApi } = this.apisFor(timeoutMs);
35522
35711
  const budgetMs = resolveChainRetryBudgetMs();
35523
35712
  let waitedMs = 0;
35524
35713
  for (let attempt = 0; ; attempt++) {
@@ -35532,13 +35721,183 @@ var GeminiClient = class {
35532
35721
  await this.sleep(delay);
35533
35722
  continue;
35534
35723
  }
35535
- throw new ChainedRequest404Error(opts.previousInteractionId, err.message, { attempts: attempt, waitedMs }, {
35536
- hint: "Re-issue the request WITHOUT previous_interaction_id (re-attaching the prior output image if you were editing). If that also 404s, the interaction id is not the cause \u2014 check the model id and any files/\u2026 uri (they expire ~48h). gemini_interact does this probe automatically.",
35537
- cause: err
35538
- });
35724
+ const { chainExists, verdict } = await this.diagnoseChain(opts.previousInteractionId);
35725
+ throw new ChainedRequest404Error(
35726
+ opts.previousInteractionId,
35727
+ err.message,
35728
+ { attempts: attempt, waitedMs },
35729
+ {
35730
+ hint: chainExists === true ? "The chain is alive, so re-issuing without previous_interaction_id would re-bill and still fail. Check the model id and any files/\u2026 uri instead." : "Re-issue the request WITHOUT previous_interaction_id (re-attaching the prior output image if you were editing). If that also 404s, the interaction id is not the cause \u2014 check the model id and any files/\u2026 uri (they expire ~48h). gemini_interact does this probe automatically.",
35731
+ cause: err
35732
+ },
35733
+ chainExists,
35734
+ verdict
35735
+ );
35539
35736
  }
35540
35737
  }
35541
35738
  }
35739
+ /**
35740
+ * Ask the API whether the chained interaction is actually gone.
35741
+ *
35742
+ * Three answers, three remediations — and a fourth state that matters just as
35743
+ * much: if the probe itself cannot be completed we return `undefined`, so the
35744
+ * caller says "unknown" rather than inventing "gone".
35745
+ */
35746
+ async diagnoseChain(id) {
35747
+ try {
35748
+ await this.getInteraction(id);
35749
+ return {
35750
+ chainExists: true,
35751
+ verdict: `That interaction still exists (GET /interactions/${id} returned it), so the chain is NOT the cause \u2014 a generic 404 also covers an unknown model id and an expired files/\u2026 uri (~48h). Check those. `
35752
+ };
35753
+ } catch (err) {
35754
+ if (err instanceof ApiError && err.status === 404) {
35755
+ return {
35756
+ chainExists: false,
35757
+ verdict: `That interaction no longer exists (GET /interactions/${id} returned 404 too) \u2014 it expired or was deleted, so re-anchor on the image it produced. That does not necessarily mean it was the cause, though: an unknown model id or an expired files/\u2026 uri returns the same generic 404, so read the upstream text above. `
35758
+ };
35759
+ }
35760
+ if (err instanceof ApiError && err.status === 400) {
35761
+ return {
35762
+ chainExists: false,
35763
+ verdict: `"${id}" is not a valid interaction id \u2014 the API rejected the name as malformed, so it was never a live chain. Check how the id was captured. `
35764
+ };
35765
+ }
35766
+ return {};
35767
+ }
35768
+ }
35769
+ /**
35770
+ * Poll a backgrounded interaction to a terminal state.
35771
+ *
35772
+ * ⚠️ **A poll of work that is still in flight answers `403
35773
+ * permission_denied`** — "There was a problem processing your request. You
35774
+ * will not be charged." (verified live 2026-08-22, against a paid key that
35775
+ * GETs the very same interaction fine once it completes). It is a transient
35776
+ * state dressed as an authorization failure, and taking it at face value
35777
+ * fails every backgrounded generation a few seconds in. A 404 is transient
35778
+ * here too — the same store lag the chained POST retries.
35779
+ *
35780
+ * Neither is distinguishable from the real thing, so the caller's timeout is
35781
+ * what bounds it, and the last poll error rides along in the timeout message
35782
+ * so a genuine permission problem is not invisible.
35783
+ *
35784
+ * A body with no `status` field is likewise treated as "still running"
35785
+ * unless a `model_output` step is already present.
35786
+ */
35787
+ async awaitInteraction(started, budgetMs) {
35788
+ let current = started;
35789
+ let waitedMs = 0;
35790
+ let lastPollError;
35791
+ for (; ; ) {
35792
+ const status = current.status;
35793
+ if (!status && (current.steps ?? []).some((step2) => step2.type === "model_output")) return current;
35794
+ if (status && TERMINAL_INTERACTION_STATUSES.has(status)) {
35795
+ if (status === "completed") return current;
35796
+ throw new McpToolError(
35797
+ `${SERVICE} interaction ${current.id} ${status} (background execution).`,
35798
+ { hint: "A safety filter or an unsupported prompt is the usual cause \u2014 rephrase and retry. The interaction id above can be inspected while it is retained." }
35799
+ );
35800
+ }
35801
+ if (waitedMs + INTERACTION_POLL_INTERVAL_MS > budgetMs) {
35802
+ throw new McpToolError(
35803
+ `${SERVICE} interaction ${current.id} is still running after ${Math.round(waitedMs / 1e3)}s. It continues on Google's side \u2014 the work is not lost.` + (lastPollError ? ` Last poll said: ${lastPollError}` : ""),
35804
+ { hint: "Raise timeout_ms (or max_wait_ms) and retry, or keep the interaction id: the generation completes upstream regardless of this request." }
35805
+ );
35806
+ }
35807
+ await this.sleep(INTERACTION_POLL_INTERVAL_MS);
35808
+ waitedMs += INTERACTION_POLL_INTERVAL_MS;
35809
+ try {
35810
+ current = await this.getInteraction(current.id);
35811
+ lastPollError = void 0;
35812
+ } catch (err) {
35813
+ if (!isTransientPoll(err)) throw err;
35814
+ lastPollError = err instanceof Error ? err.message : String(err);
35815
+ }
35816
+ }
35817
+ }
35818
+ /**
35819
+ * Fetch one interaction. Backs the background poll and the chained-404
35820
+ * diagnosis; also the honest answer to "is that id still alive?".
35821
+ */
35822
+ async getInteraction(id) {
35823
+ if (!INTERACTION_ID_RE.test(id)) {
35824
+ throw new McpToolError(`Not a valid interaction id: "${id}"`, {
35825
+ hint: "An interaction id is the opaque `id` from a previous response (e.g. v1_Chd\u2026), with no slashes or query string."
35826
+ });
35827
+ }
35828
+ const { interactionsApi } = this.apisFor(resolveTimeoutMs(void 0));
35829
+ return await interactionsApi.fetchJson("GET", `/interactions/${id}`);
35830
+ }
35831
+ /**
35832
+ * Fetch a stored interaction and resolve its media to bytes.
35833
+ *
35834
+ * The recovery read: a generation whose executor died is still retrievable
35835
+ * by id for as long as the interaction is retained (55 days paid, 1 day
35836
+ * free). Returns the status alongside the media so a caller can tell "still
35837
+ * running" from "finished with nothing".
35838
+ */
35839
+ async fetchInteractionMedia(id) {
35840
+ const data = await this.getInteraction(id);
35841
+ const { images, videos, audios, text } = this.extractInteraction(data);
35842
+ return {
35843
+ status: data.status,
35844
+ images: await this.downloadAll(images),
35845
+ videos: await this.downloadAll(videos),
35846
+ audios: await this.downloadAll(audios),
35847
+ text
35848
+ };
35849
+ }
35850
+ /**
35851
+ * Resolve one extracted medium to bytes.
35852
+ *
35853
+ * `delivery: 'uri'` hands back a Files API link, and that link **needs the
35854
+ * api key** (verified: 403 without it), so it is not something a caller could
35855
+ * use — the bytes have to arrive here exactly as inline delivery would. The
35856
+ * uri is API-supplied but addresses a request we make with credentials
35857
+ * attached, so the host is checked before it is fetched.
35858
+ */
35859
+ async downloadMedia(media) {
35860
+ if (media.base64) return { base64: media.base64, mimeType: media.mimeType };
35861
+ const requested = media.uri ?? "";
35862
+ const doFetch = this.fetchImpl;
35863
+ const key = this.requireKey();
35864
+ let current = requested;
35865
+ for (let hop = 0; ; hop++) {
35866
+ const url2 = assertMediaHost(current, requested);
35867
+ const res = await doFetch(url2.toString(), {
35868
+ redirect: "manual",
35869
+ headers: { "x-goog-api-key": key }
35870
+ });
35871
+ if (res.status >= 300 && res.status < 400) {
35872
+ const location = res.headers?.get("location");
35873
+ if (!location) {
35874
+ throw new McpToolError(`${SERVICE} media download returned HTTP ${res.status} with no Location header for ${current}`);
35875
+ }
35876
+ if (hop >= MAX_REDIRECTS) {
35877
+ throw new McpToolError(`${SERVICE} media download for ${requested} exceeded ${MAX_REDIRECTS} redirects (last hop: ${location}).`);
35878
+ }
35879
+ current = new URL(location, url2).toString();
35880
+ continue;
35881
+ }
35882
+ if (!res.ok) {
35883
+ throw new McpToolError(
35884
+ `${SERVICE} media download failed (HTTP ${res.status}) for ${current}`,
35885
+ { hint: "Generated files live in the Files API for ~48h and the download requires the api key that created them." }
35886
+ );
35887
+ }
35888
+ const bytes = await readCapped(res, MEDIA_DOWNLOAD_MAX_BYTES, current, requested, MEDIA_CAP_SUBJECT);
35889
+ return {
35890
+ base64: bytesToBase64(bytes),
35891
+ mimeType: media.mimeType || res.headers?.get("content-type") || "application/octet-stream"
35892
+ };
35893
+ }
35894
+ }
35895
+ /** {@link downloadMedia} over a list, preserving order. */
35896
+ async downloadAll(media) {
35897
+ const out = [];
35898
+ for (const m of media) out.push(await this.downloadMedia(m));
35899
+ return out;
35900
+ }
35542
35901
  /**
35543
35902
  * Collect `model_output` media (image/video/audio) + text + grounding from an
35544
35903
  * interactions response. Only the `model_output` step is surfaced — `thought`
@@ -35567,12 +35926,13 @@ var GeminiClient = class {
35567
35926
  for (const parts of [step2.content ?? [], step2.summary ?? []]) {
35568
35927
  for (const part of parts) {
35569
35928
  const mime = part.mime_type ?? part.mimeType;
35570
- if (part.data) {
35571
- if (part.type === "image") images.push({ base64: part.data, mimeType: mime ?? "image/jpeg" });
35572
- else if (part.type === "video") videos.push({ base64: part.data, mimeType: mime ?? "video/mp4" });
35573
- else if (part.type === "audio") audios.push({ base64: part.data, mimeType: mime ?? "audio/mpeg" });
35574
- } else if (part.type === "text" && part.text?.trim()) {
35575
- textParts.push(part.text);
35929
+ if (part.type === "text") {
35930
+ if (part.text?.trim()) textParts.push(part.text);
35931
+ } else if (part.data || part.uri) {
35932
+ const carrier = part.data ? { base64: part.data } : { uri: part.uri };
35933
+ if (part.type === "image") images.push({ ...carrier, mimeType: mime ?? "image/jpeg" });
35934
+ else if (part.type === "video") videos.push({ ...carrier, mimeType: mime ?? "video/mp4" });
35935
+ else if (part.type === "audio") audios.push({ ...carrier, mimeType: mime ?? "audio/mpeg" });
35576
35936
  }
35577
35937
  }
35578
35938
  }
@@ -35586,6 +35946,27 @@ var GeminiClient = class {
35586
35946
  return { images, videos, audios, text: textParts.join("\n") || void 0, grounding };
35587
35947
  }
35588
35948
  };
35949
+ function assertMediaHost(candidate, requested) {
35950
+ let parsed;
35951
+ try {
35952
+ parsed = new URL(candidate);
35953
+ } catch {
35954
+ throw new McpToolError(`${SERVICE} returned a media reference that is not a URL: ${candidate || "(empty)"}`);
35955
+ }
35956
+ if (parsed.protocol !== "https:" || parsed.hostname !== MEDIA_URI_HOST) {
35957
+ throw new McpToolError(
35958
+ `${SERVICE} media download was pointed at an unexpected host (${parsed.hostname || candidate}); expected ${MEDIA_URI_HOST}` + (candidate === requested ? "" : ` (redirected from ${requested})`),
35959
+ { hint: "Generated media is served from the Files API on that host only. Report this if it reproduces \u2014 it should not happen." }
35960
+ );
35961
+ }
35962
+ return parsed;
35963
+ }
35964
+ function isTransientPoll(err) {
35965
+ return err instanceof ApiError && (err.status === 403 || err.status === 404);
35966
+ }
35967
+ function isDeliveryUnsupported(err) {
35968
+ return err instanceof ApiError && err.status === 400 && /delivery/i.test(err.message);
35969
+ }
35589
35970
  function hostedStorage() {
35590
35971
  const blob = blobStoreFromEnv();
35591
35972
  if (!blob) return {};
@@ -35703,7 +36084,8 @@ function registerInteractTools(server, client2) {
35703
36084
  `Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation. ${NEW_REFERENCES_ONLY}`
35704
36085
  ),
35705
36086
  model: external_exports.string().optional().describe(`Model id override (default: server default; see gemini_list_models). ${MODEL_CHOICE_GUIDE}`),
35706
- aspect_ratio: external_exports.enum(ASPECT_RATIOS).optional().describe("Output aspect ratio"),
36087
+ aspect_ratio: external_exports.enum(ASPECT_RATIOS).optional().describe("Exact output aspect ratio. For a plain landscape/portrait/square request, `orientation` is the shorthand; this wins if both are given."),
36088
+ orientation: orientationSchema,
35707
36089
  image_size: external_exports.enum(IMAGE_SIZES).optional().describe("Output resolution (512 = 0.5K, Flash-only)"),
35708
36090
  thinking_level: external_exports.enum(["minimal", "high"]).optional().describe("Reasoning depth; higher can help complex/structural edits"),
35709
36091
  filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified input)"),
@@ -35748,10 +36130,11 @@ function registerInteractTools(server, client2) {
35748
36130
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local file input(s) to the Gemini Interactions API", "/v1beta/interactions", images, args.video_path);
35749
36131
  if (gate) return gate;
35750
36132
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
36133
+ const aspectRatio = resolveAspectRatio(args, ASPECT_RATIOS);
35751
36134
  const fingerprint = fingerprintRequest("gemini_interact", {
35752
36135
  model,
35753
36136
  input: args.input,
35754
- aspect_ratio: args.aspect_ratio,
36137
+ aspect_ratio: aspectRatio,
35755
36138
  image_size: args.image_size,
35756
36139
  thinking_level: args.thinking_level,
35757
36140
  previous_interaction_id: previousInteractionId,
@@ -35771,7 +36154,7 @@ function registerInteractTools(server, client2) {
35771
36154
  const callOpts = {
35772
36155
  input: args.input,
35773
36156
  model: args.model,
35774
- aspectRatio: args.aspect_ratio,
36157
+ aspectRatio,
35775
36158
  imageSize: args.image_size,
35776
36159
  thinkingLevel: args.thinking_level,
35777
36160
  googleSearch: args.google_search,
@@ -35786,6 +36169,7 @@ function registerInteractTools(server, client2) {
35786
36169
  return await client2.interact({ ...callOpts, images: inputs.length ? inputs : void 0, previousInteractionId });
35787
36170
  } catch (err) {
35788
36171
  if (!(err instanceof ChainedRequest404Error)) throw err;
36172
+ if (err.chainExists === true) throw err;
35789
36173
  if (!onDisk) throw err;
35790
36174
  const reanchorOn = await findInteractionImages(resolveOutputDir(args.output_dir), err.previousInteractionId);
35791
36175
  if (!reanchorOn.length) throw err;
@@ -35846,6 +36230,7 @@ init_dist();
35846
36230
  init_images();
35847
36231
  var VIDEO_ASPECT_RATIOS = ["16:9", "9:16"];
35848
36232
  var VIDEO_TASKS = ["text_to_video", "image_to_video", "reference_to_video", "edit"];
36233
+ var VIDEO_DELIVERY = ["inline", "uri"];
35849
36234
  function registerVideoTools(server, client2) {
35850
36235
  server.registerTool(
35851
36236
  "gemini_video_generate",
@@ -35854,7 +36239,8 @@ function registerVideoTools(server, client2) {
35854
36239
  annotations: { readOnlyHint: false, openWorldHint: true },
35855
36240
  inputSchema: {
35856
36241
  prompt: external_exports.string().min(1).describe("Description of the video to generate (or the edit instruction when task=edit)"),
35857
- aspect_ratio: external_exports.enum(VIDEO_ASPECT_RATIOS).optional().describe("Output aspect ratio (omni: 16:9 or 9:16)"),
36242
+ aspect_ratio: external_exports.enum(VIDEO_ASPECT_RATIOS).optional().describe("Exact output aspect ratio (omni: 16:9 or 9:16). `orientation` is the plain-language shorthand; this wins if both are given."),
36243
+ orientation: orientationSchema,
35858
36244
  task: external_exports.enum(VIDEO_TASKS).optional().describe("text_to_video (default), image_to_video / reference_to_video (need image input), or edit (needs previous_interaction_id)"),
35859
36245
  images: external_exports.array(external_exports.string().min(1)).optional().describe("Reference image path(s) for image_to_video / reference_to_video"),
35860
36246
  images_url: imagesUrlSchema("Reference stills"),
@@ -35864,6 +36250,8 @@ function registerVideoTools(server, client2) {
35864
36250
  filename: external_exports.string().optional().describe("Base filename for the output video (extension stripped; default: slugified prompt)"),
35865
36251
  output_dir: external_exports.string().optional().describe("Directory to write the video to (default: $GEMINI_OUTPUT_DIR or cwd)"),
35866
36252
  model: external_exports.string().regex(/^[\w.-]+$/, 'must be a bare model id (letters, digits, ".", "_", "-")').optional().describe(`Model id override (default: ${DEFAULT_VIDEO_MODEL})`),
36253
+ delivery: external_exports.enum(VIDEO_DELIVERY).optional().describe('How the clip comes back: "uri" (default \u2014 a Files API link the server downloads, no size ceiling) or "inline" (base64, capped ~4MB)'),
36254
+ background: external_exports.boolean().optional().describe("Run the generation on Google's side and poll it, so a killed job can be recovered by gemini_get_result. Trade-off: retrieving a backgrounded interaction is unreliable today (some become permanently unreadable), so the default is off"),
35867
36255
  previous_interaction_id: external_exports.string().optional().describe('Interaction id to edit/continue (with task: "edit")'),
35868
36256
  continue_last: external_exports.boolean().optional().describe("Continue from the most recent video interaction this server created (explicit previous_interaction_id wins)"),
35869
36257
  timeout_ms: timeoutMsSchema,
@@ -35887,10 +36275,11 @@ function registerVideoTools(server, client2) {
35887
36275
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local image input(s) to the Gemini omni API", "/v1beta/interactions", args.images);
35888
36276
  if (gate) return gate;
35889
36277
  const model = args.model?.trim() || DEFAULT_VIDEO_MODEL;
36278
+ const aspectRatio = resolveAspectRatio(args, VIDEO_ASPECT_RATIOS);
35890
36279
  const fingerprint = fingerprintRequest("gemini_video_generate", {
35891
36280
  model,
35892
36281
  prompt: args.prompt,
35893
- aspect_ratio: args.aspect_ratio,
36282
+ aspect_ratio: aspectRatio,
35894
36283
  task: args.task,
35895
36284
  images: args.images,
35896
36285
  images_base64: args.images_base64,
@@ -35899,16 +36288,22 @@ function registerVideoTools(server, client2) {
35899
36288
  images_file_uris: args.images_file_uris,
35900
36289
  previous_interaction_id: previousInteractionId
35901
36290
  });
35902
- return client2.session.jobs.dispatch({ toolName: "gemini_video_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
36291
+ return client2.session.jobs.dispatch({ toolName: "gemini_video_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async (ctx) => {
35903
36292
  const { inputs, report } = await resolveImageInputs(args, client2);
35904
36293
  const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client2.generateVideo({
35905
36294
  input: args.prompt,
35906
36295
  images: inputs.length ? inputs : void 0,
35907
36296
  model: args.model,
35908
- aspectRatio: args.aspect_ratio,
36297
+ aspectRatio,
35909
36298
  task: args.task,
35910
36299
  previousInteractionId,
35911
- timeoutMs: args.timeout_ms
36300
+ timeoutMs: args.timeout_ms,
36301
+ delivery: args.delivery,
36302
+ background: args.background,
36303
+ // Hand the id to the job record the moment it exists: if this
36304
+ // machine stops mid-generation, that id is the only route back to
36305
+ // an output that finishes upstream anyway.
36306
+ onInteractionStarted: ctx.reportInteraction
35912
36307
  }));
35913
36308
  client2.session.lastVideoInteractionId = r.id;
35914
36309
  const meta3 = { model, interaction_id: r.id };
@@ -35956,6 +36351,7 @@ function registerMusicTools(server, client2) {
35956
36351
  inline: external_exports.boolean().optional().describe("Return base64 audio inline instead of writing to disk"),
35957
36352
  previous_interaction_id: external_exports.string().optional().describe("Interaction id to continue from"),
35958
36353
  continue_last: external_exports.boolean().optional().describe("Continue from the most recent music interaction this server created (explicit previous_interaction_id wins)"),
36354
+ background: external_exports.boolean().optional().describe("Run the generation on Google's side and poll it, so a killed job can be recovered by gemini_get_result. Off by default \u2014 see gemini_video_generate"),
35959
36355
  timeout_ms: timeoutMsSchema,
35960
36356
  idempotency_key: idempotencyKeySchema,
35961
36357
  async: asyncSchema,
@@ -35993,7 +36389,7 @@ function registerMusicTools(server, client2) {
35993
36389
  images_file_uris: args.images_file_uris,
35994
36390
  previous_interaction_id: previousInteractionId
35995
36391
  });
35996
- return client2.session.jobs.dispatch({ toolName: "gemini_music_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
36392
+ return client2.session.jobs.dispatch({ toolName: "gemini_music_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async (ctx) => {
35997
36393
  const { inputs, report } = await resolveImageInputs(args, client2);
35998
36394
  const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () => client2.generateMusic({
35999
36395
  input: args.prompt,
@@ -36001,7 +36397,10 @@ function registerMusicTools(server, client2) {
36001
36397
  model: args.model,
36002
36398
  audioFormat: args.audio_format,
36003
36399
  previousInteractionId,
36004
- timeoutMs: args.timeout_ms
36400
+ timeoutMs: args.timeout_ms,
36401
+ background: args.background,
36402
+ // See gemini_video_generate: the id is what survives the executor.
36403
+ onInteractionStarted: ctx.reportInteraction
36005
36404
  }));
36006
36405
  client2.session.lastMusicInteractionId = r.id;
36007
36406
  const meta3 = { model, interaction_id: r.id };
@@ -36022,19 +36421,84 @@ function registerMusicTools(server, client2) {
36022
36421
 
36023
36422
  // src/tools/jobs.ts
36024
36423
  init_zod();
36424
+ init_dist();
36425
+ init_images();
36025
36426
  function registerJobTools(server, client2) {
36026
36427
  server.registerTool(
36027
36428
  "gemini_get_result",
36028
36429
  {
36029
- description: 'Retrieve a generation started with `async: true` or handed off by `max_wait_ms`. Pass the returned `job_id`: while running it reports status "running"; on completion it returns the normal result (image URLs/paths + meta); on failure it raises the recorded error, including the case where the generation was killed before it finished. On the hosted connector job records are stored durably and survive a restart; on a local stdio server they live with the process and expire ~10 min after completion, where the output dir / <image>.json sidecar is the fallback.',
36030
- annotations: { readOnlyHint: true, openWorldHint: false },
36430
+ description: 'Retrieve a generation started with `async: true` or handed off by `max_wait_ms`. Pass the returned `job_id`: while running it reports status "running"; on completion it returns the normal result (image URLs/paths + meta); on failure it raises the recorded error, including the case where the generation was killed before it finished. On the hosted connector job records are stored durably and survive a restart; on a local stdio server they live with the process and expire ~10 min after completion, where the output dir / <image>.json sidecar is the fallback. A killed video/music job started with `background: true` is recovered from its upstream interaction when it finished there.',
36431
+ // NOT read-only any more: recovering a killed job writes the media it
36432
+ // pulls back, exactly as the generation tool would have.
36433
+ annotations: { readOnlyHint: false, openWorldHint: true },
36031
36434
  inputSchema: {
36032
- job_id: external_exports.string().min(1).describe("The job_id returned by a generation tool called with async: true")
36435
+ job_id: external_exports.string().min(1).describe("The job_id returned by a generation tool called with async: true"),
36436
+ output_dir: external_exports.string().optional().describe("Where to write media recovered from a killed job (default: $GEMINI_OUTPUT_DIR or cwd)")
36033
36437
  }
36034
36438
  },
36035
- async (args) => client2.session.jobs.getResult(args.job_id)
36439
+ async (args) => {
36440
+ try {
36441
+ return await client2.session.jobs.getResult(args.job_id);
36442
+ } catch (err) {
36443
+ const recovered = await recoverFromInteraction(client2, args.job_id, args.output_dir, err);
36444
+ if (recovered) return recovered;
36445
+ throw err;
36446
+ }
36447
+ }
36036
36448
  );
36037
36449
  }
36450
+ async function recoverFromInteraction(client2, jobId, outputDir, original) {
36451
+ const record2 = await client2.session.jobs.peekRecord(jobId);
36452
+ const interactionId = record2?.interactionId;
36453
+ if (!interactionId || record2?.status !== "failed") return void 0;
36454
+ let found;
36455
+ try {
36456
+ found = await client2.fetchInteractionMedia(interactionId);
36457
+ } catch (probeErr) {
36458
+ if (isInFlight(probeErr)) return stillRunning(jobId, interactionId);
36459
+ throw new McpToolError(
36460
+ `${errorMessage(original)} Its upstream interaction "${interactionId}" could not be read either (${errorMessage(probeErr)}), so the output is not recoverable.`,
36461
+ { hint: "Any media the lost run did write is still listed by gemini_list_recent_media. Otherwise the generation has to be re-run." }
36462
+ );
36463
+ }
36464
+ const [kind, media] = found.videos.length ? ["video", found.videos] : found.audios.length ? ["audio", found.audios] : found.images.length ? ["image", found.images] : ["image", []];
36465
+ if (media.length === 0) {
36466
+ if (found.status && !TERMINAL_INTERACTION_STATUSES.has(found.status)) return stillRunning(jobId, interactionId);
36467
+ return void 0;
36468
+ }
36469
+ const slug = slugify2(`recovered-${interactionId}`.slice(0, 60));
36470
+ const named = media.map((m, i) => ({
36471
+ media: m,
36472
+ base: media.length > 1 ? `${slug}-${String(i + 1).padStart(2, "0")}` : slug
36473
+ }));
36474
+ const result = await emitMedia(named, kind, { output_dir: outputDir, sink: client2.mediaSink }, {
36475
+ job_id: jobId,
36476
+ recovered_from_interaction: interactionId,
36477
+ note: recoveryNote(record2.error?.message),
36478
+ ...found.text ? { text: found.text } : {}
36479
+ });
36480
+ await client2.session.jobs.adoptResult(jobId, record2.toolName, result, interactionId);
36481
+ return result;
36482
+ }
36483
+ function recoveryNote(recordedError) {
36484
+ const cause = /was lost:/.test(recordedError ?? "") ? "The executor that started this job was reclaimed before it could return." : "This job failed here before it could return its output.";
36485
+ return `${cause} The generation had already completed upstream and its output was recovered \u2014 it was not re-run, and you were not billed twice.`;
36486
+ }
36487
+ function stillRunning(jobId, interactionId) {
36488
+ return textResult({
36489
+ job_id: jobId,
36490
+ status: "running",
36491
+ interaction_id: interactionId,
36492
+ note: "The executor that started this job is gone, but the generation is still running upstream. Poll again \u2014 it has not been lost, and re-running it would bill a second time."
36493
+ });
36494
+ }
36495
+ function isInFlight(err) {
36496
+ const status = err?.status;
36497
+ return status === 403 || status === 404;
36498
+ }
36499
+ function errorMessage(err) {
36500
+ return err instanceof Error ? err.message : String(err);
36501
+ }
36038
36502
 
36039
36503
  // src/tools/files.ts
36040
36504
  init_zod();