@chrischall/gemini-mcp 0.7.2 → 0.8.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.
package/dist/client.js CHANGED
@@ -8,8 +8,24 @@ const BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'; // v1 lacks
8
8
  const SERVICE = 'Gemini';
9
9
  const INTERACTIONS_SERVICE = 'Gemini Interactions';
10
10
  // Per-request abort budget. Image generation routinely runs 30s+ on the Pro
11
- // model, so this is deliberately 60s (NOT the fleet's usual 15–30s).
11
+ // model, so this is deliberately 60s (NOT the fleet's usual 15–30s) — and 4K
12
+ // output routinely runs past 60s, hence the doubled 4K default. Both are
13
+ // overridable: per-call `timeout_ms` beats $GEMINI_TIMEOUT_MS beats these.
12
14
  const REQUEST_TIMEOUT_MS = 60_000;
15
+ const REQUEST_TIMEOUT_4K_MS = 120_000;
16
+ /**
17
+ * Effective upstream timeout for one request:
18
+ * per-call `timeout_ms` → `$GEMINI_TIMEOUT_MS` → 120s for 4K output → 60s.
19
+ * Resolved at request time so an env change doesn't require a new process.
20
+ */
21
+ export function resolveTimeoutMs(perCallMs, imageSize) {
22
+ if (perCallMs !== undefined && perCallMs > 0)
23
+ return perCallMs;
24
+ const env = Number(readEnvVar('GEMINI_TIMEOUT_MS'));
25
+ if (Number.isFinite(env) && env > 0)
26
+ return env;
27
+ return imageSize === '4K' ? REQUEST_TIMEOUT_4K_MS : REQUEST_TIMEOUT_MS;
28
+ }
13
29
  // Files API (local-video upload — docs/GEMINI-API.md "Files API — local video
14
30
  // upload", verified 2026-06-12). The resumable upload endpoint lives under
15
31
  // `/upload/v1beta`, NOT the normal `/v1beta` base.
@@ -31,8 +47,7 @@ const CHAIN_404_RETRY_MS = 2_000;
31
47
  export class GeminiClient {
32
48
  apiKey;
33
49
  configError;
34
- api;
35
- interactionsApi;
50
+ apis = new Map();
36
51
  fetchImpl;
37
52
  sleep;
38
53
  constructor(opts = {}) {
@@ -47,30 +62,39 @@ export class GeminiClient {
47
62
  this.apiKey = key;
48
63
  this.configError = null;
49
64
  }
50
- // Both paths share: the key in the `x-goog-api-key` header (Gemini does not
51
- // use `Authorization: Bearer`), a 60s abort budget, and the default 429
52
- // retry (once after 2s — the free tier rate-limits aggressively).
53
- // `getToken` calls requireKey() so key resolution stays at REQUEST time:
54
- // the server boots without a key and the actionable config error (with its
55
- // aistudio hint) surfaces on the first tool call, not at startup.
56
65
  this.fetchImpl = opts.fetchImpl ?? fetch;
57
66
  this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
58
- const shared = {
59
- tokenHeader: 'x-goog-api-key',
60
- getToken: () => this.requireKey(),
61
- timeout: REQUEST_TIMEOUT_MS,
62
- fetchImpl: this.fetchImpl,
63
- };
64
- this.api = createApiClient({ baseUrl: BASE_URL, serviceName: SERVICE, ...shared });
65
- // Separate client for the Interactions API so errors name the right API.
66
- // The beta-era Api-Revision header is gone: the API went GA and requests
67
- // succeed without it (verified live 2026-07-06; see docs/GEMINI-API.md).
68
- // Don't re-add a pinned revision — it would freeze us on old semantics.
69
- this.interactionsApi = createApiClient({
70
- baseUrl: BASE_URL,
71
- serviceName: INTERACTIONS_SERVICE,
72
- ...shared,
73
- });
67
+ }
68
+ /**
69
+ * The `createApiClient` pair for one timeout budget, built lazily and
70
+ * memoized per distinct timeout (a handful of values in practice). Both
71
+ * share: the key in the `x-goog-api-key` header (Gemini does not use
72
+ * `Authorization: Bearer`) and the default 429 retry (once after 2s — the
73
+ * free tier rate-limits aggressively). `getToken` calls requireKey() so key
74
+ * resolution stays at REQUEST time: the server boots without a key and the
75
+ * actionable config error (with its aistudio hint) surfaces on the first
76
+ * tool call, not at startup.
77
+ */
78
+ apisFor(timeoutMs) {
79
+ let entry = this.apis.get(timeoutMs);
80
+ if (!entry) {
81
+ const shared = {
82
+ tokenHeader: 'x-goog-api-key',
83
+ getToken: () => this.requireKey(),
84
+ timeout: timeoutMs,
85
+ fetchImpl: this.fetchImpl,
86
+ };
87
+ entry = {
88
+ api: createApiClient({ baseUrl: BASE_URL, serviceName: SERVICE, ...shared }),
89
+ // Separate client for the Interactions API so errors name the right API.
90
+ // The beta-era Api-Revision header is gone: the API went GA and requests
91
+ // succeed without it (verified live 2026-07-06; see docs/GEMINI-API.md).
92
+ // Don't re-add a pinned revision — it would freeze us on old semantics.
93
+ interactionsApi: createApiClient({ baseUrl: BASE_URL, serviceName: INTERACTIONS_SERVICE, ...shared }),
94
+ };
95
+ this.apis.set(timeoutMs, entry);
96
+ }
97
+ return entry;
74
98
  }
75
99
  requireKey() {
76
100
  if (this.configError)
@@ -81,8 +105,9 @@ export class GeminiClient {
81
105
  defaultModel() {
82
106
  return resolveModel(undefined, readEnvVar('GEMINI_IMAGE_MODEL'));
83
107
  }
84
- async call(method, path, body) {
85
- return this.api.fetchJson(method, path, body !== undefined ? { body } : {});
108
+ async call(method, path, body, timeoutMs) {
109
+ const { api } = this.apisFor(timeoutMs ?? resolveTimeoutMs());
110
+ return api.fetchJson(method, path, body !== undefined ? { body } : {});
86
111
  }
87
112
  /**
88
113
  * Upload a local video to the Gemini Files API (resumable protocol) and wait
@@ -200,7 +225,7 @@ export class GeminiClient {
200
225
  const requestBody = { contents: [{ parts }], generationConfig };
201
226
  if (opts.googleSearch)
202
227
  requestBody.tools = [{ google_search: {} }];
203
- const data = await this.call('POST', `/models/${model}:generateContent`, requestBody);
228
+ const data = await this.call('POST', `/models/${model}:generateContent`, requestBody, resolveTimeoutMs(opts.timeoutMs, opts.imageSize));
204
229
  const images = [];
205
230
  const textParts = [];
206
231
  let groundingMeta;
@@ -263,10 +288,11 @@ export class GeminiClient {
263
288
  searchTool.search_types = opts.searchTypes;
264
289
  body.tools = [searchTool];
265
290
  }
291
+ const { interactionsApi } = this.apisFor(resolveTimeoutMs(opts.timeoutMs, opts.imageSize));
266
292
  let data;
267
293
  for (let attempt = 0;; attempt++) {
268
294
  try {
269
- data = await this.interactionsApi.fetchJson('POST', '/interactions', {
295
+ data = await interactionsApi.fetchJson('POST', '/interactions', {
270
296
  body,
271
297
  });
272
298
  break;
package/dist/images.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, writeFile, access } from 'node:fs/promises';
1
+ import { mkdir, readFile, writeFile, access, stat, open } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { join, resolve, isAbsolute } from 'node:path';
4
4
  import { readEnvVar, McpToolError } from '@chrischall/mcp-utils';
@@ -57,6 +57,16 @@ export async function writeImage(dir, base, base64, mimeType) {
57
57
  await writeFile(path, Buffer.from(base64, 'base64'));
58
58
  return resolve(path);
59
59
  }
60
+ /**
61
+ * Write `<imagePath>.json` recording generation metadata (notably the
62
+ * interaction id) next to a written image. The MCP host's tools/call timeout
63
+ * can expire while the server-side generation finishes anyway — the response
64
+ * (and its interaction id) is then lost, but this sidecar survives, so the
65
+ * multi-turn chain is recoverable from disk.
66
+ */
67
+ export async function writeSidecar(imagePath, data) {
68
+ await writeFile(`${imagePath}.json`, JSON.stringify(data, null, 2));
69
+ }
60
70
  /**
61
71
  * Resolve an input file path, checking (in order):
62
72
  * 1. Absolute path that exists → returned as-is.
@@ -116,6 +126,37 @@ export function videoMimeType(p) {
116
126
  }
117
127
  return mime;
118
128
  }
129
+ /**
130
+ * Preview a local IMAGE input WITHOUT sending it anywhere: resolve the path to
131
+ * an absolute one, sniff its mime from the leading bytes, and measure its size.
132
+ * Reads the file locally (to sniff/measure) but makes NO network/API call — so a
133
+ * prompt-injected `image_path` (e.g. a local secret) is visible before upload.
134
+ */
135
+ export async function previewImageInput(path) {
136
+ const resolved = resolveImagePath(path);
137
+ // Read only the header bytes needed to sniff the MIME (≤12), and take the size
138
+ // from stat() — a dry-run must not load a large reference image into memory
139
+ // just to report it (matches previewVideoInput).
140
+ const { size } = await stat(resolved);
141
+ const fh = await open(resolved, 'r');
142
+ try {
143
+ const head = Buffer.alloc(16);
144
+ const { bytesRead } = await fh.read(head, 0, 16, 0);
145
+ return { path: resolved, mimeType: sniffMimeBytes(head.subarray(0, bytesRead)), size };
146
+ }
147
+ finally {
148
+ await fh.close();
149
+ }
150
+ }
151
+ /**
152
+ * Preview a local VIDEO input WITHOUT uploading it: resolve the path, derive its
153
+ * mime from the extension, and measure its size. Makes NO network/API call.
154
+ */
155
+ export async function previewVideoInput(path) {
156
+ const resolved = resolveVideoPath(path);
157
+ const { size } = await stat(resolved);
158
+ return { path: resolved, mimeType: videoMimeType(resolved), size };
159
+ }
119
160
  /** Read an image file into `{ base64, mimeType }` for an inline_data part. */
120
161
  export async function readImageAsInline(path) {
121
162
  const resolved = resolveImagePath(path);
@@ -0,0 +1,39 @@
1
+ import { schemaConfirm, textResult } from '@chrischall/mcp-utils';
2
+ import { previewImageInput, previewVideoInput } from '../images.js';
3
+ export { schemaConfirm };
4
+ /**
5
+ * Confirm-gate for a mutating tool (the fleet convention). When `confirm` is not
6
+ * `true`, returns a no-network dry-run preview of exactly what would be sent;
7
+ * when it is `true`, returns `null` so the caller proceeds with the write.
8
+ */
9
+ export function previewUnlessConfirmed(confirm, action, method, path, body) {
10
+ if (confirm === true)
11
+ return null;
12
+ return textResult({
13
+ dryRun: true,
14
+ action,
15
+ method,
16
+ path,
17
+ ...(body !== undefined ? { willSend: body } : {}),
18
+ note: 'Re-run with confirm: true to execute.',
19
+ });
20
+ }
21
+ /**
22
+ * Confirm-gate for a Gemini tool that ships LOCAL input files (image/video
23
+ * paths) to Google. Returns `null` (proceed) when `confirm === true` OR the call
24
+ * has no local file inputs — so pure text-to-image / base64 / clipboard calls
25
+ * are unaffected. Otherwise returns a NO-API dry-run echoing each RESOLVED
26
+ * ABSOLUTE input path plus its mime and size, so a prompt-injected path (e.g.
27
+ * `~/.ssh/id_rsa`) is visible and interceptable before any byte leaves the box.
28
+ */
29
+ export async function previewLocalInputsUnlessConfirmed(confirm, action, endpoint, imagePaths, videoPath) {
30
+ const hasLocal = (imagePaths?.length ?? 0) > 0 || Boolean(videoPath);
31
+ if (confirm === true || !hasLocal)
32
+ return null;
33
+ const inputs = [];
34
+ for (const p of imagePaths ?? [])
35
+ inputs.push(await previewImageInput(p));
36
+ if (videoPath)
37
+ inputs.push(await previewVideoInput(videoPath));
38
+ return previewUnlessConfirmed(confirm, action, 'POST', endpoint, { inputs });
39
+ }
@@ -3,7 +3,10 @@ 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, resolveVideoInput, videoPathSchema } from './shared.js';
6
+ import { emit, sharedImageSchema, pickSeed, buildMeta, resolveVideoInput, videoPathSchema, withProgressHeartbeat } from './shared.js';
7
+ import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
8
+ const GENERATE_ENDPOINT = '/v1beta/models/{model}:generateContent';
9
+ const SEND_LOCAL_ACTION = 'Send local file input(s) to the Gemini API';
7
10
  // generateContent output can't seed an Interactions-API conversation, so
8
11
  // results point follow-up edits at gemini_interact instead of an id.
9
12
  const REFINE_HINT = 'To iteratively refine this image, use gemini_interact (pass the output file via `images` on the first call, ' +
@@ -21,9 +24,16 @@ export function registerGenerateTools(server) {
21
24
  images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs'),
22
25
  video_url: z.string().url().optional().describe('Public YouTube URL (or a previously uploaded Files API uri) as a video reference (video→image; use a Flash model e.g. gemini-3.1-flash-image)'),
23
26
  video_path: videoPathSchema,
27
+ confirm: schemaConfirm,
24
28
  ...sharedImageSchema,
25
29
  },
26
- }, async (args) => {
30
+ }, async (args, extra) => {
31
+ // Confirm-gate local file inputs: a prompt-injected `images`/`video_path`
32
+ // could exfiltrate a local file. Pure text-to-image calls (no local input)
33
+ // are unaffected. Dry-run makes NO API call.
34
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images, args.video_path);
35
+ if (gate)
36
+ return gate;
27
37
  const count = args.count ?? 1;
28
38
  const seed = pickSeed(args.seed);
29
39
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
@@ -31,34 +41,37 @@ export function registerGenerateTools(server) {
31
41
  const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
32
42
  // Upload ONCE (before the count loop) — every generate call references
33
43
  // the same files/… uri.
34
- const video = await resolveVideoInput(args);
44
+ const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args));
35
45
  const named = [];
36
46
  let capturedText;
37
47
  // For count>1, surface the FIRST call's grounding (each call grounds
38
48
  // independently; one representative set of sources beats concatenating N).
39
49
  let capturedGrounding;
40
- for (let i = 0; i < count; i++) {
41
- // Distinct seed per image (seed+0 for the single-image case == echoed seed)
42
- // so count>1 yields N *different* images, not N duplicates.
43
- const result = await client.generate({
44
- prompt: args.prompt,
45
- images: refInputs.length > 0 ? refInputs : undefined,
46
- model: args.model,
47
- aspectRatio: args.aspect_ratio,
48
- imageSize: args.image_size,
49
- seed: seed + i,
50
- thinkingLevel: args.thinking_level,
51
- googleSearch: args.google_search,
52
- videoUrl: video.videoUrl,
53
- videoMimeType: video.videoMimeType,
54
- });
55
- const { images: [img], text } = result;
56
- if (text && !capturedText)
57
- capturedText = text;
58
- if (result.grounding && !capturedGrounding)
59
- capturedGrounding = result.grounding;
60
- named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, '0')}` });
61
- }
50
+ await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : 'image'} (${model})`, async () => {
51
+ for (let i = 0; i < count; i++) {
52
+ // Distinct seed per image (seed+0 for the single-image case == echoed seed)
53
+ // so count>1 yields N *different* images, not N duplicates.
54
+ const result = await client.generate({
55
+ prompt: args.prompt,
56
+ images: refInputs.length > 0 ? refInputs : undefined,
57
+ model: args.model,
58
+ aspectRatio: args.aspect_ratio,
59
+ imageSize: args.image_size,
60
+ seed: seed + i,
61
+ thinkingLevel: args.thinking_level,
62
+ googleSearch: args.google_search,
63
+ videoUrl: video.videoUrl,
64
+ videoMimeType: video.videoMimeType,
65
+ timeoutMs: args.timeout_ms,
66
+ });
67
+ const { images: [img], text } = result;
68
+ if (text && !capturedText)
69
+ capturedText = text;
70
+ if (result.grounding && !capturedGrounding)
71
+ capturedGrounding = result.grounding;
72
+ named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, '0')}` });
73
+ }
74
+ });
62
75
  const meta = buildMeta(model, seed, args);
63
76
  if (capturedText)
64
77
  meta.text = capturedText;
@@ -80,19 +93,25 @@ export function registerGenerateTools(server) {
80
93
  images: z.array(z.string().min(1)).optional().describe('Paths to input image file(s) (1 = edit, 2+ = compose)'),
81
94
  images_base64: z.array(z.string().min(1)).optional().describe('Input images as base64 strings or data URIs'),
82
95
  filename: z.string().optional().describe('Base filename for the output image (extension stripped; default: slugified prompt)'),
96
+ confirm: schemaConfirm,
83
97
  ...sharedImageSchema,
84
98
  },
85
- }, async (args) => {
99
+ }, async (args, extra) => {
86
100
  const hasPaths = (args.images?.length ?? 0) > 0;
87
101
  const hasBase64 = (args.images_base64?.length ?? 0) > 0;
88
102
  if (!hasPaths && !hasBase64 && !args.from_clipboard) {
89
103
  throw new McpToolError('Provide at least one input image via `images`, `images_base64`, or `from_clipboard`.');
90
104
  }
105
+ // Confirm-gate local file inputs (see gemini_generate_image). base64 /
106
+ // clipboard inputs are not local-path reads and pass through ungated.
107
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
108
+ if (gate)
109
+ return gate;
91
110
  const seed = pickSeed(args.seed);
92
111
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
93
112
  const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
94
113
  const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
95
- const result = await client.generate({
114
+ const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client.generate({
96
115
  prompt: args.prompt,
97
116
  images: inputs,
98
117
  model: args.model,
@@ -101,7 +120,8 @@ export function registerGenerateTools(server) {
101
120
  seed,
102
121
  thinkingLevel: args.thinking_level,
103
122
  googleSearch: args.google_search,
104
- });
123
+ timeoutMs: args.timeout_ms,
124
+ }));
105
125
  const { images: [img], text } = result;
106
126
  const meta = buildMeta(model, seed, args);
107
127
  if (text)
@@ -2,8 +2,9 @@ import { z } from 'zod';
2
2
  import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveModel } from '../models.js';
4
4
  import { client } from '../client.js';
5
- import { slugify, baseName, gatherImageInputs, resolveImagePath } from '../images.js';
6
- import { emit, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema } from './shared.js';
5
+ import { slugify, baseName, gatherImageInputs, resolveImagePath, writeSidecar } from '../images.js';
6
+ import { emit, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, withProgressHeartbeat } from './shared.js';
7
+ import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
7
8
  // The most recent interaction id this server process created — what
8
9
  // `continue_last: true` resumes. In-memory only: an MCP server lives for the
9
10
  // host session, so "last" means "last in this session".
@@ -14,6 +15,16 @@ let lastInteractionId;
14
15
  // the model against the requested edit (observed in the wild — callers pass
15
16
  // the last result path back via `images` despite the description saying not to).
16
17
  const writtenOutputs = new Set();
18
+ /**
19
+ * Test-only: clear the module-level session memory (`lastInteractionId` and the
20
+ * written-outputs set) so tests that assert the "no prior interaction" path are
21
+ * order-independent — they can't rely on module-load state once other tests have
22
+ * run first (e.g. under `--sequence.shuffle`).
23
+ */
24
+ export function __resetInteractSessionForTest() {
25
+ lastInteractionId = undefined;
26
+ writtenOutputs.clear();
27
+ }
17
28
  /** Shared warning for the reference-image params. */
18
29
  const NEW_REFERENCES_ONLY = 'NEW reference images only (e.g. a style or target photo). When chaining with previous_interaction_id, ' +
19
30
  "do NOT re-attach the prior turn's output — the interaction already contains it, and re-sending it " +
@@ -43,6 +54,9 @@ export function registerInteractTools(server) {
43
54
  'To refine, capture the returned interaction `id` and pass it as `previous_interaction_id` ' +
44
55
  'on the next call — do NOT start a new interaction or re-upload the image for each tweak. ' +
45
56
  '`continue_last: true` chains from this session\'s most recent interaction without threading the id. ' +
57
+ 'If a call times out on the client side, the generation usually still completes: the image plus a ' +
58
+ '`<image>.json` sidecar recording its interaction id land in the output dir, and `continue_last: true` ' +
59
+ 'still resumes that interaction — check the output dir before re-issuing (a re-issue is a second billable generation). ' +
46
60
  'Output is JPEG.',
47
61
  annotations: { readOnlyHint: false, openWorldHint: true },
48
62
  inputSchema: {
@@ -103,12 +117,14 @@ export function registerInteractTools(server) {
103
117
  .optional()
104
118
  .describe('Public YouTube URL (or a previously uploaded Files API uri) as a video reference (video→image; use a Flash model e.g. gemini-3.1-flash-image)'),
105
119
  video_path: videoPathSchema,
120
+ timeout_ms: timeoutMsSchema,
106
121
  from_clipboard: z
107
122
  .boolean()
108
123
  .optional()
109
124
  .describe('Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)'),
125
+ confirm: schemaConfirm,
110
126
  },
111
- }, async (args) => {
127
+ }, async (args, extra) => {
112
128
  let previousInteractionId = args.previous_interaction_id;
113
129
  if (!previousInteractionId && args.continue_last) {
114
130
  if (!lastInteractionId) {
@@ -123,9 +139,16 @@ export function registerInteractTools(server) {
123
139
  if (previousInteractionId && images?.length) {
124
140
  ({ kept: images, dropped } = splitReattachedOutputs(images));
125
141
  }
142
+ // Confirm-gate local file inputs AFTER the re-attach split, so the preview
143
+ // reflects the paths actually sent (dropped prior-output paths aren't). A
144
+ // prompt-injected `images`/`video_path` could exfiltrate a local file;
145
+ // base64/clipboard inputs pass through ungated. Dry-run makes NO API call.
146
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, 'Send local file input(s) to the Gemini Interactions API', '/v1beta/interactions', images, args.video_path);
147
+ if (gate)
148
+ return gate;
126
149
  const inputs = await gatherImageInputs({ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
127
- const video = await resolveVideoInput(args);
128
- const r = await client.interact({
150
+ const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args));
151
+ const r = await withProgressHeartbeat(extra, 'Generating image (Gemini Interactions API)', () => client.interact({
129
152
  input: args.input,
130
153
  images: inputs.length ? inputs : undefined,
131
154
  model: args.model,
@@ -137,7 +160,8 @@ export function registerInteractTools(server) {
137
160
  searchTypes: args.search_types,
138
161
  videoUrl: video.videoUrl,
139
162
  videoMimeType: video.videoMimeType,
140
- });
163
+ timeoutMs: args.timeout_ms,
164
+ }));
141
165
  lastInteractionId = r.id;
142
166
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
143
167
  const meta = { model, interaction_id: r.id };
@@ -160,7 +184,19 @@ export function registerInteractTools(server) {
160
184
  base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
161
185
  }));
162
186
  // writeImage returns already-resolved absolute paths.
163
- return emit(named, args, meta, (paths) => { for (const p of paths)
164
- writtenOutputs.add(p); });
187
+ return emit(named, args, meta, async (paths) => {
188
+ for (const p of paths)
189
+ writtenOutputs.add(p);
190
+ // Sidecar per image so the interaction id survives a lost MCP response
191
+ // (host timeout). Best-effort: the image is already on disk and the id
192
+ // is in the result — a sidecar failure shouldn't fail the call.
193
+ try {
194
+ for (const p of paths)
195
+ await writeSidecar(p, { ...meta, images: paths });
196
+ }
197
+ catch (err) {
198
+ meta.sidecar_error = err instanceof Error ? err.message : String(err);
199
+ }
200
+ });
165
201
  });
166
202
  }
package/dist/tools/set.js CHANGED
@@ -3,7 +3,8 @@ 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, withProgressHeartbeat } from './shared.js';
7
+ import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
7
8
  export function registerSetTools(server) {
8
9
  server.registerTool('gemini_generate_set', {
9
10
  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,44 +17,55 @@ export function registerSetTools(server) {
16
17
  basename: z.string().optional().describe('Base filename prefix for output images (default: slugified master_prompt)'),
17
18
  master_images: z.array(z.string().min(1)).optional().describe('Reference image paths passed to the master generation call'),
18
19
  master_images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs for master generation'),
20
+ confirm: schemaConfirm,
19
21
  ...sharedImageSchema,
20
22
  },
21
- }, async (args) => {
23
+ }, async (args, extra) => {
22
24
  // `scenes` is min(1) at the schema, so a non-undefined value is non-empty.
23
25
  if ((args.scenes && args.count) || (!args.scenes && !args.count)) {
24
26
  throw new McpToolError('Provide exactly one of `scenes` or `count`.');
25
27
  }
28
+ // Confirm-gate local file inputs: only `master_images` (paths) can read a
29
+ // local file; base64 references pass through ungated. Dry-run makes NO API call.
30
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, 'Send local master image input(s) to the Gemini API', '/v1beta/models/{model}:generateContent', args.master_images);
31
+ if (gate)
32
+ return gate;
26
33
  const seed = pickSeed(args.seed);
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 };
35
+ 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 };
29
36
  const slug = args.basename ? baseName(args.basename) : slugify(args.master_prompt);
30
37
  // 1. master (optionally seeded from reference images)
31
38
  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,
37
- });
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;
39
+ const named = [];
40
+ const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
41
+ const result = await client.generate({
42
+ prompt: args.master_prompt,
43
+ images: masterRefInputs.length > 0 ? masterRefInputs : undefined,
44
+ seed,
45
+ ...cfg,
46
+ });
47
+ const master = result.images[0];
48
+ named.push({ image: master, base: `${slug}-master` });
49
+ // Only the master's text is surfaced (below). Per-scene text is intentionally
50
+ // dropped — a set returns one combined result and merging N captions is noise.
51
+ // 2. scene prompts (explicit, or N repeats of master_prompt for variations)
52
+ const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
53
+ const mode = args.reference_mode ?? 'master';
54
+ if (mode === 'chain') {
55
+ let ref = master;
56
+ for (let i = 0; i < scenePrompts.length; i++) {
57
+ const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref], seed: seed + i + 1, ...cfg });
58
+ named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` });
59
+ ref = img;
60
+ }
51
61
  }
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
- }
62
+ else {
63
+ 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])));
64
+ scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` }));
65
+ }
66
+ return result;
67
+ });
68
+ const masterText = masterResult.text;
57
69
  const meta = buildMeta(model, seed, args);
58
70
  if (masterText)
59
71
  meta.text = masterText;