@chrischall/gemini-mcp 0.5.0 → 0.6.1
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 +192 -16
- package/dist/client.js +107 -5
- 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 +29 -2
- package/dist/version.js +1 -1
- package/package.json +3 -3
- 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.1"
|
|
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.1",
|
|
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
|
@@ -5,11 +5,20 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
|
5
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
6
|
var __getProtoOf = Object.getPrototypeOf;
|
|
7
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
-
var __esm = (fn, res) => function __init() {
|
|
9
|
-
|
|
8
|
+
var __esm = (fn, res, err) => function __init() {
|
|
9
|
+
if (err) throw err[0];
|
|
10
|
+
try {
|
|
11
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
12
|
+
} catch (e) {
|
|
13
|
+
throw err = [e], e;
|
|
14
|
+
}
|
|
10
15
|
};
|
|
11
16
|
var __commonJS = (cb, mod) => function __require() {
|
|
12
|
-
|
|
17
|
+
try {
|
|
18
|
+
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
19
|
+
} catch (e) {
|
|
20
|
+
throw mod = 0, e;
|
|
21
|
+
}
|
|
13
22
|
};
|
|
14
23
|
var __export = (target, all) => {
|
|
15
24
|
for (var name in all)
|
|
@@ -32192,6 +32201,19 @@ var init_config = __esm({
|
|
|
32192
32201
|
});
|
|
32193
32202
|
|
|
32194
32203
|
// node_modules/@chrischall/mcp-utils/dist/fs/index.js
|
|
32204
|
+
import { openAsBlob } from "node:fs";
|
|
32205
|
+
async function fileBlob(path, opts = {}) {
|
|
32206
|
+
let blob;
|
|
32207
|
+
try {
|
|
32208
|
+
blob = await openAsBlob(path, opts.type !== void 0 ? { type: opts.type } : void 0);
|
|
32209
|
+
} catch {
|
|
32210
|
+
throw new Error(`Cannot read file for upload: ${path}`);
|
|
32211
|
+
}
|
|
32212
|
+
if (opts.maxBytes !== void 0 && blob.size > opts.maxBytes) {
|
|
32213
|
+
throw new Error(`${opts.label ?? "File"} is ${blob.size} bytes, over the ${opts.maxBytes}-byte limit: ${path}`);
|
|
32214
|
+
}
|
|
32215
|
+
return blob;
|
|
32216
|
+
}
|
|
32195
32217
|
var init_fs = __esm({
|
|
32196
32218
|
"node_modules/@chrischall/mcp-utils/dist/fs/index.js"() {
|
|
32197
32219
|
}
|
|
@@ -32362,6 +32384,12 @@ var init_http = __esm({
|
|
|
32362
32384
|
}
|
|
32363
32385
|
});
|
|
32364
32386
|
|
|
32387
|
+
// node_modules/@chrischall/mcp-utils/dist/concurrency/index.js
|
|
32388
|
+
var init_concurrency = __esm({
|
|
32389
|
+
"node_modules/@chrischall/mcp-utils/dist/concurrency/index.js"() {
|
|
32390
|
+
}
|
|
32391
|
+
});
|
|
32392
|
+
|
|
32365
32393
|
// node_modules/@chrischall/mcp-utils/dist/dates/index.js
|
|
32366
32394
|
var init_dates = __esm({
|
|
32367
32395
|
"node_modules/@chrischall/mcp-utils/dist/dates/index.js"() {
|
|
@@ -32369,7 +32397,7 @@ var init_dates = __esm({
|
|
|
32369
32397
|
});
|
|
32370
32398
|
|
|
32371
32399
|
// node_modules/@chrischall/mcp-utils/dist/zod/index.js
|
|
32372
|
-
var PositiveInt, NonNegInt, NonEmptyString, IsoDate, IsoTime, schemaOrigin, schemaConfirm, paginationSchema, pageSchema;
|
|
32400
|
+
var PositiveInt, NonNegInt, NonEmptyString, IsoDate, IsoTime, NumericIdString, SafePathSegment, schemaOrigin, schemaConfirm, paginationSchema, pageSchema;
|
|
32373
32401
|
var init_zod2 = __esm({
|
|
32374
32402
|
"node_modules/@chrischall/mcp-utils/dist/zod/index.js"() {
|
|
32375
32403
|
init_zod();
|
|
@@ -32378,6 +32406,8 @@ var init_zod2 = __esm({
|
|
|
32378
32406
|
NonEmptyString = external_exports.string().min(1);
|
|
32379
32407
|
IsoDate = external_exports.iso.date();
|
|
32380
32408
|
IsoTime = external_exports.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, "must be HH:MM (24h), e.g. 19:30");
|
|
32409
|
+
NumericIdString = external_exports.string().regex(/^\d+$/, "must be a numeric id (digits only)").describe("A numeric id string (digits only), safe to interpolate into a URL path.");
|
|
32410
|
+
SafePathSegment = external_exports.string().min(1).regex(/^[^/?#\s]+$/, 'must not contain "/", "?", "#", or whitespace').refine((v) => !v.includes(".."), 'must not contain ".."').describe('A single URL path segment, safe to interpolate: no "/", "..", "?", "#", or whitespace.');
|
|
32381
32411
|
schemaOrigin = external_exports.string().optional().describe("Portal origin (e.g. https://<vendor>.example.co) selecting which active session to use. Optional when only one session is active.");
|
|
32382
32412
|
schemaConfirm = external_exports.boolean().optional().describe("Must be true to proceed. Without this, the tool returns a preview.");
|
|
32383
32413
|
paginationSchema = {
|
|
@@ -32409,6 +32439,7 @@ var init_dist = __esm({
|
|
|
32409
32439
|
init_config();
|
|
32410
32440
|
init_fs();
|
|
32411
32441
|
init_http();
|
|
32442
|
+
init_concurrency();
|
|
32412
32443
|
init_dates();
|
|
32413
32444
|
init_zod2();
|
|
32414
32445
|
init_auth();
|
|
@@ -32479,14 +32510,14 @@ var init_clipboard = __esm({
|
|
|
32479
32510
|
init_dist();
|
|
32480
32511
|
|
|
32481
32512
|
// src/version.ts
|
|
32482
|
-
var VERSION = "0.
|
|
32513
|
+
var VERSION = "0.6.1";
|
|
32483
32514
|
|
|
32484
32515
|
// src/tools/models.ts
|
|
32485
32516
|
init_dist();
|
|
32486
32517
|
|
|
32487
32518
|
// src/client.ts
|
|
32488
32519
|
init_dist();
|
|
32489
|
-
import { dirname, join } from "node:path";
|
|
32520
|
+
import { basename, dirname, join } from "node:path";
|
|
32490
32521
|
import { fileURLToPath } from "node:url";
|
|
32491
32522
|
|
|
32492
32523
|
// src/models.ts
|
|
@@ -32510,11 +32541,17 @@ var SERVICE = "Gemini";
|
|
|
32510
32541
|
var INTERACTIONS_SERVICE = "Gemini Interactions";
|
|
32511
32542
|
var API_REVISION = "2026-05-20";
|
|
32512
32543
|
var REQUEST_TIMEOUT_MS = 6e4;
|
|
32544
|
+
var UPLOAD_BASE_URL = "https://generativelanguage.googleapis.com/upload/v1beta";
|
|
32545
|
+
var FILE_MAX_BYTES = 2 * 1024 ** 3;
|
|
32546
|
+
var FILE_POLL_INTERVAL_MS = 2e3;
|
|
32547
|
+
var FILE_POLL_MAX_ATTEMPTS = 150;
|
|
32513
32548
|
var GeminiClient = class {
|
|
32514
32549
|
apiKey;
|
|
32515
32550
|
configError;
|
|
32516
32551
|
api;
|
|
32517
32552
|
interactionsApi;
|
|
32553
|
+
fetchImpl;
|
|
32554
|
+
sleep;
|
|
32518
32555
|
constructor(opts = {}) {
|
|
32519
32556
|
const key = readEnvVar("GEMINI_API_KEY");
|
|
32520
32557
|
if (!key) {
|
|
@@ -32526,11 +32563,13 @@ var GeminiClient = class {
|
|
|
32526
32563
|
this.apiKey = key;
|
|
32527
32564
|
this.configError = null;
|
|
32528
32565
|
}
|
|
32566
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
32567
|
+
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
32529
32568
|
const shared = {
|
|
32530
32569
|
tokenHeader: "x-goog-api-key",
|
|
32531
32570
|
getToken: () => this.requireKey(),
|
|
32532
32571
|
timeout: REQUEST_TIMEOUT_MS,
|
|
32533
|
-
fetchImpl:
|
|
32572
|
+
fetchImpl: this.fetchImpl
|
|
32534
32573
|
};
|
|
32535
32574
|
this.api = createApiClient({ baseUrl: BASE_URL, serviceName: SERVICE, ...shared });
|
|
32536
32575
|
this.interactionsApi = createApiClient({
|
|
@@ -32551,6 +32590,93 @@ var GeminiClient = class {
|
|
|
32551
32590
|
async call(method, path, body) {
|
|
32552
32591
|
return this.api.fetchJson(method, path, body !== void 0 ? { body } : {});
|
|
32553
32592
|
}
|
|
32593
|
+
/**
|
|
32594
|
+
* Upload a local video to the Gemini Files API (resumable protocol) and wait
|
|
32595
|
+
* for it to become usable. Three live-verified steps (docs/GEMINI-API.md
|
|
32596
|
+
* "Files API — local video upload"):
|
|
32597
|
+
*
|
|
32598
|
+
* 1. `POST {UPLOAD_BASE_URL}/files` (command `start`) → the upload session
|
|
32599
|
+
* URL arrives in the `x-goog-upload-url` RESPONSE HEADER (body is empty).
|
|
32600
|
+
* 2. `POST <session url>` (command `upload, finalize`) with the bytes —
|
|
32601
|
+
* a file-backed Blob (`fileBlob`/`fs.openAsBlob`), so fetch STREAMS the
|
|
32602
|
+
* video from disk; it is never buffered in memory. Response: `{file:…}`.
|
|
32603
|
+
* 3. Poll `GET /v1beta/files/<id>` while `state` is `PROCESSING`, until
|
|
32604
|
+
* `ACTIVE` (the poll response is the File object UNWRAPPED).
|
|
32605
|
+
*
|
|
32606
|
+
* These two upload calls are raw `fetchImpl` (not `createApiClient`): step 1
|
|
32607
|
+
* needs the response *header* and step 2 posts a binary body — neither fits
|
|
32608
|
+
* `fetchJson`. The poll DOES go through the shared client (timeout + retry).
|
|
32609
|
+
* Uploads deliberately have no abort timeout: a multi-hundred-MB video can
|
|
32610
|
+
* legitimately take longer than any fixed budget.
|
|
32611
|
+
*
|
|
32612
|
+
* Returned files live ~48h (`expirationTime`); per-file cap is 2 GB
|
|
32613
|
+
* (enforced locally before any bytes are sent).
|
|
32614
|
+
*/
|
|
32615
|
+
async uploadVideo(path, mimeType) {
|
|
32616
|
+
const key = this.requireKey();
|
|
32617
|
+
const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: "Video" });
|
|
32618
|
+
const startPath = "/files";
|
|
32619
|
+
const startRes = await this.fetchImpl(`${UPLOAD_BASE_URL}${startPath}`, {
|
|
32620
|
+
method: "POST",
|
|
32621
|
+
headers: {
|
|
32622
|
+
"x-goog-api-key": key,
|
|
32623
|
+
"X-Goog-Upload-Protocol": "resumable",
|
|
32624
|
+
"X-Goog-Upload-Command": "start",
|
|
32625
|
+
"X-Goog-Upload-Header-Content-Length": String(blob.size),
|
|
32626
|
+
"X-Goog-Upload-Header-Content-Type": mimeType,
|
|
32627
|
+
"Content-Type": "application/json"
|
|
32628
|
+
},
|
|
32629
|
+
body: JSON.stringify({ file: { display_name: basename(path) } })
|
|
32630
|
+
});
|
|
32631
|
+
if (!startRes.ok) {
|
|
32632
|
+
throw new McpToolError(
|
|
32633
|
+
formatApiError(startRes.status, "POST", `/upload/v1beta${startPath}`, await startRes.text(), { service: SERVICE })
|
|
32634
|
+
);
|
|
32635
|
+
}
|
|
32636
|
+
const uploadUrl = startRes.headers.get("x-goog-upload-url");
|
|
32637
|
+
if (!uploadUrl) {
|
|
32638
|
+
throw new McpToolError(`${SERVICE} upload start did not return an x-goog-upload-url header`, {
|
|
32639
|
+
hint: "The Files API resumable-upload contract may have changed \u2014 see docs/GEMINI-API.md."
|
|
32640
|
+
});
|
|
32641
|
+
}
|
|
32642
|
+
const upRes = await this.fetchImpl(uploadUrl, {
|
|
32643
|
+
method: "POST",
|
|
32644
|
+
headers: {
|
|
32645
|
+
"X-Goog-Upload-Offset": "0",
|
|
32646
|
+
"X-Goog-Upload-Command": "upload, finalize"
|
|
32647
|
+
},
|
|
32648
|
+
body: blob
|
|
32649
|
+
});
|
|
32650
|
+
if (!upRes.ok) {
|
|
32651
|
+
throw new McpToolError(
|
|
32652
|
+
formatApiError(upRes.status, "POST", "/upload/v1beta/files (finalize)", await upRes.text(), { service: SERVICE })
|
|
32653
|
+
);
|
|
32654
|
+
}
|
|
32655
|
+
let file2 = (await upRes.json()).file ?? {};
|
|
32656
|
+
const name = file2.name ?? "";
|
|
32657
|
+
if (!/^files\/[\w-]+$/.test(name)) {
|
|
32658
|
+
throw new McpToolError(`${SERVICE} upload returned an unexpected file name: ${name || "(none)"}`);
|
|
32659
|
+
}
|
|
32660
|
+
for (let attempt = 0; file2.state === "PROCESSING"; attempt++) {
|
|
32661
|
+
if (attempt >= FILE_POLL_MAX_ATTEMPTS) {
|
|
32662
|
+
throw new McpToolError(`${SERVICE} video file ${name} is still processing after ${FILE_POLL_INTERVAL_MS * FILE_POLL_MAX_ATTEMPTS / 6e4} minutes`, {
|
|
32663
|
+
hint: "Very long videos can take a while to process \u2014 retry later by passing the file uri as video_url."
|
|
32664
|
+
});
|
|
32665
|
+
}
|
|
32666
|
+
await this.sleep(FILE_POLL_INTERVAL_MS);
|
|
32667
|
+
file2 = await this.call("GET", `/${name}`);
|
|
32668
|
+
}
|
|
32669
|
+
if (file2.state !== "ACTIVE") {
|
|
32670
|
+
throw new McpToolError(
|
|
32671
|
+
`${SERVICE} video file processing failed (state ${file2.state ?? "unknown"})${file2.error?.message ? `: ${file2.error.message}` : ""}`,
|
|
32672
|
+
{ hint: "Check the video is a supported format/codec and under 2 GB." }
|
|
32673
|
+
);
|
|
32674
|
+
}
|
|
32675
|
+
if (!file2.uri) {
|
|
32676
|
+
throw new McpToolError(`${SERVICE} did not return a uri for uploaded file ${name}`);
|
|
32677
|
+
}
|
|
32678
|
+
return { name, uri: file2.uri, mimeType: file2.mimeType ?? mimeType, expirationTime: file2.expirationTime };
|
|
32679
|
+
}
|
|
32554
32680
|
async listModels() {
|
|
32555
32681
|
const data = await this.call("GET", "/models?pageSize=200");
|
|
32556
32682
|
return filterImageModels(data.models ?? []);
|
|
@@ -32562,7 +32688,7 @@ var GeminiClient = class {
|
|
|
32562
32688
|
parts.push({ inline_data: { mime_type: img.mimeType, data: img.base64 } });
|
|
32563
32689
|
}
|
|
32564
32690
|
if (opts.videoUrl) {
|
|
32565
|
-
parts.push({ file_data: { file_uri: opts.videoUrl, mime_type: "video/mp4" } });
|
|
32691
|
+
parts.push({ file_data: { file_uri: opts.videoUrl, mime_type: opts.videoMimeType ?? "video/mp4" } });
|
|
32566
32692
|
}
|
|
32567
32693
|
const generationConfig = { responseModalities: ["TEXT", "IMAGE"] };
|
|
32568
32694
|
if (opts.aspectRatio || opts.imageSize) {
|
|
@@ -32619,7 +32745,7 @@ var GeminiClient = class {
|
|
|
32619
32745
|
inputParts.push({ type: "image", mime_type: img.mimeType, data: img.base64 });
|
|
32620
32746
|
}
|
|
32621
32747
|
if (opts.videoUrl) {
|
|
32622
|
-
inputParts.push({ type: "video", uri: opts.videoUrl, mime_type: "video/mp4" });
|
|
32748
|
+
inputParts.push({ type: "video", uri: opts.videoUrl, mime_type: opts.videoMimeType ?? "video/mp4" });
|
|
32623
32749
|
}
|
|
32624
32750
|
const responseFormat = { type: "image", mime_type: "image/jpeg" };
|
|
32625
32751
|
if (opts.aspectRatio) responseFormat.aspect_ratio = opts.aspectRatio;
|
|
@@ -32726,7 +32852,7 @@ async function writeImage(dir, base, base643, mimeType) {
|
|
|
32726
32852
|
await writeFile(path, Buffer.from(base643, "base64"));
|
|
32727
32853
|
return resolve(path);
|
|
32728
32854
|
}
|
|
32729
|
-
function
|
|
32855
|
+
function resolveInputPath(p, kind) {
|
|
32730
32856
|
if (isAbsolute(p) && existsSync(p)) return p;
|
|
32731
32857
|
const inputDir = readEnvVar("GEMINI_INPUT_DIR");
|
|
32732
32858
|
if (inputDir) {
|
|
@@ -32735,12 +32861,40 @@ function resolveImagePath(p) {
|
|
|
32735
32861
|
}
|
|
32736
32862
|
const cwd = resolve(p);
|
|
32737
32863
|
if (existsSync(cwd)) return cwd;
|
|
32738
|
-
const hint =
|
|
32864
|
+
const hint = `Set GEMINI_INPUT_DIR to a directory containing your ${kind.toLowerCase()}s, or pass an absolute path.`;
|
|
32739
32865
|
throw new McpToolError(
|
|
32740
|
-
|
|
32866
|
+
`${kind} not found: ${p}` + (inputDir ? ` (also searched GEMINI_INPUT_DIR=${inputDir})` : ""),
|
|
32741
32867
|
{ hint }
|
|
32742
32868
|
);
|
|
32743
32869
|
}
|
|
32870
|
+
function resolveImagePath(p) {
|
|
32871
|
+
return resolveInputPath(p, "Image");
|
|
32872
|
+
}
|
|
32873
|
+
function resolveVideoPath(p) {
|
|
32874
|
+
return resolveInputPath(p, "Video");
|
|
32875
|
+
}
|
|
32876
|
+
var VIDEO_MIME_BY_EXT = {
|
|
32877
|
+
mp4: "video/mp4",
|
|
32878
|
+
mpeg: "video/mpeg",
|
|
32879
|
+
mpg: "video/mpg",
|
|
32880
|
+
mov: "video/mov",
|
|
32881
|
+
avi: "video/avi",
|
|
32882
|
+
flv: "video/x-flv",
|
|
32883
|
+
webm: "video/webm",
|
|
32884
|
+
wmv: "video/wmv",
|
|
32885
|
+
"3gp": "video/3gpp",
|
|
32886
|
+
"3gpp": "video/3gpp"
|
|
32887
|
+
};
|
|
32888
|
+
function videoMimeType(p) {
|
|
32889
|
+
const ext = p.includes(".") ? p.slice(p.lastIndexOf(".") + 1).toLowerCase() : "";
|
|
32890
|
+
const mime = VIDEO_MIME_BY_EXT[ext];
|
|
32891
|
+
if (!mime) {
|
|
32892
|
+
throw new McpToolError(`Unsupported video format: ${ext ? `.${ext}` : p}`, {
|
|
32893
|
+
hint: `Supported extensions: ${Object.keys(VIDEO_MIME_BY_EXT).map((e) => `.${e}`).join(", ")}`
|
|
32894
|
+
});
|
|
32895
|
+
}
|
|
32896
|
+
return mime;
|
|
32897
|
+
}
|
|
32744
32898
|
async function readImageAsInline(path) {
|
|
32745
32899
|
const resolved = resolveImagePath(path);
|
|
32746
32900
|
const buf = await readFile2(resolved);
|
|
@@ -32798,6 +32952,20 @@ var sharedImageSchema = {
|
|
|
32798
32952
|
google_search: external_exports.boolean().optional().describe("Ground the image in live Google Search results (current events, weather, data)"),
|
|
32799
32953
|
from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)")
|
|
32800
32954
|
};
|
|
32955
|
+
async function resolveVideoInput(args) {
|
|
32956
|
+
if (args.video_url && args.video_path) {
|
|
32957
|
+
throw new McpToolError("Provide `video_url` OR `video_path`, not both.");
|
|
32958
|
+
}
|
|
32959
|
+
if (!args.video_path) return { videoUrl: args.video_url };
|
|
32960
|
+
const path = resolveVideoPath(args.video_path);
|
|
32961
|
+
const uploaded = await client.uploadVideo(path, videoMimeType(args.video_path));
|
|
32962
|
+
const videoFileMeta = { uri: uploaded.uri, name: uploaded.name };
|
|
32963
|
+
if (uploaded.expirationTime) videoFileMeta.expires = uploaded.expirationTime;
|
|
32964
|
+
return { videoUrl: uploaded.uri, videoMimeType: uploaded.mimeType, videoFileMeta };
|
|
32965
|
+
}
|
|
32966
|
+
var videoPathSchema = external_exports.string().optional().describe(
|
|
32967
|
+
"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."
|
|
32968
|
+
);
|
|
32801
32969
|
function pickSeed(seed) {
|
|
32802
32970
|
return seed ?? Math.floor(Math.random() * 2147483e3);
|
|
32803
32971
|
}
|
|
@@ -32836,7 +33004,8 @@ function registerGenerateTools(server) {
|
|
|
32836
33004
|
filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified prompt)"),
|
|
32837
33005
|
images: external_exports.array(external_exports.string().min(1)).optional().describe("Paths to reference input images (image-conditioned generation)"),
|
|
32838
33006
|
images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs"),
|
|
32839
|
-
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)"),
|
|
33007
|
+
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)"),
|
|
33008
|
+
video_path: videoPathSchema,
|
|
32840
33009
|
...sharedImageSchema
|
|
32841
33010
|
}
|
|
32842
33011
|
},
|
|
@@ -32846,6 +33015,7 @@ function registerGenerateTools(server) {
|
|
|
32846
33015
|
const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
32847
33016
|
const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
|
|
32848
33017
|
const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
33018
|
+
const video = await resolveVideoInput(args);
|
|
32849
33019
|
const named = [];
|
|
32850
33020
|
let capturedText;
|
|
32851
33021
|
let capturedGrounding;
|
|
@@ -32859,7 +33029,8 @@ function registerGenerateTools(server) {
|
|
|
32859
33029
|
seed: seed + i,
|
|
32860
33030
|
thinkingLevel: args.thinking_level,
|
|
32861
33031
|
googleSearch: args.google_search,
|
|
32862
|
-
videoUrl:
|
|
33032
|
+
videoUrl: video.videoUrl,
|
|
33033
|
+
videoMimeType: video.videoMimeType
|
|
32863
33034
|
});
|
|
32864
33035
|
const { images: [img], text } = result;
|
|
32865
33036
|
if (text && !capturedText) capturedText = text;
|
|
@@ -32869,6 +33040,7 @@ function registerGenerateTools(server) {
|
|
|
32869
33040
|
const meta3 = buildMeta(model, seed, args);
|
|
32870
33041
|
if (capturedText) meta3.text = capturedText;
|
|
32871
33042
|
if (capturedGrounding) meta3.grounding = capturedGrounding;
|
|
33043
|
+
if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
|
|
32872
33044
|
return emit(named, args, meta3);
|
|
32873
33045
|
}
|
|
32874
33046
|
);
|
|
@@ -32996,12 +33168,14 @@ function registerInteractTools(server) {
|
|
|
32996
33168
|
output_dir: external_exports.string().optional().describe("Directory to write images to (default: $GEMINI_OUTPUT_DIR or cwd)"),
|
|
32997
33169
|
inline: external_exports.boolean().optional().describe("Return base64 images inline instead of writing to disk"),
|
|
32998
33170
|
google_search: external_exports.boolean().optional().describe("Ground the image in live Google Search results (current events, weather, data)"),
|
|
32999
|
-
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)"),
|
|
33171
|
+
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)"),
|
|
33172
|
+
video_path: videoPathSchema,
|
|
33000
33173
|
from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)")
|
|
33001
33174
|
}
|
|
33002
33175
|
},
|
|
33003
33176
|
async (args) => {
|
|
33004
33177
|
const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
33178
|
+
const video = await resolveVideoInput(args);
|
|
33005
33179
|
const r = await client.interact({
|
|
33006
33180
|
input: args.input,
|
|
33007
33181
|
images: inputs.length ? inputs : void 0,
|
|
@@ -33011,12 +33185,14 @@ function registerInteractTools(server) {
|
|
|
33011
33185
|
thinkingLevel: args.thinking_level,
|
|
33012
33186
|
previousInteractionId: args.previous_interaction_id,
|
|
33013
33187
|
googleSearch: args.google_search,
|
|
33014
|
-
videoUrl:
|
|
33188
|
+
videoUrl: video.videoUrl,
|
|
33189
|
+
videoMimeType: video.videoMimeType
|
|
33015
33190
|
});
|
|
33016
33191
|
const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
33017
33192
|
const meta3 = { model, interaction_id: r.id };
|
|
33018
33193
|
if (r.text) meta3.text = r.text;
|
|
33019
33194
|
if (r.grounding) meta3.grounding = r.grounding;
|
|
33195
|
+
if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
|
|
33020
33196
|
const slug = args.filename ? baseName(args.filename) : slugify2(args.input);
|
|
33021
33197
|
const named = r.images.map((image, i) => ({
|
|
33022
33198
|
image,
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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, createApiClient } from '@chrischall/mcp-utils';
|
|
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 });
|
|
@@ -13,11 +13,24 @@ const API_REVISION = '2026-05-20';
|
|
|
13
13
|
// Per-request abort budget. Image generation routinely runs 30s+ on the Pro
|
|
14
14
|
// model, so this is deliberately 60s (NOT the fleet's usual 15–30s).
|
|
15
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;
|
|
16
27
|
export class GeminiClient {
|
|
17
28
|
apiKey;
|
|
18
29
|
configError;
|
|
19
30
|
api;
|
|
20
31
|
interactionsApi;
|
|
32
|
+
fetchImpl;
|
|
33
|
+
sleep;
|
|
21
34
|
constructor(opts = {}) {
|
|
22
35
|
const key = readEnvVar('GEMINI_API_KEY');
|
|
23
36
|
if (!key) {
|
|
@@ -36,11 +49,13 @@ export class GeminiClient {
|
|
|
36
49
|
// `getToken` calls requireKey() so key resolution stays at REQUEST time:
|
|
37
50
|
// the server boots without a key and the actionable config error (with its
|
|
38
51
|
// aistudio hint) surfaces on the first tool call, not at startup.
|
|
52
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
53
|
+
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
39
54
|
const shared = {
|
|
40
55
|
tokenHeader: 'x-goog-api-key',
|
|
41
56
|
getToken: () => this.requireKey(),
|
|
42
57
|
timeout: REQUEST_TIMEOUT_MS,
|
|
43
|
-
fetchImpl:
|
|
58
|
+
fetchImpl: this.fetchImpl,
|
|
44
59
|
};
|
|
45
60
|
this.api = createApiClient({ baseUrl: BASE_URL, serviceName: SERVICE, ...shared });
|
|
46
61
|
// Separate client for the beta Interactions API: same base URL, but every
|
|
@@ -64,6 +79,93 @@ export class GeminiClient {
|
|
|
64
79
|
async call(method, path, body) {
|
|
65
80
|
return this.api.fetchJson(method, path, body !== undefined ? { body } : {});
|
|
66
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) {
|
|
105
|
+
const key = this.requireKey();
|
|
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,
|
|
139
|
+
});
|
|
140
|
+
if (!upRes.ok) {
|
|
141
|
+
throw new McpToolError(formatApiError(upRes.status, 'POST', '/upload/v1beta/files (finalize)', await upRes.text(), { service: SERVICE }));
|
|
142
|
+
}
|
|
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 };
|
|
168
|
+
}
|
|
67
169
|
async listModels() {
|
|
68
170
|
const data = await this.call('GET', '/models?pageSize=200');
|
|
69
171
|
return filterImageModels(data.models ?? []);
|
|
@@ -75,7 +177,7 @@ export class GeminiClient {
|
|
|
75
177
|
parts.push({ inline_data: { mime_type: img.mimeType, data: img.base64 } });
|
|
76
178
|
}
|
|
77
179
|
if (opts.videoUrl) {
|
|
78
|
-
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' } });
|
|
79
181
|
}
|
|
80
182
|
const generationConfig = { responseModalities: ['TEXT', 'IMAGE'] };
|
|
81
183
|
if (opts.aspectRatio || opts.imageSize) {
|
|
@@ -138,7 +240,7 @@ export class GeminiClient {
|
|
|
138
240
|
inputParts.push({ type: 'image', mime_type: img.mimeType, data: img.base64 });
|
|
139
241
|
}
|
|
140
242
|
if (opts.videoUrl) {
|
|
141
|
-
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' });
|
|
142
244
|
}
|
|
143
245
|
const responseFormat = { type: 'image', mime_type: 'image/jpeg' };
|
|
144
246
|
if (opts.aspectRatio)
|
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. */
|
|
@@ -28,6 +29,32 @@ export const sharedImageSchema = {
|
|
|
28
29
|
google_search: z.boolean().optional().describe('Ground the image in live Google Search results (current events, weather, data)'),
|
|
29
30
|
from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)'),
|
|
30
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.');
|
|
31
58
|
/**
|
|
32
59
|
* Return the provided seed, or pick a random one. Capped well below INT32_MAX
|
|
33
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.1'; // 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.1",
|
|
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,13 +44,13 @@
|
|
|
44
44
|
"test:coverage": "vitest run --coverage"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@chrischall/mcp-utils": "^0.
|
|
47
|
+
"@chrischall/mcp-utils": "^0.10.3",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
49
49
|
"dotenv": "^17.4.0",
|
|
50
50
|
"zod": "^4.4.2"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@types/node": "^
|
|
53
|
+
"@types/node": "^26.0.0",
|
|
54
54
|
"@vitest/coverage-v8": "^4.1.2",
|
|
55
55
|
"esbuild": "^0.28.0",
|
|
56
56
|
"typescript": "^6.0.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.1",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/gemini-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.6.1",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|