@chrischall/gemini-mcp 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.
@@ -63,11 +64,16 @@ export function registerFileTools(server, client) {
63
64
  const onDisk = client.mediaSink?.persistsFiles ?? true;
64
65
  const urlMaxBytes = onDisk ? UPLOAD_URL_MAX_BYTES_DISK : UPLOAD_URL_MAX_BYTES_HOSTED;
65
66
  server.registerTool('gemini_upload_file', {
66
- description: 'Upload an image (or video/audio) to the Gemini Files API ONCE and get back a reusable `files/<id>` reference. ' +
67
+ description: 'Upload a file — an image, reference photo, picture, screenshot, video or audio clip — to the Gemini Files API ' +
68
+ 'ONCE, and get back a reusable `file_uri` (`files/<id>`) to attach to later image, video or music generations. ' +
69
+ 'Keywords: upload, upload file, upload image, upload photo, attach, reference image, reference photo, ' +
70
+ 'file_uri, files api, image reference, reuse across calls. ' +
67
71
  'Use this instead of pasting base64 into a tool call: the reference is a short string, so no image bytes ever ' +
68
72
  'enter the conversation, and it can be reused across many generations until it expires (~48h). ' +
69
73
  'Provide exactly one of `url` (the server downloads it), `data_base64`' +
70
- (onDisk ? ', or `path` (a local file).' : '.') +
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).') +
71
77
  (onDisk
72
78
  ? ''
73
79
  : ' This connector also accepts raw bytes over HTTP: POST them to /upload with the same Authorization header ' +
@@ -86,6 +92,16 @@ export function registerFileTools(server, client) {
86
92
  .min(1)
87
93
  .optional()
88
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.'),
89
105
  path: z
90
106
  .string()
91
107
  .min(1)
@@ -102,10 +118,10 @@ export function registerFileTools(server, client) {
102
118
  confirm: schemaConfirm,
103
119
  },
104
120
  }, async (args, extra) => {
105
- 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);
106
122
  if (sources.length !== 1) {
107
123
  throw new McpToolError(sources.length === 0
108
- ? 'Provide exactly one of `url`, `data_base64`' + (onDisk ? ' or `path`.' : '.')
124
+ ? 'Provide exactly one of `url`, `data_base64`' + (onDisk ? ' or `path`.' : ' or `r2_key`.')
109
125
  : `Provide exactly one source, not ${sources.length} (${sources.join(', ')}).`);
110
126
  }
111
127
  if (args.path) {
@@ -128,6 +144,17 @@ export function registerFileTools(server, client) {
128
144
  // this tool takes video and audio too, so 'image' would be a lie.
129
145
  args.display_name ?? fileNameFromUrl(args.url, 'upload'));
130
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
+ }
131
158
  if (args.data_base64) {
132
159
  const { decodeImageInput } = await import('../images.js');
133
160
  const decoded = decodeImageInput(args.data_base64);
@@ -143,7 +170,7 @@ export function registerFileTools(server, client) {
143
170
  return uploadResult(uploaded);
144
171
  });
145
172
  server.registerTool('gemini_list_files', {
146
- description: 'List the files currently uploaded to the Gemini Files API under this API key, with their `files/<id>` references, ' +
173
+ 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
174
  'MIME types and expiry times. Retention is ~48h, so an entry that has vanished has expired rather than failed.',
148
175
  annotations: { readOnlyHint: true, openWorldHint: true },
149
176
  inputSchema: {
@@ -168,8 +195,42 @@ export function registerFileTools(server, client) {
168
195
  : 'No files uploaded (or all have expired — retention is ~48h). Upload one with gemini_upload_file.',
169
196
  });
170
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
+ }
171
232
  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 ' +
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 ' +
173
234
  'will then fail with a generic 404, so delete only references you are finished with.',
174
235
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
175
236
  inputSchema: {
@@ -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
- // Distinct seed per image (seed+0 for the single-image case == echoed seed)
72
- // so count>1 yields N *different* images, not N duplicates.
73
- const result = await client.generate({
74
- prompt: args.prompt,
75
- images: refInputs.length > 0 ? refInputs : undefined,
76
- model: args.model,
77
- aspectRatio: args.aspect_ratio,
78
- imageSize: args.image_size,
79
- seed: seed + i,
80
- thinkingLevel: args.thinking_level,
81
- googleSearch: args.google_search,
82
- videoUrl: video.videoUrl,
83
- videoMimeType: video.videoMimeType,
84
- timeoutMs: args.timeout_ms,
85
- });
86
- const { images: [img], text } = result;
87
- if (text && !capturedText)
88
- capturedText = text;
89
- if (result.grounding && !capturedGrounding)
90
- capturedGrounding = result.grounding;
91
- named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, '0')}` });
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;
@@ -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
- : 'This connector has no filesystem: there is no output dir and no `<image>.json` sidecar, so a lost response ' +
52
- 'cannot be recovered from disk and a 404 on a chained call cannot be re-anchored automatically. Capture the ' +
53
- 'returned `interaction_id` from every result, and use `async: true` + gemini_get_result for long generations. ';
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/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
- const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref, ...carried.inputs], seed: seed + i + 1, ...cfg });
86
- named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` });
87
- ref = img;
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 scenes = await Promise.all(scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master, ...carried.inputs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0])));
92
- scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` }));
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 });
@@ -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
@@ -265,9 +266,10 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
265
266
  return { content: blocks };
266
267
  }
267
268
  const sink = opts.sink ?? DEFAULT_SINK;
268
- const refs = await sink.persist(named.map((n) => ({ base: n.base, base64: n.media.base64, mimeType: n.media.mimeType })), {
269
+ const persisted = await sink.persist(named.map((n) => ({ base: n.base, base64: n.media.base64, mimeType: n.media.mimeType })), {
269
270
  output_dir: opts.output_dir,
270
271
  });
272
+ const refs = persisted.map((p) => p.ref);
271
273
  // `onWritten` (sidecars, the written-outputs set) is meaningful only when the
272
274
  // refs are real files. On a sink with no filesystem there is nothing to sit
273
275
  // next to, so it is skipped rather than handed URLs it would misinterpret.
@@ -279,8 +281,37 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
279
281
  // key is the same (`images`/`videos`/`audios`), so without this a caller would
280
282
  // reasonably read a URL as a file path.
281
283
  const storageNote = sink.note();
284
+ // Per-item detail (`url` / `r2_key` / `expires_at`) alongside the flat list,
285
+ // so a caller can reason about retention without parsing a URL. Only emitted
286
+ // where there IS extra detail — the disk sink's absolute paths say it all,
287
+ // and adding a parallel array there would change a stable result shape.
288
+ // A stored-but-unaddressable object is a configuration failure, not a mode.
289
+ // Saying so in the result beats handing back a bare key that reads like a
290
+ // filename — the silent-unopenable-ref problem this change exists to end.
291
+ const unavailable = persisted.some((p) => p.unavailable)
292
+ ? {
293
+ 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. ' +
294
+ 'This is a connector misconfiguration: set MEDIA_PUBLIC_BASE_URL, or use inline: true to receive the bytes directly.',
295
+ }
296
+ : {};
297
+ const detailed = persisted.filter((p) => p.key !== undefined);
298
+ const media = detailed.length === persisted.length && detailed.length > 0
299
+ ? {
300
+ media: persisted.map((p) => ({
301
+ url: p.ref,
302
+ ...(p.key ? { r2_key: p.key } : {}),
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}"` } : {}),
310
+ })),
311
+ }
312
+ : {};
282
313
  const storage = sink.persistsFiles ? {} : { storage: sink.kind, storage_note: storageNote };
283
- return textResult({ [`${kind}s`]: refs, ...note, ...storage, ...meta });
314
+ return textResult({ [`${kind}s`]: refs, ...media, ...unavailable, ...note, ...storage, ...meta });
284
315
  }
285
316
  /**
286
317
  * 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.0'; // x-release-please-version
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.2.0",
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.2.0",
9
+ "version": "1.4.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "1.2.0",
14
+ "version": "1.4.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },