@chrischall/gemini-mcp 0.4.0 → 0.6.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/LICENSE +21 -0
- package/README.md +4 -0
- package/SKILL.md +3 -3
- package/dist/bundle.js +379 -54
- package/dist/client.js +134 -34
- package/dist/images.js +38 -5
- package/dist/tools/generate.js +10 -3
- package/dist/tools/interact.js +8 -3
- package/dist/tools/shared.js +37 -3
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "MCP server for Google Gemini image generation and editing — generate and edit images via the Gemini API",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.6.0"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"displayName": "Gemini Images",
|
|
16
16
|
"source": "./",
|
|
17
17
|
"description": "Generate and edit images with Google Gemini image models (Nano Banana / Nano Banana Pro) via natural language prompts",
|
|
18
|
-
"version": "0.
|
|
18
|
+
"version": "0.6.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chris Hall
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# gemini-mcp
|
|
2
2
|
|
|
3
|
+
[](https://github.com/chrischall/gemini-mcp/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@chrischall/gemini-mcp)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
3
7
|
MCP server for Google Gemini image generation and editing. Exposes four tools to Claude over stdio: list available models, generate images from text prompts, edit or compose images with text instructions, and generate a consistent set of images from a master prompt. Images are written to disk by default (path returned) or returned inline as base64. Built on the Gemini v1beta API (`generativelanguage.googleapis.com`) using the Nano Banana / Nano Banana Pro model family.
|
|
4
8
|
|
|
5
9
|
Developed and maintained by AI (Claude Code).
|
package/SKILL.md
CHANGED
|
@@ -83,14 +83,14 @@ Note: Image generation requires a billing-enabled Google Cloud project.
|
|
|
83
83
|
### Image Generation
|
|
84
84
|
| Tool | Description |
|
|
85
85
|
|------|-------------|
|
|
86
|
-
| `gemini_generate_image(prompt, count?, images?, images_base64?, video_url?, google_search?, seed?, filename?, model?, aspect_ratio?, image_size?, thinking_level?, output_dir?, inline?)` | Generate image(s) from a text prompt (optionally image-conditioned via `images`/`images_base64`, or video-conditioned via `video_url`) |
|
|
86
|
+
| `gemini_generate_image(prompt, count?, images?, images_base64?, video_url?, video_path?, google_search?, seed?, filename?, model?, aspect_ratio?, image_size?, thinking_level?, output_dir?, inline?)` | Generate image(s) from a text prompt (optionally image-conditioned via `images`/`images_base64`, or video-conditioned via `video_url`/`video_path`) |
|
|
87
87
|
| `gemini_edit_image(prompt, images?, images_base64?, google_search?, seed?, filename?, model?, aspect_ratio?, image_size?, thinking_level?, output_dir?, inline?)` | Edit or compose input image(s) — by **path** (`images`) or **value** (`images_base64`: data URI or raw base64) — with a text instruction. Requires ≥1 input |
|
|
88
88
|
| `gemini_generate_set(master_prompt, scenes? \| count?, reference_mode?, master_images?, master_images_base64?, google_search?, seed?, basename?, model?, thinking_level?, ...)` | Master image (optionally seeded from a reference photo) plus N consistent images referencing it |
|
|
89
89
|
|
|
90
90
|
### Multi-turn (Interactions API — Beta)
|
|
91
91
|
| Tool | Description |
|
|
92
92
|
|------|-------------|
|
|
93
|
-
| `gemini_interact(input, previous_interaction_id?, images?, images_base64?, video_url?, google_search?, model?, aspect_ratio?, image_size?, thinking_level?, filename?, output_dir?, inline?)` | Generate/edit via Gemini's **Interactions API**. Returns an `interaction_id`; pass it back as `previous_interaction_id` to **iteratively refine the same image** conversationally — the recommended way to make incremental edits. Output is **JPEG**. (Beta API.) |
|
|
93
|
+
| `gemini_interact(input, previous_interaction_id?, images?, images_base64?, video_url?, video_path?, google_search?, model?, aspect_ratio?, image_size?, thinking_level?, filename?, output_dir?, inline?)` | Generate/edit via Gemini's **Interactions API**. Returns an `interaction_id`; pass it back as `previous_interaction_id` to **iteratively refine the same image** conversationally — the recommended way to make incremental edits. Output is **JPEG**. (Beta API.) |
|
|
94
94
|
|
|
95
95
|
## Workflows
|
|
96
96
|
|
|
@@ -179,7 +179,7 @@ extraction:
|
|
|
179
179
|
- **`thinking_level`** (`minimal`/`high`, Gemini 3 models) controls reasoning depth — `high` can improve complex compositions/edits at higher latency/cost.
|
|
180
180
|
- **Model text.** When the model returns a caption/explanation (mostly Gemini 3 **Pro**), it's surfaced as `text` in the result metadata.
|
|
181
181
|
- **`google_search: true`** grounds the image in live Google Search (current events, weather, real data — great for infographics). The result metadata includes `grounding` with the `queries` run and the `sources` (`{uri, title}`) used. (`gemini_interact` surfaces `grounding.queries` only — the Interactions API returns no clean source list.)
|
|
182
|
-
- **`video_url`** (a public YouTube URL, on `gemini_generate_image` / `gemini_interact`) generates an image from a video reference — **requires a Flash model** (e.g. `model: "gemini-3.1-flash-image"`).
|
|
182
|
+
- **`video_url`** (a public YouTube URL, on `gemini_generate_image` / `gemini_interact`) generates an image from a video reference — **requires a Flash model** (e.g. `model: "gemini-3.1-flash-image"`). For a **local video file**, use **`video_path`** instead: the file is uploaded to the Gemini Files API (streamed from disk, 2 GB max), waited to `ACTIVE`, and referenced by its `files/…` uri. The result metadata echoes `video_file` (`{uri, name, expires}`, ~48h retention) — reuse that uri as `video_url` in later calls to skip re-uploading.
|
|
183
183
|
- **`gemini_interact`** is the multi-turn path: it returns an `interaction_id`; thread it back via `previous_interaction_id` for conversational refinement. Output is **JPEG only**. It's a **Beta** API (separate from the stable `generate`/`edit`/`set` tools).
|
|
184
184
|
- `output_dir` per-call overrides `$GEMINI_OUTPUT_DIR` overrides cwd. `inline: true` returns bytes (with a metadata text block) instead of writing.
|
|
185
185
|
- `count` and `scenes` are mutually exclusive in `gemini_generate_set`; `reference_mode: "chain"` references the previous image instead of the master.
|
package/dist/bundle.js
CHANGED
|
@@ -32095,20 +32095,9 @@ var init_server3 = __esm({
|
|
|
32095
32095
|
}
|
|
32096
32096
|
});
|
|
32097
32097
|
|
|
32098
|
-
// node_modules/@chrischall/mcp-utils/dist/response/index.js
|
|
32099
|
-
function textResult(data) {
|
|
32100
|
-
return {
|
|
32101
|
-
content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
|
|
32102
|
-
};
|
|
32103
|
-
}
|
|
32104
|
-
var init_response = __esm({
|
|
32105
|
-
"node_modules/@chrischall/mcp-utils/dist/response/index.js"() {
|
|
32106
|
-
}
|
|
32107
|
-
});
|
|
32108
|
-
|
|
32109
32098
|
// node_modules/@chrischall/mcp-utils/dist/errors/index.js
|
|
32110
32099
|
function redactSecrets(text) {
|
|
32111
|
-
return text.replace(BEARER_RE, "$1[REDACTED]").replace(JWT_RE, "[REDACTED]");
|
|
32100
|
+
return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(JWT_RE, "[REDACTED]");
|
|
32112
32101
|
}
|
|
32113
32102
|
function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
|
|
32114
32103
|
const str = text === null || text === void 0 ? "" : String(text);
|
|
@@ -32117,7 +32106,7 @@ function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
|
|
|
32117
32106
|
return redacted;
|
|
32118
32107
|
return `${redacted.slice(0, max)}\u2026 [truncated]`;
|
|
32119
32108
|
}
|
|
32120
|
-
var DEFAULT_ERROR_MESSAGE_MAX, McpToolError, BEARER_RE, JWT_RE;
|
|
32109
|
+
var DEFAULT_ERROR_MESSAGE_MAX, McpToolError, BEARER_RE, JWT_RE, BASIC_AUTH_RE, SET_COOKIE_RE, COOKIE_HEADER_RE, API_KEY_RE, QUERY_SECRET_RE;
|
|
32121
32110
|
var init_errors4 = __esm({
|
|
32122
32111
|
"node_modules/@chrischall/mcp-utils/dist/errors/index.js"() {
|
|
32123
32112
|
DEFAULT_ERROR_MESSAGE_MAX = 500;
|
|
@@ -32134,6 +32123,36 @@ var init_errors4 = __esm({
|
|
|
32134
32123
|
};
|
|
32135
32124
|
BEARER_RE = /(bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
|
|
32136
32125
|
JWT_RE = /\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{8,}\b/g;
|
|
32126
|
+
BASIC_AUTH_RE = /(authorization\s*:\s*basic\s+)[A-Za-z0-9+/=_-]{6,}/gi;
|
|
32127
|
+
SET_COOKIE_RE = /(\bset-cookie\s*:\s*)([^=;,\s]+)=[^;,\s]*/gi;
|
|
32128
|
+
COOKIE_HEADER_RE = /((?<!set-)\bcookie\s*:\s*)((?:[^=;,\s]+=[^;,\s]*)(?:;\s*[^=;,\s]+=[^;,\s]*)*)/gi;
|
|
32129
|
+
API_KEY_RE = new RegExp([
|
|
32130
|
+
"sk-[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])",
|
|
32131
|
+
// OpenAI / Anthropic (incl. sk-ant-…)
|
|
32132
|
+
"gh[pousr]_[A-Za-z0-9]{36,}\\b",
|
|
32133
|
+
// GitHub ghp_/gho_/ghu_/ghs_/ghr_
|
|
32134
|
+
"xox[baprs]-[A-Za-z0-9-]{10,}(?![A-Za-z0-9-])",
|
|
32135
|
+
// Slack
|
|
32136
|
+
"AIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])",
|
|
32137
|
+
// Google API key (39 chars total)
|
|
32138
|
+
"AKIA[0-9A-Z]{16}\\b",
|
|
32139
|
+
// AWS access key id (20 chars total)
|
|
32140
|
+
"whsec_[A-Za-z0-9]{16,}\\b"
|
|
32141
|
+
// webhook signing secret (Stripe-style)
|
|
32142
|
+
].map((p) => `\\b${p}`).join("|"), "g");
|
|
32143
|
+
QUERY_SECRET_RE = /([?&](?:access_token|refresh_token|client_secret|api_?key|signature|token|key|sig)=)[^&#\s"'<>`]+/gi;
|
|
32144
|
+
}
|
|
32145
|
+
});
|
|
32146
|
+
|
|
32147
|
+
// node_modules/@chrischall/mcp-utils/dist/response/index.js
|
|
32148
|
+
function textResult(data) {
|
|
32149
|
+
return {
|
|
32150
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
|
|
32151
|
+
};
|
|
32152
|
+
}
|
|
32153
|
+
var init_response = __esm({
|
|
32154
|
+
"node_modules/@chrischall/mcp-utils/dist/response/index.js"() {
|
|
32155
|
+
init_errors4();
|
|
32137
32156
|
}
|
|
32138
32157
|
});
|
|
32139
32158
|
|
|
@@ -32173,15 +32192,186 @@ var init_config = __esm({
|
|
|
32173
32192
|
});
|
|
32174
32193
|
|
|
32175
32194
|
// node_modules/@chrischall/mcp-utils/dist/fs/index.js
|
|
32195
|
+
import { openAsBlob } from "node:fs";
|
|
32196
|
+
async function fileBlob(path, opts = {}) {
|
|
32197
|
+
let blob;
|
|
32198
|
+
try {
|
|
32199
|
+
blob = await openAsBlob(path, opts.type !== void 0 ? { type: opts.type } : void 0);
|
|
32200
|
+
} catch {
|
|
32201
|
+
throw new Error(`Cannot read file for upload: ${path}`);
|
|
32202
|
+
}
|
|
32203
|
+
if (opts.maxBytes !== void 0 && blob.size > opts.maxBytes) {
|
|
32204
|
+
throw new Error(`${opts.label ?? "File"} is ${blob.size} bytes, over the ${opts.maxBytes}-byte limit: ${path}`);
|
|
32205
|
+
}
|
|
32206
|
+
return blob;
|
|
32207
|
+
}
|
|
32176
32208
|
var init_fs = __esm({
|
|
32177
32209
|
"node_modules/@chrischall/mcp-utils/dist/fs/index.js"() {
|
|
32178
32210
|
}
|
|
32179
32211
|
});
|
|
32180
32212
|
|
|
32213
|
+
// node_modules/@chrischall/mcp-utils/dist/http/throttle.js
|
|
32214
|
+
var init_throttle = __esm({
|
|
32215
|
+
"node_modules/@chrischall/mcp-utils/dist/http/throttle.js"() {
|
|
32216
|
+
}
|
|
32217
|
+
});
|
|
32218
|
+
|
|
32181
32219
|
// node_modules/@chrischall/mcp-utils/dist/http/index.js
|
|
32220
|
+
function hostOf(baseUrl) {
|
|
32221
|
+
try {
|
|
32222
|
+
return new URL(baseUrl).host;
|
|
32223
|
+
} catch {
|
|
32224
|
+
return baseUrl;
|
|
32225
|
+
}
|
|
32226
|
+
}
|
|
32227
|
+
function createApiClient(opts) {
|
|
32228
|
+
const base = opts.baseUrl.replace(/\/+$/, "");
|
|
32229
|
+
const retry = opts.retry ?? DEFAULT_RETRY;
|
|
32230
|
+
const service = opts.serviceName ?? hostOf(opts.baseUrl);
|
|
32231
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
32232
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
32233
|
+
const unauthorized = () => opts.onUnauthorized ? opts.onUnauthorized() : new UnauthorizedError(service);
|
|
32234
|
+
const rateLimited = () => opts.onRateLimited ? opts.onRateLimited() : new RateLimitedError(service);
|
|
32235
|
+
const retryStatuses = retry.statuses ?? [429];
|
|
32236
|
+
const timeoutMs = opts.timeout;
|
|
32237
|
+
const authHeader = (token) => !token ? {} : opts.tokenHeader ? { [opts.tokenHeader]: token } : { Authorization: `Bearer ${token}` };
|
|
32238
|
+
function withTimeout(run) {
|
|
32239
|
+
if (timeoutMs == null || timeoutMs <= 0)
|
|
32240
|
+
return run(void 0);
|
|
32241
|
+
const controller = new AbortController();
|
|
32242
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
32243
|
+
return run(controller.signal).catch((err) => {
|
|
32244
|
+
if (err instanceof Error && err.name === "AbortError")
|
|
32245
|
+
throw new RequestTimeoutError(service, timeoutMs);
|
|
32246
|
+
throw err;
|
|
32247
|
+
}).finally(() => clearTimeout(timer));
|
|
32248
|
+
}
|
|
32249
|
+
async function send(method, path, opt) {
|
|
32250
|
+
const isMultipart = opt.formData !== void 0;
|
|
32251
|
+
const hasJsonBody = !isMultipart && opt.body !== void 0;
|
|
32252
|
+
const reqBody = isMultipart ? opt.formData : hasJsonBody ? JSON.stringify(opt.body) : void 0;
|
|
32253
|
+
const query = opt.query ? buildQueryString(opt.query) : "";
|
|
32254
|
+
const url2 = `${base}${path}${query}`;
|
|
32255
|
+
const bodyInit = reqBody !== void 0 ? { body: reqBody } : {};
|
|
32256
|
+
const fetchWith = (token, signal) => doFetch(url2, {
|
|
32257
|
+
method,
|
|
32258
|
+
headers: {
|
|
32259
|
+
Accept: "application/json",
|
|
32260
|
+
...hasJsonBody ? { "Content-Type": "application/json" } : {},
|
|
32261
|
+
...opts.baseHeaders,
|
|
32262
|
+
...authHeader(token || void 0),
|
|
32263
|
+
...opt.headers
|
|
32264
|
+
},
|
|
32265
|
+
...signal ? { signal } : {},
|
|
32266
|
+
...bodyInit
|
|
32267
|
+
});
|
|
32268
|
+
const once = () => withTimeout((signal) => opts.tokenManager ? opts.tokenManager.withAuth((token) => fetchWith(token, signal)) : (async () => fetchWith(await opts.getToken?.() || void 0, signal))());
|
|
32269
|
+
let attempt = 0;
|
|
32270
|
+
for (; ; ) {
|
|
32271
|
+
const res = await once();
|
|
32272
|
+
if (retryStatuses.includes(res.status) && attempt < retry.count) {
|
|
32273
|
+
attempt += 1;
|
|
32274
|
+
await sleep(retry.delayMs);
|
|
32275
|
+
continue;
|
|
32276
|
+
}
|
|
32277
|
+
return res;
|
|
32278
|
+
}
|
|
32279
|
+
}
|
|
32280
|
+
async function fetchJson(method, path, opt = {}) {
|
|
32281
|
+
const res = await send(method, path, opt);
|
|
32282
|
+
if (res.status === 401)
|
|
32283
|
+
throw unauthorized();
|
|
32284
|
+
if (res.status === 429)
|
|
32285
|
+
throw rateLimited();
|
|
32286
|
+
if (res.status === 204)
|
|
32287
|
+
return void 0;
|
|
32288
|
+
const text = await res.text();
|
|
32289
|
+
if (!res.ok) {
|
|
32290
|
+
throw new ApiError(res.status, formatApiError(res.status, method, path, text, { service }));
|
|
32291
|
+
}
|
|
32292
|
+
if (text.length === 0)
|
|
32293
|
+
return void 0;
|
|
32294
|
+
return JSON.parse(text);
|
|
32295
|
+
}
|
|
32296
|
+
async function fetchHtml(method, path, opt = {}) {
|
|
32297
|
+
const headers = { Accept: "text/html,*/*", ...opt.headers };
|
|
32298
|
+
const res = await send(method, path, { ...opt, headers });
|
|
32299
|
+
if (res.status === 401)
|
|
32300
|
+
throw unauthorized();
|
|
32301
|
+
if (res.status === 429)
|
|
32302
|
+
throw rateLimited();
|
|
32303
|
+
const text = await res.text();
|
|
32304
|
+
if (!res.ok) {
|
|
32305
|
+
throw new ApiError(res.status, formatApiError(res.status, method, path, text, { service }));
|
|
32306
|
+
}
|
|
32307
|
+
return text;
|
|
32308
|
+
}
|
|
32309
|
+
return { fetchJson, fetchHtml };
|
|
32310
|
+
}
|
|
32311
|
+
function buildQueryString(params) {
|
|
32312
|
+
const parts = [];
|
|
32313
|
+
for (const [key, value] of Object.entries(params)) {
|
|
32314
|
+
if (value === void 0 || value === null || value === "")
|
|
32315
|
+
continue;
|
|
32316
|
+
if (Array.isArray(value)) {
|
|
32317
|
+
for (const item of value) {
|
|
32318
|
+
if (item === void 0 || item === null || item === "")
|
|
32319
|
+
continue;
|
|
32320
|
+
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(item))}`);
|
|
32321
|
+
}
|
|
32322
|
+
} else {
|
|
32323
|
+
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
32324
|
+
}
|
|
32325
|
+
}
|
|
32326
|
+
return parts.length > 0 ? `?${parts.join("&")}` : "";
|
|
32327
|
+
}
|
|
32328
|
+
function formatApiError(status, method, path, errorText, opts = {}) {
|
|
32329
|
+
const service = opts.service ?? "API";
|
|
32330
|
+
const head = `${service} error ${status} for ${method.toUpperCase()} ${path}`;
|
|
32331
|
+
const safe = truncateErrorMessage(errorText ?? "", opts.max).trim();
|
|
32332
|
+
return safe.length > 0 ? `${head}: ${safe}` : head;
|
|
32333
|
+
}
|
|
32334
|
+
var DEFAULT_RETRY, defaultSleep, UnauthorizedError, RateLimitedError, RequestTimeoutError, ApiError;
|
|
32182
32335
|
var init_http = __esm({
|
|
32183
32336
|
"node_modules/@chrischall/mcp-utils/dist/http/index.js"() {
|
|
32184
32337
|
init_errors4();
|
|
32338
|
+
init_throttle();
|
|
32339
|
+
DEFAULT_RETRY = { count: 1, delayMs: 2e3 };
|
|
32340
|
+
defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
32341
|
+
UnauthorizedError = class extends Error {
|
|
32342
|
+
status = 401;
|
|
32343
|
+
constructor(service) {
|
|
32344
|
+
super(`Unauthorized (401) from ${service} \u2014 the token is missing, invalid, or expired.`);
|
|
32345
|
+
this.name = "UnauthorizedError";
|
|
32346
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
32347
|
+
}
|
|
32348
|
+
};
|
|
32349
|
+
RateLimitedError = class extends Error {
|
|
32350
|
+
status = 429;
|
|
32351
|
+
constructor(service) {
|
|
32352
|
+
super(`Rate limited (429) by ${service} after retries.`);
|
|
32353
|
+
this.name = "RateLimitedError";
|
|
32354
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
32355
|
+
}
|
|
32356
|
+
};
|
|
32357
|
+
RequestTimeoutError = class extends Error {
|
|
32358
|
+
timeoutMs;
|
|
32359
|
+
constructor(service, timeoutMs) {
|
|
32360
|
+
super(`Request to ${service} timed out after ${timeoutMs}ms.`);
|
|
32361
|
+
this.name = "RequestTimeoutError";
|
|
32362
|
+
this.timeoutMs = timeoutMs;
|
|
32363
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
32364
|
+
}
|
|
32365
|
+
};
|
|
32366
|
+
ApiError = class extends Error {
|
|
32367
|
+
status;
|
|
32368
|
+
constructor(status, message) {
|
|
32369
|
+
super(message);
|
|
32370
|
+
this.name = "ApiError";
|
|
32371
|
+
this.status = status;
|
|
32372
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
32373
|
+
}
|
|
32374
|
+
};
|
|
32185
32375
|
}
|
|
32186
32376
|
});
|
|
32187
32377
|
|
|
@@ -32302,14 +32492,14 @@ var init_clipboard = __esm({
|
|
|
32302
32492
|
init_dist();
|
|
32303
32493
|
|
|
32304
32494
|
// src/version.ts
|
|
32305
|
-
var VERSION = "0.
|
|
32495
|
+
var VERSION = "0.6.0";
|
|
32306
32496
|
|
|
32307
32497
|
// src/tools/models.ts
|
|
32308
32498
|
init_dist();
|
|
32309
32499
|
|
|
32310
32500
|
// src/client.ts
|
|
32311
32501
|
init_dist();
|
|
32312
|
-
import { dirname, join } from "node:path";
|
|
32502
|
+
import { basename, dirname, join } from "node:path";
|
|
32313
32503
|
import { fileURLToPath } from "node:url";
|
|
32314
32504
|
|
|
32315
32505
|
// src/models.ts
|
|
@@ -32329,12 +32519,21 @@ function filterImageModels(raw) {
|
|
|
32329
32519
|
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
32330
32520
|
await loadDotenvSafely({ path: join(__dirname, "..", ".env"), override: false });
|
|
32331
32521
|
var BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
|
|
32332
|
-
var INTERACTIONS_URL = "https://generativelanguage.googleapis.com/v1beta/interactions";
|
|
32333
32522
|
var SERVICE = "Gemini";
|
|
32523
|
+
var INTERACTIONS_SERVICE = "Gemini Interactions";
|
|
32524
|
+
var API_REVISION = "2026-05-20";
|
|
32525
|
+
var REQUEST_TIMEOUT_MS = 6e4;
|
|
32526
|
+
var UPLOAD_BASE_URL = "https://generativelanguage.googleapis.com/upload/v1beta";
|
|
32527
|
+
var FILE_MAX_BYTES = 2 * 1024 ** 3;
|
|
32528
|
+
var FILE_POLL_INTERVAL_MS = 2e3;
|
|
32529
|
+
var FILE_POLL_MAX_ATTEMPTS = 150;
|
|
32334
32530
|
var GeminiClient = class {
|
|
32335
32531
|
apiKey;
|
|
32336
32532
|
configError;
|
|
32533
|
+
api;
|
|
32534
|
+
interactionsApi;
|
|
32337
32535
|
fetchImpl;
|
|
32536
|
+
sleep;
|
|
32338
32537
|
constructor(opts = {}) {
|
|
32339
32538
|
const key = readEnvVar("GEMINI_API_KEY");
|
|
32340
32539
|
if (!key) {
|
|
@@ -32347,6 +32546,20 @@ var GeminiClient = class {
|
|
|
32347
32546
|
this.configError = null;
|
|
32348
32547
|
}
|
|
32349
32548
|
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
32549
|
+
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
32550
|
+
const shared = {
|
|
32551
|
+
tokenHeader: "x-goog-api-key",
|
|
32552
|
+
getToken: () => this.requireKey(),
|
|
32553
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
32554
|
+
fetchImpl: this.fetchImpl
|
|
32555
|
+
};
|
|
32556
|
+
this.api = createApiClient({ baseUrl: BASE_URL, serviceName: SERVICE, ...shared });
|
|
32557
|
+
this.interactionsApi = createApiClient({
|
|
32558
|
+
baseUrl: BASE_URL,
|
|
32559
|
+
serviceName: INTERACTIONS_SERVICE,
|
|
32560
|
+
baseHeaders: { "Api-Revision": API_REVISION },
|
|
32561
|
+
...shared
|
|
32562
|
+
});
|
|
32350
32563
|
}
|
|
32351
32564
|
requireKey() {
|
|
32352
32565
|
if (this.configError) throw this.configError;
|
|
@@ -32357,17 +32570,94 @@ var GeminiClient = class {
|
|
|
32357
32570
|
return resolveModel(void 0, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
32358
32571
|
}
|
|
32359
32572
|
async call(method, path, body) {
|
|
32573
|
+
return this.api.fetchJson(method, path, body !== void 0 ? { body } : {});
|
|
32574
|
+
}
|
|
32575
|
+
/**
|
|
32576
|
+
* Upload a local video to the Gemini Files API (resumable protocol) and wait
|
|
32577
|
+
* for it to become usable. Three live-verified steps (docs/GEMINI-API.md
|
|
32578
|
+
* "Files API — local video upload"):
|
|
32579
|
+
*
|
|
32580
|
+
* 1. `POST {UPLOAD_BASE_URL}/files` (command `start`) → the upload session
|
|
32581
|
+
* URL arrives in the `x-goog-upload-url` RESPONSE HEADER (body is empty).
|
|
32582
|
+
* 2. `POST <session url>` (command `upload, finalize`) with the bytes —
|
|
32583
|
+
* a file-backed Blob (`fileBlob`/`fs.openAsBlob`), so fetch STREAMS the
|
|
32584
|
+
* video from disk; it is never buffered in memory. Response: `{file:…}`.
|
|
32585
|
+
* 3. Poll `GET /v1beta/files/<id>` while `state` is `PROCESSING`, until
|
|
32586
|
+
* `ACTIVE` (the poll response is the File object UNWRAPPED).
|
|
32587
|
+
*
|
|
32588
|
+
* These two upload calls are raw `fetchImpl` (not `createApiClient`): step 1
|
|
32589
|
+
* needs the response *header* and step 2 posts a binary body — neither fits
|
|
32590
|
+
* `fetchJson`. The poll DOES go through the shared client (timeout + retry).
|
|
32591
|
+
* Uploads deliberately have no abort timeout: a multi-hundred-MB video can
|
|
32592
|
+
* legitimately take longer than any fixed budget.
|
|
32593
|
+
*
|
|
32594
|
+
* Returned files live ~48h (`expirationTime`); per-file cap is 2 GB
|
|
32595
|
+
* (enforced locally before any bytes are sent).
|
|
32596
|
+
*/
|
|
32597
|
+
async uploadVideo(path, mimeType) {
|
|
32360
32598
|
const key = this.requireKey();
|
|
32361
|
-
const
|
|
32362
|
-
|
|
32363
|
-
|
|
32364
|
-
|
|
32599
|
+
const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: "Video" });
|
|
32600
|
+
const startPath = "/files";
|
|
32601
|
+
const startRes = await this.fetchImpl(`${UPLOAD_BASE_URL}${startPath}`, {
|
|
32602
|
+
method: "POST",
|
|
32603
|
+
headers: {
|
|
32604
|
+
"x-goog-api-key": key,
|
|
32605
|
+
"X-Goog-Upload-Protocol": "resumable",
|
|
32606
|
+
"X-Goog-Upload-Command": "start",
|
|
32607
|
+
"X-Goog-Upload-Header-Content-Length": String(blob.size),
|
|
32608
|
+
"X-Goog-Upload-Header-Content-Type": mimeType,
|
|
32609
|
+
"Content-Type": "application/json"
|
|
32610
|
+
},
|
|
32611
|
+
body: JSON.stringify({ file: { display_name: basename(path) } })
|
|
32365
32612
|
});
|
|
32366
|
-
if (!
|
|
32367
|
-
|
|
32368
|
-
|
|
32613
|
+
if (!startRes.ok) {
|
|
32614
|
+
throw new McpToolError(
|
|
32615
|
+
formatApiError(startRes.status, "POST", `/upload/v1beta${startPath}`, await startRes.text(), { service: SERVICE })
|
|
32616
|
+
);
|
|
32369
32617
|
}
|
|
32370
|
-
|
|
32618
|
+
const uploadUrl = startRes.headers.get("x-goog-upload-url");
|
|
32619
|
+
if (!uploadUrl) {
|
|
32620
|
+
throw new McpToolError(`${SERVICE} upload start did not return an x-goog-upload-url header`, {
|
|
32621
|
+
hint: "The Files API resumable-upload contract may have changed \u2014 see docs/GEMINI-API.md."
|
|
32622
|
+
});
|
|
32623
|
+
}
|
|
32624
|
+
const upRes = await this.fetchImpl(uploadUrl, {
|
|
32625
|
+
method: "POST",
|
|
32626
|
+
headers: {
|
|
32627
|
+
"X-Goog-Upload-Offset": "0",
|
|
32628
|
+
"X-Goog-Upload-Command": "upload, finalize"
|
|
32629
|
+
},
|
|
32630
|
+
body: blob
|
|
32631
|
+
});
|
|
32632
|
+
if (!upRes.ok) {
|
|
32633
|
+
throw new McpToolError(
|
|
32634
|
+
formatApiError(upRes.status, "POST", "/upload/v1beta/files (finalize)", await upRes.text(), { service: SERVICE })
|
|
32635
|
+
);
|
|
32636
|
+
}
|
|
32637
|
+
let file2 = (await upRes.json()).file ?? {};
|
|
32638
|
+
const name = file2.name ?? "";
|
|
32639
|
+
if (!/^files\/[\w-]+$/.test(name)) {
|
|
32640
|
+
throw new McpToolError(`${SERVICE} upload returned an unexpected file name: ${name || "(none)"}`);
|
|
32641
|
+
}
|
|
32642
|
+
for (let attempt = 0; file2.state === "PROCESSING"; attempt++) {
|
|
32643
|
+
if (attempt >= FILE_POLL_MAX_ATTEMPTS) {
|
|
32644
|
+
throw new McpToolError(`${SERVICE} video file ${name} is still processing after ${FILE_POLL_INTERVAL_MS * FILE_POLL_MAX_ATTEMPTS / 6e4} minutes`, {
|
|
32645
|
+
hint: "Very long videos can take a while to process \u2014 retry later by passing the file uri as video_url."
|
|
32646
|
+
});
|
|
32647
|
+
}
|
|
32648
|
+
await this.sleep(FILE_POLL_INTERVAL_MS);
|
|
32649
|
+
file2 = await this.call("GET", `/${name}`);
|
|
32650
|
+
}
|
|
32651
|
+
if (file2.state !== "ACTIVE") {
|
|
32652
|
+
throw new McpToolError(
|
|
32653
|
+
`${SERVICE} video file processing failed (state ${file2.state ?? "unknown"})${file2.error?.message ? `: ${file2.error.message}` : ""}`,
|
|
32654
|
+
{ hint: "Check the video is a supported format/codec and under 2 GB." }
|
|
32655
|
+
);
|
|
32656
|
+
}
|
|
32657
|
+
if (!file2.uri) {
|
|
32658
|
+
throw new McpToolError(`${SERVICE} did not return a uri for uploaded file ${name}`);
|
|
32659
|
+
}
|
|
32660
|
+
return { name, uri: file2.uri, mimeType: file2.mimeType ?? mimeType, expirationTime: file2.expirationTime };
|
|
32371
32661
|
}
|
|
32372
32662
|
async listModels() {
|
|
32373
32663
|
const data = await this.call("GET", "/models?pageSize=200");
|
|
@@ -32380,7 +32670,7 @@ var GeminiClient = class {
|
|
|
32380
32670
|
parts.push({ inline_data: { mime_type: img.mimeType, data: img.base64 } });
|
|
32381
32671
|
}
|
|
32382
32672
|
if (opts.videoUrl) {
|
|
32383
|
-
parts.push({ file_data: { file_uri: opts.videoUrl, mime_type: "video/mp4" } });
|
|
32673
|
+
parts.push({ file_data: { file_uri: opts.videoUrl, mime_type: opts.videoMimeType ?? "video/mp4" } });
|
|
32384
32674
|
}
|
|
32385
32675
|
const generationConfig = { responseModalities: ["TEXT", "IMAGE"] };
|
|
32386
32676
|
if (opts.aspectRatio || opts.imageSize) {
|
|
@@ -32431,14 +32721,13 @@ var GeminiClient = class {
|
|
|
32431
32721
|
return { images, text, grounding };
|
|
32432
32722
|
}
|
|
32433
32723
|
async interact(opts) {
|
|
32434
|
-
const key = this.requireKey();
|
|
32435
32724
|
const model = resolveModel(opts.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
32436
32725
|
const inputParts = [{ type: "text", text: opts.input }];
|
|
32437
32726
|
for (const img of opts.images ?? []) {
|
|
32438
32727
|
inputParts.push({ type: "image", mime_type: img.mimeType, data: img.base64 });
|
|
32439
32728
|
}
|
|
32440
32729
|
if (opts.videoUrl) {
|
|
32441
|
-
inputParts.push({ type: "video", uri: opts.videoUrl, mime_type: "video/mp4" });
|
|
32730
|
+
inputParts.push({ type: "video", uri: opts.videoUrl, mime_type: opts.videoMimeType ?? "video/mp4" });
|
|
32442
32731
|
}
|
|
32443
32732
|
const responseFormat = { type: "image", mime_type: "image/jpeg" };
|
|
32444
32733
|
if (opts.aspectRatio) responseFormat.aspect_ratio = opts.aspectRatio;
|
|
@@ -32447,26 +32736,9 @@ var GeminiClient = class {
|
|
|
32447
32736
|
if (opts.thinkingLevel !== void 0) body.generation_config = { thinking_level: opts.thinkingLevel };
|
|
32448
32737
|
if (opts.previousInteractionId !== void 0) body.previous_interaction_id = opts.previousInteractionId;
|
|
32449
32738
|
if (opts.googleSearch) body.tools = [{ type: "google_search" }];
|
|
32450
|
-
const
|
|
32451
|
-
|
|
32452
|
-
headers: {
|
|
32453
|
-
"x-goog-api-key": key,
|
|
32454
|
-
"content-type": "application/json",
|
|
32455
|
-
"Api-Revision": "2026-05-20"
|
|
32456
|
-
},
|
|
32457
|
-
body: JSON.stringify(body)
|
|
32739
|
+
const data = await this.interactionsApi.fetchJson("POST", "/interactions", {
|
|
32740
|
+
body
|
|
32458
32741
|
});
|
|
32459
|
-
if (!res.ok) {
|
|
32460
|
-
const text = await res.text().catch(() => "");
|
|
32461
|
-
let message = text;
|
|
32462
|
-
try {
|
|
32463
|
-
const parsed = JSON.parse(text);
|
|
32464
|
-
if (parsed.error?.message) message = parsed.error.message;
|
|
32465
|
-
} catch {
|
|
32466
|
-
}
|
|
32467
|
-
throw new McpToolError(`Gemini Interactions API ${res.status}: ${truncateErrorMessage(message)}`);
|
|
32468
|
-
}
|
|
32469
|
-
const data = await res.json();
|
|
32470
32742
|
const images = [];
|
|
32471
32743
|
const textParts = [];
|
|
32472
32744
|
const searchQueries = [];
|
|
@@ -32562,7 +32834,7 @@ async function writeImage(dir, base, base643, mimeType) {
|
|
|
32562
32834
|
await writeFile(path, Buffer.from(base643, "base64"));
|
|
32563
32835
|
return resolve(path);
|
|
32564
32836
|
}
|
|
32565
|
-
function
|
|
32837
|
+
function resolveInputPath(p, kind) {
|
|
32566
32838
|
if (isAbsolute(p) && existsSync(p)) return p;
|
|
32567
32839
|
const inputDir = readEnvVar("GEMINI_INPUT_DIR");
|
|
32568
32840
|
if (inputDir) {
|
|
@@ -32571,12 +32843,40 @@ function resolveImagePath(p) {
|
|
|
32571
32843
|
}
|
|
32572
32844
|
const cwd = resolve(p);
|
|
32573
32845
|
if (existsSync(cwd)) return cwd;
|
|
32574
|
-
const hint =
|
|
32846
|
+
const hint = `Set GEMINI_INPUT_DIR to a directory containing your ${kind.toLowerCase()}s, or pass an absolute path.`;
|
|
32575
32847
|
throw new McpToolError(
|
|
32576
|
-
|
|
32848
|
+
`${kind} not found: ${p}` + (inputDir ? ` (also searched GEMINI_INPUT_DIR=${inputDir})` : ""),
|
|
32577
32849
|
{ hint }
|
|
32578
32850
|
);
|
|
32579
32851
|
}
|
|
32852
|
+
function resolveImagePath(p) {
|
|
32853
|
+
return resolveInputPath(p, "Image");
|
|
32854
|
+
}
|
|
32855
|
+
function resolveVideoPath(p) {
|
|
32856
|
+
return resolveInputPath(p, "Video");
|
|
32857
|
+
}
|
|
32858
|
+
var VIDEO_MIME_BY_EXT = {
|
|
32859
|
+
mp4: "video/mp4",
|
|
32860
|
+
mpeg: "video/mpeg",
|
|
32861
|
+
mpg: "video/mpg",
|
|
32862
|
+
mov: "video/mov",
|
|
32863
|
+
avi: "video/avi",
|
|
32864
|
+
flv: "video/x-flv",
|
|
32865
|
+
webm: "video/webm",
|
|
32866
|
+
wmv: "video/wmv",
|
|
32867
|
+
"3gp": "video/3gpp",
|
|
32868
|
+
"3gpp": "video/3gpp"
|
|
32869
|
+
};
|
|
32870
|
+
function videoMimeType(p) {
|
|
32871
|
+
const ext = p.includes(".") ? p.slice(p.lastIndexOf(".") + 1).toLowerCase() : "";
|
|
32872
|
+
const mime = VIDEO_MIME_BY_EXT[ext];
|
|
32873
|
+
if (!mime) {
|
|
32874
|
+
throw new McpToolError(`Unsupported video format: ${ext ? `.${ext}` : p}`, {
|
|
32875
|
+
hint: `Supported extensions: ${Object.keys(VIDEO_MIME_BY_EXT).map((e) => `.${e}`).join(", ")}`
|
|
32876
|
+
});
|
|
32877
|
+
}
|
|
32878
|
+
return mime;
|
|
32879
|
+
}
|
|
32580
32880
|
async function readImageAsInline(path) {
|
|
32581
32881
|
const resolved = resolveImagePath(path);
|
|
32582
32882
|
const buf = await readFile2(resolved);
|
|
@@ -32621,7 +32921,10 @@ init_dist();
|
|
|
32621
32921
|
var 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"];
|
|
32622
32922
|
var IMAGE_SIZES = ["512", "1K", "2K", "4K"];
|
|
32623
32923
|
var sharedImageSchema = {
|
|
32624
|
-
|
|
32924
|
+
// Bare id only: the model is interpolated into the URL path
|
|
32925
|
+
// (`/models/${model}:generateContent`), so slashes/colons/queries must be
|
|
32926
|
+
// rejected to keep a crafted value from escaping the path segment.
|
|
32927
|
+
model: external_exports.string().regex(/^[\w.-]+$/, 'must be a bare model id (letters, digits, ".", "_", "-")').optional().describe("Model id override (default: server default; see gemini_list_models)"),
|
|
32625
32928
|
aspect_ratio: external_exports.enum(ASPECT_RATIOS).optional().describe("Output aspect ratio"),
|
|
32626
32929
|
image_size: external_exports.enum(IMAGE_SIZES).optional().describe("Output resolution (512 = 0.5K, Flash-only)"),
|
|
32627
32930
|
output_dir: external_exports.string().optional().describe("Directory to write images to (default: $GEMINI_OUTPUT_DIR or cwd)"),
|
|
@@ -32631,6 +32934,20 @@ var sharedImageSchema = {
|
|
|
32631
32934
|
google_search: external_exports.boolean().optional().describe("Ground the image in live Google Search results (current events, weather, data)"),
|
|
32632
32935
|
from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)")
|
|
32633
32936
|
};
|
|
32937
|
+
async function resolveVideoInput(args) {
|
|
32938
|
+
if (args.video_url && args.video_path) {
|
|
32939
|
+
throw new McpToolError("Provide `video_url` OR `video_path`, not both.");
|
|
32940
|
+
}
|
|
32941
|
+
if (!args.video_path) return { videoUrl: args.video_url };
|
|
32942
|
+
const path = resolveVideoPath(args.video_path);
|
|
32943
|
+
const uploaded = await client.uploadVideo(path, videoMimeType(args.video_path));
|
|
32944
|
+
const videoFileMeta = { uri: uploaded.uri, name: uploaded.name };
|
|
32945
|
+
if (uploaded.expirationTime) videoFileMeta.expires = uploaded.expirationTime;
|
|
32946
|
+
return { videoUrl: uploaded.uri, videoMimeType: uploaded.mimeType, videoFileMeta };
|
|
32947
|
+
}
|
|
32948
|
+
var videoPathSchema = external_exports.string().optional().describe(
|
|
32949
|
+
"Path to a local video file \u2014 uploaded to the Gemini Files API (~48h retention, 2 GB max) and used as the video reference. Alternative to video_url."
|
|
32950
|
+
);
|
|
32634
32951
|
function pickSeed(seed) {
|
|
32635
32952
|
return seed ?? Math.floor(Math.random() * 2147483e3);
|
|
32636
32953
|
}
|
|
@@ -32669,7 +32986,8 @@ function registerGenerateTools(server) {
|
|
|
32669
32986
|
filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified prompt)"),
|
|
32670
32987
|
images: external_exports.array(external_exports.string().min(1)).optional().describe("Paths to reference input images (image-conditioned generation)"),
|
|
32671
32988
|
images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs"),
|
|
32672
|
-
video_url: external_exports.string().url().optional().describe("Public YouTube URL as a video reference (video\u2192image; use a Flash model e.g. gemini-3.1-flash-image)"),
|
|
32989
|
+
video_url: external_exports.string().url().optional().describe("Public YouTube URL (or a previously uploaded Files API uri) as a video reference (video\u2192image; use a Flash model e.g. gemini-3.1-flash-image)"),
|
|
32990
|
+
video_path: videoPathSchema,
|
|
32673
32991
|
...sharedImageSchema
|
|
32674
32992
|
}
|
|
32675
32993
|
},
|
|
@@ -32679,6 +32997,7 @@ function registerGenerateTools(server) {
|
|
|
32679
32997
|
const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
32680
32998
|
const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
|
|
32681
32999
|
const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
33000
|
+
const video = await resolveVideoInput(args);
|
|
32682
33001
|
const named = [];
|
|
32683
33002
|
let capturedText;
|
|
32684
33003
|
let capturedGrounding;
|
|
@@ -32692,7 +33011,8 @@ function registerGenerateTools(server) {
|
|
|
32692
33011
|
seed: seed + i,
|
|
32693
33012
|
thinkingLevel: args.thinking_level,
|
|
32694
33013
|
googleSearch: args.google_search,
|
|
32695
|
-
videoUrl:
|
|
33014
|
+
videoUrl: video.videoUrl,
|
|
33015
|
+
videoMimeType: video.videoMimeType
|
|
32696
33016
|
});
|
|
32697
33017
|
const { images: [img], text } = result;
|
|
32698
33018
|
if (text && !capturedText) capturedText = text;
|
|
@@ -32702,6 +33022,7 @@ function registerGenerateTools(server) {
|
|
|
32702
33022
|
const meta3 = buildMeta(model, seed, args);
|
|
32703
33023
|
if (capturedText) meta3.text = capturedText;
|
|
32704
33024
|
if (capturedGrounding) meta3.grounding = capturedGrounding;
|
|
33025
|
+
if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
|
|
32705
33026
|
return emit(named, args, meta3);
|
|
32706
33027
|
}
|
|
32707
33028
|
);
|
|
@@ -32829,12 +33150,14 @@ function registerInteractTools(server) {
|
|
|
32829
33150
|
output_dir: external_exports.string().optional().describe("Directory to write images to (default: $GEMINI_OUTPUT_DIR or cwd)"),
|
|
32830
33151
|
inline: external_exports.boolean().optional().describe("Return base64 images inline instead of writing to disk"),
|
|
32831
33152
|
google_search: external_exports.boolean().optional().describe("Ground the image in live Google Search results (current events, weather, data)"),
|
|
32832
|
-
video_url: external_exports.string().url().optional().describe("Public YouTube URL as a video reference (video\u2192image; use a Flash model e.g. gemini-3.1-flash-image)"),
|
|
33153
|
+
video_url: external_exports.string().url().optional().describe("Public YouTube URL (or a previously uploaded Files API uri) as a video reference (video\u2192image; use a Flash model e.g. gemini-3.1-flash-image)"),
|
|
33154
|
+
video_path: videoPathSchema,
|
|
32833
33155
|
from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)")
|
|
32834
33156
|
}
|
|
32835
33157
|
},
|
|
32836
33158
|
async (args) => {
|
|
32837
33159
|
const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
33160
|
+
const video = await resolveVideoInput(args);
|
|
32838
33161
|
const r = await client.interact({
|
|
32839
33162
|
input: args.input,
|
|
32840
33163
|
images: inputs.length ? inputs : void 0,
|
|
@@ -32844,12 +33167,14 @@ function registerInteractTools(server) {
|
|
|
32844
33167
|
thinkingLevel: args.thinking_level,
|
|
32845
33168
|
previousInteractionId: args.previous_interaction_id,
|
|
32846
33169
|
googleSearch: args.google_search,
|
|
32847
|
-
videoUrl:
|
|
33170
|
+
videoUrl: video.videoUrl,
|
|
33171
|
+
videoMimeType: video.videoMimeType
|
|
32848
33172
|
});
|
|
32849
33173
|
const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
32850
33174
|
const meta3 = { model, interaction_id: r.id };
|
|
32851
33175
|
if (r.text) meta3.text = r.text;
|
|
32852
33176
|
if (r.grounding) meta3.grounding = r.grounding;
|
|
33177
|
+
if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
|
|
32853
33178
|
const slug = args.filename ? baseName(args.filename) : slugify2(args.input);
|
|
32854
33179
|
const named = r.images.map((image, i) => ({
|
|
32855
33180
|
image,
|
package/dist/client.js
CHANGED
|
@@ -1,16 +1,36 @@
|
|
|
1
|
-
import { dirname, join } from 'node:path';
|
|
1
|
+
import { basename, dirname, join } from 'node:path';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import { loadDotenvSafely, readEnvVar, McpToolError,
|
|
3
|
+
import { loadDotenvSafely, readEnvVar, McpToolError, createApiClient, formatApiError, fileBlob } from '@chrischall/mcp-utils';
|
|
4
4
|
import { resolveModel, filterImageModels } from './models.js';
|
|
5
5
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
6
|
await loadDotenvSafely({ path: join(__dirname, '..', '.env'), override: false });
|
|
7
7
|
const BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'; // v1 lacks gemini-3-pro-image; confirmed via Task 5
|
|
8
|
-
const INTERACTIONS_URL = 'https://generativelanguage.googleapis.com/v1beta/interactions';
|
|
9
8
|
const SERVICE = 'Gemini';
|
|
9
|
+
const INTERACTIONS_SERVICE = 'Gemini Interactions';
|
|
10
|
+
// Required revision header for the beta Interactions API (verified 2026-06-08;
|
|
11
|
+
// see docs/GEMINI-API.md) — load-bearing, requests 400 without it.
|
|
12
|
+
const API_REVISION = '2026-05-20';
|
|
13
|
+
// Per-request abort budget. Image generation routinely runs 30s+ on the Pro
|
|
14
|
+
// model, so this is deliberately 60s (NOT the fleet's usual 15–30s).
|
|
15
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
16
|
+
// Files API (local-video upload — docs/GEMINI-API.md "Files API — local video
|
|
17
|
+
// upload", verified 2026-06-12). The resumable upload endpoint lives under
|
|
18
|
+
// `/upload/v1beta`, NOT the normal `/v1beta` base.
|
|
19
|
+
const UPLOAD_BASE_URL = 'https://generativelanguage.googleapis.com/upload/v1beta';
|
|
20
|
+
// Documented Files API per-file cap (2 GB); enforced locally so a too-big video
|
|
21
|
+
// fails fast instead of uploading gigabytes just to be rejected.
|
|
22
|
+
const FILE_MAX_BYTES = 2 * 1024 ** 3;
|
|
23
|
+
// PROCESSING→ACTIVE poll: every 2s for up to 5 minutes. A 2s clip went ACTIVE
|
|
24
|
+
// in ~1s (verified); longer videos take proportionally longer to process.
|
|
25
|
+
const FILE_POLL_INTERVAL_MS = 2_000;
|
|
26
|
+
const FILE_POLL_MAX_ATTEMPTS = 150;
|
|
10
27
|
export class GeminiClient {
|
|
11
28
|
apiKey;
|
|
12
29
|
configError;
|
|
30
|
+
api;
|
|
31
|
+
interactionsApi;
|
|
13
32
|
fetchImpl;
|
|
33
|
+
sleep;
|
|
14
34
|
constructor(opts = {}) {
|
|
15
35
|
const key = readEnvVar('GEMINI_API_KEY');
|
|
16
36
|
if (!key) {
|
|
@@ -23,7 +43,29 @@ export class GeminiClient {
|
|
|
23
43
|
this.apiKey = key;
|
|
24
44
|
this.configError = null;
|
|
25
45
|
}
|
|
46
|
+
// Both paths share: the key in the `x-goog-api-key` header (Gemini does not
|
|
47
|
+
// use `Authorization: Bearer`), a 60s abort budget, and the default 429
|
|
48
|
+
// retry (once after 2s — the free tier rate-limits aggressively).
|
|
49
|
+
// `getToken` calls requireKey() so key resolution stays at REQUEST time:
|
|
50
|
+
// the server boots without a key and the actionable config error (with its
|
|
51
|
+
// aistudio hint) surfaces on the first tool call, not at startup.
|
|
26
52
|
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
53
|
+
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
54
|
+
const shared = {
|
|
55
|
+
tokenHeader: 'x-goog-api-key',
|
|
56
|
+
getToken: () => this.requireKey(),
|
|
57
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
58
|
+
fetchImpl: this.fetchImpl,
|
|
59
|
+
};
|
|
60
|
+
this.api = createApiClient({ baseUrl: BASE_URL, serviceName: SERVICE, ...shared });
|
|
61
|
+
// Separate client for the beta Interactions API: same base URL, but every
|
|
62
|
+
// request must carry the Api-Revision header (and errors name the API).
|
|
63
|
+
this.interactionsApi = createApiClient({
|
|
64
|
+
baseUrl: BASE_URL,
|
|
65
|
+
serviceName: INTERACTIONS_SERVICE,
|
|
66
|
+
baseHeaders: { 'Api-Revision': API_REVISION },
|
|
67
|
+
...shared,
|
|
68
|
+
});
|
|
27
69
|
}
|
|
28
70
|
requireKey() {
|
|
29
71
|
if (this.configError)
|
|
@@ -35,17 +77,94 @@ export class GeminiClient {
|
|
|
35
77
|
return resolveModel(undefined, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
36
78
|
}
|
|
37
79
|
async call(method, path, body) {
|
|
80
|
+
return this.api.fetchJson(method, path, body !== undefined ? { body } : {});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Upload a local video to the Gemini Files API (resumable protocol) and wait
|
|
84
|
+
* for it to become usable. Three live-verified steps (docs/GEMINI-API.md
|
|
85
|
+
* "Files API — local video upload"):
|
|
86
|
+
*
|
|
87
|
+
* 1. `POST {UPLOAD_BASE_URL}/files` (command `start`) → the upload session
|
|
88
|
+
* URL arrives in the `x-goog-upload-url` RESPONSE HEADER (body is empty).
|
|
89
|
+
* 2. `POST <session url>` (command `upload, finalize`) with the bytes —
|
|
90
|
+
* a file-backed Blob (`fileBlob`/`fs.openAsBlob`), so fetch STREAMS the
|
|
91
|
+
* video from disk; it is never buffered in memory. Response: `{file:…}`.
|
|
92
|
+
* 3. Poll `GET /v1beta/files/<id>` while `state` is `PROCESSING`, until
|
|
93
|
+
* `ACTIVE` (the poll response is the File object UNWRAPPED).
|
|
94
|
+
*
|
|
95
|
+
* These two upload calls are raw `fetchImpl` (not `createApiClient`): step 1
|
|
96
|
+
* needs the response *header* and step 2 posts a binary body — neither fits
|
|
97
|
+
* `fetchJson`. The poll DOES go through the shared client (timeout + retry).
|
|
98
|
+
* Uploads deliberately have no abort timeout: a multi-hundred-MB video can
|
|
99
|
+
* legitimately take longer than any fixed budget.
|
|
100
|
+
*
|
|
101
|
+
* Returned files live ~48h (`expirationTime`); per-file cap is 2 GB
|
|
102
|
+
* (enforced locally before any bytes are sent).
|
|
103
|
+
*/
|
|
104
|
+
async uploadVideo(path, mimeType) {
|
|
38
105
|
const key = this.requireKey();
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
106
|
+
const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: 'Video' });
|
|
107
|
+
// 1. start — establish the resumable upload session
|
|
108
|
+
const startPath = '/files';
|
|
109
|
+
const startRes = await this.fetchImpl(`${UPLOAD_BASE_URL}${startPath}`, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: {
|
|
112
|
+
'x-goog-api-key': key,
|
|
113
|
+
'X-Goog-Upload-Protocol': 'resumable',
|
|
114
|
+
'X-Goog-Upload-Command': 'start',
|
|
115
|
+
'X-Goog-Upload-Header-Content-Length': String(blob.size),
|
|
116
|
+
'X-Goog-Upload-Header-Content-Type': mimeType,
|
|
117
|
+
'Content-Type': 'application/json',
|
|
118
|
+
},
|
|
119
|
+
body: JSON.stringify({ file: { display_name: basename(path) } }),
|
|
120
|
+
});
|
|
121
|
+
if (!startRes.ok) {
|
|
122
|
+
throw new McpToolError(formatApiError(startRes.status, 'POST', `/upload/v1beta${startPath}`, await startRes.text(), { service: SERVICE }));
|
|
123
|
+
}
|
|
124
|
+
const uploadUrl = startRes.headers.get('x-goog-upload-url');
|
|
125
|
+
if (!uploadUrl) {
|
|
126
|
+
throw new McpToolError(`${SERVICE} upload start did not return an x-goog-upload-url header`, {
|
|
127
|
+
hint: 'The Files API resumable-upload contract may have changed — see docs/GEMINI-API.md.',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
// 2. upload + finalize — single shot; the session URL is self-authorizing
|
|
131
|
+
// (no api-key header needed; verified live).
|
|
132
|
+
const upRes = await this.fetchImpl(uploadUrl, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: {
|
|
135
|
+
'X-Goog-Upload-Offset': '0',
|
|
136
|
+
'X-Goog-Upload-Command': 'upload, finalize',
|
|
137
|
+
},
|
|
138
|
+
body: blob,
|
|
43
139
|
});
|
|
44
|
-
if (!
|
|
45
|
-
|
|
46
|
-
throw new McpToolError(`${SERVICE} API ${res.status}: ${truncateErrorMessage(text)}`);
|
|
140
|
+
if (!upRes.ok) {
|
|
141
|
+
throw new McpToolError(formatApiError(upRes.status, 'POST', '/upload/v1beta/files (finalize)', await upRes.text(), { service: SERVICE }));
|
|
47
142
|
}
|
|
48
|
-
|
|
143
|
+
let file = (await upRes.json()).file ?? {};
|
|
144
|
+
// The server echoes the resource name (`files/<id>`); it is interpolated
|
|
145
|
+
// into the poll path below, so reject anything that could escape the
|
|
146
|
+
// `/files/` segment (same posture as the model-id guard).
|
|
147
|
+
const name = file.name ?? '';
|
|
148
|
+
if (!/^files\/[\w-]+$/.test(name)) {
|
|
149
|
+
throw new McpToolError(`${SERVICE} upload returned an unexpected file name: ${name || '(none)'}`);
|
|
150
|
+
}
|
|
151
|
+
// 3. poll PROCESSING → ACTIVE
|
|
152
|
+
for (let attempt = 0; file.state === 'PROCESSING'; attempt++) {
|
|
153
|
+
if (attempt >= FILE_POLL_MAX_ATTEMPTS) {
|
|
154
|
+
throw new McpToolError(`${SERVICE} video file ${name} is still processing after ${(FILE_POLL_INTERVAL_MS * FILE_POLL_MAX_ATTEMPTS) / 60_000} minutes`, {
|
|
155
|
+
hint: 'Very long videos can take a while to process — retry later by passing the file uri as video_url.',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
await this.sleep(FILE_POLL_INTERVAL_MS);
|
|
159
|
+
file = await this.call('GET', `/${name}`);
|
|
160
|
+
}
|
|
161
|
+
if (file.state !== 'ACTIVE') {
|
|
162
|
+
throw new McpToolError(`${SERVICE} video file processing failed (state ${file.state ?? 'unknown'})${file.error?.message ? `: ${file.error.message}` : ''}`, { hint: 'Check the video is a supported format/codec and under 2 GB.' });
|
|
163
|
+
}
|
|
164
|
+
if (!file.uri) {
|
|
165
|
+
throw new McpToolError(`${SERVICE} did not return a uri for uploaded file ${name}`);
|
|
166
|
+
}
|
|
167
|
+
return { name, uri: file.uri, mimeType: file.mimeType ?? mimeType, expirationTime: file.expirationTime };
|
|
49
168
|
}
|
|
50
169
|
async listModels() {
|
|
51
170
|
const data = await this.call('GET', '/models?pageSize=200');
|
|
@@ -58,7 +177,7 @@ export class GeminiClient {
|
|
|
58
177
|
parts.push({ inline_data: { mime_type: img.mimeType, data: img.base64 } });
|
|
59
178
|
}
|
|
60
179
|
if (opts.videoUrl) {
|
|
61
|
-
parts.push({ file_data: { file_uri: opts.videoUrl, mime_type: 'video/mp4' } });
|
|
180
|
+
parts.push({ file_data: { file_uri: opts.videoUrl, mime_type: opts.videoMimeType ?? 'video/mp4' } });
|
|
62
181
|
}
|
|
63
182
|
const generationConfig = { responseModalities: ['TEXT', 'IMAGE'] };
|
|
64
183
|
if (opts.aspectRatio || opts.imageSize) {
|
|
@@ -115,14 +234,13 @@ export class GeminiClient {
|
|
|
115
234
|
return { images, text, grounding };
|
|
116
235
|
}
|
|
117
236
|
async interact(opts) {
|
|
118
|
-
const key = this.requireKey();
|
|
119
237
|
const model = resolveModel(opts.model, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
120
238
|
const inputParts = [{ type: 'text', text: opts.input }];
|
|
121
239
|
for (const img of opts.images ?? []) {
|
|
122
240
|
inputParts.push({ type: 'image', mime_type: img.mimeType, data: img.base64 });
|
|
123
241
|
}
|
|
124
242
|
if (opts.videoUrl) {
|
|
125
|
-
inputParts.push({ type: 'video', uri: opts.videoUrl, mime_type: 'video/mp4' });
|
|
243
|
+
inputParts.push({ type: 'video', uri: opts.videoUrl, mime_type: opts.videoMimeType ?? 'video/mp4' });
|
|
126
244
|
}
|
|
127
245
|
const responseFormat = { type: 'image', mime_type: 'image/jpeg' };
|
|
128
246
|
if (opts.aspectRatio)
|
|
@@ -136,27 +254,9 @@ export class GeminiClient {
|
|
|
136
254
|
body.previous_interaction_id = opts.previousInteractionId;
|
|
137
255
|
if (opts.googleSearch)
|
|
138
256
|
body.tools = [{ type: 'google_search' }];
|
|
139
|
-
const
|
|
140
|
-
|
|
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),
|
|
257
|
+
const data = await this.interactionsApi.fetchJson('POST', '/interactions', {
|
|
258
|
+
body,
|
|
147
259
|
});
|
|
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
260
|
// Only surface the `model_output` step — that's the caller-facing result.
|
|
161
261
|
// `thought` steps hold internal reasoning (and, with includeThoughts, draft
|
|
162
262
|
// "thinking" images); collecting those would leak reasoning into `text` and
|
package/dist/images.js
CHANGED
|
@@ -58,13 +58,13 @@ export async function writeImage(dir, base, base64, mimeType) {
|
|
|
58
58
|
return resolve(path);
|
|
59
59
|
}
|
|
60
60
|
/**
|
|
61
|
-
* Resolve an
|
|
61
|
+
* Resolve an input file path, checking (in order):
|
|
62
62
|
* 1. Absolute path that exists → returned as-is.
|
|
63
63
|
* 2. `GEMINI_INPUT_DIR` env var is set → look for `join(inputDir, p)`.
|
|
64
64
|
* 3. Relative to cwd → `resolve(p)`.
|
|
65
|
-
* Throws a helpful `McpToolError` if none are found.
|
|
65
|
+
* Throws a helpful `McpToolError` (labelled `kind`) if none are found.
|
|
66
66
|
*/
|
|
67
|
-
|
|
67
|
+
function resolveInputPath(p, kind) {
|
|
68
68
|
if (isAbsolute(p) && existsSync(p))
|
|
69
69
|
return p;
|
|
70
70
|
const inputDir = readEnvVar('GEMINI_INPUT_DIR');
|
|
@@ -78,8 +78,41 @@ export function resolveImagePath(p) {
|
|
|
78
78
|
const cwd = resolve(p);
|
|
79
79
|
if (existsSync(cwd))
|
|
80
80
|
return cwd;
|
|
81
|
-
const hint =
|
|
82
|
-
throw new McpToolError(
|
|
81
|
+
const hint = `Set GEMINI_INPUT_DIR to a directory containing your ${kind.toLowerCase()}s, or pass an absolute path.`;
|
|
82
|
+
throw new McpToolError(`${kind} not found: ${p}` + (inputDir ? ` (also searched GEMINI_INPUT_DIR=${inputDir})` : ''), { hint });
|
|
83
|
+
}
|
|
84
|
+
/** Resolve an image path (absolute → GEMINI_INPUT_DIR → cwd). */
|
|
85
|
+
export function resolveImagePath(p) {
|
|
86
|
+
return resolveInputPath(p, 'Image');
|
|
87
|
+
}
|
|
88
|
+
/** Resolve a local video path (absolute → GEMINI_INPUT_DIR → cwd). */
|
|
89
|
+
export function resolveVideoPath(p) {
|
|
90
|
+
return resolveInputPath(p, 'Video');
|
|
91
|
+
}
|
|
92
|
+
/** Video MIME types the Gemini Files API accepts, by file extension
|
|
93
|
+
* (docs/GEMINI-API.md "Files API — local video upload"). */
|
|
94
|
+
const VIDEO_MIME_BY_EXT = {
|
|
95
|
+
mp4: 'video/mp4',
|
|
96
|
+
mpeg: 'video/mpeg',
|
|
97
|
+
mpg: 'video/mpg',
|
|
98
|
+
mov: 'video/mov',
|
|
99
|
+
avi: 'video/avi',
|
|
100
|
+
flv: 'video/x-flv',
|
|
101
|
+
webm: 'video/webm',
|
|
102
|
+
wmv: 'video/wmv',
|
|
103
|
+
'3gp': 'video/3gpp',
|
|
104
|
+
'3gpp': 'video/3gpp',
|
|
105
|
+
};
|
|
106
|
+
/** MIME type for a video file from its extension; throws for unsupported formats. */
|
|
107
|
+
export function videoMimeType(p) {
|
|
108
|
+
const ext = p.includes('.') ? p.slice(p.lastIndexOf('.') + 1).toLowerCase() : '';
|
|
109
|
+
const mime = VIDEO_MIME_BY_EXT[ext];
|
|
110
|
+
if (!mime) {
|
|
111
|
+
throw new McpToolError(`Unsupported video format: ${ext ? `.${ext}` : p}`, {
|
|
112
|
+
hint: `Supported extensions: ${Object.keys(VIDEO_MIME_BY_EXT).map((e) => `.${e}`).join(', ')}`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return mime;
|
|
83
116
|
}
|
|
84
117
|
/** Read an image file into `{ base64, mimeType }` for an inline_data part. */
|
|
85
118
|
export async function readImageAsInline(path) {
|
package/dist/tools/generate.js
CHANGED
|
@@ -3,7 +3,7 @@ import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
|
|
|
3
3
|
import { resolveModel } from '../models.js';
|
|
4
4
|
import { client } from '../client.js';
|
|
5
5
|
import { slugify, baseName, gatherImageInputs } from '../images.js';
|
|
6
|
-
import { emit, sharedImageSchema, pickSeed, buildMeta } from './shared.js';
|
|
6
|
+
import { emit, sharedImageSchema, pickSeed, buildMeta, resolveVideoInput, videoPathSchema } from './shared.js';
|
|
7
7
|
export function registerGenerateTools(server) {
|
|
8
8
|
server.registerTool('gemini_generate_image', {
|
|
9
9
|
description: 'Generate image(s) from a text prompt with a Gemini image model (Nano Banana / Nano Banana Pro).',
|
|
@@ -14,7 +14,8 @@ export function registerGenerateTools(server) {
|
|
|
14
14
|
filename: z.string().optional().describe('Base filename for the output image (extension stripped; default: slugified prompt)'),
|
|
15
15
|
images: z.array(z.string().min(1)).optional().describe('Paths to reference input images (image-conditioned generation)'),
|
|
16
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)'),
|
|
17
|
+
video_url: z.string().url().optional().describe('Public YouTube URL (or a previously uploaded Files API uri) as a video reference (video→image; use a Flash model e.g. gemini-3.1-flash-image)'),
|
|
18
|
+
video_path: videoPathSchema,
|
|
18
19
|
...sharedImageSchema,
|
|
19
20
|
},
|
|
20
21
|
}, async (args) => {
|
|
@@ -23,6 +24,9 @@ export function registerGenerateTools(server) {
|
|
|
23
24
|
const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
24
25
|
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
25
26
|
const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
27
|
+
// Upload ONCE (before the count loop) — every generate call references
|
|
28
|
+
// the same files/… uri.
|
|
29
|
+
const video = await resolveVideoInput(args);
|
|
26
30
|
const named = [];
|
|
27
31
|
let capturedText;
|
|
28
32
|
// For count>1, surface the FIRST call's grounding (each call grounds
|
|
@@ -40,7 +44,8 @@ export function registerGenerateTools(server) {
|
|
|
40
44
|
seed: seed + i,
|
|
41
45
|
thinkingLevel: args.thinking_level,
|
|
42
46
|
googleSearch: args.google_search,
|
|
43
|
-
videoUrl:
|
|
47
|
+
videoUrl: video.videoUrl,
|
|
48
|
+
videoMimeType: video.videoMimeType,
|
|
44
49
|
});
|
|
45
50
|
const { images: [img], text } = result;
|
|
46
51
|
if (text && !capturedText)
|
|
@@ -54,6 +59,8 @@ export function registerGenerateTools(server) {
|
|
|
54
59
|
meta.text = capturedText;
|
|
55
60
|
if (capturedGrounding)
|
|
56
61
|
meta.grounding = capturedGrounding;
|
|
62
|
+
if (video.videoFileMeta)
|
|
63
|
+
meta.video_file = video.videoFileMeta;
|
|
57
64
|
return emit(named, args, meta);
|
|
58
65
|
});
|
|
59
66
|
server.registerTool('gemini_edit_image', {
|
package/dist/tools/interact.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readEnvVar } from '@chrischall/mcp-utils';
|
|
|
3
3
|
import { resolveModel } from '../models.js';
|
|
4
4
|
import { client } from '../client.js';
|
|
5
5
|
import { slugify, baseName, gatherImageInputs } from '../images.js';
|
|
6
|
-
import { emit, ASPECT_RATIOS, IMAGE_SIZES } from './shared.js';
|
|
6
|
+
import { emit, ASPECT_RATIOS, IMAGE_SIZES, resolveVideoInput, videoPathSchema } from './shared.js';
|
|
7
7
|
export function registerInteractTools(server) {
|
|
8
8
|
server.registerTool('gemini_interact', {
|
|
9
9
|
description: "Multi-turn image generation/editing via Gemini's Interactions API (Beta). " +
|
|
@@ -58,7 +58,8 @@ export function registerInteractTools(server) {
|
|
|
58
58
|
.string()
|
|
59
59
|
.url()
|
|
60
60
|
.optional()
|
|
61
|
-
.describe('Public YouTube URL as a video reference (video→image; use a Flash model e.g. gemini-3.1-flash-image)'),
|
|
61
|
+
.describe('Public YouTube URL (or a previously uploaded Files API uri) as a video reference (video→image; use a Flash model e.g. gemini-3.1-flash-image)'),
|
|
62
|
+
video_path: videoPathSchema,
|
|
62
63
|
from_clipboard: z
|
|
63
64
|
.boolean()
|
|
64
65
|
.optional()
|
|
@@ -66,6 +67,7 @@ export function registerInteractTools(server) {
|
|
|
66
67
|
},
|
|
67
68
|
}, async (args) => {
|
|
68
69
|
const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
70
|
+
const video = await resolveVideoInput(args);
|
|
69
71
|
const r = await client.interact({
|
|
70
72
|
input: args.input,
|
|
71
73
|
images: inputs.length ? inputs : undefined,
|
|
@@ -75,7 +77,8 @@ export function registerInteractTools(server) {
|
|
|
75
77
|
thinkingLevel: args.thinking_level,
|
|
76
78
|
previousInteractionId: args.previous_interaction_id,
|
|
77
79
|
googleSearch: args.google_search,
|
|
78
|
-
videoUrl:
|
|
80
|
+
videoUrl: video.videoUrl,
|
|
81
|
+
videoMimeType: video.videoMimeType,
|
|
79
82
|
});
|
|
80
83
|
const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
|
|
81
84
|
const meta = { model, interaction_id: r.id };
|
|
@@ -83,6 +86,8 @@ export function registerInteractTools(server) {
|
|
|
83
86
|
meta.text = r.text;
|
|
84
87
|
if (r.grounding)
|
|
85
88
|
meta.grounding = r.grounding;
|
|
89
|
+
if (video.videoFileMeta)
|
|
90
|
+
meta.video_file = video.videoFileMeta;
|
|
86
91
|
const slug = args.filename ? baseName(args.filename) : slugify(args.input);
|
|
87
92
|
const named = r.images.map((image, i) => ({
|
|
88
93
|
image,
|
package/dist/tools/shared.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { textResult } from '@chrischall/mcp-utils';
|
|
3
|
-
import {
|
|
2
|
+
import { textResult, McpToolError } from '@chrischall/mcp-utils';
|
|
3
|
+
import { client } from '../client.js';
|
|
4
|
+
import { writeImage, resolveOutputDir, resolveVideoPath, videoMimeType } from '../images.js';
|
|
4
5
|
/** Supported output aspect ratios (Gemini image API). */
|
|
5
6
|
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
7
|
/** Supported output resolutions. `512` is Flash-only (0.5K); `1K`/`2K`/`4K` work on all image models. */
|
|
@@ -11,7 +12,14 @@ export const IMAGE_SIZES = ['512', '1K', '2K', '4K'];
|
|
|
11
12
|
* into each tool's `inputSchema`.
|
|
12
13
|
*/
|
|
13
14
|
export const sharedImageSchema = {
|
|
14
|
-
|
|
15
|
+
// Bare id only: the model is interpolated into the URL path
|
|
16
|
+
// (`/models/${model}:generateContent`), so slashes/colons/queries must be
|
|
17
|
+
// rejected to keep a crafted value from escaping the path segment.
|
|
18
|
+
model: z
|
|
19
|
+
.string()
|
|
20
|
+
.regex(/^[\w.-]+$/, 'must be a bare model id (letters, digits, ".", "_", "-")')
|
|
21
|
+
.optional()
|
|
22
|
+
.describe('Model id override (default: server default; see gemini_list_models)'),
|
|
15
23
|
aspect_ratio: z.enum(ASPECT_RATIOS).optional().describe('Output aspect ratio'),
|
|
16
24
|
image_size: z.enum(IMAGE_SIZES).optional().describe('Output resolution (512 = 0.5K, Flash-only)'),
|
|
17
25
|
output_dir: z.string().optional().describe('Directory to write images to (default: $GEMINI_OUTPUT_DIR or cwd)'),
|
|
@@ -21,6 +29,32 @@ export const sharedImageSchema = {
|
|
|
21
29
|
google_search: z.boolean().optional().describe('Ground the image in live Google Search results (current events, weather, data)'),
|
|
22
30
|
from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)'),
|
|
23
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* Turn the `video_url` / `video_path` tool args into the client's video opts.
|
|
34
|
+
* A `video_path` is resolved locally (absolute → $GEMINI_INPUT_DIR → cwd),
|
|
35
|
+
* uploaded to the Gemini Files API (streamed, never buffered), and waited to
|
|
36
|
+
* `ACTIVE`; the returned `files/…` uri is what the generate call references.
|
|
37
|
+
* The uploaded file (uri + expiry) is echoed via `videoFileMeta` so a caller
|
|
38
|
+
* can pass the uri straight back as `video_url` instead of re-uploading.
|
|
39
|
+
*/
|
|
40
|
+
export async function resolveVideoInput(args) {
|
|
41
|
+
if (args.video_url && args.video_path) {
|
|
42
|
+
throw new McpToolError('Provide `video_url` OR `video_path`, not both.');
|
|
43
|
+
}
|
|
44
|
+
if (!args.video_path)
|
|
45
|
+
return { videoUrl: args.video_url };
|
|
46
|
+
const path = resolveVideoPath(args.video_path);
|
|
47
|
+
const uploaded = await client.uploadVideo(path, videoMimeType(args.video_path));
|
|
48
|
+
const videoFileMeta = { uri: uploaded.uri, name: uploaded.name };
|
|
49
|
+
if (uploaded.expirationTime)
|
|
50
|
+
videoFileMeta.expires = uploaded.expirationTime;
|
|
51
|
+
return { videoUrl: uploaded.uri, videoMimeType: uploaded.mimeType, videoFileMeta };
|
|
52
|
+
}
|
|
53
|
+
/** Shared zod field for the `video_path` tool param. */
|
|
54
|
+
export const videoPathSchema = z
|
|
55
|
+
.string()
|
|
56
|
+
.optional()
|
|
57
|
+
.describe('Path to a local video file — uploaded to the Gemini Files API (~48h retention, 2 GB max) and used as the video reference. Alternative to video_url.');
|
|
24
58
|
/**
|
|
25
59
|
* Return the provided seed, or pick a random one. Capped well below INT32_MAX
|
|
26
60
|
* so derived per-image seeds (`seed + i`) can't overflow a 32-bit int.
|
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.6.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.6.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/gemini-mcp",
|
|
5
5
|
"description": "Gemini image-generation MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"test:coverage": "vitest run --coverage"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@chrischall/mcp-utils": "^0.
|
|
47
|
+
"@chrischall/mcp-utils": "^0.7.0",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
49
49
|
"dotenv": "^17.4.0",
|
|
50
50
|
"zod": "^4.4.2"
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/gemini-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.6.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/gemini-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.6.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|