@chrischall/gemini-mcp 1.1.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.
@@ -20,7 +20,7 @@ const NEW_REFERENCES_ONLY = 'NEW reference images only (e.g. a style or target p
20
20
  /**
21
21
  * On a chained call, split `images` into genuinely new references vs paths
22
22
  * this server itself generated (which the interaction already contains).
23
- * Unresolvable paths are kept so `gatherImageInputs` surfaces its usual error.
23
+ * Unresolvable paths are kept so `resolveImageInputs` surfaces its usual error.
24
24
  */
25
25
  function splitReattachedOutputs(images, writtenOutputs) {
26
26
  const kept = [];
@@ -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. " +
@@ -1,8 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { McpToolError } from '@chrischall/mcp-utils';
3
- import { slugify, baseName, gatherImageInputs } from '../images.js';
3
+ import { slugify, baseName } from '../images.js';
4
+ import { resolveImageInputs } from '../inputs.js';
4
5
  import { DEFAULT_MUSIC_MODEL } from '../models.js';
5
- import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
6
+ import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
6
7
  import { fingerprintRequest } from '../jobs.js';
7
8
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
8
9
  const MUSIC_MODELS = ['lyria-3-clip-preview', 'lyria-3-pro-preview'];
@@ -26,7 +27,9 @@ export function registerMusicTools(server, client) {
26
27
  model: z.enum(MUSIC_MODELS).optional().describe(`Lyria model (default: ${DEFAULT_MUSIC_MODEL}). Pro is longer-form and supports WAV.`),
27
28
  audio_format: z.enum(AUDIO_FORMATS).optional().describe('Output format (default mp3). wav is lyria-3-pro-preview-only.'),
28
29
  images: z.array(z.string().min(1)).optional().describe('Optional reference image path(s) to condition the music'),
29
- images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs'),
30
+ images_url: imagesUrlSchema('Reference images'),
31
+ images_file_uris: imagesFileUrisSchema('Reference images'),
32
+ images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation'),
30
33
  from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS clipboard as a reference'),
31
34
  filename: z.string().optional().describe('Base filename for the output audio (extension stripped; default: slugified prompt)'),
32
35
  output_dir: z.string().optional().describe('Directory to write audio to (default: $GEMINI_OUTPUT_DIR or cwd)'),
@@ -61,10 +64,11 @@ export function registerMusicTools(server, client) {
61
64
  const fingerprint = fingerprintRequest('gemini_music_generate', {
62
65
  model, prompt: args.prompt, audio_format: args.audio_format,
63
66
  images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
67
+ images_url: args.images_url, images_file_uris: args.images_file_uris,
64
68
  previous_interaction_id: previousInteractionId,
65
69
  });
66
70
  return client.session.jobs.dispatch({ toolName: 'gemini_music_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
67
- const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
71
+ const { inputs, report } = await resolveImageInputs(args, client);
68
72
  const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () => client.generateMusic({
69
73
  input: args.prompt,
70
74
  images: inputs.length ? inputs : undefined,
@@ -79,6 +83,8 @@ export function registerMusicTools(server, client) {
79
83
  meta.previous_interaction_id = previousInteractionId;
80
84
  if (r.text)
81
85
  meta.text = r.text;
86
+ if (report)
87
+ meta.image_inputs = report;
82
88
  meta.hint = `To continue this track, call gemini_music_generate again with previous_interaction_id: "${r.id}" (or continue_last: true).`;
83
89
  const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
84
90
  const named = r.audios.map((media, i) => ({
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import { textResult, McpToolError, readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveVideoPath, videoMimeType } from '../images.js';
4
4
  import { IMAGE_URL_MAX_BYTES } from '../fetch-image.js';
5
+ import { wholeMb } from '../bytes.js';
5
6
  import { createDiskSink } from '../storage/media.js';
6
7
  /**
7
8
  * Fallback sink for call sites that pass no explicit one (every pre-existing
@@ -64,7 +65,7 @@ export function imagesUrlSchema(label = 'Reference images') {
64
65
  .optional()
65
66
  .describe(`${label} as public https URLs — the SERVER downloads them, so no image bytes travel through the conversation. ` +
66
67
  'Preferred over images_base64, which costs ~14k tokens per photo and breaks if a file read was truncated. ' +
67
- `Max ${Math.round(IMAGE_URL_MAX_BYTES / (1024 * 1024))}MB each; must be a directly-linked image (Content-Type image/*).`);
68
+ `Max ${wholeMb(IMAGE_URL_MAX_BYTES)}MB each; must be a directly-linked image (Content-Type image/*).`);
68
69
  }
69
70
  /**
70
71
  * Reference images already in the Gemini Files API. The cheapest form there is:
@@ -264,9 +265,10 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
264
265
  return { content: blocks };
265
266
  }
266
267
  const sink = opts.sink ?? DEFAULT_SINK;
267
- const refs = await sink.persist(named.map((n) => ({ base: n.base, base64: n.media.base64, mimeType: n.media.mimeType })), {
268
+ const persisted = await sink.persist(named.map((n) => ({ base: n.base, base64: n.media.base64, mimeType: n.media.mimeType })), {
268
269
  output_dir: opts.output_dir,
269
270
  });
271
+ const refs = persisted.map((p) => p.ref);
270
272
  // `onWritten` (sidecars, the written-outputs set) is meaningful only when the
271
273
  // refs are real files. On a sink with no filesystem there is nothing to sit
272
274
  // next to, so it is skipped rather than handed URLs it would misinterpret.
@@ -278,8 +280,31 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
278
280
  // key is the same (`images`/`videos`/`audios`), so without this a caller would
279
281
  // reasonably read a URL as a file path.
280
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
+ : {};
281
306
  const storage = sink.persistsFiles ? {} : { storage: sink.kind, storage_note: storageNote };
282
- return textResult({ [`${kind}s`]: refs, ...note, ...storage, ...meta });
307
+ return textResult({ [`${kind}s`]: refs, ...media, ...unavailable, ...note, ...storage, ...meta });
283
308
  }
284
309
  /**
285
310
  * Image convenience wrapper over {@link emitMedia} — preserves the original
@@ -1,8 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { McpToolError } from '@chrischall/mcp-utils';
3
- import { slugify, baseName, gatherImageInputs } from '../images.js';
3
+ import { slugify, baseName } from '../images.js';
4
+ import { resolveImageInputs } from '../inputs.js';
4
5
  import { DEFAULT_VIDEO_MODEL } from '../models.js';
5
- import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
6
+ import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
6
7
  import { fingerprintRequest } from '../jobs.js';
7
8
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
8
9
  /** omni's own aspect-ratio enum — NOT the image tools' ASPECT_RATIOS. */
@@ -27,7 +28,9 @@ export function registerVideoTools(server, client) {
27
28
  aspect_ratio: z.enum(VIDEO_ASPECT_RATIOS).optional().describe('Output aspect ratio (omni: 16:9 or 9:16)'),
28
29
  task: z.enum(VIDEO_TASKS).optional().describe('text_to_video (default), image_to_video / reference_to_video (need image input), or edit (needs previous_interaction_id)'),
29
30
  images: z.array(z.string().min(1)).optional().describe('Reference image path(s) for image_to_video / reference_to_video'),
30
- images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs'),
31
+ images_url: imagesUrlSchema('Reference stills'),
32
+ images_file_uris: imagesFileUrisSchema('Reference stills'),
33
+ images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation'),
31
34
  from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS clipboard as a reference'),
32
35
  filename: z.string().optional().describe('Base filename for the output video (extension stripped; default: slugified prompt)'),
33
36
  output_dir: z.string().optional().describe('Directory to write the video to (default: $GEMINI_OUTPUT_DIR or cwd)'),
@@ -63,10 +66,11 @@ export function registerVideoTools(server, client) {
63
66
  const fingerprint = fingerprintRequest('gemini_video_generate', {
64
67
  model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, task: args.task,
65
68
  images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
69
+ images_url: args.images_url, images_file_uris: args.images_file_uris,
66
70
  previous_interaction_id: previousInteractionId,
67
71
  });
68
72
  return client.session.jobs.dispatch({ toolName: 'gemini_video_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
69
- const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
73
+ const { inputs, report } = await resolveImageInputs(args, client);
70
74
  const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client.generateVideo({
71
75
  input: args.prompt,
72
76
  images: inputs.length ? inputs : undefined,
@@ -82,6 +86,8 @@ export function registerVideoTools(server, client) {
82
86
  meta.previous_interaction_id = previousInteractionId;
83
87
  if (r.text)
84
88
  meta.text = r.text;
89
+ if (report)
90
+ meta.image_inputs = report;
85
91
  meta.hint = `To edit this video, call gemini_video_generate again with task: "edit" and previous_interaction_id: "${r.id}" (or continue_last: true).`;
86
92
  const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
87
93
  const named = r.videos.map((media, i) => ({
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.1.0'; // x-release-please-version
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.1.0",
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.1.0",
9
+ "version": "1.3.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "1.1.0",
14
+ "version": "1.3.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -97,6 +97,9 @@ Note: Image generation requires a billing-enabled Google Cloud project.
97
97
 
98
98
  ### Reference images — four ways in, one that costs context
99
99
 
100
+ These apply to **every** tool that takes a reference image: the four image tools, plus
101
+ `gemini_video_generate` (reference stills) and `gemini_music_generate`.
102
+
100
103
  | Parameter | Where the bytes travel | Context cost |
101
104
  |---|---|---|
102
105
  | `images_url` (`master_images_url`) | the **server** downloads the https URL | none |
@@ -142,8 +145,8 @@ curl -X POST https://<connector>/upload \
142
145
  ### Video & Music (preview — funded account)
143
146
  | Tool | Description |
144
147
  |------|-------------|
145
- | `gemini_video_generate(prompt, aspect_ratio?, task?, images?, images_base64?, from_clipboard?, previous_interaction_id?, continue_last?, model?, filename?, output_dir?, timeout_ms?, idempotency_key?, async?)` | Generate a short video via the Gemini omni model: `text_to_video` (default), `image_to_video` / `reference_to_video` (supply reference image[s]), or `edit` (with `previous_interaction_id` / `continue_last`). `aspect_ratio` is `16:9` or `9:16`. Written to disk as **MP4**. Runs long — use `async: true` + `gemini_get_result`, or raise `timeout_ms`. |
146
- | `gemini_music_generate(prompt, model?, audio_format?, images?, images_base64?, from_clipboard?, previous_interaction_id?, continue_last?, filename?, output_dir?, inline?, timeout_ms?, idempotency_key?, async?)` | Generate music from a prompt (mood/genre/instruments/structure/lyrics) via a Lyria model: `lyria-3-clip-preview` (~30s, default) or `lyria-3-pro-preview` (longer, WAV-capable). `audio_format` `mp3` (default) or `wav` (**Pro-only**). Written to disk as MP3/WAV, or returned inline. |
148
+ | `gemini_video_generate(prompt, aspect_ratio?, task?, images_url?, images_file_uris?, images?, images_base64?, from_clipboard?, previous_interaction_id?, continue_last?, model?, filename?, output_dir?, timeout_ms?, idempotency_key?, async?)` | Generate a short video via the Gemini omni model: `text_to_video` (default), `image_to_video` / `reference_to_video` (supply reference image[s]), or `edit` (with `previous_interaction_id` / `continue_last`). `aspect_ratio` is `16:9` or `9:16`. Written to disk as **MP4**. Runs long — use `async: true` + `gemini_get_result`, or raise `timeout_ms`. |
149
+ | `gemini_music_generate(prompt, model?, audio_format?, images_url?, images_file_uris?, images?, images_base64?, from_clipboard?, previous_interaction_id?, continue_last?, filename?, output_dir?, inline?, timeout_ms?, idempotency_key?, async?)` | Generate music from a prompt (mood/genre/instruments/structure/lyrics) via a Lyria model: `lyria-3-clip-preview` (~30s, default) or `lyria-3-pro-preview` (longer, WAV-capable). `audio_format` `mp3` (default) or `wav` (**Pro-only**). Written to disk as MP3/WAV, or returned inline. |
147
150
 
148
151
  ### Async / idempotency (any generation tool)
149
152
  | Tool | Description |