@chrischall/gemini-mcp 0.2.0 → 0.3.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). */
package/dist/images.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { mkdir, readFile, writeFile, access } from 'node:fs/promises';
2
- import { join } from 'node:path';
2
+ import { join, resolve } from 'node:path';
3
3
  import { readEnvVar } from '@chrischall/mcp-utils';
4
4
  /** URL/file-safe slug from a prompt; never empty. */
5
5
  export function slugify(text, max = 40) {
@@ -30,21 +30,78 @@ export async function uniquePath(dir, base, ext) {
30
30
  }
31
31
  return candidate;
32
32
  }
33
- /** Decode base64 image bytes and write to disk (creating dir). Returns the path. */
33
+ /** Sniff MIME type from the first bytes of an image buffer. */
34
+ function sniffMimeBytes(buf) {
35
+ // PNG: 89 50 4E 47
36
+ if (buf.length >= 4 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
37
+ return 'image/png';
38
+ }
39
+ // JPEG: FF D8 FF
40
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
41
+ return 'image/jpeg';
42
+ }
43
+ // WebP: RIFF....WEBP (bytes 0-3 = RIFF, bytes 8-11 = WEBP)
44
+ if (buf.length >= 12 &&
45
+ buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 &&
46
+ buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) {
47
+ return 'image/webp';
48
+ }
49
+ return 'image/png';
50
+ }
51
+ /** Decode base64 image bytes and write to disk (creating dir). Returns the absolute path. */
34
52
  export async function writeImage(dir, base, base64, mimeType) {
35
53
  await mkdir(dir, { recursive: true });
36
- const ext = mimeType.includes('jpeg') ? 'jpg' : 'png';
54
+ const ext = mimeType.includes('jpeg') ? 'jpg' : mimeType.includes('webp') ? 'webp' : 'png';
37
55
  const path = await uniquePath(dir, base, ext);
38
56
  await writeFile(path, Buffer.from(base64, 'base64'));
39
- return path;
57
+ return resolve(path);
40
58
  }
41
59
  /** Read an image file into `{ base64, mimeType }` for an inline_data part. */
42
60
  export async function readImageAsInline(path) {
43
61
  const buf = await readFile(path);
44
- const lower = path.toLowerCase();
45
- const mimeType = lower.endsWith('.jpg') || lower.endsWith('.jpeg') ? 'image/jpeg' : 'image/png';
62
+ const mimeType = sniffMimeBytes(buf);
46
63
  return { base64: buf.toString('base64'), mimeType };
47
64
  }
65
+ /**
66
+ * Accept either a data URI (`data:image/png;base64,XXXX`) or raw base64.
67
+ * For raw base64, MIME is sniffed from the decoded bytes.
68
+ */
69
+ export function decodeImageInput(input) {
70
+ const trimmed = input.trim();
71
+ if (trimmed.startsWith('data:')) {
72
+ // data:<mediatype>[;param=value…];base64,<data> — tolerate extra params
73
+ // (e.g. charset) between the type and the base64 marker. Don't silently
74
+ // fall through to the raw-base64 path: a malformed data URI would decode to
75
+ // garbage bytes and ship corrupted data to the API.
76
+ const marker = trimmed.indexOf(';base64,');
77
+ if (marker === -1) {
78
+ throw new Error(`Unsupported data URI (expected ;base64,<data>): ${trimmed.slice(0, 48)}…`);
79
+ }
80
+ // MIME runs from after "data:" to the first ';' (a param sep or the base64 marker).
81
+ const mimeType = trimmed.slice(5, trimmed.indexOf(';', 5)) || 'image/png';
82
+ return { mimeType, base64: trimmed.slice(marker + ';base64,'.length) };
83
+ }
84
+ // Raw base64 — sniff from decoded bytes
85
+ const buf = Buffer.from(trimmed, 'base64');
86
+ return { base64: trimmed, mimeType: sniffMimeBytes(buf) };
87
+ }
88
+ /** Load images from file paths (via readImageAsInline) and raw base64 strings (via decodeImageInput), paths first. */
89
+ export async function loadImageInputs(paths = [], base64s = []) {
90
+ const fromPaths = await Promise.all(paths.map((p) => readImageAsInline(p)));
91
+ const fromBase64s = base64s.map((b) => decodeImageInput(b));
92
+ return [...fromPaths, ...fromBase64s];
93
+ }
94
+ /** Caller-supplied filename → safe base name (no extension). */
95
+ export function baseName(name) {
96
+ // Strip known image extensions
97
+ const stripped = name.replace(/\.(png|jpe?g|webp)$/i, '');
98
+ // Replace non-safe chars with hyphens, collapse repeats, trim edges
99
+ const safe = stripped
100
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
101
+ .replace(/-+/g, '-')
102
+ .replace(/^-+|-+$/g, '');
103
+ return safe || 'image';
104
+ }
48
105
  /** per-call → $GEMINI_OUTPUT_DIR → cwd. */
49
106
  export function resolveOutputDir(perCall) {
50
107
  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, loadImageInputs } 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 loadImageInputs(args.images, args.images_base64);
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) {
74
+ throw new McpToolError('Provide at least one input image via `images` or `images_base64`.');
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 loadImageInputs(args.images, args.images_base64);
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,89 @@
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, loadImageInputs } 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
+ },
63
+ }, async (args) => {
64
+ const inputs = await loadImageInputs(args.images, args.images_base64);
65
+ const r = await client.interact({
66
+ input: args.input,
67
+ images: inputs.length ? inputs : undefined,
68
+ model: args.model,
69
+ aspectRatio: args.aspect_ratio,
70
+ imageSize: args.image_size,
71
+ thinkingLevel: args.thinking_level,
72
+ previousInteractionId: args.previous_interaction_id,
73
+ googleSearch: args.google_search,
74
+ videoUrl: args.video_url,
75
+ });
76
+ const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
77
+ const meta = { model, interaction_id: r.id };
78
+ if (r.text)
79
+ meta.text = r.text;
80
+ if (r.grounding)
81
+ meta.grounding = r.grounding;
82
+ const slug = args.filename ? baseName(args.filename) : slugify(args.input);
83
+ const named = r.images.map((image, i) => ({
84
+ image,
85
+ base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
86
+ }));
87
+ return emit(named, args, meta);
88
+ });
89
+ }
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, loadImageInputs } 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 loadImageInputs(args.master_images, args.master_images_base64);
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
  }
@@ -3,8 +3,8 @@ import { textResult } from '@chrischall/mcp-utils';
3
3
  import { writeImage, resolveOutputDir } from '../images.js';
4
4
  /** Supported output aspect ratios (Gemini image API). */
5
5
  export const 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'];
6
- /** Supported output resolutions. */
7
- export const IMAGE_SIZES = ['1K', '2K', '4K'];
6
+ /** Supported output resolutions. `512` is Flash-only (0.5K); `1K`/`2K`/`4K` work on all image models. */
7
+ export const IMAGE_SIZES = ['512', '1K', '2K', '4K'];
8
8
  /**
9
9
  * The model/aspect/size/output fields every generation tool shares. Defined once
10
10
  * here so descriptions can't drift between `generate.ts` and `set.ts`. Spread
@@ -13,23 +13,48 @@ export const IMAGE_SIZES = ['1K', '2K', '4K'];
13
13
  export const sharedImageSchema = {
14
14
  model: z.string().optional().describe('Model id override (default: server default; see gemini_list_models)'),
15
15
  aspect_ratio: z.enum(ASPECT_RATIOS).optional().describe('Output aspect ratio'),
16
- image_size: z.enum(IMAGE_SIZES).optional().describe('Output resolution'),
16
+ image_size: z.enum(IMAGE_SIZES).optional().describe('Output resolution (512 = 0.5K, Flash-only)'),
17
17
  output_dir: z.string().optional().describe('Directory to write images to (default: $GEMINI_OUTPUT_DIR or cwd)'),
18
18
  inline: z.boolean().optional().describe('Return base64 images inline instead of writing to disk'),
19
+ seed: z.number().int().optional().describe('Seed for reproducible generation; random if omitted'),
20
+ thinking_level: z.enum(['minimal', 'high']).optional().describe('Reasoning depth (Gemini 3 models); higher can help complex/structural edits'),
21
+ google_search: z.boolean().optional().describe('Ground the image in live Google Search results (current events, weather, data)'),
19
22
  };
23
+ /**
24
+ * Return the provided seed, or pick a random one. Capped well below INT32_MAX
25
+ * so derived per-image seeds (`seed + i`) can't overflow a 32-bit int.
26
+ */
27
+ export function pickSeed(seed) {
28
+ return seed ?? Math.floor(Math.random() * 2_147_483_000);
29
+ }
30
+ /** Build the result metadata echoed to the caller (omitting unset optionals). */
31
+ export function buildMeta(model, seed, opts) {
32
+ const meta = { model, seed };
33
+ if (opts.aspect_ratio)
34
+ meta.aspect_ratio = opts.aspect_ratio;
35
+ if (opts.image_size)
36
+ meta.image_size = opts.image_size;
37
+ return meta;
38
+ }
20
39
  /**
21
40
  * Either return images inline (base64 content blocks) or write them to disk and
22
41
  * return their paths as a text result. `inline` wins when true.
42
+ * Optional `meta` is merged into the disk-path JSON result, or prepended as a
43
+ * text block in inline mode (only if meta has keys).
23
44
  */
24
- export async function emit(named, opts) {
45
+ export async function emit(named, opts, meta) {
25
46
  if (opts.inline) {
26
- return {
27
- content: named.map((n) => ({ type: 'image', data: n.image.base64, mimeType: n.image.mimeType })),
28
- };
47
+ const imageBlocks = named.map((n) => ({ type: 'image', data: n.image.base64, mimeType: n.image.mimeType }));
48
+ if (meta && Object.keys(meta).length > 0) {
49
+ return {
50
+ content: [{ type: 'text', text: JSON.stringify(meta, null, 2) }, ...imageBlocks],
51
+ };
52
+ }
53
+ return { content: imageBlocks };
29
54
  }
30
55
  const dir = resolveOutputDir(opts.output_dir);
31
56
  const images = [];
32
57
  for (const n of named)
33
58
  images.push(await writeImage(dir, n.base, n.image.base64, n.image.mimeType));
34
- return textResult({ images });
59
+ return textResult({ images, ...meta });
35
60
  }
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.2.0'; // x-release-please-version
2
+ export const VERSION = '0.3.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/gemini-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.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>",
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.2.0",
9
+ "version": "0.3.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "0.2.0",
14
+ "version": "0.3.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },