@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/bundle.js CHANGED
@@ -32106,7 +32106,7 @@ var init_server3 = __esm({
32106
32106
 
32107
32107
  // node_modules/@chrischall/mcp-utils/dist/errors/index.js
32108
32108
  function redactSecrets(text) {
32109
- return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(JWT_RE, "[REDACTED]");
32109
+ return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
32110
32110
  }
32111
32111
  function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
32112
32112
  const str = text === null || text === void 0 ? "" : String(text);
@@ -32115,7 +32115,7 @@ function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
32115
32115
  return redacted;
32116
32116
  return `${redacted.slice(0, max)}\u2026 [truncated]`;
32117
32117
  }
32118
- var DEFAULT_ERROR_MESSAGE_MAX, McpToolError, BEARER_RE, JWT_RE, BASIC_AUTH_RE, SET_COOKIE_RE, COOKIE_HEADER_RE, API_KEY_RE, QUERY_SECRET_RE;
32118
+ var DEFAULT_ERROR_MESSAGE_MAX, McpToolError, BEARER_RE, JWT_RE, BASIC_AUTH_RE, SET_COOKIE_RE, COOKIE_HEADER_RE, API_KEY_RE, QUERY_SECRET_RE, AWS_SIGV4_RE, JSON_SECRET_KEYS, JSON_SECRET_DQ_RE, JSON_SECRET_SQ_RE;
32119
32119
  var init_errors4 = __esm({
32120
32120
  "node_modules/@chrischall/mcp-utils/dist/errors/index.js"() {
32121
32121
  DEFAULT_ERROR_MESSAGE_MAX = 500;
@@ -32150,6 +32150,10 @@ var init_errors4 = __esm({
32150
32150
  // webhook signing secret (Stripe-style)
32151
32151
  ].map((p) => `\\b${p}`).join("|"), "g");
32152
32152
  QUERY_SECRET_RE = /([?&](?:access_token|refresh_token|client_secret|api_?key|signature|token|key|sig)=)[^&#\s"'<>`]+/gi;
32153
+ AWS_SIGV4_RE = /([?&]X-Amz-(?:Signature|Security-Token|Credential)=)[^&#\s"'<>`]+/gi;
32154
+ JSON_SECRET_KEYS = "access_token|refresh_token|client_secret|api_?key|password|passwd|secret|token";
32155
+ JSON_SECRET_DQ_RE = new RegExp(`("(?:${JSON_SECRET_KEYS})"\\s*:\\s*")[^"]*(")`, "gi");
32156
+ JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')[^']*(')`, "gi");
32153
32157
  }
32154
32158
  });
32155
32159
 
@@ -32200,6 +32204,13 @@ var init_config = __esm({
32200
32204
  }
32201
32205
  });
32202
32206
 
32207
+ // node_modules/@chrischall/mcp-utils/dist/fs/output.js
32208
+ var init_output = __esm({
32209
+ "node_modules/@chrischall/mcp-utils/dist/fs/output.js"() {
32210
+ init_config();
32211
+ }
32212
+ });
32213
+
32203
32214
  // node_modules/@chrischall/mcp-utils/dist/fs/index.js
32204
32215
  import { openAsBlob } from "node:fs";
32205
32216
  async function fileBlob(path, opts = {}) {
@@ -32216,6 +32227,7 @@ async function fileBlob(path, opts = {}) {
32216
32227
  }
32217
32228
  var init_fs = __esm({
32218
32229
  "node_modules/@chrischall/mcp-utils/dist/fs/index.js"() {
32230
+ init_output();
32219
32231
  }
32220
32232
  });
32221
32233
 
@@ -32225,6 +32237,28 @@ var init_throttle = __esm({
32225
32237
  }
32226
32238
  });
32227
32239
 
32240
+ // node_modules/@chrischall/mcp-utils/dist/http/response-cache.js
32241
+ var init_response_cache = __esm({
32242
+ "node_modules/@chrischall/mcp-utils/dist/http/response-cache.js"() {
32243
+ }
32244
+ });
32245
+
32246
+ // node_modules/@chrischall/mcp-utils/dist/http/net-atoms.js
32247
+ function parseRetryAfterMs(header, opts = {}) {
32248
+ const defaultMs = opts.defaultMs ?? 2e3;
32249
+ const capMs = opts.capMs ?? 3e4;
32250
+ if (header == null)
32251
+ return defaultMs;
32252
+ const trimmed = header.trim();
32253
+ if (!/^\d+$/.test(trimmed))
32254
+ return defaultMs;
32255
+ return Math.min(Number(trimmed) * 1e3, capMs);
32256
+ }
32257
+ var init_net_atoms = __esm({
32258
+ "node_modules/@chrischall/mcp-utils/dist/http/net-atoms.js"() {
32259
+ }
32260
+ });
32261
+
32228
32262
  // node_modules/@chrischall/mcp-utils/dist/http/index.js
32229
32263
  function hostOf(baseUrl) {
32230
32264
  try {
@@ -32280,7 +32314,11 @@ function createApiClient(opts) {
32280
32314
  const res = await once();
32281
32315
  if (retryStatuses.includes(res.status) && attempt < retry.count) {
32282
32316
  attempt += 1;
32283
- await sleep(retry.delayMs);
32317
+ const delay = retry.honorRetryAfter ? parseRetryAfterMs(res.headers.get("retry-after"), {
32318
+ defaultMs: retry.delayMs,
32319
+ capMs: retry.maxRetryAfterMs ?? 3e4
32320
+ }) : retry.delayMs;
32321
+ await sleep(delay);
32284
32322
  continue;
32285
32323
  }
32286
32324
  return res;
@@ -32315,7 +32353,26 @@ function createApiClient(opts) {
32315
32353
  }
32316
32354
  return text;
32317
32355
  }
32318
- return { fetchJson, fetchHtml };
32356
+ async function fetchRaw(method, path, opt = {}) {
32357
+ const headers = { Accept: "*/*", ...opt.headers };
32358
+ const res = await send(method, path, { ...opt, headers });
32359
+ if (res.status === 401)
32360
+ throw unauthorized();
32361
+ if (res.status === 429)
32362
+ throw rateLimited();
32363
+ if (!res.ok) {
32364
+ const text = await res.text().catch(() => "");
32365
+ throw new ApiError(res.status, formatApiError(res.status, method, path, text, { service }));
32366
+ }
32367
+ const bytes = new Uint8Array(await res.arrayBuffer());
32368
+ return {
32369
+ status: res.status,
32370
+ contentType: res.headers.get("content-type"),
32371
+ headers: res.headers,
32372
+ bytes
32373
+ };
32374
+ }
32375
+ return { fetchJson, fetchHtml, fetchRaw };
32319
32376
  }
32320
32377
  function buildQueryString(params) {
32321
32378
  const parts = [];
@@ -32345,6 +32402,9 @@ var init_http = __esm({
32345
32402
  "node_modules/@chrischall/mcp-utils/dist/http/index.js"() {
32346
32403
  init_errors4();
32347
32404
  init_throttle();
32405
+ init_response_cache();
32406
+ init_net_atoms();
32407
+ init_net_atoms();
32348
32408
  DEFAULT_RETRY = { count: 1, delayMs: 2e3 };
32349
32409
  defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
32350
32410
  UnauthorizedError = class extends Error {
@@ -32384,9 +32444,16 @@ var init_http = __esm({
32384
32444
  }
32385
32445
  });
32386
32446
 
32447
+ // node_modules/@chrischall/mcp-utils/dist/concurrency/single-flight.js
32448
+ var init_single_flight = __esm({
32449
+ "node_modules/@chrischall/mcp-utils/dist/concurrency/single-flight.js"() {
32450
+ }
32451
+ });
32452
+
32387
32453
  // node_modules/@chrischall/mcp-utils/dist/concurrency/index.js
32388
32454
  var init_concurrency = __esm({
32389
32455
  "node_modules/@chrischall/mcp-utils/dist/concurrency/index.js"() {
32456
+ init_single_flight();
32390
32457
  }
32391
32458
  });
32392
32459
 
@@ -32396,11 +32463,19 @@ var init_dates = __esm({
32396
32463
  }
32397
32464
  });
32398
32465
 
32466
+ // node_modules/@chrischall/mcp-utils/dist/zod/parse-lenient.js
32467
+ var init_parse_lenient = __esm({
32468
+ "node_modules/@chrischall/mcp-utils/dist/zod/parse-lenient.js"() {
32469
+ init_errors4();
32470
+ }
32471
+ });
32472
+
32399
32473
  // node_modules/@chrischall/mcp-utils/dist/zod/index.js
32400
32474
  var PositiveInt, NonNegInt, NonEmptyString, IsoDate, IsoTime, NumericIdString, SafePathSegment, schemaOrigin, schemaConfirm, paginationSchema, pageSchema;
32401
32475
  var init_zod2 = __esm({
32402
32476
  "node_modules/@chrischall/mcp-utils/dist/zod/index.js"() {
32403
32477
  init_zod();
32478
+ init_parse_lenient();
32404
32479
  PositiveInt = external_exports.number().int().positive();
32405
32480
  NonNegInt = external_exports.number().int().nonnegative();
32406
32481
  NonEmptyString = external_exports.string().min(1);
@@ -32421,12 +32496,32 @@ var init_zod2 = __esm({
32421
32496
  }
32422
32497
  });
32423
32498
 
32499
+ // node_modules/@chrischall/mcp-utils/dist/auth/cached-token.js
32500
+ var init_cached_token = __esm({
32501
+ "node_modules/@chrischall/mcp-utils/dist/auth/cached-token.js"() {
32502
+ }
32503
+ });
32504
+
32505
+ // node_modules/@chrischall/mcp-utils/dist/auth/es256.js
32506
+ var init_es256 = __esm({
32507
+ "node_modules/@chrischall/mcp-utils/dist/auth/es256.js"() {
32508
+ }
32509
+ });
32510
+
32424
32511
  // node_modules/@chrischall/mcp-utils/dist/auth/index.js
32425
32512
  var init_auth = __esm({
32426
32513
  "node_modules/@chrischall/mcp-utils/dist/auth/index.js"() {
32427
32514
  init_config();
32428
32515
  init_errors4();
32429
32516
  init_http();
32517
+ init_cached_token();
32518
+ init_es256();
32519
+ }
32520
+ });
32521
+
32522
+ // node_modules/@chrischall/mcp-utils/dist/scrape/index.js
32523
+ var init_scrape = __esm({
32524
+ "node_modules/@chrischall/mcp-utils/dist/scrape/index.js"() {
32430
32525
  }
32431
32526
  });
32432
32527
 
@@ -32443,6 +32538,7 @@ var init_dist = __esm({
32443
32538
  init_dates();
32444
32539
  init_zod2();
32445
32540
  init_auth();
32541
+ init_scrape();
32446
32542
  }
32447
32543
  });
32448
32544
 
@@ -32510,7 +32606,7 @@ var init_clipboard = __esm({
32510
32606
  init_dist();
32511
32607
 
32512
32608
  // src/version.ts
32513
- var VERSION = "0.7.2";
32609
+ var VERSION = "0.9.0";
32514
32610
 
32515
32611
  // src/tools/models.ts
32516
32612
  init_dist();
@@ -32540,6 +32636,13 @@ var BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
32540
32636
  var SERVICE = "Gemini";
32541
32637
  var INTERACTIONS_SERVICE = "Gemini Interactions";
32542
32638
  var REQUEST_TIMEOUT_MS = 6e4;
32639
+ var REQUEST_TIMEOUT_4K_MS = 12e4;
32640
+ function resolveTimeoutMs(perCallMs, imageSize) {
32641
+ if (perCallMs !== void 0 && perCallMs > 0) return perCallMs;
32642
+ const env = Number(readEnvVar("GEMINI_TIMEOUT_MS"));
32643
+ if (Number.isFinite(env) && env > 0) return env;
32644
+ return imageSize === "4K" ? REQUEST_TIMEOUT_4K_MS : REQUEST_TIMEOUT_MS;
32645
+ }
32543
32646
  var UPLOAD_BASE_URL = "https://generativelanguage.googleapis.com/upload/v1beta";
32544
32647
  var FILE_MAX_BYTES = 2 * 1024 ** 3;
32545
32648
  var FILE_POLL_INTERVAL_MS = 2e3;
@@ -32549,8 +32652,7 @@ var CHAIN_404_RETRY_MS = 2e3;
32549
32652
  var GeminiClient = class {
32550
32653
  apiKey;
32551
32654
  configError;
32552
- api;
32553
- interactionsApi;
32655
+ apis = /* @__PURE__ */ new Map();
32554
32656
  fetchImpl;
32555
32657
  sleep;
32556
32658
  constructor(opts = {}) {
@@ -32566,18 +32668,37 @@ var GeminiClient = class {
32566
32668
  }
32567
32669
  this.fetchImpl = opts.fetchImpl ?? fetch;
32568
32670
  this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
32569
- const shared = {
32570
- tokenHeader: "x-goog-api-key",
32571
- getToken: () => this.requireKey(),
32572
- timeout: REQUEST_TIMEOUT_MS,
32573
- fetchImpl: this.fetchImpl
32574
- };
32575
- this.api = createApiClient({ baseUrl: BASE_URL, serviceName: SERVICE, ...shared });
32576
- this.interactionsApi = createApiClient({
32577
- baseUrl: BASE_URL,
32578
- serviceName: INTERACTIONS_SERVICE,
32579
- ...shared
32580
- });
32671
+ }
32672
+ /**
32673
+ * The `createApiClient` pair for one timeout budget, built lazily and
32674
+ * memoized per distinct timeout (a handful of values in practice). Both
32675
+ * share: the key in the `x-goog-api-key` header (Gemini does not use
32676
+ * `Authorization: Bearer`) and the default 429 retry (once after 2s — the
32677
+ * free tier rate-limits aggressively). `getToken` calls requireKey() so key
32678
+ * resolution stays at REQUEST time: the server boots without a key and the
32679
+ * actionable config error (with its aistudio hint) surfaces on the first
32680
+ * tool call, not at startup.
32681
+ */
32682
+ apisFor(timeoutMs) {
32683
+ let entry = this.apis.get(timeoutMs);
32684
+ if (!entry) {
32685
+ const shared = {
32686
+ tokenHeader: "x-goog-api-key",
32687
+ getToken: () => this.requireKey(),
32688
+ timeout: timeoutMs,
32689
+ fetchImpl: this.fetchImpl
32690
+ };
32691
+ entry = {
32692
+ api: createApiClient({ baseUrl: BASE_URL, serviceName: SERVICE, ...shared }),
32693
+ // Separate client for the Interactions API so errors name the right API.
32694
+ // The beta-era Api-Revision header is gone: the API went GA and requests
32695
+ // succeed without it (verified live 2026-07-06; see docs/GEMINI-API.md).
32696
+ // Don't re-add a pinned revision — it would freeze us on old semantics.
32697
+ interactionsApi: createApiClient({ baseUrl: BASE_URL, serviceName: INTERACTIONS_SERVICE, ...shared })
32698
+ };
32699
+ this.apis.set(timeoutMs, entry);
32700
+ }
32701
+ return entry;
32581
32702
  }
32582
32703
  requireKey() {
32583
32704
  if (this.configError) throw this.configError;
@@ -32587,8 +32708,9 @@ var GeminiClient = class {
32587
32708
  defaultModel() {
32588
32709
  return resolveModel(void 0, readEnvVar("GEMINI_IMAGE_MODEL"));
32589
32710
  }
32590
- async call(method, path, body) {
32591
- return this.api.fetchJson(method, path, body !== void 0 ? { body } : {});
32711
+ async call(method, path, body, timeoutMs) {
32712
+ const { api } = this.apisFor(timeoutMs ?? resolveTimeoutMs());
32713
+ return api.fetchJson(method, path, body !== void 0 ? { body } : {});
32592
32714
  }
32593
32715
  /**
32594
32716
  * Upload a local video to the Gemini Files API (resumable protocol) and wait
@@ -32704,7 +32826,8 @@ var GeminiClient = class {
32704
32826
  const data = await this.call(
32705
32827
  "POST",
32706
32828
  `/models/${model}:generateContent`,
32707
- requestBody
32829
+ requestBody,
32830
+ resolveTimeoutMs(opts.timeoutMs, opts.imageSize)
32708
32831
  );
32709
32832
  const images = [];
32710
32833
  const textParts = [];
@@ -32758,10 +32881,11 @@ var GeminiClient = class {
32758
32881
  if (opts.searchTypes?.length) searchTool.search_types = opts.searchTypes;
32759
32882
  body.tools = [searchTool];
32760
32883
  }
32884
+ const { interactionsApi } = this.apisFor(resolveTimeoutMs(opts.timeoutMs, opts.imageSize));
32761
32885
  let data;
32762
32886
  for (let attempt = 0; ; attempt++) {
32763
32887
  try {
32764
- data = await this.interactionsApi.fetchJson("POST", "/interactions", {
32888
+ data = await interactionsApi.fetchJson("POST", "/interactions", {
32765
32889
  body
32766
32890
  });
32767
32891
  break;
@@ -32844,7 +32968,7 @@ init_dist();
32844
32968
 
32845
32969
  // src/images.ts
32846
32970
  init_dist();
32847
- import { mkdir, readFile as readFile2, writeFile, access } from "node:fs/promises";
32971
+ import { mkdir, readFile as readFile2, writeFile, access, stat, open } from "node:fs/promises";
32848
32972
  import { existsSync } from "node:fs";
32849
32973
  import { join as join3, resolve, isAbsolute } from "node:path";
32850
32974
  function slugify2(text, max = 40) {
@@ -32887,6 +33011,9 @@ async function writeImage(dir, base, base643, mimeType) {
32887
33011
  await writeFile(path, Buffer.from(base643, "base64"));
32888
33012
  return resolve(path);
32889
33013
  }
33014
+ async function writeSidecar(imagePath, data) {
33015
+ await writeFile(`${imagePath}.json`, JSON.stringify(data, null, 2));
33016
+ }
32890
33017
  function resolveInputPath(p, kind) {
32891
33018
  if (isAbsolute(p) && existsSync(p)) return p;
32892
33019
  const inputDir = readEnvVar("GEMINI_INPUT_DIR");
@@ -32930,6 +33057,23 @@ function videoMimeType(p) {
32930
33057
  }
32931
33058
  return mime;
32932
33059
  }
33060
+ async function previewImageInput(path) {
33061
+ const resolved = resolveImagePath(path);
33062
+ const { size } = await stat(resolved);
33063
+ const fh = await open(resolved, "r");
33064
+ try {
33065
+ const head = Buffer.alloc(16);
33066
+ const { bytesRead } = await fh.read(head, 0, 16, 0);
33067
+ return { path: resolved, mimeType: sniffMimeBytes(head.subarray(0, bytesRead)), size };
33068
+ } finally {
33069
+ await fh.close();
33070
+ }
33071
+ }
33072
+ async function previewVideoInput(path) {
33073
+ const resolved = resolveVideoPath(path);
33074
+ const { size } = await stat(resolved);
33075
+ return { path: resolved, mimeType: videoMimeType(resolved), size };
33076
+ }
32933
33077
  async function readImageAsInline(path) {
32934
33078
  const resolved = resolveImagePath(path);
32935
33079
  const buf = await readFile2(resolved);
@@ -32974,6 +33118,15 @@ init_dist();
32974
33118
  var 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"];
32975
33119
  var IMAGE_SIZES = ["512", "1K", "2K", "4K"];
32976
33120
  var MODEL_CHOICE_GUIDE = "gemini-3.1-flash-image (Nano Banana 2) is the versatile generalist workhorse \u2014 balances speed with state-of-the-art 4K generation, world knowledge, and reliable text rendering; excels at multi-reference-image processing and consistency. gemini-3-pro-image (Nano Banana Pro) is the premium choice for the most complex visual tasks \u2014 highest world knowledge, advanced localization, accurate brand consistency, precision creative control. gemini-3.1-flash-lite-image (Nano Banana 2 Lite) is the fastest/cheapest for simple tasks (1K only, no search grounding).";
33121
+ var timeoutMsSchema = external_exports.number().int().positive().optional().describe(
33122
+ "Upstream request timeout in ms for this call (default: $GEMINI_TIMEOUT_MS, else 60000 \u2014 or 120000 when image_size is 4K, which routinely runs past 60s)"
33123
+ );
33124
+ var idempotencyKeySchema = external_exports.string().min(1).optional().describe(
33125
+ "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."
33126
+ );
33127
+ var asyncSchema = external_exports.boolean().optional().describe(
33128
+ "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)."
33129
+ );
32977
33130
  var sharedImageSchema = {
32978
33131
  // Bare id only: the model is interpolated into the URL path
32979
33132
  // (`/models/${model}:generateContent`), so slashes/colons/queries must be
@@ -32986,7 +33139,10 @@ var sharedImageSchema = {
32986
33139
  seed: external_exports.number().int().optional().describe("Seed for reproducible generation; random if omitted"),
32987
33140
  thinking_level: external_exports.enum(["minimal", "high"]).optional().describe("Reasoning depth (Gemini 3 models); higher can help complex/structural edits"),
32988
33141
  google_search: external_exports.boolean().optional().describe("Ground the image in live Google Search results (current events, weather, data)"),
32989
- from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)")
33142
+ from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)"),
33143
+ timeout_ms: timeoutMsSchema,
33144
+ idempotency_key: idempotencyKeySchema,
33145
+ async: asyncSchema
32990
33146
  };
32991
33147
  async function resolveVideoInput(args) {
32992
33148
  if (args.video_url && args.video_path) {
@@ -33005,6 +33161,53 @@ var videoPathSchema = external_exports.string().optional().describe(
33005
33161
  function pickSeed(seed) {
33006
33162
  return seed ?? Math.floor(Math.random() * 2147483e3);
33007
33163
  }
33164
+ var HEARTBEAT_DEFAULT_MS = 1e4;
33165
+ async function withProgressHeartbeat(extra, message, fn) {
33166
+ const progressToken = extra?._meta?.progressToken;
33167
+ const send = extra?.sendNotification;
33168
+ const env = Number(readEnvVar("GEMINI_HEARTBEAT_MS"));
33169
+ const intervalMs = Number.isFinite(env) ? env : HEARTBEAT_DEFAULT_MS;
33170
+ const debug = !!readEnvVar("GEMINI_DEBUG");
33171
+ const active = progressToken !== void 0 && !!send && intervalMs > 0;
33172
+ if (debug) {
33173
+ if (active) {
33174
+ console.error(`[gemini-mcp] heartbeat active: progressToken present, interval=${intervalMs}ms \u2014 ${message}`);
33175
+ } else {
33176
+ const reason = progressToken === void 0 ? "no progressToken from host (host cannot extend its tools/call timeout; long calls will hit it)" : !send ? "host provided no sendNotification channel" : "heartbeat disabled (GEMINI_HEARTBEAT_MS=0)";
33177
+ console.error(`[gemini-mcp] heartbeat inactive: ${reason} \u2014 ${message}`);
33178
+ }
33179
+ }
33180
+ if (progressToken === void 0 || !send || intervalMs <= 0) return fn();
33181
+ let ticks = 0;
33182
+ const timer = setInterval(() => {
33183
+ ticks++;
33184
+ const elapsed = Math.round(ticks * intervalMs / 1e3);
33185
+ if (debug) console.error(`[gemini-mcp] heartbeat: ${message} \u2014 still working (${elapsed}s)`);
33186
+ void send({
33187
+ method: "notifications/progress",
33188
+ params: {
33189
+ progressToken,
33190
+ progress: ticks,
33191
+ message: `${message} \u2014 still working (${elapsed}s)`
33192
+ }
33193
+ }).catch(() => {
33194
+ });
33195
+ }, intervalMs);
33196
+ timer.unref?.();
33197
+ try {
33198
+ return await fn();
33199
+ } finally {
33200
+ clearInterval(timer);
33201
+ }
33202
+ }
33203
+ function timeoutRiskHint(opts) {
33204
+ const proModel = /(?:^|[-_./])pro(?:[-_./]|$)/i.test(opts.model);
33205
+ const fourK = opts.imageSize === "4K";
33206
+ const multi = (opts.count ?? 1) > 1;
33207
+ if (!proModel && !fourK && !multi) return void 0;
33208
+ const why = [proModel && "Pro model", fourK && "4K output", multi && `count=${opts.count}`].filter(Boolean).join(" + ");
33209
+ return `Slow config (${why}) can exceed a host's tools/call timeout (e.g. Claude Desktop ~30s, error -32001). If the host gives up mid-generation the image is still written to disk (and gemini_interact leaves an <image>.json sidecar with the interaction id) \u2014 no rerun needed, just look in the output dir. For a faster in-band result use a Flash model, drop image_size to 1K/2K, or \u2014 in Claude Code \u2014 set MCP_TOOL_TIMEOUT.`;
33210
+ }
33008
33211
  function buildMeta(model, seed, opts) {
33009
33212
  const meta3 = { model, seed };
33010
33213
  if (opts.aspect_ratio) meta3.aspect_ratio = opts.aspect_ratio;
@@ -33024,11 +33227,147 @@ async function emit(named, opts, meta3, onWritten) {
33024
33227
  const dir = resolveOutputDir(opts.output_dir);
33025
33228
  const images = [];
33026
33229
  for (const n of named) images.push(await writeImage(dir, n.base, n.image.base64, n.image.mimeType));
33027
- onWritten?.(images);
33230
+ await onWritten?.(images);
33028
33231
  return textResult({ images, ...meta3 });
33029
33232
  }
33030
33233
 
33234
+ // src/jobs.ts
33235
+ init_dist();
33236
+ import { randomUUID, createHash } from "node:crypto";
33237
+ var JOB_TTL_MS = 6e5;
33238
+ var JOB_MAX = 64;
33239
+ var jobs = /* @__PURE__ */ new Map();
33240
+ var byKey = /* @__PURE__ */ new Map();
33241
+ var runningByFingerprint = /* @__PURE__ */ new Map();
33242
+ function isExpired(entry, now) {
33243
+ return entry.settledAt !== void 0 && now - entry.settledAt > JOB_TTL_MS;
33244
+ }
33245
+ function evict(now) {
33246
+ for (const [id, entry] of jobs) {
33247
+ if (isExpired(entry, now)) remove(id, entry);
33248
+ }
33249
+ if (jobs.size < JOB_MAX) return;
33250
+ const settled = [...jobs.values()].filter((e) => e.settledAt !== void 0).sort((a, b) => (a.settledAt ?? 0) - (b.settledAt ?? 0));
33251
+ for (const e of settled) {
33252
+ if (jobs.size < JOB_MAX) break;
33253
+ remove(e.jobId, e);
33254
+ }
33255
+ }
33256
+ function remove(id, entry) {
33257
+ jobs.delete(id);
33258
+ if (entry.idempotencyKey !== void 0 && byKey.get(entry.idempotencyKey) === id) byKey.delete(entry.idempotencyKey);
33259
+ if (runningByFingerprint.get(entry.fingerprint) === id) runningByFingerprint.delete(entry.fingerprint);
33260
+ }
33261
+ function fingerprintRequest(toolName, parts) {
33262
+ return createHash("sha256").update(`${toolName}\0${JSON.stringify(parts)}`).digest("hex");
33263
+ }
33264
+ function annotateReused(result, jobId) {
33265
+ const content = result.content ?? [];
33266
+ const first = content[0];
33267
+ if (first && first.type === "text") {
33268
+ let obj;
33269
+ try {
33270
+ obj = JSON.parse(first.text);
33271
+ } catch {
33272
+ obj = {};
33273
+ }
33274
+ obj.reused = true;
33275
+ obj.reused_job_id = jobId;
33276
+ return { ...result, content: [{ ...first, text: JSON.stringify(obj, null, 2) }, ...content.slice(1)] };
33277
+ }
33278
+ return {
33279
+ ...result,
33280
+ content: [{ type: "text", text: JSON.stringify({ reused: true, reused_job_id: jobId }, null, 2) }, ...content]
33281
+ };
33282
+ }
33283
+ function jobHandle(jobId, status) {
33284
+ return textResult({
33285
+ job_id: jobId,
33286
+ status,
33287
+ hint: `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done" (results are per-process and expire ~10 min after completion).`
33288
+ });
33289
+ }
33290
+ async function dispatch(opts, work) {
33291
+ const { toolName, fingerprint, idempotencyKey, async } = opts;
33292
+ const now = Date.now();
33293
+ let hit;
33294
+ if (idempotencyKey !== void 0) {
33295
+ const id = byKey.get(idempotencyKey);
33296
+ const entry2 = id ? jobs.get(id) : void 0;
33297
+ if (entry2 && entry2.status !== "failed" && !isExpired(entry2, now)) hit = entry2;
33298
+ } else {
33299
+ const id = runningByFingerprint.get(fingerprint);
33300
+ const entry2 = id ? jobs.get(id) : void 0;
33301
+ if (entry2 && entry2.status === "running") hit = entry2;
33302
+ }
33303
+ if (hit) {
33304
+ if (async) return jobHandle(hit.jobId, hit.status);
33305
+ return annotateReused(await hit.promise, hit.jobId);
33306
+ }
33307
+ const jobId = randomUUID();
33308
+ evict(now);
33309
+ const promise2 = work();
33310
+ const entry = { jobId, toolName, fingerprint, idempotencyKey, status: "running", promise: promise2, createdAt: now };
33311
+ jobs.set(jobId, entry);
33312
+ if (idempotencyKey !== void 0) byKey.set(idempotencyKey, jobId);
33313
+ runningByFingerprint.set(fingerprint, jobId);
33314
+ promise2.then(
33315
+ (res) => {
33316
+ entry.status = "done";
33317
+ entry.result = res;
33318
+ entry.settledAt = Date.now();
33319
+ },
33320
+ (err) => {
33321
+ entry.status = "failed";
33322
+ entry.error = { message: err instanceof Error ? err.message : String(err), hint: err?.hint };
33323
+ entry.settledAt = Date.now();
33324
+ }
33325
+ ).finally(() => {
33326
+ if (runningByFingerprint.get(fingerprint) === jobId) runningByFingerprint.delete(fingerprint);
33327
+ });
33328
+ if (async) return jobHandle(jobId, "running");
33329
+ return promise2;
33330
+ }
33331
+ function getJobResult(jobId) {
33332
+ const entry = jobs.get(jobId);
33333
+ if (!entry) {
33334
+ throw new McpToolError(`No job "${jobId}" \u2014 unknown or expired.`, {
33335
+ hint: "Jobs are per-process and evicted ~10 min after completion (or on server restart). If the generation likely finished, check the output dir / <image>.json sidecar instead of polling."
33336
+ });
33337
+ }
33338
+ if (entry.status === "running") return jobHandle(jobId, "running");
33339
+ if (entry.status === "failed") {
33340
+ throw new McpToolError(entry.error?.message ?? "Job failed.", entry.error?.hint ? { hint: entry.error.hint } : void 0);
33341
+ }
33342
+ if (!entry.result) throw new McpToolError(`Job "${jobId}" completed without a recorded result.`);
33343
+ return entry.result;
33344
+ }
33345
+
33346
+ // src/tools/_confirm.ts
33347
+ init_dist();
33348
+ function previewUnlessConfirmed(confirm, action, method, path, body) {
33349
+ if (confirm === true) return null;
33350
+ return textResult({
33351
+ dryRun: true,
33352
+ action,
33353
+ method,
33354
+ path,
33355
+ ...body !== void 0 ? { willSend: body } : {},
33356
+ note: "Re-run with confirm: true to execute."
33357
+ });
33358
+ }
33359
+ async function previewLocalInputsUnlessConfirmed(confirm, action, endpoint, imagePaths, videoPath) {
33360
+ const hasLocal = (imagePaths?.length ?? 0) > 0 || Boolean(videoPath);
33361
+ if (confirm === true || !hasLocal) return null;
33362
+ const inputs = [];
33363
+ for (const p of imagePaths ?? []) inputs.push(await previewImageInput(p));
33364
+ if (videoPath) inputs.push(await previewVideoInput(videoPath));
33365
+ return previewUnlessConfirmed(confirm, action, "POST", endpoint, { inputs });
33366
+ }
33367
+
33031
33368
  // src/tools/generate.ts
33369
+ var GENERATE_ENDPOINT = "/v1beta/models/{model}:generateContent";
33370
+ var SEND_LOCAL_ACTION = "Send local file input(s) to the Gemini API";
33032
33371
  var REFINE_HINT = "To iteratively refine this image, use gemini_interact (pass the output file via `images` on the first call, then chain `previous_interaction_id` on each subsequent call).";
33033
33372
  function registerGenerateTools(server) {
33034
33373
  server.registerTool(
@@ -33044,43 +33383,67 @@ function registerGenerateTools(server) {
33044
33383
  images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs"),
33045
33384
  video_url: external_exports.string().url().optional().describe("Public YouTube URL (or a previously uploaded Files API uri) as a video reference (video\u2192image; use a Flash model e.g. gemini-3.1-flash-image)"),
33046
33385
  video_path: videoPathSchema,
33386
+ confirm: schemaConfirm,
33047
33387
  ...sharedImageSchema
33048
33388
  }
33049
33389
  },
33050
- async (args) => {
33390
+ async (args, extra) => {
33391
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images, args.video_path);
33392
+ if (gate) return gate;
33051
33393
  const count = args.count ?? 1;
33052
- const seed = pickSeed(args.seed);
33053
33394
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33054
- const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
33055
- const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33056
- const video = await resolveVideoInput(args);
33057
- const named = [];
33058
- let capturedText;
33059
- let capturedGrounding;
33060
- for (let i = 0; i < count; i++) {
33061
- const result = await client.generate({
33062
- prompt: args.prompt,
33063
- images: refInputs.length > 0 ? refInputs : void 0,
33064
- model: args.model,
33065
- aspectRatio: args.aspect_ratio,
33066
- imageSize: args.image_size,
33067
- seed: seed + i,
33068
- thinkingLevel: args.thinking_level,
33069
- googleSearch: args.google_search,
33070
- videoUrl: video.videoUrl,
33071
- videoMimeType: video.videoMimeType
33395
+ const fingerprint = fingerprintRequest("gemini_generate_image", {
33396
+ model,
33397
+ prompt: args.prompt,
33398
+ aspect_ratio: args.aspect_ratio,
33399
+ image_size: args.image_size,
33400
+ thinking_level: args.thinking_level,
33401
+ google_search: args.google_search,
33402
+ count,
33403
+ images: args.images,
33404
+ images_base64: args.images_base64,
33405
+ from_clipboard: args.from_clipboard,
33406
+ video_url: args.video_url,
33407
+ video_path: args.video_path
33408
+ });
33409
+ return dispatch({ toolName: "gemini_generate_image", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33410
+ const seed = pickSeed(args.seed);
33411
+ const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
33412
+ const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33413
+ const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args));
33414
+ const named = [];
33415
+ let capturedText;
33416
+ let capturedGrounding;
33417
+ await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : "image"} (${model})`, async () => {
33418
+ for (let i = 0; i < count; i++) {
33419
+ const result = await client.generate({
33420
+ prompt: args.prompt,
33421
+ images: refInputs.length > 0 ? refInputs : void 0,
33422
+ model: args.model,
33423
+ aspectRatio: args.aspect_ratio,
33424
+ imageSize: args.image_size,
33425
+ seed: seed + i,
33426
+ thinkingLevel: args.thinking_level,
33427
+ googleSearch: args.google_search,
33428
+ videoUrl: video.videoUrl,
33429
+ videoMimeType: video.videoMimeType,
33430
+ timeoutMs: args.timeout_ms
33431
+ });
33432
+ const { images: [img], text } = result;
33433
+ if (text && !capturedText) capturedText = text;
33434
+ if (result.grounding && !capturedGrounding) capturedGrounding = result.grounding;
33435
+ named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, "0")}` });
33436
+ }
33072
33437
  });
33073
- const { images: [img], text } = result;
33074
- if (text && !capturedText) capturedText = text;
33075
- if (result.grounding && !capturedGrounding) capturedGrounding = result.grounding;
33076
- named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, "0")}` });
33077
- }
33078
- const meta3 = buildMeta(model, seed, args);
33079
- if (capturedText) meta3.text = capturedText;
33080
- if (capturedGrounding) meta3.grounding = capturedGrounding;
33081
- if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
33082
- meta3.hint = REFINE_HINT;
33083
- return emit(named, args, meta3);
33438
+ const meta3 = buildMeta(model, seed, args);
33439
+ if (capturedText) meta3.text = capturedText;
33440
+ if (capturedGrounding) meta3.grounding = capturedGrounding;
33441
+ if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
33442
+ meta3.hint = REFINE_HINT;
33443
+ const risk = timeoutRiskHint({ model, imageSize: args.image_size, count });
33444
+ if (risk) meta3.timeout_risk = risk;
33445
+ return emit(named, args, meta3);
33446
+ });
33084
33447
  }
33085
33448
  );
33086
33449
  server.registerTool(
@@ -33093,35 +33456,54 @@ function registerGenerateTools(server) {
33093
33456
  images: external_exports.array(external_exports.string().min(1)).optional().describe("Paths to input image file(s) (1 = edit, 2+ = compose)"),
33094
33457
  images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Input images as base64 strings or data URIs"),
33095
33458
  filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified prompt)"),
33459
+ confirm: schemaConfirm,
33096
33460
  ...sharedImageSchema
33097
33461
  }
33098
33462
  },
33099
- async (args) => {
33463
+ async (args, extra) => {
33100
33464
  const hasPaths = (args.images?.length ?? 0) > 0;
33101
33465
  const hasBase64 = (args.images_base64?.length ?? 0) > 0;
33102
33466
  if (!hasPaths && !hasBase64 && !args.from_clipboard) {
33103
33467
  throw new McpToolError("Provide at least one input image via `images`, `images_base64`, or `from_clipboard`.");
33104
33468
  }
33105
- const seed = pickSeed(args.seed);
33469
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
33470
+ if (gate) return gate;
33106
33471
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33107
- const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
33108
- const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33109
- const result = await client.generate({
33472
+ const fingerprint = fingerprintRequest("gemini_edit_image", {
33473
+ model,
33110
33474
  prompt: args.prompt,
33111
- images: inputs,
33112
- model: args.model,
33113
- aspectRatio: args.aspect_ratio,
33114
- imageSize: args.image_size,
33115
- seed,
33116
- thinkingLevel: args.thinking_level,
33117
- googleSearch: args.google_search
33475
+ aspect_ratio: args.aspect_ratio,
33476
+ image_size: args.image_size,
33477
+ thinking_level: args.thinking_level,
33478
+ google_search: args.google_search,
33479
+ images: args.images,
33480
+ images_base64: args.images_base64,
33481
+ from_clipboard: args.from_clipboard
33482
+ });
33483
+ return dispatch({ toolName: "gemini_edit_image", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33484
+ const seed = pickSeed(args.seed);
33485
+ const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
33486
+ const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33487
+ const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client.generate({
33488
+ prompt: args.prompt,
33489
+ images: inputs,
33490
+ model: args.model,
33491
+ aspectRatio: args.aspect_ratio,
33492
+ imageSize: args.image_size,
33493
+ seed,
33494
+ thinkingLevel: args.thinking_level,
33495
+ googleSearch: args.google_search,
33496
+ timeoutMs: args.timeout_ms
33497
+ }));
33498
+ const { images: [img], text } = result;
33499
+ const meta3 = buildMeta(model, seed, args);
33500
+ if (text) meta3.text = text;
33501
+ if (result.grounding) meta3.grounding = result.grounding;
33502
+ meta3.hint = REFINE_HINT;
33503
+ const risk = timeoutRiskHint({ model, imageSize: args.image_size });
33504
+ if (risk) meta3.timeout_risk = risk;
33505
+ return emit([{ image: img, base: slug }], args, meta3);
33118
33506
  });
33119
- const { images: [img], text } = result;
33120
- const meta3 = buildMeta(model, seed, args);
33121
- if (text) meta3.text = text;
33122
- if (result.grounding) meta3.grounding = result.grounding;
33123
- meta3.hint = REFINE_HINT;
33124
- return emit([{ image: img, base: slug }], args, meta3);
33125
33507
  }
33126
33508
  );
33127
33509
  }
@@ -33143,45 +33525,71 @@ function registerSetTools(server) {
33143
33525
  basename: external_exports.string().optional().describe("Base filename prefix for output images (default: slugified master_prompt)"),
33144
33526
  master_images: external_exports.array(external_exports.string().min(1)).optional().describe("Reference image paths passed to the master generation call"),
33145
33527
  master_images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs for master generation"),
33528
+ confirm: schemaConfirm,
33146
33529
  ...sharedImageSchema
33147
33530
  }
33148
33531
  },
33149
- async (args) => {
33532
+ async (args, extra) => {
33150
33533
  if (args.scenes && args.count || !args.scenes && !args.count) {
33151
33534
  throw new McpToolError("Provide exactly one of `scenes` or `count`.");
33152
33535
  }
33153
- const seed = pickSeed(args.seed);
33536
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local master image input(s) to the Gemini API", "/v1beta/models/{model}:generateContent", args.master_images);
33537
+ if (gate) return gate;
33154
33538
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33155
- const cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search };
33156
- const slug = args.basename ? baseName(args.basename) : slugify2(args.master_prompt);
33157
- const masterRefInputs = await gatherImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard });
33158
- const masterResult = await client.generate({
33159
- prompt: args.master_prompt,
33160
- images: masterRefInputs.length > 0 ? masterRefInputs : void 0,
33161
- seed,
33162
- ...cfg
33539
+ const fingerprint = fingerprintRequest("gemini_generate_set", {
33540
+ model,
33541
+ master_prompt: args.master_prompt,
33542
+ scenes: args.scenes,
33543
+ count: args.count,
33544
+ reference_mode: args.reference_mode,
33545
+ aspect_ratio: args.aspect_ratio,
33546
+ image_size: args.image_size,
33547
+ thinking_level: args.thinking_level,
33548
+ google_search: args.google_search,
33549
+ master_images: args.master_images,
33550
+ master_images_base64: args.master_images_base64,
33551
+ from_clipboard: args.from_clipboard
33552
+ });
33553
+ return dispatch({ toolName: "gemini_generate_set", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33554
+ const seed = pickSeed(args.seed);
33555
+ 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 };
33556
+ const slug = args.basename ? baseName(args.basename) : slugify2(args.master_prompt);
33557
+ const masterRefInputs = await gatherImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard });
33558
+ const named = [];
33559
+ const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
33560
+ const result = await client.generate({
33561
+ prompt: args.master_prompt,
33562
+ images: masterRefInputs.length > 0 ? masterRefInputs : void 0,
33563
+ seed,
33564
+ ...cfg
33565
+ });
33566
+ const master = result.images[0];
33567
+ named.push({ image: master, base: `${slug}-master` });
33568
+ const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
33569
+ const mode = args.reference_mode ?? "master";
33570
+ if (mode === "chain") {
33571
+ let ref = master;
33572
+ for (let i = 0; i < scenePrompts.length; i++) {
33573
+ const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref], seed: seed + i + 1, ...cfg });
33574
+ named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` });
33575
+ ref = img;
33576
+ }
33577
+ } else {
33578
+ const scenes = await Promise.all(
33579
+ scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
33580
+ );
33581
+ scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` }));
33582
+ }
33583
+ return result;
33584
+ });
33585
+ const masterText = masterResult.text;
33586
+ const meta3 = buildMeta(model, seed, args);
33587
+ if (masterText) meta3.text = masterText;
33588
+ if (masterResult.grounding) meta3.grounding = masterResult.grounding;
33589
+ const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length });
33590
+ if (risk) meta3.timeout_risk = risk;
33591
+ return emit(named, args, meta3);
33163
33592
  });
33164
- const { images: [master], text: masterText } = masterResult;
33165
- const named = [{ image: master, base: `${slug}-master` }];
33166
- const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
33167
- const mode = args.reference_mode ?? "master";
33168
- if (mode === "chain") {
33169
- let ref = master;
33170
- for (let i = 0; i < scenePrompts.length; i++) {
33171
- const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref], seed: seed + i + 1, ...cfg });
33172
- named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` });
33173
- ref = img;
33174
- }
33175
- } else {
33176
- const scenes = await Promise.all(
33177
- scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
33178
- );
33179
- scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` }));
33180
- }
33181
- const meta3 = buildMeta(model, seed, args);
33182
- if (masterText) meta3.text = masterText;
33183
- if (masterResult.grounding) meta3.grounding = masterResult.grounding;
33184
- return emit(named, args, meta3);
33185
33593
  }
33186
33594
  );
33187
33595
  }
@@ -33209,7 +33617,7 @@ function registerInteractTools(server) {
33209
33617
  server.registerTool(
33210
33618
  "gemini_interact",
33211
33619
  {
33212
- description: "Preferred tool for iterative or multi-step refinement of a single image \u2014 multi-turn generation/editing via Gemini's Interactions API. To refine, capture the returned interaction `id` and pass it as `previous_interaction_id` on the next call \u2014 do NOT start a new interaction or re-upload the image for each tweak. `continue_last: true` chains from this session's most recent interaction without threading the id. Output is JPEG.",
33620
+ description: "Preferred tool for iterative or multi-step refinement of a single image \u2014 multi-turn generation/editing via Gemini's Interactions API. To refine, capture the returned interaction `id` and pass it as `previous_interaction_id` on the next call \u2014 do NOT start a new interaction or re-upload the image for each tweak. `continue_last: true` chains from this session's most recent interaction without threading the id. If a call times out on the client side, the generation usually still completes: the image plus a `<image>.json` sidecar recording its interaction id land in the output dir, and `continue_last: true` still resumes that interaction \u2014 check the output dir before re-issuing (a re-issue is a second billable generation). Output is JPEG.",
33213
33621
  annotations: { readOnlyHint: false, openWorldHint: true },
33214
33622
  inputSchema: {
33215
33623
  input: external_exports.string().min(1).describe("Text prompt or editing instruction"),
@@ -33230,10 +33638,14 @@ function registerInteractTools(server) {
33230
33638
  ),
33231
33639
  video_url: external_exports.string().url().optional().describe("Public YouTube URL (or a previously uploaded Files API uri) as a video reference (video\u2192image; use a Flash model e.g. gemini-3.1-flash-image)"),
33232
33640
  video_path: videoPathSchema,
33233
- from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)")
33641
+ timeout_ms: timeoutMsSchema,
33642
+ idempotency_key: idempotencyKeySchema,
33643
+ async: asyncSchema,
33644
+ from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)"),
33645
+ confirm: schemaConfirm
33234
33646
  }
33235
33647
  },
33236
- async (args) => {
33648
+ async (args, extra) => {
33237
33649
  let previousInteractionId = args.previous_interaction_id;
33238
33650
  if (!previousInteractionId && args.continue_last) {
33239
33651
  if (!lastInteractionId) {
@@ -33248,46 +33660,89 @@ function registerInteractTools(server) {
33248
33660
  if (previousInteractionId && images?.length) {
33249
33661
  ({ kept: images, dropped } = splitReattachedOutputs(images));
33250
33662
  }
33251
- const inputs = await gatherImageInputs({ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33252
- const video = await resolveVideoInput(args);
33253
- const r = await client.interact({
33663
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local file input(s) to the Gemini Interactions API", "/v1beta/interactions", images, args.video_path);
33664
+ if (gate) return gate;
33665
+ const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33666
+ const fingerprint = fingerprintRequest("gemini_interact", {
33667
+ model,
33254
33668
  input: args.input,
33255
- images: inputs.length ? inputs : void 0,
33256
- model: args.model,
33257
- aspectRatio: args.aspect_ratio,
33258
- imageSize: args.image_size,
33259
- thinkingLevel: args.thinking_level,
33260
- previousInteractionId,
33261
- googleSearch: args.google_search,
33262
- searchTypes: args.search_types,
33263
- videoUrl: video.videoUrl,
33264
- videoMimeType: video.videoMimeType
33669
+ aspect_ratio: args.aspect_ratio,
33670
+ image_size: args.image_size,
33671
+ thinking_level: args.thinking_level,
33672
+ previous_interaction_id: previousInteractionId,
33673
+ google_search: args.google_search,
33674
+ search_types: args.search_types,
33675
+ images,
33676
+ images_base64: args.images_base64,
33677
+ from_clipboard: args.from_clipboard,
33678
+ video_url: args.video_url,
33679
+ video_path: args.video_path
33265
33680
  });
33266
- lastInteractionId = r.id;
33267
- const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33268
- const meta3 = { model, interaction_id: r.id };
33269
- if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
33270
- if (dropped.length) meta3.dropped_previous_output = dropped;
33271
- meta3.hint = `To refine this image, call gemini_interact again with previous_interaction_id: "${r.id}" (or continue_last: true) \u2014 do NOT re-attach the returned image as an input; the interaction already contains it.`;
33272
- if (r.text) meta3.text = r.text;
33273
- if (r.grounding) meta3.grounding = r.grounding;
33274
- if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
33275
- const slug = args.filename ? baseName(args.filename) : slugify2(args.input);
33276
- const named = r.images.map((image, i) => ({
33277
- image,
33278
- base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, "0")}` : slug
33279
- }));
33280
- return emit(named, args, meta3, (paths) => {
33281
- for (const p of paths) writtenOutputs.add(p);
33681
+ return dispatch({ toolName: "gemini_interact", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
33682
+ const inputs = await gatherImageInputs({ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33683
+ const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args));
33684
+ const r = await withProgressHeartbeat(extra, "Generating image (Gemini Interactions API)", () => client.interact({
33685
+ input: args.input,
33686
+ images: inputs.length ? inputs : void 0,
33687
+ model: args.model,
33688
+ aspectRatio: args.aspect_ratio,
33689
+ imageSize: args.image_size,
33690
+ thinkingLevel: args.thinking_level,
33691
+ previousInteractionId,
33692
+ googleSearch: args.google_search,
33693
+ searchTypes: args.search_types,
33694
+ videoUrl: video.videoUrl,
33695
+ videoMimeType: video.videoMimeType,
33696
+ timeoutMs: args.timeout_ms
33697
+ }));
33698
+ lastInteractionId = r.id;
33699
+ const meta3 = { model, interaction_id: r.id };
33700
+ if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
33701
+ if (dropped.length) meta3.dropped_previous_output = dropped;
33702
+ meta3.hint = `To refine this image, call gemini_interact again with previous_interaction_id: "${r.id}" (or continue_last: true) \u2014 do NOT re-attach the returned image as an input; the interaction already contains it.`;
33703
+ if (r.text) meta3.text = r.text;
33704
+ if (r.grounding) meta3.grounding = r.grounding;
33705
+ if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
33706
+ const risk = timeoutRiskHint({ model, imageSize: args.image_size });
33707
+ if (risk) meta3.timeout_risk = risk;
33708
+ const slug = args.filename ? baseName(args.filename) : slugify2(args.input);
33709
+ const named = r.images.map((image, i) => ({
33710
+ image,
33711
+ base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, "0")}` : slug
33712
+ }));
33713
+ return emit(named, args, meta3, async (paths) => {
33714
+ for (const p of paths) writtenOutputs.add(p);
33715
+ try {
33716
+ for (const p of paths) await writeSidecar(p, { ...meta3, images: paths });
33717
+ } catch (err) {
33718
+ meta3.sidecar_error = err instanceof Error ? err.message : String(err);
33719
+ }
33720
+ });
33282
33721
  });
33283
33722
  }
33284
33723
  );
33285
33724
  }
33286
33725
 
33726
+ // src/tools/jobs.ts
33727
+ init_zod();
33728
+ function registerJobTools(server) {
33729
+ server.registerTool(
33730
+ "gemini_get_result",
33731
+ {
33732
+ 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 \u2014 if a job id is unknown, check the output dir / <image>.json sidecar.',
33733
+ annotations: { readOnlyHint: true, openWorldHint: false },
33734
+ inputSchema: {
33735
+ job_id: external_exports.string().min(1).describe("The job_id returned by a generation tool called with async: true")
33736
+ }
33737
+ },
33738
+ async (args) => getJobResult(args.job_id)
33739
+ );
33740
+ }
33741
+
33287
33742
  // src/index.ts
33288
33743
  await runMcp({
33289
33744
  name: "gemini-mcp",
33290
33745
  version: VERSION,
33291
33746
  banner: "[gemini-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.",
33292
- tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools]
33747
+ tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerJobTools]
33293
33748
  });