@chrischall/gemini-mcp 2.4.1 → 2.5.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.
@@ -5,7 +5,7 @@ import { ChainedRequest404Error } from '../client.js';
5
5
  import { slugify, baseName, resolveImagePath, writeSidecar, lookupOutputDir, readOutputImageAsInline } from '../images.js';
6
6
  import { resolveImageInputs } from '../inputs.js';
7
7
  import { findInteractionImages, latestInteractionId } from '../sidecar.js';
8
- import { emit, reportShape, resolveAspectRatio, orientationSchema, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesBase64Schema } from './shared.js';
8
+ import { emit, reportShape, resolveAspectRatio, orientationSchema, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesBase64Schema, hostedR2KeysParam, hostedUploadNote } from './shared.js';
9
9
  import { bytesToBase64 } from '../bytes.js';
10
10
  import { fingerprintRequest } from '../jobs.js';
11
11
  import { attachCost } from '../pricing.js';
@@ -89,6 +89,7 @@ export function registerInteractTools(server, client) {
89
89
  'do NOT start a new interaction or re-send the image for each tweak. `continue_last: true` chains from this ' +
90
90
  "session's most recent interaction without threading the id. " +
91
91
  recoveryDescription +
92
+ hostedUploadNote(client.mediaSink) +
92
93
  'Output is JPEG. ' +
93
94
  confirmNote('Local file inputs are confirmed first'),
94
95
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
@@ -108,6 +109,7 @@ export function registerInteractTools(server, client) {
108
109
  .describe(`Paths to reference input images. Local paths must be inside GEMINI_REFERENCE_DIR (else GEMINI_UPLOAD_DIR) when set. ${NEW_REFERENCES_ONLY}`),
109
110
  images_url: imagesUrlSchema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
110
111
  images_file_uris: imagesFileUrisSchema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
112
+ ...hostedR2KeysParam(client.mediaSink, `Reference images. ${NEW_REFERENCES_ONLY} Given`),
111
113
  images_base64: imagesBase64Schema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
112
114
  model: z
113
115
  .string()
@@ -223,7 +225,7 @@ export function registerInteractTools(server, client) {
223
225
  thinking_level: args.thinking_level, previous_interaction_id: previousInteractionId,
224
226
  google_search: args.google_search, search_types: args.search_types,
225
227
  images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
226
- images_url: args.images_url, images_file_uris: args.images_file_uris,
228
+ images_url: args.images_url, images_file_uris: args.images_file_uris, images_r2_keys: args.images_r2_keys,
227
229
  video_url: args.video_url, video_path: args.video_path,
228
230
  });
229
231
  return client.session.jobs.dispatch({ toolName: 'gemini_interact', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { slugify, baseName } from '../images.js';
3
3
  import { resolveImageInputs } from '../inputs.js';
4
4
  import { DEFAULT_MUSIC_MODEL } from '../models.js';
5
- import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesBase64Schema } from './shared.js';
5
+ import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesBase64Schema, hostedR2KeysParam, hostedUploadNote } from './shared.js';
6
6
  import { fingerprintRequest } from '../jobs.js';
7
7
  import { attachCost } from '../pricing.js';
8
8
  import { confirmLocalInputs, confirmNote, confirmTokenParam } from './_confirm.js';
@@ -29,6 +29,7 @@ export function registerMusicTools(server, client) {
29
29
  'lyria-3-pro-preview (longer-form). Output is MP3, written to disk (or returned inline). Single-turn: a track ' +
30
30
  'cannot be refined by a follow-up call, so put the whole brief in the prompt. Runs long — give it a `max_wait_ms` ' +
31
31
  'budget (or `async: true` + gemini_get_result on a local install), or raise `timeout_ms`. Needs a funded account. ' +
32
+ hostedUploadNote(client.mediaSink) +
32
33
  confirmNote('Local file inputs are confirmed first'),
33
34
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
34
35
  inputSchema: z.object({
@@ -37,6 +38,7 @@ export function registerMusicTools(server, client) {
37
38
  images: z.array(z.string().min(1)).optional().describe('Optional reference image path(s) to condition the music. Local paths must be inside GEMINI_REFERENCE_DIR (else GEMINI_UPLOAD_DIR) when set.'),
38
39
  images_url: imagesUrlSchema('Reference images'),
39
40
  images_file_uris: imagesFileUrisSchema('Reference images'),
41
+ ...hostedR2KeysParam(client.mediaSink, 'Reference images'),
40
42
  images_base64: imagesBase64Schema(),
41
43
  from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS clipboard as a reference'),
42
44
  filename: z.string().optional().describe('Base filename for the output audio (extension stripped; default: slugified prompt)'),
@@ -62,7 +64,7 @@ export function registerMusicTools(server, client) {
62
64
  const fingerprint = fingerprintRequest('gemini_music_generate', {
63
65
  model, prompt: args.prompt,
64
66
  images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
65
- images_url: args.images_url, images_file_uris: args.images_file_uris,
67
+ images_url: args.images_url, images_file_uris: args.images_file_uris, images_r2_keys: args.images_r2_keys,
66
68
  });
67
69
  return client.session.jobs.dispatch({ toolName: 'gemini_music_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async (ctx) => {
68
70
  const { inputs, report } = await resolveImageInputs(args, client);
@@ -5,7 +5,7 @@ import { IMAGE_URL_MAX_BYTES, IMAGE_INLINE_MAX_BYTES } from '../fetch-image.js';
5
5
  import { bytesToBase64, base64ToBytes, wholeMb } from '../bytes.js';
6
6
  import { createDiskSink } from '../storage/media.js';
7
7
  import { downloadFilename } from '../media-name.js';
8
- import { buildZip } from '../zip.js';
8
+ import { buildZip } from '@chrischall/mcp-utils/export';
9
9
  /**
10
10
  * Fallback sink for call sites that pass no explicit one (every pre-existing
11
11
  * test, and any caller constructed before the sink seam existed). The stdio
@@ -144,6 +144,32 @@ export function imagesR2KeysSchema(label = 'Reference images') {
144
144
  "generation's media[].r2_key. The server reads its own bucket — no bytes in the conversation, no ~48h expiry. " +
145
145
  'Hosted connector only.');
146
146
  }
147
+ /**
148
+ * `images_r2_keys` for a tool, but ONLY where a key can resolve.
149
+ *
150
+ * An `r2_key` names an object in the hosted connector's blob store; a disk
151
+ * install has no store, so there the param could never work and would be
152
+ * standing `tools/list` cost on every request (see tool-surface-budget). So it
153
+ * is registered from the sink's capability, like interact's recovery text.
154
+ *
155
+ * The cast is runtime-honest: off the hosted connector the key is absent from
156
+ * the schema, zod strips it, and `args.images_r2_keys` is `undefined` — which
157
+ * is exactly what the declared optional type allows.
158
+ */
159
+ export function hostedR2KeysParam(sink, label) {
160
+ return ((sink ?? DEFAULT_SINK).persistsFiles ? {} : { images_r2_keys: imagesR2KeysSchema(label) });
161
+ }
162
+ /**
163
+ * The description sentence that sends a hosted caller to the upload route.
164
+ * Empty on disk (where `images` paths work and no upload tool exists), so it
165
+ * costs nothing there.
166
+ */
167
+ export function hostedUploadNote(sink) {
168
+ return (sink ?? DEFAULT_SINK).persistsFiles
169
+ ? ''
170
+ : 'For a reference photo you hold locally: gemini_get_upload_url, curl PUT it, then pass its r2_key in images_r2_keys ' +
171
+ '(no bytes in the conversation; reusable across calls). ';
172
+ }
147
173
  /** Saved-character names attached to a generation (hosted connector only). */
148
174
  export const charactersSchema = z
149
175
  .array(z.string().min(1))
@@ -616,7 +642,10 @@ export async function persistBundle(named, sink, base) {
616
642
  }
617
643
  try {
618
644
  const { mediaExt } = await import('../images.js');
619
- const zip = buildZip(named.map((n) => ({ name: `${n.base}.${mediaExt(n.image.mimeType)}`, bytes: base64ToBytes(n.image.base64) })));
645
+ // STORE (`compress: false`): the images are already-compressed PNG/JPEG,
646
+ // so DEFLATE would only spend CPU, and STORE keeps the bundle byte-identical
647
+ // to what this repo's retired hand-rolled zip writer produced.
648
+ const zip = buildZip(named.map((n) => ({ name: `${n.base}.${mediaExt(n.image.mimeType)}`, bytes: base64ToBytes(n.image.base64), compress: false })));
620
649
  const [persisted] = await sink.persist([{ base: `${base}-bundle`, base64: bytesToBase64(zip), mimeType: 'application/zip' }], { urlTtlMs: SET_URL_TTL_MS });
621
650
  if (!persisted || persisted.unavailable) {
622
651
  return { bundle_skipped: 'The bundle zip could not be stored; the images themselves are unaffected — download them individually via their media urls / curl_hints.' };
@@ -3,7 +3,7 @@ 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, imagesBase64Schema } from './shared.js';
6
+ import { emitMedia, reportShape, resolveAspectRatio, orientationSchema, timeoutMsSchema, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesBase64Schema, hostedR2KeysParam, hostedUploadNote } from './shared.js';
7
7
  import { fingerprintRequest } from '../jobs.js';
8
8
  import { attachCost } from '../pricing.js';
9
9
  import { confirmLocalInputs, confirmNote, confirmTokenParam } from './_confirm.js';
@@ -35,6 +35,7 @@ export function registerVideoTools(server, client) {
35
35
  'add ~3-10s each, to ~40s total). Cost scales with `resolution` — draft at 360p, keep at 1080p/4k. ' +
36
36
  'Output is written to disk as MP4 (video has no inline MCP block). Video runs long — give it a `max_wait_ms` budget ' +
37
37
  '(or `async: true` + gemini_get_result on a local install), or raise `timeout_ms`. Needs a funded account. ' +
38
+ hostedUploadNote(client.mediaSink) +
38
39
  confirmNote('Local file inputs are confirmed first'),
39
40
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
40
41
  inputSchema: z.object({
@@ -46,6 +47,7 @@ export function registerVideoTools(server, client) {
46
47
  images: z.array(z.string().min(1)).optional().describe('Reference image path(s) for image_to_video / reference_to_video. Local paths must be inside GEMINI_REFERENCE_DIR (else GEMINI_UPLOAD_DIR) when set.'),
47
48
  images_url: imagesUrlSchema('Reference stills'),
48
49
  images_file_uris: imagesFileUrisSchema('Reference stills'),
50
+ ...hostedR2KeysParam(client.mediaSink, 'Reference stills'),
49
51
  images_base64: imagesBase64Schema(),
50
52
  from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS clipboard as a reference'),
51
53
  filename: z.string().optional().describe('Base filename for the output video (extension stripped; default: slugified prompt)'),
@@ -95,7 +97,7 @@ export function registerVideoTools(server, client) {
95
97
  // where `delivery` is not (same bytes, different transport).
96
98
  model, prompt: args.prompt, aspect_ratio: aspectRatio, resolution: args.resolution, task: args.task,
97
99
  images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
98
- images_url: args.images_url, images_file_uris: args.images_file_uris,
100
+ images_url: args.images_url, images_file_uris: args.images_file_uris, images_r2_keys: args.images_r2_keys,
99
101
  previous_interaction_id: previousInteractionId,
100
102
  });
101
103
  return client.session.jobs.dispatch({ toolName: 'gemini_video_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async (ctx) => {
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 = '2.4.1'; // x-release-please-version
2
+ export const VERSION = '2.5.0'; // x-release-please-version
package/mint.yaml CHANGED
@@ -4,6 +4,19 @@ slug: gemini
4
4
  summary: >-
5
5
  Generate and edit images with Google Gemini image models (Nano Banana /
6
6
  Nano Banana Pro) via natural language prompts
7
+ examples:
8
+ - prompt: >-
9
+ Generate a watercolor illustration of a lighthouse on a rocky coast at
10
+ sunset, in a 16:9 aspect ratio.
11
+ note: Text-to-image with gemini_image_generate.
12
+ - prompt: >-
13
+ Make a set of four images of the same cartoon fox mascot: waving,
14
+ reading a book, riding a bike, and sleeping under a tree.
15
+ note: A consistent character across scenes with gemini_image_set.
16
+ - prompt: >-
17
+ Compose a 30-second upbeat acoustic guitar instrumental for a cooking
18
+ video intro.
19
+ note: A short Lyria clip with gemini_music_generate.
7
20
  env:
8
21
  - name: GEMINI_API_KEY
9
22
  secret: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/gemini-mcp",
3
- "version": "2.4.1",
3
+ "version": "2.5.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,7 +46,7 @@
46
46
  "typecheck": "tsc -p tsconfig.test.json"
47
47
  },
48
48
  "dependencies": {
49
- "@chrischall/mcp-utils": "^3.0.3",
49
+ "@chrischall/mcp-utils": "^3.3.0",
50
50
  "@modelcontextprotocol/server": "^2.3.0",
51
51
  "dotenv": "^18.0.2",
52
52
  "undici": "^8.12.2",
@@ -59,5 +59,9 @@
59
59
  "esbuild": "^0.28.0",
60
60
  "typescript": "^7.0.2",
61
61
  "vitest": "^5.0.0"
62
+ },
63
+ "allowScripts": {
64
+ "esbuild": false,
65
+ "fsevents": false
62
66
  }
63
67
  }
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": "2.4.1",
9
+ "version": "2.5.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "2.4.1",
14
+ "version": "2.5.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
package/dist/zip.js DELETED
@@ -1,113 +0,0 @@
1
- /**
2
- * Minimal ZIP writer (STORE only, no compression) for bundling a multi-image
3
- * result into one downloadable archive.
4
- *
5
- * Why hand-rolled: the Worker has no filesystem and no archiver dependency,
6
- * and the images being bundled are already-compressed PNG/JPEG — DEFLATE would
7
- * buy nothing. STORE entries are just headers + raw bytes + CRC32, which is
8
- * ~100 lines and runs identically under Node and workerd.
9
- *
10
- * Layout: [local header + name + data]* [central directory]* [EOCD]. No
11
- * ZIP64 (a bundle is a handful of images, far under the 4 GB / 65k-entry
12
- * limits), no encryption, no streaming.
13
- */
14
- /** Standard CRC-32 (IEEE 802.3), table-driven. The table is immutable. */
15
- const CRC_TABLE = (() => {
16
- const table = new Uint32Array(256);
17
- for (let n = 0; n < 256; n++) {
18
- let c = n;
19
- for (let k = 0; k < 8; k++)
20
- c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
21
- table[n] = c >>> 0;
22
- }
23
- return table;
24
- })();
25
- export function crc32(bytes) {
26
- let crc = 0xffffffff;
27
- for (let i = 0; i < bytes.length; i++) {
28
- crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
29
- }
30
- return (crc ^ 0xffffffff) >>> 0;
31
- }
32
- /** MS-DOS date/time pair (ZIP's native timestamp format). */
33
- function dosDateTime(at) {
34
- const year = Math.max(1980, at.getUTCFullYear());
35
- return {
36
- time: (at.getUTCHours() << 11) | (at.getUTCMinutes() << 5) | (at.getUTCSeconds() >> 1),
37
- date: ((year - 1980) << 9) | ((at.getUTCMonth() + 1) << 5) | at.getUTCDate(),
38
- };
39
- }
40
- const LOCAL_HEADER = 0x04034b50;
41
- const CENTRAL_HEADER = 0x02014b50;
42
- const EOCD = 0x06054b50;
43
- /** Version 2.0 — the minimum that understands STORE + directories. */
44
- const VERSION = 20;
45
- /** General-purpose bit 11: names are UTF-8. */
46
- const FLAG_UTF8 = 0x0800;
47
- /**
48
- * Build a STORE-only zip. `modifiedAt` stamps every entry (injectable so a
49
- * given input always produces identical bytes; defaults to the epoch of the
50
- * format, 1980).
51
- */
52
- export function buildZip(entries, modifiedAt = new Date(Date.UTC(1980, 0, 1))) {
53
- const { time, date } = dosDateTime(modifiedAt);
54
- const encoder = new TextEncoder();
55
- const parts = [];
56
- const central = [];
57
- let offset = 0;
58
- for (const entry of entries) {
59
- const name = encoder.encode(entry.name);
60
- const crc = crc32(entry.bytes);
61
- const size = entry.bytes.byteLength;
62
- const local = new Uint8Array(30 + name.byteLength);
63
- const lv = new DataView(local.buffer);
64
- lv.setUint32(0, LOCAL_HEADER, true);
65
- lv.setUint16(4, VERSION, true);
66
- lv.setUint16(6, FLAG_UTF8, true);
67
- lv.setUint16(8, 0, true); // method: STORE
68
- lv.setUint16(10, time, true);
69
- lv.setUint16(12, date, true);
70
- lv.setUint32(14, crc, true);
71
- lv.setUint32(18, size, true); // compressed == uncompressed under STORE
72
- lv.setUint32(22, size, true);
73
- lv.setUint16(26, name.byteLength, true);
74
- lv.setUint16(28, 0, true); // extra length
75
- local.set(name, 30);
76
- const dir = new Uint8Array(46 + name.byteLength);
77
- const dv = new DataView(dir.buffer);
78
- dv.setUint32(0, CENTRAL_HEADER, true);
79
- dv.setUint16(4, VERSION, true); // version made by
80
- dv.setUint16(6, VERSION, true); // version needed
81
- dv.setUint16(8, FLAG_UTF8, true);
82
- dv.setUint16(10, 0, true);
83
- dv.setUint16(12, time, true);
84
- dv.setUint16(14, date, true);
85
- dv.setUint32(16, crc, true);
86
- dv.setUint32(20, size, true);
87
- dv.setUint32(24, size, true);
88
- dv.setUint16(28, name.byteLength, true);
89
- // extra len, comment len, disk start, internal attrs, external attrs = 0
90
- dv.setUint32(42, offset, true);
91
- dir.set(name, 46);
92
- parts.push(local, entry.bytes);
93
- central.push(dir);
94
- offset += local.byteLength + size;
95
- }
96
- const centralSize = central.reduce((n, c) => n + c.byteLength, 0);
97
- const eocd = new Uint8Array(22);
98
- const ev = new DataView(eocd.buffer);
99
- ev.setUint32(0, EOCD, true);
100
- ev.setUint16(8, entries.length, true); // entries on this disk
101
- ev.setUint16(10, entries.length, true); // entries total
102
- ev.setUint32(12, centralSize, true);
103
- ev.setUint32(16, offset, true); // central directory offset
104
- // comment length = 0
105
- const total = offset + centralSize + eocd.byteLength;
106
- const out = new Uint8Array(total);
107
- let cursor = 0;
108
- for (const part of [...parts, ...central, eocd]) {
109
- out.set(part, cursor);
110
- cursor += part.byteLength;
111
- }
112
- return out;
113
- }