@chrischall/gemini-mcp 1.6.2 → 1.7.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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for Google Gemini image generation and editing — generate and edit images via the Gemini API",
10
- "version": "1.6.2"
10
+ "version": "1.7.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Gemini Images",
16
16
  "source": "./",
17
17
  "description": "Generate and edit images with Google Gemini image models (Nano Banana / Nano Banana Pro) via natural language prompts",
18
- "version": "1.6.2",
18
+ "version": "1.7.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gemini-mcp",
3
3
  "displayName": "Gemini Images",
4
- "version": "1.6.2",
4
+ "version": "1.7.0",
5
5
  "description": "MCP server for Google Gemini image generation and editing — generate and edit images via the Gemini API",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/dist/bundle.js CHANGED
@@ -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.7.0";
32842
32842
 
32843
32843
  // src/errors.ts
32844
32844
  init_dist();
@@ -33367,6 +33367,14 @@ async function tenantIdFor(secret) {
33367
33367
  return [...new Uint8Array(digest).slice(0, 6)].map((b) => b.toString(16).padStart(2, "0")).join("");
33368
33368
  }
33369
33369
  var KEY_PREFIX_DEFAULT = "gen";
33370
+ var MAX_LIST_PAGES = 10;
33371
+ function parseMediaKey(key, prefix) {
33372
+ const rest = key.slice(prefix.length);
33373
+ const match = /^(\d{4}-\d{2}-\d{2})\/(.+)$/.exec(rest);
33374
+ if (!match) return void 0;
33375
+ const name = match[2].replace(/^[0-9a-f]{4,}-/i, "").replace(/\.[A-Za-z0-9]+$/, "");
33376
+ return { day: match[1], name: name || match[2] };
33377
+ }
33370
33378
  function safeKeySegment(base) {
33371
33379
  const cleaned = base.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/\.{2,}/g, "-").replace(/-+/g, "-").replace(/^[-.]+|[-.]+$/g, "");
33372
33380
  return cleaned || "media";
@@ -33402,6 +33410,40 @@ function createR2Sink(bucket, opts) {
33402
33410
  mimeType: object3.httpMetadata?.contentType ?? "application/octet-stream"
33403
33411
  };
33404
33412
  },
33413
+ async listRecent(opts2 = {}) {
33414
+ return (await this.listRecentPage(opts2)).media;
33415
+ },
33416
+ async listRecentPage({ limit = 20, sinceDay } = {}) {
33417
+ if (!bucket.list) return { media: [], truncated: false, scannedPages: 0 };
33418
+ const prefix2 = await ownPrefix();
33419
+ const found = [];
33420
+ let cursor;
33421
+ let truncated = false;
33422
+ let scannedPages = 0;
33423
+ for (let page2 = 0; page2 < MAX_LIST_PAGES; page2++) {
33424
+ scannedPages = page2 + 1;
33425
+ const listed = await bucket.list({ prefix: prefix2, cursor, limit: 1e3 });
33426
+ for (const obj of listed.objects) {
33427
+ const parsed = parseMediaKey(obj.key, prefix2);
33428
+ if (!parsed) continue;
33429
+ if (sinceDay && parsed.day < sinceDay) continue;
33430
+ found.push({ ...parsed, key: obj.key, ...obj.size !== void 0 ? { sizeBytes: obj.size } : {} });
33431
+ }
33432
+ if (!listed.truncated || !listed.cursor) break;
33433
+ cursor = listed.cursor;
33434
+ if (page2 === MAX_LIST_PAGES - 1) truncated = true;
33435
+ }
33436
+ found.sort((a, b) => a.day < b.day ? 1 : a.day > b.day ? -1 : a.key < b.key ? 1 : -1);
33437
+ const page = found.slice(0, limit);
33438
+ const expiresAtMs2 = now().getTime() + ttlMs;
33439
+ const media = await Promise.all(
33440
+ page.map(async (entry) => {
33441
+ const link = await describe3(entry.key, expiresAtMs2);
33442
+ return { ...entry, ...link.unavailable ? {} : { url: link.ref, expiresAt: link.expiresAt } };
33443
+ })
33444
+ );
33445
+ return { media, truncated: truncated || found.length > limit, scannedPages };
33446
+ },
33405
33447
  async resign(key) {
33406
33448
  if (!await owned(key)) return void 0;
33407
33449
  if (bucket.get && !await bucket.get(key)) return void 0;
@@ -33543,7 +33585,7 @@ var idempotencyKeySchema = external_exports.string().min(1).optional().describe(
33543
33585
  "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
33586
  );
33545
33587
  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)."
33588
+ "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
33589
  );
33548
33590
  var maxWaitMsSchema = external_exports.number().int().positive().max(6e5).optional().describe(
33549
33591
  '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 +33750,7 @@ function timeoutRiskHint(opts) {
33708
33750
  const why = [proModel && "Pro model", fourK && "4K output", multi && `count=${opts.count}`].filter(Boolean).join(" + ");
33709
33751
  const lead = `Slow config (${why}) can exceed a host's tools/call timeout (e.g. Claude Desktop ~30s, error -32001). `;
33710
33752
  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.`;
33753
+ 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
33754
  }
33713
33755
  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
33756
  }
@@ -33810,14 +33852,73 @@ function assertLocalInputsAvailable(sink, args) {
33810
33852
 
33811
33853
  // src/jobs.ts
33812
33854
  init_dist();
33813
- import { randomUUID, createHash } from "node:crypto";
33855
+ import { randomUUID, createHash as createHash2 } from "node:crypto";
33856
+
33857
+ // src/job-store.ts
33858
+ import { createHash } from "node:crypto";
33859
+ var HEARTBEAT_MS = 6e4;
33860
+ var EXECUTOR_LOST_AFTER_MS = 3 * HEARTBEAT_MS;
33861
+ var PREFIX = "jobs";
33862
+ function keyHash(idempotencyKey) {
33863
+ return createHash("sha256").update(idempotencyKey).digest("hex").slice(0, 32);
33864
+ }
33865
+ function createBlobJobStore(bucket, opts) {
33866
+ const now = opts.now ?? (() => Date.now());
33867
+ const tenantOf = async () => typeof opts.tenant === "function" ? opts.tenant() : opts.tenant;
33868
+ const recordKey = async (jobId) => `${PREFIX}/${await tenantOf()}/${encodeURIComponent(jobId)}.json`;
33869
+ const pointerKey = async (k) => `${PREFIX}/${await tenantOf()}/keys/${keyHash(k)}.json`;
33870
+ async function writeJson(key, value) {
33871
+ try {
33872
+ await bucket.put(key, JSON.stringify(value), { httpMetadata: { contentType: "application/json" } });
33873
+ } catch {
33874
+ }
33875
+ }
33876
+ async function readJson(key) {
33877
+ try {
33878
+ const obj = await bucket.get(key);
33879
+ if (!obj) return void 0;
33880
+ return JSON.parse(new TextDecoder().decode(await obj.arrayBuffer()));
33881
+ } catch {
33882
+ return void 0;
33883
+ }
33884
+ }
33885
+ return {
33886
+ async put(record2) {
33887
+ await writeJson(await recordKey(record2.jobId), record2);
33888
+ },
33889
+ async get(jobId) {
33890
+ const record2 = await readJson(await recordKey(jobId));
33891
+ if (!record2 || typeof record2.status !== "string") return void 0;
33892
+ if (record2.status !== "running") return record2;
33893
+ if (now() - record2.updatedAt <= EXECUTOR_LOST_AFTER_MS) return record2;
33894
+ return {
33895
+ ...record2,
33896
+ status: "failed",
33897
+ error: {
33898
+ 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.`,
33899
+ 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."
33900
+ }
33901
+ };
33902
+ },
33903
+ async putKey(idempotencyKey, jobId) {
33904
+ await writeJson(await pointerKey(idempotencyKey), { jobId });
33905
+ },
33906
+ async jobIdForKey(idempotencyKey) {
33907
+ const pointer = await readJson(await pointerKey(idempotencyKey));
33908
+ return typeof pointer?.jobId === "string" ? pointer.jobId : void 0;
33909
+ }
33910
+ };
33911
+ }
33912
+
33913
+ // src/jobs.ts
33814
33914
  var JOB_TTL_MS = 6e5;
33915
+ var DURABLE_JOB_REUSE_MS = 24 * 60 * 60 * 1e3;
33815
33916
  var JOB_MAX = 64;
33816
33917
  function isExpired(entry, now) {
33817
33918
  return entry.settledAt !== void 0 && now - entry.settledAt > JOB_TTL_MS;
33818
33919
  }
33819
33920
  function fingerprintRequest(toolName, parts) {
33820
- return createHash("sha256").update(`${toolName}\0${JSON.stringify(parts)}`).digest("hex");
33921
+ return createHash2("sha256").update(`${toolName}\0${JSON.stringify(parts)}`).digest("hex");
33821
33922
  }
33822
33923
  async function refreshMedia(result, sink) {
33823
33924
  const first = result.content?.[0];
@@ -33896,13 +33997,23 @@ function annotateReused(result, jobId) {
33896
33997
  content: [{ type: "text", text: JSON.stringify({ reused: true, reused_job_id: jobId }, null, 2) }, ...content]
33897
33998
  };
33898
33999
  }
33899
- function jobHandle(jobId, status) {
34000
+ function jobHandle(jobId, status, durable = false) {
33900
34001
  return textResult({
33901
34002
  job_id: jobId,
33902
34003
  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).`
34004
+ 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
34005
  });
33905
34006
  }
34007
+ var MAX_PERSISTED_RESULT_BYTES = 64 * 1024;
34008
+ function persistableResult(result) {
34009
+ if ((result.content ?? []).some((block) => block.type !== "text")) return { resultOmitted: "inline" };
34010
+ try {
34011
+ if (JSON.stringify(result).length > MAX_PERSISTED_RESULT_BYTES) return { resultOmitted: "too_large" };
34012
+ } catch {
34013
+ return { resultOmitted: "too_large" };
34014
+ }
34015
+ return { result };
34016
+ }
33906
34017
  function awaitWithBudget(promise2, waitMs) {
33907
34018
  return new Promise((resolve2, reject) => {
33908
34019
  const timer = setTimeout(() => resolve2(void 0), waitMs);
@@ -33926,16 +34037,112 @@ var JobRegistry = class {
33926
34037
  * never expire.
33927
34038
  */
33928
34039
  resigner;
34040
+ /**
34041
+ * Durable backing for this registry, when the deployment has somewhere to put
34042
+ * it. Absent on a local stdio install, where the process that starts a
34043
+ * generation is the process that finishes it and a `Map` is the whole truth.
34044
+ *
34045
+ * Present on the hosted connector, where it is load-bearing: see the header of
34046
+ * `src/job-store.ts` for why an in-memory-only registry cannot work on a
34047
+ * machine that stops whenever no request is in flight.
34048
+ */
34049
+ store;
34050
+ /**
34051
+ * When set, `async: true` is served as a bounded in-band wait instead of an
34052
+ * immediate hand-off.
34053
+ *
34054
+ * Set by the hosted client, because there the two are not the trade-off they
34055
+ * look like. Returning immediately releases the request, and the released
34056
+ * request is the thing keeping the executor alive — so `async: true` is
34057
+ * precisely the way to get a generation killed. Waiting holds the machine up
34058
+ * until the work is done. If the budget still expires the caller gets the same
34059
+ * job handle it asked for, now backed by a durable record.
34060
+ */
34061
+ asyncWaitFallbackMs;
34062
+ /** Injectable clock (tests). */
34063
+ now = () => Date.now();
33929
34064
  jobs = /* @__PURE__ */ new Map();
33930
34065
  byKey = /* @__PURE__ */ new Map();
33931
34066
  // idempotencyKey -> jobId
33932
34067
  runningByFingerprint = /* @__PURE__ */ new Map();
33933
34068
  // fingerprint -> jobId (only while running)
34069
+ /** Durable bookkeeping still in flight — awaited by `drain()` in tests. */
34070
+ pending = /* @__PURE__ */ new Set();
34071
+ heartbeatTimer;
33934
34072
  /** Drop every job. Used by `SessionState.reset()` (tests) — not by handlers. */
33935
34073
  reset() {
33936
34074
  this.jobs.clear();
33937
34075
  this.byKey.clear();
33938
34076
  this.runningByFingerprint.clear();
34077
+ this.stopHeartbeat();
34078
+ }
34079
+ /** Await outstanding durable writes. Tests only — handlers never need it. */
34080
+ async drain() {
34081
+ for (let round = 0; round < 50; round++) {
34082
+ await new Promise((resolve2) => setTimeout(resolve2, 0));
34083
+ if (!this.pending.size) return;
34084
+ await Promise.allSettled([...this.pending]);
34085
+ }
34086
+ }
34087
+ /**
34088
+ * Re-stamp every running job's durable record.
34089
+ *
34090
+ * This is what separates "still working" from "the machine went away": a
34091
+ * reader compares the stamp against `EXECUTOR_LOST_AFTER_MS`, so a 12-scene
34092
+ * set that legitimately runs for ten minutes stays `running` while a job whose
34093
+ * child was killed goes terminal within three missed beats.
34094
+ */
34095
+ async beat() {
34096
+ if (!this.store) return;
34097
+ await Promise.allSettled(
34098
+ [...this.jobs.values()].filter((e) => e.status === "running").map((e) => this.writeRecord(e, { onlyWhileRunning: true }))
34099
+ );
34100
+ }
34101
+ /**
34102
+ * Queue one durable write for `entry`, after any write already in flight for
34103
+ * it. `onlyWhileRunning` drops the write if the job settled in the meantime —
34104
+ * which is what makes a late heartbeat a no-op rather than a resurrection.
34105
+ */
34106
+ writeRecord(entry, opts = {}) {
34107
+ const next = (entry.writeChain ?? Promise.resolve()).then(async () => {
34108
+ if (opts.onlyWhileRunning && entry.status !== "running") return;
34109
+ await this.store.put(this.recordFor(entry, this.now()));
34110
+ }, () => void 0);
34111
+ entry.writeChain = next;
34112
+ return next;
34113
+ }
34114
+ recordFor(entry, updatedAt) {
34115
+ return {
34116
+ jobId: entry.jobId,
34117
+ toolName: entry.toolName,
34118
+ fingerprint: entry.fingerprint,
34119
+ ...entry.idempotencyKey !== void 0 ? { idempotencyKey: entry.idempotencyKey } : {},
34120
+ status: entry.status,
34121
+ createdAt: entry.createdAt,
34122
+ updatedAt,
34123
+ ...entry.error ? { error: entry.error } : {},
34124
+ ...entry.status === "done" && entry.result ? persistableResult(entry.result) : {}
34125
+ };
34126
+ }
34127
+ /** Run `p` as durable bookkeeping: tracked for `drain()`, never thrown from. */
34128
+ track(p) {
34129
+ const wrapped = p.catch(() => void 0).finally(() => this.pending.delete(wrapped));
34130
+ this.pending.add(wrapped);
34131
+ }
34132
+ startHeartbeat() {
34133
+ if (this.heartbeatTimer || !this.store) return;
34134
+ this.heartbeatTimer = setInterval(() => {
34135
+ if (![...this.jobs.values()].some((e) => e.status === "running")) {
34136
+ this.stopHeartbeat();
34137
+ return;
34138
+ }
34139
+ this.track(this.beat());
34140
+ }, HEARTBEAT_MS);
34141
+ this.heartbeatTimer.unref?.();
34142
+ }
34143
+ stopHeartbeat() {
34144
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
34145
+ this.heartbeatTimer = void 0;
33939
34146
  }
33940
34147
  /** Drop expired entries, then enforce JOB_MAX by evicting oldest-settled first. */
33941
34148
  evict(now) {
@@ -33971,8 +34178,11 @@ var JobRegistry = class {
33971
34178
  * different registry and therefore a genuinely separate generation.
33972
34179
  */
33973
34180
  async dispatch(opts, work) {
33974
- const { toolName, fingerprint, idempotencyKey, async, waitMs } = opts;
33975
- const now = Date.now();
34181
+ const { toolName, fingerprint, idempotencyKey } = opts;
34182
+ const degrade = opts.async === true && this.asyncWaitFallbackMs !== void 0;
34183
+ const async = degrade ? false : opts.async;
34184
+ const waitMs = degrade ? opts.waitMs ?? this.asyncWaitFallbackMs : opts.waitMs;
34185
+ const now = this.now();
33976
34186
  let hit;
33977
34187
  if (idempotencyKey !== void 0) {
33978
34188
  const id = this.byKey.get(idempotencyKey);
@@ -33983,6 +34193,10 @@ var JobRegistry = class {
33983
34193
  const entry2 = id ? this.jobs.get(id) : void 0;
33984
34194
  if (entry2 && entry2.status === "running") hit = entry2;
33985
34195
  }
34196
+ if (!hit && idempotencyKey !== void 0 && this.store) {
34197
+ const durable = await this.durableByKey(idempotencyKey, fingerprint);
34198
+ if (durable) return durable;
34199
+ }
33986
34200
  if (hit) {
33987
34201
  if (async) return jobHandle(hit.jobId, hit.status);
33988
34202
  const settled = waitMs !== void 0 && hit.status === "running" ? await awaitWithBudget(hit.promise, waitMs) : await hit.promise;
@@ -33996,27 +34210,53 @@ var JobRegistry = class {
33996
34210
  this.jobs.set(jobId, entry);
33997
34211
  if (idempotencyKey !== void 0) this.byKey.set(idempotencyKey, jobId);
33998
34212
  this.runningByFingerprint.set(fingerprint, jobId);
33999
- promise2.then(
34213
+ if (this.store) {
34214
+ this.track(this.writeRecord(entry));
34215
+ if (idempotencyKey !== void 0) this.track(this.store.putKey(idempotencyKey, jobId));
34216
+ this.startHeartbeat();
34217
+ }
34218
+ const settle = promise2.then(
34000
34219
  (res) => {
34001
34220
  entry.status = "done";
34002
34221
  entry.result = res;
34003
- entry.settledAt = Date.now();
34222
+ entry.settledAt = this.now();
34004
34223
  },
34005
34224
  (err) => {
34006
34225
  entry.status = "failed";
34007
34226
  entry.error = { message: err instanceof Error ? err.message : String(err), hint: err?.hint };
34008
- entry.settledAt = Date.now();
34227
+ entry.settledAt = this.now();
34009
34228
  }
34010
34229
  ).finally(() => {
34011
34230
  if (this.runningByFingerprint.get(fingerprint) === jobId) this.runningByFingerprint.delete(fingerprint);
34012
34231
  });
34013
- if (async) return jobHandle(jobId, "running");
34232
+ if (this.store) void settle.then(() => this.track(this.writeRecord(entry)));
34233
+ if (async) return jobHandle(jobId, "running", this.store !== void 0);
34014
34234
  if (waitMs !== void 0) {
34015
34235
  const settled = await awaitWithBudget(promise2, waitMs);
34016
- return settled ?? jobHandle(jobId, "running");
34236
+ return settled ?? jobHandle(jobId, "running", this.store !== void 0);
34017
34237
  }
34018
34238
  return promise2;
34019
34239
  }
34240
+ /**
34241
+ * Resolve an idempotency key against the durable store — the path a retry
34242
+ * takes when the child that ran the original job is gone.
34243
+ *
34244
+ * `undefined` means "nothing durable to reuse", which includes a job that
34245
+ * FAILED: a retry after an error must really retry. A job still recorded as
34246
+ * running belongs to an executor this process cannot await, so the caller gets
34247
+ * the handle back rather than a promise that would never settle.
34248
+ */
34249
+ async durableByKey(idempotencyKey, fingerprint) {
34250
+ const id = await this.store.jobIdForKey(idempotencyKey);
34251
+ if (!id) return void 0;
34252
+ const record2 = await this.store.get(id);
34253
+ if (!record2) return void 0;
34254
+ if (record2.fingerprint !== fingerprint) return void 0;
34255
+ if (this.now() - record2.updatedAt > DURABLE_JOB_REUSE_MS) return void 0;
34256
+ if (record2.status === "running") return jobHandle(id, "running", true);
34257
+ if (record2.status !== "done" || !record2.result) return void 0;
34258
+ return annotateReused(await refreshMedia(record2.result, this.resigner), id);
34259
+ }
34020
34260
  /**
34021
34261
  * Retrieve an async job by id (backs the `gemini_get_result` tool):
34022
34262
  * `running` → a status handle; `done` → the recorded result payload;
@@ -34026,21 +34266,52 @@ var JobRegistry = class {
34026
34266
  * the unknown-id path — the poll tool cannot be used to read another user's
34027
34267
  * result even if they know the uuid.
34028
34268
  */
34029
- getResult(jobId) {
34269
+ async getResult(jobId) {
34030
34270
  const entry = this.jobs.get(jobId);
34031
34271
  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
- });
34272
+ const durable = this.store ? await this.store.get(jobId) : void 0;
34273
+ if (durable) return this.fromRecord(durable);
34274
+ throw new McpToolError(
34275
+ 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.`,
34276
+ {
34277
+ 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."
34278
+ }
34279
+ );
34035
34280
  }
34036
- if (entry.status === "running") return jobHandle(jobId, "running");
34281
+ if (entry.status === "running") return jobHandle(jobId, "running", this.store !== void 0);
34037
34282
  if (entry.status === "failed") {
34038
34283
  throw new McpToolError(entry.error?.message ?? "Job failed.", entry.error?.hint ? { hint: entry.error.hint } : void 0);
34039
34284
  }
34040
34285
  if (!entry.result) throw new McpToolError(`Job "${jobId}" completed without a recorded result.`);
34041
34286
  return entry.result;
34042
34287
  }
34288
+ /**
34289
+ * Render a durable record as a poll answer — the path taken when the child
34290
+ * that ran the job is gone.
34291
+ *
34292
+ * The three outcomes are kept distinct on purpose. Collapsing "your job
34293
+ * failed", "your job was killed" and "no such job" into one message is what
34294
+ * made the original bug read as an expiry problem when it was a machine
34295
+ * lifecycle problem.
34296
+ */
34297
+ async fromRecord(record2) {
34298
+ if (record2.status === "running") return jobHandle(record2.jobId, "running", true);
34299
+ if (record2.status === "failed") {
34300
+ throw new McpToolError(record2.error?.message ?? "Job failed.", record2.error?.hint ? { hint: record2.error.hint } : void 0);
34301
+ }
34302
+ if (record2.resultOmitted) {
34303
+ throw new McpToolError(
34304
+ 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.`,
34305
+ {
34306
+ hint: "The generation itself completed \u2014 list what it produced with gemini_list_recent_media rather than paying for it again."
34307
+ }
34308
+ );
34309
+ }
34310
+ if (!record2.result) throw new McpToolError(`Job "${record2.jobId}" completed without a recorded result.`);
34311
+ return refreshMedia(record2.result, this.resigner);
34312
+ }
34043
34313
  };
34314
+ var JOB_RECORD_RETENTION_LABEL = "30 days";
34044
34315
 
34045
34316
  // src/tools/_confirm.ts
34046
34317
  init_dist();
@@ -34810,6 +35081,8 @@ var GeminiClient = class {
34810
35081
  this.library = opts.library;
34811
35082
  this.uploadUrls = opts.uploadUrls;
34812
35083
  this.session.jobs.resigner = this.mediaSink;
35084
+ this.session.jobs.store = opts.jobStore;
35085
+ if (opts.jobStore) this.session.jobs.asyncWaitFallbackMs = HOSTED_ASYNC_WAIT_MS;
34813
35086
  this.explicitApiKey = opts.apiKey?.trim() || void 0;
34814
35087
  this.fetchImpl = opts.fetchImpl ?? ((...args) => fetch(...args));
34815
35088
  this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
@@ -35339,9 +35612,14 @@ function hostedStorage() {
35339
35612
  tenant
35340
35613
  }),
35341
35614
  library: createR2Library(blob.bucket, { tenant }),
35342
- uploadUrls: createUploadUrlMinter({ links: blob.links, sign: blob.signWrite, tenant })
35615
+ uploadUrls: createUploadUrlMinter({ links: blob.links, sign: blob.signWrite, tenant }),
35616
+ // Job records go in the same store as the media, for the same reason: this
35617
+ // runtime's machine stops whenever no request is in flight, taking the
35618
+ // child — and an in-memory registry — with it (src/job-store.ts).
35619
+ jobStore: createBlobJobStore(blob.bucket, { tenant })
35343
35620
  };
35344
35621
  }
35622
+ var HOSTED_ASYNC_WAIT_MS = 24e4;
35345
35623
  var BLOB_URL_TTL_MS = 24 * 60 * 60 * 1e3;
35346
35624
  var BLOB_MAX_TTL_MS = 24 * 60 * 60 * 1e3;
35347
35625
  var client = new GeminiClient(hostedStorage());
@@ -35748,7 +36026,7 @@ function registerJobTools(server, client2) {
35748
36026
  server.registerTool(
35749
36027
  "gemini_get_result",
35750
36028
  {
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.',
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.',
35752
36030
  annotations: { readOnlyHint: true, openWorldHint: false },
35753
36031
  inputSchema: {
35754
36032
  job_id: external_exports.string().min(1).describe("The job_id returned by a generation tool called with async: true")
@@ -35928,6 +36206,43 @@ function registerFileTools(server, client2) {
35928
36206
  });
35929
36207
  }
35930
36208
  );
36209
+ server.registerTool(
36210
+ "gemini_list_recent_media",
36211
+ {
36212
+ 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.",
36213
+ annotations: { readOnlyHint: true, openWorldHint: false },
36214
+ inputSchema: {
36215
+ limit: external_exports.number().int().positive().max(100).optional().describe("How many to return (default 20, newest first)"),
36216
+ 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"')
36217
+ }
36218
+ },
36219
+ async (args) => {
36220
+ const sink = client2.mediaSink;
36221
+ if (!sink?.listRecent) {
36222
+ throw new McpToolError("This server writes generated files to disk \u2014 list the output directory instead.", {
36223
+ hint: "gemini_list_recent_media applies to the hosted connector only."
36224
+ });
36225
+ }
36226
+ const limit = args.limit ?? 20;
36227
+ const page = sink.listRecentPage ? await sink.listRecentPage({ limit, sinceDay: args.since_day }) : { media: await sink.listRecent({ limit, sinceDay: args.since_day }), truncated: false };
36228
+ const found = page.media;
36229
+ return textResult({
36230
+ count: found.length,
36231
+ // Never let a bounded walk pass for the whole picture: concluding an
36232
+ // image is gone is what makes someone pay to generate it twice.
36233
+ ...page.truncated ? { truncated: true } : {},
36234
+ media: found.map((m) => ({
36235
+ r2_key: m.key,
36236
+ day: m.day,
36237
+ name: m.name,
36238
+ ...m.sizeBytes !== void 0 ? { size_bytes: m.sizeBytes } : {},
36239
+ ...m.url ? { url: m.url, curl_hint: `curl -sS -o ${downloadFilename(m.key)} "${m.url}"` } : {},
36240
+ ...m.expiresAt ? { expires_at: m.expiresAt } : {}
36241
+ })),
36242
+ 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."
36243
+ });
36244
+ }
36245
+ );
35931
36246
  }
35932
36247
  server.registerTool(
35933
36248
  "gemini_delete_file",
package/dist/client.js CHANGED
@@ -5,6 +5,7 @@ import { createDiskSink, createR2Sink, tenantIdFor } from './storage/media.js';
5
5
  import { createR2Library } from './library.js';
6
6
  import { createUploadUrlMinter } from './upload-url.js';
7
7
  import { blobStoreFromEnv } from './blob-store.js';
8
+ import { createBlobJobStore } from './job-store.js';
8
9
  import { SessionState } from './session.js';
9
10
  import { fetchRemoteImage } from './fetch-image.js';
10
11
  // NOTE: this module must stay SIDE-EFFECT-FREE at module scope — no I/O, no
@@ -167,6 +168,14 @@ export class GeminiClient {
167
168
  // A replayed idempotent result must not hand back an expired media URL, so
168
169
  // the job registry needs a way to re-sign from the stable r2_key.
169
170
  this.session.jobs.resigner = this.mediaSink;
171
+ // Durable job records, where the deployment has somewhere to keep them.
172
+ // Without this the hosted connector loses a job the moment its machine
173
+ // stops — which it does as soon as no request is in flight (job-store.ts).
174
+ this.session.jobs.store = opts.jobStore;
175
+ // And on that runtime, `async: true` is served by HOLDING the request:
176
+ // releasing it is what lets the executor be reclaimed mid-generation.
177
+ if (opts.jobStore)
178
+ this.session.jobs.asyncWaitFallbackMs = HOSTED_ASYNC_WAIT_MS;
170
179
  this.explicitApiKey = opts.apiKey?.trim() || undefined;
171
180
  // Never store the global `fetch` raw on the instance: it would be invoked
172
181
  // as `this.fetchImpl(...)`, handing native fetch this GeminiClient as its
@@ -797,8 +806,24 @@ function hostedStorage() {
797
806
  }),
798
807
  library: createR2Library(blob.bucket, { tenant }),
799
808
  uploadUrls: createUploadUrlMinter({ links: blob.links, sign: blob.signWrite, tenant }),
809
+ // Job records go in the same store as the media, for the same reason: this
810
+ // runtime's machine stops whenever no request is in flight, taking the
811
+ // child — and an in-memory registry — with it (src/job-store.ts).
812
+ jobStore: createBlobJobStore(blob.bucket, { tenant }),
800
813
  };
801
814
  }
815
+ /**
816
+ * How long a hosted `async: true` call holds the request open before handing
817
+ * back a job id.
818
+ *
819
+ * Not a tuning knob so much as the shape of the platform: holding the request
820
+ * is what keeps the executor alive, so this is "how long we are willing to keep
821
+ * the machine up for one generation". Four minutes is the value observed to
822
+ * work in practice against claude.ai, and comfortably covers a Pro/4K image or
823
+ * a modest set; anything slower hands back a durable job id rather than
824
+ * silently dying, which is the behaviour this whole change is for.
825
+ */
826
+ const HOSTED_ASYNC_WAIT_MS = 240_000;
802
827
  /**
803
828
  * How long a freshly-minted media link lives.
804
829
  *