@chrischall/gemini-mcp 0.2.0 → 0.4.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
@@ -5,6 +5,7 @@ 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';
8
9
  const SERVICE = 'Gemini';
9
10
  export class GeminiClient {
10
11
  apiKey;
@@ -56,7 +57,10 @@ export class GeminiClient {
56
57
  for (const img of opts.images ?? []) {
57
58
  parts.push({ inline_data: { mime_type: img.mimeType, data: img.base64 } });
58
59
  }
59
- const generationConfig = { responseModalities: ['IMAGE'] };
60
+ if (opts.videoUrl) {
61
+ parts.push({ file_data: { file_uri: opts.videoUrl, mime_type: 'video/mp4' } });
62
+ }
63
+ const generationConfig = { responseModalities: ['TEXT', 'IMAGE'] };
60
64
  if (opts.aspectRatio || opts.imageSize) {
61
65
  const imageConfig = {};
62
66
  if (opts.aspectRatio)
@@ -65,21 +69,134 @@ export class GeminiClient {
65
69
  imageConfig.imageSize = opts.imageSize;
66
70
  generationConfig.imageConfig = imageConfig;
67
71
  }
68
- const data = await this.call('POST', `/models/${model}:generateContent`, { contents: [{ parts }], generationConfig });
69
- const out = [];
72
+ if (opts.seed !== undefined)
73
+ generationConfig.seed = opts.seed;
74
+ if (opts.thinkingLevel !== undefined)
75
+ generationConfig.thinkingConfig = { thinkingLevel: opts.thinkingLevel };
76
+ const requestBody = { contents: [{ parts }], generationConfig };
77
+ if (opts.googleSearch)
78
+ requestBody.tools = [{ google_search: {} }];
79
+ const data = await this.call('POST', `/models/${model}:generateContent`, requestBody);
80
+ const images = [];
81
+ const textParts = [];
82
+ let groundingMeta;
70
83
  for (const cand of data.candidates ?? []) {
71
84
  for (const part of cand.content?.parts ?? []) {
72
85
  const inline = (part.inline_data ?? part.inlineData);
73
- if (inline?.data)
74
- out.push({ base64: inline.data, mimeType: inline.mime_type ?? inline.mimeType ?? 'image/jpeg' });
86
+ if (inline?.data) {
87
+ images.push({ base64: inline.data, mimeType: inline.mime_type ?? inline.mimeType ?? 'image/jpeg' });
88
+ }
89
+ else if (typeof part.text === 'string' && part.text.trim()) {
90
+ textParts.push(part.text);
91
+ }
75
92
  }
93
+ if (cand.groundingMetadata)
94
+ groundingMeta = cand.groundingMetadata;
76
95
  }
77
- if (out.length === 0) {
96
+ if (images.length === 0) {
78
97
  throw new McpToolError(`${SERVICE} returned no image`, {
79
98
  hint: 'The request may have been blocked by safety filters — try rephrasing the prompt.',
80
99
  });
81
100
  }
82
- return out;
101
+ const text = textParts.join('\n') || undefined;
102
+ // Parse grounding metadata if present
103
+ let grounding;
104
+ if (groundingMeta) {
105
+ const queries = (groundingMeta.webSearchQueries ?? []).filter(Boolean);
106
+ const sources = (groundingMeta.groundingChunks ?? []).map((c) => c.web).filter((w) => !!w);
107
+ if (queries.length > 0 || sources.length > 0) {
108
+ grounding = {};
109
+ if (queries.length > 0)
110
+ grounding.queries = queries;
111
+ if (sources.length > 0)
112
+ grounding.sources = sources;
113
+ }
114
+ }
115
+ return { images, text, grounding };
116
+ }
117
+ async interact(opts) {
118
+ const key = this.requireKey();
119
+ const model = resolveModel(opts.model, readEnvVar('GEMINI_IMAGE_MODEL'));
120
+ const inputParts = [{ type: 'text', text: opts.input }];
121
+ for (const img of opts.images ?? []) {
122
+ inputParts.push({ type: 'image', mime_type: img.mimeType, data: img.base64 });
123
+ }
124
+ if (opts.videoUrl) {
125
+ inputParts.push({ type: 'video', uri: opts.videoUrl, mime_type: 'video/mp4' });
126
+ }
127
+ const responseFormat = { type: 'image', mime_type: 'image/jpeg' };
128
+ if (opts.aspectRatio)
129
+ responseFormat.aspect_ratio = opts.aspectRatio;
130
+ if (opts.imageSize)
131
+ responseFormat.image_size = opts.imageSize;
132
+ const body = { model, input: inputParts, response_format: responseFormat };
133
+ if (opts.thinkingLevel !== undefined)
134
+ body.generation_config = { thinking_level: opts.thinkingLevel };
135
+ if (opts.previousInteractionId !== undefined)
136
+ body.previous_interaction_id = opts.previousInteractionId;
137
+ if (opts.googleSearch)
138
+ 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),
147
+ });
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
+ // Only surface the `model_output` step — that's the caller-facing result.
161
+ // `thought` steps hold internal reasoning (and, with includeThoughts, draft
162
+ // "thinking" images); collecting those would leak reasoning into `text` and
163
+ // pollute the returned images. The verified contract puts the output image
164
+ // in `model_output`.
165
+ const images = [];
166
+ const textParts = [];
167
+ const searchQueries = [];
168
+ for (const step of data.steps ?? []) {
169
+ // Grounding queries live on the `google_search_call` step's arguments.
170
+ // (The `google_search_result` step only carries HTML `search_suggestions`
171
+ // chips — no clean source uri/title list like generateContent's
172
+ // groundingChunks — so interact surfaces queries only, not sources.)
173
+ if (step.type === 'google_search_call') {
174
+ for (const q of step.arguments?.queries ?? [])
175
+ if (q?.trim())
176
+ searchQueries.push(q);
177
+ continue;
178
+ }
179
+ if (step.type !== 'model_output')
180
+ continue;
181
+ for (const parts of [step.content ?? [], step.summary ?? []]) {
182
+ for (const part of parts) {
183
+ if (part.type === 'image' && part.data) {
184
+ images.push({ base64: part.data, mimeType: part.mime_type ?? 'image/jpeg' });
185
+ }
186
+ else if (part.type === 'text' && part.text?.trim()) {
187
+ textParts.push(part.text);
188
+ }
189
+ }
190
+ }
191
+ }
192
+ if (images.length === 0) {
193
+ throw new McpToolError('Gemini returned no image', {
194
+ hint: 'The request may have been blocked by safety filters — try rephrasing the prompt.',
195
+ });
196
+ }
197
+ const resultText = textParts.join('\n') || undefined;
198
+ const grounding = searchQueries.length > 0 ? { queries: searchQueries } : undefined;
199
+ return { id: data.id, images, text: resultText, grounding };
83
200
  }
84
201
  }
85
202
  /** Module-level singleton shared by every tool module (deferred-config-error). */
@@ -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 } 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
@@ -30,21 +31,116 @@ export async function uniquePath(dir, base, ext) {
30
31
  }
31
32
  return candidate;
32
33
  }
33
- /** Decode base64 image bytes and write to disk (creating dir). Returns the path. */
34
+ /** Sniff MIME type from the first bytes of an image buffer. */
35
+ function sniffMimeBytes(buf) {
36
+ // PNG: 89 50 4E 47
37
+ if (buf.length >= 4 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
38
+ return 'image/png';
39
+ }
40
+ // JPEG: FF D8 FF
41
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
42
+ return 'image/jpeg';
43
+ }
44
+ // WebP: RIFF....WEBP (bytes 0-3 = RIFF, bytes 8-11 = WEBP)
45
+ if (buf.length >= 12 &&
46
+ buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 &&
47
+ buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) {
48
+ return 'image/webp';
49
+ }
50
+ return 'image/png';
51
+ }
52
+ /** Decode base64 image bytes and write to disk (creating dir). Returns the absolute path. */
34
53
  export async function writeImage(dir, base, base64, mimeType) {
35
54
  await mkdir(dir, { recursive: true });
36
- const ext = mimeType.includes('jpeg') ? 'jpg' : 'png';
55
+ const ext = mimeType.includes('jpeg') ? 'jpg' : mimeType.includes('webp') ? 'webp' : 'png';
37
56
  const path = await uniquePath(dir, base, ext);
38
57
  await writeFile(path, Buffer.from(base64, 'base64'));
39
- return path;
58
+ return resolve(path);
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 });
40
83
  }
41
84
  /** Read an image file into `{ base64, mimeType }` for an inline_data part. */
42
85
  export async function readImageAsInline(path) {
43
- const buf = await readFile(path);
44
- const lower = path.toLowerCase();
45
- const mimeType = lower.endsWith('.jpg') || lower.endsWith('.jpeg') ? 'image/jpeg' : 'image/png';
86
+ const resolved = resolveImagePath(path);
87
+ const buf = await readFile(resolved);
88
+ const mimeType = sniffMimeBytes(buf);
46
89
  return { base64: buf.toString('base64'), mimeType };
47
90
  }
91
+ /**
92
+ * Accept either a data URI (`data:image/png;base64,XXXX`) or raw base64.
93
+ * For raw base64, MIME is sniffed from the decoded bytes.
94
+ */
95
+ export function decodeImageInput(input) {
96
+ const trimmed = input.trim();
97
+ if (trimmed.startsWith('data:')) {
98
+ // data:<mediatype>[;param=value…];base64,<data> — tolerate extra params
99
+ // (e.g. charset) between the type and the base64 marker. Don't silently
100
+ // fall through to the raw-base64 path: a malformed data URI would decode to
101
+ // garbage bytes and ship corrupted data to the API.
102
+ const marker = trimmed.indexOf(';base64,');
103
+ if (marker === -1) {
104
+ throw new Error(`Unsupported data URI (expected ;base64,<data>): ${trimmed.slice(0, 48)}…`);
105
+ }
106
+ // MIME runs from after "data:" to the first ';' (a param sep or the base64 marker).
107
+ const mimeType = trimmed.slice(5, trimmed.indexOf(';', 5)) || 'image/png';
108
+ return { mimeType, base64: trimmed.slice(marker + ';base64,'.length) };
109
+ }
110
+ // Raw base64 — sniff from decoded bytes
111
+ const buf = Buffer.from(trimmed, 'base64');
112
+ return { base64: trimmed, mimeType: sniffMimeBytes(buf) };
113
+ }
114
+ /** Load images from file paths (via readImageAsInline) and raw base64 strings (via decodeImageInput), paths first. */
115
+ export async function loadImageInputs(paths = [], base64s = []) {
116
+ const fromPaths = await Promise.all(paths.map((p) => readImageAsInline(p)));
117
+ const fromBase64s = base64s.map((b) => decodeImageInput(b));
118
+ return [...fromPaths, ...fromBase64s];
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
+ }
133
+ /** Caller-supplied filename → safe base name (no extension). */
134
+ export function baseName(name) {
135
+ // Strip known image extensions
136
+ const stripped = name.replace(/\.(png|jpe?g|webp)$/i, '');
137
+ // Replace non-safe chars with hyphens, collapse repeats, trim edges
138
+ const safe = stripped
139
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
140
+ .replace(/-+/g, '-')
141
+ .replace(/^-+|-+$/g, '');
142
+ return safe || 'image';
143
+ }
48
144
  /** per-call → $GEMINI_OUTPUT_DIR → cwd. */
49
145
  export function resolveOutputDir(perCall) {
50
146
  return perCall?.trim() || readEnvVar('GEMINI_OUTPUT_DIR') || process.cwd();
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { VERSION } from './version.js';
4
4
  import { registerModelTools } from './tools/models.js';
5
5
  import { registerGenerateTools } from './tools/generate.js';
6
6
  import { registerSetTools } from './tools/set.js';
7
+ import { registerInteractTools } from './tools/interact.js';
7
8
  // The GeminiClient is a module-level singleton (imported by each tool module)
8
9
  // that defers its config error to the first request — so the server boots and
9
10
  // answers the host's install-time tools/list probe even without GEMINI_API_KEY.
@@ -11,5 +12,5 @@ await runMcp({
11
12
  name: 'gemini-mcp',
12
13
  version: VERSION,
13
14
  banner: '[gemini-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.',
14
- tools: [registerModelTools, registerGenerateTools, registerSetTools],
15
+ tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools],
15
16
  });
@@ -1,7 +1,9 @@
1
1
  import { z } from 'zod';
2
+ import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
3
+ import { resolveModel } from '../models.js';
2
4
  import { client } from '../client.js';
3
- import { slugify, readImageAsInline } from '../images.js';
4
- import { emit, sharedImageSchema } from './shared.js';
5
+ import { slugify, baseName, gatherImageInputs } from '../images.js';
6
+ import { emit, sharedImageSchema, pickSeed, buildMeta } from './shared.js';
5
7
  export function registerGenerateTools(server) {
6
8
  server.registerTool('gemini_generate_image', {
7
9
  description: 'Generate image(s) from a text prompt with a Gemini image model (Nano Banana / Nano Banana Pro).',
@@ -9,40 +11,88 @@ export function registerGenerateTools(server) {
9
11
  inputSchema: {
10
12
  prompt: z.string().min(1).describe('Text prompt describing the image'),
11
13
  count: z.number().int().positive().max(8).optional().describe('Number of independent images (default 1)'),
14
+ filename: z.string().optional().describe('Base filename for the output image (extension stripped; default: slugified prompt)'),
15
+ images: z.array(z.string().min(1)).optional().describe('Paths to reference input images (image-conditioned generation)'),
16
+ images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs'),
17
+ video_url: z.string().url().optional().describe('Public YouTube URL as a video reference (video→image; use a Flash model e.g. gemini-3.1-flash-image)'),
12
18
  ...sharedImageSchema,
13
19
  },
14
20
  }, async (args) => {
15
21
  const count = args.count ?? 1;
16
- const slug = slugify(args.prompt);
22
+ const seed = pickSeed(args.seed);
23
+ const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
24
+ const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
25
+ const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
17
26
  const named = [];
27
+ let capturedText;
28
+ // For count>1, surface the FIRST call's grounding (each call grounds
29
+ // independently; one representative set of sources beats concatenating N).
30
+ let capturedGrounding;
18
31
  for (let i = 0; i < count; i++) {
19
- const [img] = await client.generate({
32
+ // Distinct seed per image (seed+0 for the single-image case == echoed seed)
33
+ // so count>1 yields N *different* images, not N duplicates.
34
+ const result = await client.generate({
20
35
  prompt: args.prompt,
36
+ images: refInputs.length > 0 ? refInputs : undefined,
21
37
  model: args.model,
22
38
  aspectRatio: args.aspect_ratio,
23
39
  imageSize: args.image_size,
40
+ seed: seed + i,
41
+ thinkingLevel: args.thinking_level,
42
+ googleSearch: args.google_search,
43
+ videoUrl: args.video_url,
24
44
  });
45
+ const { images: [img], text } = result;
46
+ if (text && !capturedText)
47
+ capturedText = text;
48
+ if (result.grounding && !capturedGrounding)
49
+ capturedGrounding = result.grounding;
25
50
  named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, '0')}` });
26
51
  }
27
- return emit(named, args);
52
+ const meta = buildMeta(model, seed, args);
53
+ if (capturedText)
54
+ meta.text = capturedText;
55
+ if (capturedGrounding)
56
+ meta.grounding = capturedGrounding;
57
+ return emit(named, args, meta);
28
58
  });
29
59
  server.registerTool('gemini_edit_image', {
30
- description: 'Edit or compose images: provide one image to edit, or multiple images to combine/blend, plus a text instruction.',
60
+ description: 'Edit or compose images: provide one or more input images (paths or base64), plus a text instruction. ' +
61
+ 'Gemini over-preserves the input; there is no edit-strength control — for large structural changes, reroll with a different `seed` or more forceful wording.',
31
62
  annotations: { readOnlyHint: false, openWorldHint: true },
32
63
  inputSchema: {
33
64
  prompt: z.string().min(1).describe('Instruction describing the edit or composition'),
34
- images: z.array(z.string().min(1)).min(1).describe('Paths to input image file(s) (1 = edit, 2+ = compose)'),
65
+ images: z.array(z.string().min(1)).optional().describe('Paths to input image file(s) (1 = edit, 2+ = compose)'),
66
+ images_base64: z.array(z.string().min(1)).optional().describe('Input images as base64 strings or data URIs'),
67
+ filename: z.string().optional().describe('Base filename for the output image (extension stripped; default: slugified prompt)'),
35
68
  ...sharedImageSchema,
36
69
  },
37
70
  }, async (args) => {
38
- const inputs = await Promise.all(args.images.map((p) => readImageAsInline(p)));
39
- const [img] = await client.generate({
71
+ const hasPaths = (args.images?.length ?? 0) > 0;
72
+ const hasBase64 = (args.images_base64?.length ?? 0) > 0;
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
+ }
76
+ const seed = pickSeed(args.seed);
77
+ const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
78
+ const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
79
+ const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
80
+ const result = await client.generate({
40
81
  prompt: args.prompt,
41
82
  images: inputs,
42
83
  model: args.model,
43
84
  aspectRatio: args.aspect_ratio,
44
85
  imageSize: args.image_size,
86
+ seed,
87
+ thinkingLevel: args.thinking_level,
88
+ googleSearch: args.google_search,
45
89
  });
46
- return emit([{ image: img, base: slugify(args.prompt) }], args);
90
+ const { images: [img], text } = result;
91
+ const meta = buildMeta(model, seed, args);
92
+ if (text)
93
+ meta.text = text;
94
+ if (result.grounding)
95
+ meta.grounding = result.grounding;
96
+ return emit([{ image: img, base: slug }], args, meta);
47
97
  });
48
98
  }
@@ -0,0 +1,93 @@
1
+ import { z } from 'zod';
2
+ import { readEnvVar } from '@chrischall/mcp-utils';
3
+ import { resolveModel } from '../models.js';
4
+ import { client } from '../client.js';
5
+ import { slugify, baseName, gatherImageInputs } from '../images.js';
6
+ import { emit, ASPECT_RATIOS, IMAGE_SIZES } from './shared.js';
7
+ export function registerInteractTools(server) {
8
+ server.registerTool('gemini_interact', {
9
+ description: "Multi-turn image generation/editing via Gemini's Interactions API (Beta). " +
10
+ 'Returns an interaction `id`; pass it back as `previous_interaction_id` to iteratively ' +
11
+ 'refine the SAME image conversationally (the recommended way to make incremental edits). ' +
12
+ 'Output is JPEG.',
13
+ annotations: { readOnlyHint: false, openWorldHint: true },
14
+ inputSchema: {
15
+ input: z.string().min(1).describe('Text prompt or editing instruction'),
16
+ previous_interaction_id: z
17
+ .string()
18
+ .optional()
19
+ .describe('ID from a prior gemini_interact call — continues that multi-turn conversation'),
20
+ images: z
21
+ .array(z.string().min(1))
22
+ .optional()
23
+ .describe('Paths to reference input images'),
24
+ images_base64: z
25
+ .array(z.string().min(1))
26
+ .optional()
27
+ .describe('Reference images as base64 strings or data URIs'),
28
+ model: z
29
+ .string()
30
+ .optional()
31
+ .describe('Model id override (default: server default; see gemini_list_models)'),
32
+ aspect_ratio: z.enum(ASPECT_RATIOS).optional().describe('Output aspect ratio'),
33
+ image_size: z
34
+ .enum(IMAGE_SIZES)
35
+ .optional()
36
+ .describe('Output resolution (512 = 0.5K, Flash-only)'),
37
+ thinking_level: z
38
+ .enum(['minimal', 'high'])
39
+ .optional()
40
+ .describe('Reasoning depth; higher can help complex/structural edits'),
41
+ filename: z
42
+ .string()
43
+ .optional()
44
+ .describe('Base filename for the output image (extension stripped; default: slugified input)'),
45
+ output_dir: z
46
+ .string()
47
+ .optional()
48
+ .describe('Directory to write images to (default: $GEMINI_OUTPUT_DIR or cwd)'),
49
+ inline: z
50
+ .boolean()
51
+ .optional()
52
+ .describe('Return base64 images inline instead of writing to disk'),
53
+ google_search: z
54
+ .boolean()
55
+ .optional()
56
+ .describe('Ground the image in live Google Search results (current events, weather, data)'),
57
+ video_url: z
58
+ .string()
59
+ .url()
60
+ .optional()
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)'),
66
+ },
67
+ }, async (args) => {
68
+ const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
69
+ const r = await client.interact({
70
+ input: args.input,
71
+ images: inputs.length ? inputs : undefined,
72
+ model: args.model,
73
+ aspectRatio: args.aspect_ratio,
74
+ imageSize: args.image_size,
75
+ thinkingLevel: args.thinking_level,
76
+ previousInteractionId: args.previous_interaction_id,
77
+ googleSearch: args.google_search,
78
+ videoUrl: args.video_url,
79
+ });
80
+ const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
81
+ const meta = { model, interaction_id: r.id };
82
+ if (r.text)
83
+ meta.text = r.text;
84
+ if (r.grounding)
85
+ meta.grounding = r.grounding;
86
+ const slug = args.filename ? baseName(args.filename) : slugify(args.input);
87
+ const named = r.images.map((image, i) => ({
88
+ image,
89
+ base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
90
+ }));
91
+ return emit(named, args, meta);
92
+ });
93
+ }
package/dist/tools/set.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { z } from 'zod';
2
- import { McpToolError } from '@chrischall/mcp-utils';
2
+ import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
3
+ import { resolveModel } from '../models.js';
3
4
  import { client } from '../client.js';
4
- import { slugify } from '../images.js';
5
- import { emit, sharedImageSchema } from './shared.js';
5
+ import { slugify, baseName, gatherImageInputs } from '../images.js';
6
+ import { emit, sharedImageSchema, pickSeed, buildMeta } from './shared.js';
6
7
  export function registerSetTools(server) {
7
8
  server.registerTool('gemini_generate_set', {
8
9
  description: 'Generate a consistent SET of images: a master image from master_prompt, then one image per scene that references the master so the subject/style stays consistent. Provide `scenes` (explicit per-image prompts) OR `count` (variations of the master).',
@@ -12,6 +13,9 @@ export function registerSetTools(server) {
12
13
  scenes: z.array(z.string().min(1)).min(1).max(8).optional().describe('Per-image prompts (1-8); each references the master'),
13
14
  count: z.number().int().positive().max(8).optional().describe('Number of variations of master_prompt (when scenes omitted)'),
14
15
  reference_mode: z.enum(['master', 'chain']).optional().describe('master: every image references the master (default). chain: each references the previous.'),
16
+ basename: z.string().optional().describe('Base filename prefix for output images (default: slugified master_prompt)'),
17
+ master_images: z.array(z.string().min(1)).optional().describe('Reference image paths passed to the master generation call'),
18
+ master_images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs for master generation'),
15
19
  ...sharedImageSchema,
16
20
  },
17
21
  }, async (args) => {
@@ -19,26 +23,42 @@ export function registerSetTools(server) {
19
23
  if ((args.scenes && args.count) || (!args.scenes && !args.count)) {
20
24
  throw new McpToolError('Provide exactly one of `scenes` or `count`.');
21
25
  }
22
- const cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size };
23
- const slug = slugify(args.master_prompt);
24
- // 1. master
25
- const [master] = await client.generate({ prompt: args.master_prompt, ...cfg });
26
+ const seed = pickSeed(args.seed);
27
+ const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
28
+ const cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search };
29
+ const slug = args.basename ? baseName(args.basename) : slugify(args.master_prompt);
30
+ // 1. master (optionally seeded from reference images)
31
+ const masterRefInputs = await gatherImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard });
32
+ const masterResult = await client.generate({
33
+ prompt: args.master_prompt,
34
+ images: masterRefInputs.length > 0 ? masterRefInputs : undefined,
35
+ seed,
36
+ ...cfg,
37
+ });
38
+ const { images: [master], text: masterText } = masterResult;
26
39
  const named = [{ image: master, base: `${slug}-master` }];
40
+ // Only the master's text is surfaced (below). Per-scene text is intentionally
41
+ // dropped — a set returns one combined result and merging N captions is noise.
27
42
  // 2. scene prompts (explicit, or N repeats of master_prompt for variations)
28
43
  const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
29
44
  const mode = args.reference_mode ?? 'master';
30
45
  if (mode === 'chain') {
31
46
  let ref = master;
32
47
  for (let i = 0; i < scenePrompts.length; i++) {
33
- const [img] = await client.generate({ prompt: scenePrompts[i], images: [ref], ...cfg });
48
+ const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref], seed: seed + i + 1, ...cfg });
34
49
  named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` });
35
50
  ref = img;
36
51
  }
37
52
  }
38
53
  else {
39
- const scenes = await Promise.all(scenePrompts.map((p) => client.generate({ prompt: p, images: [master], ...cfg }).then((r) => r[0])));
54
+ const scenes = await Promise.all(scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master], seed: seed + i + 1, ...cfg }).then((r) => r.images[0])));
40
55
  scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` }));
41
56
  }
42
- return emit(named, args);
57
+ const meta = buildMeta(model, seed, args);
58
+ if (masterText)
59
+ meta.text = masterText;
60
+ if (masterResult.grounding)
61
+ meta.grounding = masterResult.grounding;
62
+ return emit(named, args, meta);
43
63
  });
44
64
  }