@chrischall/gemini-mcp 1.2.0 → 1.4.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/README.md +70 -0
- package/dist/bundle.js +214 -33
- package/dist/client.js +18 -0
- package/dist/connector-origin.js +34 -0
- package/dist/jobs.js +77 -1
- package/dist/media-cleanup.js +75 -0
- package/dist/media-endpoint.js +160 -0
- package/dist/media-url.js +135 -0
- package/dist/storage/media.js +72 -12
- package/dist/tools/files.js +67 -6
- package/dist/tools/generate.js +42 -21
- package/dist/tools/interact.js +5 -3
- package/dist/tools/set.js +41 -5
- package/dist/tools/shared.js +33 -2
- 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.4.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.4.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
package/README.md
CHANGED
|
@@ -86,6 +86,76 @@ progress), two guards make re-issuing safe and unnecessary:
|
|
|
86
86
|
| `gemini_upload_file` | Upload an image (or video/audio) to the Gemini Files API once — from a `url`, `data_base64`, or a local `path` — and get a reusable `files/<id>` reference |
|
|
87
87
|
| `gemini_list_files` | List the files currently uploaded under this API key, with MIME types and expiry times |
|
|
88
88
|
| `gemini_delete_file` | Delete an uploaded file before its ~48h expiry (confirm-gated) |
|
|
89
|
+
| `gemini_sign_media` | *(hosted connector only)* Mint a fresh signed URL for generated media from its `r2_key` — an expired link is not a dead end |
|
|
90
|
+
|
|
91
|
+
## Seeing your images (hosted connector)
|
|
92
|
+
|
|
93
|
+
On the hosted connector there is no filesystem, so a generated image has to come back as
|
|
94
|
+
something you can *open*. It does: **every result includes a URL**, with no configuration.
|
|
95
|
+
|
|
96
|
+
```jsonc
|
|
97
|
+
{
|
|
98
|
+
"images": ["https://connector.example.com/media/gen/2026-07-29/ab12cd34-a-cat.png?exp=…&sig=…"],
|
|
99
|
+
"media": [{ "url": "https://…", "r2_key": "gen/2026-07-29/ab12cd34-a-cat.png",
|
|
100
|
+
"expires_at": "2026-07-31T12:00:00.000Z",
|
|
101
|
+
"curl_hint": "curl -sS -o a-cat.png \"https://…\"" }]
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Those links need no auth header — the signature is in the URL — so they work in a browser, in
|
|
106
|
+
`curl`, and in a chat message. They expire (48h by default) and the objects behind them are
|
|
107
|
+
swept on a retention schedule. The `r2_key` is the durable handle for that window:
|
|
108
|
+
|
|
109
|
+
- **`gemini_sign_media`** (hosted connector only) mints a fresh signed URL from an `r2_key`,
|
|
110
|
+
so an expired link never forces you to re-generate — and re-pay for — the image.
|
|
111
|
+
- **`gemini_upload_file` with `r2_key`** turns media the connector generated into a Files API
|
|
112
|
+
reference (the connector reads its own bucket — no HTTP round trip, no signature needed), so
|
|
113
|
+
a generated image can become the reference image for the next generation in one cheap call.
|
|
114
|
+
- Idempotent replays (`idempotency_key`) re-mint the URLs inside the recorded result before
|
|
115
|
+
returning it, so a reused result never carries a dead link.
|
|
116
|
+
|
|
117
|
+
**Why this matters:** MCP's inline image content blocks (`inline: true`) are visible to the
|
|
118
|
+
*assistant* but many chat clients never render them to the user, and the assistant cannot
|
|
119
|
+
extract bytes back out of its own context to save them elsewhere. A generation could bill
|
|
120
|
+
successfully and be invisible. A URL is the portable answer; `inline` remains available, but
|
|
121
|
+
it is no longer the only way to receive media.
|
|
122
|
+
|
|
123
|
+
### Three ways to serve the bytes
|
|
124
|
+
|
|
125
|
+
| | setup | URL shape |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| **Worker route** (default) | none — works out of the box | `https://<worker-host>/media/<key>?exp=&sig=` |
|
|
128
|
+
| **r2.dev public bucket** | enable public access on the bucket, set `MEDIA_PUBLIC_BASE_URL` | `https://pub-….r2.dev/<key>` |
|
|
129
|
+
| **Custom domain** (best) | attach a domain to the bucket in Cloudflare, set `MEDIA_PUBLIC_BASE_URL` | `https://media.example.com/<key>` |
|
|
130
|
+
|
|
131
|
+
The built-in Worker route is the zero-config path: the connector streams the object out of R2
|
|
132
|
+
itself. The other two are worth setting up if you want plain, non-expiring URLs or want media
|
|
133
|
+
traffic served by R2 rather than by the Worker — r2.dev is easiest but rate-limited and fine
|
|
134
|
+
for personal use; a custom domain is the best of the three.
|
|
135
|
+
|
|
136
|
+
**Auth on `/media` is a signed, expiring URL rather than an unlisted key.** Random keys would
|
|
137
|
+
be simpler, but they never expire and never revoke: anything that ever logged or forwarded the
|
|
138
|
+
link keeps working forever. A signature scopes access to one object with a deadline, and
|
|
139
|
+
rotating `MEDIA_URL_SECRET` invalidates every outstanding link at once. The tradeoff is that
|
|
140
|
+
links are long and cannot be shortened by hand.
|
|
141
|
+
|
|
142
|
+
### For assistants relaying a result
|
|
143
|
+
|
|
144
|
+
Show the user the URL. If your sandbox has network egress to the connector host, fetching it
|
|
145
|
+
and attaching the bytes as a file gives the nicest result; otherwise present the link itself.
|
|
146
|
+
Whether a given client renders `` markdown inline varies by client — a bare URL is the
|
|
147
|
+
safe form, and a markdown link is a reasonable enhancement where you know it renders.
|
|
148
|
+
|
|
149
|
+
### Retention
|
|
150
|
+
|
|
151
|
+
| Variable | Default | Effect |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| `MEDIA_TTL_DAYS` | `7` | Objects older than this are deleted by a daily cron (both the current `gen/` prefix and the legacy `media/` one) |
|
|
154
|
+
| `MEDIA_URL_SECRET` | generated | HMAC key for `/media` links; rotate to revoke all outstanding URLs |
|
|
155
|
+
| `MEDIA_PUBLIC_BASE_URL` | unset | Serve from R2 directly instead of the Worker route |
|
|
156
|
+
|
|
157
|
+
Signed-URL lifetime is clamped to `MEDIA_TTL_DAYS`, so a link never outlives the object it
|
|
158
|
+
points at.
|
|
89
159
|
|
|
90
160
|
## Sending reference images without burning context
|
|
91
161
|
|
package/dist/bundle.js
CHANGED
|
@@ -32783,6 +32783,15 @@ var init_clipboard = __esm({
|
|
|
32783
32783
|
}
|
|
32784
32784
|
});
|
|
32785
32785
|
|
|
32786
|
+
// src/media-url.ts
|
|
32787
|
+
var MEDIA_URL_TTL_MS;
|
|
32788
|
+
var init_media_url = __esm({
|
|
32789
|
+
"src/media-url.ts"() {
|
|
32790
|
+
"use strict";
|
|
32791
|
+
MEDIA_URL_TTL_MS = 48 * 60 * 60 * 1e3;
|
|
32792
|
+
}
|
|
32793
|
+
});
|
|
32794
|
+
|
|
32786
32795
|
// src/index.ts
|
|
32787
32796
|
init_dist();
|
|
32788
32797
|
|
|
@@ -32796,7 +32805,7 @@ async function loadStdioDotenv() {
|
|
|
32796
32805
|
}
|
|
32797
32806
|
|
|
32798
32807
|
// src/version.ts
|
|
32799
|
-
var VERSION = "1.
|
|
32808
|
+
var VERSION = "1.4.0";
|
|
32800
32809
|
|
|
32801
32810
|
// src/tools/models.ts
|
|
32802
32811
|
init_dist();
|
|
@@ -33214,13 +33223,21 @@ function createDiskSink() {
|
|
|
33214
33223
|
const { writeMedia: writeMedia2, resolveOutputDir: resolveOutputDir2 } = await Promise.resolve().then(() => (init_images(), images_exports));
|
|
33215
33224
|
const dir = resolveOutputDir2(opts.output_dir);
|
|
33216
33225
|
const refs = [];
|
|
33217
|
-
for (const it of items) refs.push(await writeMedia2(dir, it.base, it.base64, it.mimeType));
|
|
33226
|
+
for (const it of items) refs.push({ ref: await writeMedia2(dir, it.base, it.base64, it.mimeType) });
|
|
33218
33227
|
return refs;
|
|
33219
33228
|
},
|
|
33220
33229
|
note: () => void 0
|
|
33221
33230
|
};
|
|
33222
33231
|
}
|
|
33223
33232
|
|
|
33233
|
+
// src/media-endpoint.ts
|
|
33234
|
+
init_media_url();
|
|
33235
|
+
function downloadFilename(key) {
|
|
33236
|
+
const last = key.split("/").pop() || "media";
|
|
33237
|
+
const stripped = last.replace(/^[0-9a-f]{6,12}-/i, "");
|
|
33238
|
+
return stripped || last;
|
|
33239
|
+
}
|
|
33240
|
+
|
|
33224
33241
|
// src/tools/shared.ts
|
|
33225
33242
|
var DEFAULT_SINK = createDiskSink();
|
|
33226
33243
|
var ASPECT_RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9", "1:4", "4:1", "1:8", "8:1"];
|
|
@@ -33346,14 +33363,32 @@ async function emitMedia(named, kind, opts, meta3, onWritten) {
|
|
|
33346
33363
|
return { content: blocks };
|
|
33347
33364
|
}
|
|
33348
33365
|
const sink = opts.sink ?? DEFAULT_SINK;
|
|
33349
|
-
const
|
|
33366
|
+
const persisted = await sink.persist(named.map((n) => ({ base: n.base, base64: n.media.base64, mimeType: n.media.mimeType })), {
|
|
33350
33367
|
output_dir: opts.output_dir
|
|
33351
33368
|
});
|
|
33369
|
+
const refs = persisted.map((p) => p.ref);
|
|
33352
33370
|
if (sink.persistsFiles) await onWritten?.(refs);
|
|
33353
33371
|
const note = opts.inline && kind === "video" ? { inline_unsupported: "video has no MCP inline content type \u2014 written to disk instead" } : {};
|
|
33354
33372
|
const storageNote = sink.note();
|
|
33373
|
+
const unavailable = persisted.some((p) => p.unavailable) ? {
|
|
33374
|
+
media_url_unavailable: "The media was stored but no public URL could be generated for it, so the values above are bare object keys, not links. This is a connector misconfiguration: set MEDIA_PUBLIC_BASE_URL, or use inline: true to receive the bytes directly."
|
|
33375
|
+
} : {};
|
|
33376
|
+
const detailed = persisted.filter((p) => p.key !== void 0);
|
|
33377
|
+
const media = detailed.length === persisted.length && detailed.length > 0 ? {
|
|
33378
|
+
media: persisted.map((p) => ({
|
|
33379
|
+
url: p.ref,
|
|
33380
|
+
...p.key ? { r2_key: p.key } : {},
|
|
33381
|
+
...p.expiresAt ? { expires_at: p.expiresAt } : {},
|
|
33382
|
+
// A ready-to-run command, not a description of one. Models copy an
|
|
33383
|
+
// exact string far more reliably than they compose one — and in a
|
|
33384
|
+
// chat client this download-then-attach flow is the ONLY way the end
|
|
33385
|
+
// user actually sees the image (inline MCP blocks and markdown embeds
|
|
33386
|
+
// both render for the model and not for them).
|
|
33387
|
+
...p.key ? { curl_hint: `curl -sS -o ${downloadFilename(p.key)} "${p.ref}"` } : {}
|
|
33388
|
+
}))
|
|
33389
|
+
} : {};
|
|
33355
33390
|
const storage = sink.persistsFiles ? {} : { storage: sink.kind, storage_note: storageNote };
|
|
33356
|
-
return textResult({ [`${kind}s`]: refs, ...note, ...storage, ...meta3 });
|
|
33391
|
+
return textResult({ [`${kind}s`]: refs, ...media, ...unavailable, ...note, ...storage, ...meta3 });
|
|
33357
33392
|
}
|
|
33358
33393
|
async function emit(named, opts, meta3, onWritten) {
|
|
33359
33394
|
return emitMedia(named.map((n) => ({ media: n.image, base: n.base })), "image", opts, meta3, onWritten);
|
|
@@ -33384,6 +33419,47 @@ function isExpired(entry, now) {
|
|
|
33384
33419
|
function fingerprintRequest(toolName, parts) {
|
|
33385
33420
|
return createHash("sha256").update(`${toolName}\0${JSON.stringify(parts)}`).digest("hex");
|
|
33386
33421
|
}
|
|
33422
|
+
async function refreshMedia(result, sink) {
|
|
33423
|
+
const first = result.content?.[0];
|
|
33424
|
+
if (!sink?.resign || !first || first.type !== "text") return result;
|
|
33425
|
+
let body;
|
|
33426
|
+
try {
|
|
33427
|
+
body = JSON.parse(first.text);
|
|
33428
|
+
} catch {
|
|
33429
|
+
return result;
|
|
33430
|
+
}
|
|
33431
|
+
const entries = Array.isArray(body.media) ? body.media : [];
|
|
33432
|
+
if (!entries.some((e) => typeof e.r2_key === "string")) return result;
|
|
33433
|
+
const refreshed = await Promise.all(entries.map(async (e) => {
|
|
33434
|
+
if (typeof e.r2_key !== "string") return void 0;
|
|
33435
|
+
try {
|
|
33436
|
+
return await sink.resign(e.r2_key);
|
|
33437
|
+
} catch {
|
|
33438
|
+
return void 0;
|
|
33439
|
+
}
|
|
33440
|
+
}));
|
|
33441
|
+
if (refreshed.every((r) => r === void 0)) return result;
|
|
33442
|
+
const mediaKeyName = ["images", "videos", "audios"].find((k) => Array.isArray(body[k]));
|
|
33443
|
+
const refs = [];
|
|
33444
|
+
refreshed.forEach((fresh, i) => {
|
|
33445
|
+
if (!fresh) {
|
|
33446
|
+
refs.push(String(entries[i]?.url ?? ""));
|
|
33447
|
+
return;
|
|
33448
|
+
}
|
|
33449
|
+
const { expires_at: _stale, ...rest } = entries[i];
|
|
33450
|
+
entries[i] = {
|
|
33451
|
+
...rest,
|
|
33452
|
+
url: fresh.ref,
|
|
33453
|
+
...fresh.expiresAt ? { expires_at: fresh.expiresAt } : {},
|
|
33454
|
+
curl_hint: `curl -sS -o ${downloadFilename(fresh.key ?? "media")} "${fresh.ref}"`
|
|
33455
|
+
};
|
|
33456
|
+
refs.push(fresh.ref);
|
|
33457
|
+
});
|
|
33458
|
+
body.media = entries;
|
|
33459
|
+
if (mediaKeyName) body[mediaKeyName] = refs;
|
|
33460
|
+
body.media_urls_refreshed = true;
|
|
33461
|
+
return { ...result, content: [{ ...first, text: JSON.stringify(body, null, 2) }, ...(result.content ?? []).slice(1)] };
|
|
33462
|
+
}
|
|
33387
33463
|
function annotateReused(result, jobId) {
|
|
33388
33464
|
const content = result.content ?? [];
|
|
33389
33465
|
const first = content[0];
|
|
@@ -33411,6 +33487,12 @@ function jobHandle(jobId, status) {
|
|
|
33411
33487
|
});
|
|
33412
33488
|
}
|
|
33413
33489
|
var JobRegistry = class {
|
|
33490
|
+
/**
|
|
33491
|
+
* Lets a replayed result have its media URLs re-minted. Set by the session
|
|
33492
|
+
* that owns this registry; absent on stdio, where refs are file paths that
|
|
33493
|
+
* never expire.
|
|
33494
|
+
*/
|
|
33495
|
+
resigner;
|
|
33414
33496
|
jobs = /* @__PURE__ */ new Map();
|
|
33415
33497
|
byKey = /* @__PURE__ */ new Map();
|
|
33416
33498
|
// idempotencyKey -> jobId
|
|
@@ -33470,7 +33552,7 @@ var JobRegistry = class {
|
|
|
33470
33552
|
}
|
|
33471
33553
|
if (hit) {
|
|
33472
33554
|
if (async) return jobHandle(hit.jobId, hit.status);
|
|
33473
|
-
return annotateReused(await hit.promise, hit.jobId);
|
|
33555
|
+
return annotateReused(await refreshMedia(await hit.promise, this.resigner), hit.jobId);
|
|
33474
33556
|
}
|
|
33475
33557
|
const jobId = randomUUID();
|
|
33476
33558
|
this.evict(now);
|
|
@@ -33598,31 +33680,43 @@ function registerGenerateTools(server, client2) {
|
|
|
33598
33680
|
const named = [];
|
|
33599
33681
|
let capturedText;
|
|
33600
33682
|
let capturedGrounding;
|
|
33683
|
+
const failed = [];
|
|
33684
|
+
let firstError;
|
|
33601
33685
|
await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : "image"} (${model})`, async () => {
|
|
33602
33686
|
for (let i = 0; i < count; i++) {
|
|
33603
|
-
|
|
33604
|
-
|
|
33605
|
-
|
|
33606
|
-
|
|
33607
|
-
|
|
33608
|
-
|
|
33609
|
-
|
|
33610
|
-
|
|
33611
|
-
|
|
33612
|
-
|
|
33613
|
-
|
|
33614
|
-
|
|
33615
|
-
|
|
33616
|
-
|
|
33617
|
-
|
|
33618
|
-
|
|
33619
|
-
|
|
33687
|
+
try {
|
|
33688
|
+
const result = await client2.generate({
|
|
33689
|
+
prompt: args.prompt,
|
|
33690
|
+
images: refInputs.length > 0 ? refInputs : void 0,
|
|
33691
|
+
model: args.model,
|
|
33692
|
+
aspectRatio: args.aspect_ratio,
|
|
33693
|
+
imageSize: args.image_size,
|
|
33694
|
+
seed: seed + i,
|
|
33695
|
+
thinkingLevel: args.thinking_level,
|
|
33696
|
+
googleSearch: args.google_search,
|
|
33697
|
+
videoUrl: video.videoUrl,
|
|
33698
|
+
videoMimeType: video.videoMimeType,
|
|
33699
|
+
timeoutMs: args.timeout_ms
|
|
33700
|
+
});
|
|
33701
|
+
const { images: [img], text } = result;
|
|
33702
|
+
if (text && !capturedText) capturedText = text;
|
|
33703
|
+
if (result.grounding && !capturedGrounding) capturedGrounding = result.grounding;
|
|
33704
|
+
named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, "0")}` });
|
|
33705
|
+
} catch (err) {
|
|
33706
|
+
firstError ??= err;
|
|
33707
|
+
failed.push({ image: i + 1, error: err instanceof Error ? err.message : String(err) });
|
|
33708
|
+
}
|
|
33620
33709
|
}
|
|
33621
33710
|
});
|
|
33711
|
+
if (named.length === 0) throw firstError;
|
|
33622
33712
|
const meta3 = buildMeta(model, seed, args);
|
|
33623
33713
|
if (capturedText) meta3.text = capturedText;
|
|
33624
33714
|
if (capturedGrounding) meta3.grounding = capturedGrounding;
|
|
33625
33715
|
if (report) meta3.image_inputs = report;
|
|
33716
|
+
if (failed.length) {
|
|
33717
|
+
meta3.failed_images = failed;
|
|
33718
|
+
meta3.partial = `${failed.length} of ${count} image(s) failed; the ${named.length} that succeeded are returned above. Re-run with count set to the shortfall rather than the whole batch \u2014 each failure message is in failed_images.`;
|
|
33719
|
+
}
|
|
33626
33720
|
if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
|
|
33627
33721
|
meta3.hint = REFINE_HINT;
|
|
33628
33722
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, count, persistsFiles: client2.mediaSink?.persistsFiles });
|
|
@@ -33756,6 +33850,7 @@ function registerSetTools(server, client2) {
|
|
|
33756
33850
|
client2
|
|
33757
33851
|
);
|
|
33758
33852
|
const masterRefInputs = [...masterOnly.inputs, ...carried.inputs];
|
|
33853
|
+
const failed = [];
|
|
33759
33854
|
const report = carried.report || masterOnly.report ? { ...masterOnly.report, ...carried.report } : void 0;
|
|
33760
33855
|
const named = [];
|
|
33761
33856
|
const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
|
|
@@ -33769,18 +33864,33 @@ function registerSetTools(server, client2) {
|
|
|
33769
33864
|
named.push({ image: master, base: `${slug}-master` });
|
|
33770
33865
|
const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
|
|
33771
33866
|
const mode = args.reference_mode ?? "master";
|
|
33867
|
+
const sceneName = (i) => `${slug}-${String(i + 1).padStart(2, "0")}`;
|
|
33868
|
+
const record2 = (err, i) => {
|
|
33869
|
+
failed.push({
|
|
33870
|
+
scene: i + 1,
|
|
33871
|
+
prompt: scenePrompts[i],
|
|
33872
|
+
error: err instanceof Error ? err.message : String(err)
|
|
33873
|
+
});
|
|
33874
|
+
};
|
|
33772
33875
|
if (mode === "chain") {
|
|
33773
33876
|
let ref = master;
|
|
33774
33877
|
for (let i = 0; i < scenePrompts.length; i++) {
|
|
33775
|
-
|
|
33776
|
-
|
|
33777
|
-
|
|
33878
|
+
try {
|
|
33879
|
+
const { images: [img] } = await client2.generate({ prompt: scenePrompts[i], images: [ref, ...carried.inputs], seed: seed + i + 1, ...cfg });
|
|
33880
|
+
named.push({ image: img, base: sceneName(i) });
|
|
33881
|
+
ref = img;
|
|
33882
|
+
} catch (err) {
|
|
33883
|
+
record2(err, i);
|
|
33884
|
+
}
|
|
33778
33885
|
}
|
|
33779
33886
|
} else {
|
|
33780
|
-
const
|
|
33887
|
+
const settled = await Promise.allSettled(
|
|
33781
33888
|
scenePrompts.map((p, i) => client2.generate({ prompt: p, images: [master, ...carried.inputs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
|
|
33782
33889
|
);
|
|
33783
|
-
|
|
33890
|
+
settled.forEach((outcome, i) => {
|
|
33891
|
+
if (outcome.status === "fulfilled") named.push({ image: outcome.value, base: sceneName(i) });
|
|
33892
|
+
else record2(outcome.reason, i);
|
|
33893
|
+
});
|
|
33784
33894
|
}
|
|
33785
33895
|
return result;
|
|
33786
33896
|
});
|
|
@@ -33789,6 +33899,10 @@ function registerSetTools(server, client2) {
|
|
|
33789
33899
|
if (masterText) meta3.text = masterText;
|
|
33790
33900
|
if (masterResult.grounding) meta3.grounding = masterResult.grounding;
|
|
33791
33901
|
if (report) meta3.image_inputs = report;
|
|
33902
|
+
if (failed.length) {
|
|
33903
|
+
meta3.failed_scenes = failed;
|
|
33904
|
+
meta3.partial = `${failed.length} of ${failed.length + named.length - 1} scene(s) failed; the master and the ${named.length - 1} scene(s) that succeeded are returned above. Re-run just the failed prompts rather than the whole set \u2014 each failure message is in failed_scenes.`;
|
|
33905
|
+
}
|
|
33792
33906
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length, persistsFiles: client2.mediaSink?.persistsFiles });
|
|
33793
33907
|
if (risk) meta3.timeout_risk = risk;
|
|
33794
33908
|
return emit(named, { ...args, sink: client2.mediaSink }, meta3);
|
|
@@ -33958,6 +34072,7 @@ var GeminiClient = class {
|
|
|
33958
34072
|
session = new SessionState();
|
|
33959
34073
|
constructor(opts = {}) {
|
|
33960
34074
|
this.mediaSink = opts.mediaSink ?? createDiskSink();
|
|
34075
|
+
this.session.jobs.resigner = this.mediaSink;
|
|
33961
34076
|
this.explicitApiKey = opts.apiKey?.trim() || void 0;
|
|
33962
34077
|
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
33963
34078
|
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
@@ -34027,6 +34142,22 @@ var GeminiClient = class {
|
|
|
34027
34142
|
async fetchRemoteImage(url2, opts = {}) {
|
|
34028
34143
|
return fetchRemoteImage(url2, { ...opts, fetchImpl: this.fetchImpl, timeoutMs: resolveTimeoutMs(opts.timeoutMs) });
|
|
34029
34144
|
}
|
|
34145
|
+
/**
|
|
34146
|
+
* Read back media this connector generated, straight out of its own store.
|
|
34147
|
+
*
|
|
34148
|
+
* The alternative — fetching our own `/media` URL — needs a signature the
|
|
34149
|
+
* caller cannot mint and returns 403, which is the loop this exists to close.
|
|
34150
|
+
*/
|
|
34151
|
+
async readStoredMedia(key) {
|
|
34152
|
+
const stored = await this.mediaSink.read?.(key.trim());
|
|
34153
|
+
if (!stored) {
|
|
34154
|
+
throw new McpToolError(
|
|
34155
|
+
`No stored media for r2_key "${key}" \u2014 objects are removed on a retention schedule, so an old key may be gone. Re-run the generation, or upload the bytes with \`url\` / \`data_base64\`.`,
|
|
34156
|
+
{ hint: "Use the exact media[].r2_key from a generation result." }
|
|
34157
|
+
);
|
|
34158
|
+
}
|
|
34159
|
+
return stored;
|
|
34160
|
+
}
|
|
34030
34161
|
/** The default model after env override (no per-call arg). */
|
|
34031
34162
|
defaultModel() {
|
|
34032
34163
|
return resolveModel(void 0, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
@@ -34508,7 +34639,7 @@ function splitReattachedOutputs(images, writtenOutputs) {
|
|
|
34508
34639
|
}
|
|
34509
34640
|
function registerInteractTools(server, client2) {
|
|
34510
34641
|
const onDiskRuntime = client2.mediaSink?.persistsFiles ?? true;
|
|
34511
|
-
const recoveryDescription = onDiskRuntime ? "If a call times out on the client side, the generation usually still completes: the image plus a `<image>.json` sidecar recording its interaction id land in the output dir, and `continue_last: true` still resumes that interaction \u2014 check the output dir before re-issuing (a re-issue is a second billable generation). If a chained call 404s, this tool re-anchors itself on the prior output image and re-issues un-chained: success is reported as `chain_recovered` (the chain was the problem, and you get your image anyway); a second 404 is reported as the interaction id NOT being the cause (check the model id / files uri instead). " : "
|
|
34642
|
+
const recoveryDescription = onDiskRuntime ? "If a call times out on the client side, the generation usually still completes: the image plus a `<image>.json` sidecar recording its interaction id land in the output dir, and `continue_last: true` still resumes that interaction \u2014 check the output dir before re-issuing (a re-issue is a second billable generation). If a chained call 404s, this tool re-anchors itself on the prior output image and re-issues un-chained: success is reported as `chain_recovered` (the chain was the problem, and you get your image anyway); a second 404 is reported as the interaction id NOT being the cause (check the model id / files uri instead). " : "On this connector each result returns an image URL you can open or share directly (see `media[].url`) \u2014 show it to the user rather than assuming they can see the image. There is no filesystem, so no output dir and no `<image>.json` sidecar: a lost response cannot be recovered from disk and a 404 on a chained call cannot be re-anchored automatically. Capture the returned `interaction_id` from every result, and use `async: true` + gemini_get_result for long generations. ";
|
|
34512
34643
|
server.registerTool(
|
|
34513
34644
|
"gemini_interact",
|
|
34514
34645
|
{
|
|
@@ -34882,13 +35013,16 @@ function registerFileTools(server, client2) {
|
|
|
34882
35013
|
server.registerTool(
|
|
34883
35014
|
"gemini_upload_file",
|
|
34884
35015
|
{
|
|
34885
|
-
description: "Upload an image
|
|
35016
|
+
description: "Upload a file \u2014 an image, reference photo, picture, screenshot, video or audio clip \u2014 to the Gemini Files API ONCE, and get back a reusable `file_uri` (`files/<id>`) to attach to later image, video or music generations. Keywords: upload, upload file, upload image, upload photo, attach, reference image, reference photo, file_uri, files api, image reference, reuse across calls. Use this instead of pasting base64 into a tool call: the reference is a short string, so no image bytes ever enter the conversation, and it can be reused across many generations until it expires (~48h). Provide exactly one of `url` (the server downloads it), `data_base64`" + (onDisk ? ", or `path` (a local file)." : ", or `r2_key` (re-upload media this connector generated, from media[].r2_key in an earlier result).") + (onDisk ? "" : " This connector also accepts raw bytes over HTTP: POST them to /upload with the same Authorization header and a Content-Type of image/*, which avoids base64 entirely."),
|
|
34886
35017
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
34887
35018
|
inputSchema: {
|
|
34888
35019
|
url: external_exports.string().url().optional().describe(
|
|
34889
35020
|
`Public https URL the SERVER downloads and uploads (image/video/audio, up to ${wholeMb(urlMaxBytes)}MB). No bytes pass through the conversation.` + (onDisk ? "" : " Larger files: POST the raw bytes to /upload, which streams instead of buffering.")
|
|
34890
35021
|
),
|
|
34891
35022
|
data_base64: external_exports.string().min(1).optional().describe("Raw base64 or a data: URI. Last resort \u2014 this is the one form that costs model context (~14k tokens for a modest JPEG)."),
|
|
35023
|
+
r2_key: external_exports.string().min(1).optional().describe(
|
|
35024
|
+
onDisk ? "Unavailable on this server (media is written to local disk) \u2014 pass the file path via `path` instead." : "An `r2_key` from a previous generation result (media[].r2_key) \u2014 re-uploads media THIS connector generated, so a generated image can become a reference image in one cheap call. The server reads its own bucket directly: no HTTP request, no signed URL, and no expiry to worry about. Do NOT pass a /media URL as `url` for this \u2014 that would be the connector fetching itself and needs a signature."
|
|
35025
|
+
),
|
|
34892
35026
|
path: external_exports.string().min(1).optional().describe(
|
|
34893
35027
|
onDisk ? "Path to a local file (absolute, or resolved against $GEMINI_INPUT_DIR). Confirm-gated like every other local-file input." : "Unavailable on this hosted connector (no filesystem) \u2014 use `url`, `data_base64`, or POST /upload."
|
|
34894
35028
|
),
|
|
@@ -34898,10 +35032,10 @@ function registerFileTools(server, client2) {
|
|
|
34898
35032
|
}
|
|
34899
35033
|
},
|
|
34900
35034
|
async (args, extra) => {
|
|
34901
|
-
const sources = [args.url && "url", args.data_base64 && "data_base64", args.path && "path"].filter(Boolean);
|
|
35035
|
+
const sources = [args.url && "url", args.data_base64 && "data_base64", args.r2_key && "r2_key", args.path && "path"].filter(Boolean);
|
|
34902
35036
|
if (sources.length !== 1) {
|
|
34903
35037
|
throw new McpToolError(
|
|
34904
|
-
sources.length === 0 ? "Provide exactly one of `url`, `data_base64`" + (onDisk ? " or `path`." : "
|
|
35038
|
+
sources.length === 0 ? "Provide exactly one of `url`, `data_base64`" + (onDisk ? " or `path`." : " or `r2_key`.") : `Provide exactly one source, not ${sources.length} (${sources.join(", ")}).`
|
|
34905
35039
|
);
|
|
34906
35040
|
}
|
|
34907
35041
|
if (args.path) {
|
|
@@ -34930,6 +35064,19 @@ function registerFileTools(server, client2) {
|
|
|
34930
35064
|
args.display_name ?? fileNameFromUrl(args.url, "upload")
|
|
34931
35065
|
);
|
|
34932
35066
|
}
|
|
35067
|
+
if (args.r2_key) {
|
|
35068
|
+
if (onDisk) {
|
|
35069
|
+
throw new McpToolError(
|
|
35070
|
+
"`r2_key` is only available on the hosted connector \u2014 this server writes media to local disk, so pass the generated file via `path` instead."
|
|
35071
|
+
);
|
|
35072
|
+
}
|
|
35073
|
+
const stored = await client2.readStoredMedia(args.r2_key);
|
|
35074
|
+
return client2.uploadBytes(
|
|
35075
|
+
stored.bytes,
|
|
35076
|
+
args.mime_type ?? stored.mimeType,
|
|
35077
|
+
args.display_name ?? downloadFilename(args.r2_key)
|
|
35078
|
+
);
|
|
35079
|
+
}
|
|
34933
35080
|
if (args.data_base64) {
|
|
34934
35081
|
const { decodeImageInput: decodeImageInput2 } = await Promise.resolve().then(() => (init_images(), images_exports));
|
|
34935
35082
|
const decoded = decodeImageInput2(args.data_base64);
|
|
@@ -34952,7 +35099,7 @@ function registerFileTools(server, client2) {
|
|
|
34952
35099
|
server.registerTool(
|
|
34953
35100
|
"gemini_list_files",
|
|
34954
35101
|
{
|
|
34955
|
-
description: "List
|
|
35102
|
+
description: "List files, images and photos currently uploaded to the Gemini Files API under this API key, with their reusable `file_uri` (`files/<id>`) references, MIME types and expiry times. Retention is ~48h, so an entry that has vanished has expired rather than failed.",
|
|
34956
35103
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
34957
35104
|
inputSchema: {
|
|
34958
35105
|
page_size: external_exports.number().int().positive().max(100).optional().describe("Maximum files to return (1-100, default 100)")
|
|
@@ -34976,10 +35123,44 @@ function registerFileTools(server, client2) {
|
|
|
34976
35123
|
});
|
|
34977
35124
|
}
|
|
34978
35125
|
);
|
|
35126
|
+
if (!onDisk) {
|
|
35127
|
+
server.registerTool(
|
|
35128
|
+
"gemini_sign_media",
|
|
35129
|
+
{
|
|
35130
|
+
description: "Mint a FRESH signed URL for media this connector generated, from its `r2_key`. Signed media links expire well before the object behind them is cleaned up, so an expired link is not a dead end \u2014 re-sign it instead of re-generating (and re-paying for) the image. Keywords: expired link, refresh url, re-sign, media url, download again.",
|
|
35131
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
35132
|
+
inputSchema: {
|
|
35133
|
+
r2_key: external_exports.string().min(1).describe("The `media[].r2_key` from an earlier generation result")
|
|
35134
|
+
}
|
|
35135
|
+
},
|
|
35136
|
+
async (args) => {
|
|
35137
|
+
const sink = client2.mediaSink;
|
|
35138
|
+
if (!sink?.resign) {
|
|
35139
|
+
throw new McpToolError("This server does not serve media URLs \u2014 generated files are written to disk and the path in the result is already usable.", {
|
|
35140
|
+
hint: "gemini_sign_media applies to the hosted connector only."
|
|
35141
|
+
});
|
|
35142
|
+
}
|
|
35143
|
+
const fresh = await sink.resign(args.r2_key.trim());
|
|
35144
|
+
if (!fresh) {
|
|
35145
|
+
throw new McpToolError(
|
|
35146
|
+
`No stored media for r2_key "${args.r2_key}" \u2014 it has probably passed its retention window. Re-run the generation to get a new one.`,
|
|
35147
|
+
{ hint: "Objects are swept on a retention schedule (MEDIA_TTL_DAYS, default 7 days)." }
|
|
35148
|
+
);
|
|
35149
|
+
}
|
|
35150
|
+
return textResult({
|
|
35151
|
+
url: fresh.ref,
|
|
35152
|
+
r2_key: fresh.key,
|
|
35153
|
+
...fresh.expiresAt ? { expires_at: fresh.expiresAt } : {},
|
|
35154
|
+
curl_hint: `curl -sS -o ${downloadFilename(fresh.key ?? "media")} "${fresh.ref}"`,
|
|
35155
|
+
hint: "In a Claude chat client, download this URL into your sandbox and present it as an output file \u2014 inline image blocks and markdown embeds do not render for the end user."
|
|
35156
|
+
});
|
|
35157
|
+
}
|
|
35158
|
+
);
|
|
35159
|
+
}
|
|
34979
35160
|
server.registerTool(
|
|
34980
35161
|
"gemini_delete_file",
|
|
34981
35162
|
{
|
|
34982
|
-
description: "Delete an uploaded file from the Gemini Files API before its ~48h expiry. Any tool call still referencing it will then fail with a generic 404, so delete only references you are finished with.",
|
|
35163
|
+
description: "Delete an uploaded file, image or photo (by file_uri) from the Gemini Files API before its ~48h expiry. Any tool call still referencing it will then fail with a generic 404, so delete only references you are finished with.",
|
|
34983
35164
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
34984
35165
|
inputSchema: {
|
|
34985
35166
|
file_uri: external_exports.string().min(1).describe("The `files/<id>` reference (or full uri) to delete"),
|
package/dist/client.js
CHANGED
|
@@ -155,6 +155,9 @@ export class GeminiClient {
|
|
|
155
155
|
session = new SessionState();
|
|
156
156
|
constructor(opts = {}) {
|
|
157
157
|
this.mediaSink = opts.mediaSink ?? createDiskSink();
|
|
158
|
+
// A replayed idempotent result must not hand back an expired media URL, so
|
|
159
|
+
// the job registry needs a way to re-sign from the stable r2_key.
|
|
160
|
+
this.session.jobs.resigner = this.mediaSink;
|
|
158
161
|
this.explicitApiKey = opts.apiKey?.trim() || undefined;
|
|
159
162
|
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
160
163
|
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
@@ -227,6 +230,21 @@ export class GeminiClient {
|
|
|
227
230
|
// one unbounded network operation in the server.
|
|
228
231
|
return fetchRemoteImage(url, { ...opts, fetchImpl: this.fetchImpl, timeoutMs: resolveTimeoutMs(opts.timeoutMs) });
|
|
229
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* Read back media this connector generated, straight out of its own store.
|
|
235
|
+
*
|
|
236
|
+
* The alternative — fetching our own `/media` URL — needs a signature the
|
|
237
|
+
* caller cannot mint and returns 403, which is the loop this exists to close.
|
|
238
|
+
*/
|
|
239
|
+
async readStoredMedia(key) {
|
|
240
|
+
const stored = await this.mediaSink.read?.(key.trim());
|
|
241
|
+
if (!stored) {
|
|
242
|
+
// Remediation goes in the MESSAGE: MCP serialization drops hints.
|
|
243
|
+
throw new McpToolError(`No stored media for r2_key "${key}" — objects are removed on a retention schedule, so an old key may be gone. ` +
|
|
244
|
+
'Re-run the generation, or upload the bytes with `url` / `data_base64`.', { hint: 'Use the exact media[].r2_key from a generation result.' });
|
|
245
|
+
}
|
|
246
|
+
return stored;
|
|
247
|
+
}
|
|
230
248
|
/** The default model after env override (no per-call arg). */
|
|
231
249
|
defaultModel() {
|
|
232
250
|
return resolveModel(undefined, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tell an MCP session which hostname it was reached on.
|
|
3
|
+
*
|
|
4
|
+
* This is the single load-bearing link in the zero-config media-URL path: the
|
|
5
|
+
* origin captured here becomes `https://<host>/media/…` for every generated
|
|
6
|
+
* image. If it goes missing, generations silently stop coming back with
|
|
7
|
+
* openable links — the exact failure the media work exists to end — so it lives
|
|
8
|
+
* in its own module, free of any Cloudflare import, purely so it can be tested.
|
|
9
|
+
* (`src/worker.ts` pulls in `agents` and cannot load under Node at all.)
|
|
10
|
+
*
|
|
11
|
+
* Deriving the origin per request rather than configuring it is what makes a
|
|
12
|
+
* fork, a `*.workers.dev` preview and the custom domain all mint correct links
|
|
13
|
+
* with nothing to set. It rides on `ctx.props` — the object the OAuth provider
|
|
14
|
+
* has already populated with the decrypted grant — rather than a module-level
|
|
15
|
+
* variable, because one isolate serves many authenticated sessions (session.ts).
|
|
16
|
+
*/
|
|
17
|
+
/** Property name the session reads back off its props. */
|
|
18
|
+
export const CONNECTOR_ORIGIN_PROP = 'connectorOrigin';
|
|
19
|
+
/**
|
|
20
|
+
* Wrap a handler so `ctx.props.connectorOrigin` is set before it runs.
|
|
21
|
+
*
|
|
22
|
+
* Existing props are preserved — they carry the user's API key, and dropping
|
|
23
|
+
* them would break authentication rather than merely lose a URL.
|
|
24
|
+
*/
|
|
25
|
+
export function withConnectorOrigin(inner) {
|
|
26
|
+
return {
|
|
27
|
+
fetch(request, env, ctx) {
|
|
28
|
+
const carrier = (ctx ?? {});
|
|
29
|
+
const existing = (typeof carrier.props === 'object' && carrier.props !== null ? carrier.props : {});
|
|
30
|
+
carrier.props = { ...existing, [CONNECTOR_ORIGIN_PROP]: new URL(request.url).origin };
|
|
31
|
+
return inner.fetch(request, env, ctx);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|