@chrischall/gemini-mcp 0.7.2 → 0.9.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.
@@ -0,0 +1,17 @@
1
+ import { z } from 'zod';
2
+ import { getJobResult } from '../jobs.js';
3
+ /**
4
+ * Poll tool for the async generation pattern (issue #52). A generation tool
5
+ * called with `async: true` returns a `job_id` immediately (instead of blocking
6
+ * past a host's tools/call timeout); this tool retrieves that job's status and,
7
+ * once complete, its normal result payload.
8
+ */
9
+ export function registerJobTools(server) {
10
+ server.registerTool('gemini_get_result', {
11
+ description: 'Retrieve an async generation started with `async: true`. Pass the returned `job_id`: while running it reports status "running"; on completion it returns the normal result (image paths / inline images + meta); on failure it raises the recorded error. Jobs are per-process and expire ~10 min after completion — if a job id is unknown, check the output dir / <image>.json sidecar.',
12
+ annotations: { readOnlyHint: true, openWorldHint: false },
13
+ inputSchema: {
14
+ job_id: z.string().min(1).describe('The job_id returned by a generation tool called with async: true'),
15
+ },
16
+ }, async (args) => getJobResult(args.job_id));
17
+ }
package/dist/tools/set.js CHANGED
@@ -3,7 +3,9 @@ import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveModel } from '../models.js';
4
4
  import { client } from '../client.js';
5
5
  import { slugify, baseName, gatherImageInputs } from '../images.js';
6
- import { emit, sharedImageSchema, pickSeed, buildMeta } from './shared.js';
6
+ import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProgressHeartbeat } from './shared.js';
7
+ import { dispatch, fingerprintRequest } from '../jobs.js';
8
+ import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
7
9
  export function registerSetTools(server) {
8
10
  server.registerTool('gemini_generate_set', {
9
11
  description: 'Generate a consistent SET of images: a master image from master_prompt, then one image per scene that references the master so the subject/style stays consistent. Provide `scenes` (explicit per-image prompts) OR `count` (variations of the master).',
@@ -16,49 +18,73 @@ export function registerSetTools(server) {
16
18
  basename: z.string().optional().describe('Base filename prefix for output images (default: slugified master_prompt)'),
17
19
  master_images: z.array(z.string().min(1)).optional().describe('Reference image paths passed to the master generation call'),
18
20
  master_images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs for master generation'),
21
+ confirm: schemaConfirm,
19
22
  ...sharedImageSchema,
20
23
  },
21
- }, async (args) => {
24
+ }, async (args, extra) => {
22
25
  // `scenes` is min(1) at the schema, so a non-undefined value is non-empty.
23
26
  if ((args.scenes && args.count) || (!args.scenes && !args.count)) {
24
27
  throw new McpToolError('Provide exactly one of `scenes` or `count`.');
25
28
  }
26
- const seed = pickSeed(args.seed);
29
+ // Confirm-gate local file inputs: only `master_images` (paths) can read a
30
+ // local file; base64 references pass through ungated. Dry-run makes NO API call.
31
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, 'Send local master image input(s) to the Gemini API', '/v1beta/models/{model}:generateContent', args.master_images);
32
+ if (gate)
33
+ return gate;
27
34
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
28
- const cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search };
29
- const slug = args.basename ? baseName(args.basename) : slugify(args.master_prompt);
30
- // 1. master (optionally seeded from reference images)
31
- const masterRefInputs = await gatherImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard });
32
- const masterResult = await client.generate({
33
- prompt: args.master_prompt,
34
- images: masterRefInputs.length > 0 ? masterRefInputs : undefined,
35
- seed,
36
- ...cfg,
35
+ const fingerprint = fingerprintRequest('gemini_generate_set', {
36
+ model, master_prompt: args.master_prompt, scenes: args.scenes, count: args.count,
37
+ reference_mode: args.reference_mode, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
38
+ thinking_level: args.thinking_level, google_search: args.google_search,
39
+ master_images: args.master_images, master_images_base64: args.master_images_base64, from_clipboard: args.from_clipboard,
40
+ });
41
+ return dispatch({ toolName: 'gemini_generate_set', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
42
+ const seed = pickSeed(args.seed);
43
+ const cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search, timeoutMs: args.timeout_ms };
44
+ const slug = args.basename ? baseName(args.basename) : slugify(args.master_prompt);
45
+ // 1. master (optionally seeded from reference images)
46
+ const masterRefInputs = await gatherImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard });
47
+ const named = [];
48
+ const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
49
+ const result = await client.generate({
50
+ prompt: args.master_prompt,
51
+ images: masterRefInputs.length > 0 ? masterRefInputs : undefined,
52
+ seed,
53
+ ...cfg,
54
+ });
55
+ const master = result.images[0];
56
+ named.push({ image: master, base: `${slug}-master` });
57
+ // Only the master's text is surfaced (below). Per-scene text is intentionally
58
+ // dropped — a set returns one combined result and merging N captions is noise.
59
+ // 2. scene prompts (explicit, or N repeats of master_prompt for variations)
60
+ const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
61
+ const mode = args.reference_mode ?? 'master';
62
+ if (mode === 'chain') {
63
+ let ref = master;
64
+ for (let i = 0; i < scenePrompts.length; i++) {
65
+ const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref], seed: seed + i + 1, ...cfg });
66
+ named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` });
67
+ ref = img;
68
+ }
69
+ }
70
+ else {
71
+ const scenes = await Promise.all(scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master], seed: seed + i + 1, ...cfg }).then((r) => r.images[0])));
72
+ scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` }));
73
+ }
74
+ return result;
75
+ });
76
+ const masterText = masterResult.text;
77
+ const meta = buildMeta(model, seed, args);
78
+ if (masterText)
79
+ meta.text = masterText;
80
+ if (masterResult.grounding)
81
+ meta.grounding = masterResult.grounding;
82
+ // A set is master + N scenes in one tools/call — the most timeout-prone
83
+ // tool. Effective image count drives the risk hint.
84
+ const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length });
85
+ if (risk)
86
+ meta.timeout_risk = risk;
87
+ return emit(named, args, meta);
37
88
  });
38
- const { images: [master], text: masterText } = masterResult;
39
- const named = [{ image: master, base: `${slug}-master` }];
40
- // Only the master's text is surfaced (below). Per-scene text is intentionally
41
- // dropped — a set returns one combined result and merging N captions is noise.
42
- // 2. scene prompts (explicit, or N repeats of master_prompt for variations)
43
- const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
44
- const mode = args.reference_mode ?? 'master';
45
- if (mode === 'chain') {
46
- let ref = master;
47
- for (let i = 0; i < scenePrompts.length; i++) {
48
- const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref], seed: seed + i + 1, ...cfg });
49
- named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` });
50
- ref = img;
51
- }
52
- }
53
- else {
54
- const scenes = await Promise.all(scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master], seed: seed + i + 1, ...cfg }).then((r) => r.images[0])));
55
- scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` }));
56
- }
57
- const meta = buildMeta(model, seed, args);
58
- if (masterText)
59
- meta.text = masterText;
60
- if (masterResult.grounding)
61
- meta.grounding = masterResult.grounding;
62
- return emit(named, args, meta);
63
89
  });
64
90
  }
@@ -1,16 +1,11 @@
1
1
  import { z } from 'zod';
2
- import { textResult, McpToolError } from '@chrischall/mcp-utils';
2
+ import { textResult, McpToolError, readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { client } from '../client.js';
4
4
  import { writeImage, resolveOutputDir, resolveVideoPath, videoMimeType } from '../images.js';
5
5
  /** Supported output aspect ratios (Gemini image API). */
6
6
  export const ASPECT_RATIOS = ['1:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9', '1:4', '4:1', '1:8', '8:1'];
7
7
  /** Supported output resolutions. `512` is Flash-only (0.5K); `1K`/`2K`/`4K` work on all image models. */
8
8
  export const IMAGE_SIZES = ['512', '1K', '2K', '4K'];
9
- /**
10
- * The model/aspect/size/output fields every generation tool shares. Defined once
11
- * here so descriptions can't drift between `generate.ts` and `set.ts`. Spread
12
- * into each tool's `inputSchema`.
13
- */
14
9
  /**
15
10
  * When to pick which Nano Banana model — shared by every tool's `model` param
16
11
  * so the guidance can't drift between them.
@@ -20,6 +15,42 @@ export const MODEL_CHOICE_GUIDE = 'gemini-3.1-flash-image (Nano Banana 2) is the
20
15
  'processing and consistency. gemini-3-pro-image (Nano Banana Pro) is the premium choice for the most complex ' +
21
16
  'visual tasks — highest world knowledge, advanced localization, accurate brand consistency, precision creative control. ' +
22
17
  'gemini-3.1-flash-lite-image (Nano Banana 2 Lite) is the fastest/cheapest for simple tasks (1K only, no search grounding).';
18
+ /**
19
+ * Per-call upstream timeout override, shared by every generation tool
20
+ * (spread via {@link sharedImageSchema}; `gemini_interact` imports it directly
21
+ * since its schema doesn't take the seed/clipboard fields).
22
+ */
23
+ export const timeoutMsSchema = z
24
+ .number()
25
+ .int()
26
+ .positive()
27
+ .optional()
28
+ .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)');
29
+ /**
30
+ * Idempotency key shared by every generation tool. A repeat call carrying the
31
+ * same key returns the recorded result (`reused: true`) instead of billing a
32
+ * fresh generation — the fix for a host timeout (`-32001`) prompting a blind
33
+ * re-issue of a call the server actually completed.
34
+ */
35
+ export const idempotencyKeySchema = z
36
+ .string()
37
+ .min(1)
38
+ .optional()
39
+ .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.');
40
+ /**
41
+ * Async escape hatch shared by every generation tool: return a `job_id`
42
+ * immediately so a long (Pro/4K) generation can't hit a host's fixed tools/call
43
+ * timeout (`-32001`). The caller polls `gemini_get_result`.
44
+ */
45
+ export const asyncSchema = z
46
+ .boolean()
47
+ .optional()
48
+ .describe('Run in the background and return a job_id immediately instead of the image, so a long (Pro/4K) generation cannot hit the host tools/call timeout (-32001). Poll gemini_get_result with the job_id to fetch the result (jobs are per-process and expire ~10 min after completion).');
49
+ /**
50
+ * The model/aspect/size/output fields every generation tool shares. Defined once
51
+ * here so descriptions can't drift between `generate.ts` and `set.ts`. Spread
52
+ * into each tool's `inputSchema`.
53
+ */
23
54
  export const sharedImageSchema = {
24
55
  // Bare id only: the model is interpolated into the URL path
25
56
  // (`/models/${model}:generateContent`), so slashes/colons/queries must be
@@ -37,6 +68,9 @@ export const sharedImageSchema = {
37
68
  thinking_level: z.enum(['minimal', 'high']).optional().describe('Reasoning depth (Gemini 3 models); higher can help complex/structural edits'),
38
69
  google_search: z.boolean().optional().describe('Ground the image in live Google Search results (current events, weather, data)'),
39
70
  from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)'),
71
+ timeout_ms: timeoutMsSchema,
72
+ idempotency_key: idempotencyKeySchema,
73
+ async: asyncSchema,
40
74
  };
41
75
  /**
42
76
  * Turn the `video_url` / `video_path` tool args into the client's video opts.
@@ -71,6 +105,95 @@ export const videoPathSchema = z
71
105
  export function pickSeed(seed) {
72
106
  return seed ?? Math.floor(Math.random() * 2_147_483_000);
73
107
  }
108
+ // Progress-heartbeat cadence while a generation is in flight. MCP hosts
109
+ // enforce their own tools/call timeout (surfaced as -32001) but reset it on
110
+ // notifications/progress, so a heartbeat lets a 4K/Pro generation outlive the
111
+ // host's default budget. Tunable via $GEMINI_HEARTBEAT_MS (0 disables).
112
+ const HEARTBEAT_DEFAULT_MS = 10_000;
113
+ /**
114
+ * Run `fn`, emitting a `notifications/progress` heartbeat while it is pending —
115
+ * but only when the caller asked for progress (sent a `progressToken`).
116
+ * Send failures are swallowed (the client may have abandoned the request);
117
+ * `fn`'s result/rejection passes through untouched.
118
+ */
119
+ export async function withProgressHeartbeat(extra, message, fn) {
120
+ const progressToken = extra?._meta?.progressToken;
121
+ const send = extra?.sendNotification;
122
+ const env = Number(readEnvVar('GEMINI_HEARTBEAT_MS'));
123
+ const intervalMs = Number.isFinite(env) ? env : HEARTBEAT_DEFAULT_MS;
124
+ const debug = !!readEnvVar('GEMINI_DEBUG');
125
+ const active = progressToken !== undefined && !!send && intervalMs > 0;
126
+ // Diagnostics (opt-in via GEMINI_DEBUG). Goes to stderr — the only channel
127
+ // safe under the stdio transport, and where the host surfaces it (Claude
128
+ // Desktop: ~/Library/Logs/Claude/mcp-server-*.log). The active/inactive line
129
+ // reveals whether the host even asked for progress: a missing progressToken
130
+ // means the host cannot be kept alive by our heartbeat, so a long call will
131
+ // hit the host's own tools/call timeout no matter what we send.
132
+ if (debug) {
133
+ if (active) {
134
+ console.error(`[gemini-mcp] heartbeat active: progressToken present, interval=${intervalMs}ms — ${message}`);
135
+ }
136
+ else {
137
+ const reason = progressToken === undefined
138
+ ? 'no progressToken from host (host cannot extend its tools/call timeout; long calls will hit it)'
139
+ : !send
140
+ ? 'host provided no sendNotification channel'
141
+ : 'heartbeat disabled (GEMINI_HEARTBEAT_MS=0)';
142
+ console.error(`[gemini-mcp] heartbeat inactive: ${reason} — ${message}`);
143
+ }
144
+ }
145
+ if (progressToken === undefined || !send || intervalMs <= 0)
146
+ return fn();
147
+ let ticks = 0;
148
+ const timer = setInterval(() => {
149
+ ticks++;
150
+ const elapsed = Math.round((ticks * intervalMs) / 1000);
151
+ if (debug)
152
+ console.error(`[gemini-mcp] heartbeat: ${message} — still working (${elapsed}s)`);
153
+ void send({
154
+ method: 'notifications/progress',
155
+ params: {
156
+ progressToken,
157
+ progress: ticks,
158
+ message: `${message} — still working (${elapsed}s)`,
159
+ },
160
+ }).catch(() => { });
161
+ }, intervalMs);
162
+ // Never hold the process open just for a heartbeat.
163
+ timer.unref?.();
164
+ try {
165
+ return await fn();
166
+ }
167
+ finally {
168
+ clearInterval(timer);
169
+ }
170
+ }
171
+ /**
172
+ * Some configs routinely run long enough to trip a host's tools/call timeout —
173
+ * Pro models, 4K output, or multi-image counts. Claude Desktop caps at ~30s,
174
+ * ignores our progress heartbeat, and offers no config to raise it (verified: the
175
+ * bundled MCP client's hard limit does not reset on notifications/progress), so
176
+ * such a call surfaces as `-32001 Request timed out`. Returned as
177
+ * `meta.timeout_risk` so a caller that *does* get the result is steered off the
178
+ * slow path next time and knows the image still landed on disk when a host bails.
179
+ * `undefined` for fast configs (no field added).
180
+ */
181
+ export function timeoutRiskHint(opts) {
182
+ // Delimiter-anchored so real Pro ids (gemini-3-pro-image, nano-banana-pro)
183
+ // match but incidental substrings (…-prototype-…, …-flash-proxy) don't.
184
+ const proModel = /(?:^|[-_./])pro(?:[-_./]|$)/i.test(opts.model);
185
+ const fourK = opts.imageSize === '4K';
186
+ const multi = (opts.count ?? 1) > 1;
187
+ if (!proModel && !fourK && !multi)
188
+ return undefined;
189
+ const why = [proModel && 'Pro model', fourK && '4K output', multi && `count=${opts.count}`]
190
+ .filter(Boolean)
191
+ .join(' + ');
192
+ return (`Slow config (${why}) can exceed a host's tools/call timeout (e.g. Claude Desktop ~30s, error -32001). ` +
193
+ `If the host gives up mid-generation the image is still written to disk (and gemini_interact leaves an ` +
194
+ `<image>.json sidecar with the interaction id) — no rerun needed, just look in the output dir. ` +
195
+ `For a faster in-band result use a Flash model, drop image_size to 1K/2K, or — in Claude Code — set MCP_TOOL_TIMEOUT.`);
196
+ }
74
197
  /** Build the result metadata echoed to the caller (omitting unset optionals). */
75
198
  export function buildMeta(model, seed, opts) {
76
199
  const meta = { model, seed };
@@ -86,7 +209,8 @@ export function buildMeta(model, seed, opts) {
86
209
  * Optional `meta` is merged into the disk-path JSON result, or prepended as a
87
210
  * text block in inline mode (only if meta has keys).
88
211
  * Optional `onWritten` receives the absolute paths written in disk mode (never
89
- * called inline) — lets a tool remember its own outputs.
212
+ * called inline) — lets a tool remember its own outputs or write sidecar
213
+ * files; an async callback is awaited before the result is returned.
90
214
  */
91
215
  export async function emit(named, opts, meta, onWritten) {
92
216
  if (opts.inline) {
@@ -102,6 +226,6 @@ export async function emit(named, opts, meta, onWritten) {
102
226
  const images = [];
103
227
  for (const n of named)
104
228
  images.push(await writeImage(dir, n.base, n.image.base64, n.image.mimeType));
105
- onWritten?.(images);
229
+ await onWritten?.(images);
106
230
  return textResult({ images, ...meta });
107
231
  }
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 = '0.7.2'; // x-release-please-version
2
+ export const VERSION = '0.9.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/gemini-mcp",
3
- "version": "0.7.2",
3
+ "version": "0.9.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>",
@@ -44,7 +44,7 @@
44
44
  "test:coverage": "vitest run --coverage"
45
45
  },
46
46
  "dependencies": {
47
- "@chrischall/mcp-utils": "^0.10.3",
47
+ "@chrischall/mcp-utils": "^0.12.0",
48
48
  "@modelcontextprotocol/sdk": "^1.29.0",
49
49
  "dotenv": "^17.4.0",
50
50
  "zod": "^4.4.2"
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": "0.7.2",
9
+ "version": "0.9.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "0.7.2",
14
+ "version": "0.9.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },