@chrischall/gemini-mcp 1.2.0 → 1.3.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 +60 -0
- package/dist/bundle.js +20 -8
- package/dist/connector-origin.js +34 -0
- package/dist/media-cleanup.js +75 -0
- package/dist/media-endpoint.js +72 -0
- package/dist/media-url.js +135 -0
- package/dist/storage/media.js +33 -11
- package/dist/tools/files.js +6 -3
- package/dist/tools/interact.js +5 -3
- package/dist/tools/shared.js +26 -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.3.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.3.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
package/README.md
CHANGED
|
@@ -87,6 +87,66 @@ progress), two guards make re-issuing safe and unnecessary:
|
|
|
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
89
|
|
|
90
|
+
## Seeing your images (hosted connector)
|
|
91
|
+
|
|
92
|
+
On the hosted connector there is no filesystem, so a generated image has to come back as
|
|
93
|
+
something you can *open*. It does: **every result includes a URL**, with no configuration.
|
|
94
|
+
|
|
95
|
+
```jsonc
|
|
96
|
+
{
|
|
97
|
+
"images": ["https://connector.example.com/media/gen/2026-07-29/ab12cd34-a-cat.png?exp=…&sig=…"],
|
|
98
|
+
"media": [{ "url": "https://…", "r2_key": "gen/2026-07-29/ab12cd34-a-cat.png",
|
|
99
|
+
"expires_at": "2026-07-31T12:00:00.000Z" }]
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Those links need no auth header — the signature is in the URL — so they work in a browser, in
|
|
104
|
+
`curl`, and in a chat message. They expire (48h by default) and the objects behind them are
|
|
105
|
+
swept on a retention schedule.
|
|
106
|
+
|
|
107
|
+
**Why this matters:** MCP's inline image content blocks (`inline: true`) are visible to the
|
|
108
|
+
*assistant* but many chat clients never render them to the user, and the assistant cannot
|
|
109
|
+
extract bytes back out of its own context to save them elsewhere. A generation could bill
|
|
110
|
+
successfully and be invisible. A URL is the portable answer; `inline` remains available, but
|
|
111
|
+
it is no longer the only way to receive media.
|
|
112
|
+
|
|
113
|
+
### Three ways to serve the bytes
|
|
114
|
+
|
|
115
|
+
| | setup | URL shape |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| **Worker route** (default) | none — works out of the box | `https://<worker-host>/media/<key>?exp=&sig=` |
|
|
118
|
+
| **r2.dev public bucket** | enable public access on the bucket, set `MEDIA_PUBLIC_BASE_URL` | `https://pub-….r2.dev/<key>` |
|
|
119
|
+
| **Custom domain** (best) | attach a domain to the bucket in Cloudflare, set `MEDIA_PUBLIC_BASE_URL` | `https://media.example.com/<key>` |
|
|
120
|
+
|
|
121
|
+
The built-in Worker route is the zero-config path: the connector streams the object out of R2
|
|
122
|
+
itself. The other two are worth setting up if you want plain, non-expiring URLs or want media
|
|
123
|
+
traffic served by R2 rather than by the Worker — r2.dev is easiest but rate-limited and fine
|
|
124
|
+
for personal use; a custom domain is the best of the three.
|
|
125
|
+
|
|
126
|
+
**Auth on `/media` is a signed, expiring URL rather than an unlisted key.** Random keys would
|
|
127
|
+
be simpler, but they never expire and never revoke: anything that ever logged or forwarded the
|
|
128
|
+
link keeps working forever. A signature scopes access to one object with a deadline, and
|
|
129
|
+
rotating `MEDIA_URL_SECRET` invalidates every outstanding link at once. The tradeoff is that
|
|
130
|
+
links are long and cannot be shortened by hand.
|
|
131
|
+
|
|
132
|
+
### For assistants relaying a result
|
|
133
|
+
|
|
134
|
+
Show the user the URL. If your sandbox has network egress to the connector host, fetching it
|
|
135
|
+
and attaching the bytes as a file gives the nicest result; otherwise present the link itself.
|
|
136
|
+
Whether a given client renders `` markdown inline varies by client — a bare URL is the
|
|
137
|
+
safe form, and a markdown link is a reasonable enhancement where you know it renders.
|
|
138
|
+
|
|
139
|
+
### Retention
|
|
140
|
+
|
|
141
|
+
| Variable | Default | Effect |
|
|
142
|
+
|---|---|---|
|
|
143
|
+
| `MEDIA_TTL_DAYS` | `7` | Objects older than this are deleted by a daily cron (both the current `gen/` prefix and the legacy `media/` one) |
|
|
144
|
+
| `MEDIA_URL_SECRET` | generated | HMAC key for `/media` links; rotate to revoke all outstanding URLs |
|
|
145
|
+
| `MEDIA_PUBLIC_BASE_URL` | unset | Serve from R2 directly instead of the Worker route |
|
|
146
|
+
|
|
147
|
+
Signed-URL lifetime is clamped to `MEDIA_TTL_DAYS`, so a link never outlives the object it
|
|
148
|
+
points at.
|
|
149
|
+
|
|
90
150
|
## Sending reference images without burning context
|
|
91
151
|
|
|
92
152
|
Every tool that takes a reference image — `gemini_image_generate`, `gemini_image_edit`,
|
package/dist/bundle.js
CHANGED
|
@@ -32796,7 +32796,7 @@ async function loadStdioDotenv() {
|
|
|
32796
32796
|
}
|
|
32797
32797
|
|
|
32798
32798
|
// src/version.ts
|
|
32799
|
-
var VERSION = "1.
|
|
32799
|
+
var VERSION = "1.3.0";
|
|
32800
32800
|
|
|
32801
32801
|
// src/tools/models.ts
|
|
32802
32802
|
init_dist();
|
|
@@ -33214,7 +33214,7 @@ function createDiskSink() {
|
|
|
33214
33214
|
const { writeMedia: writeMedia2, resolveOutputDir: resolveOutputDir2 } = await Promise.resolve().then(() => (init_images(), images_exports));
|
|
33215
33215
|
const dir = resolveOutputDir2(opts.output_dir);
|
|
33216
33216
|
const refs = [];
|
|
33217
|
-
for (const it of items) refs.push(await writeMedia2(dir, it.base, it.base64, it.mimeType));
|
|
33217
|
+
for (const it of items) refs.push({ ref: await writeMedia2(dir, it.base, it.base64, it.mimeType) });
|
|
33218
33218
|
return refs;
|
|
33219
33219
|
},
|
|
33220
33220
|
note: () => void 0
|
|
@@ -33346,14 +33346,26 @@ async function emitMedia(named, kind, opts, meta3, onWritten) {
|
|
|
33346
33346
|
return { content: blocks };
|
|
33347
33347
|
}
|
|
33348
33348
|
const sink = opts.sink ?? DEFAULT_SINK;
|
|
33349
|
-
const
|
|
33349
|
+
const persisted = await sink.persist(named.map((n) => ({ base: n.base, base64: n.media.base64, mimeType: n.media.mimeType })), {
|
|
33350
33350
|
output_dir: opts.output_dir
|
|
33351
33351
|
});
|
|
33352
|
+
const refs = persisted.map((p) => p.ref);
|
|
33352
33353
|
if (sink.persistsFiles) await onWritten?.(refs);
|
|
33353
33354
|
const note = opts.inline && kind === "video" ? { inline_unsupported: "video has no MCP inline content type \u2014 written to disk instead" } : {};
|
|
33354
33355
|
const storageNote = sink.note();
|
|
33356
|
+
const unavailable = persisted.some((p) => p.unavailable) ? {
|
|
33357
|
+
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."
|
|
33358
|
+
} : {};
|
|
33359
|
+
const detailed = persisted.filter((p) => p.key !== void 0);
|
|
33360
|
+
const media = detailed.length === persisted.length && detailed.length > 0 ? {
|
|
33361
|
+
media: persisted.map((p) => ({
|
|
33362
|
+
url: p.ref,
|
|
33363
|
+
...p.key ? { r2_key: p.key } : {},
|
|
33364
|
+
...p.expiresAt ? { expires_at: p.expiresAt } : {}
|
|
33365
|
+
}))
|
|
33366
|
+
} : {};
|
|
33355
33367
|
const storage = sink.persistsFiles ? {} : { storage: sink.kind, storage_note: storageNote };
|
|
33356
|
-
return textResult({ [`${kind}s`]: refs, ...note, ...storage, ...meta3 });
|
|
33368
|
+
return textResult({ [`${kind}s`]: refs, ...media, ...unavailable, ...note, ...storage, ...meta3 });
|
|
33357
33369
|
}
|
|
33358
33370
|
async function emit(named, opts, meta3, onWritten) {
|
|
33359
33371
|
return emitMedia(named.map((n) => ({ media: n.image, base: n.base })), "image", opts, meta3, onWritten);
|
|
@@ -34508,7 +34520,7 @@ function splitReattachedOutputs(images, writtenOutputs) {
|
|
|
34508
34520
|
}
|
|
34509
34521
|
function registerInteractTools(server, client2) {
|
|
34510
34522
|
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). " : "
|
|
34523
|
+
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
34524
|
server.registerTool(
|
|
34513
34525
|
"gemini_interact",
|
|
34514
34526
|
{
|
|
@@ -34882,7 +34894,7 @@ function registerFileTools(server, client2) {
|
|
|
34882
34894
|
server.registerTool(
|
|
34883
34895
|
"gemini_upload_file",
|
|
34884
34896
|
{
|
|
34885
|
-
description: "Upload an image
|
|
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."),
|
|
34886
34898
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
34887
34899
|
inputSchema: {
|
|
34888
34900
|
url: external_exports.string().url().optional().describe(
|
|
@@ -34952,7 +34964,7 @@ function registerFileTools(server, client2) {
|
|
|
34952
34964
|
server.registerTool(
|
|
34953
34965
|
"gemini_list_files",
|
|
34954
34966
|
{
|
|
34955
|
-
description: "List
|
|
34967
|
+
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
34968
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
34957
34969
|
inputSchema: {
|
|
34958
34970
|
page_size: external_exports.number().int().positive().max(100).optional().describe("Maximum files to return (1-100, default 100)")
|
|
@@ -34979,7 +34991,7 @@ function registerFileTools(server, client2) {
|
|
|
34979
34991
|
server.registerTool(
|
|
34980
34992
|
"gemini_delete_file",
|
|
34981
34993
|
{
|
|
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.",
|
|
34994
|
+
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
34995
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
34984
34996
|
inputSchema: {
|
|
34985
34997
|
file_uri: external_exports.string().min(1).describe("The `files/<id>` reference (or full uri) to delete"),
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retention for generated media.
|
|
3
|
+
*
|
|
4
|
+
* Every hosted generation writes an object to R2 and nothing ever removed it,
|
|
5
|
+
* so the bucket grew without bound — and the signed links pointing into it are
|
|
6
|
+
* only useful for a couple of days anyway. A scheduled sweep keeps the two in
|
|
7
|
+
* step: objects live `MEDIA_TTL_DAYS` (default 7), and a signed URL's lifetime
|
|
8
|
+
* is clamped to that, so a link never outlives the object behind it.
|
|
9
|
+
*
|
|
10
|
+
* R2 lifecycle rules would be the cheaper mechanism, but they are configured on
|
|
11
|
+
* the bucket rather than in `wrangler.jsonc`, so a fresh deploy of this repo
|
|
12
|
+
* would silently have no retention at all. A cron in the Worker ships with the
|
|
13
|
+
* code.
|
|
14
|
+
*/
|
|
15
|
+
const DEFAULT_MAX_OBJECTS = 5_000;
|
|
16
|
+
/**
|
|
17
|
+
* `gen/` is where media is written now; `media/` is where it was written before
|
|
18
|
+
* the rename that made URLs read `/media/gen/…` instead of `/media/media/…`.
|
|
19
|
+
* Both are swept — retention that ignores the existing backlog is not retention.
|
|
20
|
+
*/
|
|
21
|
+
const LEGACY_AND_CURRENT_PREFIXES = ['gen/', 'media/'];
|
|
22
|
+
/** R2 accepts bulk deletes in batches; 1000 is the documented ceiling. */
|
|
23
|
+
const DELETE_BATCH = 1_000;
|
|
24
|
+
/** Days → ms, defaulting to 7 for anything missing or nonsensical. */
|
|
25
|
+
export function retentionMsFromDays(days) {
|
|
26
|
+
const n = Number(days);
|
|
27
|
+
return (Number.isFinite(n) && n > 0 ? n : 7) * 24 * 60 * 60 * 1000;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Delete generated media older than `retentionMs`. Returns counts rather than
|
|
31
|
+
* logging them, so the caller decides what to do with the numbers and the sweep
|
|
32
|
+
* itself stays testable.
|
|
33
|
+
*/
|
|
34
|
+
export async function cleanupExpiredMedia(bucket, retentionMs, opts = {}) {
|
|
35
|
+
const now = opts.now?.() ?? Date.now();
|
|
36
|
+
const cutoff = now - retentionMs;
|
|
37
|
+
const maxObjects = opts.maxObjects ?? DEFAULT_MAX_OBJECTS;
|
|
38
|
+
const prefixes = opts.prefixes ?? LEGACY_AND_CURRENT_PREFIXES;
|
|
39
|
+
let scanned = 0;
|
|
40
|
+
let deleted = 0;
|
|
41
|
+
let truncated = false;
|
|
42
|
+
const doomed = [];
|
|
43
|
+
const flush = async (all) => {
|
|
44
|
+
while (doomed.length >= (all ? 1 : DELETE_BATCH)) {
|
|
45
|
+
const batch = doomed.splice(0, DELETE_BATCH);
|
|
46
|
+
await bucket.delete(batch);
|
|
47
|
+
deleted += batch.length;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
for (const prefix of prefixes) {
|
|
51
|
+
let cursor;
|
|
52
|
+
for (;;) {
|
|
53
|
+
if (scanned >= maxObjects) {
|
|
54
|
+
truncated = true;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
const page = await bucket.list({ prefix, cursor, limit: Math.min(1000, maxObjects - scanned) });
|
|
58
|
+
for (const object of page.objects) {
|
|
59
|
+
scanned++;
|
|
60
|
+
if (object.uploaded.getTime() < cutoff)
|
|
61
|
+
doomed.push(object.key);
|
|
62
|
+
}
|
|
63
|
+
await flush(false);
|
|
64
|
+
if (!page.truncated)
|
|
65
|
+
break;
|
|
66
|
+
if (scanned >= maxObjects) {
|
|
67
|
+
truncated = true;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
cursor = page.cursor;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
await flush(true);
|
|
74
|
+
return { scanned, deleted, truncated };
|
|
75
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { loadSigningKey, verifyMediaSignature } from './media-url.js';
|
|
2
|
+
function problem(status, message) {
|
|
3
|
+
return new Response(`${message}\n`, {
|
|
4
|
+
status,
|
|
5
|
+
headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' },
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Build the `/media` handler. `basePath` is stripped from the pathname to get
|
|
10
|
+
* the R2 key, so the key keeps its own prefix and the two never drift.
|
|
11
|
+
*/
|
|
12
|
+
export function createMediaHandler(env, basePath = '/media/') {
|
|
13
|
+
return async function handleMedia(request) {
|
|
14
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
15
|
+
return problem(405, `Method ${request.method} not allowed; media links are GET-only.`);
|
|
16
|
+
}
|
|
17
|
+
const url = new URL(request.url);
|
|
18
|
+
if (!url.pathname.startsWith(basePath))
|
|
19
|
+
return problem(404, 'Not found');
|
|
20
|
+
// decodeURIComponent per segment, mirroring how the URL was built. A key
|
|
21
|
+
// cannot contain `..` (the sink sanitises every segment), and R2 has no
|
|
22
|
+
// path traversal to exploit anyway — a key is an opaque string, not a path.
|
|
23
|
+
const key = url.pathname
|
|
24
|
+
.slice(basePath.length)
|
|
25
|
+
.split('/')
|
|
26
|
+
.map((segment) => {
|
|
27
|
+
try {
|
|
28
|
+
return decodeURIComponent(segment);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return segment;
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
.join('/');
|
|
35
|
+
if (!key)
|
|
36
|
+
return problem(404, 'Not found');
|
|
37
|
+
const now = env.now?.() ?? Date.now();
|
|
38
|
+
// Read-only: verification must never CREATE a signing secret. A stale KV
|
|
39
|
+
// read here would otherwise clobber the secret every outstanding link was
|
|
40
|
+
// signed with (see loadSigningKey). No secret ⇒ nothing was legitimately
|
|
41
|
+
// signed ⇒ refuse, without touching storage or the bucket.
|
|
42
|
+
const signingKey = await loadSigningKey(env.secret, env.store);
|
|
43
|
+
const check = signingKey
|
|
44
|
+
? await verifyMediaSignature(signingKey, key, url.searchParams.get('exp'), url.searchParams.get('sig'), now)
|
|
45
|
+
: { ok: false, reason: 'invalid' };
|
|
46
|
+
if (!check.ok) {
|
|
47
|
+
if (check.reason === 'expired') {
|
|
48
|
+
return problem(410, 'This media link has expired. Generated media is retained for a limited time — re-run the generation to get a fresh link.');
|
|
49
|
+
}
|
|
50
|
+
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
|
+
}
|
|
52
|
+
const object = await env.bucket.get(key);
|
|
53
|
+
if (!object) {
|
|
54
|
+
return problem(404, 'That media object no longer exists — it may have been cleaned up. Re-run the generation to get a fresh one.');
|
|
55
|
+
}
|
|
56
|
+
const headers = new 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));
|
|
68
|
+
if (request.method === 'HEAD')
|
|
69
|
+
return new Response(null, { status: 200, headers });
|
|
70
|
+
return new Response(object.body, { status: 200, headers });
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed URLs for generated media on the hosted connector.
|
|
3
|
+
*
|
|
4
|
+
* The problem these exist to solve: a chat client shows the user whatever the
|
|
5
|
+
* assistant *says*, and MCP inline image blocks are not that. On claude.ai and
|
|
6
|
+
* the mobile app the bytes arrive in the model's context and are never rendered
|
|
7
|
+
* — so a generation that billed successfully is invisible. A URL is the only
|
|
8
|
+
* portable answer: a human can tap it and an agent can curl it.
|
|
9
|
+
*
|
|
10
|
+
* That means the media route cannot sit behind the connector's OAuth, because
|
|
11
|
+
* a browser opening a link carries no bearer token. The signature IS the
|
|
12
|
+
* authorization: a URL is valid for one key until it expires, and nothing else
|
|
13
|
+
* in the bucket is reachable without one.
|
|
14
|
+
*
|
|
15
|
+
* Kept free of any Cloudflare import so the whole thing is testable under Node:
|
|
16
|
+
* WebCrypto is the only primitive used, and it exists on both runtimes.
|
|
17
|
+
*/
|
|
18
|
+
/** How long a freshly minted media URL stays valid. Matches Files API retention. */
|
|
19
|
+
export const MEDIA_URL_TTL_MS = 48 * 60 * 60 * 1000;
|
|
20
|
+
/** KV key holding the lazily-generated signing secret (zero-config path). */
|
|
21
|
+
export const SIGNING_SECRET_KV_KEY = 'media:url-signing-secret';
|
|
22
|
+
function base64UrlEncode(bytes) {
|
|
23
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
24
|
+
let binary = '';
|
|
25
|
+
for (let i = 0; i < view.length; i++)
|
|
26
|
+
binary += String.fromCharCode(view[i]);
|
|
27
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
28
|
+
}
|
|
29
|
+
function base64UrlDecode(text) {
|
|
30
|
+
const padded = text.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(text.length / 4) * 4, '=');
|
|
31
|
+
const binary = atob(padded);
|
|
32
|
+
const out = new Uint8Array(binary.length);
|
|
33
|
+
for (let i = 0; i < binary.length; i++)
|
|
34
|
+
out[i] = binary.charCodeAt(i);
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
/** Import raw secret material as an HMAC key. */
|
|
38
|
+
async function importKey(material) {
|
|
39
|
+
return crypto.subtle.importKey('raw', new TextEncoder().encode(material), { name: 'HMAC', hash: 'SHA-256' }, false, [
|
|
40
|
+
'sign',
|
|
41
|
+
'verify',
|
|
42
|
+
]);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The HMAC key for **signing**, creating one if this deployment has never
|
|
46
|
+
* signed anything.
|
|
47
|
+
*
|
|
48
|
+
* `MEDIA_URL_SECRET` wins when an operator sets one. Otherwise a random 32-byte
|
|
49
|
+
* secret is generated once and persisted in KV — which is what makes the whole
|
|
50
|
+
* feature work with **zero configuration**, the entire point of this change.
|
|
51
|
+
*
|
|
52
|
+
* Creating is confined to this function, and this function is called only when
|
|
53
|
+
* minting a URL. {@link loadSigningKey} is what the verify path uses. That
|
|
54
|
+
* split is load-bearing, not stylistic — see the warning there.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately NOT cached in a module-level variable: `src/` holds no
|
|
57
|
+
* module-scope mutable state (see session.ts), and a KV read per sign is cheap
|
|
58
|
+
* next to an image generation. There is a narrow first-use race — two isolates
|
|
59
|
+
* generating simultaneously — so the value is re-read after writing and the
|
|
60
|
+
* stored one wins, collapsing the window to URLs minted in that same instant.
|
|
61
|
+
*/
|
|
62
|
+
export async function resolveSigningKey(secret, store) {
|
|
63
|
+
let material = secret?.trim();
|
|
64
|
+
if (!material) {
|
|
65
|
+
material = (await store.get(SIGNING_SECRET_KV_KEY)) ?? undefined;
|
|
66
|
+
if (!material) {
|
|
67
|
+
const generated = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
|
|
68
|
+
await store.put(SIGNING_SECRET_KV_KEY, generated);
|
|
69
|
+
// Re-read so a concurrent generator's value wins consistently for both.
|
|
70
|
+
material = (await store.get(SIGNING_SECRET_KV_KEY)) ?? generated;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return importKey(material);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The HMAC key for **verifying**. Read-only: returns `undefined` when no secret
|
|
77
|
+
* is stored, and never creates one.
|
|
78
|
+
*
|
|
79
|
+
* This is not a stylistic split. KV is eventually consistent — a `put` on one
|
|
80
|
+
* isolate is not immediately visible to another — so the verify path can and
|
|
81
|
+
* will see a miss for a secret that exists. If verification created-on-miss
|
|
82
|
+
* (as it did originally), that transient miss would mint a NEW secret and
|
|
83
|
+
* overwrite the one every outstanding link was signed with, permanently 403ing
|
|
84
|
+
* media that had been signed perfectly correctly. A self-inflicted denial of
|
|
85
|
+
* service, triggered by ordinary traffic, and irreversible once the old secret
|
|
86
|
+
* is gone.
|
|
87
|
+
*
|
|
88
|
+
* No secret stored ⇒ nothing was ever legitimately signed ⇒ refuse.
|
|
89
|
+
*/
|
|
90
|
+
export async function loadSigningKey(secret, store) {
|
|
91
|
+
const material = secret?.trim() || (await store.get(SIGNING_SECRET_KV_KEY));
|
|
92
|
+
return material ? importKey(material) : undefined;
|
|
93
|
+
}
|
|
94
|
+
/** The bytes covered by a signature: the object key and the expiry, nothing else. */
|
|
95
|
+
function payload(key, expiresAtMs) {
|
|
96
|
+
return new TextEncoder().encode(`${key}\n${expiresAtMs}`);
|
|
97
|
+
}
|
|
98
|
+
/** Sign one object key for use until `expiresAtMs`. */
|
|
99
|
+
export async function signMediaKey(signingKey, key, expiresAtMs) {
|
|
100
|
+
return base64UrlEncode(await crypto.subtle.sign('HMAC', signingKey, payload(key, expiresAtMs)));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Check a `?exp=&sig=` pair against an object key.
|
|
104
|
+
*
|
|
105
|
+
* Verification goes through `crypto.subtle.verify` rather than a string
|
|
106
|
+
* comparison, so it is constant-time — a `===` on the signature would leak the
|
|
107
|
+
* expected value one byte at a time to anyone willing to time it.
|
|
108
|
+
*/
|
|
109
|
+
export async function verifyMediaSignature(signingKey, key, exp, sig, nowMs) {
|
|
110
|
+
if (!exp || !sig)
|
|
111
|
+
return { ok: false, reason: 'missing' };
|
|
112
|
+
const expiresAtMs = Number(exp);
|
|
113
|
+
if (!Number.isFinite(expiresAtMs))
|
|
114
|
+
return { ok: false, reason: 'invalid' };
|
|
115
|
+
let valid;
|
|
116
|
+
try {
|
|
117
|
+
valid = await crypto.subtle.verify('HMAC', signingKey, base64UrlDecode(sig), payload(key, expiresAtMs));
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return { ok: false, reason: 'invalid' };
|
|
121
|
+
}
|
|
122
|
+
// Signature first, then expiry: an expired-but-authentic link deserves a
|
|
123
|
+
// different message from a forged one, and saying "expired" about a forgery
|
|
124
|
+
// would confirm the attacker got the signature right.
|
|
125
|
+
if (!valid)
|
|
126
|
+
return { ok: false, reason: 'invalid' };
|
|
127
|
+
if (nowMs > expiresAtMs)
|
|
128
|
+
return { ok: false, reason: 'expired' };
|
|
129
|
+
return { ok: true };
|
|
130
|
+
}
|
|
131
|
+
/** `<base>/<key>?exp=…&sig=…` — the URL handed back to the caller. */
|
|
132
|
+
export function buildMediaUrl(baseUrl, key, expiresAtMs, signature) {
|
|
133
|
+
const path = key.split('/').map(encodeURIComponent).join('/');
|
|
134
|
+
return `${baseUrl.replace(/\/+$/, '')}/${path}?exp=${expiresAtMs}&sig=${signature}`;
|
|
135
|
+
}
|
package/dist/storage/media.js
CHANGED
|
@@ -30,13 +30,15 @@ export function createDiskSink() {
|
|
|
30
30
|
// Sequential on purpose: uniquePath checks the filesystem, so writing two
|
|
31
31
|
// colliding names concurrently would race onto the same path.
|
|
32
32
|
for (const it of items)
|
|
33
|
-
refs.push(await writeMedia(dir, it.base, it.base64, it.mimeType));
|
|
33
|
+
refs.push({ ref: await writeMedia(dir, it.base, it.base64, it.mimeType) });
|
|
34
34
|
return refs;
|
|
35
35
|
},
|
|
36
36
|
note: () => undefined,
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
|
-
|
|
39
|
+
// `gen`, not `media`: the objects are served at `/media/<key>`, and a `media`
|
|
40
|
+
// prefix would make every URL read `/media/media/…`.
|
|
41
|
+
const KEY_PREFIX_DEFAULT = 'gen';
|
|
40
42
|
/** Object-key-safe name: no slashes, no dot-segments, never empty. */
|
|
41
43
|
function safeKeySegment(base) {
|
|
42
44
|
const cleaned = base
|
|
@@ -57,7 +59,9 @@ export function createR2Sink(bucket, opts) {
|
|
|
57
59
|
const prefix = opts.prefix ?? KEY_PREFIX_DEFAULT;
|
|
58
60
|
const now = opts.now ?? (() => new Date());
|
|
59
61
|
const randomId = opts.randomId ?? (() => crypto.randomUUID().slice(0, 8));
|
|
60
|
-
const
|
|
62
|
+
const publicBase = opts.publicBaseUrl?.replace(/\/+$/, '');
|
|
63
|
+
const signedBase = opts.signedBaseUrl?.replace(/\/+$/, '');
|
|
64
|
+
const ttlMs = opts.urlTtlMs ?? 0;
|
|
61
65
|
return {
|
|
62
66
|
kind: 'r2',
|
|
63
67
|
persistsFiles: false,
|
|
@@ -67,20 +71,38 @@ export function createR2Sink(bucket, opts) {
|
|
|
67
71
|
// module graph.
|
|
68
72
|
const { mediaExt } = await import('../images.js');
|
|
69
73
|
const day = now().toISOString().slice(0, 10);
|
|
74
|
+
const expiresAtMs = now().getTime() + ttlMs;
|
|
70
75
|
const refs = [];
|
|
71
76
|
for (const it of items) {
|
|
72
77
|
const key = `${prefix}/${day}/${randomId()}-${safeKeySegment(it.base)}.${mediaExt(it.mimeType)}`;
|
|
73
|
-
|
|
74
|
-
await
|
|
75
|
-
refs.push(base ? `${base}/${key}` : `r2://${opts.bucketName ?? 'media'}/${key}`);
|
|
78
|
+
await bucket.put(key, base64ToBytes(it.base64), { httpMetadata: { contentType: it.mimeType } });
|
|
79
|
+
refs.push(await describe(key, expiresAtMs));
|
|
76
80
|
}
|
|
77
81
|
return refs;
|
|
78
82
|
},
|
|
79
|
-
note: () =>
|
|
80
|
-
? 'Generated media was uploaded to R2
|
|
83
|
+
note: () => publicBase
|
|
84
|
+
? 'Generated media was uploaded to R2 and the values above are public URLs (not local file paths) — open or download them directly. ' +
|
|
81
85
|
'This hosted connector has no filesystem, so `output_dir` is ignored and no <image>.json sidecar is written — capture interaction_id from this result to chain further turns.'
|
|
82
|
-
:
|
|
83
|
-
'
|
|
84
|
-
|
|
86
|
+
: signedBase
|
|
87
|
+
? 'Generated media is served by this connector at the signed URLs above — open them in a browser or fetch them with curl; no auth header is needed, the signature is in the link. ' +
|
|
88
|
+
'They expire (see media[].expires_at), and the objects behind them are cleaned up on a retention schedule. ' +
|
|
89
|
+
'This hosted connector has no filesystem, so `output_dir` is ignored and no <image>.json sidecar is written — capture interaction_id from this result to chain further turns.'
|
|
90
|
+
: 'Generated media was stored, but this connector has no public media URL configured and no signing route available, so the values above are bare object keys rather than fetchable links. ' +
|
|
91
|
+
'Set MEDIA_PUBLIC_BASE_URL on the Worker, or pass inline: true to receive the bytes directly.',
|
|
85
92
|
};
|
|
93
|
+
/** One stored object → the ref the caller sees, plus its key and expiry. */
|
|
94
|
+
async function describe(key, expiresAtMs) {
|
|
95
|
+
// A bucket served directly (r2.dev or a custom domain) needs no signature.
|
|
96
|
+
if (publicBase)
|
|
97
|
+
return { ref: `${publicBase}/${key}`, key };
|
|
98
|
+
if (signedBase && opts.sign) {
|
|
99
|
+
const { buildMediaUrl } = await import('../media-url.js');
|
|
100
|
+
const signature = await opts.sign(key, expiresAtMs);
|
|
101
|
+
return { ref: buildMediaUrl(signedBase, key, expiresAtMs, signature), key, expiresAt: new Date(expiresAtMs).toISOString() };
|
|
102
|
+
}
|
|
103
|
+
// Neither configured. Unreachable in the Worker (which always passes its
|
|
104
|
+
// own origin), so this is a misconfiguration rather than a mode — say so
|
|
105
|
+
// explicitly rather than returning something that looks like a filename.
|
|
106
|
+
return { ref: key, key, unavailable: true };
|
|
107
|
+
}
|
|
86
108
|
}
|
package/dist/tools/files.js
CHANGED
|
@@ -63,7 +63,10 @@ export function registerFileTools(server, client) {
|
|
|
63
63
|
const onDisk = client.mediaSink?.persistsFiles ?? true;
|
|
64
64
|
const urlMaxBytes = onDisk ? UPLOAD_URL_MAX_BYTES_DISK : UPLOAD_URL_MAX_BYTES_HOSTED;
|
|
65
65
|
server.registerTool('gemini_upload_file', {
|
|
66
|
-
description: 'Upload an image
|
|
66
|
+
description: 'Upload a file — an image, reference photo, picture, screenshot, video or audio clip — to the Gemini Files API ' +
|
|
67
|
+
'ONCE, and get back a reusable `file_uri` (`files/<id>`) to attach to later image, video or music generations. ' +
|
|
68
|
+
'Keywords: upload, upload file, upload image, upload photo, attach, reference image, reference photo, ' +
|
|
69
|
+
'file_uri, files api, image reference, reuse across calls. ' +
|
|
67
70
|
'Use this instead of pasting base64 into a tool call: the reference is a short string, so no image bytes ever ' +
|
|
68
71
|
'enter the conversation, and it can be reused across many generations until it expires (~48h). ' +
|
|
69
72
|
'Provide exactly one of `url` (the server downloads it), `data_base64`' +
|
|
@@ -143,7 +146,7 @@ export function registerFileTools(server, client) {
|
|
|
143
146
|
return uploadResult(uploaded);
|
|
144
147
|
});
|
|
145
148
|
server.registerTool('gemini_list_files', {
|
|
146
|
-
description: 'List
|
|
149
|
+
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, ' +
|
|
147
150
|
'MIME types and expiry times. Retention is ~48h, so an entry that has vanished has expired rather than failed.',
|
|
148
151
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
149
152
|
inputSchema: {
|
|
@@ -169,7 +172,7 @@ export function registerFileTools(server, client) {
|
|
|
169
172
|
});
|
|
170
173
|
});
|
|
171
174
|
server.registerTool('gemini_delete_file', {
|
|
172
|
-
description: 'Delete an uploaded file from the Gemini Files API before its ~48h expiry. Any tool call still referencing it ' +
|
|
175
|
+
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 ' +
|
|
173
176
|
'will then fail with a generic 404, so delete only references you are finished with.',
|
|
174
177
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
175
178
|
inputSchema: {
|
package/dist/tools/interact.js
CHANGED
|
@@ -48,9 +48,11 @@ export function registerInteractTools(server, client) {
|
|
|
48
48
|
'If a chained call 404s, this tool re-anchors itself on the prior output image and re-issues un-chained: ' +
|
|
49
49
|
'success is reported as `chain_recovered` (the chain was the problem, and you get your image anyway); ' +
|
|
50
50
|
'a second 404 is reported as the interaction id NOT being the cause (check the model id / files uri instead). '
|
|
51
|
-
: '
|
|
52
|
-
'
|
|
53
|
-
'
|
|
51
|
+
: 'On this connector each result returns an image URL you can open or share directly (see `media[].url`) — show it ' +
|
|
52
|
+
'to the user rather than assuming they can see the image. There is no filesystem, so no output dir and no ' +
|
|
53
|
+
'`<image>.json` sidecar: a lost response cannot be recovered from disk and a 404 on a chained call cannot be ' +
|
|
54
|
+
're-anchored automatically. Capture the returned `interaction_id` from every result, and use `async: true` + ' +
|
|
55
|
+
'gemini_get_result for long generations. ';
|
|
54
56
|
server.registerTool('gemini_interact', {
|
|
55
57
|
description: 'Preferred tool for iterative or multi-step refinement of a single image — ' +
|
|
56
58
|
"multi-turn generation/editing via Gemini's Interactions API. " +
|
package/dist/tools/shared.js
CHANGED
|
@@ -265,9 +265,10 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
|
|
|
265
265
|
return { content: blocks };
|
|
266
266
|
}
|
|
267
267
|
const sink = opts.sink ?? DEFAULT_SINK;
|
|
268
|
-
const
|
|
268
|
+
const persisted = await sink.persist(named.map((n) => ({ base: n.base, base64: n.media.base64, mimeType: n.media.mimeType })), {
|
|
269
269
|
output_dir: opts.output_dir,
|
|
270
270
|
});
|
|
271
|
+
const refs = persisted.map((p) => p.ref);
|
|
271
272
|
// `onWritten` (sidecars, the written-outputs set) is meaningful only when the
|
|
272
273
|
// refs are real files. On a sink with no filesystem there is nothing to sit
|
|
273
274
|
// next to, so it is skipped rather than handed URLs it would misinterpret.
|
|
@@ -279,8 +280,31 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
|
|
|
279
280
|
// key is the same (`images`/`videos`/`audios`), so without this a caller would
|
|
280
281
|
// reasonably read a URL as a file path.
|
|
281
282
|
const storageNote = sink.note();
|
|
283
|
+
// Per-item detail (`url` / `r2_key` / `expires_at`) alongside the flat list,
|
|
284
|
+
// so a caller can reason about retention without parsing a URL. Only emitted
|
|
285
|
+
// where there IS extra detail — the disk sink's absolute paths say it all,
|
|
286
|
+
// and adding a parallel array there would change a stable result shape.
|
|
287
|
+
// A stored-but-unaddressable object is a configuration failure, not a mode.
|
|
288
|
+
// Saying so in the result beats handing back a bare key that reads like a
|
|
289
|
+
// filename — the silent-unopenable-ref problem this change exists to end.
|
|
290
|
+
const unavailable = persisted.some((p) => p.unavailable)
|
|
291
|
+
? {
|
|
292
|
+
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. ' +
|
|
293
|
+
'This is a connector misconfiguration: set MEDIA_PUBLIC_BASE_URL, or use inline: true to receive the bytes directly.',
|
|
294
|
+
}
|
|
295
|
+
: {};
|
|
296
|
+
const detailed = persisted.filter((p) => p.key !== undefined);
|
|
297
|
+
const media = detailed.length === persisted.length && detailed.length > 0
|
|
298
|
+
? {
|
|
299
|
+
media: persisted.map((p) => ({
|
|
300
|
+
url: p.ref,
|
|
301
|
+
...(p.key ? { r2_key: p.key } : {}),
|
|
302
|
+
...(p.expiresAt ? { expires_at: p.expiresAt } : {}),
|
|
303
|
+
})),
|
|
304
|
+
}
|
|
305
|
+
: {};
|
|
282
306
|
const storage = sink.persistsFiles ? {} : { storage: sink.kind, storage_note: storageNote };
|
|
283
|
-
return textResult({ [`${kind}s`]: refs, ...note, ...storage, ...meta });
|
|
307
|
+
return textResult({ [`${kind}s`]: refs, ...media, ...unavailable, ...note, ...storage, ...meta });
|
|
284
308
|
}
|
|
285
309
|
/**
|
|
286
310
|
* Image convenience wrapper over {@link emitMedia} — preserves the original
|
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.3.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.3.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.3.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/gemini-mcp",
|
|
14
|
-
"version": "1.
|
|
14
|
+
"version": "1.3.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|