@chrischall/gemini-mcp 1.6.2 → 1.8.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;
@@ -32838,7 +32838,7 @@ async function loadStdioDotenv() {
32838
32838
  }
32839
32839
 
32840
32840
  // src/version.ts
32841
- var VERSION = "1.6.2";
32841
+ var VERSION = "1.8.0";
32842
32842
 
32843
32843
  // src/errors.ts
32844
32844
  init_dist();
@@ -33078,15 +33078,15 @@ function fileNameFromUrl(url2, fallback) {
33078
33078
  function describeUrl(current, requested) {
33079
33079
  return current === requested ? current : `${current} (redirected from ${requested})`;
33080
33080
  }
33081
- async function readCapped(res, maxBytes, url2, requested) {
33081
+ async function readCapped(res, maxBytes, url2, requested, subject) {
33082
33082
  const declared = Number(res.headers.get("content-length"));
33083
33083
  if (Number.isFinite(declared) && declared > maxBytes) {
33084
- throw tooBig(url2, requested, declared, maxBytes);
33084
+ throw tooBig(url2, requested, declared, maxBytes, false, subject);
33085
33085
  }
33086
33086
  const reader = res.body?.getReader();
33087
33087
  if (!reader) {
33088
33088
  const buf = new Uint8Array(await res.arrayBuffer());
33089
- if (buf.byteLength > maxBytes) throw tooBig(url2, requested, buf.byteLength, maxBytes);
33089
+ if (buf.byteLength > maxBytes) throw tooBig(url2, requested, buf.byteLength, maxBytes, false, subject);
33090
33090
  return buf;
33091
33091
  }
33092
33092
  const chunks = [];
@@ -33100,7 +33100,7 @@ async function readCapped(res, maxBytes, url2, requested) {
33100
33100
  if (total > maxBytes) {
33101
33101
  await reader.cancel().catch(() => {
33102
33102
  });
33103
- throw tooBig(url2, requested, total, maxBytes, true);
33103
+ throw tooBig(url2, requested, total, maxBytes, true, subject);
33104
33104
  }
33105
33105
  chunks.push(value);
33106
33106
  }
@@ -33127,12 +33127,18 @@ function fetchFailure(err, current, requested, timeoutMs) {
33127
33127
  { hint: "Check the URL is reachable from the server and serves the image without authentication." }
33128
33128
  );
33129
33129
  }
33130
- function tooBig(url2, requested, size, max, partial2 = false) {
33130
+ function tooBig(url2, requested, size, max, partial2 = false, subject) {
33131
+ const what = subject ?? IMAGE_CAP_SUBJECT;
33131
33132
  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." }
33133
+ `${what.noun} at ${describeUrl(url2, requested)} is ${partial2 ? "over " : ""}${formatMb(size)}, above the ${formatMb(max)} limit${what.limitFor ? ` for ${what.limitFor}` : ""}.`,
33134
+ { hint: what.hint }
33134
33135
  );
33135
33136
  }
33137
+ var IMAGE_CAP_SUBJECT = {
33138
+ noun: "Image",
33139
+ limitFor: "images_url",
33140
+ hint: "Resize or re-encode the image, or upload it yourself with gemini_upload_file and pass the returned file_uri."
33141
+ };
33136
33142
  async function fetchRemoteImage(requestedUrl, opts = {}) {
33137
33143
  const doFetch = opts.fetchImpl ?? fetch;
33138
33144
  const maxBytes = opts.maxBytes ?? IMAGE_URL_MAX_BYTES;
@@ -33367,6 +33373,14 @@ async function tenantIdFor(secret) {
33367
33373
  return [...new Uint8Array(digest).slice(0, 6)].map((b) => b.toString(16).padStart(2, "0")).join("");
33368
33374
  }
33369
33375
  var KEY_PREFIX_DEFAULT = "gen";
33376
+ var MAX_LIST_PAGES = 10;
33377
+ function parseMediaKey(key, prefix) {
33378
+ const rest = key.slice(prefix.length);
33379
+ const match = /^(\d{4}-\d{2}-\d{2})\/(.+)$/.exec(rest);
33380
+ if (!match) return void 0;
33381
+ const name = match[2].replace(/^[0-9a-f]{4,}-/i, "").replace(/\.[A-Za-z0-9]+$/, "");
33382
+ return { day: match[1], name: name || match[2] };
33383
+ }
33370
33384
  function safeKeySegment(base) {
33371
33385
  const cleaned = base.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/\.{2,}/g, "-").replace(/-+/g, "-").replace(/^[-.]+|[-.]+$/g, "");
33372
33386
  return cleaned || "media";
@@ -33402,6 +33416,40 @@ function createR2Sink(bucket, opts) {
33402
33416
  mimeType: object3.httpMetadata?.contentType ?? "application/octet-stream"
33403
33417
  };
33404
33418
  },
33419
+ async listRecent(opts2 = {}) {
33420
+ return (await this.listRecentPage(opts2)).media;
33421
+ },
33422
+ async listRecentPage({ limit = 20, sinceDay } = {}) {
33423
+ if (!bucket.list) return { media: [], truncated: false, scannedPages: 0 };
33424
+ const prefix2 = await ownPrefix();
33425
+ const found = [];
33426
+ let cursor;
33427
+ let truncated = false;
33428
+ let scannedPages = 0;
33429
+ for (let page2 = 0; page2 < MAX_LIST_PAGES; page2++) {
33430
+ scannedPages = page2 + 1;
33431
+ const listed = await bucket.list({ prefix: prefix2, cursor, limit: 1e3 });
33432
+ for (const obj of listed.objects) {
33433
+ const parsed = parseMediaKey(obj.key, prefix2);
33434
+ if (!parsed) continue;
33435
+ if (sinceDay && parsed.day < sinceDay) continue;
33436
+ found.push({ ...parsed, key: obj.key, ...obj.size !== void 0 ? { sizeBytes: obj.size } : {} });
33437
+ }
33438
+ if (!listed.truncated || !listed.cursor) break;
33439
+ cursor = listed.cursor;
33440
+ if (page2 === MAX_LIST_PAGES - 1) truncated = true;
33441
+ }
33442
+ found.sort((a, b) => a.day < b.day ? 1 : a.day > b.day ? -1 : a.key < b.key ? 1 : -1);
33443
+ const page = found.slice(0, limit);
33444
+ const expiresAtMs2 = now().getTime() + ttlMs;
33445
+ const media = await Promise.all(
33446
+ page.map(async (entry) => {
33447
+ const link = await describe3(entry.key, expiresAtMs2);
33448
+ return { ...entry, ...link.unavailable ? {} : { url: link.ref, expiresAt: link.expiresAt } };
33449
+ })
33450
+ );
33451
+ return { media, truncated: truncated || found.length > limit, scannedPages };
33452
+ },
33405
33453
  async resign(key) {
33406
33454
  if (!await owned(key)) return void 0;
33407
33455
  if (bucket.get && !await bucket.get(key)) return void 0;
@@ -33543,7 +33591,7 @@ var idempotencyKeySchema = external_exports.string().min(1).optional().describe(
33543
33591
  "Opaque idempotency key: a repeat call with the same key returns the recorded result (reused: true) instead of billing a new generation. Set it when retrying after a host timeout (-32001) to avoid a duplicate charge."
33544
33592
  );
33545
33593
  var asyncSchema = external_exports.boolean().optional().describe(
33546
- "Run in the background and return a job_id immediately instead of the image, so a long (Pro/4K) generation cannot hit the host tools/call timeout (-32001). Poll gemini_get_result with the job_id to fetch the result (jobs are per-process and expire ~10 min after completion)."
33594
+ "Run in the background and return a job_id immediately instead of the image, so a long (Pro/4K) generation cannot hit the host tools/call timeout (-32001). Poll gemini_get_result with the job_id to fetch the result. PREFER `max_wait_ms` on the hosted connector: it runs where the executor is only guaranteed to stay alive while the request is open, so this option is served there as a bounded wait rather than an immediate hand-off."
33547
33595
  );
33548
33596
  var maxWaitMsSchema = external_exports.number().int().positive().max(6e5).optional().describe(
33549
33597
  'Wait up to this many ms for the result; if generation is still running when the budget expires, return { job_id, status: "running" } immediately instead (poll gemini_get_result). Keeps fast results in-band while a slow batch can never trip the host tools/call timeout (-32001) \u2014 e.g. 20000 for multi-image sets. Ignored when async is set.'
@@ -33708,7 +33756,7 @@ function timeoutRiskHint(opts) {
33708
33756
  const why = [proModel && "Pro model", fourK && "4K output", multi && `count=${opts.count}`].filter(Boolean).join(" + ");
33709
33757
  const lead = `Slow config (${why}) can exceed a host's tools/call timeout (e.g. Claude Desktop ~30s, error -32001). `;
33710
33758
  if (opts.persistsFiles === false) {
33711
- return lead + `This connector has no filesystem, so a lost response cannot be recovered from disk \u2014 there is no output dir and no <image>.json sidecar. Use \`async: true\` to get a job_id immediately and poll gemini_get_result, or use a Flash model / drop image_size to 1K/2K for a faster in-band result.`;
33759
+ return lead + `This connector has no filesystem, so a lost response cannot be recovered from disk \u2014 there is no output dir and no <image>.json sidecar. Set \`max_wait_ms\` (e.g. 240000): keeping the request open is also what keeps the generation running here, and a batch that outlives the budget hands back a job_id to poll with gemini_get_result. For a faster in-band result use a Flash model or drop image_size to 1K/2K. If a result is ever lost, list what was already generated with gemini_list_recent_media before paying for it again.`;
33712
33760
  }
33713
33761
  return lead + `If the host gives up mid-generation the image is still written to disk (and gemini_interact leaves an <image>.json sidecar with the interaction id) \u2014 no rerun needed, just look in the output dir. For a faster in-band result use a Flash model, drop image_size to 1K/2K, or \u2014 in Claude Code \u2014 set MCP_TOOL_TIMEOUT.`;
33714
33762
  }
@@ -33810,14 +33858,73 @@ function assertLocalInputsAvailable(sink, args) {
33810
33858
 
33811
33859
  // src/jobs.ts
33812
33860
  init_dist();
33813
- import { randomUUID, createHash } from "node:crypto";
33861
+ import { randomUUID, createHash as createHash2 } from "node:crypto";
33862
+
33863
+ // src/job-store.ts
33864
+ import { createHash } from "node:crypto";
33865
+ var HEARTBEAT_MS = 6e4;
33866
+ var EXECUTOR_LOST_AFTER_MS = 3 * HEARTBEAT_MS;
33867
+ var PREFIX = "jobs";
33868
+ function keyHash(idempotencyKey) {
33869
+ return createHash("sha256").update(idempotencyKey).digest("hex").slice(0, 32);
33870
+ }
33871
+ function createBlobJobStore(bucket, opts) {
33872
+ const now = opts.now ?? (() => Date.now());
33873
+ const tenantOf = async () => typeof opts.tenant === "function" ? opts.tenant() : opts.tenant;
33874
+ const recordKey = async (jobId) => `${PREFIX}/${await tenantOf()}/${encodeURIComponent(jobId)}.json`;
33875
+ const pointerKey = async (k) => `${PREFIX}/${await tenantOf()}/keys/${keyHash(k)}.json`;
33876
+ async function writeJson(key, value) {
33877
+ try {
33878
+ await bucket.put(key, JSON.stringify(value), { httpMetadata: { contentType: "application/json" } });
33879
+ } catch {
33880
+ }
33881
+ }
33882
+ async function readJson(key) {
33883
+ try {
33884
+ const obj = await bucket.get(key);
33885
+ if (!obj) return void 0;
33886
+ return JSON.parse(new TextDecoder().decode(await obj.arrayBuffer()));
33887
+ } catch {
33888
+ return void 0;
33889
+ }
33890
+ }
33891
+ return {
33892
+ async put(record2) {
33893
+ await writeJson(await recordKey(record2.jobId), record2);
33894
+ },
33895
+ async get(jobId) {
33896
+ const record2 = await readJson(await recordKey(jobId));
33897
+ if (!record2 || typeof record2.status !== "string") return void 0;
33898
+ if (record2.status !== "running") return record2;
33899
+ if (now() - record2.updatedAt <= EXECUTOR_LOST_AFTER_MS) return record2;
33900
+ return {
33901
+ ...record2,
33902
+ status: "failed",
33903
+ error: {
33904
+ message: `Job "${jobId}" was lost: its executor stopped reporting ${Math.round((now() - record2.updatedAt) / 1e3)}s ago, before the generation finished. This connector runs on a machine that stops when no request is in flight, so background work started with \`async: true\` does not reliably survive.`,
33905
+ hint: "Re-run with `max_wait_ms` (e.g. 240000) instead of `async: true` \u2014 holding the request open keeps the executor alive. Any images the lost run did finish are still stored: list them with gemini_list_recent_media before paying for a regeneration."
33906
+ }
33907
+ };
33908
+ },
33909
+ async putKey(idempotencyKey, jobId) {
33910
+ await writeJson(await pointerKey(idempotencyKey), { jobId });
33911
+ },
33912
+ async jobIdForKey(idempotencyKey) {
33913
+ const pointer = await readJson(await pointerKey(idempotencyKey));
33914
+ return typeof pointer?.jobId === "string" ? pointer.jobId : void 0;
33915
+ }
33916
+ };
33917
+ }
33918
+
33919
+ // src/jobs.ts
33814
33920
  var JOB_TTL_MS = 6e5;
33921
+ var DURABLE_JOB_REUSE_MS = 24 * 60 * 60 * 1e3;
33815
33922
  var JOB_MAX = 64;
33816
33923
  function isExpired(entry, now) {
33817
33924
  return entry.settledAt !== void 0 && now - entry.settledAt > JOB_TTL_MS;
33818
33925
  }
33819
33926
  function fingerprintRequest(toolName, parts) {
33820
- return createHash("sha256").update(`${toolName}\0${JSON.stringify(parts)}`).digest("hex");
33927
+ return createHash2("sha256").update(`${toolName}\0${JSON.stringify(parts)}`).digest("hex");
33821
33928
  }
33822
33929
  async function refreshMedia(result, sink) {
33823
33930
  const first = result.content?.[0];
@@ -33896,13 +34003,23 @@ function annotateReused(result, jobId) {
33896
34003
  content: [{ type: "text", text: JSON.stringify({ reused: true, reused_job_id: jobId }, null, 2) }, ...content]
33897
34004
  };
33898
34005
  }
33899
- function jobHandle(jobId, status) {
34006
+ function jobHandle(jobId, status, durable = false) {
33900
34007
  return textResult({
33901
34008
  job_id: jobId,
33902
34009
  status,
33903
- hint: `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done" (results are per-process and expire ~10 min after completion).`
34010
+ hint: durable ? `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done". The job record is stored durably, so a poll still works if this connector restarts \u2014 but background work is NOT restarted, so a long generation is safer with max_wait_ms than with async.` : `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done" (results are per-process and expire ~10 min after completion).`
33904
34011
  });
33905
34012
  }
34013
+ var MAX_PERSISTED_RESULT_BYTES = 64 * 1024;
34014
+ function persistableResult(result) {
34015
+ if ((result.content ?? []).some((block) => block.type !== "text")) return { resultOmitted: "inline" };
34016
+ try {
34017
+ if (JSON.stringify(result).length > MAX_PERSISTED_RESULT_BYTES) return { resultOmitted: "too_large" };
34018
+ } catch {
34019
+ return { resultOmitted: "too_large" };
34020
+ }
34021
+ return { result };
34022
+ }
33906
34023
  function awaitWithBudget(promise2, waitMs) {
33907
34024
  return new Promise((resolve2, reject) => {
33908
34025
  const timer = setTimeout(() => resolve2(void 0), waitMs);
@@ -33926,16 +34043,113 @@ var JobRegistry = class {
33926
34043
  * never expire.
33927
34044
  */
33928
34045
  resigner;
34046
+ /**
34047
+ * Durable backing for this registry, when the deployment has somewhere to put
34048
+ * it. Absent on a local stdio install, where the process that starts a
34049
+ * generation is the process that finishes it and a `Map` is the whole truth.
34050
+ *
34051
+ * Present on the hosted connector, where it is load-bearing: see the header of
34052
+ * `src/job-store.ts` for why an in-memory-only registry cannot work on a
34053
+ * machine that stops whenever no request is in flight.
34054
+ */
34055
+ store;
34056
+ /**
34057
+ * When set, `async: true` is served as a bounded in-band wait instead of an
34058
+ * immediate hand-off.
34059
+ *
34060
+ * Set by the hosted client, because there the two are not the trade-off they
34061
+ * look like. Returning immediately releases the request, and the released
34062
+ * request is the thing keeping the executor alive — so `async: true` is
34063
+ * precisely the way to get a generation killed. Waiting holds the machine up
34064
+ * until the work is done. If the budget still expires the caller gets the same
34065
+ * job handle it asked for, now backed by a durable record.
34066
+ */
34067
+ asyncWaitFallbackMs;
34068
+ /** Injectable clock (tests). */
34069
+ now = () => Date.now();
33929
34070
  jobs = /* @__PURE__ */ new Map();
33930
34071
  byKey = /* @__PURE__ */ new Map();
33931
34072
  // idempotencyKey -> jobId
33932
34073
  runningByFingerprint = /* @__PURE__ */ new Map();
33933
34074
  // fingerprint -> jobId (only while running)
34075
+ /** Durable bookkeeping still in flight — awaited by `drain()` in tests. */
34076
+ pending = /* @__PURE__ */ new Set();
34077
+ heartbeatTimer;
33934
34078
  /** Drop every job. Used by `SessionState.reset()` (tests) — not by handlers. */
33935
34079
  reset() {
33936
34080
  this.jobs.clear();
33937
34081
  this.byKey.clear();
33938
34082
  this.runningByFingerprint.clear();
34083
+ this.stopHeartbeat();
34084
+ }
34085
+ /** Await outstanding durable writes. Tests only — handlers never need it. */
34086
+ async drain() {
34087
+ for (let round = 0; round < 50; round++) {
34088
+ await new Promise((resolve2) => setTimeout(resolve2, 0));
34089
+ if (!this.pending.size) return;
34090
+ await Promise.allSettled([...this.pending]);
34091
+ }
34092
+ }
34093
+ /**
34094
+ * Re-stamp every running job's durable record.
34095
+ *
34096
+ * This is what separates "still working" from "the machine went away": a
34097
+ * reader compares the stamp against `EXECUTOR_LOST_AFTER_MS`, so a 12-scene
34098
+ * set that legitimately runs for ten minutes stays `running` while a job whose
34099
+ * child was killed goes terminal within three missed beats.
34100
+ */
34101
+ async beat() {
34102
+ if (!this.store) return;
34103
+ await Promise.allSettled(
34104
+ [...this.jobs.values()].filter((e) => e.status === "running").map((e) => this.writeRecord(e, { onlyWhileRunning: true }))
34105
+ );
34106
+ }
34107
+ /**
34108
+ * Queue one durable write for `entry`, after any write already in flight for
34109
+ * it. `onlyWhileRunning` drops the write if the job settled in the meantime —
34110
+ * which is what makes a late heartbeat a no-op rather than a resurrection.
34111
+ */
34112
+ writeRecord(entry, opts = {}) {
34113
+ const next = (entry.writeChain ?? Promise.resolve()).then(async () => {
34114
+ if (opts.onlyWhileRunning && entry.status !== "running") return;
34115
+ await this.store.put(this.recordFor(entry, this.now()));
34116
+ }, () => void 0);
34117
+ entry.writeChain = next;
34118
+ return next;
34119
+ }
34120
+ recordFor(entry, updatedAt) {
34121
+ return {
34122
+ jobId: entry.jobId,
34123
+ toolName: entry.toolName,
34124
+ fingerprint: entry.fingerprint,
34125
+ ...entry.idempotencyKey !== void 0 ? { idempotencyKey: entry.idempotencyKey } : {},
34126
+ status: entry.status,
34127
+ createdAt: entry.createdAt,
34128
+ updatedAt,
34129
+ ...entry.interactionId !== void 0 ? { interactionId: entry.interactionId } : {},
34130
+ ...entry.error ? { error: entry.error } : {},
34131
+ ...entry.status === "done" && entry.result ? persistableResult(entry.result) : {}
34132
+ };
34133
+ }
34134
+ /** Run `p` as durable bookkeeping: tracked for `drain()`, never thrown from. */
34135
+ track(p) {
34136
+ const wrapped = p.catch(() => void 0).finally(() => this.pending.delete(wrapped));
34137
+ this.pending.add(wrapped);
34138
+ }
34139
+ startHeartbeat() {
34140
+ if (this.heartbeatTimer || !this.store) return;
34141
+ this.heartbeatTimer = setInterval(() => {
34142
+ if (![...this.jobs.values()].some((e) => e.status === "running")) {
34143
+ this.stopHeartbeat();
34144
+ return;
34145
+ }
34146
+ this.track(this.beat());
34147
+ }, HEARTBEAT_MS);
34148
+ this.heartbeatTimer.unref?.();
34149
+ }
34150
+ stopHeartbeat() {
34151
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
34152
+ this.heartbeatTimer = void 0;
33939
34153
  }
33940
34154
  /** Drop expired entries, then enforce JOB_MAX by evicting oldest-settled first. */
33941
34155
  evict(now) {
@@ -33971,8 +34185,11 @@ var JobRegistry = class {
33971
34185
  * different registry and therefore a genuinely separate generation.
33972
34186
  */
33973
34187
  async dispatch(opts, work) {
33974
- const { toolName, fingerprint, idempotencyKey, async, waitMs } = opts;
33975
- const now = Date.now();
34188
+ const { toolName, fingerprint, idempotencyKey } = opts;
34189
+ const degrade = opts.async === true && this.asyncWaitFallbackMs !== void 0;
34190
+ const async = degrade ? false : opts.async;
34191
+ const waitMs = degrade ? opts.waitMs ?? this.asyncWaitFallbackMs : opts.waitMs;
34192
+ const now = this.now();
33976
34193
  let hit;
33977
34194
  if (idempotencyKey !== void 0) {
33978
34195
  const id = this.byKey.get(idempotencyKey);
@@ -33983,6 +34200,10 @@ var JobRegistry = class {
33983
34200
  const entry2 = id ? this.jobs.get(id) : void 0;
33984
34201
  if (entry2 && entry2.status === "running") hit = entry2;
33985
34202
  }
34203
+ if (!hit && idempotencyKey !== void 0 && this.store) {
34204
+ const durable = await this.durableByKey(idempotencyKey, fingerprint);
34205
+ if (durable) return durable;
34206
+ }
33986
34207
  if (hit) {
33987
34208
  if (async) return jobHandle(hit.jobId, hit.status);
33988
34209
  const settled = waitMs !== void 0 && hit.status === "running" ? await awaitWithBudget(hit.promise, waitMs) : await hit.promise;
@@ -33991,32 +34212,80 @@ var JobRegistry = class {
33991
34212
  }
33992
34213
  const jobId = randomUUID();
33993
34214
  this.evict(now);
33994
- const promise2 = work();
33995
- const entry = { jobId, toolName, fingerprint, idempotencyKey, status: "running", promise: promise2, createdAt: now };
34215
+ let entry;
34216
+ let earlyInteractionId;
34217
+ const ctx = {
34218
+ reportInteraction: (interactionId) => {
34219
+ if (!entry) {
34220
+ earlyInteractionId = interactionId;
34221
+ return;
34222
+ }
34223
+ if (entry.interactionId === interactionId) return;
34224
+ entry.interactionId = interactionId;
34225
+ if (this.store) this.track(this.writeRecord(entry));
34226
+ }
34227
+ };
34228
+ const promise2 = work(ctx);
34229
+ entry = {
34230
+ jobId,
34231
+ toolName,
34232
+ fingerprint,
34233
+ idempotencyKey,
34234
+ status: "running",
34235
+ promise: promise2,
34236
+ createdAt: now,
34237
+ ...earlyInteractionId !== void 0 ? { interactionId: earlyInteractionId } : {}
34238
+ };
33996
34239
  this.jobs.set(jobId, entry);
33997
34240
  if (idempotencyKey !== void 0) this.byKey.set(idempotencyKey, jobId);
33998
34241
  this.runningByFingerprint.set(fingerprint, jobId);
33999
- promise2.then(
34242
+ if (this.store) {
34243
+ this.track(this.writeRecord(entry));
34244
+ if (idempotencyKey !== void 0) this.track(this.store.putKey(idempotencyKey, jobId));
34245
+ this.startHeartbeat();
34246
+ }
34247
+ const settle = promise2.then(
34000
34248
  (res) => {
34001
34249
  entry.status = "done";
34002
34250
  entry.result = res;
34003
- entry.settledAt = Date.now();
34251
+ entry.settledAt = this.now();
34004
34252
  },
34005
34253
  (err) => {
34006
34254
  entry.status = "failed";
34007
34255
  entry.error = { message: err instanceof Error ? err.message : String(err), hint: err?.hint };
34008
- entry.settledAt = Date.now();
34256
+ entry.settledAt = this.now();
34009
34257
  }
34010
34258
  ).finally(() => {
34011
34259
  if (this.runningByFingerprint.get(fingerprint) === jobId) this.runningByFingerprint.delete(fingerprint);
34012
34260
  });
34013
- if (async) return jobHandle(jobId, "running");
34261
+ if (this.store) void settle.then(() => this.track(this.writeRecord(entry)));
34262
+ if (async) return jobHandle(jobId, "running", this.store !== void 0);
34014
34263
  if (waitMs !== void 0) {
34015
34264
  const settled = await awaitWithBudget(promise2, waitMs);
34016
- return settled ?? jobHandle(jobId, "running");
34265
+ return settled ?? jobHandle(jobId, "running", this.store !== void 0);
34017
34266
  }
34018
34267
  return promise2;
34019
34268
  }
34269
+ /**
34270
+ * Resolve an idempotency key against the durable store — the path a retry
34271
+ * takes when the child that ran the original job is gone.
34272
+ *
34273
+ * `undefined` means "nothing durable to reuse", which includes a job that
34274
+ * FAILED: a retry after an error must really retry. A job still recorded as
34275
+ * running belongs to an executor this process cannot await, so the caller gets
34276
+ * the handle back rather than a promise that would never settle.
34277
+ */
34278
+ async durableByKey(idempotencyKey, fingerprint) {
34279
+ const id = await this.store.jobIdForKey(idempotencyKey);
34280
+ if (!id) return void 0;
34281
+ const record2 = await this.store.get(id);
34282
+ if (!record2) return void 0;
34283
+ if (record2.fingerprint !== fingerprint) return void 0;
34284
+ if (this.now() - record2.updatedAt > DURABLE_JOB_REUSE_MS) return void 0;
34285
+ if (record2.status === "running") return jobHandle(id, "running", true);
34286
+ if (record2.status !== "done" || !record2.result) return void 0;
34287
+ return annotateReused(await refreshMedia(record2.result, this.resigner), id);
34288
+ }
34020
34289
  /**
34021
34290
  * Retrieve an async job by id (backs the `gemini_get_result` tool):
34022
34291
  * `running` → a status handle; `done` → the recorded result payload;
@@ -34026,21 +34295,91 @@ var JobRegistry = class {
34026
34295
  * the unknown-id path — the poll tool cannot be used to read another user's
34027
34296
  * result even if they know the uuid.
34028
34297
  */
34029
- getResult(jobId) {
34298
+ /**
34299
+ * The durable record for `jobId`, if there is one — including the
34300
+ * executor-lost rewrite. Read-only view for a caller that wants to act on a
34301
+ * record's contents (see `tools/jobs.ts` recovering an interaction id) rather
34302
+ * than just render it.
34303
+ */
34304
+ async peekRecord(jobId) {
34305
+ return this.store ? await this.store.get(jobId) : void 0;
34306
+ }
34307
+ /**
34308
+ * Adopt a result produced OUTSIDE this registry — recovery reading a lost
34309
+ * job's output back off the upstream interaction (`tools/jobs.ts`).
34310
+ *
34311
+ * Without this the job stays `failed` and every later poll re-enters
34312
+ * recovery: another interaction fetch, another media download, and another
34313
+ * copy on disk (`…-2.mp4`, `…-3.mp4`) or in R2, with nothing telling the
34314
+ * caller they are duplicates. The ordinary done path is idempotent, so this
34315
+ * one has to be too.
34316
+ */
34317
+ async adoptResult(jobId, toolName, result, interactionId) {
34318
+ const now = this.now();
34319
+ let entry = this.jobs.get(jobId);
34320
+ if (!entry) {
34321
+ entry = {
34322
+ jobId,
34323
+ toolName,
34324
+ fingerprint: `recovered:${jobId}`,
34325
+ status: "done",
34326
+ promise: Promise.resolve(result),
34327
+ createdAt: now
34328
+ };
34329
+ this.jobs.set(jobId, entry);
34330
+ }
34331
+ entry.status = "done";
34332
+ entry.result = result;
34333
+ entry.settledAt = now;
34334
+ if (interactionId !== void 0) entry.interactionId = interactionId;
34335
+ if (this.store) await this.writeRecord(entry);
34336
+ }
34337
+ async getResult(jobId) {
34030
34338
  const entry = this.jobs.get(jobId);
34031
34339
  if (!entry) {
34032
- throw new McpToolError(`No job "${jobId}" \u2014 unknown or expired.`, {
34033
- hint: "Jobs live with your session and are evicted ~10 min after completion (or on server restart). If the generation likely finished, check the output dir / <image>.json sidecar instead of polling."
34034
- });
34340
+ const durable = this.store ? await this.store.get(jobId) : void 0;
34341
+ if (durable) return this.fromRecord(durable);
34342
+ throw new McpToolError(
34343
+ this.store ? `No job "${jobId}" \u2014 no record of it exists. Either the id is wrong, or the job is older than the ${JOB_RECORD_RETENTION_LABEL} job records are kept.` : `No job "${jobId}" \u2014 unknown or expired.`,
34344
+ {
34345
+ hint: this.store ? "Job records are stored durably here, so a restart alone does not lose one \u2014 an id with no record was never started under this account. Images from a completed run are listed by gemini_list_recent_media." : "Jobs live with your session and are evicted ~10 min after completion (or on server restart). If the generation likely finished, check the output dir / <image>.json sidecar instead of polling."
34346
+ }
34347
+ );
34035
34348
  }
34036
- if (entry.status === "running") return jobHandle(jobId, "running");
34349
+ if (entry.status === "running") return jobHandle(jobId, "running", this.store !== void 0);
34037
34350
  if (entry.status === "failed") {
34038
34351
  throw new McpToolError(entry.error?.message ?? "Job failed.", entry.error?.hint ? { hint: entry.error.hint } : void 0);
34039
34352
  }
34040
34353
  if (!entry.result) throw new McpToolError(`Job "${jobId}" completed without a recorded result.`);
34041
34354
  return entry.result;
34042
34355
  }
34356
+ /**
34357
+ * Render a durable record as a poll answer — the path taken when the child
34358
+ * that ran the job is gone.
34359
+ *
34360
+ * The three outcomes are kept distinct on purpose. Collapsing "your job
34361
+ * failed", "your job was killed" and "no such job" into one message is what
34362
+ * made the original bug read as an expiry problem when it was a machine
34363
+ * lifecycle problem.
34364
+ */
34365
+ async fromRecord(record2) {
34366
+ if (record2.status === "running") return jobHandle(record2.jobId, "running", true);
34367
+ if (record2.status === "failed") {
34368
+ throw new McpToolError(record2.error?.message ?? "Job failed.", record2.error?.hint ? { hint: record2.error.hint } : void 0);
34369
+ }
34370
+ if (record2.resultOmitted) {
34371
+ throw new McpToolError(
34372
+ record2.resultOmitted === "inline" ? `Job "${record2.jobId}" finished, but its result was returned inline (base64 image data) and inline results are not stored, so there is nothing to replay. Re-run without \`inline: true\` so the media is stored and returned as URLs, which a poll can replay.` : `Job "${record2.jobId}" finished, but its result was too large to store (over ${Math.round(MAX_PERSISTED_RESULT_BYTES / 1024)} KB), so there is nothing to replay.`,
34373
+ {
34374
+ hint: "The generation itself completed \u2014 list what it produced with gemini_list_recent_media rather than paying for it again."
34375
+ }
34376
+ );
34377
+ }
34378
+ if (!record2.result) throw new McpToolError(`Job "${record2.jobId}" completed without a recorded result.`);
34379
+ return refreshMedia(record2.result, this.resigner);
34380
+ }
34043
34381
  };
34382
+ var JOB_RECORD_RETENTION_LABEL = "30 days";
34044
34383
 
34045
34384
  // src/tools/_confirm.ts
34046
34385
  init_dist();
@@ -34739,6 +35078,21 @@ var UPLOAD_BASE_URL = "https://generativelanguage.googleapis.com/upload/v1beta";
34739
35078
  var FILE_MAX_BYTES = 2 * 1024 ** 3;
34740
35079
  var FILE_POLL_INTERVAL_MS = 2e3;
34741
35080
  var FILE_POLL_MAX_ATTEMPTS = 150;
35081
+ var INTERACTION_POLL_INTERVAL_MS = 3e3;
35082
+ var TERMINAL_INTERACTION_STATUSES = /* @__PURE__ */ new Set([
35083
+ "completed",
35084
+ "failed",
35085
+ "cancelled",
35086
+ "incomplete",
35087
+ "budget_exceeded"
35088
+ ]);
35089
+ var MEDIA_URI_HOST = "generativelanguage.googleapis.com";
35090
+ var MEDIA_DOWNLOAD_MAX_BYTES = 128 * 1024 * 1024;
35091
+ var MEDIA_CAP_SUBJECT = {
35092
+ noun: "Generated media",
35093
+ 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."
35094
+ };
35095
+ var INTERACTION_ID_RE = /^[\w-]+$/;
34742
35096
  var CHAIN_404_RETRY_BUDGET_MS = 12e4;
34743
35097
  var CHAIN_404_RETRY_BASE_MS = 2e3;
34744
35098
  var CHAIN_404_RETRY_MAX_SLEEP_MS = 3e4;
@@ -34747,18 +35101,20 @@ function resolveChainRetryBudgetMs() {
34747
35101
  return Number.isFinite(env) && env >= 0 ? env : CHAIN_404_RETRY_BUDGET_MS;
34748
35102
  }
34749
35103
  var ChainedRequest404Error = class extends McpToolError {
34750
- constructor(previousInteractionId, upstreamMessage, retries, opts) {
35104
+ constructor(previousInteractionId, upstreamMessage, retries, opts, chainExists, verdict) {
34751
35105
  super(
34752
- `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.)`,
35106
+ `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.)",
34753
35107
  opts
34754
35108
  );
34755
35109
  this.previousInteractionId = previousInteractionId;
34756
35110
  this.upstreamMessage = upstreamMessage;
34757
35111
  this.retries = retries;
35112
+ this.chainExists = chainExists;
34758
35113
  }
34759
35114
  previousInteractionId;
34760
35115
  upstreamMessage;
34761
35116
  retries;
35117
+ chainExists;
34762
35118
  };
34763
35119
  function generatePart(img) {
34764
35120
  return img.uri ? { file_data: { file_uri: img.uri, mime_type: img.mimeType } } : { inline_data: { mime_type: img.mimeType, data: img.base64 } };
@@ -34810,6 +35166,8 @@ var GeminiClient = class {
34810
35166
  this.library = opts.library;
34811
35167
  this.uploadUrls = opts.uploadUrls;
34812
35168
  this.session.jobs.resigner = this.mediaSink;
35169
+ this.session.jobs.store = opts.jobStore;
35170
+ if (opts.jobStore) this.session.jobs.asyncWaitFallbackMs = HOSTED_ASYNC_WAIT_MS;
34813
35171
  this.explicitApiKey = opts.apiKey?.trim() || void 0;
34814
35172
  this.fetchImpl = opts.fetchImpl ?? ((...args) => fetch(...args));
34815
35173
  this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
@@ -35196,31 +35554,52 @@ var GeminiClient = class {
35196
35554
  hint: "The request may have been blocked by safety filters \u2014 try rephrasing the prompt."
35197
35555
  });
35198
35556
  }
35199
- return { id: data.id, images, text, grounding };
35557
+ return { id: data.id, images: await this.downloadAll(images), text, grounding };
35200
35558
  }
35201
35559
  /**
35202
- * Generate video via the omni model (Interactions API, inline delivery). Same
35203
- * plumbing as {@link interact} — text/image input parts, a `video`
35204
- * response_format, optional `video_config.task`, and `previous_interaction_id`
35205
- * for edits. Preview model: shapes are docs-derived; parsing stays tolerant.
35560
+ * Generate video via the omni model (Interactions API). Same plumbing as
35561
+ * {@link interact} — text/image input parts, a `video` response_format,
35562
+ * optional `video_config.task`, and `previous_interaction_id` for edits.
35563
+ *
35564
+ * Delivered by uri unless the caller says otherwise, so the clip is not
35565
+ * capped by the ~4MB inline limit; the bytes are downloaded here either way.
35566
+ * Verified live 2026-08-22 end to end (a real MP4 off the wire), which is
35567
+ * why this path no longer carries the "docs-derived" caveat that music
35568
+ * still does.
35206
35569
  */
35207
35570
  async generateVideo(opts) {
35208
35571
  const model = opts.model ?? DEFAULT_VIDEO_MODEL;
35209
35572
  const inputParts = [{ type: "text", text: opts.input }];
35210
35573
  for (const img of opts.images ?? []) inputParts.push(interactPart(img));
35211
- const responseFormat = { type: "video", delivery: "inline" };
35212
- if (opts.aspectRatio) responseFormat.aspect_ratio = opts.aspectRatio;
35213
- const body = { model, input: inputParts, response_format: responseFormat };
35214
- if (opts.task) body.generation_config = { video_config: { task: opts.task } };
35215
- if (opts.previousInteractionId !== void 0) body.previous_interaction_id = opts.previousInteractionId;
35216
- const data = await this.postInteraction(body, opts);
35574
+ const delivery = opts.delivery ?? "uri";
35575
+ const buildBody = (withDelivery) => {
35576
+ const responseFormat = { type: "video" };
35577
+ if (withDelivery) responseFormat.delivery = delivery;
35578
+ if (opts.aspectRatio) responseFormat.aspect_ratio = opts.aspectRatio;
35579
+ const body2 = { model, input: inputParts, response_format: responseFormat };
35580
+ if (opts.task) body2.generation_config = { video_config: { task: opts.task } };
35581
+ if (opts.previousInteractionId !== void 0) body2.previous_interaction_id = opts.previousInteractionId;
35582
+ if (opts.background === true) body2.background = true;
35583
+ return body2;
35584
+ };
35585
+ const timeoutMs = resolveTimeoutMs(opts.timeoutMs, void 0);
35586
+ let body = buildBody(true);
35587
+ let started;
35588
+ try {
35589
+ started = await this.startInteraction(body, opts, timeoutMs);
35590
+ } catch (err) {
35591
+ if (!isDeliveryUnsupported(err)) throw err;
35592
+ body = buildBody(false);
35593
+ started = await this.startInteraction(body, opts, timeoutMs);
35594
+ }
35595
+ const data = await this.settleInteraction(started, body, timeoutMs);
35217
35596
  const { videos, text } = this.extractInteraction(data);
35218
35597
  if (videos.length === 0) {
35219
35598
  throw new McpToolError("Gemini returned no video", {
35220
- 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."
35599
+ hint: "The request may have been blocked by a safety filter \u2014 try a shorter/simpler prompt or a different aspect ratio."
35221
35600
  });
35222
35601
  }
35223
- return { id: data.id, videos, text };
35602
+ return { id: data.id, videos: await this.downloadAll(videos), text };
35224
35603
  }
35225
35604
  /**
35226
35605
  * Generate music via a Lyria model (Interactions API, inline audio). Reuses
@@ -35234,6 +35613,7 @@ var GeminiClient = class {
35234
35613
  if (opts.audioFormat) responseFormat.audio_format = opts.audioFormat;
35235
35614
  const body = { model, input: inputParts, response_format: responseFormat };
35236
35615
  if (opts.previousInteractionId !== void 0) body.previous_interaction_id = opts.previousInteractionId;
35616
+ if (opts.background === true) body.background = true;
35237
35617
  const data = await this.postInteraction(body, opts);
35238
35618
  const { audios, text } = this.extractInteraction(data);
35239
35619
  if (audios.length === 0) {
@@ -35241,11 +35621,37 @@ var GeminiClient = class {
35241
35621
  hint: "The request may have been blocked by a safety filter \u2014 try rephrasing the prompt."
35242
35622
  });
35243
35623
  }
35244
- return { id: data.id, audios, text };
35624
+ return { id: data.id, audios: await this.downloadAll(audios), text };
35245
35625
  }
35246
- /** POST `/interactions` with the chained-404 retry. Shared by all media. */
35626
+ /**
35627
+ * POST `/interactions`, wait out the store lag on a chained 404, and — when
35628
+ * the request was backgrounded — poll until the interaction reaches a
35629
+ * terminal state. Shared by all media.
35630
+ */
35247
35631
  async postInteraction(body, opts) {
35248
- const { interactionsApi } = this.apisFor(resolveTimeoutMs(opts.timeoutMs, opts.imageSize));
35632
+ const timeoutMs = resolveTimeoutMs(opts.timeoutMs, opts.imageSize);
35633
+ return await this.settleInteraction(await this.startInteraction(body, opts, timeoutMs), body, timeoutMs);
35634
+ }
35635
+ /**
35636
+ * The POST half: issue the request and announce the id.
35637
+ *
35638
+ * Split from the wait so a caller can retry the POST — and ONLY the POST —
35639
+ * without the retry predicate ever seeing an error from after the generation
35640
+ * started. Past this point the work is running and billable.
35641
+ */
35642
+ async startInteraction(body, opts, timeoutMs) {
35643
+ const started = await this.postInteractionOnce(body, opts, timeoutMs);
35644
+ if (started.id) opts.onInteractionStarted?.(started.id);
35645
+ return started;
35646
+ }
35647
+ /** The wait half: poll a backgrounded request to a terminal state. */
35648
+ async settleInteraction(started, body, timeoutMs) {
35649
+ if (body.background !== true) return started;
35650
+ return await this.awaitInteraction(started, timeoutMs);
35651
+ }
35652
+ /** The POST itself, with the chained-404 retry + diagnosis. */
35653
+ async postInteractionOnce(body, opts, timeoutMs) {
35654
+ const { interactionsApi } = this.apisFor(timeoutMs);
35249
35655
  const budgetMs = resolveChainRetryBudgetMs();
35250
35656
  let waitedMs = 0;
35251
35657
  for (let attempt = 0; ; attempt++) {
@@ -35259,13 +35665,183 @@ var GeminiClient = class {
35259
35665
  await this.sleep(delay);
35260
35666
  continue;
35261
35667
  }
35262
- throw new ChainedRequest404Error(opts.previousInteractionId, err.message, { attempts: attempt, waitedMs }, {
35263
- 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.",
35264
- cause: err
35265
- });
35668
+ const { chainExists, verdict } = await this.diagnoseChain(opts.previousInteractionId);
35669
+ throw new ChainedRequest404Error(
35670
+ opts.previousInteractionId,
35671
+ err.message,
35672
+ { attempts: attempt, waitedMs },
35673
+ {
35674
+ 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.",
35675
+ cause: err
35676
+ },
35677
+ chainExists,
35678
+ verdict
35679
+ );
35266
35680
  }
35267
35681
  }
35268
35682
  }
35683
+ /**
35684
+ * Ask the API whether the chained interaction is actually gone.
35685
+ *
35686
+ * Three answers, three remediations — and a fourth state that matters just as
35687
+ * much: if the probe itself cannot be completed we return `undefined`, so the
35688
+ * caller says "unknown" rather than inventing "gone".
35689
+ */
35690
+ async diagnoseChain(id) {
35691
+ try {
35692
+ await this.getInteraction(id);
35693
+ return {
35694
+ chainExists: true,
35695
+ 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. `
35696
+ };
35697
+ } catch (err) {
35698
+ if (err instanceof ApiError && err.status === 404) {
35699
+ return {
35700
+ chainExists: false,
35701
+ 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. `
35702
+ };
35703
+ }
35704
+ if (err instanceof ApiError && err.status === 400) {
35705
+ return {
35706
+ chainExists: false,
35707
+ 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. `
35708
+ };
35709
+ }
35710
+ return {};
35711
+ }
35712
+ }
35713
+ /**
35714
+ * Poll a backgrounded interaction to a terminal state.
35715
+ *
35716
+ * ⚠️ **A poll of work that is still in flight answers `403
35717
+ * permission_denied`** — "There was a problem processing your request. You
35718
+ * will not be charged." (verified live 2026-08-22, against a paid key that
35719
+ * GETs the very same interaction fine once it completes). It is a transient
35720
+ * state dressed as an authorization failure, and taking it at face value
35721
+ * fails every backgrounded generation a few seconds in. A 404 is transient
35722
+ * here too — the same store lag the chained POST retries.
35723
+ *
35724
+ * Neither is distinguishable from the real thing, so the caller's timeout is
35725
+ * what bounds it, and the last poll error rides along in the timeout message
35726
+ * so a genuine permission problem is not invisible.
35727
+ *
35728
+ * A body with no `status` field is likewise treated as "still running"
35729
+ * unless a `model_output` step is already present.
35730
+ */
35731
+ async awaitInteraction(started, budgetMs) {
35732
+ let current = started;
35733
+ let waitedMs = 0;
35734
+ let lastPollError;
35735
+ for (; ; ) {
35736
+ const status = current.status;
35737
+ if (!status && (current.steps ?? []).some((step2) => step2.type === "model_output")) return current;
35738
+ if (status && TERMINAL_INTERACTION_STATUSES.has(status)) {
35739
+ if (status === "completed") return current;
35740
+ throw new McpToolError(
35741
+ `${SERVICE} interaction ${current.id} ${status} (background execution).`,
35742
+ { 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." }
35743
+ );
35744
+ }
35745
+ if (waitedMs + INTERACTION_POLL_INTERVAL_MS > budgetMs) {
35746
+ throw new McpToolError(
35747
+ `${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}` : ""),
35748
+ { hint: "Raise timeout_ms (or max_wait_ms) and retry, or keep the interaction id: the generation completes upstream regardless of this request." }
35749
+ );
35750
+ }
35751
+ await this.sleep(INTERACTION_POLL_INTERVAL_MS);
35752
+ waitedMs += INTERACTION_POLL_INTERVAL_MS;
35753
+ try {
35754
+ current = await this.getInteraction(current.id);
35755
+ lastPollError = void 0;
35756
+ } catch (err) {
35757
+ if (!isTransientPoll(err)) throw err;
35758
+ lastPollError = err instanceof Error ? err.message : String(err);
35759
+ }
35760
+ }
35761
+ }
35762
+ /**
35763
+ * Fetch one interaction. Backs the background poll and the chained-404
35764
+ * diagnosis; also the honest answer to "is that id still alive?".
35765
+ */
35766
+ async getInteraction(id) {
35767
+ if (!INTERACTION_ID_RE.test(id)) {
35768
+ throw new McpToolError(`Not a valid interaction id: "${id}"`, {
35769
+ hint: "An interaction id is the opaque `id` from a previous response (e.g. v1_Chd\u2026), with no slashes or query string."
35770
+ });
35771
+ }
35772
+ const { interactionsApi } = this.apisFor(resolveTimeoutMs(void 0));
35773
+ return await interactionsApi.fetchJson("GET", `/interactions/${id}`);
35774
+ }
35775
+ /**
35776
+ * Fetch a stored interaction and resolve its media to bytes.
35777
+ *
35778
+ * The recovery read: a generation whose executor died is still retrievable
35779
+ * by id for as long as the interaction is retained (55 days paid, 1 day
35780
+ * free). Returns the status alongside the media so a caller can tell "still
35781
+ * running" from "finished with nothing".
35782
+ */
35783
+ async fetchInteractionMedia(id) {
35784
+ const data = await this.getInteraction(id);
35785
+ const { images, videos, audios, text } = this.extractInteraction(data);
35786
+ return {
35787
+ status: data.status,
35788
+ images: await this.downloadAll(images),
35789
+ videos: await this.downloadAll(videos),
35790
+ audios: await this.downloadAll(audios),
35791
+ text
35792
+ };
35793
+ }
35794
+ /**
35795
+ * Resolve one extracted medium to bytes.
35796
+ *
35797
+ * `delivery: 'uri'` hands back a Files API link, and that link **needs the
35798
+ * api key** (verified: 403 without it), so it is not something a caller could
35799
+ * use — the bytes have to arrive here exactly as inline delivery would. The
35800
+ * uri is API-supplied but addresses a request we make with credentials
35801
+ * attached, so the host is checked before it is fetched.
35802
+ */
35803
+ async downloadMedia(media) {
35804
+ if (media.base64) return { base64: media.base64, mimeType: media.mimeType };
35805
+ const requested = media.uri ?? "";
35806
+ const doFetch = this.fetchImpl;
35807
+ const key = this.requireKey();
35808
+ let current = requested;
35809
+ for (let hop = 0; ; hop++) {
35810
+ const url2 = assertMediaHost(current, requested);
35811
+ const res = await doFetch(url2.toString(), {
35812
+ redirect: "manual",
35813
+ headers: { "x-goog-api-key": key }
35814
+ });
35815
+ if (res.status >= 300 && res.status < 400) {
35816
+ const location = res.headers?.get("location");
35817
+ if (!location) {
35818
+ throw new McpToolError(`${SERVICE} media download returned HTTP ${res.status} with no Location header for ${current}`);
35819
+ }
35820
+ if (hop >= MAX_REDIRECTS) {
35821
+ throw new McpToolError(`${SERVICE} media download for ${requested} exceeded ${MAX_REDIRECTS} redirects (last hop: ${location}).`);
35822
+ }
35823
+ current = new URL(location, url2).toString();
35824
+ continue;
35825
+ }
35826
+ if (!res.ok) {
35827
+ throw new McpToolError(
35828
+ `${SERVICE} media download failed (HTTP ${res.status}) for ${current}`,
35829
+ { hint: "Generated files live in the Files API for ~48h and the download requires the api key that created them." }
35830
+ );
35831
+ }
35832
+ const bytes = await readCapped(res, MEDIA_DOWNLOAD_MAX_BYTES, current, requested, MEDIA_CAP_SUBJECT);
35833
+ return {
35834
+ base64: bytesToBase64(bytes),
35835
+ mimeType: media.mimeType || res.headers?.get("content-type") || "application/octet-stream"
35836
+ };
35837
+ }
35838
+ }
35839
+ /** {@link downloadMedia} over a list, preserving order. */
35840
+ async downloadAll(media) {
35841
+ const out = [];
35842
+ for (const m of media) out.push(await this.downloadMedia(m));
35843
+ return out;
35844
+ }
35269
35845
  /**
35270
35846
  * Collect `model_output` media (image/video/audio) + text + grounding from an
35271
35847
  * interactions response. Only the `model_output` step is surfaced — `thought`
@@ -35294,12 +35870,13 @@ var GeminiClient = class {
35294
35870
  for (const parts of [step2.content ?? [], step2.summary ?? []]) {
35295
35871
  for (const part of parts) {
35296
35872
  const mime = part.mime_type ?? part.mimeType;
35297
- if (part.data) {
35298
- if (part.type === "image") images.push({ base64: part.data, mimeType: mime ?? "image/jpeg" });
35299
- else if (part.type === "video") videos.push({ base64: part.data, mimeType: mime ?? "video/mp4" });
35300
- else if (part.type === "audio") audios.push({ base64: part.data, mimeType: mime ?? "audio/mpeg" });
35301
- } else if (part.type === "text" && part.text?.trim()) {
35302
- textParts.push(part.text);
35873
+ if (part.type === "text") {
35874
+ if (part.text?.trim()) textParts.push(part.text);
35875
+ } else if (part.data || part.uri) {
35876
+ const carrier = part.data ? { base64: part.data } : { uri: part.uri };
35877
+ if (part.type === "image") images.push({ ...carrier, mimeType: mime ?? "image/jpeg" });
35878
+ else if (part.type === "video") videos.push({ ...carrier, mimeType: mime ?? "video/mp4" });
35879
+ else if (part.type === "audio") audios.push({ ...carrier, mimeType: mime ?? "audio/mpeg" });
35303
35880
  }
35304
35881
  }
35305
35882
  }
@@ -35313,6 +35890,27 @@ var GeminiClient = class {
35313
35890
  return { images, videos, audios, text: textParts.join("\n") || void 0, grounding };
35314
35891
  }
35315
35892
  };
35893
+ function assertMediaHost(candidate, requested) {
35894
+ let parsed;
35895
+ try {
35896
+ parsed = new URL(candidate);
35897
+ } catch {
35898
+ throw new McpToolError(`${SERVICE} returned a media reference that is not a URL: ${candidate || "(empty)"}`);
35899
+ }
35900
+ if (parsed.protocol !== "https:" || parsed.hostname !== MEDIA_URI_HOST) {
35901
+ throw new McpToolError(
35902
+ `${SERVICE} media download was pointed at an unexpected host (${parsed.hostname || candidate}); expected ${MEDIA_URI_HOST}` + (candidate === requested ? "" : ` (redirected from ${requested})`),
35903
+ { hint: "Generated media is served from the Files API on that host only. Report this if it reproduces \u2014 it should not happen." }
35904
+ );
35905
+ }
35906
+ return parsed;
35907
+ }
35908
+ function isTransientPoll(err) {
35909
+ return err instanceof ApiError && (err.status === 403 || err.status === 404);
35910
+ }
35911
+ function isDeliveryUnsupported(err) {
35912
+ return err instanceof ApiError && err.status === 400 && /delivery/i.test(err.message);
35913
+ }
35316
35914
  function hostedStorage() {
35317
35915
  const blob = blobStoreFromEnv();
35318
35916
  if (!blob) return {};
@@ -35339,9 +35937,14 @@ function hostedStorage() {
35339
35937
  tenant
35340
35938
  }),
35341
35939
  library: createR2Library(blob.bucket, { tenant }),
35342
- uploadUrls: createUploadUrlMinter({ links: blob.links, sign: blob.signWrite, tenant })
35940
+ uploadUrls: createUploadUrlMinter({ links: blob.links, sign: blob.signWrite, tenant }),
35941
+ // Job records go in the same store as the media, for the same reason: this
35942
+ // runtime's machine stops whenever no request is in flight, taking the
35943
+ // child — and an in-memory registry — with it (src/job-store.ts).
35944
+ jobStore: createBlobJobStore(blob.bucket, { tenant })
35343
35945
  };
35344
35946
  }
35947
+ var HOSTED_ASYNC_WAIT_MS = 24e4;
35345
35948
  var BLOB_URL_TTL_MS = 24 * 60 * 60 * 1e3;
35346
35949
  var BLOB_MAX_TTL_MS = 24 * 60 * 60 * 1e3;
35347
35950
  var client = new GeminiClient(hostedStorage());
@@ -35508,6 +36111,7 @@ function registerInteractTools(server, client2) {
35508
36111
  return await client2.interact({ ...callOpts, images: inputs.length ? inputs : void 0, previousInteractionId });
35509
36112
  } catch (err) {
35510
36113
  if (!(err instanceof ChainedRequest404Error)) throw err;
36114
+ if (err.chainExists === true) throw err;
35511
36115
  if (!onDisk) throw err;
35512
36116
  const reanchorOn = await findInteractionImages(resolveOutputDir(args.output_dir), err.previousInteractionId);
35513
36117
  if (!reanchorOn.length) throw err;
@@ -35568,6 +36172,7 @@ init_dist();
35568
36172
  init_images();
35569
36173
  var VIDEO_ASPECT_RATIOS = ["16:9", "9:16"];
35570
36174
  var VIDEO_TASKS = ["text_to_video", "image_to_video", "reference_to_video", "edit"];
36175
+ var VIDEO_DELIVERY = ["inline", "uri"];
35571
36176
  function registerVideoTools(server, client2) {
35572
36177
  server.registerTool(
35573
36178
  "gemini_video_generate",
@@ -35586,6 +36191,8 @@ function registerVideoTools(server, client2) {
35586
36191
  filename: external_exports.string().optional().describe("Base filename for the output video (extension stripped; default: slugified prompt)"),
35587
36192
  output_dir: external_exports.string().optional().describe("Directory to write the video to (default: $GEMINI_OUTPUT_DIR or cwd)"),
35588
36193
  model: external_exports.string().regex(/^[\w.-]+$/, 'must be a bare model id (letters, digits, ".", "_", "-")').optional().describe(`Model id override (default: ${DEFAULT_VIDEO_MODEL})`),
36194
+ 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)'),
36195
+ 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"),
35589
36196
  previous_interaction_id: external_exports.string().optional().describe('Interaction id to edit/continue (with task: "edit")'),
35590
36197
  continue_last: external_exports.boolean().optional().describe("Continue from the most recent video interaction this server created (explicit previous_interaction_id wins)"),
35591
36198
  timeout_ms: timeoutMsSchema,
@@ -35621,7 +36228,7 @@ function registerVideoTools(server, client2) {
35621
36228
  images_file_uris: args.images_file_uris,
35622
36229
  previous_interaction_id: previousInteractionId
35623
36230
  });
35624
- return client2.session.jobs.dispatch({ toolName: "gemini_video_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
36231
+ return client2.session.jobs.dispatch({ toolName: "gemini_video_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async (ctx) => {
35625
36232
  const { inputs, report } = await resolveImageInputs(args, client2);
35626
36233
  const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client2.generateVideo({
35627
36234
  input: args.prompt,
@@ -35630,7 +36237,13 @@ function registerVideoTools(server, client2) {
35630
36237
  aspectRatio: args.aspect_ratio,
35631
36238
  task: args.task,
35632
36239
  previousInteractionId,
35633
- timeoutMs: args.timeout_ms
36240
+ timeoutMs: args.timeout_ms,
36241
+ delivery: args.delivery,
36242
+ background: args.background,
36243
+ // Hand the id to the job record the moment it exists: if this
36244
+ // machine stops mid-generation, that id is the only route back to
36245
+ // an output that finishes upstream anyway.
36246
+ onInteractionStarted: ctx.reportInteraction
35634
36247
  }));
35635
36248
  client2.session.lastVideoInteractionId = r.id;
35636
36249
  const meta3 = { model, interaction_id: r.id };
@@ -35678,6 +36291,7 @@ function registerMusicTools(server, client2) {
35678
36291
  inline: external_exports.boolean().optional().describe("Return base64 audio inline instead of writing to disk"),
35679
36292
  previous_interaction_id: external_exports.string().optional().describe("Interaction id to continue from"),
35680
36293
  continue_last: external_exports.boolean().optional().describe("Continue from the most recent music interaction this server created (explicit previous_interaction_id wins)"),
36294
+ 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"),
35681
36295
  timeout_ms: timeoutMsSchema,
35682
36296
  idempotency_key: idempotencyKeySchema,
35683
36297
  async: asyncSchema,
@@ -35715,7 +36329,7 @@ function registerMusicTools(server, client2) {
35715
36329
  images_file_uris: args.images_file_uris,
35716
36330
  previous_interaction_id: previousInteractionId
35717
36331
  });
35718
- return client2.session.jobs.dispatch({ toolName: "gemini_music_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
36332
+ return client2.session.jobs.dispatch({ toolName: "gemini_music_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async (ctx) => {
35719
36333
  const { inputs, report } = await resolveImageInputs(args, client2);
35720
36334
  const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () => client2.generateMusic({
35721
36335
  input: args.prompt,
@@ -35723,7 +36337,10 @@ function registerMusicTools(server, client2) {
35723
36337
  model: args.model,
35724
36338
  audioFormat: args.audio_format,
35725
36339
  previousInteractionId,
35726
- timeoutMs: args.timeout_ms
36340
+ timeoutMs: args.timeout_ms,
36341
+ background: args.background,
36342
+ // See gemini_video_generate: the id is what survives the executor.
36343
+ onInteractionStarted: ctx.reportInteraction
35727
36344
  }));
35728
36345
  client2.session.lastMusicInteractionId = r.id;
35729
36346
  const meta3 = { model, interaction_id: r.id };
@@ -35744,19 +36361,84 @@ function registerMusicTools(server, client2) {
35744
36361
 
35745
36362
  // src/tools/jobs.ts
35746
36363
  init_zod();
36364
+ init_dist();
36365
+ init_images();
35747
36366
  function registerJobTools(server, client2) {
35748
36367
  server.registerTool(
35749
36368
  "gemini_get_result",
35750
36369
  {
35751
- description: 'Retrieve an async generation started with `async: true`. Pass the returned `job_id`: while running it reports status "running"; on completion it returns the normal result (image paths / inline images + meta); on failure it raises the recorded error. Jobs belong to your session and expire ~10 min after completion \u2014 if a job id is unknown, check the output dir / <image>.json sidecar.',
35752
- annotations: { readOnlyHint: true, openWorldHint: false },
36370
+ 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.',
36371
+ // NOT read-only any more: recovering a killed job writes the media it
36372
+ // pulls back, exactly as the generation tool would have.
36373
+ annotations: { readOnlyHint: false, openWorldHint: true },
35753
36374
  inputSchema: {
35754
- job_id: external_exports.string().min(1).describe("The job_id returned by a generation tool called with async: true")
36375
+ job_id: external_exports.string().min(1).describe("The job_id returned by a generation tool called with async: true"),
36376
+ output_dir: external_exports.string().optional().describe("Where to write media recovered from a killed job (default: $GEMINI_OUTPUT_DIR or cwd)")
35755
36377
  }
35756
36378
  },
35757
- async (args) => client2.session.jobs.getResult(args.job_id)
36379
+ async (args) => {
36380
+ try {
36381
+ return await client2.session.jobs.getResult(args.job_id);
36382
+ } catch (err) {
36383
+ const recovered = await recoverFromInteraction(client2, args.job_id, args.output_dir, err);
36384
+ if (recovered) return recovered;
36385
+ throw err;
36386
+ }
36387
+ }
35758
36388
  );
35759
36389
  }
36390
+ async function recoverFromInteraction(client2, jobId, outputDir, original) {
36391
+ const record2 = await client2.session.jobs.peekRecord(jobId);
36392
+ const interactionId = record2?.interactionId;
36393
+ if (!interactionId || record2?.status !== "failed") return void 0;
36394
+ let found;
36395
+ try {
36396
+ found = await client2.fetchInteractionMedia(interactionId);
36397
+ } catch (probeErr) {
36398
+ if (isInFlight(probeErr)) return stillRunning(jobId, interactionId);
36399
+ throw new McpToolError(
36400
+ `${errorMessage(original)} Its upstream interaction "${interactionId}" could not be read either (${errorMessage(probeErr)}), so the output is not recoverable.`,
36401
+ { hint: "Any media the lost run did write is still listed by gemini_list_recent_media. Otherwise the generation has to be re-run." }
36402
+ );
36403
+ }
36404
+ const [kind, media] = found.videos.length ? ["video", found.videos] : found.audios.length ? ["audio", found.audios] : found.images.length ? ["image", found.images] : ["image", []];
36405
+ if (media.length === 0) {
36406
+ if (found.status && !TERMINAL_INTERACTION_STATUSES.has(found.status)) return stillRunning(jobId, interactionId);
36407
+ return void 0;
36408
+ }
36409
+ const slug = slugify2(`recovered-${interactionId}`.slice(0, 60));
36410
+ const named = media.map((m, i) => ({
36411
+ media: m,
36412
+ base: media.length > 1 ? `${slug}-${String(i + 1).padStart(2, "0")}` : slug
36413
+ }));
36414
+ const result = await emitMedia(named, kind, { output_dir: outputDir, sink: client2.mediaSink }, {
36415
+ job_id: jobId,
36416
+ recovered_from_interaction: interactionId,
36417
+ note: recoveryNote(record2.error?.message),
36418
+ ...found.text ? { text: found.text } : {}
36419
+ });
36420
+ await client2.session.jobs.adoptResult(jobId, record2.toolName, result, interactionId);
36421
+ return result;
36422
+ }
36423
+ function recoveryNote(recordedError) {
36424
+ 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.";
36425
+ 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.`;
36426
+ }
36427
+ function stillRunning(jobId, interactionId) {
36428
+ return textResult({
36429
+ job_id: jobId,
36430
+ status: "running",
36431
+ interaction_id: interactionId,
36432
+ 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."
36433
+ });
36434
+ }
36435
+ function isInFlight(err) {
36436
+ const status = err?.status;
36437
+ return status === 403 || status === 404;
36438
+ }
36439
+ function errorMessage(err) {
36440
+ return err instanceof Error ? err.message : String(err);
36441
+ }
35760
36442
 
35761
36443
  // src/tools/files.ts
35762
36444
  init_zod();
@@ -35928,6 +36610,43 @@ function registerFileTools(server, client2) {
35928
36610
  });
35929
36611
  }
35930
36612
  );
36613
+ server.registerTool(
36614
+ "gemini_list_recent_media",
36615
+ {
36616
+ description: "List images/video/audio this connector recently generated for your account, newest first, with fresh openable URLs. Use it when a result was lost \u2014 a generation whose response never arrived, or an async job whose id came back unknown \u2014 to find what already exists BEFORE re-running (and re-paying for) it. Keywords: lost result, orphaned job, missing image, what did I generate, recover, recent media.",
36617
+ annotations: { readOnlyHint: true, openWorldHint: false },
36618
+ inputSchema: {
36619
+ limit: external_exports.number().int().positive().max(100).optional().describe("How many to return (default 20, newest first)"),
36620
+ since_day: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Only media stored on or after this UTC day, e.g. "2026-08-18"')
36621
+ }
36622
+ },
36623
+ async (args) => {
36624
+ const sink = client2.mediaSink;
36625
+ if (!sink?.listRecent) {
36626
+ throw new McpToolError("This server writes generated files to disk \u2014 list the output directory instead.", {
36627
+ hint: "gemini_list_recent_media applies to the hosted connector only."
36628
+ });
36629
+ }
36630
+ const limit = args.limit ?? 20;
36631
+ const page = sink.listRecentPage ? await sink.listRecentPage({ limit, sinceDay: args.since_day }) : { media: await sink.listRecent({ limit, sinceDay: args.since_day }), truncated: false };
36632
+ const found = page.media;
36633
+ return textResult({
36634
+ count: found.length,
36635
+ // Never let a bounded walk pass for the whole picture: concluding an
36636
+ // image is gone is what makes someone pay to generate it twice.
36637
+ ...page.truncated ? { truncated: true } : {},
36638
+ media: found.map((m) => ({
36639
+ r2_key: m.key,
36640
+ day: m.day,
36641
+ name: m.name,
36642
+ ...m.sizeBytes !== void 0 ? { size_bytes: m.sizeBytes } : {},
36643
+ ...m.url ? { url: m.url, curl_hint: `curl -sS -o ${downloadFilename(m.key)} "${m.url}"` } : {},
36644
+ ...m.expiresAt ? { expires_at: m.expiresAt } : {}
36645
+ })),
36646
+ hint: found.length ? "Ordered by the day each object was stored (the store keeps no finer timestamp), newest first. Re-sign any expired link with gemini_sign_media." + (page.truncated ? " More media exists than is listed here \u2014 narrow with since_day, or raise limit, before concluding something is missing." : "") : "Nothing stored in that window. Media is swept on a retention schedule, so an older generation may already be gone."
36647
+ });
36648
+ }
36649
+ );
35931
36650
  }
35932
36651
  server.registerTool(
35933
36652
  "gemini_delete_file",