@chrischall/gemini-mcp 1.1.0 → 1.2.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/README.md +3 -2
- package/dist/bundle.js +124 -118
- package/dist/bytes.js +46 -0
- package/dist/fetch-image.js +24 -2
- package/dist/images.js +0 -19
- package/dist/inputs.js +6 -27
- package/dist/storage/media.js +2 -13
- package/dist/tools/files.js +9 -15
- package/dist/tools/interact.js +1 -1
- package/dist/tools/music.js +10 -4
- package/dist/tools/shared.js +2 -1
- package/dist/tools/video.js +10 -4
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/gemini-mcp/SKILL.md +5 -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": "1.
|
|
10
|
+
"version": "1.2.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": "1.
|
|
18
|
+
"version": "1.2.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
package/README.md
CHANGED
|
@@ -89,8 +89,9 @@ progress), two guards make re-issuing safe and unnecessary:
|
|
|
89
89
|
|
|
90
90
|
## Sending reference images without burning context
|
|
91
91
|
|
|
92
|
-
Every image
|
|
93
|
-
`gemini_interact`
|
|
92
|
+
Every tool that takes a reference image — `gemini_image_generate`, `gemini_image_edit`,
|
|
93
|
+
`gemini_image_set`, `gemini_interact`, plus `gemini_video_generate` (reference stills) and
|
|
94
|
+
`gemini_music_generate` — accepts them four ways. Only one of them costs model context:
|
|
94
95
|
|
|
95
96
|
| Parameter | Where the bytes travel | Context cost |
|
|
96
97
|
|---|---|---|
|
package/dist/bundle.js
CHANGED
|
@@ -32548,73 +32548,11 @@ var init_dist = __esm({
|
|
|
32548
32548
|
}
|
|
32549
32549
|
});
|
|
32550
32550
|
|
|
32551
|
-
// src/clipboard.ts
|
|
32552
|
-
var clipboard_exports = {};
|
|
32553
|
-
__export(clipboard_exports, {
|
|
32554
|
-
readClipboardImage: () => readClipboardImage
|
|
32555
|
-
});
|
|
32556
|
-
import { execFile } from "node:child_process";
|
|
32557
|
-
import { promisify } from "node:util";
|
|
32558
|
-
import { readFile, unlink } from "node:fs/promises";
|
|
32559
|
-
import { tmpdir } from "node:os";
|
|
32560
|
-
import { join as join2 } from "node:path";
|
|
32561
|
-
async function readClipboardImage(run = defaultRun) {
|
|
32562
|
-
if (process.platform !== "darwin") {
|
|
32563
|
-
throw new McpToolError("from_clipboard is only supported on macOS", {
|
|
32564
|
-
hint: "Save the image to a file and pass it via `images` instead."
|
|
32565
|
-
});
|
|
32566
|
-
}
|
|
32567
|
-
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
32568
|
-
const png = join2(tmpdir(), `gemini-clip-${id}.png`);
|
|
32569
|
-
const jpg = join2(tmpdir(), `gemini-clip-${id}.jpg`);
|
|
32570
|
-
try {
|
|
32571
|
-
try {
|
|
32572
|
-
await run("osascript", [
|
|
32573
|
-
"-e",
|
|
32574
|
-
`set f to (open for access POSIX file "${png}" with write permission)`,
|
|
32575
|
-
"-e",
|
|
32576
|
-
"write (the clipboard as \xABclass PNGf\xBB) to f",
|
|
32577
|
-
"-e",
|
|
32578
|
-
"close access f"
|
|
32579
|
-
]);
|
|
32580
|
-
} catch {
|
|
32581
|
-
throw new McpToolError("No image found on the clipboard", {
|
|
32582
|
-
hint: "Copy an image (\u2318C) first, or pass `images`/`images_base64`."
|
|
32583
|
-
});
|
|
32584
|
-
}
|
|
32585
|
-
try {
|
|
32586
|
-
await run("sips", ["-Z", "2048", "-s", "format", "jpeg", png, "--out", jpg]);
|
|
32587
|
-
} catch {
|
|
32588
|
-
throw new McpToolError("Failed to convert the clipboard image", {
|
|
32589
|
-
hint: "The clipboard may contain a non-standard image format."
|
|
32590
|
-
});
|
|
32591
|
-
}
|
|
32592
|
-
const buf = await readFile(jpg);
|
|
32593
|
-
return { base64: buf.toString("base64"), mimeType: "image/jpeg" };
|
|
32594
|
-
} finally {
|
|
32595
|
-
await unlink(png).catch(() => void 0);
|
|
32596
|
-
await unlink(jpg).catch(() => void 0);
|
|
32597
|
-
}
|
|
32598
|
-
}
|
|
32599
|
-
var execFileAsync, defaultRun;
|
|
32600
|
-
var init_clipboard = __esm({
|
|
32601
|
-
"src/clipboard.ts"() {
|
|
32602
|
-
"use strict";
|
|
32603
|
-
init_dist();
|
|
32604
|
-
execFileAsync = promisify(execFile);
|
|
32605
|
-
defaultRun = async (cmd, args) => {
|
|
32606
|
-
await execFileAsync(cmd, args);
|
|
32607
|
-
};
|
|
32608
|
-
}
|
|
32609
|
-
});
|
|
32610
|
-
|
|
32611
32551
|
// src/images.ts
|
|
32612
32552
|
var images_exports = {};
|
|
32613
32553
|
__export(images_exports, {
|
|
32614
32554
|
baseName: () => baseName,
|
|
32615
32555
|
decodeImageInput: () => decodeImageInput,
|
|
32616
|
-
gatherImageInputs: () => gatherImageInputs,
|
|
32617
|
-
loadImageInputs: () => loadImageInputs,
|
|
32618
32556
|
mediaExt: () => mediaExt,
|
|
32619
32557
|
previewImageInput: () => previewImageInput,
|
|
32620
32558
|
previewVideoInput: () => previewVideoInput,
|
|
@@ -32629,9 +32567,9 @@ __export(images_exports, {
|
|
|
32629
32567
|
writeMedia: () => writeMedia,
|
|
32630
32568
|
writeSidecar: () => writeSidecar
|
|
32631
32569
|
});
|
|
32632
|
-
import { mkdir, readFile
|
|
32570
|
+
import { mkdir, readFile, writeFile, access, stat, open } from "node:fs/promises";
|
|
32633
32571
|
import { existsSync } from "node:fs";
|
|
32634
|
-
import { join as
|
|
32572
|
+
import { join as join2, resolve, isAbsolute } from "node:path";
|
|
32635
32573
|
function slugify2(text, max = 40) {
|
|
32636
32574
|
const s = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/g, "");
|
|
32637
32575
|
return s || "image";
|
|
@@ -32645,10 +32583,10 @@ async function exists(p) {
|
|
|
32645
32583
|
}
|
|
32646
32584
|
}
|
|
32647
32585
|
async function uniquePath(dir, base, ext) {
|
|
32648
|
-
let candidate =
|
|
32586
|
+
let candidate = join2(dir, `${base}.${ext}`);
|
|
32649
32587
|
let n = 2;
|
|
32650
32588
|
while (await exists(candidate)) {
|
|
32651
|
-
candidate =
|
|
32589
|
+
candidate = join2(dir, `${base}-${n}.${ext}`);
|
|
32652
32590
|
n++;
|
|
32653
32591
|
}
|
|
32654
32592
|
return candidate;
|
|
@@ -32694,7 +32632,7 @@ function resolveInputPath(p, kind) {
|
|
|
32694
32632
|
if (isAbsolute(p) && existsSync(p)) return p;
|
|
32695
32633
|
const inputDir = readEnvVar("GEMINI_INPUT_DIR");
|
|
32696
32634
|
if (inputDir) {
|
|
32697
|
-
const candidate =
|
|
32635
|
+
const candidate = join2(inputDir, p);
|
|
32698
32636
|
if (existsSync(candidate)) return resolve(candidate);
|
|
32699
32637
|
}
|
|
32700
32638
|
const cwd = resolve(p);
|
|
@@ -32740,7 +32678,7 @@ async function previewVideoInput(path) {
|
|
|
32740
32678
|
}
|
|
32741
32679
|
async function readImageAsInline(path) {
|
|
32742
32680
|
const resolved = resolveImagePath(path);
|
|
32743
|
-
const buf = await
|
|
32681
|
+
const buf = await readFile(resolved);
|
|
32744
32682
|
const mimeType = sniffMimeBytes(buf);
|
|
32745
32683
|
return { base64: buf.toString("base64"), mimeType };
|
|
32746
32684
|
}
|
|
@@ -32757,16 +32695,6 @@ function decodeImageInput(input) {
|
|
|
32757
32695
|
const buf = Buffer.from(trimmed, "base64");
|
|
32758
32696
|
return { base64: trimmed, mimeType: sniffMimeBytes(buf) };
|
|
32759
32697
|
}
|
|
32760
|
-
async function loadImageInputs(paths = [], base64s = []) {
|
|
32761
|
-
const fromPaths = await Promise.all(paths.map((p) => readImageAsInline(p)));
|
|
32762
|
-
const fromBase64s = base64s.map((b) => decodeImageInput(b));
|
|
32763
|
-
return [...fromPaths, ...fromBase64s];
|
|
32764
|
-
}
|
|
32765
|
-
async function gatherImageInputs(opts) {
|
|
32766
|
-
const clipboardImages = opts.from_clipboard ? [await (await Promise.resolve().then(() => (init_clipboard(), clipboard_exports))).readClipboardImage()] : [];
|
|
32767
|
-
const fileImages = await loadImageInputs(opts.images, opts.images_base64);
|
|
32768
|
-
return [...clipboardImages, ...fileImages];
|
|
32769
|
-
}
|
|
32770
32698
|
function baseName(name) {
|
|
32771
32699
|
const stripped = name.replace(/\.(png|jpe?g|webp|mp4|mov|webm|mp3|wav|ogg)$/i, "");
|
|
32772
32700
|
const safe = stripped.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
@@ -32795,6 +32723,66 @@ var init_images = __esm({
|
|
|
32795
32723
|
}
|
|
32796
32724
|
});
|
|
32797
32725
|
|
|
32726
|
+
// src/clipboard.ts
|
|
32727
|
+
var clipboard_exports = {};
|
|
32728
|
+
__export(clipboard_exports, {
|
|
32729
|
+
readClipboardImage: () => readClipboardImage
|
|
32730
|
+
});
|
|
32731
|
+
import { execFile } from "node:child_process";
|
|
32732
|
+
import { promisify } from "node:util";
|
|
32733
|
+
import { readFile as readFile2, unlink } from "node:fs/promises";
|
|
32734
|
+
import { tmpdir } from "node:os";
|
|
32735
|
+
import { join as join3 } from "node:path";
|
|
32736
|
+
async function readClipboardImage(run = defaultRun) {
|
|
32737
|
+
if (process.platform !== "darwin") {
|
|
32738
|
+
throw new McpToolError("from_clipboard is only supported on macOS", {
|
|
32739
|
+
hint: "Save the image to a file and pass it via `images` instead."
|
|
32740
|
+
});
|
|
32741
|
+
}
|
|
32742
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
32743
|
+
const png = join3(tmpdir(), `gemini-clip-${id}.png`);
|
|
32744
|
+
const jpg = join3(tmpdir(), `gemini-clip-${id}.jpg`);
|
|
32745
|
+
try {
|
|
32746
|
+
try {
|
|
32747
|
+
await run("osascript", [
|
|
32748
|
+
"-e",
|
|
32749
|
+
`set f to (open for access POSIX file "${png}" with write permission)`,
|
|
32750
|
+
"-e",
|
|
32751
|
+
"write (the clipboard as \xABclass PNGf\xBB) to f",
|
|
32752
|
+
"-e",
|
|
32753
|
+
"close access f"
|
|
32754
|
+
]);
|
|
32755
|
+
} catch {
|
|
32756
|
+
throw new McpToolError("No image found on the clipboard", {
|
|
32757
|
+
hint: "Copy an image (\u2318C) first, or pass `images`/`images_base64`."
|
|
32758
|
+
});
|
|
32759
|
+
}
|
|
32760
|
+
try {
|
|
32761
|
+
await run("sips", ["-Z", "2048", "-s", "format", "jpeg", png, "--out", jpg]);
|
|
32762
|
+
} catch {
|
|
32763
|
+
throw new McpToolError("Failed to convert the clipboard image", {
|
|
32764
|
+
hint: "The clipboard may contain a non-standard image format."
|
|
32765
|
+
});
|
|
32766
|
+
}
|
|
32767
|
+
const buf = await readFile2(jpg);
|
|
32768
|
+
return { base64: buf.toString("base64"), mimeType: "image/jpeg" };
|
|
32769
|
+
} finally {
|
|
32770
|
+
await unlink(png).catch(() => void 0);
|
|
32771
|
+
await unlink(jpg).catch(() => void 0);
|
|
32772
|
+
}
|
|
32773
|
+
}
|
|
32774
|
+
var execFileAsync, defaultRun;
|
|
32775
|
+
var init_clipboard = __esm({
|
|
32776
|
+
"src/clipboard.ts"() {
|
|
32777
|
+
"use strict";
|
|
32778
|
+
init_dist();
|
|
32779
|
+
execFileAsync = promisify(execFile);
|
|
32780
|
+
defaultRun = async (cmd, args) => {
|
|
32781
|
+
await execFileAsync(cmd, args);
|
|
32782
|
+
};
|
|
32783
|
+
}
|
|
32784
|
+
});
|
|
32785
|
+
|
|
32798
32786
|
// src/index.ts
|
|
32799
32787
|
init_dist();
|
|
32800
32788
|
|
|
@@ -32808,7 +32796,7 @@ async function loadStdioDotenv() {
|
|
|
32808
32796
|
}
|
|
32809
32797
|
|
|
32810
32798
|
// src/version.ts
|
|
32811
|
-
var VERSION = "1.
|
|
32799
|
+
var VERSION = "1.2.0";
|
|
32812
32800
|
|
|
32813
32801
|
// src/tools/models.ts
|
|
32814
32802
|
init_dist();
|
|
@@ -32853,6 +32841,30 @@ init_dist();
|
|
|
32853
32841
|
|
|
32854
32842
|
// src/fetch-image.ts
|
|
32855
32843
|
init_dist();
|
|
32844
|
+
|
|
32845
|
+
// src/bytes.ts
|
|
32846
|
+
function base64ToBytes(base643) {
|
|
32847
|
+
const binary = atob(base643);
|
|
32848
|
+
const bytes = new Uint8Array(binary.length);
|
|
32849
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
32850
|
+
return bytes;
|
|
32851
|
+
}
|
|
32852
|
+
function bytesToBase64(bytes) {
|
|
32853
|
+
const CHUNK = 32768;
|
|
32854
|
+
let binary = "";
|
|
32855
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
32856
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
32857
|
+
}
|
|
32858
|
+
return btoa(binary);
|
|
32859
|
+
}
|
|
32860
|
+
function wholeMb(bytes) {
|
|
32861
|
+
return Math.round(bytes / (1024 * 1024));
|
|
32862
|
+
}
|
|
32863
|
+
function formatMb(bytes) {
|
|
32864
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
32865
|
+
}
|
|
32866
|
+
|
|
32867
|
+
// src/fetch-image.ts
|
|
32856
32868
|
var IMAGE_URL_MAX_BYTES = 15 * 1024 * 1024;
|
|
32857
32869
|
var IMAGE_INLINE_MAX_BYTES = 6 * 1024 * 1024;
|
|
32858
32870
|
var MAX_REDIRECTS = 5;
|
|
@@ -32955,6 +32967,14 @@ function assertPublicHttpsUrl(raw, requested) {
|
|
|
32955
32967
|
}
|
|
32956
32968
|
return url2;
|
|
32957
32969
|
}
|
|
32970
|
+
function fileNameFromUrl(url2, fallback) {
|
|
32971
|
+
try {
|
|
32972
|
+
const name = new URL(url2).pathname.split("/").filter(Boolean).pop();
|
|
32973
|
+
return name ? decodeURIComponent(name).slice(0, 120) : fallback;
|
|
32974
|
+
} catch {
|
|
32975
|
+
return fallback;
|
|
32976
|
+
}
|
|
32977
|
+
}
|
|
32958
32978
|
function describeUrl(current, requested) {
|
|
32959
32979
|
return current === requested ? current : `${current} (redirected from ${requested})`;
|
|
32960
32980
|
}
|
|
@@ -33008,9 +33028,8 @@ function fetchFailure(err, current, requested, timeoutMs) {
|
|
|
33008
33028
|
);
|
|
33009
33029
|
}
|
|
33010
33030
|
function tooBig(url2, requested, size, max, partial2 = false) {
|
|
33011
|
-
const mb2 = (n) => `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
33012
33031
|
return new McpToolError(
|
|
33013
|
-
`Image at ${describeUrl(url2, requested)} is ${partial2 ? "over" : ""} ${
|
|
33032
|
+
`Image at ${describeUrl(url2, requested)} is ${partial2 ? "over" : ""} ${formatMb(size)}, above the ${formatMb(max)} limit for images_url.`,
|
|
33014
33033
|
{ hint: "Resize or re-encode the image, or upload it yourself with gemini_upload_file and pass the returned file_uri." }
|
|
33015
33034
|
);
|
|
33016
33035
|
}
|
|
@@ -33143,7 +33162,7 @@ async function resolveLocalPath(path, client2, onDisk, report) {
|
|
|
33143
33162
|
const inline = await readImageAsInline2(resolved);
|
|
33144
33163
|
if (references < 2) return inline;
|
|
33145
33164
|
try {
|
|
33146
|
-
const bytes =
|
|
33165
|
+
const bytes = base64ToBytes(inline.base64);
|
|
33147
33166
|
const displayName = resolved.split(/[\\/]/).pop() || "image";
|
|
33148
33167
|
const uploaded = await client2.uploadBytes(bytes, inline.mimeType, displayName);
|
|
33149
33168
|
client2.session.uploadCache.set(key, {
|
|
@@ -33167,29 +33186,13 @@ async function resolveUrl(url2, client2, report) {
|
|
|
33167
33186
|
};
|
|
33168
33187
|
(report.fetched_urls ??= []).push(entry);
|
|
33169
33188
|
if (fetched.size <= IMAGE_INLINE_MAX_BYTES) {
|
|
33170
|
-
return { base64:
|
|
33189
|
+
return { base64: bytesToBase64(fetched.bytes), mimeType: fetched.mimeType };
|
|
33171
33190
|
}
|
|
33172
|
-
const displayName = fileNameFromUrl(url2);
|
|
33191
|
+
const displayName = fileNameFromUrl(url2, "image");
|
|
33173
33192
|
const uploaded = await client2.uploadBytes(fetched.bytes, fetched.mimeType, displayName);
|
|
33174
33193
|
entry.file_uri = uploaded.name;
|
|
33175
33194
|
return { uri: uploaded.uri, mimeType: uploaded.mimeType };
|
|
33176
33195
|
}
|
|
33177
|
-
function fileNameFromUrl(url2) {
|
|
33178
|
-
try {
|
|
33179
|
-
const name = new URL(url2).pathname.split("/").filter(Boolean).pop();
|
|
33180
|
-
return name ? decodeURIComponent(name).slice(0, 120) : "image";
|
|
33181
|
-
} catch {
|
|
33182
|
-
return "image";
|
|
33183
|
-
}
|
|
33184
|
-
}
|
|
33185
|
-
function encodeBase64(bytes) {
|
|
33186
|
-
let binary = "";
|
|
33187
|
-
const CHUNK = 32768;
|
|
33188
|
-
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
33189
|
-
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
33190
|
-
}
|
|
33191
|
-
return btoa(binary);
|
|
33192
|
-
}
|
|
33193
33196
|
function requireImageInput(args) {
|
|
33194
33197
|
if (hasImageInput(args)) return;
|
|
33195
33198
|
throw new McpToolError(
|
|
@@ -33234,7 +33237,7 @@ var asyncSchema = external_exports.boolean().optional().describe(
|
|
|
33234
33237
|
);
|
|
33235
33238
|
function imagesUrlSchema(label = "Reference images") {
|
|
33236
33239
|
return external_exports.array(external_exports.string().url()).optional().describe(
|
|
33237
|
-
`${label} as public https URLs \u2014 the SERVER downloads them, so no image bytes travel through the conversation. Preferred over images_base64, which costs ~14k tokens per photo and breaks if a file read was truncated. Max ${
|
|
33240
|
+
`${label} as public https URLs \u2014 the SERVER downloads them, so no image bytes travel through the conversation. Preferred over images_base64, which costs ~14k tokens per photo and breaks if a file read was truncated. Max ${wholeMb(IMAGE_URL_MAX_BYTES)}MB each; must be a directly-linked image (Content-Type image/*).`
|
|
33238
33241
|
);
|
|
33239
33242
|
}
|
|
33240
33243
|
function imagesFileUrisSchema(label = "Reference images") {
|
|
@@ -34675,7 +34678,9 @@ function registerVideoTools(server, client2) {
|
|
|
34675
34678
|
aspect_ratio: external_exports.enum(VIDEO_ASPECT_RATIOS).optional().describe("Output aspect ratio (omni: 16:9 or 9:16)"),
|
|
34676
34679
|
task: external_exports.enum(VIDEO_TASKS).optional().describe("text_to_video (default), image_to_video / reference_to_video (need image input), or edit (needs previous_interaction_id)"),
|
|
34677
34680
|
images: external_exports.array(external_exports.string().min(1)).optional().describe("Reference image path(s) for image_to_video / reference_to_video"),
|
|
34678
|
-
|
|
34681
|
+
images_url: imagesUrlSchema("Reference stills"),
|
|
34682
|
+
images_file_uris: imagesFileUrisSchema("Reference stills"),
|
|
34683
|
+
images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation"),
|
|
34679
34684
|
from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS clipboard as a reference"),
|
|
34680
34685
|
filename: external_exports.string().optional().describe("Base filename for the output video (extension stripped; default: slugified prompt)"),
|
|
34681
34686
|
output_dir: external_exports.string().optional().describe("Directory to write the video to (default: $GEMINI_OUTPUT_DIR or cwd)"),
|
|
@@ -34710,10 +34715,12 @@ function registerVideoTools(server, client2) {
|
|
|
34710
34715
|
images: args.images,
|
|
34711
34716
|
images_base64: args.images_base64,
|
|
34712
34717
|
from_clipboard: args.from_clipboard,
|
|
34718
|
+
images_url: args.images_url,
|
|
34719
|
+
images_file_uris: args.images_file_uris,
|
|
34713
34720
|
previous_interaction_id: previousInteractionId
|
|
34714
34721
|
});
|
|
34715
34722
|
return client2.session.jobs.dispatch({ toolName: "gemini_video_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
34716
|
-
const inputs = await
|
|
34723
|
+
const { inputs, report } = await resolveImageInputs(args, client2);
|
|
34717
34724
|
const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client2.generateVideo({
|
|
34718
34725
|
input: args.prompt,
|
|
34719
34726
|
images: inputs.length ? inputs : void 0,
|
|
@@ -34727,6 +34734,7 @@ function registerVideoTools(server, client2) {
|
|
|
34727
34734
|
const meta3 = { model, interaction_id: r.id };
|
|
34728
34735
|
if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
|
|
34729
34736
|
if (r.text) meta3.text = r.text;
|
|
34737
|
+
if (report) meta3.image_inputs = report;
|
|
34730
34738
|
meta3.hint = `To edit this video, call gemini_video_generate again with task: "edit" and previous_interaction_id: "${r.id}" (or continue_last: true).`;
|
|
34731
34739
|
const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
|
|
34732
34740
|
const named = r.videos.map((media, i) => ({
|
|
@@ -34759,7 +34767,9 @@ function registerMusicTools(server, client2) {
|
|
|
34759
34767
|
model: external_exports.enum(MUSIC_MODELS).optional().describe(`Lyria model (default: ${DEFAULT_MUSIC_MODEL}). Pro is longer-form and supports WAV.`),
|
|
34760
34768
|
audio_format: external_exports.enum(AUDIO_FORMATS).optional().describe("Output format (default mp3). wav is lyria-3-pro-preview-only."),
|
|
34761
34769
|
images: external_exports.array(external_exports.string().min(1)).optional().describe("Optional reference image path(s) to condition the music"),
|
|
34762
|
-
|
|
34770
|
+
images_url: imagesUrlSchema("Reference images"),
|
|
34771
|
+
images_file_uris: imagesFileUrisSchema("Reference images"),
|
|
34772
|
+
images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation"),
|
|
34763
34773
|
from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS clipboard as a reference"),
|
|
34764
34774
|
filename: external_exports.string().optional().describe("Base filename for the output audio (extension stripped; default: slugified prompt)"),
|
|
34765
34775
|
output_dir: external_exports.string().optional().describe("Directory to write audio to (default: $GEMINI_OUTPUT_DIR or cwd)"),
|
|
@@ -34798,10 +34808,12 @@ function registerMusicTools(server, client2) {
|
|
|
34798
34808
|
images: args.images,
|
|
34799
34809
|
images_base64: args.images_base64,
|
|
34800
34810
|
from_clipboard: args.from_clipboard,
|
|
34811
|
+
images_url: args.images_url,
|
|
34812
|
+
images_file_uris: args.images_file_uris,
|
|
34801
34813
|
previous_interaction_id: previousInteractionId
|
|
34802
34814
|
});
|
|
34803
34815
|
return client2.session.jobs.dispatch({ toolName: "gemini_music_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
34804
|
-
const inputs = await
|
|
34816
|
+
const { inputs, report } = await resolveImageInputs(args, client2);
|
|
34805
34817
|
const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () => client2.generateMusic({
|
|
34806
34818
|
input: args.prompt,
|
|
34807
34819
|
images: inputs.length ? inputs : void 0,
|
|
@@ -34814,6 +34826,7 @@ function registerMusicTools(server, client2) {
|
|
|
34814
34826
|
const meta3 = { model, interaction_id: r.id };
|
|
34815
34827
|
if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
|
|
34816
34828
|
if (r.text) meta3.text = r.text;
|
|
34829
|
+
if (report) meta3.image_inputs = report;
|
|
34817
34830
|
meta3.hint = `To continue this track, call gemini_music_generate again with previous_interaction_id: "${r.id}" (or continue_last: true).`;
|
|
34818
34831
|
const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
|
|
34819
34832
|
const named = r.audios.map((media, i) => ({
|
|
@@ -34847,7 +34860,6 @@ init_zod();
|
|
|
34847
34860
|
init_dist();
|
|
34848
34861
|
var UPLOAD_URL_MAX_BYTES_DISK = 100 * 1024 * 1024;
|
|
34849
34862
|
var UPLOAD_URL_MAX_BYTES_HOSTED = 25 * 1024 * 1024;
|
|
34850
|
-
var mb = (bytes) => Math.round(bytes / (1024 * 1024));
|
|
34851
34863
|
var UPLOAD_ACCEPT = ["image/", "video/", "audio/"];
|
|
34852
34864
|
var RETENTION_NOTE = "Gemini retains uploaded files for ~48h. Until then this reference can be reused across any number of calls (images_file_uris on gemini_image_generate / _edit / _set / gemini_interact, or video_url) without re-sending the bytes.";
|
|
34853
34865
|
function uploadResult(file2) {
|
|
@@ -34874,7 +34886,7 @@ function registerFileTools(server, client2) {
|
|
|
34874
34886
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
34875
34887
|
inputSchema: {
|
|
34876
34888
|
url: external_exports.string().url().optional().describe(
|
|
34877
|
-
`Public https URL the SERVER downloads and uploads (image/video/audio, up to ${
|
|
34889
|
+
`Public https URL the SERVER downloads and uploads (image/video/audio, up to ${wholeMb(urlMaxBytes)}MB). No bytes pass through the conversation.` + (onDisk ? "" : " Larger files: POST the raw bytes to /upload, which streams instead of buffering.")
|
|
34878
34890
|
),
|
|
34879
34891
|
data_base64: external_exports.string().min(1).optional().describe("Raw base64 or a data: URI. Last resort \u2014 this is the one form that costs model context (~14k tokens for a modest JPEG)."),
|
|
34880
34892
|
path: external_exports.string().min(1).optional().describe(
|
|
@@ -34913,19 +34925,21 @@ function registerFileTools(server, client2) {
|
|
|
34913
34925
|
return client2.uploadBytes(
|
|
34914
34926
|
fetched.bytes,
|
|
34915
34927
|
args.mime_type ?? fetched.mimeType,
|
|
34916
|
-
|
|
34928
|
+
// 'upload', matching the data_base64 and path branches below —
|
|
34929
|
+
// this tool takes video and audio too, so 'image' would be a lie.
|
|
34930
|
+
args.display_name ?? fileNameFromUrl(args.url, "upload")
|
|
34917
34931
|
);
|
|
34918
34932
|
}
|
|
34919
34933
|
if (args.data_base64) {
|
|
34920
34934
|
const { decodeImageInput: decodeImageInput2 } = await Promise.resolve().then(() => (init_images(), images_exports));
|
|
34921
34935
|
const decoded = decodeImageInput2(args.data_base64);
|
|
34922
|
-
const bytes2 =
|
|
34936
|
+
const bytes2 = base64ToBytes(decoded.base64);
|
|
34923
34937
|
return client2.uploadBytes(bytes2, args.mime_type ?? decoded.mimeType, args.display_name ?? "upload");
|
|
34924
34938
|
}
|
|
34925
34939
|
const { readImageAsInline: readImageAsInline2, resolveImagePath: resolveImagePath2 } = await Promise.resolve().then(() => (init_images(), images_exports));
|
|
34926
34940
|
const resolved = resolveImagePath2(args.path);
|
|
34927
34941
|
const inline = await readImageAsInline2(resolved);
|
|
34928
|
-
const bytes =
|
|
34942
|
+
const bytes = base64ToBytes(inline.base64);
|
|
34929
34943
|
return client2.uploadBytes(
|
|
34930
34944
|
bytes,
|
|
34931
34945
|
args.mime_type ?? inline.mimeType,
|
|
@@ -34980,14 +34994,6 @@ function registerFileTools(server, client2) {
|
|
|
34980
34994
|
}
|
|
34981
34995
|
);
|
|
34982
34996
|
}
|
|
34983
|
-
function nameFromUrl(url2) {
|
|
34984
|
-
try {
|
|
34985
|
-
const name = new URL(url2).pathname.split("/").filter(Boolean).pop();
|
|
34986
|
-
return name ? decodeURIComponent(name).slice(0, 120) : "upload";
|
|
34987
|
-
} catch {
|
|
34988
|
-
return "upload";
|
|
34989
|
-
}
|
|
34990
|
-
}
|
|
34991
34997
|
|
|
34992
34998
|
// src/index.ts
|
|
34993
34999
|
await loadStdioDotenv();
|
package/dist/bytes.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* base64 ↔ bytes, and byte-size formatting.
|
|
3
|
+
*
|
|
4
|
+
* These conversions had grown four near-identical copies — an inline
|
|
5
|
+
* `Uint8Array.from(atob(x), c => c.charCodeAt(0))` in `inputs.ts` and twice in
|
|
6
|
+
* `tools/files.ts`, plus a private `decodeBase64` in `storage/media.ts` — and
|
|
7
|
+
* a fifth (`encodeBase64`) that was subtly different because it is the only one
|
|
8
|
+
* that has to chunk. One place, so the chunking rule can't be forgotten in copy
|
|
9
|
+
* number six.
|
|
10
|
+
*
|
|
11
|
+
* `atob`/`btoa` rather than `Buffer`: this module is on the Worker's import
|
|
12
|
+
* graph, and they are the primitive both runtimes share. Module scope stays
|
|
13
|
+
* side-effect-free for the same reason (see the note in client.ts).
|
|
14
|
+
*/
|
|
15
|
+
/** Decode base64 into raw bytes. */
|
|
16
|
+
export function base64ToBytes(base64) {
|
|
17
|
+
const binary = atob(base64);
|
|
18
|
+
const bytes = new Uint8Array(binary.length);
|
|
19
|
+
for (let i = 0; i < binary.length; i++)
|
|
20
|
+
bytes[i] = binary.charCodeAt(i);
|
|
21
|
+
return bytes;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Encode raw bytes as base64.
|
|
25
|
+
*
|
|
26
|
+
* Chunked because `String.fromCharCode(...bytes)` spreads one argument per
|
|
27
|
+
* byte and blows the engine's argument limit somewhere around 100–125k — i.e.
|
|
28
|
+
* it works fine on every small test fixture and throws on the first real
|
|
29
|
+
* photo, which is the worst possible place to learn about it.
|
|
30
|
+
*/
|
|
31
|
+
export function bytesToBase64(bytes) {
|
|
32
|
+
const CHUNK = 0x8000;
|
|
33
|
+
let binary = '';
|
|
34
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
35
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
36
|
+
}
|
|
37
|
+
return btoa(binary);
|
|
38
|
+
}
|
|
39
|
+
/** Whole megabytes, for limits stated in tool descriptions (`25MB`). */
|
|
40
|
+
export function wholeMb(bytes) {
|
|
41
|
+
return Math.round(bytes / (1024 * 1024));
|
|
42
|
+
}
|
|
43
|
+
/** One-decimal megabytes, for error messages about an actual size (`15.4 MB`). */
|
|
44
|
+
export function formatMb(bytes) {
|
|
45
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
46
|
+
}
|
package/dist/fetch-image.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { McpToolError } from '@chrischall/mcp-utils';
|
|
2
|
+
import { formatMb } from './bytes.js';
|
|
2
3
|
/**
|
|
3
4
|
* Server-side fetching of `images_url` references.
|
|
4
5
|
*
|
|
@@ -207,6 +208,28 @@ function assertPublicHttpsUrl(raw, requested) {
|
|
|
207
208
|
}
|
|
208
209
|
return url;
|
|
209
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* Last path segment of a URL, for the Files API display name. Cosmetic — the
|
|
213
|
+
* API keys on the resource name it returns, not on this — so an unparseable
|
|
214
|
+
* URL degrades to `fallback` rather than failing the upload.
|
|
215
|
+
*
|
|
216
|
+
* `fallback` is REQUIRED, deliberately. This function was created by merging
|
|
217
|
+
* two byte-identical copies that differed only in their default (`'image'` in
|
|
218
|
+
* `inputs.ts`, `'upload'` in `tools/files.ts`), and taking one of those
|
|
219
|
+
* defaults silently changed the other caller's behaviour — `gemini_upload_file`
|
|
220
|
+
* started naming a fallback upload "image" even for an mp4, and disagreed with
|
|
221
|
+
* the other two branches of its own handler. With no default, merging callers
|
|
222
|
+
* that disagree stops being something you can do by accident.
|
|
223
|
+
*/
|
|
224
|
+
export function fileNameFromUrl(url, fallback) {
|
|
225
|
+
try {
|
|
226
|
+
const name = new URL(url).pathname.split('/').filter(Boolean).pop();
|
|
227
|
+
return name ? decodeURIComponent(name).slice(0, 120) : fallback;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return fallback;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
210
233
|
/** "<url>" — or "<url> (redirected from <requested>)" when they differ. */
|
|
211
234
|
function describeUrl(current, requested) {
|
|
212
235
|
return current === requested ? current : `${current} (redirected from ${requested})`;
|
|
@@ -265,8 +288,7 @@ function fetchFailure(err, current, requested, timeoutMs) {
|
|
|
265
288
|
return new McpToolError(`Could not fetch ${describeUrl(current, requested)}: ${err instanceof Error ? err.message : String(err)}`, { hint: 'Check the URL is reachable from the server and serves the image without authentication.' });
|
|
266
289
|
}
|
|
267
290
|
function tooBig(url, requested, size, max, partial = false) {
|
|
268
|
-
|
|
269
|
-
return new McpToolError(`Image at ${describeUrl(url, requested)} is ${partial ? 'over' : ''} ${mb(size)}, above the ${mb(max)} limit for images_url.`, { hint: 'Resize or re-encode the image, or upload it yourself with gemini_upload_file and pass the returned file_uri.' });
|
|
291
|
+
return new McpToolError(`Image at ${describeUrl(url, requested)} is ${partial ? 'over' : ''} ${formatMb(size)}, above the ${formatMb(max)} limit for images_url.`, { hint: 'Resize or re-encode the image, or upload it yourself with gemini_upload_file and pass the returned file_uri.' });
|
|
270
292
|
}
|
|
271
293
|
/**
|
|
272
294
|
* Fetch one image URL server-side, validating protocol, destination, response
|
package/dist/images.js
CHANGED
|
@@ -215,25 +215,6 @@ export function decodeImageInput(input) {
|
|
|
215
215
|
const buf = Buffer.from(trimmed, 'base64');
|
|
216
216
|
return { base64: trimmed, mimeType: sniffMimeBytes(buf) };
|
|
217
217
|
}
|
|
218
|
-
/** Load images from file paths (via readImageAsInline) and raw base64 strings (via decodeImageInput), paths first. */
|
|
219
|
-
export async function loadImageInputs(paths = [], base64s = []) {
|
|
220
|
-
const fromPaths = await Promise.all(paths.map((p) => readImageAsInline(p)));
|
|
221
|
-
const fromBase64s = base64s.map((b) => decodeImageInput(b));
|
|
222
|
-
return [...fromPaths, ...fromBase64s];
|
|
223
|
-
}
|
|
224
|
-
/**
|
|
225
|
-
* Aggregate image inputs from all sources: clipboard (macOS only), file paths, and base64 strings.
|
|
226
|
-
* Clipboard image is prepended if `from_clipboard` is true.
|
|
227
|
-
*/
|
|
228
|
-
export async function gatherImageInputs(opts) {
|
|
229
|
-
// Only load the clipboard module when actually requested (keeps the child_process
|
|
230
|
-
// path off the default code path).
|
|
231
|
-
const clipboardImages = opts.from_clipboard
|
|
232
|
-
? [await (await import('./clipboard.js')).readClipboardImage()]
|
|
233
|
-
: [];
|
|
234
|
-
const fileImages = await loadImageInputs(opts.images, opts.images_base64);
|
|
235
|
-
return [...clipboardImages, ...fileImages];
|
|
236
|
-
}
|
|
237
218
|
/** Caller-supplied filename → safe base name (no extension). */
|
|
238
219
|
export function baseName(name) {
|
|
239
220
|
// Strip a known media extension (image / video / audio) so writeMedia doesn't
|
package/dist/inputs.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { McpToolError } from '@chrischall/mcp-utils';
|
|
2
|
-
import { IMAGE_INLINE_MAX_BYTES } from './fetch-image.js';
|
|
2
|
+
import { IMAGE_INLINE_MAX_BYTES, fileNameFromUrl } from './fetch-image.js';
|
|
3
|
+
import { base64ToBytes, bytesToBase64 } from './bytes.js';
|
|
3
4
|
/** True when this call carries at least one image input of any kind. */
|
|
4
5
|
export function hasImageInput(args) {
|
|
5
6
|
return Boolean(args.images?.length ||
|
|
@@ -117,7 +118,7 @@ async function resolveLocalPath(path, client, onDisk, report) {
|
|
|
117
118
|
// Best-effort — an upload failure must not fail a call that inline handles
|
|
118
119
|
// perfectly well.
|
|
119
120
|
try {
|
|
120
|
-
const bytes =
|
|
121
|
+
const bytes = base64ToBytes(inline.base64);
|
|
121
122
|
const displayName = resolved.split(/[\\/]/).pop() || 'image';
|
|
122
123
|
const uploaded = await client.uploadBytes(bytes, inline.mimeType, displayName);
|
|
123
124
|
client.session.uploadCache.set(key, {
|
|
@@ -143,36 +144,14 @@ async function resolveUrl(url, client, report) {
|
|
|
143
144
|
};
|
|
144
145
|
(report.fetched_urls ??= []).push(entry);
|
|
145
146
|
if (fetched.size <= IMAGE_INLINE_MAX_BYTES) {
|
|
146
|
-
return { base64:
|
|
147
|
+
return { base64: bytesToBase64(fetched.bytes), mimeType: fetched.mimeType };
|
|
147
148
|
}
|
|
148
|
-
|
|
149
|
+
// 'image' here: this path is `images_url`, which only ever carries images.
|
|
150
|
+
const displayName = fileNameFromUrl(url, 'image');
|
|
149
151
|
const uploaded = await client.uploadBytes(fetched.bytes, fetched.mimeType, displayName);
|
|
150
152
|
entry.file_uri = uploaded.name;
|
|
151
153
|
return { uri: uploaded.uri, mimeType: uploaded.mimeType };
|
|
152
154
|
}
|
|
153
|
-
/** Last path segment of a URL, for the Files API display name. */
|
|
154
|
-
function fileNameFromUrl(url) {
|
|
155
|
-
try {
|
|
156
|
-
const name = new URL(url).pathname.split('/').filter(Boolean).pop();
|
|
157
|
-
return name ? decodeURIComponent(name).slice(0, 120) : 'image';
|
|
158
|
-
}
|
|
159
|
-
catch {
|
|
160
|
-
return 'image';
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
/**
|
|
164
|
-
* bytes → base64 without `Buffer`. `btoa` is the primitive both runtimes share
|
|
165
|
-
* (see `storage/media.ts`, which decodes the same way); chunked because
|
|
166
|
-
* `String.fromCharCode(...huge)` blows the argument limit.
|
|
167
|
-
*/
|
|
168
|
-
function encodeBase64(bytes) {
|
|
169
|
-
let binary = '';
|
|
170
|
-
const CHUNK = 0x8000;
|
|
171
|
-
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
172
|
-
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
173
|
-
}
|
|
174
|
-
return btoa(binary);
|
|
175
|
-
}
|
|
176
155
|
/**
|
|
177
156
|
* Guard for tools that REQUIRE at least one reference image
|
|
178
157
|
* (`gemini_image_edit`). Names every accepted param, in the order a caller
|
package/dist/storage/media.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* is a thin wrapper over the pre-existing `resolveOutputDir` + `writeMedia`
|
|
12
12
|
* behaviour and must stay byte-for-byte equivalent to it.
|
|
13
13
|
*/
|
|
14
|
+
import { base64ToBytes } from '../bytes.js';
|
|
14
15
|
/**
|
|
15
16
|
* The stdio sink: `resolveOutputDir(output_dir)` → `writeMedia` per item, in
|
|
16
17
|
* order (so `uniquePath`'s `name`/`name-2`/`name-3` sequencing is preserved).
|
|
@@ -69,7 +70,7 @@ export function createR2Sink(bucket, opts) {
|
|
|
69
70
|
const refs = [];
|
|
70
71
|
for (const it of items) {
|
|
71
72
|
const key = `${prefix}/${day}/${randomId()}-${safeKeySegment(it.base)}.${mediaExt(it.mimeType)}`;
|
|
72
|
-
const bytes =
|
|
73
|
+
const bytes = base64ToBytes(it.base64);
|
|
73
74
|
await bucket.put(key, bytes, { httpMetadata: { contentType: it.mimeType } });
|
|
74
75
|
refs.push(base ? `${base}/${key}` : `r2://${opts.bucketName ?? 'media'}/${key}`);
|
|
75
76
|
}
|
|
@@ -83,15 +84,3 @@ export function createR2Sink(bucket, opts) {
|
|
|
83
84
|
'This hosted connector has no filesystem, so `output_dir` is ignored and no <image>.json sidecar is written — capture interaction_id from this result to chain further turns.',
|
|
84
85
|
};
|
|
85
86
|
}
|
|
86
|
-
/**
|
|
87
|
-
* base64 → bytes without `Buffer`. `Buffer` exists in a Worker under
|
|
88
|
-
* `nodejs_compat`, but `atob` is the platform primitive on both runtimes and
|
|
89
|
-
* keeps this module free of any Node dependency.
|
|
90
|
-
*/
|
|
91
|
-
function decodeBase64(b64) {
|
|
92
|
-
const binary = atob(b64);
|
|
93
|
-
const bytes = new Uint8Array(binary.length);
|
|
94
|
-
for (let i = 0; i < binary.length; i++)
|
|
95
|
-
bytes[i] = binary.charCodeAt(i);
|
|
96
|
-
return bytes;
|
|
97
|
-
}
|
package/dist/tools/files.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { McpToolError, textResult } from '@chrischall/mcp-utils';
|
|
3
3
|
import { withProgressHeartbeat } from './shared.js';
|
|
4
|
+
import { base64ToBytes, wholeMb } from '../bytes.js';
|
|
5
|
+
import { fileNameFromUrl } from '../fetch-image.js';
|
|
4
6
|
import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
5
7
|
/**
|
|
6
8
|
* First-class Gemini Files API access.
|
|
@@ -38,7 +40,6 @@ import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfir
|
|
|
38
40
|
*/
|
|
39
41
|
const UPLOAD_URL_MAX_BYTES_DISK = 100 * 1024 * 1024;
|
|
40
42
|
const UPLOAD_URL_MAX_BYTES_HOSTED = 25 * 1024 * 1024;
|
|
41
|
-
const mb = (bytes) => Math.round(bytes / (1024 * 1024));
|
|
42
43
|
/** What a `url` upload will accept: the media the Files API is useful for. */
|
|
43
44
|
const UPLOAD_ACCEPT = ['image/', 'video/', 'audio/'];
|
|
44
45
|
const RETENTION_NOTE = 'Gemini retains uploaded files for ~48h. Until then this reference can be reused across any number of calls ' +
|
|
@@ -77,7 +78,7 @@ export function registerFileTools(server, client) {
|
|
|
77
78
|
.string()
|
|
78
79
|
.url()
|
|
79
80
|
.optional()
|
|
80
|
-
.describe(`Public https URL the SERVER downloads and uploads (image/video/audio, up to ${
|
|
81
|
+
.describe(`Public https URL the SERVER downloads and uploads (image/video/audio, up to ${wholeMb(urlMaxBytes)}MB). ` +
|
|
81
82
|
'No bytes pass through the conversation.' +
|
|
82
83
|
(onDisk ? '' : ' Larger files: POST the raw bytes to /upload, which streams instead of buffering.')),
|
|
83
84
|
data_base64: z
|
|
@@ -122,18 +123,21 @@ export function registerFileTools(server, client) {
|
|
|
122
123
|
const uploaded = await withProgressHeartbeat(extra, 'Uploading to the Gemini Files API', async () => {
|
|
123
124
|
if (args.url) {
|
|
124
125
|
const fetched = await client.fetchRemoteImage(args.url, { maxBytes: urlMaxBytes, accept: UPLOAD_ACCEPT });
|
|
125
|
-
return client.uploadBytes(fetched.bytes, args.mime_type ?? fetched.mimeType,
|
|
126
|
+
return client.uploadBytes(fetched.bytes, args.mime_type ?? fetched.mimeType,
|
|
127
|
+
// 'upload', matching the data_base64 and path branches below —
|
|
128
|
+
// this tool takes video and audio too, so 'image' would be a lie.
|
|
129
|
+
args.display_name ?? fileNameFromUrl(args.url, 'upload'));
|
|
126
130
|
}
|
|
127
131
|
if (args.data_base64) {
|
|
128
132
|
const { decodeImageInput } = await import('../images.js');
|
|
129
133
|
const decoded = decodeImageInput(args.data_base64);
|
|
130
|
-
const bytes =
|
|
134
|
+
const bytes = base64ToBytes(decoded.base64);
|
|
131
135
|
return client.uploadBytes(bytes, args.mime_type ?? decoded.mimeType, args.display_name ?? 'upload');
|
|
132
136
|
}
|
|
133
137
|
const { readImageAsInline, resolveImagePath } = await import('../images.js');
|
|
134
138
|
const resolved = resolveImagePath(args.path);
|
|
135
139
|
const inline = await readImageAsInline(resolved);
|
|
136
|
-
const bytes =
|
|
140
|
+
const bytes = base64ToBytes(inline.base64);
|
|
137
141
|
return client.uploadBytes(bytes, args.mime_type ?? inline.mimeType, args.display_name ?? (resolved.split(/[\\/]/).pop() || 'upload'));
|
|
138
142
|
});
|
|
139
143
|
return uploadResult(uploaded);
|
|
@@ -182,13 +186,3 @@ export function registerFileTools(server, client) {
|
|
|
182
186
|
return textResult({ deleted: args.file_uri });
|
|
183
187
|
});
|
|
184
188
|
}
|
|
185
|
-
/** Last path segment of a URL, for the Files API display name. */
|
|
186
|
-
function nameFromUrl(url) {
|
|
187
|
-
try {
|
|
188
|
-
const name = new URL(url).pathname.split('/').filter(Boolean).pop();
|
|
189
|
-
return name ? decodeURIComponent(name).slice(0, 120) : 'upload';
|
|
190
|
-
}
|
|
191
|
-
catch {
|
|
192
|
-
return 'upload';
|
|
193
|
-
}
|
|
194
|
-
}
|
package/dist/tools/interact.js
CHANGED
|
@@ -20,7 +20,7 @@ const NEW_REFERENCES_ONLY = 'NEW reference images only (e.g. a style or target p
|
|
|
20
20
|
/**
|
|
21
21
|
* On a chained call, split `images` into genuinely new references vs paths
|
|
22
22
|
* this server itself generated (which the interaction already contains).
|
|
23
|
-
* Unresolvable paths are kept so `
|
|
23
|
+
* Unresolvable paths are kept so `resolveImageInputs` surfaces its usual error.
|
|
24
24
|
*/
|
|
25
25
|
function splitReattachedOutputs(images, writtenOutputs) {
|
|
26
26
|
const kept = [];
|
package/dist/tools/music.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { McpToolError } from '@chrischall/mcp-utils';
|
|
3
|
-
import { slugify, baseName
|
|
3
|
+
import { slugify, baseName } from '../images.js';
|
|
4
|
+
import { resolveImageInputs } from '../inputs.js';
|
|
4
5
|
import { DEFAULT_MUSIC_MODEL } from '../models.js';
|
|
5
|
-
import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
|
|
6
|
+
import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
|
|
6
7
|
import { fingerprintRequest } from '../jobs.js';
|
|
7
8
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
8
9
|
const MUSIC_MODELS = ['lyria-3-clip-preview', 'lyria-3-pro-preview'];
|
|
@@ -26,7 +27,9 @@ export function registerMusicTools(server, client) {
|
|
|
26
27
|
model: z.enum(MUSIC_MODELS).optional().describe(`Lyria model (default: ${DEFAULT_MUSIC_MODEL}). Pro is longer-form and supports WAV.`),
|
|
27
28
|
audio_format: z.enum(AUDIO_FORMATS).optional().describe('Output format (default mp3). wav is lyria-3-pro-preview-only.'),
|
|
28
29
|
images: z.array(z.string().min(1)).optional().describe('Optional reference image path(s) to condition the music'),
|
|
29
|
-
|
|
30
|
+
images_url: imagesUrlSchema('Reference images'),
|
|
31
|
+
images_file_uris: imagesFileUrisSchema('Reference images'),
|
|
32
|
+
images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation'),
|
|
30
33
|
from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS clipboard as a reference'),
|
|
31
34
|
filename: z.string().optional().describe('Base filename for the output audio (extension stripped; default: slugified prompt)'),
|
|
32
35
|
output_dir: z.string().optional().describe('Directory to write audio to (default: $GEMINI_OUTPUT_DIR or cwd)'),
|
|
@@ -61,10 +64,11 @@ export function registerMusicTools(server, client) {
|
|
|
61
64
|
const fingerprint = fingerprintRequest('gemini_music_generate', {
|
|
62
65
|
model, prompt: args.prompt, audio_format: args.audio_format,
|
|
63
66
|
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
67
|
+
images_url: args.images_url, images_file_uris: args.images_file_uris,
|
|
64
68
|
previous_interaction_id: previousInteractionId,
|
|
65
69
|
});
|
|
66
70
|
return client.session.jobs.dispatch({ toolName: 'gemini_music_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
67
|
-
const inputs = await
|
|
71
|
+
const { inputs, report } = await resolveImageInputs(args, client);
|
|
68
72
|
const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () => client.generateMusic({
|
|
69
73
|
input: args.prompt,
|
|
70
74
|
images: inputs.length ? inputs : undefined,
|
|
@@ -79,6 +83,8 @@ export function registerMusicTools(server, client) {
|
|
|
79
83
|
meta.previous_interaction_id = previousInteractionId;
|
|
80
84
|
if (r.text)
|
|
81
85
|
meta.text = r.text;
|
|
86
|
+
if (report)
|
|
87
|
+
meta.image_inputs = report;
|
|
82
88
|
meta.hint = `To continue this track, call gemini_music_generate again with previous_interaction_id: "${r.id}" (or continue_last: true).`;
|
|
83
89
|
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
84
90
|
const named = r.audios.map((media, i) => ({
|
package/dist/tools/shared.js
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { textResult, McpToolError, readEnvVar } from '@chrischall/mcp-utils';
|
|
3
3
|
import { resolveVideoPath, videoMimeType } from '../images.js';
|
|
4
4
|
import { IMAGE_URL_MAX_BYTES } from '../fetch-image.js';
|
|
5
|
+
import { wholeMb } from '../bytes.js';
|
|
5
6
|
import { createDiskSink } from '../storage/media.js';
|
|
6
7
|
/**
|
|
7
8
|
* Fallback sink for call sites that pass no explicit one (every pre-existing
|
|
@@ -64,7 +65,7 @@ export function imagesUrlSchema(label = 'Reference images') {
|
|
|
64
65
|
.optional()
|
|
65
66
|
.describe(`${label} as public https URLs — the SERVER downloads them, so no image bytes travel through the conversation. ` +
|
|
66
67
|
'Preferred over images_base64, which costs ~14k tokens per photo and breaks if a file read was truncated. ' +
|
|
67
|
-
`Max ${
|
|
68
|
+
`Max ${wholeMb(IMAGE_URL_MAX_BYTES)}MB each; must be a directly-linked image (Content-Type image/*).`);
|
|
68
69
|
}
|
|
69
70
|
/**
|
|
70
71
|
* Reference images already in the Gemini Files API. The cheapest form there is:
|
package/dist/tools/video.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { McpToolError } from '@chrischall/mcp-utils';
|
|
3
|
-
import { slugify, baseName
|
|
3
|
+
import { slugify, baseName } from '../images.js';
|
|
4
|
+
import { resolveImageInputs } from '../inputs.js';
|
|
4
5
|
import { DEFAULT_VIDEO_MODEL } from '../models.js';
|
|
5
|
-
import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
|
|
6
|
+
import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
|
|
6
7
|
import { fingerprintRequest } from '../jobs.js';
|
|
7
8
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
8
9
|
/** omni's own aspect-ratio enum — NOT the image tools' ASPECT_RATIOS. */
|
|
@@ -27,7 +28,9 @@ export function registerVideoTools(server, client) {
|
|
|
27
28
|
aspect_ratio: z.enum(VIDEO_ASPECT_RATIOS).optional().describe('Output aspect ratio (omni: 16:9 or 9:16)'),
|
|
28
29
|
task: z.enum(VIDEO_TASKS).optional().describe('text_to_video (default), image_to_video / reference_to_video (need image input), or edit (needs previous_interaction_id)'),
|
|
29
30
|
images: z.array(z.string().min(1)).optional().describe('Reference image path(s) for image_to_video / reference_to_video'),
|
|
30
|
-
|
|
31
|
+
images_url: imagesUrlSchema('Reference stills'),
|
|
32
|
+
images_file_uris: imagesFileUrisSchema('Reference stills'),
|
|
33
|
+
images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation'),
|
|
31
34
|
from_clipboard: z.boolean().optional().describe('Use the image currently on the macOS clipboard as a reference'),
|
|
32
35
|
filename: z.string().optional().describe('Base filename for the output video (extension stripped; default: slugified prompt)'),
|
|
33
36
|
output_dir: z.string().optional().describe('Directory to write the video to (default: $GEMINI_OUTPUT_DIR or cwd)'),
|
|
@@ -63,10 +66,11 @@ export function registerVideoTools(server, client) {
|
|
|
63
66
|
const fingerprint = fingerprintRequest('gemini_video_generate', {
|
|
64
67
|
model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, task: args.task,
|
|
65
68
|
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
69
|
+
images_url: args.images_url, images_file_uris: args.images_file_uris,
|
|
66
70
|
previous_interaction_id: previousInteractionId,
|
|
67
71
|
});
|
|
68
72
|
return client.session.jobs.dispatch({ toolName: 'gemini_video_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
69
|
-
const inputs = await
|
|
73
|
+
const { inputs, report } = await resolveImageInputs(args, client);
|
|
70
74
|
const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client.generateVideo({
|
|
71
75
|
input: args.prompt,
|
|
72
76
|
images: inputs.length ? inputs : undefined,
|
|
@@ -82,6 +86,8 @@ export function registerVideoTools(server, client) {
|
|
|
82
86
|
meta.previous_interaction_id = previousInteractionId;
|
|
83
87
|
if (r.text)
|
|
84
88
|
meta.text = r.text;
|
|
89
|
+
if (report)
|
|
90
|
+
meta.image_inputs = report;
|
|
85
91
|
meta.hint = `To edit this video, call gemini_video_generate again with task: "edit" and previous_interaction_id: "${r.id}" (or continue_last: true).`;
|
|
86
92
|
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
87
93
|
const named = r.videos.map((media, i) => ({
|
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 = '1.
|
|
2
|
+
export const VERSION = '1.2.0'; // x-release-please-version
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrischall/gemini-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/gemini-mcp",
|
|
5
5
|
"description": "Gemini image-generation MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/gemini-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.2.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/gemini-mcp",
|
|
14
|
-
"version": "1.
|
|
14
|
+
"version": "1.2.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -97,6 +97,9 @@ Note: Image generation requires a billing-enabled Google Cloud project.
|
|
|
97
97
|
|
|
98
98
|
### Reference images — four ways in, one that costs context
|
|
99
99
|
|
|
100
|
+
These apply to **every** tool that takes a reference image: the four image tools, plus
|
|
101
|
+
`gemini_video_generate` (reference stills) and `gemini_music_generate`.
|
|
102
|
+
|
|
100
103
|
| Parameter | Where the bytes travel | Context cost |
|
|
101
104
|
|---|---|---|
|
|
102
105
|
| `images_url` (`master_images_url`) | the **server** downloads the https URL | none |
|
|
@@ -142,8 +145,8 @@ curl -X POST https://<connector>/upload \
|
|
|
142
145
|
### Video & Music (preview — funded account)
|
|
143
146
|
| Tool | Description |
|
|
144
147
|
|------|-------------|
|
|
145
|
-
| `gemini_video_generate(prompt, aspect_ratio?, task?, images?, images_base64?, from_clipboard?, previous_interaction_id?, continue_last?, model?, filename?, output_dir?, timeout_ms?, idempotency_key?, async?)` | Generate a short video via the Gemini omni model: `text_to_video` (default), `image_to_video` / `reference_to_video` (supply reference image[s]), or `edit` (with `previous_interaction_id` / `continue_last`). `aspect_ratio` is `16:9` or `9:16`. Written to disk as **MP4**. Runs long — use `async: true` + `gemini_get_result`, or raise `timeout_ms`. |
|
|
146
|
-
| `gemini_music_generate(prompt, model?, audio_format?, images?, images_base64?, from_clipboard?, previous_interaction_id?, continue_last?, filename?, output_dir?, inline?, timeout_ms?, idempotency_key?, async?)` | Generate music from a prompt (mood/genre/instruments/structure/lyrics) via a Lyria model: `lyria-3-clip-preview` (~30s, default) or `lyria-3-pro-preview` (longer, WAV-capable). `audio_format` `mp3` (default) or `wav` (**Pro-only**). Written to disk as MP3/WAV, or returned inline. |
|
|
148
|
+
| `gemini_video_generate(prompt, aspect_ratio?, task?, images_url?, images_file_uris?, images?, images_base64?, from_clipboard?, previous_interaction_id?, continue_last?, model?, filename?, output_dir?, timeout_ms?, idempotency_key?, async?)` | Generate a short video via the Gemini omni model: `text_to_video` (default), `image_to_video` / `reference_to_video` (supply reference image[s]), or `edit` (with `previous_interaction_id` / `continue_last`). `aspect_ratio` is `16:9` or `9:16`. Written to disk as **MP4**. Runs long — use `async: true` + `gemini_get_result`, or raise `timeout_ms`. |
|
|
149
|
+
| `gemini_music_generate(prompt, model?, audio_format?, images_url?, images_file_uris?, images?, images_base64?, from_clipboard?, previous_interaction_id?, continue_last?, filename?, output_dir?, inline?, timeout_ms?, idempotency_key?, async?)` | Generate music from a prompt (mood/genre/instruments/structure/lyrics) via a Lyria model: `lyria-3-clip-preview` (~30s, default) or `lyria-3-pro-preview` (longer, WAV-capable). `audio_format` `mp3` (default) or `wav` (**Pro-only**). Written to disk as MP3/WAV, or returned inline. |
|
|
147
150
|
|
|
148
151
|
### Async / idempotency (any generation tool)
|
|
149
152
|
| Tool | Description |
|