@chrischall/gemini-mcp 1.6.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/blob-store.js +18 -2
- package/dist/bundle.js +942 -617
- package/dist/client.js +31 -2
- package/dist/errors.js +120 -0
- package/dist/index.js +6 -25
- package/dist/job-store.js +138 -0
- package/dist/jobs.js +257 -12
- package/dist/media-url.js +10 -3
- package/dist/registrars.js +50 -0
- package/dist/session.js +5 -4
- package/dist/signed-url.js +42 -0
- package/dist/storage/media.js +76 -3
- package/dist/tools/files.js +49 -0
- package/dist/tools/jobs.js +5 -1
- package/dist/tools/shared.js +13 -3
- package/dist/tools/uploads.js +42 -19
- package/dist/upload-url.js +24 -4
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/tools/files.js
CHANGED
|
@@ -228,6 +228,55 @@ export function registerFileTools(server, client) {
|
|
|
228
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
229
|
});
|
|
230
230
|
});
|
|
231
|
+
// The recovery path for media whose RESULT was lost rather than never
|
|
232
|
+
// produced — a background job killed with its machine still wrote its
|
|
233
|
+
// images, but every reference to them went with the response. Without this
|
|
234
|
+
// the only way to see them again is to pay for the generation twice.
|
|
235
|
+
server.registerTool('gemini_list_recent_media', {
|
|
236
|
+
description: 'List images/video/audio this connector recently generated for your account, newest first, with fresh openable URLs. ' +
|
|
237
|
+
'Use it when a result was lost — a generation whose response never arrived, or an async job whose id came back unknown — ' +
|
|
238
|
+
'to find what already exists BEFORE re-running (and re-paying for) it. Keywords: lost result, orphaned job, ' +
|
|
239
|
+
'missing image, what did I generate, recover, recent media.',
|
|
240
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
241
|
+
inputSchema: {
|
|
242
|
+
limit: z.number().int().positive().max(100).optional().describe('How many to return (default 20, newest first)'),
|
|
243
|
+
since_day: z
|
|
244
|
+
.string()
|
|
245
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
246
|
+
.optional()
|
|
247
|
+
.describe('Only media stored on or after this UTC day, e.g. "2026-08-18"'),
|
|
248
|
+
},
|
|
249
|
+
}, async (args) => {
|
|
250
|
+
const sink = client.mediaSink;
|
|
251
|
+
if (!sink?.listRecent) {
|
|
252
|
+
throw new McpToolError('This server writes generated files to disk — list the output directory instead.', {
|
|
253
|
+
hint: 'gemini_list_recent_media applies to the hosted connector only.',
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
const limit = args.limit ?? 20;
|
|
257
|
+
const page = sink.listRecentPage
|
|
258
|
+
? await sink.listRecentPage({ limit, sinceDay: args.since_day })
|
|
259
|
+
: { media: await sink.listRecent({ limit, sinceDay: args.since_day }), truncated: false };
|
|
260
|
+
const found = page.media;
|
|
261
|
+
return textResult({
|
|
262
|
+
count: found.length,
|
|
263
|
+
// Never let a bounded walk pass for the whole picture: concluding an
|
|
264
|
+
// image is gone is what makes someone pay to generate it twice.
|
|
265
|
+
...(page.truncated ? { truncated: true } : {}),
|
|
266
|
+
media: found.map((m) => ({
|
|
267
|
+
r2_key: m.key,
|
|
268
|
+
day: m.day,
|
|
269
|
+
name: m.name,
|
|
270
|
+
...(m.sizeBytes !== undefined ? { size_bytes: m.sizeBytes } : {}),
|
|
271
|
+
...(m.url ? { url: m.url, curl_hint: `curl -sS -o ${downloadFilename(m.key)} "${m.url}"` } : {}),
|
|
272
|
+
...(m.expiresAt ? { expires_at: m.expiresAt } : {}),
|
|
273
|
+
})),
|
|
274
|
+
hint: found.length
|
|
275
|
+
? 'Ordered by the day each object was stored (the store keeps no finer timestamp), newest first. Re-sign any expired link with gemini_sign_media.' +
|
|
276
|
+
(page.truncated ? ' More media exists than is listed here — narrow with since_day, or raise limit, before concluding something is missing.' : '')
|
|
277
|
+
: 'Nothing stored in that window. Media is swept on a retention schedule, so an older generation may already be gone.',
|
|
278
|
+
});
|
|
279
|
+
});
|
|
231
280
|
}
|
|
232
281
|
server.registerTool('gemini_delete_file', {
|
|
233
282
|
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 ' +
|
package/dist/tools/jobs.js
CHANGED
|
@@ -11,7 +11,11 @@ import { z } from 'zod';
|
|
|
11
11
|
// other session's result by job id — see src/session.ts.
|
|
12
12
|
export function registerJobTools(server, client) {
|
|
13
13
|
server.registerTool('gemini_get_result', {
|
|
14
|
-
description: 'Retrieve
|
|
14
|
+
description: 'Retrieve a generation started with `async: true` or handed off by `max_wait_ms`. Pass the returned `job_id`: ' +
|
|
15
|
+
'while running it reports status "running"; on completion it returns the normal result (image URLs/paths + meta); ' +
|
|
16
|
+
'on failure it raises the recorded error, including the case where the generation was killed before it finished. ' +
|
|
17
|
+
'On the hosted connector job records are stored durably and survive a restart; on a local stdio server they live ' +
|
|
18
|
+
'with the process and expire ~10 min after completion, where the output dir / <image>.json sidecar is the fallback.',
|
|
15
19
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
16
20
|
inputSchema: {
|
|
17
21
|
job_id: z.string().min(1).describe('The job_id returned by a generation tool called with async: true'),
|
package/dist/tools/shared.js
CHANGED
|
@@ -55,7 +55,10 @@ export const idempotencyKeySchema = z
|
|
|
55
55
|
export const asyncSchema = z
|
|
56
56
|
.boolean()
|
|
57
57
|
.optional()
|
|
58
|
-
.describe('Run in the background and return a job_id immediately instead of the image, so a long (Pro/4K) generation cannot hit
|
|
58
|
+
.describe('Run in the background and return a job_id immediately instead of the image, so a long (Pro/4K) generation cannot hit ' +
|
|
59
|
+
'the host tools/call timeout (-32001). Poll gemini_get_result with the job_id to fetch the result. ' +
|
|
60
|
+
'PREFER `max_wait_ms` on the hosted connector: it runs where the executor is only guaranteed to stay alive while the ' +
|
|
61
|
+
'request is open, so this option is served there as a bounded wait rather than an immediate hand-off.');
|
|
59
62
|
/**
|
|
60
63
|
* Wait-then-hand-off budget shared by every generation tool: wait up to the
|
|
61
64
|
* budget for the in-band result, and past it return a `{ job_id }` handle
|
|
@@ -361,10 +364,17 @@ export function timeoutRiskHint(opts) {
|
|
|
361
364
|
.join(' + ');
|
|
362
365
|
const lead = `Slow config (${why}) can exceed a host's tools/call timeout (e.g. Claude Desktop ~30s, error -32001). `;
|
|
363
366
|
if (opts.persistsFiles === false) {
|
|
367
|
+
// Deliberately NOT `async: true`. This connector runs on a machine that is
|
|
368
|
+
// stopped whenever no request is in flight, so an immediate hand-off is
|
|
369
|
+
// what gets a generation killed mid-flight; holding the request open is
|
|
370
|
+
// what keeps it alive. Recommending async here was recommending the
|
|
371
|
+
// failure mode (see src/job-store.ts).
|
|
364
372
|
return (lead +
|
|
365
373
|
`This connector has no filesystem, so a lost response cannot be recovered from disk — there is no output dir and no ` +
|
|
366
|
-
`<image>.json sidecar.
|
|
367
|
-
`
|
|
374
|
+
`<image>.json sidecar. Set \`max_wait_ms\` (e.g. 240000): keeping the request open is also what keeps the generation ` +
|
|
375
|
+
`running here, and a batch that outlives the budget hands back a job_id to poll with gemini_get_result. ` +
|
|
376
|
+
`For a faster in-band result use a Flash model or drop image_size to 1K/2K. If a result is ever lost, ` +
|
|
377
|
+
`list what was already generated with gemini_list_recent_media before paying for it again.`);
|
|
368
378
|
}
|
|
369
379
|
return (lead +
|
|
370
380
|
`If the host gives up mid-generation the image is still written to disk (and gemini_interact leaves an ` +
|
package/dist/tools/uploads.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { textResult } from '@chrischall/mcp-utils';
|
|
3
3
|
import { UPLOAD_MAX_BYTES, RASTER_IMAGE_TYPE_PATTERN } from '../upload-url.js';
|
|
4
4
|
import { wholeMb } from '../bytes.js';
|
|
5
|
+
import { step } from '../errors.js';
|
|
5
6
|
/**
|
|
6
7
|
* `gemini_get_upload_url` — mint a short-lived signed PUT URL so a shell can
|
|
7
8
|
* upload a reference image with ZERO auth headers:
|
|
@@ -15,9 +16,27 @@ import { wholeMb } from '../bytes.js';
|
|
|
15
16
|
* shell doesn't have. A signed URL is mintable by the session and usable by
|
|
16
17
|
* the shell.
|
|
17
18
|
*
|
|
18
|
-
* Hosted
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* Hosted only — stdio has no HTTP surface to PUT against (and no need: it reads
|
|
20
|
+
* local paths directly). Gated on `client.uploadUrls`, the same pattern as
|
|
21
|
+
* `gemini_sign_media` / the library tools.
|
|
22
|
+
*
|
|
23
|
+
* ## The PUT response body is NOT the contract any more
|
|
24
|
+
*
|
|
25
|
+
* The retired Cloudflare connector served `/put` itself and answered
|
|
26
|
+
* `201 { r2_key, size_bytes, content_type, hint }`. mcp-host's blob gateway is
|
|
27
|
+
* a generic object store: it answers a bare 2xx and the body is whatever the
|
|
28
|
+
* gateway feels like. So the `r2_key` a caller must keep is the one in THIS
|
|
29
|
+
* result, minted here, known before a byte is sent — and success is "the PUT
|
|
30
|
+
* returned 2xx", nothing finer. Every hint below says so; don't reintroduce
|
|
31
|
+
* guidance to read the key out of the PUT response, it isn't there.
|
|
32
|
+
*
|
|
33
|
+
* ## Minting writes nothing
|
|
34
|
+
*
|
|
35
|
+
* There is no reservation, no pending-upload record, no placeholder object —
|
|
36
|
+
* a mint is a tenant id, a key string and one HMAC. That is what makes a failed
|
|
37
|
+
* mint atomic: it cannot leave orphaned state behind because it never had any
|
|
38
|
+
* to leave. `tests/upload-flow.test.ts` holds that property to a fake gateway
|
|
39
|
+
* that fails the call if it is touched at all.
|
|
21
40
|
*/
|
|
22
41
|
export function registerUploadUrlTools(server, client) {
|
|
23
42
|
const minter = client.uploadUrls;
|
|
@@ -26,10 +45,12 @@ export function registerUploadUrlTools(server, client) {
|
|
|
26
45
|
server.registerTool('gemini_get_upload_url', {
|
|
27
46
|
description: 'Mint a short-lived (~10 min) signed PUT URL for uploading one reference image to this connector with NO auth ' +
|
|
28
47
|
'headers — usable from any shell: curl -sS -X PUT -H "Content-Type: image/jpeg" --data-binary @photo.jpg "<upload_url>". ' +
|
|
29
|
-
'
|
|
30
|
-
'
|
|
31
|
-
'
|
|
32
|
-
|
|
48
|
+
'A 2xx from that PUT means the bytes are stored; the key to reference them is the `r2_key` in THIS result (the PUT ' +
|
|
49
|
+
'itself returns no useful body). Pass that r2_key in `images_r2_keys` on generation tools, save it permanently with ' +
|
|
50
|
+
'gemini_save_character, or convert it to a ~48h Files API reference with gemini_upload_file({ r2_key }). Prefer this ' +
|
|
51
|
+
'over images_base64 (which costs ~14k tokens per photo) whenever the caller has a shell. Images only, up to ' +
|
|
52
|
+
`${wholeMb(UPLOAD_MAX_BYTES)}MB. The PUT must send exactly the Content-Type this URL was minted for — the signature ` +
|
|
53
|
+
'covers it, so a mismatched header is rejected.',
|
|
33
54
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
34
55
|
inputSchema: {
|
|
35
56
|
filename: z
|
|
@@ -45,13 +66,11 @@ export function registerUploadUrlTools(server, client) {
|
|
|
45
66
|
.describe('MIME type of the bytes that will be PUT — raster images only (jpeg/png/webp/gif/avif/heic/heif/bmp/tiff; not SVG). The PUT must send exactly this Content-Type.'),
|
|
46
67
|
},
|
|
47
68
|
}, async (args) => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
throw new McpToolError(err instanceof Error ? err.message : String(err));
|
|
54
|
-
}
|
|
69
|
+
// `step` names the operation in the message, and `surfaceToolErrors`
|
|
70
|
+
// (wired in index.ts) prefixes the tool and flattens the cause chain —
|
|
71
|
+
// between them a mint failure can no longer reach the client as a bare
|
|
72
|
+
// "Error occurred during tool execution".
|
|
73
|
+
const minted = await step(`minting a signed upload URL for ${args.filename} (${args.content_type})`, () => minter.mint(args.filename, args.content_type));
|
|
55
74
|
return textResult({
|
|
56
75
|
upload_url: minted.url,
|
|
57
76
|
method: 'PUT',
|
|
@@ -60,10 +79,14 @@ export function registerUploadUrlTools(server, client) {
|
|
|
60
79
|
expires_at: new Date(minted.expiresAtMs).toISOString(),
|
|
61
80
|
max_bytes: UPLOAD_MAX_BYTES,
|
|
62
81
|
curl_hint: `curl -sS -X PUT -H "Content-Type: ${minted.contentType}" --data-binary @${args.filename} "${minted.url}"`,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
82
|
+
// Named for what it is: the PUT's own body is the object store's, not
|
|
83
|
+
// this connector's, so 2xx is the whole success signal.
|
|
84
|
+
success_is: 'any 2xx from the PUT; the response body is not part of the contract and carries no r2_key',
|
|
85
|
+
hint: 'PUT the raw bytes within ~10 minutes with exactly the Content-Type above; no Authorization header is needed ' +
|
|
86
|
+
'(the signature in the URL is the auth, and it covers the content type). On a 2xx, reference the `r2_key` from ' +
|
|
87
|
+
'THIS result — not the PUT response: images_r2_keys on generation tools (master_images_r2_keys on ' +
|
|
88
|
+
'gemini_image_set), gemini_save_character to keep it with no expiry, or gemini_upload_file({ r2_key }) for a ' +
|
|
89
|
+
'~48h files/… reference. Uploads themselves follow the media retention schedule (default 7 days).',
|
|
67
90
|
});
|
|
68
91
|
});
|
|
69
92
|
}
|
package/dist/upload-url.js
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
* import so it tests under Node.
|
|
33
33
|
*/
|
|
34
34
|
import { base64UrlEncode, base64UrlDecode } from './media-url.js';
|
|
35
|
+
import { signedLinks } from './signed-url.js';
|
|
35
36
|
import { safeKeySegment } from './storage/media.js';
|
|
36
37
|
/** How long a minted upload URL stays usable. Deliberately short — it exists to
|
|
37
38
|
* be curled within the same conversation turn that minted it. */
|
|
@@ -86,10 +87,17 @@ export async function verifyUploadSignature(signingKey, key, contentType, exp, s
|
|
|
86
87
|
return { ok: false, reason: 'expired' };
|
|
87
88
|
return { ok: true };
|
|
88
89
|
}
|
|
89
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* `<base>/<key>?ct=…&exp=…&sig=…` — the URL handed back by the mint tool.
|
|
92
|
+
*
|
|
93
|
+
* Delegates to {@link signedLinks}, the single shape-owner it shares with
|
|
94
|
+
* `buildMediaUrl`. Prefer passing a {@link SignedLinks} into
|
|
95
|
+
* {@link createUploadUrlMinter} (`links`) over a bare `baseUrl`: a store handed
|
|
96
|
+
* to the sink and the minter as one object cannot be re-pointed for one and
|
|
97
|
+
* not the other, which is precisely how the re-host broke this flow.
|
|
98
|
+
*/
|
|
90
99
|
export function buildUploadUrl(baseUrl, key, contentType, expiresAtMs, signature) {
|
|
91
|
-
|
|
92
|
-
return `${baseUrl.replace(/\/+$/, '')}/${path}?ct=${encodeURIComponent(contentType.toLowerCase())}&exp=${expiresAtMs}&sig=${signature}`;
|
|
100
|
+
return signedLinks(baseUrl).upload(key, contentType, expiresAtMs, signature);
|
|
93
101
|
}
|
|
94
102
|
/**
|
|
95
103
|
* RASTER image types only — an explicit allowlist, not `image/*`.
|
|
@@ -117,6 +125,18 @@ export function acceptableUploadType(contentType) {
|
|
|
117
125
|
* session's own namespace, so a signed PUT cannot write into anyone else's.
|
|
118
126
|
*/
|
|
119
127
|
export function createUploadUrlMinter(opts) {
|
|
128
|
+
// Fail at construction, not at mint. A minter built without somewhere to
|
|
129
|
+
// point is not a degraded minter, it is a broken one — and the symptom it
|
|
130
|
+
// used to produce (a URL reading `undefined/up/…`, or one pointing at a host
|
|
131
|
+
// that no longer exists) is unreadable from the client side. This is the
|
|
132
|
+
// "mint-with-missing-env fails loudly and atomically" guarantee: it throws
|
|
133
|
+
// before any key is derived and long before anything could be written, and
|
|
134
|
+
// the tool simply does not register.
|
|
135
|
+
const links = opts.links ?? (opts.baseUrl ? signedLinks(opts.baseUrl) : undefined);
|
|
136
|
+
if (!links) {
|
|
137
|
+
throw new Error('createUploadUrlMinter requires `links` (preferred) or `baseUrl`: there is nowhere to point a signed upload URL. ' +
|
|
138
|
+
'Hosted, both come from the blob store — check MCP_BLOB_BASE_URL is set on the deployment.');
|
|
139
|
+
}
|
|
120
140
|
const now = opts.now ?? (() => new Date());
|
|
121
141
|
const randomId = opts.randomId ?? (() => crypto.randomUUID().slice(0, 8));
|
|
122
142
|
const ttlMs = opts.ttlMs ?? UPLOAD_URL_TTL_MS;
|
|
@@ -132,7 +152,7 @@ export function createUploadUrlMinter(opts) {
|
|
|
132
152
|
const key = `${UPLOAD_KEY_PREFIX}/${tenant}/${day}/${randomId()}-${safeKeySegment(filename)}`;
|
|
133
153
|
const expiresAtMs = now().getTime() + ttlMs;
|
|
134
154
|
const signature = await opts.sign(key, ct, expiresAtMs);
|
|
135
|
-
return { url:
|
|
155
|
+
return { url: links.upload(key, ct, expiresAtMs, signature), key, contentType: ct, expiresAtMs };
|
|
136
156
|
},
|
|
137
157
|
};
|
|
138
158
|
}
|
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.7.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.7.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.7.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/gemini-mcp",
|
|
14
|
-
"version": "1.
|
|
14
|
+
"version": "1.7.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|