@chrischall/gemini-mcp 0.7.1 → 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/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.1";
32609
+ var VERSION = "0.8.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,12 +33011,15 @@ 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");
32893
33020
  if (inputDir) {
32894
33021
  const candidate = join3(inputDir, p);
32895
- if (existsSync(candidate)) return candidate;
33022
+ if (existsSync(candidate)) return resolve(candidate);
32896
33023
  }
32897
33024
  const cwd = resolve(p);
32898
33025
  if (existsSync(cwd)) return cwd;
@@ -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,9 @@ 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
+ );
32977
33124
  var sharedImageSchema = {
32978
33125
  // Bare id only: the model is interpolated into the URL path
32979
33126
  // (`/models/${model}:generateContent`), so slashes/colons/queries must be
@@ -32986,7 +33133,8 @@ var sharedImageSchema = {
32986
33133
  seed: external_exports.number().int().optional().describe("Seed for reproducible generation; random if omitted"),
32987
33134
  thinking_level: external_exports.enum(["minimal", "high"]).optional().describe("Reasoning depth (Gemini 3 models); higher can help complex/structural edits"),
32988
33135
  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)")
33136
+ from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)"),
33137
+ timeout_ms: timeoutMsSchema
32990
33138
  };
32991
33139
  async function resolveVideoInput(args) {
32992
33140
  if (args.video_url && args.video_path) {
@@ -33005,13 +33153,40 @@ var videoPathSchema = external_exports.string().optional().describe(
33005
33153
  function pickSeed(seed) {
33006
33154
  return seed ?? Math.floor(Math.random() * 2147483e3);
33007
33155
  }
33156
+ var HEARTBEAT_DEFAULT_MS = 1e4;
33157
+ async function withProgressHeartbeat(extra, message, fn) {
33158
+ const progressToken = extra?._meta?.progressToken;
33159
+ const send = extra?.sendNotification;
33160
+ const env = Number(readEnvVar("GEMINI_HEARTBEAT_MS"));
33161
+ const intervalMs = Number.isFinite(env) ? env : HEARTBEAT_DEFAULT_MS;
33162
+ if (progressToken === void 0 || !send || intervalMs <= 0) return fn();
33163
+ let ticks = 0;
33164
+ const timer = setInterval(() => {
33165
+ ticks++;
33166
+ void send({
33167
+ method: "notifications/progress",
33168
+ params: {
33169
+ progressToken,
33170
+ progress: ticks,
33171
+ message: `${message} \u2014 still working (${Math.round(ticks * intervalMs / 1e3)}s)`
33172
+ }
33173
+ }).catch(() => {
33174
+ });
33175
+ }, intervalMs);
33176
+ timer.unref?.();
33177
+ try {
33178
+ return await fn();
33179
+ } finally {
33180
+ clearInterval(timer);
33181
+ }
33182
+ }
33008
33183
  function buildMeta(model, seed, opts) {
33009
33184
  const meta3 = { model, seed };
33010
33185
  if (opts.aspect_ratio) meta3.aspect_ratio = opts.aspect_ratio;
33011
33186
  if (opts.image_size) meta3.image_size = opts.image_size;
33012
33187
  return meta3;
33013
33188
  }
33014
- async function emit(named, opts, meta3) {
33189
+ async function emit(named, opts, meta3, onWritten) {
33015
33190
  if (opts.inline) {
33016
33191
  const imageBlocks = named.map((n) => ({ type: "image", data: n.image.base64, mimeType: n.image.mimeType }));
33017
33192
  if (meta3 && Object.keys(meta3).length > 0) {
@@ -33024,10 +33199,35 @@ async function emit(named, opts, meta3) {
33024
33199
  const dir = resolveOutputDir(opts.output_dir);
33025
33200
  const images = [];
33026
33201
  for (const n of named) images.push(await writeImage(dir, n.base, n.image.base64, n.image.mimeType));
33202
+ await onWritten?.(images);
33027
33203
  return textResult({ images, ...meta3 });
33028
33204
  }
33029
33205
 
33206
+ // src/tools/_confirm.ts
33207
+ init_dist();
33208
+ function previewUnlessConfirmed(confirm, action, method, path, body) {
33209
+ if (confirm === true) return null;
33210
+ return textResult({
33211
+ dryRun: true,
33212
+ action,
33213
+ method,
33214
+ path,
33215
+ ...body !== void 0 ? { willSend: body } : {},
33216
+ note: "Re-run with confirm: true to execute."
33217
+ });
33218
+ }
33219
+ async function previewLocalInputsUnlessConfirmed(confirm, action, endpoint, imagePaths, videoPath) {
33220
+ const hasLocal = (imagePaths?.length ?? 0) > 0 || Boolean(videoPath);
33221
+ if (confirm === true || !hasLocal) return null;
33222
+ const inputs = [];
33223
+ for (const p of imagePaths ?? []) inputs.push(await previewImageInput(p));
33224
+ if (videoPath) inputs.push(await previewVideoInput(videoPath));
33225
+ return previewUnlessConfirmed(confirm, action, "POST", endpoint, { inputs });
33226
+ }
33227
+
33030
33228
  // src/tools/generate.ts
33229
+ var GENERATE_ENDPOINT = "/v1beta/models/{model}:generateContent";
33230
+ var SEND_LOCAL_ACTION = "Send local file input(s) to the Gemini API";
33031
33231
  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).";
33032
33232
  function registerGenerateTools(server) {
33033
33233
  server.registerTool(
@@ -33043,37 +33243,43 @@ function registerGenerateTools(server) {
33043
33243
  images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs"),
33044
33244
  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)"),
33045
33245
  video_path: videoPathSchema,
33246
+ confirm: schemaConfirm,
33046
33247
  ...sharedImageSchema
33047
33248
  }
33048
33249
  },
33049
- async (args) => {
33250
+ async (args, extra) => {
33251
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images, args.video_path);
33252
+ if (gate) return gate;
33050
33253
  const count = args.count ?? 1;
33051
33254
  const seed = pickSeed(args.seed);
33052
33255
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33053
33256
  const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
33054
33257
  const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33055
- const video = await resolveVideoInput(args);
33258
+ const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args));
33056
33259
  const named = [];
33057
33260
  let capturedText;
33058
33261
  let capturedGrounding;
33059
- for (let i = 0; i < count; i++) {
33060
- const result = await client.generate({
33061
- prompt: args.prompt,
33062
- images: refInputs.length > 0 ? refInputs : void 0,
33063
- model: args.model,
33064
- aspectRatio: args.aspect_ratio,
33065
- imageSize: args.image_size,
33066
- seed: seed + i,
33067
- thinkingLevel: args.thinking_level,
33068
- googleSearch: args.google_search,
33069
- videoUrl: video.videoUrl,
33070
- videoMimeType: video.videoMimeType
33071
- });
33072
- const { images: [img], text } = result;
33073
- if (text && !capturedText) capturedText = text;
33074
- if (result.grounding && !capturedGrounding) capturedGrounding = result.grounding;
33075
- named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, "0")}` });
33076
- }
33262
+ await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : "image"} (${model})`, async () => {
33263
+ for (let i = 0; i < count; i++) {
33264
+ const result = await client.generate({
33265
+ prompt: args.prompt,
33266
+ images: refInputs.length > 0 ? refInputs : void 0,
33267
+ model: args.model,
33268
+ aspectRatio: args.aspect_ratio,
33269
+ imageSize: args.image_size,
33270
+ seed: seed + i,
33271
+ thinkingLevel: args.thinking_level,
33272
+ googleSearch: args.google_search,
33273
+ videoUrl: video.videoUrl,
33274
+ videoMimeType: video.videoMimeType,
33275
+ timeoutMs: args.timeout_ms
33276
+ });
33277
+ const { images: [img], text } = result;
33278
+ if (text && !capturedText) capturedText = text;
33279
+ if (result.grounding && !capturedGrounding) capturedGrounding = result.grounding;
33280
+ named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, "0")}` });
33281
+ }
33282
+ });
33077
33283
  const meta3 = buildMeta(model, seed, args);
33078
33284
  if (capturedText) meta3.text = capturedText;
33079
33285
  if (capturedGrounding) meta3.grounding = capturedGrounding;
@@ -33092,20 +33298,23 @@ function registerGenerateTools(server) {
33092
33298
  images: external_exports.array(external_exports.string().min(1)).optional().describe("Paths to input image file(s) (1 = edit, 2+ = compose)"),
33093
33299
  images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Input images as base64 strings or data URIs"),
33094
33300
  filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified prompt)"),
33301
+ confirm: schemaConfirm,
33095
33302
  ...sharedImageSchema
33096
33303
  }
33097
33304
  },
33098
- async (args) => {
33305
+ async (args, extra) => {
33099
33306
  const hasPaths = (args.images?.length ?? 0) > 0;
33100
33307
  const hasBase64 = (args.images_base64?.length ?? 0) > 0;
33101
33308
  if (!hasPaths && !hasBase64 && !args.from_clipboard) {
33102
33309
  throw new McpToolError("Provide at least one input image via `images`, `images_base64`, or `from_clipboard`.");
33103
33310
  }
33311
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
33312
+ if (gate) return gate;
33104
33313
  const seed = pickSeed(args.seed);
33105
33314
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33106
33315
  const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
33107
33316
  const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33108
- const result = await client.generate({
33317
+ const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client.generate({
33109
33318
  prompt: args.prompt,
33110
33319
  images: inputs,
33111
33320
  model: args.model,
@@ -33113,8 +33322,9 @@ function registerGenerateTools(server) {
33113
33322
  imageSize: args.image_size,
33114
33323
  seed,
33115
33324
  thinkingLevel: args.thinking_level,
33116
- googleSearch: args.google_search
33117
- });
33325
+ googleSearch: args.google_search,
33326
+ timeoutMs: args.timeout_ms
33327
+ }));
33118
33328
  const { images: [img], text } = result;
33119
33329
  const meta3 = buildMeta(model, seed, args);
33120
33330
  if (text) meta3.text = text;
@@ -33142,41 +33352,49 @@ function registerSetTools(server) {
33142
33352
  basename: external_exports.string().optional().describe("Base filename prefix for output images (default: slugified master_prompt)"),
33143
33353
  master_images: external_exports.array(external_exports.string().min(1)).optional().describe("Reference image paths passed to the master generation call"),
33144
33354
  master_images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs for master generation"),
33355
+ confirm: schemaConfirm,
33145
33356
  ...sharedImageSchema
33146
33357
  }
33147
33358
  },
33148
- async (args) => {
33359
+ async (args, extra) => {
33149
33360
  if (args.scenes && args.count || !args.scenes && !args.count) {
33150
33361
  throw new McpToolError("Provide exactly one of `scenes` or `count`.");
33151
33362
  }
33363
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local master image input(s) to the Gemini API", "/v1beta/models/{model}:generateContent", args.master_images);
33364
+ if (gate) return gate;
33152
33365
  const seed = pickSeed(args.seed);
33153
33366
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33154
- const cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search };
33367
+ 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 };
33155
33368
  const slug = args.basename ? baseName(args.basename) : slugify2(args.master_prompt);
33156
33369
  const masterRefInputs = await gatherImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard });
33157
- const masterResult = await client.generate({
33158
- prompt: args.master_prompt,
33159
- images: masterRefInputs.length > 0 ? masterRefInputs : void 0,
33160
- seed,
33161
- ...cfg
33162
- });
33163
- const { images: [master], text: masterText } = masterResult;
33164
- const named = [{ image: master, base: `${slug}-master` }];
33165
- const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
33166
- const mode = args.reference_mode ?? "master";
33167
- if (mode === "chain") {
33168
- let ref = master;
33169
- for (let i = 0; i < scenePrompts.length; i++) {
33170
- const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref], seed: seed + i + 1, ...cfg });
33171
- named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` });
33172
- ref = img;
33370
+ const named = [];
33371
+ const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
33372
+ const result = await client.generate({
33373
+ prompt: args.master_prompt,
33374
+ images: masterRefInputs.length > 0 ? masterRefInputs : void 0,
33375
+ seed,
33376
+ ...cfg
33377
+ });
33378
+ const master = result.images[0];
33379
+ named.push({ image: master, base: `${slug}-master` });
33380
+ const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
33381
+ const mode = args.reference_mode ?? "master";
33382
+ if (mode === "chain") {
33383
+ let ref = master;
33384
+ for (let i = 0; i < scenePrompts.length; i++) {
33385
+ const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref], seed: seed + i + 1, ...cfg });
33386
+ named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` });
33387
+ ref = img;
33388
+ }
33389
+ } else {
33390
+ const scenes = await Promise.all(
33391
+ scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
33392
+ );
33393
+ scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` }));
33173
33394
  }
33174
- } else {
33175
- const scenes = await Promise.all(
33176
- scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
33177
- );
33178
- scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` }));
33179
- }
33395
+ return result;
33396
+ });
33397
+ const masterText = masterResult.text;
33180
33398
  const meta3 = buildMeta(model, seed, args);
33181
33399
  if (masterText) meta3.text = masterText;
33182
33400
  if (masterResult.grounding) meta3.grounding = masterResult.grounding;
@@ -33189,18 +33407,33 @@ function registerSetTools(server) {
33189
33407
  init_zod();
33190
33408
  init_dist();
33191
33409
  var lastInteractionId;
33410
+ var writtenOutputs = /* @__PURE__ */ new Set();
33411
+ var NEW_REFERENCES_ONLY = "NEW reference images only (e.g. a style or target photo). When chaining with previous_interaction_id, do NOT re-attach the prior turn's output \u2014 the interaction already contains it, and re-sending it anchors the model against your edit.";
33412
+ function splitReattachedOutputs(images) {
33413
+ const kept = [];
33414
+ const dropped = [];
33415
+ for (const p of images) {
33416
+ let resolved;
33417
+ try {
33418
+ resolved = resolveImagePath(p);
33419
+ } catch {
33420
+ }
33421
+ (resolved && writtenOutputs.has(resolved) ? dropped : kept).push(p);
33422
+ }
33423
+ return { kept, dropped };
33424
+ }
33192
33425
  function registerInteractTools(server) {
33193
33426
  server.registerTool(
33194
33427
  "gemini_interact",
33195
33428
  {
33196
- 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.",
33429
+ 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.",
33197
33430
  annotations: { readOnlyHint: false, openWorldHint: true },
33198
33431
  inputSchema: {
33199
33432
  input: external_exports.string().min(1).describe("Text prompt or editing instruction"),
33200
33433
  previous_interaction_id: external_exports.string().optional().describe("ID from a prior gemini_interact call \u2014 continues that multi-turn conversation"),
33201
33434
  continue_last: external_exports.boolean().optional().describe("Continue from the most recent interaction this server created (convenience for previous_interaction_id; an explicit id wins)"),
33202
- images: external_exports.array(external_exports.string().min(1)).optional().describe("Paths to reference input images"),
33203
- images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs"),
33435
+ images: external_exports.array(external_exports.string().min(1)).optional().describe(`Paths to reference input images. ${NEW_REFERENCES_ONLY}`),
33436
+ images_base64: external_exports.array(external_exports.string().min(1)).optional().describe(`Reference images as base64 strings or data URIs. ${NEW_REFERENCES_ONLY}`),
33204
33437
  model: external_exports.string().optional().describe(`Model id override (default: server default; see gemini_list_models). ${MODEL_CHOICE_GUIDE}`),
33205
33438
  aspect_ratio: external_exports.enum(ASPECT_RATIOS).optional().describe("Output aspect ratio"),
33206
33439
  image_size: external_exports.enum(IMAGE_SIZES).optional().describe("Output resolution (512 = 0.5K, Flash-only)"),
@@ -33214,10 +33447,12 @@ function registerInteractTools(server) {
33214
33447
  ),
33215
33448
  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)"),
33216
33449
  video_path: videoPathSchema,
33217
- from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)")
33450
+ timeout_ms: timeoutMsSchema,
33451
+ from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)"),
33452
+ confirm: schemaConfirm
33218
33453
  }
33219
33454
  },
33220
- async (args) => {
33455
+ async (args, extra) => {
33221
33456
  let previousInteractionId = args.previous_interaction_id;
33222
33457
  if (!previousInteractionId && args.continue_last) {
33223
33458
  if (!lastInteractionId) {
@@ -33227,9 +33462,16 @@ function registerInteractTools(server) {
33227
33462
  }
33228
33463
  previousInteractionId = lastInteractionId;
33229
33464
  }
33230
- const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33231
- const video = await resolveVideoInput(args);
33232
- const r = await client.interact({
33465
+ let images = args.images;
33466
+ let dropped = [];
33467
+ if (previousInteractionId && images?.length) {
33468
+ ({ kept: images, dropped } = splitReattachedOutputs(images));
33469
+ }
33470
+ const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local file input(s) to the Gemini Interactions API", "/v1beta/interactions", images, args.video_path);
33471
+ if (gate) return gate;
33472
+ const inputs = await gatherImageInputs({ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
33473
+ const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args));
33474
+ const r = await withProgressHeartbeat(extra, "Generating image (Gemini Interactions API)", () => client.interact({
33233
33475
  input: args.input,
33234
33476
  images: inputs.length ? inputs : void 0,
33235
33477
  model: args.model,
@@ -33240,13 +33482,15 @@ function registerInteractTools(server) {
33240
33482
  googleSearch: args.google_search,
33241
33483
  searchTypes: args.search_types,
33242
33484
  videoUrl: video.videoUrl,
33243
- videoMimeType: video.videoMimeType
33244
- });
33485
+ videoMimeType: video.videoMimeType,
33486
+ timeoutMs: args.timeout_ms
33487
+ }));
33245
33488
  lastInteractionId = r.id;
33246
33489
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
33247
33490
  const meta3 = { model, interaction_id: r.id };
33248
33491
  if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
33249
- meta3.hint = `To refine this image, call gemini_interact again with previous_interaction_id: "${r.id}" (or continue_last: true).`;
33492
+ if (dropped.length) meta3.dropped_previous_output = dropped;
33493
+ 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.`;
33250
33494
  if (r.text) meta3.text = r.text;
33251
33495
  if (r.grounding) meta3.grounding = r.grounding;
33252
33496
  if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
@@ -33255,7 +33499,14 @@ function registerInteractTools(server) {
33255
33499
  image,
33256
33500
  base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, "0")}` : slug
33257
33501
  }));
33258
- return emit(named, args, meta3);
33502
+ return emit(named, args, meta3, async (paths) => {
33503
+ for (const p of paths) writtenOutputs.add(p);
33504
+ try {
33505
+ for (const p of paths) await writeSidecar(p, { ...meta3, images: paths });
33506
+ } catch (err) {
33507
+ meta3.sidecar_error = err instanceof Error ? err.message : String(err);
33508
+ }
33509
+ });
33259
33510
  }
33260
33511
  );
33261
33512
  }