@chrischall/gemini-mcp 1.12.0 → 1.14.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.
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { textResult, McpToolError, readEnvVar } from '@chrischall/mcp-utils';
2
+ import { McpToolError, minifiedResult, readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveVideoPath, videoMimeType } from '../images.js';
4
4
  import { IMAGE_URL_MAX_BYTES, IMAGE_INLINE_MAX_BYTES } from '../fetch-image.js';
5
5
  import { bytesToBase64, base64ToBytes, wholeMb } from '../bytes.js';
@@ -71,19 +71,15 @@ export function resolveAspectRatio(opts, supported) {
71
71
  export const orientationSchema = z
72
72
  .enum(ORIENTATIONS)
73
73
  .optional()
74
- .describe('Shape of the output, in plain terms: "landscape" (wide, 16:9), "portrait" (tall, 9:16) or "square" (1:1). ' +
75
- 'Use this for a request phrased as landscape/portrait/vertical/horizontal. For any other proportion — ' +
76
- '35mm photo (3:2), print (4:3), social (4:5), cinematic (21:9) — name it with aspect_ratio instead, ' +
77
- 'which overrides this when both are given.');
74
+ .describe('Output shape in plain terms: landscape (16:9), portrait (9:16) or square (1:1). For any other proportion — ' +
75
+ '3:2, 4:3, 4:5, 21:9 — use aspect_ratio, which wins if both are given.');
78
76
  /**
79
77
  * When to pick which Nano Banana model — shared by every tool's `model` param
80
78
  * so the guidance can't drift between them.
81
79
  */
82
- export const MODEL_CHOICE_GUIDE = 'gemini-3.1-flash-image (Nano Banana 2) is the versatile generalist workhorse — balances speed with ' +
83
- 'state-of-the-art 4K generation, world knowledge, and reliable text rendering; excels at multi-reference-image ' +
84
- 'processing and consistency. gemini-3-pro-image (Nano Banana Pro) is the premium choice for the most complex ' +
85
- 'visual tasks — highest world knowledge, advanced localization, accurate brand consistency, precision creative control. ' +
86
- 'gemini-3.1-flash-lite-image (Nano Banana 2 Lite) is the fastest/cheapest for simple tasks (1K only, no search grounding).';
80
+ export const MODEL_CHOICE_GUIDE = 'gemini-3.1-flash-image (Nano Banana 2, the default) is the generalist: fast, 4K, reliable text, strong ' +
81
+ 'multi-reference consistency. gemini-3-pro-image (Pro) is for the hardest work — best world knowledge, brand ' +
82
+ 'precision. gemini-3.1-flash-lite-image (Lite) is cheapest: 1K only, no search grounding.';
87
83
  /**
88
84
  * Per-call upstream timeout override, shared by every generation tool
89
85
  * (spread via {@link sharedImageSchema}; `gemini_interact` imports it directly
@@ -94,7 +90,7 @@ export const timeoutMsSchema = z
94
90
  .int()
95
91
  .positive()
96
92
  .optional()
97
- .describe('Upstream request timeout in ms for this call (default: $GEMINI_TIMEOUT_MS, else 60000 — or 120000 when image_size is 4K, which routinely runs past 60s)');
93
+ .describe('Upstream timeout in ms for this call (default $GEMINI_TIMEOUT_MS, else 60000 — 120000 at 4K, which runs past 60s)');
98
94
  /**
99
95
  * Idempotency key shared by every generation tool. A repeat call carrying the
100
96
  * same key returns the recorded result (`reused: true`) instead of billing a
@@ -105,7 +101,7 @@ export const idempotencyKeySchema = z
105
101
  .string()
106
102
  .min(1)
107
103
  .optional()
108
- .describe('Opaque idempotency key: a repeat call with the same key returns the recorded result (reused: true) instead of billing a new generation. Set it when retrying after a host timeout (-32001) to avoid a duplicate charge.');
104
+ .describe('Repeat calls with this key return the recorded result (reused: true) instead of billing a new generation. Set it when retrying after a host timeout (-32001).');
109
105
  /**
110
106
  * Async escape hatch shared by every generation tool: return a `job_id`
111
107
  * immediately so a long (Pro/4K) generation can't hit a host's fixed tools/call
@@ -114,10 +110,9 @@ export const idempotencyKeySchema = z
114
110
  export const asyncSchema = z
115
111
  .boolean()
116
112
  .optional()
117
- .describe('Run in the background and return a job_id immediately instead of the image, so a long (Pro/4K) generation cannot hit ' +
118
- 'the host tools/call timeout (-32001). Poll gemini_get_result with the job_id to fetch the result. ' +
119
- 'PREFER `max_wait_ms` on the hosted connector: it runs where the executor is only guaranteed to stay alive while the ' +
120
- 'request is open, so this option is served there as a bounded wait rather than an immediate hand-off.');
113
+ .describe('Return a job_id immediately instead of the result, so a long generation cannot hit the host tools/call timeout ' +
114
+ '(-32001); poll gemini_get_result. On the hosted connector prefer max_wait_ms — the executor only lives while a ' +
115
+ 'request is open, so async is served there as a bounded wait.');
121
116
  /**
122
117
  * Wait-then-hand-off budget shared by every generation tool: wait up to the
123
118
  * budget for the in-band result, and past it return a `{ job_id }` handle
@@ -130,9 +125,9 @@ export const maxWaitMsSchema = z
130
125
  .positive()
131
126
  .max(600_000)
132
127
  .optional()
133
- .describe('Wait up to this many ms for the result; if generation is still running when the budget expires, return ' +
134
- '{ job_id, status: "running" } immediately instead (poll gemini_get_result). Keeps fast results in-band while a ' +
135
- 'slow batch can never trip the host tools/call timeout (-32001) — e.g. 20000 for multi-image sets. Ignored when async is set.');
128
+ .describe('Wait up to this many ms in-band, then hand back { job_id, status: "running" } to poll with gemini_get_result ' +
129
+ '(e.g. 20000 for multi-image sets). Keeps fast results inline and slow ones off the host timeout (-32001). ' +
130
+ 'Ignored when async is set.');
136
131
  /**
137
132
  * Reference images already in THIS connector's object store, by `r2_key`.
138
133
  * Hosted connector only — the server reads its own bucket (tenant-gated), so
@@ -142,25 +137,24 @@ export function imagesR2KeysSchema(label = 'Reference images') {
142
137
  return z
143
138
  .array(z.string().min(1))
144
139
  .optional()
145
- .describe(`${label} by r2_key from THIS connector's store: a signed upload (gemini_get_upload_url → curl PUT) or an earlier ` +
146
- "generation's media[].r2_key. The server reads its own bucket directly — no bytes in the conversation, no signed URL, " +
147
- 'no ~48h Files API expiry. Hosted connector only.');
140
+ .describe(`${label} by r2_key from this connector's store: a signed upload (gemini_get_upload_url → curl PUT) or an earlier ` +
141
+ "generation's media[].r2_key. The server reads its own bucket — no bytes in the conversation, no ~48h expiry. " +
142
+ 'Hosted connector only.');
148
143
  }
149
144
  /** Saved-character names attached to a generation (hosted connector only). */
150
145
  export const charactersSchema = z
151
146
  .array(z.string().min(1))
152
147
  .max(8)
153
148
  .optional()
154
- .describe('Names of saved characters (see gemini_list_characters / gemini_save_character): each one\'s reference image and ' +
155
- 'description are attached to the request automatically, keeping recurring subjects consistent without re-sending anything. ' +
156
- 'Hosted connector only.');
149
+ .describe('Names of saved characters (gemini_list_characters): each one\'s reference image and description are attached ' +
150
+ 'automatically, keeping recurring subjects consistent without re-sending anything. Hosted connector only.');
157
151
  /** Saved-style name applied to a generation (hosted connector only). */
158
152
  export const styleSchema = z
159
153
  .string()
160
154
  .min(1)
161
155
  .optional()
162
- .describe('Name of a saved style preset (see gemini_list_styles / gemini_save_style): its prompt fragment — and reference image, ' +
163
- 'if it has one — is applied to the request automatically. Hosted connector only.');
156
+ .describe('Name of a saved style preset (gemini_list_styles): its prompt fragment, and reference image if it has one, are ' +
157
+ 'applied automatically. Hosted connector only.');
164
158
  /**
165
159
  * Turn `characters: ["finn", …]` / `style: "…"` into reference-image inputs
166
160
  * plus prompt text, from the account's saved library.
@@ -257,9 +251,23 @@ export function imagesUrlSchema(label = 'Reference images') {
257
251
  return z
258
252
  .array(z.string().url())
259
253
  .optional()
260
- .describe(`${label} as public https URLs — the SERVER downloads them, so no image bytes travel through the conversation. ` +
261
- 'Preferred over images_base64, which costs ~14k tokens per photo and breaks if a file read was truncated. ' +
262
- `Max ${wholeMb(IMAGE_URL_MAX_BYTES)}MB each; must be a directly-linked image (Content-Type image/*).`);
254
+ .describe(`${label} as public https URLs — the server downloads them, so no image bytes cross the conversation. Preferred ` +
255
+ `over images_base64, which costs ~14k tokens per photo. Max ${wholeMb(IMAGE_URL_MAX_BYTES)}MB each, Content-Type image/*.`);
256
+ }
257
+ /**
258
+ * Raw base64 / data URIs — the last-resort input form, and the only one whose
259
+ * bytes have already crossed the conversation by the time the server sees
260
+ * them. The description says so, and says what to do instead NEXT time: the
261
+ * funnel uploads each entry once and reports the `files/<id>` back, so the
262
+ * repeat reference costs a short string rather than another photo.
263
+ */
264
+ export function imagesBase64Schema(label = 'Reference images', prefix = 'images') {
265
+ return z
266
+ .array(z.string().min(1))
267
+ .optional()
268
+ .describe(`${label} as base64 strings or data URIs. Last resort — about 14k tokens per photo; prefer ${prefix}_url or ` +
269
+ `${prefix}_file_uris. The server uploads each one and reports a file_uri under image_inputs: pass that to ` +
270
+ `${prefix}_file_uris next time instead of re-sending the bytes.`);
263
271
  }
264
272
  /**
265
273
  * Reference images already in the Gemini Files API. The cheapest form there is:
@@ -269,9 +277,8 @@ export function imagesFileUrisSchema(label = 'Reference images') {
269
277
  return z
270
278
  .array(z.string().min(1))
271
279
  .optional()
272
- .describe(`${label} by Gemini Files API reference ("files/<id>", or the full uri) from gemini_upload_file or POST /upload. ` +
273
- 'Upload once, then reference it across as many calls as you like — no bytes are re-sent and none enter the conversation. ' +
274
- 'Files are retained ~48h, after which the reference stops resolving.');
280
+ .describe(`${label} as Files API references ("files/<id>" or the full uri) from gemini_upload_file. Upload once and reuse ` +
281
+ 'across calls with no bytes in the conversation; retained ~48h, after which the reference stops resolving.');
275
282
  }
276
283
  /**
277
284
  * The model/aspect/size/output fields every generation tool shares. Defined once
@@ -294,7 +301,7 @@ export const sharedImageSchema = {
294
301
  orientation: orientationSchema,
295
302
  image_size: z.enum(IMAGE_SIZES).optional().describe('Output resolution (512 = 0.5K, Flash-only)'),
296
303
  output_dir: z.string().optional().describe('Directory to write images to (default: $GEMINI_OUTPUT_DIR or cwd)'),
297
- inline: z.boolean().optional().describe('Return base64 images inline instead of writing to disk'),
304
+ inline: z.boolean().optional().describe('Return the image as an inline image block you can SEE, instead of a path (stdio) or link (hosted). The default costs nothing to carry and hands back a reference you can reuse; use this when you need to check the result yourself. On the hosted connector gemini_view_media does the same for an image you already have'),
298
305
  seed: z.number().int().optional().describe('Seed for reproducible generation; random if omitted'),
299
306
  thinking_level: z.enum(['minimal', 'high']).optional().describe('Reasoning depth (Gemini 3 models); higher can help complex/structural edits'),
300
307
  google_search: z.boolean().optional().describe('Ground the image in live Google Search results (current events, weather, data)'),
@@ -499,6 +506,28 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
499
506
  // next to, so it is skipped rather than handed URLs it would misinterpret.
500
507
  if (sink.persistsFiles)
501
508
  await onWritten?.(refs);
509
+ // The hosted twin of the `<image>.json` sidecar `onWritten` writes on disk.
510
+ // Same purpose: the interaction id has to outlive the response, or a dropped
511
+ // turn ends the chain and a 404 has nothing to re-anchor on. Written for
512
+ // EVERY stored object, since a set's scenes are as worth identifying later
513
+ // as its master. Best-effort inside the sink, which never throws.
514
+ const record = {
515
+ ...opts.sidecar,
516
+ // The kind is what keeps the three chains apart. `gemini_interact`'s
517
+ // continue_last reads the newest recorded id, and video and music write
518
+ // records too — without this it could resume an omni interaction, or a
519
+ // Lyria one, where a chained call is a documented 400.
520
+ kind,
521
+ ...(typeof meta?.interaction_id === 'string' ? { interaction_id: meta.interaction_id } : {}),
522
+ ...(typeof meta?.model === 'string' ? { model: meta.model } : {}),
523
+ };
524
+ // `kind` alone is not worth an object — a record earns its place by carrying
525
+ // something a later caller can act on or recognise.
526
+ if (!sink.persistsFiles && sink.writeSidecar && (record.interaction_id || record.prompt)) {
527
+ for (const p of persisted)
528
+ if (p.key)
529
+ await sink.writeSidecar(p.key, record);
530
+ }
502
531
  // Note a downgraded inline-video request so the caller isn't left wondering.
503
532
  const note = opts.inline && kind === 'video' ? { inline_unsupported: 'video has no MCP inline content type — written to disk instead' } : {};
504
533
  // Say where the bytes actually went whenever they are NOT local paths — the
@@ -535,7 +564,7 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
535
564
  }
536
565
  : {};
537
566
  const storage = sink.persistsFiles ? {} : { storage: sink.kind, storage_note: storageNote };
538
- return textResult({ [`${kind}s`]: refs, ...media, ...unavailable, ...note, ...storage, ...meta });
567
+ return minifiedResult({ [`${kind}s`]: refs, ...media, ...unavailable, ...note, ...storage, ...meta });
539
568
  }
540
569
  /**
541
570
  * Image convenience wrapper over {@link emitMedia} — preserves the original
@@ -605,8 +634,9 @@ export async function persistBundle(named, sink, base) {
605
634
  }
606
635
  }
607
636
  /**
608
- * Inputs that can only come off a local filesystem. On the hosted connector
609
- * (a Cloudflare Worker) none of them exist:
637
+ * Inputs that can only come off a local filesystem. The hosted deployment runs
638
+ * as a child process on mcp-host with no access to the CALLER's machine, so
639
+ * none of them exist there:
610
640
  *
611
641
  * - `images` / `master_images` — file paths read with `node:fs`.
612
642
  * - `from_clipboard` — shells out to `osascript`/`sips` via `child_process`.
@@ -642,12 +672,13 @@ export function assertLocalInputsAvailable(sink, args) {
642
672
  // exactly when it is needed.
643
673
  const fix = 'Reference images WITHOUT putting bytes in the conversation, in order of preference: ' +
644
674
  '(1) `images_url` — public https URLs the server fetches itself; ' +
645
- '(2) `images_file_uris` — upload once with `gemini_upload_file` (it takes a `url` or `data_base64`) and reuse the returned files/… uri; ' +
646
- '(3) `POST /upload` on this connector with the raw bytes as the body ' +
647
- "(`curl -X POST <connector-url>/upload -H 'Authorization: Bearer <token>' -H 'Content-Type: image/jpeg' --data-binary @photo.jpg`), " +
648
- 'then pass the returned file_uri. ' +
649
- 'As a last resort `images_base64` still works, but it costs ~14k tokens per photo and breaks on a truncated read. ' +
675
+ '(2) `images_r2_keys` — mint a signed upload URL with `gemini_get_upload_url`, PUT the file to it, then pass the r2_key back ' +
676
+ '(the server reads its own store; nothing crosses the conversation); ' +
677
+ '(3) `images_file_uris` — upload once with `gemini_upload_file` (it takes a `url` or `data_base64`) and reuse the returned files/… uri. ' +
678
+ 'If you CANNOT make HTTP requests at all, routes (1) and (2) are closed to you and the only way to reference a local file ' +
679
+ 'is `images_base64`, which costs ~14k tokens per photo and breaks on a truncated read — the server uploads it once and ' +
680
+ 'returns a files/… uri under image_inputs to reuse on later calls. ' +
650
681
  'Reference video by `video_url` (a public YouTube URL, or a files/… uri uploaded elsewhere) instead of `video_path`.';
651
682
  throw new McpToolError(`${unavailable.join(', ')} ${unavailable.length > 1 ? 'are' : 'is'} unavailable on the hosted connector: ` +
652
- `it runs on a Cloudflare Worker, which has no filesystem and no local clipboard. ${fix}`, { hint: fix });
683
+ `it runs on a remote machine with no access to your filesystem or clipboard. ${fix}`, { hint: fix });
653
684
  }
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { textResult } from '@chrischall/mcp-utils';
2
+ import { minifiedResult } 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
5
  import { step } from '../errors.js';
@@ -9,12 +9,12 @@ import { step } from '../errors.js';
9
9
  *
10
10
  * curl -sS -X PUT -H "Content-Type: image/jpeg" --data-binary @photo.jpg "<upload_url>"
11
11
  *
12
- * This closes the last gap in the no-bytes-in-the-conversation story. The two
13
- * existing byte routes each exclude a common caller: `images_base64` costs
14
- * ~14k tokens per photo and corrupts silently on a truncated read, and
15
- * `POST /upload` needs the MCP session's OAuth bearer token, which a sandboxed
16
- * shell doesn't have. A signed URL is mintable by the session and usable by
17
- * the shell.
12
+ * This closes the last gap in the no-bytes-in-the-conversation story. The
13
+ * alternatives each exclude a common caller: `images_base64` costs ~14k tokens
14
+ * per photo and corrupts silently on a truncated read, and the Worker's old
15
+ * `POST /upload` (retired with the Worker) needed the MCP session's OAuth
16
+ * bearer token, which a sandboxed shell does not have. A signed URL is mintable
17
+ * by the session and usable by the shell.
18
18
  *
19
19
  * Hosted only — stdio has no HTTP surface to PUT against (and no need: it reads
20
20
  * local paths directly). Gated on `client.uploadUrls`, the same pattern as
@@ -71,7 +71,7 @@ export function registerUploadUrlTools(server, client) {
71
71
  // between them a mint failure can no longer reach the client as a bare
72
72
  // "Error occurred during tool execution".
73
73
  const minted = await step(`minting a signed upload URL for ${args.filename} (${args.content_type})`, () => minter.mint(args.filename, args.content_type));
74
- return textResult({
74
+ return minifiedResult({
75
75
  upload_url: minted.url,
76
76
  method: 'PUT',
77
77
  r2_key: minted.key,
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
2
+ import { minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
3
3
  import { PRICED_AT } from '../pricing.js';
4
4
  /**
5
5
  * What this session has spent, in tokens.
@@ -51,7 +51,7 @@ export function registerUsageTools(server, client) {
51
51
  client.session.costUsd = undefined;
52
52
  client.session.pricedCalls = 0;
53
53
  }
54
- return textResult({
54
+ return minifiedResult({
55
55
  usage: usage ?? null,
56
56
  billed_calls: calls,
57
57
  estimated_cost_usd: costUsd ?? null,
@@ -3,13 +3,20 @@ import { McpToolError } from '@chrischall/mcp-utils';
3
3
  import { slugify, baseName } from '../images.js';
4
4
  import { resolveImageInputs } from '../inputs.js';
5
5
  import { DEFAULT_VIDEO_MODEL } from '../models.js';
6
- import { emitMedia, reportShape, resolveAspectRatio, orientationSchema, timeoutMsSchema, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
6
+ import { emitMedia, reportShape, resolveAspectRatio, orientationSchema, timeoutMsSchema, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesBase64Schema } from './shared.js';
7
7
  import { fingerprintRequest } from '../jobs.js';
8
8
  import { attachCost } from '../pricing.js';
9
9
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
10
10
  /** omni's own aspect-ratio enum — NOT the image tools' ASPECT_RATIOS. */
11
11
  const VIDEO_ASPECT_RATIOS = ['16:9', '9:16'];
12
- const VIDEO_TASKS = ['text_to_video', 'image_to_video', 'reference_to_video', 'edit'];
12
+ const VIDEO_TASKS = ['text_to_video', 'image_to_video', 'reference_to_video', 'edit', 'extend'];
13
+ /**
14
+ * omni's output resolutions (GA, verified live 2026-09-09). Unlike `delivery`
15
+ * this is content and not transport: it is the cost lever on the whole video
16
+ * path, since omni bills video output per token and a 10s clip came back at
17
+ * ~$0.34 in 360p against roughly triple that in 720p.
18
+ */
19
+ const VIDEO_RESOLUTIONS = ['360p', '720p', '1080p', '4k'];
13
20
  // `delivery` is transport, not content: uri hands back a Files API link the
14
21
  // client downloads with the api key, inline returns base64 capped at ~4MB.
15
22
  // Left unset here so the client's verified default (uri) applies; this is an
@@ -24,20 +31,23 @@ const VIDEO_DELIVERY = ['inline', 'uri'];
24
31
  // "last video" and a "last image" are distinct chains. See src/session.ts.
25
32
  export function registerVideoTools(server, client) {
26
33
  server.registerTool('gemini_video_generate', {
27
- description: 'Generate a short video via the Gemini omni model (preview): text→video, image→video / reference→video ' +
28
- '(supply reference image[s]), or edit a prior video (task: "edit" + previous_interaction_id / continue_last). ' +
29
- 'Output is written to disk as MP4 (video has no inline MCP block). Video runs long — use `async: true` to get a ' +
30
- 'job_id immediately and poll gemini_get_result, or raise `timeout_ms`. Preview model: needs a funded account.',
34
+ description: 'Generate a short video (~10s) via the Gemini omni model: text→video, image→video / reference→video ' +
35
+ '(supply reference image[s]), interpolate between two stills (pass first frame then last frame as images), ' +
36
+ 'or continue a prior video (task: "edit" or "extend" + previous_interaction_id / continue_last; extensions ' +
37
+ 'add ~3-10s each, to ~40s total). Cost scales with `resolution` — draft at 360p, keep at 1080p/4k. ' +
38
+ 'Output is written to disk as MP4 (video has no inline MCP block). Video runs long — give it a `max_wait_ms` budget ' +
39
+ '(or `async: true` + gemini_get_result on a local install), or raise `timeout_ms`. Needs a funded account.',
31
40
  annotations: { readOnlyHint: false, openWorldHint: true },
32
41
  inputSchema: {
33
42
  prompt: z.string().min(1).describe('Description of the video to generate (or the edit instruction when task=edit)'),
34
43
  aspect_ratio: z.enum(VIDEO_ASPECT_RATIOS).optional().describe('Exact output aspect ratio (omni: 16:9 or 9:16). `orientation` is the plain-language shorthand; this wins if both are given.'),
35
44
  orientation: orientationSchema,
36
- 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)'),
45
+ task: z.enum(VIDEO_TASKS).optional().describe('text_to_video (default), image_to_video / reference_to_video (need image input), or edit / extend (need previous_interaction_id)'),
46
+ resolution: z.enum(VIDEO_RESOLUTIONS).optional().describe('Output resolution (default 720p). Video is billed per output token, so 360p costs roughly a third of 720p — use it for drafts'),
37
47
  images: z.array(z.string().min(1)).optional().describe('Reference image path(s) for image_to_video / reference_to_video'),
38
48
  images_url: imagesUrlSchema('Reference stills'),
39
49
  images_file_uris: imagesFileUrisSchema('Reference stills'),
40
- 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'),
50
+ images_base64: imagesBase64Schema(),
41
51
  from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS clipboard as a reference'),
42
52
  filename: z.string().optional().describe('Base filename for the output video (extension stripped; default: slugified prompt)'),
43
53
  output_dir: z.string().optional().describe('Directory to write the video to (default: $GEMINI_OUTPUT_DIR or cwd)'),
@@ -78,7 +88,9 @@ export function registerVideoTools(server, client) {
78
88
  // fingerprint so "portrait" and "9:16" dedup to one billable job.
79
89
  const aspectRatio = resolveAspectRatio(args, VIDEO_ASPECT_RATIOS);
80
90
  const fingerprint = fingerprintRequest('gemini_video_generate', {
81
- model, prompt: args.prompt, aspect_ratio: aspectRatio, task: args.task,
91
+ // `resolution` is IN the fingerprint (a 4k render is not the 360p one)
92
+ // where `delivery` is not (same bytes, different transport).
93
+ model, prompt: args.prompt, aspect_ratio: aspectRatio, resolution: args.resolution, task: args.task,
82
94
  images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
83
95
  images_url: args.images_url, images_file_uris: args.images_file_uris,
84
96
  previous_interaction_id: previousInteractionId,
@@ -90,6 +102,7 @@ export function registerVideoTools(server, client) {
90
102
  images: inputs.length ? inputs : undefined,
91
103
  model: args.model,
92
104
  aspectRatio,
105
+ resolution: args.resolution,
93
106
  task: args.task,
94
107
  previousInteractionId,
95
108
  timeoutMs: args.timeout_ms,
@@ -104,6 +117,10 @@ export function registerVideoTools(server, client) {
104
117
  // The RESOLVED ratio: omni takes only 16:9/9:16, so a caller who asked
105
118
  // in words can see which of the two their request became.
106
119
  const meta = reportShape({ model, interaction_id: r.id }, { ...args, aspect_ratio: aspectRatio });
120
+ // Echoed for the same reason the resolved ratio is: it changed what
121
+ // the caller was billed, so it has to be visible in the result.
122
+ if (args.resolution)
123
+ meta.resolution = args.resolution;
107
124
  if (previousInteractionId)
108
125
  meta.previous_interaction_id = previousInteractionId;
109
126
  if (r.usage)
@@ -119,7 +136,7 @@ export function registerVideoTools(server, client) {
119
136
  media,
120
137
  base: r.videos.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
121
138
  }));
122
- return emitMedia(named, 'video', { output_dir: args.output_dir, sink: client.mediaSink }, meta);
139
+ return emitMedia(named, 'video', { output_dir: args.output_dir, sink: client.mediaSink, sidecar: { prompt: args.prompt } }, meta);
123
140
  });
124
141
  });
125
142
  }
@@ -5,9 +5,9 @@
5
5
  * The problem these solve is the mirror image of `/media`: a signed GET lets a
6
6
  * shell *download* generated media with no auth header, but uploading a
7
7
  * reference photo still required `images_base64` (~14k tokens per JPEG, silently
8
- * corrupting on a truncated read) or `POST /upload` (which 401s from any shell
9
- * that doesn't hold the MCP session's bearer token). A signed PUT URL closes
10
- * that gap: the authenticated MCP session mints it, and then a bare
8
+ * corrupting on a truncated read) or the Worker's `POST /upload`, which 401'd
9
+ * from any shell without the MCP session's bearer token and is gone entirely
10
+ * now that the Worker is. A signed PUT URL closes that gap: the authenticated MCP session mints it, and then a bare
11
11
  * `curl -X PUT --data-binary @photo.jpg "<url>"` stores the bytes.
12
12
  *
13
13
  * Same HMAC secret as the media GET URLs, but a DIFFERENT payload shape, so the
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.12.0'; // x-release-please-version
2
+ export const VERSION = '1.14.0'; // x-release-please-version
package/mint.yaml CHANGED
@@ -38,6 +38,12 @@ env:
38
38
  How often, in milliseconds, to emit a progress notification while a
39
39
  generation is in flight. Hosts reset their tools/call timeout on progress,
40
40
  so this lets a long 4K/Pro generation outlive the default budget.
41
+ - name: GEMINI_RATE_CARD
42
+ required: false
43
+ help: >-
44
+ JSON overriding or extending the built-in pricing card, so a stale rate is
45
+ yours to fix in your own environment rather than waiting on a release.
46
+ Anything absent from the card prices as unknown rather than zero.
41
47
  egress:
42
48
  allow:
43
49
  # Only hosts the SERVER process actually fetches. Hosts that appear
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/gemini-mcp",
3
- "version": "1.12.0",
3
+ "version": "1.14.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>",
@@ -46,16 +46,16 @@
46
46
  "typecheck": "tsc -p tsconfig.test.json"
47
47
  },
48
48
  "dependencies": {
49
- "@chrischall/mcp-utils": "^0.19.2",
49
+ "@chrischall/mcp-utils": "^0.23.0",
50
50
  "@modelcontextprotocol/sdk": "^1.29.0",
51
51
  "dotenv": "^17.4.0",
52
52
  "zod": "^4.4.2"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@types/node": "^26.0.0",
56
- "@vitest/coverage-v8": "^4.1.2",
56
+ "@vitest/coverage-v8": "^5.0.0",
57
57
  "esbuild": "^0.28.0",
58
58
  "typescript": "^7.0.2",
59
- "vitest": "^4.1.2"
59
+ "vitest": "^5.0.0"
60
60
  }
61
61
  }
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.12.0",
9
+ "version": "1.14.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "1.12.0",
14
+ "version": "1.14.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -118,6 +118,9 @@ not an error.
118
118
  any number of calls until then.
119
119
  - On stdio, an `images` path referenced **more than once in a session** is auto-uploaded to the
120
120
  Files API (keyed on path + mtime + size) so the bytes stop being re-sent.
121
+ - `images_base64` is uploaded on the **first** sighting, not the second — the tokens are already
122
+ spent by then. The result reports it under `image_inputs.base64_uploaded[].file_uri`: pass that
123
+ to `images_file_uris` on the next call instead of pasting the bytes again.
121
124
 
122
125
  ### Files API
123
126
  | Tool | Description |
@@ -145,8 +148,8 @@ curl -X POST https://<hosted deployment>/upload \
145
148
  ### Video & Music (preview — funded account)
146
149
  | Tool | Description |
147
150
  |------|-------------|
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. |
151
+ | `gemini_video_generate(prompt, aspect_ratio?, resolution?, 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 (~10s) via the Gemini omni model: `text_to_video` (default), `image_to_video` / `reference_to_video` (supply reference image[s]), interpolation (pass first frame then last frame as `images`), or `edit` / `extend` (with `previous_interaction_id` / `continue_last`; extensions add ~3-10s each, to ~40s). `aspect_ratio` is `16:9` or `9:16`; `resolution` is `360p`/`720p` (default)/`1080p`/`4k` and is the cost lever — a 10s 360p clip runs about a third of 720p. Written to disk as **MP4**. Runs long — use `async: true` + `gemini_get_result`, or raise `timeout_ms`. |
152
+ | `gemini_music_generate(prompt, model?, images_url?, images_file_uris?, images?, images_base64?, from_clipboard?, 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 instrumental, default, $0.04), `lyria-3.5` (full-length song with vocals, $0.08) or `lyria-3-pro-preview` ($0.08). Output is MP3. **Single-turn** — there is no follow-up refinement, so put the whole brief in the prompt. Written to disk, or returned inline. |
150
153
 
151
154
  ### Async / idempotency (any generation tool)
152
155
  | Tool | Description |
@@ -217,7 +220,8 @@ gemini_image_edit(
217
220
  → returns path to the edited image
218
221
  ```
219
222
  `images_base64` is for bytes you actually have — a file you `Read`/encode, a URL
220
- you fetch, or a `data:` URI the user pastes as **text**.
223
+ you fetch, or a `data:` URI the user pastes as **text**. Send them once: the result's
224
+ `image_inputs.base64_uploaded[].file_uri` is a `files/<id>` to reuse via `images_file_uris`.
221
225
 
222
226
  **Iterate on ONE image conversationally (multi-turn):**
223
227
  ```
@@ -231,21 +235,25 @@ r3 = gemini_interact(input: "warmer lighting", continue_last: true)
231
235
  ```
232
236
  Prefer this over re-running `gemini_image_edit` when you're making a *series* of incremental edits — the model keeps the prior result in context. Every result echoes `interaction_id` (and `previous_interaction_id` when chaining) plus a `hint` with the exact follow-up call.
233
237
 
234
- **Generate a video (preview — runs long, use async):**
238
+ **Generate a video (runs long, use async):**
235
239
  ```
236
240
  job = gemini_video_generate(prompt: "a paper boat sailing down a rain gutter, cinematic",
237
- aspect_ratio: "16:9", async: true)
241
+ aspect_ratio: "16:9", resolution: "360p", async: true)
238
242
  → { job_id, status: "running" } (returns immediately — no host timeout)
239
243
  gemini_get_result(job_id: job.job_id)
240
244
  → "running" until done, then the MP4 path on disk
245
+ # Draft at 360p, re-run the keeper at 1080p — video bills per output token, so the
246
+ # resolution is roughly the price.
241
247
  # Animate a still instead: gemini_video_generate(prompt: "…", task: "image_to_video", images: ["/path/still.png"])
248
+ # Interpolate between two stills: images: ["/path/first.png", "/path/last.png"]
249
+ # Extend a clip: gemini_video_generate(prompt: "…", task: "extend", continue_last: true)
242
250
  ```
243
251
 
244
- **Generate music (preview):**
252
+ **Generate music:**
245
253
  ```
246
254
  gemini_music_generate(prompt: "warm lo-fi hip hop, mellow Rhodes, vinyl crackle, 70bpm")
247
- → ~30s MP3 on disk (lyria-3-clip-preview)
248
- # Longer / WAV: gemini_music_generate(prompt: "…", model: "lyria-3-pro-preview", audio_format: "wav")
255
+ → 30s MP3 on disk (lyria-3-clip-preview)
256
+ # Full-length song with vocals: gemini_music_generate(prompt: "…", model: "lyria-3.5")
249
257
  ```
250
258
  **⚠️ Chat-pasted/attached images can't be fed to these tools directly.** A pasted
251
259
  image reaches the assistant as a *vision* block — the assistant can SEE it but