@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bundle.js +336 -21
- package/dist/client.js +25 -0
- package/dist/job-store.js +138 -0
- package/dist/jobs.js +257 -12
- package/dist/session.js +5 -4
- package/dist/storage/media.js +70 -0
- package/dist/tools/files.js +49 -0
- package/dist/tools/jobs.js +5 -1
- package/dist/tools/shared.js +13 -3
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -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.
|
|
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.
|
|
18
|
+
"version": "1.7.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
33975
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
34033
|
-
|
|
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
|
|
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
|
*
|