@chrischall/gemini-mcp 0.1.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 ADDED
@@ -0,0 +1,86 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { loadDotenvSafely, readEnvVar, McpToolError, truncateErrorMessage } from '@chrischall/mcp-utils';
4
+ import { resolveModel, filterImageModels } from './models.js';
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ await loadDotenvSafely({ path: join(__dirname, '..', '.env'), override: false });
7
+ const BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'; // v1 lacks gemini-3-pro-image; confirmed via Task 5
8
+ const SERVICE = 'Gemini';
9
+ export class GeminiClient {
10
+ apiKey;
11
+ configError;
12
+ fetchImpl;
13
+ constructor(opts = {}) {
14
+ const key = readEnvVar('GEMINI_API_KEY');
15
+ if (!key) {
16
+ this.apiKey = null;
17
+ this.configError = new McpToolError('GEMINI_API_KEY environment variable is required', {
18
+ hint: 'Create a key at https://aistudio.google.com/apikey and set GEMINI_API_KEY in your MCP host env or .env',
19
+ });
20
+ }
21
+ else {
22
+ this.apiKey = key;
23
+ this.configError = null;
24
+ }
25
+ this.fetchImpl = opts.fetchImpl ?? fetch;
26
+ }
27
+ requireKey() {
28
+ if (this.configError)
29
+ throw this.configError;
30
+ return this.apiKey;
31
+ }
32
+ /** The default model after env override (no per-call arg). */
33
+ defaultModel() {
34
+ return resolveModel(undefined, readEnvVar('GEMINI_IMAGE_MODEL'));
35
+ }
36
+ async call(method, path, body) {
37
+ const key = this.requireKey();
38
+ const res = await this.fetchImpl(`${BASE_URL}${path}`, {
39
+ method,
40
+ headers: { 'x-goog-api-key': key, 'content-type': 'application/json' },
41
+ body: body !== undefined ? JSON.stringify(body) : undefined,
42
+ });
43
+ if (!res.ok) {
44
+ const text = await res.text().catch(() => '');
45
+ throw new McpToolError(`${SERVICE} API ${res.status}: ${truncateErrorMessage(text)}`);
46
+ }
47
+ return (await res.json());
48
+ }
49
+ async listModels() {
50
+ const data = await this.call('GET', '/models?pageSize=200');
51
+ return filterImageModels(data.models ?? []);
52
+ }
53
+ async generate(opts) {
54
+ const model = resolveModel(opts.model, readEnvVar('GEMINI_IMAGE_MODEL'));
55
+ const parts = [{ text: opts.prompt }];
56
+ for (const img of opts.images ?? []) {
57
+ parts.push({ inline_data: { mime_type: img.mimeType, data: img.base64 } });
58
+ }
59
+ const generationConfig = { responseModalities: ['IMAGE'] };
60
+ if (opts.aspectRatio || opts.imageSize) {
61
+ const imageConfig = {};
62
+ if (opts.aspectRatio)
63
+ imageConfig.aspectRatio = opts.aspectRatio;
64
+ if (opts.imageSize)
65
+ imageConfig.imageSize = opts.imageSize;
66
+ generationConfig.imageConfig = imageConfig;
67
+ }
68
+ const data = await this.call('POST', `/models/${model}:generateContent`, { contents: [{ parts }], generationConfig });
69
+ const out = [];
70
+ for (const cand of data.candidates ?? []) {
71
+ for (const part of cand.content?.parts ?? []) {
72
+ 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' });
75
+ }
76
+ }
77
+ if (out.length === 0) {
78
+ throw new McpToolError(`${SERVICE} returned no image`, {
79
+ hint: 'The request may have been blocked by safety filters — try rephrasing the prompt.',
80
+ });
81
+ }
82
+ return out;
83
+ }
84
+ }
85
+ /** Module-level singleton shared by every tool module (deferred-config-error). */
86
+ export const client = new GeminiClient();
package/dist/images.js ADDED
@@ -0,0 +1,51 @@
1
+ import { mkdir, readFile, writeFile, access } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { readEnvVar } from '@chrischall/mcp-utils';
4
+ /** URL/file-safe slug from a prompt; never empty. */
5
+ export function slugify(text, max = 40) {
6
+ const s = text
7
+ .toLowerCase()
8
+ .replace(/[^a-z0-9]+/g, '-')
9
+ .replace(/^-+|-+$/g, '')
10
+ .slice(0, max)
11
+ .replace(/-+$/g, '');
12
+ return s || 'image';
13
+ }
14
+ async function exists(p) {
15
+ try {
16
+ await access(p);
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ /** `<base>.<ext>`, then `<base>-2.<ext>`, … until a free path is found. */
24
+ export async function uniquePath(dir, base, ext) {
25
+ let candidate = join(dir, `${base}.${ext}`);
26
+ let n = 2;
27
+ while (await exists(candidate)) {
28
+ candidate = join(dir, `${base}-${n}.${ext}`);
29
+ n++;
30
+ }
31
+ return candidate;
32
+ }
33
+ /** Decode base64 image bytes and write to disk (creating dir). Returns the path. */
34
+ export async function writeImage(dir, base, base64, mimeType) {
35
+ await mkdir(dir, { recursive: true });
36
+ const ext = mimeType.includes('jpeg') ? 'jpg' : 'png';
37
+ const path = await uniquePath(dir, base, ext);
38
+ await writeFile(path, Buffer.from(base64, 'base64'));
39
+ return path;
40
+ }
41
+ /** Read an image file into `{ base64, mimeType }` for an inline_data part. */
42
+ 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';
46
+ return { base64: buf.toString('base64'), mimeType };
47
+ }
48
+ /** per-call → $GEMINI_OUTPUT_DIR → cwd. */
49
+ export function resolveOutputDir(perCall) {
50
+ return perCall?.trim() || readEnvVar('GEMINI_OUTPUT_DIR') || process.cwd();
51
+ }
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ import { runMcp } from '@chrischall/mcp-utils';
3
+ import { VERSION } from './version.js';
4
+ import { registerModelTools } from './tools/models.js';
5
+ import { registerGenerateTools } from './tools/generate.js';
6
+ import { registerSetTools } from './tools/set.js';
7
+ // The GeminiClient is a module-level singleton (imported by each tool module)
8
+ // that defers its config error to the first request — so the server boots and
9
+ // answers the host's install-time tools/list probe even without GEMINI_API_KEY.
10
+ await runMcp({
11
+ name: 'gemini-mcp',
12
+ version: VERSION,
13
+ banner: '[gemini-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.',
14
+ tools: [registerModelTools, registerGenerateTools, registerSetTools],
15
+ });
package/dist/models.js ADDED
@@ -0,0 +1,20 @@
1
+ /** Latest top-end image model; overridable per-call and via GEMINI_IMAGE_MODEL. */
2
+ export const DEFAULT_IMAGE_MODEL = 'gemini-3-pro-image';
3
+ /** Per-call → env override → hardcoded default. Blank/whitespace counts as unset. */
4
+ export function resolveModel(perCall, envOverride) {
5
+ return perCall?.trim() || envOverride?.trim() || DEFAULT_IMAGE_MODEL;
6
+ }
7
+ /**
8
+ * Keep only Gemini image-generation models (the Nano Banana family) and strip
9
+ * the `models/` prefix. Excludes `imagen-*` — those contain the substring
10
+ * "image" but use a different `:predict` API this server doesn't implement.
11
+ */
12
+ export function filterImageModels(raw) {
13
+ return raw
14
+ .filter((m) => /image/i.test(m.name ?? '') && !/imagen/i.test(m.name ?? ''))
15
+ .map((m) => ({
16
+ id: (m.name ?? '').replace(/^models\//, ''),
17
+ displayName: m.displayName ?? '',
18
+ description: m.description ?? '',
19
+ }));
20
+ }
@@ -0,0 +1,48 @@
1
+ import { z } from 'zod';
2
+ import { client } from '../client.js';
3
+ import { slugify, readImageAsInline } from '../images.js';
4
+ import { emit, sharedImageSchema } from './shared.js';
5
+ export function registerGenerateTools(server) {
6
+ server.registerTool('gemini_generate_image', {
7
+ description: 'Generate image(s) from a text prompt with a Gemini image model (Nano Banana / Nano Banana Pro).',
8
+ annotations: { readOnlyHint: false, openWorldHint: true },
9
+ inputSchema: {
10
+ prompt: z.string().min(1).describe('Text prompt describing the image'),
11
+ count: z.number().int().positive().max(8).optional().describe('Number of independent images (default 1)'),
12
+ ...sharedImageSchema,
13
+ },
14
+ }, async (args) => {
15
+ const count = args.count ?? 1;
16
+ const slug = slugify(args.prompt);
17
+ const named = [];
18
+ for (let i = 0; i < count; i++) {
19
+ const [img] = await client.generate({
20
+ prompt: args.prompt,
21
+ model: args.model,
22
+ aspectRatio: args.aspect_ratio,
23
+ imageSize: args.image_size,
24
+ });
25
+ named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, '0')}` });
26
+ }
27
+ return emit(named, args);
28
+ });
29
+ 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.',
31
+ annotations: { readOnlyHint: false, openWorldHint: true },
32
+ inputSchema: {
33
+ 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)'),
35
+ ...sharedImageSchema,
36
+ },
37
+ }, async (args) => {
38
+ const inputs = await Promise.all(args.images.map((p) => readImageAsInline(p)));
39
+ const [img] = await client.generate({
40
+ prompt: args.prompt,
41
+ images: inputs,
42
+ model: args.model,
43
+ aspectRatio: args.aspect_ratio,
44
+ imageSize: args.image_size,
45
+ });
46
+ return emit([{ image: img, base: slugify(args.prompt) }], args);
47
+ });
48
+ }
@@ -0,0 +1,11 @@
1
+ import { textResult } from '@chrischall/mcp-utils';
2
+ import { client } from '../client.js';
3
+ export function registerModelTools(server) {
4
+ server.registerTool('gemini_list_models', {
5
+ description: 'List the Gemini image-generation models available to your API key (Nano Banana / Nano Banana Pro family), and the current default model.',
6
+ annotations: { readOnlyHint: true },
7
+ }, async () => {
8
+ const models = await client.listModels();
9
+ return textResult({ default: client.defaultModel(), models });
10
+ });
11
+ }
@@ -0,0 +1,44 @@
1
+ import { z } from 'zod';
2
+ import { McpToolError } from '@chrischall/mcp-utils';
3
+ import { client } from '../client.js';
4
+ import { slugify } from '../images.js';
5
+ import { emit, sharedImageSchema } from './shared.js';
6
+ export function registerSetTools(server) {
7
+ server.registerTool('gemini_generate_set', {
8
+ 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).',
9
+ annotations: { readOnlyHint: false, openWorldHint: true },
10
+ inputSchema: {
11
+ master_prompt: z.string().min(1).describe('Prompt for the master/reference image'),
12
+ scenes: z.array(z.string().min(1)).min(1).max(8).optional().describe('Per-image prompts (1-8); each references the master'),
13
+ count: z.number().int().positive().max(8).optional().describe('Number of variations of master_prompt (when scenes omitted)'),
14
+ reference_mode: z.enum(['master', 'chain']).optional().describe('master: every image references the master (default). chain: each references the previous.'),
15
+ ...sharedImageSchema,
16
+ },
17
+ }, async (args) => {
18
+ // `scenes` is min(1) at the schema, so a non-undefined value is non-empty.
19
+ if ((args.scenes && args.count) || (!args.scenes && !args.count)) {
20
+ throw new McpToolError('Provide exactly one of `scenes` or `count`.');
21
+ }
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 named = [{ image: master, base: `${slug}-master` }];
27
+ // 2. scene prompts (explicit, or N repeats of master_prompt for variations)
28
+ const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
29
+ const mode = args.reference_mode ?? 'master';
30
+ if (mode === 'chain') {
31
+ let ref = master;
32
+ for (let i = 0; i < scenePrompts.length; i++) {
33
+ const [img] = await client.generate({ prompt: scenePrompts[i], images: [ref], ...cfg });
34
+ named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` });
35
+ ref = img;
36
+ }
37
+ }
38
+ else {
39
+ const scenes = await Promise.all(scenePrompts.map((p) => client.generate({ prompt: p, images: [master], ...cfg }).then((r) => r[0])));
40
+ scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, '0')}` }));
41
+ }
42
+ return emit(named, args);
43
+ });
44
+ }
@@ -0,0 +1,35 @@
1
+ import { z } from 'zod';
2
+ import { textResult } from '@chrischall/mcp-utils';
3
+ import { writeImage, resolveOutputDir } from '../images.js';
4
+ /** Supported output aspect ratios (Gemini image API). */
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'];
8
+ /**
9
+ * The model/aspect/size/output fields every generation tool shares. Defined once
10
+ * here so descriptions can't drift between `generate.ts` and `set.ts`. Spread
11
+ * into each tool's `inputSchema`.
12
+ */
13
+ export const sharedImageSchema = {
14
+ model: z.string().optional().describe('Model id override (default: server default; see gemini_list_models)'),
15
+ aspect_ratio: z.enum(ASPECT_RATIOS).optional().describe('Output aspect ratio'),
16
+ image_size: z.enum(IMAGE_SIZES).optional().describe('Output resolution'),
17
+ output_dir: z.string().optional().describe('Directory to write images to (default: $GEMINI_OUTPUT_DIR or cwd)'),
18
+ inline: z.boolean().optional().describe('Return base64 images inline instead of writing to disk'),
19
+ };
20
+ /**
21
+ * Either return images inline (base64 content blocks) or write them to disk and
22
+ * return their paths as a text result. `inline` wins when true.
23
+ */
24
+ export async function emit(named, opts) {
25
+ if (opts.inline) {
26
+ return {
27
+ content: named.map((n) => ({ type: 'image', data: n.image.base64, mimeType: n.image.mimeType })),
28
+ };
29
+ }
30
+ const dir = resolveOutputDir(opts.output_dir);
31
+ const images = [];
32
+ for (const n of named)
33
+ images.push(await writeImage(dir, n.base, n.image.base64, n.image.mimeType));
34
+ return textResult({ images });
35
+ }
@@ -0,0 +1,2 @@
1
+ /** Single source of the server version. release-please bumps the literal below. */
2
+ export const VERSION = '0.1.0'; // x-release-please-version
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@chrischall/gemini-mcp",
3
+ "version": "0.1.0",
4
+ "mcpName": "io.github.chrischall/gemini-mcp",
5
+ "description": "Gemini image-generation MCP server for Claude — developed and maintained by AI (Claude Code)",
6
+ "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
7
+ "repository": { "type": "git", "url": "git+https://github.com/chrischall/gemini-mcp.git" },
8
+ "license": "MIT",
9
+ "keywords": ["mcp", "model-context-protocol", "claude", "ai", "gemini", "nano-banana", "image-generation", "image-editing", "text-to-image", "google-gemini"],
10
+ "type": "module",
11
+ "publishConfig": { "access": "public" },
12
+ "bin": { "gemini-mcp": "dist/index.js" },
13
+ "files": ["dist", ".claude-plugin", "SKILL.md", ".mcp.json", "server.json"],
14
+ "scripts": {
15
+ "build": "tsc && npm run bundle",
16
+ "bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --outfile=dist/bundle.js",
17
+ "dev": "node dist/index.js",
18
+ "test": "vitest run",
19
+ "test:watch": "vitest",
20
+ "test:coverage": "vitest run --coverage"
21
+ },
22
+ "dependencies": {
23
+ "@chrischall/mcp-utils": "^0.6.0",
24
+ "@modelcontextprotocol/sdk": "^1.29.0",
25
+ "dotenv": "^17.4.0",
26
+ "zod": "^4.4.2"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^25.5.2",
30
+ "@vitest/coverage-v8": "^4.1.2",
31
+ "esbuild": "^0.28.0",
32
+ "typescript": "^6.0.2",
33
+ "vitest": "^4.1.2"
34
+ }
35
+ }
package/server.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.chrischall/gemini-mcp",
4
+ "description": "Generate and edit images with Google Gemini (Nano Banana / Nano Banana Pro) image models.",
5
+ "repository": {
6
+ "url": "https://github.com/chrischall/gemini-mcp",
7
+ "source": "github"
8
+ },
9
+ "version": "0.1.0",
10
+ "packages": [
11
+ {
12
+ "registryType": "npm",
13
+ "identifier": "@chrischall/gemini-mcp",
14
+ "version": "0.1.0",
15
+ "transport": {
16
+ "type": "stdio"
17
+ },
18
+ "environmentVariables": [
19
+ {
20
+ "name": "GEMINI_API_KEY",
21
+ "description": "Your Google Gemini API key (aistudio.google.com/apikey)",
22
+ "isRequired": true,
23
+ "format": "string",
24
+ "isSecret": true
25
+ },
26
+ {
27
+ "name": "GEMINI_IMAGE_MODEL",
28
+ "description": "Override the default image model (default: gemini-3-pro-image)",
29
+ "isRequired": false,
30
+ "format": "string",
31
+ "isSecret": false
32
+ },
33
+ {
34
+ "name": "GEMINI_OUTPUT_DIR",
35
+ "description": "Default directory for generated images (default: current working directory)",
36
+ "isRequired": false,
37
+ "format": "string",
38
+ "isSecret": false
39
+ }
40
+ ]
41
+ }
42
+ ]
43
+ }