@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.
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);
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { registerModelTools } from './tools/models.js';
5
5
  import { registerGenerateTools } from './tools/generate.js';
6
6
  import { registerSetTools } from './tools/set.js';
7
7
  import { registerInteractTools } from './tools/interact.js';
8
+ import { registerJobTools } from './tools/jobs.js';
8
9
  // The GeminiClient is a module-level singleton (imported by each tool module)
9
10
  // that defers its config error to the first request — so the server boots and
10
11
  // answers the host's install-time tools/list probe even without GEMINI_API_KEY.
@@ -12,5 +13,5 @@ await runMcp({
12
13
  name: 'gemini-mcp',
13
14
  version: VERSION,
14
15
  banner: '[gemini-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.',
15
- tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools],
16
+ tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerJobTools],
16
17
  });
package/dist/jobs.js ADDED
Binary file
@@ -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,11 @@ 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, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat } from './shared.js';
7
+ import { dispatch, fingerprintRequest } from '../jobs.js';
8
+ import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
9
+ const GENERATE_ENDPOINT = '/v1beta/models/{model}:generateContent';
10
+ const SEND_LOCAL_ACTION = 'Send local file input(s) to the Gemini API';
7
11
  // generateContent output can't seed an Interactions-API conversation, so
8
12
  // results point follow-up edits at gemini_interact instead of an id.
9
13
  const REFINE_HINT = 'To iteratively refine this image, use gemini_interact (pass the output file via `images` on the first call, ' +
@@ -21,53 +25,76 @@ export function registerGenerateTools(server) {
21
25
  images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs'),
22
26
  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
27
  video_path: videoPathSchema,
28
+ confirm: schemaConfirm,
24
29
  ...sharedImageSchema,
25
30
  },
26
- }, async (args) => {
31
+ }, async (args, extra) => {
32
+ // Confirm-gate local file inputs: a prompt-injected `images`/`video_path`
33
+ // could exfiltrate a local file. Pure text-to-image calls (no local input)
34
+ // are unaffected. Dry-run makes NO API call.
35
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images, args.video_path);
36
+ if (gate)
37
+ return gate;
27
38
  const count = args.count ?? 1;
28
- const seed = pickSeed(args.seed);
29
39
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
30
- const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
31
- const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
32
- // Upload ONCE (before the count loop) — every generate call references
33
- // the same files/… uri.
34
- const video = await resolveVideoInput(args);
35
- const named = [];
36
- let capturedText;
37
- // For count>1, surface the FIRST call's grounding (each call grounds
38
- // independently; one representative set of sources beats concatenating N).
39
- 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,
40
+ // Fingerprint the request identity (excl. server-picked seed / output path)
41
+ // so a blind re-issue after a host timeout dedups instead of re-billing.
42
+ const fingerprint = fingerprintRequest('gemini_generate_image', {
43
+ model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
44
+ thinking_level: args.thinking_level, google_search: args.google_search, count,
45
+ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
46
+ video_url: args.video_url, video_path: args.video_path,
47
+ });
48
+ return dispatch({ toolName: 'gemini_generate_image', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
49
+ const seed = pickSeed(args.seed);
50
+ const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
51
+ const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
52
+ // Upload ONCE (before the count loop) — every generate call references
53
+ // the same files/… uri.
54
+ const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args));
55
+ const named = [];
56
+ let capturedText;
57
+ // For count>1, surface the FIRST call's grounding (each call grounds
58
+ // independently; one representative set of sources beats concatenating N).
59
+ let capturedGrounding;
60
+ await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : 'image'} (${model})`, async () => {
61
+ for (let i = 0; i < count; i++) {
62
+ // Distinct seed per image (seed+0 for the single-image case == echoed seed)
63
+ // so count>1 yields N *different* images, not N duplicates.
64
+ const result = await client.generate({
65
+ prompt: args.prompt,
66
+ images: refInputs.length > 0 ? refInputs : undefined,
67
+ model: args.model,
68
+ aspectRatio: args.aspect_ratio,
69
+ imageSize: args.image_size,
70
+ seed: seed + i,
71
+ thinkingLevel: args.thinking_level,
72
+ googleSearch: args.google_search,
73
+ videoUrl: video.videoUrl,
74
+ videoMimeType: video.videoMimeType,
75
+ timeoutMs: args.timeout_ms,
76
+ });
77
+ const { images: [img], text } = result;
78
+ if (text && !capturedText)
79
+ capturedText = text;
80
+ if (result.grounding && !capturedGrounding)
81
+ capturedGrounding = result.grounding;
82
+ named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, '0')}` });
83
+ }
54
84
  });
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
- }
62
- const meta = buildMeta(model, seed, args);
63
- if (capturedText)
64
- meta.text = capturedText;
65
- if (capturedGrounding)
66
- meta.grounding = capturedGrounding;
67
- if (video.videoFileMeta)
68
- meta.video_file = video.videoFileMeta;
69
- meta.hint = REFINE_HINT;
70
- return emit(named, args, meta);
85
+ const meta = buildMeta(model, seed, args);
86
+ if (capturedText)
87
+ meta.text = capturedText;
88
+ if (capturedGrounding)
89
+ meta.grounding = capturedGrounding;
90
+ if (video.videoFileMeta)
91
+ meta.video_file = video.videoFileMeta;
92
+ meta.hint = REFINE_HINT;
93
+ const risk = timeoutRiskHint({ model, imageSize: args.image_size, count });
94
+ if (risk)
95
+ meta.timeout_risk = risk;
96
+ return emit(named, args, meta);
97
+ });
71
98
  });
72
99
  server.registerTool('gemini_edit_image', {
73
100
  description: 'Edit or compose images: provide one or more input images (paths or base64), plus a text instruction. ' +
@@ -80,35 +107,52 @@ export function registerGenerateTools(server) {
80
107
  images: z.array(z.string().min(1)).optional().describe('Paths to input image file(s) (1 = edit, 2+ = compose)'),
81
108
  images_base64: z.array(z.string().min(1)).optional().describe('Input images as base64 strings or data URIs'),
82
109
  filename: z.string().optional().describe('Base filename for the output image (extension stripped; default: slugified prompt)'),
110
+ confirm: schemaConfirm,
83
111
  ...sharedImageSchema,
84
112
  },
85
- }, async (args) => {
113
+ }, async (args, extra) => {
86
114
  const hasPaths = (args.images?.length ?? 0) > 0;
87
115
  const hasBase64 = (args.images_base64?.length ?? 0) > 0;
88
116
  if (!hasPaths && !hasBase64 && !args.from_clipboard) {
89
117
  throw new McpToolError('Provide at least one input image via `images`, `images_base64`, or `from_clipboard`.');
90
118
  }
91
- const seed = pickSeed(args.seed);
119
+ // Confirm-gate local file inputs (see gemini_generate_image). base64 /
120
+ // clipboard inputs are not local-path reads and pass through ungated.
121
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
122
+ if (gate)
123
+ return gate;
92
124
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
93
- const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
94
- const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
95
- const result = await client.generate({
96
- prompt: args.prompt,
97
- images: inputs,
98
- model: args.model,
99
- aspectRatio: args.aspect_ratio,
100
- imageSize: args.image_size,
101
- seed,
102
- thinkingLevel: args.thinking_level,
103
- googleSearch: args.google_search,
125
+ const fingerprint = fingerprintRequest('gemini_edit_image', {
126
+ model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
127
+ thinking_level: args.thinking_level, google_search: args.google_search,
128
+ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
129
+ });
130
+ return dispatch({ toolName: 'gemini_edit_image', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
131
+ const seed = pickSeed(args.seed);
132
+ const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
133
+ const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
134
+ const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client.generate({
135
+ prompt: args.prompt,
136
+ images: inputs,
137
+ model: args.model,
138
+ aspectRatio: args.aspect_ratio,
139
+ imageSize: args.image_size,
140
+ seed,
141
+ thinkingLevel: args.thinking_level,
142
+ googleSearch: args.google_search,
143
+ timeoutMs: args.timeout_ms,
144
+ }));
145
+ const { images: [img], text } = result;
146
+ const meta = buildMeta(model, seed, args);
147
+ if (text)
148
+ meta.text = text;
149
+ if (result.grounding)
150
+ meta.grounding = result.grounding;
151
+ meta.hint = REFINE_HINT;
152
+ const risk = timeoutRiskHint({ model, imageSize: args.image_size });
153
+ if (risk)
154
+ meta.timeout_risk = risk;
155
+ return emit([{ image: img, base: slug }], args, meta);
104
156
  });
105
- const { images: [img], text } = result;
106
- const meta = buildMeta(model, seed, args);
107
- if (text)
108
- meta.text = text;
109
- if (result.grounding)
110
- meta.grounding = result.grounding;
111
- meta.hint = REFINE_HINT;
112
- return emit([{ image: img, base: slug }], args, meta);
113
157
  });
114
158
  }
@@ -2,8 +2,10 @@ 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, timeoutRiskHint, idempotencyKeySchema, asyncSchema, withProgressHeartbeat } from './shared.js';
7
+ import { dispatch, fingerprintRequest } from '../jobs.js';
8
+ import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
7
9
  // The most recent interaction id this server process created — what
8
10
  // `continue_last: true` resumes. In-memory only: an MCP server lives for the
9
11
  // host session, so "last" means "last in this session".
@@ -14,6 +16,16 @@ let lastInteractionId;
14
16
  // the model against the requested edit (observed in the wild — callers pass
15
17
  // the last result path back via `images` despite the description saying not to).
16
18
  const writtenOutputs = new Set();
19
+ /**
20
+ * Test-only: clear the module-level session memory (`lastInteractionId` and the
21
+ * written-outputs set) so tests that assert the "no prior interaction" path are
22
+ * order-independent — they can't rely on module-load state once other tests have
23
+ * run first (e.g. under `--sequence.shuffle`).
24
+ */
25
+ export function __resetInteractSessionForTest() {
26
+ lastInteractionId = undefined;
27
+ writtenOutputs.clear();
28
+ }
17
29
  /** Shared warning for the reference-image params. */
18
30
  const NEW_REFERENCES_ONLY = 'NEW reference images only (e.g. a style or target photo). When chaining with previous_interaction_id, ' +
19
31
  "do NOT re-attach the prior turn's output — the interaction already contains it, and re-sending it " +
@@ -43,6 +55,9 @@ export function registerInteractTools(server) {
43
55
  'To refine, capture the returned interaction `id` and pass it as `previous_interaction_id` ' +
44
56
  'on the next call — do NOT start a new interaction or re-upload the image for each tweak. ' +
45
57
  '`continue_last: true` chains from this session\'s most recent interaction without threading the id. ' +
58
+ 'If a call times out on the client side, the generation usually still completes: the image plus a ' +
59
+ '`<image>.json` sidecar recording its interaction id land in the output dir, and `continue_last: true` ' +
60
+ 'still resumes that interaction — check the output dir before re-issuing (a re-issue is a second billable generation). ' +
46
61
  'Output is JPEG.',
47
62
  annotations: { readOnlyHint: false, openWorldHint: true },
48
63
  inputSchema: {
@@ -103,12 +118,16 @@ export function registerInteractTools(server) {
103
118
  .optional()
104
119
  .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
120
  video_path: videoPathSchema,
121
+ timeout_ms: timeoutMsSchema,
122
+ idempotency_key: idempotencyKeySchema,
123
+ async: asyncSchema,
106
124
  from_clipboard: z
107
125
  .boolean()
108
126
  .optional()
109
127
  .describe('Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)'),
128
+ confirm: schemaConfirm,
110
129
  },
111
- }, async (args) => {
130
+ }, async (args, extra) => {
112
131
  let previousInteractionId = args.previous_interaction_id;
113
132
  if (!previousInteractionId && args.continue_last) {
114
133
  if (!lastInteractionId) {
@@ -123,44 +142,76 @@ export function registerInteractTools(server) {
123
142
  if (previousInteractionId && images?.length) {
124
143
  ({ kept: images, dropped } = splitReattachedOutputs(images));
125
144
  }
126
- 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({
129
- input: args.input,
130
- images: inputs.length ? inputs : undefined,
131
- model: args.model,
132
- aspectRatio: args.aspect_ratio,
133
- imageSize: args.image_size,
134
- thinkingLevel: args.thinking_level,
135
- previousInteractionId,
136
- googleSearch: args.google_search,
137
- searchTypes: args.search_types,
138
- videoUrl: video.videoUrl,
139
- videoMimeType: video.videoMimeType,
140
- });
141
- lastInteractionId = r.id;
145
+ // Confirm-gate local file inputs AFTER the re-attach split, so the preview
146
+ // reflects the paths actually sent (dropped prior-output paths aren't). A
147
+ // prompt-injected `images`/`video_path` could exfiltrate a local file;
148
+ // base64/clipboard inputs pass through ungated. Dry-run makes NO API call.
149
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, 'Send local file input(s) to the Gemini Interactions API', '/v1beta/interactions', images, args.video_path);
150
+ if (gate)
151
+ return gate;
142
152
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
143
- const meta = { model, interaction_id: r.id };
144
- if (previousInteractionId)
145
- meta.previous_interaction_id = previousInteractionId;
146
- if (dropped.length)
147
- meta.dropped_previous_output = dropped;
148
- meta.hint =
149
- `To refine this image, call gemini_interact again with previous_interaction_id: "${r.id}" (or continue_last: true) — ` +
150
- 'do NOT re-attach the returned image as an input; the interaction already contains it.';
151
- if (r.text)
152
- meta.text = r.text;
153
- if (r.grounding)
154
- meta.grounding = r.grounding;
155
- if (video.videoFileMeta)
156
- meta.video_file = video.videoFileMeta;
157
- const slug = args.filename ? baseName(args.filename) : slugify(args.input);
158
- const named = r.images.map((image, i) => ({
159
- image,
160
- base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
161
- }));
162
- // writeImage returns already-resolved absolute paths.
163
- return emit(named, args, meta, (paths) => { for (const p of paths)
164
- writtenOutputs.add(p); });
153
+ const fingerprint = fingerprintRequest('gemini_interact', {
154
+ model, input: args.input, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
155
+ thinking_level: args.thinking_level, previous_interaction_id: previousInteractionId,
156
+ google_search: args.google_search, search_types: args.search_types,
157
+ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
158
+ video_url: args.video_url, video_path: args.video_path,
159
+ });
160
+ return dispatch({ toolName: 'gemini_interact', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
161
+ const inputs = await gatherImageInputs({ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
162
+ const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args));
163
+ const r = await withProgressHeartbeat(extra, 'Generating image (Gemini Interactions API)', () => client.interact({
164
+ input: args.input,
165
+ images: inputs.length ? inputs : undefined,
166
+ model: args.model,
167
+ aspectRatio: args.aspect_ratio,
168
+ imageSize: args.image_size,
169
+ thinkingLevel: args.thinking_level,
170
+ previousInteractionId,
171
+ googleSearch: args.google_search,
172
+ searchTypes: args.search_types,
173
+ videoUrl: video.videoUrl,
174
+ videoMimeType: video.videoMimeType,
175
+ timeoutMs: args.timeout_ms,
176
+ }));
177
+ lastInteractionId = r.id;
178
+ const meta = { model, interaction_id: r.id };
179
+ if (previousInteractionId)
180
+ meta.previous_interaction_id = previousInteractionId;
181
+ if (dropped.length)
182
+ meta.dropped_previous_output = dropped;
183
+ meta.hint =
184
+ `To refine this image, call gemini_interact again with previous_interaction_id: "${r.id}" (or continue_last: true) — ` +
185
+ 'do NOT re-attach the returned image as an input; the interaction already contains it.';
186
+ if (r.text)
187
+ meta.text = r.text;
188
+ if (r.grounding)
189
+ meta.grounding = r.grounding;
190
+ if (video.videoFileMeta)
191
+ meta.video_file = video.videoFileMeta;
192
+ const risk = timeoutRiskHint({ model, imageSize: args.image_size });
193
+ if (risk)
194
+ meta.timeout_risk = risk;
195
+ const slug = args.filename ? baseName(args.filename) : slugify(args.input);
196
+ const named = r.images.map((image, i) => ({
197
+ image,
198
+ base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
199
+ }));
200
+ // writeImage returns already-resolved absolute paths.
201
+ return emit(named, args, meta, async (paths) => {
202
+ for (const p of paths)
203
+ writtenOutputs.add(p);
204
+ // Sidecar per image so the interaction id survives a lost MCP response
205
+ // (host timeout). Best-effort: the image is already on disk and the id
206
+ // is in the result — a sidecar failure shouldn't fail the call.
207
+ try {
208
+ for (const p of paths)
209
+ await writeSidecar(p, { ...meta, images: paths });
210
+ }
211
+ catch (err) {
212
+ meta.sidecar_error = err instanceof Error ? err.message : String(err);
213
+ }
214
+ });
215
+ });
165
216
  });
166
217
  }