@chrischall/gemini-mcp 0.3.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.mcp.json +2 -1
- package/README.md +1 -0
- package/SKILL.md +23 -2
- package/dist/bundle.js +30779 -29514
- package/dist/clipboard.js +67 -0
- package/dist/images.js +42 -3
- package/dist/tools/generate.js +5 -5
- package/dist/tools/interact.js +6 -2
- package/dist/tools/set.js +2 -2
- package/dist/tools/shared.js +1 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +9 -2
|
@@ -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 {
|
|
3
|
-
import {
|
|
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
|
|
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
|
package/dist/tools/generate.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,
|
|
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
|
|
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 `
|
|
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
|
|
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,
|
package/dist/tools/interact.js
CHANGED
|
@@ -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,
|
|
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
|
|
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,
|
|
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
|
|
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,
|
package/dist/tools/shared.js
CHANGED
|
@@ -19,6 +19,7 @@ export const sharedImageSchema = {
|
|
|
19
19
|
seed: z.number().int().optional().describe('Seed for reproducible generation; random if omitted'),
|
|
20
20
|
thinking_level: z.enum(['minimal', 'high']).optional().describe('Reasoning depth (Gemini 3 models); higher can help complex/structural edits'),
|
|
21
21
|
google_search: z.boolean().optional().describe('Ground the image in live Google Search results (current events, weather, data)'),
|
|
22
|
+
from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)'),
|
|
22
23
|
};
|
|
23
24
|
/**
|
|
24
25
|
* 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.
|
|
2
|
+
export const VERSION = '0.4.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
|
+
"version": "0.4.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.
|
|
9
|
+
"version": "0.4.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/gemini-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.4.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
|
}
|