@chrischall/gemini-mcp 1.3.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 +12 -2
- package/dist/bundle.js +197 -28
- package/dist/client.js +18 -0
- package/dist/jobs.js +77 -1
- package/dist/media-endpoint.js +103 -15
- package/dist/storage/media.js +39 -1
- package/dist/tools/files.js +61 -3
- package/dist/tools/generate.js +42 -21
- package/dist/tools/set.js +41 -5
- package/dist/tools/shared.js +7 -0
- 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,7 @@ 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 |
|
|
89
90
|
|
|
90
91
|
## Seeing your images (hosted connector)
|
|
91
92
|
|
|
@@ -96,13 +97,22 @@ something you can *open*. It does: **every result includes a URL**, with no conf
|
|
|
96
97
|
{
|
|
97
98
|
"images": ["https://connector.example.com/media/gen/2026-07-29/ab12cd34-a-cat.png?exp=…&sig=…"],
|
|
98
99
|
"media": [{ "url": "https://…", "r2_key": "gen/2026-07-29/ab12cd34-a-cat.png",
|
|
99
|
-
"expires_at": "2026-07-31T12:00:00.000Z"
|
|
100
|
+
"expires_at": "2026-07-31T12:00:00.000Z",
|
|
101
|
+
"curl_hint": "curl -sS -o a-cat.png \"https://…\"" }]
|
|
100
102
|
}
|
|
101
103
|
```
|
|
102
104
|
|
|
103
105
|
Those links need no auth header — the signature is in the URL — so they work in a browser, in
|
|
104
106
|
`curl`, and in a chat message. They expire (48h by default) and the objects behind them are
|
|
105
|
-
swept on a retention schedule.
|
|
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.
|
|
106
116
|
|
|
107
117
|
**Why this matters:** MCP's inline image content blocks (`inline: true`) are visible to the
|
|
108
118
|
*assistant* but many chat clients never render them to the user, and the assistant cannot
|
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();
|
|
@@ -33221,6 +33230,14 @@ function createDiskSink() {
|
|
|
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"];
|
|
@@ -33361,7 +33378,13 @@ async function emitMedia(named, kind, opts, meta3, onWritten) {
|
|
|
33361
33378
|
media: persisted.map((p) => ({
|
|
33362
33379
|
url: p.ref,
|
|
33363
33380
|
...p.key ? { r2_key: p.key } : {},
|
|
33364
|
-
...p.expiresAt ? { expires_at: p.expiresAt } : {}
|
|
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}"` } : {}
|
|
33365
33388
|
}))
|
|
33366
33389
|
} : {};
|
|
33367
33390
|
const storage = sink.persistsFiles ? {} : { storage: sink.kind, storage_note: storageNote };
|
|
@@ -33396,6 +33419,47 @@ function isExpired(entry, now) {
|
|
|
33396
33419
|
function fingerprintRequest(toolName, parts) {
|
|
33397
33420
|
return createHash("sha256").update(`${toolName}\0${JSON.stringify(parts)}`).digest("hex");
|
|
33398
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
|
+
}
|
|
33399
33463
|
function annotateReused(result, jobId) {
|
|
33400
33464
|
const content = result.content ?? [];
|
|
33401
33465
|
const first = content[0];
|
|
@@ -33423,6 +33487,12 @@ function jobHandle(jobId, status) {
|
|
|
33423
33487
|
});
|
|
33424
33488
|
}
|
|
33425
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;
|
|
33426
33496
|
jobs = /* @__PURE__ */ new Map();
|
|
33427
33497
|
byKey = /* @__PURE__ */ new Map();
|
|
33428
33498
|
// idempotencyKey -> jobId
|
|
@@ -33482,7 +33552,7 @@ var JobRegistry = class {
|
|
|
33482
33552
|
}
|
|
33483
33553
|
if (hit) {
|
|
33484
33554
|
if (async) return jobHandle(hit.jobId, hit.status);
|
|
33485
|
-
return annotateReused(await hit.promise, hit.jobId);
|
|
33555
|
+
return annotateReused(await refreshMedia(await hit.promise, this.resigner), hit.jobId);
|
|
33486
33556
|
}
|
|
33487
33557
|
const jobId = randomUUID();
|
|
33488
33558
|
this.evict(now);
|
|
@@ -33610,31 +33680,43 @@ function registerGenerateTools(server, client2) {
|
|
|
33610
33680
|
const named = [];
|
|
33611
33681
|
let capturedText;
|
|
33612
33682
|
let capturedGrounding;
|
|
33683
|
+
const failed = [];
|
|
33684
|
+
let firstError;
|
|
33613
33685
|
await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : "image"} (${model})`, async () => {
|
|
33614
33686
|
for (let i = 0; i < count; i++) {
|
|
33615
|
-
|
|
33616
|
-
|
|
33617
|
-
|
|
33618
|
-
|
|
33619
|
-
|
|
33620
|
-
|
|
33621
|
-
|
|
33622
|
-
|
|
33623
|
-
|
|
33624
|
-
|
|
33625
|
-
|
|
33626
|
-
|
|
33627
|
-
|
|
33628
|
-
|
|
33629
|
-
|
|
33630
|
-
|
|
33631
|
-
|
|
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
|
+
}
|
|
33632
33709
|
}
|
|
33633
33710
|
});
|
|
33711
|
+
if (named.length === 0) throw firstError;
|
|
33634
33712
|
const meta3 = buildMeta(model, seed, args);
|
|
33635
33713
|
if (capturedText) meta3.text = capturedText;
|
|
33636
33714
|
if (capturedGrounding) meta3.grounding = capturedGrounding;
|
|
33637
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
|
+
}
|
|
33638
33720
|
if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
|
|
33639
33721
|
meta3.hint = REFINE_HINT;
|
|
33640
33722
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, count, persistsFiles: client2.mediaSink?.persistsFiles });
|
|
@@ -33768,6 +33850,7 @@ function registerSetTools(server, client2) {
|
|
|
33768
33850
|
client2
|
|
33769
33851
|
);
|
|
33770
33852
|
const masterRefInputs = [...masterOnly.inputs, ...carried.inputs];
|
|
33853
|
+
const failed = [];
|
|
33771
33854
|
const report = carried.report || masterOnly.report ? { ...masterOnly.report, ...carried.report } : void 0;
|
|
33772
33855
|
const named = [];
|
|
33773
33856
|
const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
|
|
@@ -33781,18 +33864,33 @@ function registerSetTools(server, client2) {
|
|
|
33781
33864
|
named.push({ image: master, base: `${slug}-master` });
|
|
33782
33865
|
const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
|
|
33783
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
|
+
};
|
|
33784
33875
|
if (mode === "chain") {
|
|
33785
33876
|
let ref = master;
|
|
33786
33877
|
for (let i = 0; i < scenePrompts.length; i++) {
|
|
33787
|
-
|
|
33788
|
-
|
|
33789
|
-
|
|
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
|
+
}
|
|
33790
33885
|
}
|
|
33791
33886
|
} else {
|
|
33792
|
-
const
|
|
33887
|
+
const settled = await Promise.allSettled(
|
|
33793
33888
|
scenePrompts.map((p, i) => client2.generate({ prompt: p, images: [master, ...carried.inputs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
|
|
33794
33889
|
);
|
|
33795
|
-
|
|
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
|
+
});
|
|
33796
33894
|
}
|
|
33797
33895
|
return result;
|
|
33798
33896
|
});
|
|
@@ -33801,6 +33899,10 @@ function registerSetTools(server, client2) {
|
|
|
33801
33899
|
if (masterText) meta3.text = masterText;
|
|
33802
33900
|
if (masterResult.grounding) meta3.grounding = masterResult.grounding;
|
|
33803
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
|
+
}
|
|
33804
33906
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length, persistsFiles: client2.mediaSink?.persistsFiles });
|
|
33805
33907
|
if (risk) meta3.timeout_risk = risk;
|
|
33806
33908
|
return emit(named, { ...args, sink: client2.mediaSink }, meta3);
|
|
@@ -33970,6 +34072,7 @@ var GeminiClient = class {
|
|
|
33970
34072
|
session = new SessionState();
|
|
33971
34073
|
constructor(opts = {}) {
|
|
33972
34074
|
this.mediaSink = opts.mediaSink ?? createDiskSink();
|
|
34075
|
+
this.session.jobs.resigner = this.mediaSink;
|
|
33973
34076
|
this.explicitApiKey = opts.apiKey?.trim() || void 0;
|
|
33974
34077
|
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
33975
34078
|
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
@@ -34039,6 +34142,22 @@ var GeminiClient = class {
|
|
|
34039
34142
|
async fetchRemoteImage(url2, opts = {}) {
|
|
34040
34143
|
return fetchRemoteImage(url2, { ...opts, fetchImpl: this.fetchImpl, timeoutMs: resolveTimeoutMs(opts.timeoutMs) });
|
|
34041
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
|
+
}
|
|
34042
34161
|
/** The default model after env override (no per-call arg). */
|
|
34043
34162
|
defaultModel() {
|
|
34044
34163
|
return resolveModel(void 0, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
@@ -34894,13 +35013,16 @@ function registerFileTools(server, client2) {
|
|
|
34894
35013
|
server.registerTool(
|
|
34895
35014
|
"gemini_upload_file",
|
|
34896
35015
|
{
|
|
34897
|
-
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)." : ".") + (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."),
|
|
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."),
|
|
34898
35017
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
34899
35018
|
inputSchema: {
|
|
34900
35019
|
url: external_exports.string().url().optional().describe(
|
|
34901
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.")
|
|
34902
35021
|
),
|
|
34903
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
|
+
),
|
|
34904
35026
|
path: external_exports.string().min(1).optional().describe(
|
|
34905
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."
|
|
34906
35028
|
),
|
|
@@ -34910,10 +35032,10 @@ function registerFileTools(server, client2) {
|
|
|
34910
35032
|
}
|
|
34911
35033
|
},
|
|
34912
35034
|
async (args, extra) => {
|
|
34913
|
-
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);
|
|
34914
35036
|
if (sources.length !== 1) {
|
|
34915
35037
|
throw new McpToolError(
|
|
34916
|
-
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(", ")}).`
|
|
34917
35039
|
);
|
|
34918
35040
|
}
|
|
34919
35041
|
if (args.path) {
|
|
@@ -34942,6 +35064,19 @@ function registerFileTools(server, client2) {
|
|
|
34942
35064
|
args.display_name ?? fileNameFromUrl(args.url, "upload")
|
|
34943
35065
|
);
|
|
34944
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
|
+
}
|
|
34945
35080
|
if (args.data_base64) {
|
|
34946
35081
|
const { decodeImageInput: decodeImageInput2 } = await Promise.resolve().then(() => (init_images(), images_exports));
|
|
34947
35082
|
const decoded = decodeImageInput2(args.data_base64);
|
|
@@ -34988,6 +35123,40 @@ function registerFileTools(server, client2) {
|
|
|
34988
35123
|
});
|
|
34989
35124
|
}
|
|
34990
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
|
+
}
|
|
34991
35160
|
server.registerTool(
|
|
34992
35161
|
"gemini_delete_file",
|
|
34993
35162
|
{
|
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'));
|
package/dist/jobs.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID, createHash } from 'node:crypto';
|
|
2
2
|
import { textResult, McpToolError } from '@chrischall/mcp-utils';
|
|
3
|
+
import { downloadFilename } from './media-endpoint.js';
|
|
3
4
|
/** Recently-completed jobs are reusable by key for this long, then evicted. */
|
|
4
5
|
const JOB_TTL_MS = 600_000; // 10 min
|
|
5
6
|
/** Hard cap on retained entries (oldest-settled evicted first). */
|
|
@@ -22,6 +23,72 @@ function isExpired(entry, now) {
|
|
|
22
23
|
export function fingerprintRequest(toolName, parts) {
|
|
23
24
|
return createHash('sha256').update(`${toolName}\u0000${JSON.stringify(parts)}`).digest('hex');
|
|
24
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Re-mint any media URLs a recorded result carries, before it is replayed.
|
|
28
|
+
*
|
|
29
|
+
* A recorded result can outlive the signed URLs inside it — and after a change
|
|
30
|
+
* to the result shape it can even carry the OLD shape entirely. Either way the
|
|
31
|
+
* caller gets something that looks right and does not work. Re-signing from the
|
|
32
|
+
* `r2_key`, which is stable, fixes both: an expired link becomes a live one,
|
|
33
|
+
* and a legacy entry gains the `url`/`curl_hint` fields the current shape has.
|
|
34
|
+
*
|
|
35
|
+
* Best-effort by design. A replay that cannot be refreshed is still a valid
|
|
36
|
+
* replay — the point of idempotency is not billing twice, and failing here
|
|
37
|
+
* would trade a stale URL for no result at all.
|
|
38
|
+
*/
|
|
39
|
+
async function refreshMedia(result, sink) {
|
|
40
|
+
const first = result.content?.[0];
|
|
41
|
+
if (!sink?.resign || !first || first.type !== 'text')
|
|
42
|
+
return result;
|
|
43
|
+
let body;
|
|
44
|
+
try {
|
|
45
|
+
body = JSON.parse(first.text);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
const entries = Array.isArray(body.media) ? body.media : [];
|
|
51
|
+
if (!entries.some((e) => typeof e.r2_key === 'string'))
|
|
52
|
+
return result;
|
|
53
|
+
// Per ENTRY, not per key: filtering first would shift every assignment after
|
|
54
|
+
// an entry without an r2_key and truncate the flat list below.
|
|
55
|
+
const refreshed = await Promise.all(entries.map(async (e) => {
|
|
56
|
+
if (typeof e.r2_key !== 'string')
|
|
57
|
+
return undefined;
|
|
58
|
+
try {
|
|
59
|
+
return await sink.resign(e.r2_key);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}));
|
|
65
|
+
if (refreshed.every((r) => r === undefined))
|
|
66
|
+
return result;
|
|
67
|
+
const mediaKeyName = ['images', 'videos', 'audios'].find((k) => Array.isArray(body[k]));
|
|
68
|
+
const refs = [];
|
|
69
|
+
refreshed.forEach((fresh, i) => {
|
|
70
|
+
if (!fresh) {
|
|
71
|
+
refs.push(String(entries[i]?.url ?? ''));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Drop the recorded expiry rather than carrying it over: a re-signed URL
|
|
75
|
+
// has its own deadline (or none, on a public-bucket sink), and the old one
|
|
76
|
+
// would mislabel a live link as expired.
|
|
77
|
+
const { expires_at: _stale, ...rest } = entries[i];
|
|
78
|
+
entries[i] = {
|
|
79
|
+
...rest,
|
|
80
|
+
url: fresh.ref,
|
|
81
|
+
...(fresh.expiresAt ? { expires_at: fresh.expiresAt } : {}),
|
|
82
|
+
curl_hint: `curl -sS -o ${downloadFilename(fresh.key ?? 'media')} "${fresh.ref}"`,
|
|
83
|
+
};
|
|
84
|
+
refs.push(fresh.ref);
|
|
85
|
+
});
|
|
86
|
+
body.media = entries;
|
|
87
|
+
if (mediaKeyName)
|
|
88
|
+
body[mediaKeyName] = refs;
|
|
89
|
+
body.media_urls_refreshed = true;
|
|
90
|
+
return { ...result, content: [{ ...first, text: JSON.stringify(body, null, 2) }, ...(result.content ?? []).slice(1)] };
|
|
91
|
+
}
|
|
25
92
|
/** Clone `result`, adding `reused: true` + `reused_job_id` to its meta block. */
|
|
26
93
|
function annotateReused(result, jobId) {
|
|
27
94
|
const content = result.content ?? [];
|
|
@@ -58,6 +125,12 @@ function jobHandle(jobId, status) {
|
|
|
58
125
|
* `client.session.jobs`.
|
|
59
126
|
*/
|
|
60
127
|
export class JobRegistry {
|
|
128
|
+
/**
|
|
129
|
+
* Lets a replayed result have its media URLs re-minted. Set by the session
|
|
130
|
+
* that owns this registry; absent on stdio, where refs are file paths that
|
|
131
|
+
* never expire.
|
|
132
|
+
*/
|
|
133
|
+
resigner;
|
|
61
134
|
jobs = new Map();
|
|
62
135
|
byKey = new Map(); // idempotencyKey -> jobId
|
|
63
136
|
runningByFingerprint = new Map(); // fingerprint -> jobId (only while running)
|
|
@@ -126,7 +199,10 @@ export class JobRegistry {
|
|
|
126
199
|
if (hit) {
|
|
127
200
|
if (async)
|
|
128
201
|
return jobHandle(hit.jobId, hit.status);
|
|
129
|
-
|
|
202
|
+
// Refresh before annotating: a recorded result can outlive the signed
|
|
203
|
+
// URLs inside it, and replaying an expired link is its own kind of wrong
|
|
204
|
+
// answer — it looks like success.
|
|
205
|
+
return annotateReused(await refreshMedia(await hit.promise, this.resigner), hit.jobId);
|
|
130
206
|
}
|
|
131
207
|
const jobId = randomUUID();
|
|
132
208
|
this.evict(now);
|
package/dist/media-endpoint.js
CHANGED
|
@@ -1,4 +1,75 @@
|
|
|
1
1
|
import { loadSigningKey, verifyMediaSignature } from './media-url.js';
|
|
2
|
+
/**
|
|
3
|
+
* Human-friendly download name from an object key.
|
|
4
|
+
*
|
|
5
|
+
* Keys look like `gen/<tenant>/2026-07-29/ab12cd34-a-cat.png`; the random component is
|
|
6
|
+
* there to make the key unique, not to be read. Stripping it turns a saved file
|
|
7
|
+
* into `a-cat.png`, which matters because the reliable way to show a user an
|
|
8
|
+
* image in a chat client is for the agent to download it and attach it — and an
|
|
9
|
+
* attachment called `ab12cd34-a-cat.png` looks like a machine artefact.
|
|
10
|
+
*/
|
|
11
|
+
export function downloadFilename(key) {
|
|
12
|
+
const last = key.split('/').pop() || 'media';
|
|
13
|
+
const stripped = last.replace(/^[0-9a-f]{6,12}-/i, '');
|
|
14
|
+
return stripped || last;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Parse a single-range `Range: bytes=a-b` header. Multi-range is unsupported,
|
|
18
|
+
* and an unknown object size means no range handling at all (Range is advisory
|
|
19
|
+
* — `undefined` degrades to a full 200, never an error).
|
|
20
|
+
*/
|
|
21
|
+
function parseRange(header, size) {
|
|
22
|
+
if (!header || size === undefined)
|
|
23
|
+
return undefined;
|
|
24
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
25
|
+
if (!match)
|
|
26
|
+
return undefined;
|
|
27
|
+
const [, startText, endText] = match;
|
|
28
|
+
if (startText === '' && endText === '')
|
|
29
|
+
return undefined;
|
|
30
|
+
if (startText === '') {
|
|
31
|
+
// Suffix range: the last N bytes.
|
|
32
|
+
const suffix = Number(endText);
|
|
33
|
+
if (!suffix)
|
|
34
|
+
return undefined;
|
|
35
|
+
const offset = Math.max(0, size - suffix);
|
|
36
|
+
return { offset, length: size - offset };
|
|
37
|
+
}
|
|
38
|
+
const offset = Number(startText);
|
|
39
|
+
if (offset >= size)
|
|
40
|
+
return 'unsatisfiable';
|
|
41
|
+
const end = endText === '' ? size - 1 : Number(endText);
|
|
42
|
+
if (end < offset)
|
|
43
|
+
return 'unsatisfiable';
|
|
44
|
+
return { offset, length: end - offset + 1 };
|
|
45
|
+
}
|
|
46
|
+
const MISSING = 'That media object no longer exists — it may have been cleaned up. Re-run the generation to get a fresh one.';
|
|
47
|
+
/**
|
|
48
|
+
* Response headers shared by GET, HEAD and 206. `Content-Disposition` carries a
|
|
49
|
+
* readable filename because the reliable way to show a user an image in a chat
|
|
50
|
+
* client is for the agent to download it and attach it.
|
|
51
|
+
*/
|
|
52
|
+
function baseHeaders(key, object, size) {
|
|
53
|
+
const headers = new Headers({
|
|
54
|
+
'content-type': object.httpMetadata?.contentType ?? 'application/octet-stream',
|
|
55
|
+
// The URL is signed and expiring, so it is safe to cache for its lifetime;
|
|
56
|
+
// `private` keeps shared caches from serving it to anyone else.
|
|
57
|
+
'cache-control': 'private, max-age=3600',
|
|
58
|
+
'content-disposition': `inline; filename="${downloadFilename(key)}"`,
|
|
59
|
+
// The bytes are model output being handed to a browser — never let a
|
|
60
|
+
// sniffed content type turn an image response into something executable.
|
|
61
|
+
'x-content-type-options': 'nosniff',
|
|
62
|
+
// Range support is cheap on R2 and is what makes a resumable `curl -C -`
|
|
63
|
+
// work against a large video.
|
|
64
|
+
'accept-ranges': 'bytes',
|
|
65
|
+
});
|
|
66
|
+
const etag = object.httpEtag ?? object.etag;
|
|
67
|
+
if (etag)
|
|
68
|
+
headers.set('etag', etag);
|
|
69
|
+
if (size !== undefined)
|
|
70
|
+
headers.set('content-length', String(size));
|
|
71
|
+
return headers;
|
|
72
|
+
}
|
|
2
73
|
function problem(status, message) {
|
|
3
74
|
return new Response(`${message}\n`, {
|
|
4
75
|
status,
|
|
@@ -49,24 +120,41 @@ export function createMediaHandler(env, basePath = '/media/') {
|
|
|
49
120
|
}
|
|
50
121
|
return problem(403, 'Invalid or missing media signature. Media links must be used exactly as returned by the tool, including the exp and sig parameters.');
|
|
51
122
|
}
|
|
123
|
+
const rangeHeader = request.headers.get('range');
|
|
124
|
+
// HEAD is what a link-preview fetcher and `curl -I` send first; serve it
|
|
125
|
+
// from a metadata read where the bucket offers one, rather than opening a
|
|
126
|
+
// body we are about to throw away.
|
|
127
|
+
if (request.method === 'HEAD' && env.bucket.head) {
|
|
128
|
+
const meta = await env.bucket.head(key);
|
|
129
|
+
if (!meta)
|
|
130
|
+
return problem(404, MISSING);
|
|
131
|
+
return new Response(null, { status: 200, headers: baseHeaders(key, meta, meta.size) });
|
|
132
|
+
}
|
|
133
|
+
// Fetch whole and slice on the way out rather than pushing the range into
|
|
134
|
+
// R2: the objects here are single images and short clips, and a typed
|
|
135
|
+
// range option is not expressible against both the real binding and a test
|
|
136
|
+
// fake without casting away the very types that make this safe.
|
|
52
137
|
const object = await env.bucket.get(key);
|
|
53
|
-
if (!object)
|
|
54
|
-
return problem(404,
|
|
138
|
+
if (!object)
|
|
139
|
+
return problem(404, MISSING);
|
|
140
|
+
const totalSize = object.size;
|
|
141
|
+
const range = parseRange(rangeHeader, totalSize);
|
|
142
|
+
if (range === 'unsatisfiable') {
|
|
143
|
+
return new Response('Requested range not satisfiable\n', {
|
|
144
|
+
status: 416,
|
|
145
|
+
headers: { 'content-range': `bytes */${totalSize ?? 0}`, 'content-type': 'text/plain; charset=utf-8' },
|
|
146
|
+
});
|
|
55
147
|
}
|
|
56
|
-
const headers =
|
|
57
|
-
'content-type': object.httpMetadata?.contentType ?? 'application/octet-stream',
|
|
58
|
-
// The URL is signed and expiring, so it is safe to cache for its lifetime;
|
|
59
|
-
// `private` keeps shared caches from serving it to anyone else.
|
|
60
|
-
'cache-control': 'private, max-age=3600',
|
|
61
|
-
'content-disposition': `inline; filename="${key.split('/').pop() ?? 'media'}"`,
|
|
62
|
-
// The bytes are model output being handed to a browser — never let a
|
|
63
|
-
// sniffed content type turn an image response into something executable.
|
|
64
|
-
'x-content-type-options': 'nosniff',
|
|
65
|
-
});
|
|
66
|
-
if (object.size !== undefined)
|
|
67
|
-
headers.set('content-length', String(object.size));
|
|
148
|
+
const headers = baseHeaders(key, object, totalSize);
|
|
68
149
|
if (request.method === 'HEAD')
|
|
69
150
|
return new Response(null, { status: 200, headers });
|
|
70
|
-
|
|
151
|
+
if (range && totalSize !== undefined && object.arrayBuffer) {
|
|
152
|
+
const length = Math.min(range.length, totalSize - range.offset);
|
|
153
|
+
const slice = (await object.arrayBuffer()).slice(range.offset, range.offset + length);
|
|
154
|
+
headers.set('content-length', String(slice.byteLength));
|
|
155
|
+
headers.set('content-range', `bytes ${range.offset}-${range.offset + slice.byteLength - 1}/${totalSize}`);
|
|
156
|
+
return new Response(slice, { status: 206, headers });
|
|
157
|
+
}
|
|
158
|
+
return new Response(object.body ?? null, { status: 200, headers });
|
|
71
159
|
};
|
|
72
160
|
}
|
package/dist/storage/media.js
CHANGED
|
@@ -36,6 +36,11 @@ export function createDiskSink() {
|
|
|
36
36
|
note: () => undefined,
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
|
+
/** Stable, non-reversible short tenant id from a per-session secret. */
|
|
40
|
+
export async function tenantIdFor(secret) {
|
|
41
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));
|
|
42
|
+
return [...new Uint8Array(digest).slice(0, 6)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
43
|
+
}
|
|
39
44
|
// `gen`, not `media`: the objects are served at `/media/<key>`, and a `media`
|
|
40
45
|
// prefix would make every URL read `/media/media/…`.
|
|
41
46
|
const KEY_PREFIX_DEFAULT = 'gen';
|
|
@@ -62,19 +67,52 @@ export function createR2Sink(bucket, opts) {
|
|
|
62
67
|
const publicBase = opts.publicBaseUrl?.replace(/\/+$/, '');
|
|
63
68
|
const signedBase = opts.signedBaseUrl?.replace(/\/+$/, '');
|
|
64
69
|
const ttlMs = opts.urlTtlMs ?? 0;
|
|
70
|
+
/** `<prefix>/` or `<prefix>/<tenant>/` — every key this sink may touch. */
|
|
71
|
+
let ownPrefixCache;
|
|
72
|
+
const ownPrefix = () => (ownPrefixCache ??= (async () => {
|
|
73
|
+
const tenant = typeof opts.tenant === 'function' ? await opts.tenant() : opts.tenant;
|
|
74
|
+
return tenant ? `${prefix}/${tenant}/` : `${prefix}/`;
|
|
75
|
+
})());
|
|
76
|
+
/**
|
|
77
|
+
* Ownership gate for caller-supplied keys. A foreign key is refused without
|
|
78
|
+
* touching storage, so the refusal is indistinguishable from a swept object.
|
|
79
|
+
* With no tenant configured there is nothing to enforce (single-user shapes).
|
|
80
|
+
*/
|
|
81
|
+
const owned = async (key) => !opts.tenant || key.startsWith(await ownPrefix());
|
|
65
82
|
return {
|
|
66
83
|
kind: 'r2',
|
|
67
84
|
persistsFiles: false,
|
|
85
|
+
async read(key) {
|
|
86
|
+
if (!bucket.get || !(await owned(key)))
|
|
87
|
+
return undefined;
|
|
88
|
+
const object = await bucket.get(key);
|
|
89
|
+
if (!object)
|
|
90
|
+
return undefined;
|
|
91
|
+
return {
|
|
92
|
+
bytes: new Uint8Array(await object.arrayBuffer()),
|
|
93
|
+
mimeType: object.httpMetadata?.contentType ?? 'application/octet-stream',
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
async resign(key) {
|
|
97
|
+
if (!(await owned(key)))
|
|
98
|
+
return undefined;
|
|
99
|
+
// Only for objects that still exist — re-signing a swept key would hand
|
|
100
|
+
// back a link that 404s, which is no better than the expired one.
|
|
101
|
+
if (bucket.get && !(await bucket.get(key)))
|
|
102
|
+
return undefined;
|
|
103
|
+
return describe(key, now().getTime() + ttlMs);
|
|
104
|
+
},
|
|
68
105
|
async persist(items, _opts) {
|
|
69
106
|
// `mediaExt` is pure string work but lives in images.ts next to node:fs
|
|
70
107
|
// imports; the dynamic import keeps that module off a Worker's eager
|
|
71
108
|
// module graph.
|
|
72
109
|
const { mediaExt } = await import('../images.js');
|
|
110
|
+
const keyBase = await ownPrefix();
|
|
73
111
|
const day = now().toISOString().slice(0, 10);
|
|
74
112
|
const expiresAtMs = now().getTime() + ttlMs;
|
|
75
113
|
const refs = [];
|
|
76
114
|
for (const it of items) {
|
|
77
|
-
const key = `${
|
|
115
|
+
const key = `${keyBase}${day}/${randomId()}-${safeKeySegment(it.base)}.${mediaExt(it.mimeType)}`;
|
|
78
116
|
await bucket.put(key, base64ToBytes(it.base64), { httpMetadata: { contentType: it.mimeType } });
|
|
79
117
|
refs.push(await describe(key, expiresAtMs));
|
|
80
118
|
}
|
package/dist/tools/files.js
CHANGED
|
@@ -3,6 +3,7 @@ import { McpToolError, textResult } from '@chrischall/mcp-utils';
|
|
|
3
3
|
import { withProgressHeartbeat } from './shared.js';
|
|
4
4
|
import { base64ToBytes, wholeMb } from '../bytes.js';
|
|
5
5
|
import { fileNameFromUrl } from '../fetch-image.js';
|
|
6
|
+
import { downloadFilename } from '../media-endpoint.js';
|
|
6
7
|
import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
7
8
|
/**
|
|
8
9
|
* First-class Gemini Files API access.
|
|
@@ -70,7 +71,9 @@ export function registerFileTools(server, client) {
|
|
|
70
71
|
'Use this instead of pasting base64 into a tool call: the reference is a short string, so no image bytes ever ' +
|
|
71
72
|
'enter the conversation, and it can be reused across many generations until it expires (~48h). ' +
|
|
72
73
|
'Provide exactly one of `url` (the server downloads it), `data_base64`' +
|
|
73
|
-
(onDisk
|
|
74
|
+
(onDisk
|
|
75
|
+
? ', or `path` (a local file).'
|
|
76
|
+
: ', or `r2_key` (re-upload media this connector generated, from media[].r2_key in an earlier result).') +
|
|
74
77
|
(onDisk
|
|
75
78
|
? ''
|
|
76
79
|
: ' This connector also accepts raw bytes over HTTP: POST them to /upload with the same Authorization header ' +
|
|
@@ -89,6 +92,16 @@ export function registerFileTools(server, client) {
|
|
|
89
92
|
.min(1)
|
|
90
93
|
.optional()
|
|
91
94
|
.describe('Raw base64 or a data: URI. Last resort — this is the one form that costs model context (~14k tokens for a modest JPEG).'),
|
|
95
|
+
r2_key: z
|
|
96
|
+
.string()
|
|
97
|
+
.min(1)
|
|
98
|
+
.optional()
|
|
99
|
+
.describe(onDisk
|
|
100
|
+
? 'Unavailable on this server (media is written to local disk) — pass the file path via `path` instead.'
|
|
101
|
+
: 'An `r2_key` from a previous generation result (media[].r2_key) — re-uploads media THIS connector generated, ' +
|
|
102
|
+
'so a generated image can become a reference image in one cheap call. The server reads its own bucket directly: ' +
|
|
103
|
+
'no HTTP request, no signed URL, and no expiry to worry about. Do NOT pass a /media URL as `url` for this — ' +
|
|
104
|
+
'that would be the connector fetching itself and needs a signature.'),
|
|
92
105
|
path: z
|
|
93
106
|
.string()
|
|
94
107
|
.min(1)
|
|
@@ -105,10 +118,10 @@ export function registerFileTools(server, client) {
|
|
|
105
118
|
confirm: schemaConfirm,
|
|
106
119
|
},
|
|
107
120
|
}, async (args, extra) => {
|
|
108
|
-
const sources = [args.url && 'url', args.data_base64 && 'data_base64', args.path && 'path'].filter(Boolean);
|
|
121
|
+
const sources = [args.url && 'url', args.data_base64 && 'data_base64', args.r2_key && 'r2_key', args.path && 'path'].filter(Boolean);
|
|
109
122
|
if (sources.length !== 1) {
|
|
110
123
|
throw new McpToolError(sources.length === 0
|
|
111
|
-
? 'Provide exactly one of `url`, `data_base64`' + (onDisk ? ' or `path`.' : '
|
|
124
|
+
? 'Provide exactly one of `url`, `data_base64`' + (onDisk ? ' or `path`.' : ' or `r2_key`.')
|
|
112
125
|
: `Provide exactly one source, not ${sources.length} (${sources.join(', ')}).`);
|
|
113
126
|
}
|
|
114
127
|
if (args.path) {
|
|
@@ -131,6 +144,17 @@ export function registerFileTools(server, client) {
|
|
|
131
144
|
// this tool takes video and audio too, so 'image' would be a lie.
|
|
132
145
|
args.display_name ?? fileNameFromUrl(args.url, 'upload'));
|
|
133
146
|
}
|
|
147
|
+
if (args.r2_key) {
|
|
148
|
+
if (onDisk) {
|
|
149
|
+
throw new McpToolError('`r2_key` is only available on the hosted connector — this server writes media to local disk, ' +
|
|
150
|
+
'so pass the generated file via `path` instead.');
|
|
151
|
+
}
|
|
152
|
+
// Reading our own bucket rather than fetching our own /media URL. A
|
|
153
|
+
// self-fetch would need a signature the caller cannot mint, which is
|
|
154
|
+
// exactly the 403 loop this parameter exists to remove.
|
|
155
|
+
const stored = await client.readStoredMedia(args.r2_key);
|
|
156
|
+
return client.uploadBytes(stored.bytes, args.mime_type ?? stored.mimeType, args.display_name ?? downloadFilename(args.r2_key));
|
|
157
|
+
}
|
|
134
158
|
if (args.data_base64) {
|
|
135
159
|
const { decodeImageInput } = await import('../images.js');
|
|
136
160
|
const decoded = decodeImageInput(args.data_base64);
|
|
@@ -171,6 +195,40 @@ export function registerFileTools(server, client) {
|
|
|
171
195
|
: 'No files uploaded (or all have expired — retention is ~48h). Upload one with gemini_upload_file.',
|
|
172
196
|
});
|
|
173
197
|
});
|
|
198
|
+
// Hosted connector only. On a disk-backed server the result carries a path
|
|
199
|
+
// that never expires, so a "refresh my link" tool would only ever error —
|
|
200
|
+
// don't advertise it there (mirrors the Worker not registering video).
|
|
201
|
+
if (!onDisk) {
|
|
202
|
+
server.registerTool('gemini_sign_media', {
|
|
203
|
+
description: 'Mint a FRESH signed URL for media this connector generated, from its `r2_key`. ' +
|
|
204
|
+
'Signed media links expire well before the object behind them is cleaned up, so an expired link is not a dead ' +
|
|
205
|
+
'end — re-sign it instead of re-generating (and re-paying for) the image. Keywords: expired link, refresh url, ' +
|
|
206
|
+
're-sign, media url, download again.',
|
|
207
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
208
|
+
inputSchema: {
|
|
209
|
+
r2_key: z.string().min(1).describe('The `media[].r2_key` from an earlier generation result'),
|
|
210
|
+
},
|
|
211
|
+
}, async (args) => {
|
|
212
|
+
const sink = client.mediaSink;
|
|
213
|
+
if (!sink?.resign) {
|
|
214
|
+
throw new McpToolError('This server does not serve media URLs — generated files are written to disk and the path in the result is already usable.', {
|
|
215
|
+
hint: 'gemini_sign_media applies to the hosted connector only.',
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
const fresh = await sink.resign(args.r2_key.trim());
|
|
219
|
+
if (!fresh) {
|
|
220
|
+
// Remediation goes in the MESSAGE: MCP serialization drops hints.
|
|
221
|
+
throw new McpToolError(`No stored media for r2_key "${args.r2_key}" — it has probably passed its retention window. Re-run the generation to get a new one.`, { hint: 'Objects are swept on a retention schedule (MEDIA_TTL_DAYS, default 7 days).' });
|
|
222
|
+
}
|
|
223
|
+
return textResult({
|
|
224
|
+
url: fresh.ref,
|
|
225
|
+
r2_key: fresh.key,
|
|
226
|
+
...(fresh.expiresAt ? { expires_at: fresh.expiresAt } : {}),
|
|
227
|
+
curl_hint: `curl -sS -o ${downloadFilename(fresh.key ?? 'media')} "${fresh.ref}"`,
|
|
228
|
+
hint: 'In a Claude chat client, download this URL into your sandbox and present it as an output file — inline image blocks and markdown embeds do not render for the end user.',
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
}
|
|
174
232
|
server.registerTool('gemini_delete_file', {
|
|
175
233
|
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 ' +
|
|
176
234
|
'will then fail with a generic 404, so delete only references you are finished with.',
|
package/dist/tools/generate.js
CHANGED
|
@@ -66,31 +66,46 @@ export function registerGenerateTools(server, client) {
|
|
|
66
66
|
// For count>1, surface the FIRST call's grounding (each call grounds
|
|
67
67
|
// independently; one representative set of sources beats concatenating N).
|
|
68
68
|
let capturedGrounding;
|
|
69
|
+
// count>1 is N billed generations in one tools/call, and any one can
|
|
70
|
+
// fail (a safety filter, a transient 5xx). Failures are collected per
|
|
71
|
+
// image so one bad draw cannot lose the ones that succeeded — same
|
|
72
|
+
// contract as gemini_image_set's failed_scenes. Zero successes still
|
|
73
|
+
// throws: an empty success is worse than an error.
|
|
74
|
+
const failed = [];
|
|
75
|
+
let firstError;
|
|
69
76
|
await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : 'image'} (${model})`, async () => {
|
|
70
77
|
for (let i = 0; i < count; i++) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
78
|
+
try {
|
|
79
|
+
// Distinct seed per image (seed+0 for the single-image case == echoed seed)
|
|
80
|
+
// so count>1 yields N *different* images, not N duplicates.
|
|
81
|
+
const result = await client.generate({
|
|
82
|
+
prompt: args.prompt,
|
|
83
|
+
images: refInputs.length > 0 ? refInputs : undefined,
|
|
84
|
+
model: args.model,
|
|
85
|
+
aspectRatio: args.aspect_ratio,
|
|
86
|
+
imageSize: args.image_size,
|
|
87
|
+
seed: seed + i,
|
|
88
|
+
thinkingLevel: args.thinking_level,
|
|
89
|
+
googleSearch: args.google_search,
|
|
90
|
+
videoUrl: video.videoUrl,
|
|
91
|
+
videoMimeType: video.videoMimeType,
|
|
92
|
+
timeoutMs: args.timeout_ms,
|
|
93
|
+
});
|
|
94
|
+
const { images: [img], text } = result;
|
|
95
|
+
if (text && !capturedText)
|
|
96
|
+
capturedText = text;
|
|
97
|
+
if (result.grounding && !capturedGrounding)
|
|
98
|
+
capturedGrounding = result.grounding;
|
|
99
|
+
named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, '0')}` });
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
firstError ??= err;
|
|
103
|
+
failed.push({ image: i + 1, error: err instanceof Error ? err.message : String(err) });
|
|
104
|
+
}
|
|
92
105
|
}
|
|
93
106
|
});
|
|
107
|
+
if (named.length === 0)
|
|
108
|
+
throw firstError;
|
|
94
109
|
const meta = buildMeta(model, seed, args);
|
|
95
110
|
if (capturedText)
|
|
96
111
|
meta.text = capturedText;
|
|
@@ -98,6 +113,12 @@ export function registerGenerateTools(server, client) {
|
|
|
98
113
|
meta.grounding = capturedGrounding;
|
|
99
114
|
if (report)
|
|
100
115
|
meta.image_inputs = report;
|
|
116
|
+
if (failed.length) {
|
|
117
|
+
meta.failed_images = failed;
|
|
118
|
+
meta.partial =
|
|
119
|
+
`${failed.length} of ${count} image(s) failed; the ${named.length} that succeeded are returned above. ` +
|
|
120
|
+
'Re-run with count set to the shortfall rather than the whole batch — each failure message is in failed_images.';
|
|
121
|
+
}
|
|
101
122
|
if (video.videoFileMeta)
|
|
102
123
|
meta.video_file = video.videoFileMeta;
|
|
103
124
|
meta.hint = REFINE_HINT;
|
package/dist/tools/set.js
CHANGED
|
@@ -61,6 +61,8 @@ export function registerSetTools(server, client) {
|
|
|
61
61
|
const carried = await resolveImageInputs({ images_url: args.master_images_url, images_file_uris: args.master_images_file_uris }, client);
|
|
62
62
|
const masterOnly = await resolveImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard }, client);
|
|
63
63
|
const masterRefInputs = [...masterOnly.inputs, ...carried.inputs];
|
|
64
|
+
/** Scenes that failed, so one bad scene cannot lose the whole batch. */
|
|
65
|
+
const failed = [];
|
|
64
66
|
const report = carried.report || masterOnly.report
|
|
65
67
|
? { ...masterOnly.report, ...carried.report }
|
|
66
68
|
: undefined;
|
|
@@ -79,17 +81,44 @@ export function registerSetTools(server, client) {
|
|
|
79
81
|
// 2. scene prompts (explicit, or N repeats of master_prompt for variations)
|
|
80
82
|
const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
|
|
81
83
|
const mode = args.reference_mode ?? 'master';
|
|
84
|
+
// A set is N+1 billed generations in one tools/call, and any one of
|
|
85
|
+
// them can fail — a safety filter on a single scene, a transient 5xx.
|
|
86
|
+
// `Promise.all` rejected the whole batch on the first failure, losing
|
|
87
|
+
// the master and every scene that HAD succeeded, and surfaced as an
|
|
88
|
+
// opaque "Error occurred during tool execution" with no indication of
|
|
89
|
+
// which scene or why. Failures are collected per scene instead.
|
|
90
|
+
const sceneName = (i) => `${slug}-${String(i + 1).padStart(2, '0')}`;
|
|
91
|
+
const record = (err, i) => {
|
|
92
|
+
failed.push({
|
|
93
|
+
scene: i + 1,
|
|
94
|
+
prompt: scenePrompts[i],
|
|
95
|
+
error: err instanceof Error ? err.message : String(err),
|
|
96
|
+
});
|
|
97
|
+
};
|
|
82
98
|
if (mode === 'chain') {
|
|
83
99
|
let ref = master;
|
|
84
100
|
for (let i = 0; i < scenePrompts.length; i++) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
101
|
+
try {
|
|
102
|
+
const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref, ...carried.inputs], seed: seed + i + 1, ...cfg });
|
|
103
|
+
named.push({ image: img, base: sceneName(i) });
|
|
104
|
+
// Chain mode anchors each scene on the previous OUTPUT, so a
|
|
105
|
+
// failure must not advance the reference — the next scene
|
|
106
|
+
// continues from the last image that actually exists.
|
|
107
|
+
ref = img;
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
record(err, i);
|
|
111
|
+
}
|
|
88
112
|
}
|
|
89
113
|
}
|
|
90
114
|
else {
|
|
91
|
-
const
|
|
92
|
-
|
|
115
|
+
const settled = await Promise.allSettled(scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master, ...carried.inputs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0])));
|
|
116
|
+
settled.forEach((outcome, i) => {
|
|
117
|
+
if (outcome.status === 'fulfilled')
|
|
118
|
+
named.push({ image: outcome.value, base: sceneName(i) });
|
|
119
|
+
else
|
|
120
|
+
record(outcome.reason, i);
|
|
121
|
+
});
|
|
93
122
|
}
|
|
94
123
|
return result;
|
|
95
124
|
});
|
|
@@ -101,6 +130,13 @@ export function registerSetTools(server, client) {
|
|
|
101
130
|
meta.grounding = masterResult.grounding;
|
|
102
131
|
if (report)
|
|
103
132
|
meta.image_inputs = report;
|
|
133
|
+
if (failed.length) {
|
|
134
|
+
meta.failed_scenes = failed;
|
|
135
|
+
meta.partial =
|
|
136
|
+
`${failed.length} of ${failed.length + named.length - 1} scene(s) failed; the master and the ` +
|
|
137
|
+
`${named.length - 1} scene(s) that succeeded are returned above. Re-run just the failed prompts rather than the whole set — ` +
|
|
138
|
+
'each failure message is in failed_scenes.';
|
|
139
|
+
}
|
|
104
140
|
// A set is master + N scenes in one tools/call — the most timeout-prone
|
|
105
141
|
// tool. Effective image count drives the risk hint.
|
|
106
142
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length, persistsFiles: client.mediaSink?.persistsFiles });
|
package/dist/tools/shared.js
CHANGED
|
@@ -4,6 +4,7 @@ import { resolveVideoPath, videoMimeType } from '../images.js';
|
|
|
4
4
|
import { IMAGE_URL_MAX_BYTES } from '../fetch-image.js';
|
|
5
5
|
import { wholeMb } from '../bytes.js';
|
|
6
6
|
import { createDiskSink } from '../storage/media.js';
|
|
7
|
+
import { downloadFilename } from '../media-endpoint.js';
|
|
7
8
|
/**
|
|
8
9
|
* Fallback sink for call sites that pass no explicit one (every pre-existing
|
|
9
10
|
* test, and any caller constructed before the sink seam existed). The stdio
|
|
@@ -300,6 +301,12 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
|
|
|
300
301
|
url: p.ref,
|
|
301
302
|
...(p.key ? { r2_key: p.key } : {}),
|
|
302
303
|
...(p.expiresAt ? { expires_at: p.expiresAt } : {}),
|
|
304
|
+
// A ready-to-run command, not a description of one. Models copy an
|
|
305
|
+
// exact string far more reliably than they compose one — and in a
|
|
306
|
+
// chat client this download-then-attach flow is the ONLY way the end
|
|
307
|
+
// user actually sees the image (inline MCP blocks and markdown embeds
|
|
308
|
+
// both render for the model and not for them).
|
|
309
|
+
...(p.key ? { curl_hint: `curl -sS -o ${downloadFilename(p.key)} "${p.ref}"` } : {}),
|
|
303
310
|
})),
|
|
304
311
|
}
|
|
305
312
|
: {};
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Single source of the server version. release-please bumps the literal below. */
|
|
2
|
-
export const VERSION = '1.
|
|
2
|
+
export const VERSION = '1.4.0'; // x-release-please-version
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrischall/gemini-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/gemini-mcp",
|
|
5
5
|
"description": "Gemini image-generation MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/gemini-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.4.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/gemini-mcp",
|
|
14
|
-
"version": "1.
|
|
14
|
+
"version": "1.4.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|