@chrischall/gemini-mcp 0.3.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.
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
@@ -0,0 +1,67 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { readFile, unlink } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { McpToolError } from '@chrischall/mcp-utils';
7
+ const execFileAsync = promisify(execFile);
8
+ const defaultRun = async (cmd, args) => {
9
+ await execFileAsync(cmd, args);
10
+ };
11
+ /**
12
+ * Read the macOS system clipboard image into a base64 JPEG.
13
+ *
14
+ * Steps:
15
+ * 1. osascript: write clipboard PNG to a temp file.
16
+ * 2. sips: downscale + convert to JPEG (≤2048px, avoids 16-bit/large payloads).
17
+ * 3. Read the JPEG → base64.
18
+ *
19
+ * Temp files are cleaned up in a `finally` block.
20
+ */
21
+ export async function readClipboardImage(run = defaultRun) {
22
+ if (process.platform !== 'darwin') {
23
+ throw new McpToolError('from_clipboard is only supported on macOS', {
24
+ hint: 'Save the image to a file and pass it via `images` instead.',
25
+ });
26
+ }
27
+ // Unique temp paths under tmpdir() — OS-controlled (/var/folders/…/T or /tmp on
28
+ // macOS), digits+alnum id, no user input. Commands run via execFile (no shell),
29
+ // so the only string-interpolated value (the path, in the AppleScript literal)
30
+ // can't reach a shell; a quote-bearing TMPDIR would just yield a parse error,
31
+ // caught as "No image found" below.
32
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
33
+ const png = join(tmpdir(), `gemini-clip-${id}.png`);
34
+ const jpg = join(tmpdir(), `gemini-clip-${id}.jpg`);
35
+ try {
36
+ // Step 1: write clipboard PNG to temp file via osascript
37
+ try {
38
+ await run('osascript', [
39
+ '-e', `set f to (open for access POSIX file "${png}" with write permission)`,
40
+ '-e', 'write (the clipboard as «class PNGf») to f',
41
+ '-e', 'close access f',
42
+ ]);
43
+ }
44
+ catch {
45
+ throw new McpToolError('No image found on the clipboard', {
46
+ hint: 'Copy an image (⌘C) first, or pass `images`/`images_base64`.',
47
+ });
48
+ }
49
+ // Step 2: downscale + convert to JPEG via sips
50
+ try {
51
+ await run('sips', ['-Z', '2048', '-s', 'format', 'jpeg', png, '--out', jpg]);
52
+ }
53
+ catch {
54
+ throw new McpToolError('Failed to convert the clipboard image', {
55
+ hint: 'The clipboard may contain a non-standard image format.',
56
+ });
57
+ }
58
+ // Step 3: read JPEG → base64
59
+ const buf = await readFile(jpg);
60
+ return { base64: buf.toString('base64'), mimeType: 'image/jpeg' };
61
+ }
62
+ finally {
63
+ // Clean up both temp files (ignore errors if they don't exist)
64
+ await unlink(png).catch(() => undefined);
65
+ await unlink(jpg).catch(() => undefined);
66
+ }
67
+ }
package/dist/images.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readFile, writeFile, access } from 'node:fs/promises';
2
- import { join, resolve } from 'node:path';
3
- import { readEnvVar } from '@chrischall/mcp-utils';
2
+ import { existsSync } from 'node:fs';
3
+ import { join, resolve, isAbsolute } from 'node:path';
4
+ import { readEnvVar, McpToolError } from '@chrischall/mcp-utils';
4
5
  /** URL/file-safe slug from a prompt; never empty. */
5
6
  export function slugify(text, max = 40) {
6
7
  const s = text
@@ -56,9 +57,34 @@ export async function writeImage(dir, base, base64, mimeType) {
56
57
  await writeFile(path, Buffer.from(base64, 'base64'));
57
58
  return resolve(path);
58
59
  }
60
+ /**
61
+ * Resolve an image path, checking (in order):
62
+ * 1. Absolute path that exists → returned as-is.
63
+ * 2. `GEMINI_INPUT_DIR` env var is set → look for `join(inputDir, p)`.
64
+ * 3. Relative to cwd → `resolve(p)`.
65
+ * Throws a helpful `McpToolError` if none are found.
66
+ */
67
+ export function resolveImagePath(p) {
68
+ if (isAbsolute(p) && existsSync(p))
69
+ return p;
70
+ const inputDir = readEnvVar('GEMINI_INPUT_DIR');
71
+ if (inputDir) {
72
+ // NOTE: a `../`-bearing `p` can escape inputDir. Acceptable for a local,
73
+ // single-user MCP — the caller already has the user's own filesystem access.
74
+ const candidate = join(inputDir, p);
75
+ if (existsSync(candidate))
76
+ return candidate;
77
+ }
78
+ const cwd = resolve(p);
79
+ if (existsSync(cwd))
80
+ return cwd;
81
+ const hint = 'Set GEMINI_INPUT_DIR to a directory containing your images, or pass an absolute path.';
82
+ throw new McpToolError(`Image not found: ${p}` + (inputDir ? ` (also searched GEMINI_INPUT_DIR=${inputDir})` : ''), { hint });
83
+ }
59
84
  /** Read an image file into `{ base64, mimeType }` for an inline_data part. */
60
85
  export async function readImageAsInline(path) {
61
- const buf = await readFile(path);
86
+ const resolved = resolveImagePath(path);
87
+ const buf = await readFile(resolved);
62
88
  const mimeType = sniffMimeBytes(buf);
63
89
  return { base64: buf.toString('base64'), mimeType };
64
90
  }
@@ -91,6 +117,19 @@ export async function loadImageInputs(paths = [], base64s = []) {
91
117
  const fromBase64s = base64s.map((b) => decodeImageInput(b));
92
118
  return [...fromPaths, ...fromBase64s];
93
119
  }
120
+ /**
121
+ * Aggregate image inputs from all sources: clipboard (macOS only), file paths, and base64 strings.
122
+ * Clipboard image is prepended if `from_clipboard` is true.
123
+ */
124
+ export async function gatherImageInputs(opts) {
125
+ // Only load the clipboard module when actually requested (keeps the child_process
126
+ // path off the default code path).
127
+ const clipboardImages = opts.from_clipboard
128
+ ? [await (await import('./clipboard.js')).readClipboardImage()]
129
+ : [];
130
+ const fileImages = await loadImageInputs(opts.images, opts.images_base64);
131
+ return [...clipboardImages, ...fileImages];
132
+ }
94
133
  /** Caller-supplied filename → safe base name (no extension). */
95
134
  export function baseName(name) {
96
135
  // Strip known image extensions
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveModel } from '../models.js';
4
4
  import { client } from '../client.js';
5
- import { slugify, baseName, loadImageInputs } from '../images.js';
5
+ import { slugify, baseName, gatherImageInputs } from '../images.js';
6
6
  import { emit, sharedImageSchema, pickSeed, buildMeta } from './shared.js';
7
7
  export function registerGenerateTools(server) {
8
8
  server.registerTool('gemini_generate_image', {
@@ -22,7 +22,7 @@ export function registerGenerateTools(server) {
22
22
  const seed = pickSeed(args.seed);
23
23
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
24
24
  const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
25
- const refInputs = await loadImageInputs(args.images, args.images_base64);
25
+ const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
26
26
  const named = [];
27
27
  let capturedText;
28
28
  // For count>1, surface the FIRST call's grounding (each call grounds
@@ -70,13 +70,13 @@ export function registerGenerateTools(server) {
70
70
  }, async (args) => {
71
71
  const hasPaths = (args.images?.length ?? 0) > 0;
72
72
  const hasBase64 = (args.images_base64?.length ?? 0) > 0;
73
- if (!hasPaths && !hasBase64) {
74
- throw new McpToolError('Provide at least one input image via `images` or `images_base64`.');
73
+ if (!hasPaths && !hasBase64 && !args.from_clipboard) {
74
+ throw new McpToolError('Provide at least one input image via `images`, `images_base64`, or `from_clipboard`.');
75
75
  }
76
76
  const seed = pickSeed(args.seed);
77
77
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
78
78
  const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
79
- const inputs = await loadImageInputs(args.images, args.images_base64);
79
+ const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
80
80
  const result = await client.generate({
81
81
  prompt: args.prompt,
82
82
  images: inputs,
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveModel } from '../models.js';
4
4
  import { client } from '../client.js';
5
- import { slugify, baseName, loadImageInputs } from '../images.js';
5
+ import { slugify, baseName, gatherImageInputs } from '../images.js';
6
6
  import { emit, ASPECT_RATIOS, IMAGE_SIZES } from './shared.js';
7
7
  export function registerInteractTools(server) {
8
8
  server.registerTool('gemini_interact', {
@@ -59,9 +59,13 @@ export function registerInteractTools(server) {
59
59
  .url()
60
60
  .optional()
61
61
  .describe('Public YouTube URL as a video reference (video→image; use a Flash model e.g. gemini-3.1-flash-image)'),
62
+ from_clipboard: z
63
+ .boolean()
64
+ .optional()
65
+ .describe('Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)'),
62
66
  },
63
67
  }, async (args) => {
64
- const inputs = await loadImageInputs(args.images, args.images_base64);
68
+ const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
65
69
  const r = await client.interact({
66
70
  input: args.input,
67
71
  images: inputs.length ? inputs : undefined,
package/dist/tools/set.js CHANGED
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveModel } from '../models.js';
4
4
  import { client } from '../client.js';
5
- import { slugify, baseName, loadImageInputs } from '../images.js';
5
+ import { slugify, baseName, gatherImageInputs } from '../images.js';
6
6
  import { emit, sharedImageSchema, pickSeed, buildMeta } from './shared.js';
7
7
  export function registerSetTools(server) {
8
8
  server.registerTool('gemini_generate_set', {
@@ -28,7 +28,7 @@ export function registerSetTools(server) {
28
28
  const cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search };
29
29
  const slug = args.basename ? baseName(args.basename) : slugify(args.master_prompt);
30
30
  // 1. master (optionally seeded from reference images)
31
- const masterRefInputs = await loadImageInputs(args.master_images, args.master_images_base64);
31
+ const masterRefInputs = await gatherImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard });
32
32
  const masterResult = await client.generate({
33
33
  prompt: args.master_prompt,
34
34
  images: masterRefInputs.length > 0 ? masterRefInputs : undefined,
@@ -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)'),
@@ -19,6 +26,7 @@ export const sharedImageSchema = {
19
26
  seed: z.number().int().optional().describe('Seed for reproducible generation; random if omitted'),
20
27
  thinking_level: z.enum(['minimal', 'high']).optional().describe('Reasoning depth (Gemini 3 models); higher can help complex/structural edits'),
21
28
  google_search: z.boolean().optional().describe('Ground the image in live Google Search results (current events, weather, data)'),
29
+ from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)'),
22
30
  };
23
31
  /**
24
32
  * Return the provided seed, or pick a random one. Capped well below INT32_MAX
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.3.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.3.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.3.0",
9
+ "version": "0.5.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "0.3.0",
14
+ "version": "0.5.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -36,6 +36,13 @@
36
36
  "isRequired": false,
37
37
  "format": "string",
38
38
  "isSecret": false
39
+ },
40
+ {
41
+ "name": "GEMINI_INPUT_DIR",
42
+ "description": "Directory to resolve bare input-image filenames against",
43
+ "isRequired": false,
44
+ "format": "string",
45
+ "isSecret": false
39
46
  }
40
47
  ]
41
48
  }