@chrischall/gemini-mcp 0.4.0 → 0.5.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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for Google Gemini image generation and editing — generate and edit images via the Gemini API",
10
- "version": "0.4.0"
10
+ "version": "0.5.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Gemini Images",
16
16
  "source": "./",
17
17
  "description": "Generate and edit images with Google Gemini image models (Nano Banana / Nano Banana Pro) via natural language prompts",
18
- "version": "0.4.0",
18
+ "version": "0.5.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gemini-mcp",
3
3
  "displayName": "Gemini Images",
4
- "version": "0.4.0",
4
+ "version": "0.5.0",
5
5
  "description": "MCP server for Google Gemini image generation and editing — generate and edit images via the Gemini API",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/dist/bundle.js CHANGED
@@ -32095,20 +32095,9 @@ var init_server3 = __esm({
32095
32095
  }
32096
32096
  });
32097
32097
 
32098
- // node_modules/@chrischall/mcp-utils/dist/response/index.js
32099
- function textResult(data) {
32100
- return {
32101
- content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
32102
- };
32103
- }
32104
- var init_response = __esm({
32105
- "node_modules/@chrischall/mcp-utils/dist/response/index.js"() {
32106
- }
32107
- });
32108
-
32109
32098
  // node_modules/@chrischall/mcp-utils/dist/errors/index.js
32110
32099
  function redactSecrets(text) {
32111
- return text.replace(BEARER_RE, "$1[REDACTED]").replace(JWT_RE, "[REDACTED]");
32100
+ 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]");
32112
32101
  }
32113
32102
  function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
32114
32103
  const str = text === null || text === void 0 ? "" : String(text);
@@ -32117,7 +32106,7 @@ function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
32117
32106
  return redacted;
32118
32107
  return `${redacted.slice(0, max)}\u2026 [truncated]`;
32119
32108
  }
32120
- var DEFAULT_ERROR_MESSAGE_MAX, McpToolError, BEARER_RE, JWT_RE;
32109
+ 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;
32121
32110
  var init_errors4 = __esm({
32122
32111
  "node_modules/@chrischall/mcp-utils/dist/errors/index.js"() {
32123
32112
  DEFAULT_ERROR_MESSAGE_MAX = 500;
@@ -32134,6 +32123,36 @@ var init_errors4 = __esm({
32134
32123
  };
32135
32124
  BEARER_RE = /(bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
32136
32125
  JWT_RE = /\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{8,}\b/g;
32126
+ BASIC_AUTH_RE = /(authorization\s*:\s*basic\s+)[A-Za-z0-9+/=_-]{6,}/gi;
32127
+ SET_COOKIE_RE = /(\bset-cookie\s*:\s*)([^=;,\s]+)=[^;,\s]*/gi;
32128
+ COOKIE_HEADER_RE = /((?<!set-)\bcookie\s*:\s*)((?:[^=;,\s]+=[^;,\s]*)(?:;\s*[^=;,\s]+=[^;,\s]*)*)/gi;
32129
+ API_KEY_RE = new RegExp([
32130
+ "sk-[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])",
32131
+ // OpenAI / Anthropic (incl. sk-ant-…)
32132
+ "gh[pousr]_[A-Za-z0-9]{36,}\\b",
32133
+ // GitHub ghp_/gho_/ghu_/ghs_/ghr_
32134
+ "xox[baprs]-[A-Za-z0-9-]{10,}(?![A-Za-z0-9-])",
32135
+ // Slack
32136
+ "AIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])",
32137
+ // Google API key (39 chars total)
32138
+ "AKIA[0-9A-Z]{16}\\b",
32139
+ // AWS access key id (20 chars total)
32140
+ "whsec_[A-Za-z0-9]{16,}\\b"
32141
+ // webhook signing secret (Stripe-style)
32142
+ ].map((p) => `\\b${p}`).join("|"), "g");
32143
+ QUERY_SECRET_RE = /([?&](?:access_token|refresh_token|client_secret|api_?key|signature|token|key|sig)=)[^&#\s"'<>`]+/gi;
32144
+ }
32145
+ });
32146
+
32147
+ // node_modules/@chrischall/mcp-utils/dist/response/index.js
32148
+ function textResult(data) {
32149
+ return {
32150
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
32151
+ };
32152
+ }
32153
+ var init_response = __esm({
32154
+ "node_modules/@chrischall/mcp-utils/dist/response/index.js"() {
32155
+ init_errors4();
32137
32156
  }
32138
32157
  });
32139
32158
 
@@ -32178,10 +32197,168 @@ var init_fs = __esm({
32178
32197
  }
32179
32198
  });
32180
32199
 
32200
+ // node_modules/@chrischall/mcp-utils/dist/http/throttle.js
32201
+ var init_throttle = __esm({
32202
+ "node_modules/@chrischall/mcp-utils/dist/http/throttle.js"() {
32203
+ }
32204
+ });
32205
+
32181
32206
  // node_modules/@chrischall/mcp-utils/dist/http/index.js
32207
+ function hostOf(baseUrl) {
32208
+ try {
32209
+ return new URL(baseUrl).host;
32210
+ } catch {
32211
+ return baseUrl;
32212
+ }
32213
+ }
32214
+ function createApiClient(opts) {
32215
+ const base = opts.baseUrl.replace(/\/+$/, "");
32216
+ const retry = opts.retry ?? DEFAULT_RETRY;
32217
+ const service = opts.serviceName ?? hostOf(opts.baseUrl);
32218
+ const doFetch = opts.fetchImpl ?? fetch;
32219
+ const sleep = opts.sleep ?? defaultSleep;
32220
+ const unauthorized = () => opts.onUnauthorized ? opts.onUnauthorized() : new UnauthorizedError(service);
32221
+ const rateLimited = () => opts.onRateLimited ? opts.onRateLimited() : new RateLimitedError(service);
32222
+ const retryStatuses = retry.statuses ?? [429];
32223
+ const timeoutMs = opts.timeout;
32224
+ const authHeader = (token) => !token ? {} : opts.tokenHeader ? { [opts.tokenHeader]: token } : { Authorization: `Bearer ${token}` };
32225
+ function withTimeout(run) {
32226
+ if (timeoutMs == null || timeoutMs <= 0)
32227
+ return run(void 0);
32228
+ const controller = new AbortController();
32229
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
32230
+ return run(controller.signal).catch((err) => {
32231
+ if (err instanceof Error && err.name === "AbortError")
32232
+ throw new RequestTimeoutError(service, timeoutMs);
32233
+ throw err;
32234
+ }).finally(() => clearTimeout(timer));
32235
+ }
32236
+ async function send(method, path, opt) {
32237
+ const isMultipart = opt.formData !== void 0;
32238
+ const hasJsonBody = !isMultipart && opt.body !== void 0;
32239
+ const reqBody = isMultipart ? opt.formData : hasJsonBody ? JSON.stringify(opt.body) : void 0;
32240
+ const query = opt.query ? buildQueryString(opt.query) : "";
32241
+ const url2 = `${base}${path}${query}`;
32242
+ const bodyInit = reqBody !== void 0 ? { body: reqBody } : {};
32243
+ const fetchWith = (token, signal) => doFetch(url2, {
32244
+ method,
32245
+ headers: {
32246
+ Accept: "application/json",
32247
+ ...hasJsonBody ? { "Content-Type": "application/json" } : {},
32248
+ ...opts.baseHeaders,
32249
+ ...authHeader(token || void 0),
32250
+ ...opt.headers
32251
+ },
32252
+ ...signal ? { signal } : {},
32253
+ ...bodyInit
32254
+ });
32255
+ const once = () => withTimeout((signal) => opts.tokenManager ? opts.tokenManager.withAuth((token) => fetchWith(token, signal)) : (async () => fetchWith(await opts.getToken?.() || void 0, signal))());
32256
+ let attempt = 0;
32257
+ for (; ; ) {
32258
+ const res = await once();
32259
+ if (retryStatuses.includes(res.status) && attempt < retry.count) {
32260
+ attempt += 1;
32261
+ await sleep(retry.delayMs);
32262
+ continue;
32263
+ }
32264
+ return res;
32265
+ }
32266
+ }
32267
+ async function fetchJson(method, path, opt = {}) {
32268
+ const res = await send(method, path, opt);
32269
+ if (res.status === 401)
32270
+ throw unauthorized();
32271
+ if (res.status === 429)
32272
+ throw rateLimited();
32273
+ if (res.status === 204)
32274
+ return void 0;
32275
+ const text = await res.text();
32276
+ if (!res.ok) {
32277
+ throw new ApiError(res.status, formatApiError(res.status, method, path, text, { service }));
32278
+ }
32279
+ if (text.length === 0)
32280
+ return void 0;
32281
+ return JSON.parse(text);
32282
+ }
32283
+ async function fetchHtml(method, path, opt = {}) {
32284
+ const headers = { Accept: "text/html,*/*", ...opt.headers };
32285
+ const res = await send(method, path, { ...opt, headers });
32286
+ if (res.status === 401)
32287
+ throw unauthorized();
32288
+ if (res.status === 429)
32289
+ throw rateLimited();
32290
+ const text = await res.text();
32291
+ if (!res.ok) {
32292
+ throw new ApiError(res.status, formatApiError(res.status, method, path, text, { service }));
32293
+ }
32294
+ return text;
32295
+ }
32296
+ return { fetchJson, fetchHtml };
32297
+ }
32298
+ function buildQueryString(params) {
32299
+ const parts = [];
32300
+ for (const [key, value] of Object.entries(params)) {
32301
+ if (value === void 0 || value === null || value === "")
32302
+ continue;
32303
+ if (Array.isArray(value)) {
32304
+ for (const item of value) {
32305
+ if (item === void 0 || item === null || item === "")
32306
+ continue;
32307
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(item))}`);
32308
+ }
32309
+ } else {
32310
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
32311
+ }
32312
+ }
32313
+ return parts.length > 0 ? `?${parts.join("&")}` : "";
32314
+ }
32315
+ function formatApiError(status, method, path, errorText, opts = {}) {
32316
+ const service = opts.service ?? "API";
32317
+ const head = `${service} error ${status} for ${method.toUpperCase()} ${path}`;
32318
+ const safe = truncateErrorMessage(errorText ?? "", opts.max).trim();
32319
+ return safe.length > 0 ? `${head}: ${safe}` : head;
32320
+ }
32321
+ var DEFAULT_RETRY, defaultSleep, UnauthorizedError, RateLimitedError, RequestTimeoutError, ApiError;
32182
32322
  var init_http = __esm({
32183
32323
  "node_modules/@chrischall/mcp-utils/dist/http/index.js"() {
32184
32324
  init_errors4();
32325
+ init_throttle();
32326
+ DEFAULT_RETRY = { count: 1, delayMs: 2e3 };
32327
+ defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
32328
+ UnauthorizedError = class extends Error {
32329
+ status = 401;
32330
+ constructor(service) {
32331
+ super(`Unauthorized (401) from ${service} \u2014 the token is missing, invalid, or expired.`);
32332
+ this.name = "UnauthorizedError";
32333
+ Object.setPrototypeOf(this, new.target.prototype);
32334
+ }
32335
+ };
32336
+ RateLimitedError = class extends Error {
32337
+ status = 429;
32338
+ constructor(service) {
32339
+ super(`Rate limited (429) by ${service} after retries.`);
32340
+ this.name = "RateLimitedError";
32341
+ Object.setPrototypeOf(this, new.target.prototype);
32342
+ }
32343
+ };
32344
+ RequestTimeoutError = class extends Error {
32345
+ timeoutMs;
32346
+ constructor(service, timeoutMs) {
32347
+ super(`Request to ${service} timed out after ${timeoutMs}ms.`);
32348
+ this.name = "RequestTimeoutError";
32349
+ this.timeoutMs = timeoutMs;
32350
+ Object.setPrototypeOf(this, new.target.prototype);
32351
+ }
32352
+ };
32353
+ ApiError = class extends Error {
32354
+ status;
32355
+ constructor(status, message) {
32356
+ super(message);
32357
+ this.name = "ApiError";
32358
+ this.status = status;
32359
+ Object.setPrototypeOf(this, new.target.prototype);
32360
+ }
32361
+ };
32185
32362
  }
32186
32363
  });
32187
32364
 
@@ -32302,7 +32479,7 @@ var init_clipboard = __esm({
32302
32479
  init_dist();
32303
32480
 
32304
32481
  // src/version.ts
32305
- var VERSION = "0.4.0";
32482
+ var VERSION = "0.5.0";
32306
32483
 
32307
32484
  // src/tools/models.ts
32308
32485
  init_dist();
@@ -32329,12 +32506,15 @@ function filterImageModels(raw) {
32329
32506
  var __dirname = dirname(fileURLToPath(import.meta.url));
32330
32507
  await loadDotenvSafely({ path: join(__dirname, "..", ".env"), override: false });
32331
32508
  var BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
32332
- var INTERACTIONS_URL = "https://generativelanguage.googleapis.com/v1beta/interactions";
32333
32509
  var SERVICE = "Gemini";
32510
+ var INTERACTIONS_SERVICE = "Gemini Interactions";
32511
+ var API_REVISION = "2026-05-20";
32512
+ var REQUEST_TIMEOUT_MS = 6e4;
32334
32513
  var GeminiClient = class {
32335
32514
  apiKey;
32336
32515
  configError;
32337
- fetchImpl;
32516
+ api;
32517
+ interactionsApi;
32338
32518
  constructor(opts = {}) {
32339
32519
  const key = readEnvVar("GEMINI_API_KEY");
32340
32520
  if (!key) {
@@ -32346,7 +32526,19 @@ var GeminiClient = class {
32346
32526
  this.apiKey = key;
32347
32527
  this.configError = null;
32348
32528
  }
32349
- this.fetchImpl = opts.fetchImpl ?? fetch;
32529
+ const shared = {
32530
+ tokenHeader: "x-goog-api-key",
32531
+ getToken: () => this.requireKey(),
32532
+ timeout: REQUEST_TIMEOUT_MS,
32533
+ fetchImpl: opts.fetchImpl ?? fetch
32534
+ };
32535
+ this.api = createApiClient({ baseUrl: BASE_URL, serviceName: SERVICE, ...shared });
32536
+ this.interactionsApi = createApiClient({
32537
+ baseUrl: BASE_URL,
32538
+ serviceName: INTERACTIONS_SERVICE,
32539
+ baseHeaders: { "Api-Revision": API_REVISION },
32540
+ ...shared
32541
+ });
32350
32542
  }
32351
32543
  requireKey() {
32352
32544
  if (this.configError) throw this.configError;
@@ -32357,17 +32549,7 @@ var GeminiClient = class {
32357
32549
  return resolveModel(void 0, readEnvVar("GEMINI_IMAGE_MODEL"));
32358
32550
  }
32359
32551
  async call(method, path, body) {
32360
- const key = this.requireKey();
32361
- const res = await this.fetchImpl(`${BASE_URL}${path}`, {
32362
- method,
32363
- headers: { "x-goog-api-key": key, "content-type": "application/json" },
32364
- body: body !== void 0 ? JSON.stringify(body) : void 0
32365
- });
32366
- if (!res.ok) {
32367
- const text = await res.text().catch(() => "");
32368
- throw new McpToolError(`${SERVICE} API ${res.status}: ${truncateErrorMessage(text)}`);
32369
- }
32370
- return await res.json();
32552
+ return this.api.fetchJson(method, path, body !== void 0 ? { body } : {});
32371
32553
  }
32372
32554
  async listModels() {
32373
32555
  const data = await this.call("GET", "/models?pageSize=200");
@@ -32431,7 +32613,6 @@ var GeminiClient = class {
32431
32613
  return { images, text, grounding };
32432
32614
  }
32433
32615
  async interact(opts) {
32434
- const key = this.requireKey();
32435
32616
  const model = resolveModel(opts.model, readEnvVar("GEMINI_IMAGE_MODEL"));
32436
32617
  const inputParts = [{ type: "text", text: opts.input }];
32437
32618
  for (const img of opts.images ?? []) {
@@ -32447,26 +32628,9 @@ var GeminiClient = class {
32447
32628
  if (opts.thinkingLevel !== void 0) body.generation_config = { thinking_level: opts.thinkingLevel };
32448
32629
  if (opts.previousInteractionId !== void 0) body.previous_interaction_id = opts.previousInteractionId;
32449
32630
  if (opts.googleSearch) body.tools = [{ type: "google_search" }];
32450
- const res = await this.fetchImpl(INTERACTIONS_URL, {
32451
- method: "POST",
32452
- headers: {
32453
- "x-goog-api-key": key,
32454
- "content-type": "application/json",
32455
- "Api-Revision": "2026-05-20"
32456
- },
32457
- body: JSON.stringify(body)
32631
+ const data = await this.interactionsApi.fetchJson("POST", "/interactions", {
32632
+ body
32458
32633
  });
32459
- if (!res.ok) {
32460
- const text = await res.text().catch(() => "");
32461
- let message = text;
32462
- try {
32463
- const parsed = JSON.parse(text);
32464
- if (parsed.error?.message) message = parsed.error.message;
32465
- } catch {
32466
- }
32467
- throw new McpToolError(`Gemini Interactions API ${res.status}: ${truncateErrorMessage(message)}`);
32468
- }
32469
- const data = await res.json();
32470
32634
  const images = [];
32471
32635
  const textParts = [];
32472
32636
  const searchQueries = [];
@@ -32621,7 +32785,10 @@ init_dist();
32621
32785
  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"];
32622
32786
  var IMAGE_SIZES = ["512", "1K", "2K", "4K"];
32623
32787
  var sharedImageSchema = {
32624
- model: external_exports.string().optional().describe("Model id override (default: server default; see gemini_list_models)"),
32788
+ // Bare id only: the model is interpolated into the URL path
32789
+ // (`/models/${model}:generateContent`), so slashes/colons/queries must be
32790
+ // rejected to keep a crafted value from escaping the path segment.
32791
+ model: external_exports.string().regex(/^[\w.-]+$/, 'must be a bare model id (letters, digits, ".", "_", "-")').optional().describe("Model id override (default: server default; see gemini_list_models)"),
32625
32792
  aspect_ratio: external_exports.enum(ASPECT_RATIOS).optional().describe("Output aspect ratio"),
32626
32793
  image_size: external_exports.enum(IMAGE_SIZES).optional().describe("Output resolution (512 = 0.5K, Flash-only)"),
32627
32794
  output_dir: external_exports.string().optional().describe("Directory to write images to (default: $GEMINI_OUTPUT_DIR or cwd)"),
package/dist/client.js CHANGED
@@ -1,16 +1,23 @@
1
1
  import { dirname, join } from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
- import { loadDotenvSafely, readEnvVar, McpToolError, truncateErrorMessage } from '@chrischall/mcp-utils';
3
+ import { loadDotenvSafely, readEnvVar, McpToolError, createApiClient } from '@chrischall/mcp-utils';
4
4
  import { resolveModel, filterImageModels } from './models.js';
5
5
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
6
  await loadDotenvSafely({ path: join(__dirname, '..', '.env'), override: false });
7
7
  const BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'; // v1 lacks gemini-3-pro-image; confirmed via Task 5
8
- const INTERACTIONS_URL = 'https://generativelanguage.googleapis.com/v1beta/interactions';
9
8
  const SERVICE = 'Gemini';
9
+ const INTERACTIONS_SERVICE = 'Gemini Interactions';
10
+ // Required revision header for the beta Interactions API (verified 2026-06-08;
11
+ // see docs/GEMINI-API.md) — load-bearing, requests 400 without it.
12
+ const API_REVISION = '2026-05-20';
13
+ // Per-request abort budget. Image generation routinely runs 30s+ on the Pro
14
+ // model, so this is deliberately 60s (NOT the fleet's usual 15–30s).
15
+ const REQUEST_TIMEOUT_MS = 60_000;
10
16
  export class GeminiClient {
11
17
  apiKey;
12
18
  configError;
13
- fetchImpl;
19
+ api;
20
+ interactionsApi;
14
21
  constructor(opts = {}) {
15
22
  const key = readEnvVar('GEMINI_API_KEY');
16
23
  if (!key) {
@@ -23,7 +30,27 @@ export class GeminiClient {
23
30
  this.apiKey = key;
24
31
  this.configError = null;
25
32
  }
26
- this.fetchImpl = opts.fetchImpl ?? fetch;
33
+ // Both paths share: the key in the `x-goog-api-key` header (Gemini does not
34
+ // use `Authorization: Bearer`), a 60s abort budget, and the default 429
35
+ // retry (once after 2s — the free tier rate-limits aggressively).
36
+ // `getToken` calls requireKey() so key resolution stays at REQUEST time:
37
+ // the server boots without a key and the actionable config error (with its
38
+ // aistudio hint) surfaces on the first tool call, not at startup.
39
+ const shared = {
40
+ tokenHeader: 'x-goog-api-key',
41
+ getToken: () => this.requireKey(),
42
+ timeout: REQUEST_TIMEOUT_MS,
43
+ fetchImpl: opts.fetchImpl ?? fetch,
44
+ };
45
+ this.api = createApiClient({ baseUrl: BASE_URL, serviceName: SERVICE, ...shared });
46
+ // Separate client for the beta Interactions API: same base URL, but every
47
+ // request must carry the Api-Revision header (and errors name the API).
48
+ this.interactionsApi = createApiClient({
49
+ baseUrl: BASE_URL,
50
+ serviceName: INTERACTIONS_SERVICE,
51
+ baseHeaders: { 'Api-Revision': API_REVISION },
52
+ ...shared,
53
+ });
27
54
  }
28
55
  requireKey() {
29
56
  if (this.configError)
@@ -35,17 +62,7 @@ export class GeminiClient {
35
62
  return resolveModel(undefined, readEnvVar('GEMINI_IMAGE_MODEL'));
36
63
  }
37
64
  async call(method, path, body) {
38
- const key = this.requireKey();
39
- const res = await this.fetchImpl(`${BASE_URL}${path}`, {
40
- method,
41
- headers: { 'x-goog-api-key': key, 'content-type': 'application/json' },
42
- body: body !== undefined ? JSON.stringify(body) : undefined,
43
- });
44
- if (!res.ok) {
45
- const text = await res.text().catch(() => '');
46
- throw new McpToolError(`${SERVICE} API ${res.status}: ${truncateErrorMessage(text)}`);
47
- }
48
- return (await res.json());
65
+ return this.api.fetchJson(method, path, body !== undefined ? { body } : {});
49
66
  }
50
67
  async listModels() {
51
68
  const data = await this.call('GET', '/models?pageSize=200');
@@ -115,7 +132,6 @@ export class GeminiClient {
115
132
  return { images, text, grounding };
116
133
  }
117
134
  async interact(opts) {
118
- const key = this.requireKey();
119
135
  const model = resolveModel(opts.model, readEnvVar('GEMINI_IMAGE_MODEL'));
120
136
  const inputParts = [{ type: 'text', text: opts.input }];
121
137
  for (const img of opts.images ?? []) {
@@ -136,27 +152,9 @@ export class GeminiClient {
136
152
  body.previous_interaction_id = opts.previousInteractionId;
137
153
  if (opts.googleSearch)
138
154
  body.tools = [{ type: 'google_search' }];
139
- const res = await this.fetchImpl(INTERACTIONS_URL, {
140
- method: 'POST',
141
- headers: {
142
- 'x-goog-api-key': key,
143
- 'content-type': 'application/json',
144
- 'Api-Revision': '2026-05-20',
145
- },
146
- body: JSON.stringify(body),
155
+ const data = await this.interactionsApi.fetchJson('POST', '/interactions', {
156
+ body,
147
157
  });
148
- if (!res.ok) {
149
- const text = await res.text().catch(() => '');
150
- let message = text;
151
- try {
152
- const parsed = JSON.parse(text);
153
- if (parsed.error?.message)
154
- message = parsed.error.message;
155
- }
156
- catch { /* use raw text */ }
157
- throw new McpToolError(`Gemini Interactions API ${res.status}: ${truncateErrorMessage(message)}`);
158
- }
159
- const data = await res.json();
160
158
  // Only surface the `model_output` step — that's the caller-facing result.
161
159
  // `thought` steps hold internal reasoning (and, with includeThoughts, draft
162
160
  // "thinking" images); collecting those would leak reasoning into `text` and
@@ -11,7 +11,14 @@ export const IMAGE_SIZES = ['512', '1K', '2K', '4K'];
11
11
  * into each tool's `inputSchema`.
12
12
  */
13
13
  export const sharedImageSchema = {
14
- model: z.string().optional().describe('Model id override (default: server default; see gemini_list_models)'),
14
+ // Bare id only: the model is interpolated into the URL path
15
+ // (`/models/${model}:generateContent`), so slashes/colons/queries must be
16
+ // rejected to keep a crafted value from escaping the path segment.
17
+ model: z
18
+ .string()
19
+ .regex(/^[\w.-]+$/, 'must be a bare model id (letters, digits, ".", "_", "-")')
20
+ .optional()
21
+ .describe('Model id override (default: server default; see gemini_list_models)'),
15
22
  aspect_ratio: z.enum(ASPECT_RATIOS).optional().describe('Output aspect ratio'),
16
23
  image_size: z.enum(IMAGE_SIZES).optional().describe('Output resolution (512 = 0.5K, Flash-only)'),
17
24
  output_dir: z.string().optional().describe('Directory to write images to (default: $GEMINI_OUTPUT_DIR or cwd)'),
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Single source of the server version. release-please bumps the literal below. */
2
- export const VERSION = '0.4.0'; // x-release-please-version
2
+ export const VERSION = '0.5.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/gemini-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "mcpName": "io.github.chrischall/gemini-mcp",
5
5
  "description": "Gemini image-generation MCP server for Claude — developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -44,7 +44,7 @@
44
44
  "test:coverage": "vitest run --coverage"
45
45
  },
46
46
  "dependencies": {
47
- "@chrischall/mcp-utils": "^0.6.0",
47
+ "@chrischall/mcp-utils": "^0.7.0",
48
48
  "@modelcontextprotocol/sdk": "^1.29.0",
49
49
  "dotenv": "^17.4.0",
50
50
  "zod": "^4.4.2"
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/gemini-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.4.0",
9
+ "version": "0.5.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "0.4.0",
14
+ "version": "0.5.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },