@chrischall/gemini-mcp 1.0.1 → 1.0.3
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 +34 -0
- package/dist/bundle.js +1320 -1027
- package/dist/client.js +130 -36
- package/dist/dotenv.js +29 -0
- package/dist/index.js +19 -3
- package/dist/jobs.js +0 -0
- package/dist/session.js +55 -0
- package/dist/sidecar.js +72 -0
- package/dist/storage/media.js +97 -0
- package/dist/tools/generate.js +15 -11
- package/dist/tools/interact.js +116 -47
- package/dist/tools/jobs.js +7 -4
- package/dist/tools/models.js +1 -2
- package/dist/tools/music.js +14 -13
- package/dist/tools/set.js +7 -7
- package/dist/tools/shared.js +72 -10
- package/dist/tools/video.js +16 -14
- package/dist/version.js +1 -1
- package/package.json +15 -4
- package/server.json +2 -2
package/dist/bundle.js
CHANGED
|
@@ -32602,19 +32602,227 @@ var init_clipboard = __esm({
|
|
|
32602
32602
|
}
|
|
32603
32603
|
});
|
|
32604
32604
|
|
|
32605
|
+
// src/images.ts
|
|
32606
|
+
var images_exports = {};
|
|
32607
|
+
__export(images_exports, {
|
|
32608
|
+
baseName: () => baseName,
|
|
32609
|
+
decodeImageInput: () => decodeImageInput,
|
|
32610
|
+
gatherImageInputs: () => gatherImageInputs,
|
|
32611
|
+
loadImageInputs: () => loadImageInputs,
|
|
32612
|
+
mediaExt: () => mediaExt,
|
|
32613
|
+
previewImageInput: () => previewImageInput,
|
|
32614
|
+
previewVideoInput: () => previewVideoInput,
|
|
32615
|
+
readImageAsInline: () => readImageAsInline,
|
|
32616
|
+
resolveImagePath: () => resolveImagePath,
|
|
32617
|
+
resolveOutputDir: () => resolveOutputDir,
|
|
32618
|
+
resolveVideoPath: () => resolveVideoPath,
|
|
32619
|
+
slugify: () => slugify2,
|
|
32620
|
+
uniquePath: () => uniquePath,
|
|
32621
|
+
videoMimeType: () => videoMimeType,
|
|
32622
|
+
writeImage: () => writeImage,
|
|
32623
|
+
writeMedia: () => writeMedia,
|
|
32624
|
+
writeSidecar: () => writeSidecar
|
|
32625
|
+
});
|
|
32626
|
+
import { mkdir, readFile as readFile2, writeFile, access, stat, open } from "node:fs/promises";
|
|
32627
|
+
import { existsSync } from "node:fs";
|
|
32628
|
+
import { join as join3, resolve, isAbsolute } from "node:path";
|
|
32629
|
+
function slugify2(text, max = 40) {
|
|
32630
|
+
const s = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/g, "");
|
|
32631
|
+
return s || "image";
|
|
32632
|
+
}
|
|
32633
|
+
async function exists(p) {
|
|
32634
|
+
try {
|
|
32635
|
+
await access(p);
|
|
32636
|
+
return true;
|
|
32637
|
+
} catch {
|
|
32638
|
+
return false;
|
|
32639
|
+
}
|
|
32640
|
+
}
|
|
32641
|
+
async function uniquePath(dir, base, ext) {
|
|
32642
|
+
let candidate = join3(dir, `${base}.${ext}`);
|
|
32643
|
+
let n = 2;
|
|
32644
|
+
while (await exists(candidate)) {
|
|
32645
|
+
candidate = join3(dir, `${base}-${n}.${ext}`);
|
|
32646
|
+
n++;
|
|
32647
|
+
}
|
|
32648
|
+
return candidate;
|
|
32649
|
+
}
|
|
32650
|
+
function sniffMimeBytes(buf) {
|
|
32651
|
+
if (buf.length >= 4 && buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) {
|
|
32652
|
+
return "image/png";
|
|
32653
|
+
}
|
|
32654
|
+
if (buf.length >= 3 && buf[0] === 255 && buf[1] === 216 && buf[2] === 255) {
|
|
32655
|
+
return "image/jpeg";
|
|
32656
|
+
}
|
|
32657
|
+
if (buf.length >= 12 && buf[0] === 82 && buf[1] === 73 && buf[2] === 70 && buf[3] === 70 && buf[8] === 87 && buf[9] === 69 && buf[10] === 66 && buf[11] === 80) {
|
|
32658
|
+
return "image/webp";
|
|
32659
|
+
}
|
|
32660
|
+
return "image/png";
|
|
32661
|
+
}
|
|
32662
|
+
function mediaExt(mimeType) {
|
|
32663
|
+
const m = mimeType.toLowerCase();
|
|
32664
|
+
if (m.includes("jpeg")) return "jpg";
|
|
32665
|
+
if (m.includes("webp")) return "webp";
|
|
32666
|
+
if (m.includes("png")) return "png";
|
|
32667
|
+
if (m.includes("mp4")) return "mp4";
|
|
32668
|
+
if (m.includes("quicktime")) return "mov";
|
|
32669
|
+
if (m.includes("webm")) return "webm";
|
|
32670
|
+
if (m.includes("wav")) return "wav";
|
|
32671
|
+
if (m.includes("mpeg") || m.includes("mp3")) return "mp3";
|
|
32672
|
+
if (m.includes("ogg")) return "ogg";
|
|
32673
|
+
return m.split("/")[1]?.split(";")[0]?.replace(/^x-/, "") || "bin";
|
|
32674
|
+
}
|
|
32675
|
+
async function writeMedia(dir, base, base643, mimeType) {
|
|
32676
|
+
await mkdir(dir, { recursive: true });
|
|
32677
|
+
const path = await uniquePath(dir, base, mediaExt(mimeType));
|
|
32678
|
+
await writeFile(path, Buffer.from(base643, "base64"));
|
|
32679
|
+
return resolve(path);
|
|
32680
|
+
}
|
|
32681
|
+
async function writeImage(dir, base, base643, mimeType) {
|
|
32682
|
+
return writeMedia(dir, base, base643, mimeType);
|
|
32683
|
+
}
|
|
32684
|
+
async function writeSidecar(imagePath, data) {
|
|
32685
|
+
await writeFile(`${imagePath}.json`, JSON.stringify(data, null, 2));
|
|
32686
|
+
}
|
|
32687
|
+
function resolveInputPath(p, kind) {
|
|
32688
|
+
if (isAbsolute(p) && existsSync(p)) return p;
|
|
32689
|
+
const inputDir = readEnvVar("GEMINI_INPUT_DIR");
|
|
32690
|
+
if (inputDir) {
|
|
32691
|
+
const candidate = join3(inputDir, p);
|
|
32692
|
+
if (existsSync(candidate)) return resolve(candidate);
|
|
32693
|
+
}
|
|
32694
|
+
const cwd = resolve(p);
|
|
32695
|
+
if (existsSync(cwd)) return cwd;
|
|
32696
|
+
const hint = `Set GEMINI_INPUT_DIR to a directory containing your ${kind.toLowerCase()}s, or pass an absolute path.`;
|
|
32697
|
+
throw new McpToolError(
|
|
32698
|
+
`${kind} not found: ${p}` + (inputDir ? ` (also searched GEMINI_INPUT_DIR=${inputDir})` : ""),
|
|
32699
|
+
{ hint }
|
|
32700
|
+
);
|
|
32701
|
+
}
|
|
32702
|
+
function resolveImagePath(p) {
|
|
32703
|
+
return resolveInputPath(p, "Image");
|
|
32704
|
+
}
|
|
32705
|
+
function resolveVideoPath(p) {
|
|
32706
|
+
return resolveInputPath(p, "Video");
|
|
32707
|
+
}
|
|
32708
|
+
function videoMimeType(p) {
|
|
32709
|
+
const ext = p.includes(".") ? p.slice(p.lastIndexOf(".") + 1).toLowerCase() : "";
|
|
32710
|
+
const mime = VIDEO_MIME_BY_EXT[ext];
|
|
32711
|
+
if (!mime) {
|
|
32712
|
+
throw new McpToolError(`Unsupported video format: ${ext ? `.${ext}` : p}`, {
|
|
32713
|
+
hint: `Supported extensions: ${Object.keys(VIDEO_MIME_BY_EXT).map((e) => `.${e}`).join(", ")}`
|
|
32714
|
+
});
|
|
32715
|
+
}
|
|
32716
|
+
return mime;
|
|
32717
|
+
}
|
|
32718
|
+
async function previewImageInput(path) {
|
|
32719
|
+
const resolved = resolveImagePath(path);
|
|
32720
|
+
const { size } = await stat(resolved);
|
|
32721
|
+
const fh = await open(resolved, "r");
|
|
32722
|
+
try {
|
|
32723
|
+
const head = Buffer.alloc(16);
|
|
32724
|
+
const { bytesRead } = await fh.read(head, 0, 16, 0);
|
|
32725
|
+
return { path: resolved, mimeType: sniffMimeBytes(head.subarray(0, bytesRead)), size };
|
|
32726
|
+
} finally {
|
|
32727
|
+
await fh.close();
|
|
32728
|
+
}
|
|
32729
|
+
}
|
|
32730
|
+
async function previewVideoInput(path) {
|
|
32731
|
+
const resolved = resolveVideoPath(path);
|
|
32732
|
+
const { size } = await stat(resolved);
|
|
32733
|
+
return { path: resolved, mimeType: videoMimeType(resolved), size };
|
|
32734
|
+
}
|
|
32735
|
+
async function readImageAsInline(path) {
|
|
32736
|
+
const resolved = resolveImagePath(path);
|
|
32737
|
+
const buf = await readFile2(resolved);
|
|
32738
|
+
const mimeType = sniffMimeBytes(buf);
|
|
32739
|
+
return { base64: buf.toString("base64"), mimeType };
|
|
32740
|
+
}
|
|
32741
|
+
function decodeImageInput(input) {
|
|
32742
|
+
const trimmed = input.trim();
|
|
32743
|
+
if (trimmed.startsWith("data:")) {
|
|
32744
|
+
const marker = trimmed.indexOf(";base64,");
|
|
32745
|
+
if (marker === -1) {
|
|
32746
|
+
throw new Error(`Unsupported data URI (expected ;base64,<data>): ${trimmed.slice(0, 48)}\u2026`);
|
|
32747
|
+
}
|
|
32748
|
+
const mimeType = trimmed.slice(5, trimmed.indexOf(";", 5)) || "image/png";
|
|
32749
|
+
return { mimeType, base64: trimmed.slice(marker + ";base64,".length) };
|
|
32750
|
+
}
|
|
32751
|
+
const buf = Buffer.from(trimmed, "base64");
|
|
32752
|
+
return { base64: trimmed, mimeType: sniffMimeBytes(buf) };
|
|
32753
|
+
}
|
|
32754
|
+
async function loadImageInputs(paths = [], base64s = []) {
|
|
32755
|
+
const fromPaths = await Promise.all(paths.map((p) => readImageAsInline(p)));
|
|
32756
|
+
const fromBase64s = base64s.map((b) => decodeImageInput(b));
|
|
32757
|
+
return [...fromPaths, ...fromBase64s];
|
|
32758
|
+
}
|
|
32759
|
+
async function gatherImageInputs(opts) {
|
|
32760
|
+
const clipboardImages = opts.from_clipboard ? [await (await Promise.resolve().then(() => (init_clipboard(), clipboard_exports))).readClipboardImage()] : [];
|
|
32761
|
+
const fileImages = await loadImageInputs(opts.images, opts.images_base64);
|
|
32762
|
+
return [...clipboardImages, ...fileImages];
|
|
32763
|
+
}
|
|
32764
|
+
function baseName(name) {
|
|
32765
|
+
const stripped = name.replace(/\.(png|jpe?g|webp|mp4|mov|webm|mp3|wav|ogg)$/i, "");
|
|
32766
|
+
const safe = stripped.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
32767
|
+
return safe || "image";
|
|
32768
|
+
}
|
|
32769
|
+
function resolveOutputDir(perCall) {
|
|
32770
|
+
return perCall?.trim() || readEnvVar("GEMINI_OUTPUT_DIR") || process.cwd();
|
|
32771
|
+
}
|
|
32772
|
+
var VIDEO_MIME_BY_EXT;
|
|
32773
|
+
var init_images = __esm({
|
|
32774
|
+
"src/images.ts"() {
|
|
32775
|
+
"use strict";
|
|
32776
|
+
init_dist();
|
|
32777
|
+
VIDEO_MIME_BY_EXT = {
|
|
32778
|
+
mp4: "video/mp4",
|
|
32779
|
+
mpeg: "video/mpeg",
|
|
32780
|
+
mpg: "video/mpg",
|
|
32781
|
+
mov: "video/mov",
|
|
32782
|
+
avi: "video/avi",
|
|
32783
|
+
flv: "video/x-flv",
|
|
32784
|
+
webm: "video/webm",
|
|
32785
|
+
wmv: "video/wmv",
|
|
32786
|
+
"3gp": "video/3gpp",
|
|
32787
|
+
"3gpp": "video/3gpp"
|
|
32788
|
+
};
|
|
32789
|
+
}
|
|
32790
|
+
});
|
|
32791
|
+
|
|
32605
32792
|
// src/index.ts
|
|
32606
32793
|
init_dist();
|
|
32607
32794
|
|
|
32795
|
+
// src/dotenv.ts
|
|
32796
|
+
init_dist();
|
|
32797
|
+
import { dirname, join } from "node:path";
|
|
32798
|
+
import { fileURLToPath } from "node:url";
|
|
32799
|
+
async function loadStdioDotenv() {
|
|
32800
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
32801
|
+
await loadDotenvSafely({ path: join(here, "..", ".env"), override: false });
|
|
32802
|
+
}
|
|
32803
|
+
|
|
32608
32804
|
// src/version.ts
|
|
32609
|
-
var VERSION = "1.0.
|
|
32805
|
+
var VERSION = "1.0.3";
|
|
32610
32806
|
|
|
32611
32807
|
// src/tools/models.ts
|
|
32612
32808
|
init_dist();
|
|
32809
|
+
function registerModelTools(server, client2) {
|
|
32810
|
+
server.registerTool(
|
|
32811
|
+
"gemini_list_models",
|
|
32812
|
+
{
|
|
32813
|
+
description: "List the Gemini image-generation models available to your API key (Nano Banana / Nano Banana Pro family), and the current default model.",
|
|
32814
|
+
annotations: { readOnlyHint: true }
|
|
32815
|
+
},
|
|
32816
|
+
async () => {
|
|
32817
|
+
const models = await client2.listModels();
|
|
32818
|
+
return textResult({ default: client2.defaultModel(), models });
|
|
32819
|
+
}
|
|
32820
|
+
);
|
|
32821
|
+
}
|
|
32613
32822
|
|
|
32614
|
-
// src/
|
|
32823
|
+
// src/tools/generate.ts
|
|
32824
|
+
init_zod();
|
|
32615
32825
|
init_dist();
|
|
32616
|
-
import { basename, dirname, join } from "node:path";
|
|
32617
|
-
import { fileURLToPath } from "node:url";
|
|
32618
32826
|
|
|
32619
32827
|
// src/models.ts
|
|
32620
32828
|
var DEFAULT_IMAGE_MODEL = "gemini-3.1-flash-image";
|
|
@@ -32631,1052 +32839,1095 @@ function filterImageModels(raw) {
|
|
|
32631
32839
|
}));
|
|
32632
32840
|
}
|
|
32633
32841
|
|
|
32634
|
-
// src/
|
|
32635
|
-
|
|
32636
|
-
|
|
32637
|
-
|
|
32638
|
-
|
|
32639
|
-
|
|
32640
|
-
|
|
32641
|
-
|
|
32642
|
-
|
|
32643
|
-
|
|
32644
|
-
|
|
32645
|
-
|
|
32646
|
-
|
|
32842
|
+
// src/tools/generate.ts
|
|
32843
|
+
init_images();
|
|
32844
|
+
|
|
32845
|
+
// src/tools/shared.ts
|
|
32846
|
+
init_zod();
|
|
32847
|
+
init_dist();
|
|
32848
|
+
init_images();
|
|
32849
|
+
|
|
32850
|
+
// src/storage/media.ts
|
|
32851
|
+
function createDiskSink() {
|
|
32852
|
+
return {
|
|
32853
|
+
kind: "disk",
|
|
32854
|
+
persistsFiles: true,
|
|
32855
|
+
async persist(items, opts) {
|
|
32856
|
+
const { writeMedia: writeMedia2, resolveOutputDir: resolveOutputDir2 } = await Promise.resolve().then(() => (init_images(), images_exports));
|
|
32857
|
+
const dir = resolveOutputDir2(opts.output_dir);
|
|
32858
|
+
const refs = [];
|
|
32859
|
+
for (const it of items) refs.push(await writeMedia2(dir, it.base, it.base64, it.mimeType));
|
|
32860
|
+
return refs;
|
|
32861
|
+
},
|
|
32862
|
+
note: () => void 0
|
|
32863
|
+
};
|
|
32647
32864
|
}
|
|
32648
|
-
|
|
32649
|
-
|
|
32650
|
-
var
|
|
32651
|
-
var
|
|
32652
|
-
var
|
|
32653
|
-
var
|
|
32654
|
-
var
|
|
32655
|
-
|
|
32656
|
-
|
|
32657
|
-
|
|
32658
|
-
|
|
32659
|
-
|
|
32660
|
-
|
|
32661
|
-
|
|
32662
|
-
|
|
32663
|
-
|
|
32664
|
-
|
|
32665
|
-
|
|
32666
|
-
|
|
32667
|
-
|
|
32668
|
-
|
|
32669
|
-
|
|
32670
|
-
|
|
32671
|
-
|
|
32672
|
-
|
|
32865
|
+
|
|
32866
|
+
// src/tools/shared.ts
|
|
32867
|
+
var DEFAULT_SINK = createDiskSink();
|
|
32868
|
+
var ASPECT_RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9", "1:4", "4:1", "1:8", "8:1"];
|
|
32869
|
+
var IMAGE_SIZES = ["512", "1K", "2K", "4K"];
|
|
32870
|
+
var MODEL_CHOICE_GUIDE = "gemini-3.1-flash-image (Nano Banana 2) is the versatile generalist workhorse \u2014 balances speed with state-of-the-art 4K generation, world knowledge, and reliable text rendering; excels at multi-reference-image processing and consistency. gemini-3-pro-image (Nano Banana Pro) is the premium choice for the most complex visual tasks \u2014 highest world knowledge, advanced localization, accurate brand consistency, precision creative control. gemini-3.1-flash-lite-image (Nano Banana 2 Lite) is the fastest/cheapest for simple tasks (1K only, no search grounding).";
|
|
32871
|
+
var timeoutMsSchema = external_exports.number().int().positive().optional().describe(
|
|
32872
|
+
"Upstream request timeout in ms for this call (default: $GEMINI_TIMEOUT_MS, else 60000 \u2014 or 120000 when image_size is 4K, which routinely runs past 60s)"
|
|
32873
|
+
);
|
|
32874
|
+
var idempotencyKeySchema = external_exports.string().min(1).optional().describe(
|
|
32875
|
+
"Opaque idempotency key: a repeat call with the same key returns the recorded result (reused: true) instead of billing a new generation. Set it when retrying after a host timeout (-32001) to avoid a duplicate charge."
|
|
32876
|
+
);
|
|
32877
|
+
var asyncSchema = external_exports.boolean().optional().describe(
|
|
32878
|
+
"Run in the background and return a job_id immediately instead of the image, so a long (Pro/4K) generation cannot hit the host tools/call timeout (-32001). Poll gemini_get_result with the job_id to fetch the result (jobs are per-process and expire ~10 min after completion)."
|
|
32879
|
+
);
|
|
32880
|
+
var sharedImageSchema = {
|
|
32881
|
+
// Bare id only: the model is interpolated into the URL path
|
|
32882
|
+
// (`/models/${model}:generateContent`), so slashes/colons/queries must be
|
|
32883
|
+
// rejected to keep a crafted value from escaping the path segment.
|
|
32884
|
+
model: external_exports.string().regex(/^[\w.-]+$/, 'must be a bare model id (letters, digits, ".", "_", "-")').optional().describe(`Model id override (default: server default; see gemini_list_models). ${MODEL_CHOICE_GUIDE}`),
|
|
32885
|
+
aspect_ratio: external_exports.enum(ASPECT_RATIOS).optional().describe("Output aspect ratio"),
|
|
32886
|
+
image_size: external_exports.enum(IMAGE_SIZES).optional().describe("Output resolution (512 = 0.5K, Flash-only)"),
|
|
32887
|
+
output_dir: external_exports.string().optional().describe("Directory to write images to (default: $GEMINI_OUTPUT_DIR or cwd)"),
|
|
32888
|
+
inline: external_exports.boolean().optional().describe("Return base64 images inline instead of writing to disk"),
|
|
32889
|
+
seed: external_exports.number().int().optional().describe("Seed for reproducible generation; random if omitted"),
|
|
32890
|
+
thinking_level: external_exports.enum(["minimal", "high"]).optional().describe("Reasoning depth (Gemini 3 models); higher can help complex/structural edits"),
|
|
32891
|
+
google_search: external_exports.boolean().optional().describe("Ground the image in live Google Search results (current events, weather, data)"),
|
|
32892
|
+
from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS system clipboard as an input (downscaled to JPEG)"),
|
|
32893
|
+
timeout_ms: timeoutMsSchema,
|
|
32894
|
+
idempotency_key: idempotencyKeySchema,
|
|
32895
|
+
async: asyncSchema
|
|
32896
|
+
};
|
|
32897
|
+
async function resolveVideoInput(args, client2) {
|
|
32898
|
+
if (args.video_url && args.video_path) {
|
|
32899
|
+
throw new McpToolError("Provide `video_url` OR `video_path`, not both.");
|
|
32673
32900
|
}
|
|
32674
|
-
|
|
32675
|
-
|
|
32676
|
-
|
|
32677
|
-
|
|
32678
|
-
|
|
32679
|
-
|
|
32680
|
-
|
|
32681
|
-
|
|
32682
|
-
|
|
32683
|
-
|
|
32684
|
-
|
|
32685
|
-
|
|
32686
|
-
|
|
32687
|
-
|
|
32688
|
-
|
|
32689
|
-
|
|
32690
|
-
|
|
32691
|
-
|
|
32692
|
-
|
|
32693
|
-
|
|
32694
|
-
|
|
32695
|
-
|
|
32696
|
-
|
|
32697
|
-
|
|
32698
|
-
|
|
32699
|
-
|
|
32700
|
-
};
|
|
32701
|
-
this.apis.set(timeoutMs, entry);
|
|
32901
|
+
if (!args.video_path) return { videoUrl: args.video_url };
|
|
32902
|
+
const path = resolveVideoPath(args.video_path);
|
|
32903
|
+
const uploaded = await client2.uploadVideo(path, videoMimeType(args.video_path));
|
|
32904
|
+
const videoFileMeta = { uri: uploaded.uri, name: uploaded.name };
|
|
32905
|
+
if (uploaded.expirationTime) videoFileMeta.expires = uploaded.expirationTime;
|
|
32906
|
+
return { videoUrl: uploaded.uri, videoMimeType: uploaded.mimeType, videoFileMeta };
|
|
32907
|
+
}
|
|
32908
|
+
var videoPathSchema = external_exports.string().optional().describe(
|
|
32909
|
+
"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."
|
|
32910
|
+
);
|
|
32911
|
+
function pickSeed(seed) {
|
|
32912
|
+
return seed ?? Math.floor(Math.random() * 2147483e3);
|
|
32913
|
+
}
|
|
32914
|
+
var HEARTBEAT_DEFAULT_MS = 1e4;
|
|
32915
|
+
async function withProgressHeartbeat(extra, message, fn) {
|
|
32916
|
+
const progressToken = extra?._meta?.progressToken;
|
|
32917
|
+
const send = extra?.sendNotification;
|
|
32918
|
+
const env = Number(readEnvVar("GEMINI_HEARTBEAT_MS"));
|
|
32919
|
+
const intervalMs = Number.isFinite(env) ? env : HEARTBEAT_DEFAULT_MS;
|
|
32920
|
+
const debug = !!readEnvVar("GEMINI_DEBUG");
|
|
32921
|
+
const active = progressToken !== void 0 && !!send && intervalMs > 0;
|
|
32922
|
+
if (debug) {
|
|
32923
|
+
if (active) {
|
|
32924
|
+
console.error(`[gemini-mcp] heartbeat active: progressToken present, interval=${intervalMs}ms \u2014 ${message}`);
|
|
32925
|
+
} else {
|
|
32926
|
+
const reason = progressToken === void 0 ? "no progressToken from host (host cannot extend its tools/call timeout; long calls will hit it)" : !send ? "host provided no sendNotification channel" : "heartbeat disabled (GEMINI_HEARTBEAT_MS=0)";
|
|
32927
|
+
console.error(`[gemini-mcp] heartbeat inactive: ${reason} \u2014 ${message}`);
|
|
32702
32928
|
}
|
|
32703
|
-
return entry;
|
|
32704
32929
|
}
|
|
32705
|
-
|
|
32706
|
-
|
|
32707
|
-
|
|
32930
|
+
if (progressToken === void 0 || !send || intervalMs <= 0) return fn();
|
|
32931
|
+
let ticks = 0;
|
|
32932
|
+
const timer = setInterval(() => {
|
|
32933
|
+
ticks++;
|
|
32934
|
+
const elapsed = Math.round(ticks * intervalMs / 1e3);
|
|
32935
|
+
if (debug) console.error(`[gemini-mcp] heartbeat: ${message} \u2014 still working (${elapsed}s)`);
|
|
32936
|
+
void send({
|
|
32937
|
+
method: "notifications/progress",
|
|
32938
|
+
params: {
|
|
32939
|
+
progressToken,
|
|
32940
|
+
progress: ticks,
|
|
32941
|
+
message: `${message} \u2014 still working (${elapsed}s)`
|
|
32942
|
+
}
|
|
32943
|
+
}).catch(() => {
|
|
32944
|
+
});
|
|
32945
|
+
}, intervalMs);
|
|
32946
|
+
timer.unref?.();
|
|
32947
|
+
try {
|
|
32948
|
+
return await fn();
|
|
32949
|
+
} finally {
|
|
32950
|
+
clearInterval(timer);
|
|
32708
32951
|
}
|
|
32709
|
-
|
|
32710
|
-
|
|
32711
|
-
|
|
32952
|
+
}
|
|
32953
|
+
function timeoutRiskHint(opts) {
|
|
32954
|
+
const proModel = /(?:^|[-_./])pro(?:[-_./]|$)/i.test(opts.model);
|
|
32955
|
+
const fourK = opts.imageSize === "4K";
|
|
32956
|
+
const multi = (opts.count ?? 1) > 1;
|
|
32957
|
+
if (!proModel && !fourK && !multi) return void 0;
|
|
32958
|
+
const why = [proModel && "Pro model", fourK && "4K output", multi && `count=${opts.count}`].filter(Boolean).join(" + ");
|
|
32959
|
+
const lead = `Slow config (${why}) can exceed a host's tools/call timeout (e.g. Claude Desktop ~30s, error -32001). `;
|
|
32960
|
+
if (opts.persistsFiles === false) {
|
|
32961
|
+
return lead + `This connector has no filesystem, so a lost response cannot be recovered from disk \u2014 there is no output dir and no <image>.json sidecar. Use \`async: true\` to get a job_id immediately and poll gemini_get_result, or use a Flash model / drop image_size to 1K/2K for a faster in-band result.`;
|
|
32712
32962
|
}
|
|
32713
|
-
|
|
32714
|
-
|
|
32715
|
-
|
|
32963
|
+
return lead + `If the host gives up mid-generation the image is still written to disk (and gemini_interact leaves an <image>.json sidecar with the interaction id) \u2014 no rerun needed, just look in the output dir. For a faster in-band result use a Flash model, drop image_size to 1K/2K, or \u2014 in Claude Code \u2014 set MCP_TOOL_TIMEOUT.`;
|
|
32964
|
+
}
|
|
32965
|
+
function buildMeta(model, seed, opts) {
|
|
32966
|
+
const meta3 = { model, seed };
|
|
32967
|
+
if (opts.aspect_ratio) meta3.aspect_ratio = opts.aspect_ratio;
|
|
32968
|
+
if (opts.image_size) meta3.image_size = opts.image_size;
|
|
32969
|
+
return meta3;
|
|
32970
|
+
}
|
|
32971
|
+
async function emitMedia(named, kind, opts, meta3, onWritten) {
|
|
32972
|
+
const inline = opts.inline && kind !== "video";
|
|
32973
|
+
if (inline) {
|
|
32974
|
+
const blocks = named.map((n) => ({ type: kind, data: n.media.base64, mimeType: n.media.mimeType }));
|
|
32975
|
+
if (meta3 && Object.keys(meta3).length > 0) {
|
|
32976
|
+
return { content: [{ type: "text", text: JSON.stringify(meta3, null, 2) }, ...blocks] };
|
|
32977
|
+
}
|
|
32978
|
+
return { content: blocks };
|
|
32979
|
+
}
|
|
32980
|
+
const sink = opts.sink ?? DEFAULT_SINK;
|
|
32981
|
+
const refs = await sink.persist(named.map((n) => ({ base: n.base, base64: n.media.base64, mimeType: n.media.mimeType })), {
|
|
32982
|
+
output_dir: opts.output_dir
|
|
32983
|
+
});
|
|
32984
|
+
if (sink.persistsFiles) await onWritten?.(refs);
|
|
32985
|
+
const note = opts.inline && kind === "video" ? { inline_unsupported: "video has no MCP inline content type \u2014 written to disk instead" } : {};
|
|
32986
|
+
const storageNote = sink.note();
|
|
32987
|
+
const storage = sink.persistsFiles ? {} : { storage: sink.kind, storage_note: storageNote };
|
|
32988
|
+
return textResult({ [`${kind}s`]: refs, ...note, ...storage, ...meta3 });
|
|
32989
|
+
}
|
|
32990
|
+
async function emit(named, opts, meta3, onWritten) {
|
|
32991
|
+
return emitMedia(named.map((n) => ({ media: n.image, base: n.base })), "image", opts, meta3, onWritten);
|
|
32992
|
+
}
|
|
32993
|
+
function assertLocalInputsAvailable(sink, args) {
|
|
32994
|
+
if ((sink ?? DEFAULT_SINK).persistsFiles) return;
|
|
32995
|
+
const unavailable = [];
|
|
32996
|
+
if (args.images?.length) unavailable.push("`images` (local file paths)");
|
|
32997
|
+
if (args.master_images?.length) unavailable.push("`master_images` (local file paths)");
|
|
32998
|
+
if (args.from_clipboard) unavailable.push("`from_clipboard` (reads the macOS clipboard via child_process)");
|
|
32999
|
+
if (args.video_path) unavailable.push("`video_path` (uploads a file streamed from disk)");
|
|
33000
|
+
if (unavailable.length === 0) return;
|
|
33001
|
+
const fix = "Send image bytes as `images_base64` (raw base64 or a data: URI) instead of `images`/`master_images`/`from_clipboard`, and reference video by `video_url` (a public YouTube URL, or a files/\u2026 uri uploaded elsewhere) instead of `video_path`.";
|
|
33002
|
+
throw new McpToolError(
|
|
33003
|
+
`${unavailable.join(", ")} ${unavailable.length > 1 ? "are" : "is"} unavailable on the hosted connector: it runs on a Cloudflare Worker, which has no filesystem and no local clipboard. ${fix}`,
|
|
33004
|
+
{ hint: fix }
|
|
33005
|
+
);
|
|
33006
|
+
}
|
|
33007
|
+
|
|
33008
|
+
// src/jobs.ts
|
|
33009
|
+
init_dist();
|
|
33010
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
33011
|
+
var JOB_TTL_MS = 6e5;
|
|
33012
|
+
var JOB_MAX = 64;
|
|
33013
|
+
function isExpired(entry, now) {
|
|
33014
|
+
return entry.settledAt !== void 0 && now - entry.settledAt > JOB_TTL_MS;
|
|
33015
|
+
}
|
|
33016
|
+
function fingerprintRequest(toolName, parts) {
|
|
33017
|
+
return createHash("sha256").update(`${toolName}\0${JSON.stringify(parts)}`).digest("hex");
|
|
33018
|
+
}
|
|
33019
|
+
function annotateReused(result, jobId) {
|
|
33020
|
+
const content = result.content ?? [];
|
|
33021
|
+
const first = content[0];
|
|
33022
|
+
if (first && first.type === "text") {
|
|
33023
|
+
let obj;
|
|
33024
|
+
try {
|
|
33025
|
+
obj = JSON.parse(first.text);
|
|
33026
|
+
} catch {
|
|
33027
|
+
obj = {};
|
|
33028
|
+
}
|
|
33029
|
+
obj.reused = true;
|
|
33030
|
+
obj.reused_job_id = jobId;
|
|
33031
|
+
return { ...result, content: [{ ...first, text: JSON.stringify(obj, null, 2) }, ...content.slice(1)] };
|
|
33032
|
+
}
|
|
33033
|
+
return {
|
|
33034
|
+
...result,
|
|
33035
|
+
content: [{ type: "text", text: JSON.stringify({ reused: true, reused_job_id: jobId }, null, 2) }, ...content]
|
|
33036
|
+
};
|
|
33037
|
+
}
|
|
33038
|
+
function jobHandle(jobId, status) {
|
|
33039
|
+
return textResult({
|
|
33040
|
+
job_id: jobId,
|
|
33041
|
+
status,
|
|
33042
|
+
hint: `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done" (results are per-process and expire ~10 min after completion).`
|
|
33043
|
+
});
|
|
33044
|
+
}
|
|
33045
|
+
var JobRegistry = class {
|
|
33046
|
+
jobs = /* @__PURE__ */ new Map();
|
|
33047
|
+
byKey = /* @__PURE__ */ new Map();
|
|
33048
|
+
// idempotencyKey -> jobId
|
|
33049
|
+
runningByFingerprint = /* @__PURE__ */ new Map();
|
|
33050
|
+
// fingerprint -> jobId (only while running)
|
|
33051
|
+
/** Drop every job. Used by `SessionState.reset()` (tests) — not by handlers. */
|
|
33052
|
+
reset() {
|
|
33053
|
+
this.jobs.clear();
|
|
33054
|
+
this.byKey.clear();
|
|
33055
|
+
this.runningByFingerprint.clear();
|
|
33056
|
+
}
|
|
33057
|
+
/** Drop expired entries, then enforce JOB_MAX by evicting oldest-settled first. */
|
|
33058
|
+
evict(now) {
|
|
33059
|
+
for (const [id, entry] of this.jobs) {
|
|
33060
|
+
if (isExpired(entry, now)) this.remove(id, entry);
|
|
33061
|
+
}
|
|
33062
|
+
if (this.jobs.size < JOB_MAX) return;
|
|
33063
|
+
const settled = [...this.jobs.values()].filter((e) => e.settledAt !== void 0).sort((a, b) => (a.settledAt ?? 0) - (b.settledAt ?? 0));
|
|
33064
|
+
for (const e of settled) {
|
|
33065
|
+
if (this.jobs.size < JOB_MAX) break;
|
|
33066
|
+
this.remove(e.jobId, e);
|
|
33067
|
+
}
|
|
33068
|
+
}
|
|
33069
|
+
remove(id, entry) {
|
|
33070
|
+
this.jobs.delete(id);
|
|
33071
|
+
if (entry.idempotencyKey !== void 0 && this.byKey.get(entry.idempotencyKey) === id) this.byKey.delete(entry.idempotencyKey);
|
|
33072
|
+
if (this.runningByFingerprint.get(entry.fingerprint) === id) this.runningByFingerprint.delete(entry.fingerprint);
|
|
32716
33073
|
}
|
|
32717
33074
|
/**
|
|
32718
|
-
*
|
|
32719
|
-
*
|
|
32720
|
-
*
|
|
32721
|
-
*
|
|
32722
|
-
*
|
|
32723
|
-
*
|
|
32724
|
-
*
|
|
32725
|
-
*
|
|
32726
|
-
*
|
|
32727
|
-
*
|
|
32728
|
-
* `ACTIVE` (the poll response is the File object UNWRAPPED).
|
|
32729
|
-
*
|
|
32730
|
-
* These two upload calls are raw `fetchImpl` (not `createApiClient`): step 1
|
|
32731
|
-
* needs the response *header* and step 2 posts a binary body — neither fits
|
|
32732
|
-
* `fetchJson`. The poll DOES go through the shared client (timeout + retry).
|
|
32733
|
-
* Uploads deliberately have no abort timeout: a multi-hundred-MB video can
|
|
32734
|
-
* legitimately take longer than any fixed budget.
|
|
33075
|
+
* Run `work` under the registry, deduplicating where safe:
|
|
33076
|
+
* - with an `idempotencyKey`: attach to a matching `running` OR recently-`done`
|
|
33077
|
+
* job (not `failed`, not expired);
|
|
33078
|
+
* - without a key: attach only to a matching **in-flight** job by fingerprint
|
|
33079
|
+
* (two truly-simultaneous identical requests are a timeout-retry race, never
|
|
33080
|
+
* an intentional variation).
|
|
33081
|
+
* On a dedup hit the shared result is returned with `reused: true`. When
|
|
33082
|
+
* `async` is set, a `{ job_id, status }` handle is returned immediately (no
|
|
33083
|
+
* await) and the caller retrieves the result via `getResult`. Otherwise a
|
|
33084
|
+
* new job runs synchronously and failures propagate to the caller as before.
|
|
32735
33085
|
*
|
|
32736
|
-
*
|
|
32737
|
-
*
|
|
33086
|
+
* Both dedup paths are scoped to THIS registry, i.e. to one session — a
|
|
33087
|
+
* colliding `idempotency_key` or an identical prompt from another user is a
|
|
33088
|
+
* different registry and therefore a genuinely separate generation.
|
|
32738
33089
|
*/
|
|
32739
|
-
async
|
|
32740
|
-
const
|
|
32741
|
-
const
|
|
32742
|
-
|
|
32743
|
-
|
|
32744
|
-
|
|
32745
|
-
|
|
32746
|
-
|
|
32747
|
-
|
|
32748
|
-
|
|
32749
|
-
|
|
32750
|
-
|
|
32751
|
-
|
|
33090
|
+
async dispatch(opts, work) {
|
|
33091
|
+
const { toolName, fingerprint, idempotencyKey, async } = opts;
|
|
33092
|
+
const now = Date.now();
|
|
33093
|
+
let hit;
|
|
33094
|
+
if (idempotencyKey !== void 0) {
|
|
33095
|
+
const id = this.byKey.get(idempotencyKey);
|
|
33096
|
+
const entry2 = id ? this.jobs.get(id) : void 0;
|
|
33097
|
+
if (entry2 && entry2.status !== "failed" && !isExpired(entry2, now)) hit = entry2;
|
|
33098
|
+
} else {
|
|
33099
|
+
const id = this.runningByFingerprint.get(fingerprint);
|
|
33100
|
+
const entry2 = id ? this.jobs.get(id) : void 0;
|
|
33101
|
+
if (entry2 && entry2.status === "running") hit = entry2;
|
|
33102
|
+
}
|
|
33103
|
+
if (hit) {
|
|
33104
|
+
if (async) return jobHandle(hit.jobId, hit.status);
|
|
33105
|
+
return annotateReused(await hit.promise, hit.jobId);
|
|
33106
|
+
}
|
|
33107
|
+
const jobId = randomUUID();
|
|
33108
|
+
this.evict(now);
|
|
33109
|
+
const promise2 = work();
|
|
33110
|
+
const entry = { jobId, toolName, fingerprint, idempotencyKey, status: "running", promise: promise2, createdAt: now };
|
|
33111
|
+
this.jobs.set(jobId, entry);
|
|
33112
|
+
if (idempotencyKey !== void 0) this.byKey.set(idempotencyKey, jobId);
|
|
33113
|
+
this.runningByFingerprint.set(fingerprint, jobId);
|
|
33114
|
+
promise2.then(
|
|
33115
|
+
(res) => {
|
|
33116
|
+
entry.status = "done";
|
|
33117
|
+
entry.result = res;
|
|
33118
|
+
entry.settledAt = Date.now();
|
|
32752
33119
|
},
|
|
32753
|
-
|
|
33120
|
+
(err) => {
|
|
33121
|
+
entry.status = "failed";
|
|
33122
|
+
entry.error = { message: err instanceof Error ? err.message : String(err), hint: err?.hint };
|
|
33123
|
+
entry.settledAt = Date.now();
|
|
33124
|
+
}
|
|
33125
|
+
).finally(() => {
|
|
33126
|
+
if (this.runningByFingerprint.get(fingerprint) === jobId) this.runningByFingerprint.delete(fingerprint);
|
|
32754
33127
|
});
|
|
32755
|
-
if (
|
|
32756
|
-
|
|
32757
|
-
|
|
32758
|
-
|
|
32759
|
-
|
|
32760
|
-
|
|
32761
|
-
|
|
32762
|
-
|
|
32763
|
-
|
|
33128
|
+
if (async) return jobHandle(jobId, "running");
|
|
33129
|
+
return promise2;
|
|
33130
|
+
}
|
|
33131
|
+
/**
|
|
33132
|
+
* Retrieve an async job by id (backs the `gemini_get_result` tool):
|
|
33133
|
+
* `running` → a status handle; `done` → the recorded result payload;
|
|
33134
|
+
* `failed` → the recorded error; unknown/expired → an actionable error.
|
|
33135
|
+
*
|
|
33136
|
+
* A job id from ANOTHER session is simply not in this registry, so it takes
|
|
33137
|
+
* the unknown-id path — the poll tool cannot be used to read another user's
|
|
33138
|
+
* result even if they know the uuid.
|
|
33139
|
+
*/
|
|
33140
|
+
getResult(jobId) {
|
|
33141
|
+
const entry = this.jobs.get(jobId);
|
|
33142
|
+
if (!entry) {
|
|
33143
|
+
throw new McpToolError(`No job "${jobId}" \u2014 unknown or expired.`, {
|
|
33144
|
+
hint: "Jobs live with your session and are evicted ~10 min after completion (or on server restart). If the generation likely finished, check the output dir / <image>.json sidecar instead of polling."
|
|
32764
33145
|
});
|
|
32765
33146
|
}
|
|
32766
|
-
|
|
32767
|
-
|
|
32768
|
-
|
|
32769
|
-
"X-Goog-Upload-Offset": "0",
|
|
32770
|
-
"X-Goog-Upload-Command": "upload, finalize"
|
|
32771
|
-
},
|
|
32772
|
-
body: blob
|
|
32773
|
-
});
|
|
32774
|
-
if (!upRes.ok) {
|
|
32775
|
-
throw new McpToolError(
|
|
32776
|
-
formatApiError(upRes.status, "POST", "/upload/v1beta/files (finalize)", await upRes.text(), { service: SERVICE })
|
|
32777
|
-
);
|
|
32778
|
-
}
|
|
32779
|
-
let file2 = (await upRes.json()).file ?? {};
|
|
32780
|
-
const name = file2.name ?? "";
|
|
32781
|
-
if (!/^files\/[\w-]+$/.test(name)) {
|
|
32782
|
-
throw new McpToolError(`${SERVICE} upload returned an unexpected file name: ${name || "(none)"}`);
|
|
32783
|
-
}
|
|
32784
|
-
for (let attempt = 0; file2.state === "PROCESSING"; attempt++) {
|
|
32785
|
-
if (attempt >= FILE_POLL_MAX_ATTEMPTS) {
|
|
32786
|
-
throw new McpToolError(`${SERVICE} video file ${name} is still processing after ${FILE_POLL_INTERVAL_MS * FILE_POLL_MAX_ATTEMPTS / 6e4} minutes`, {
|
|
32787
|
-
hint: "Very long videos can take a while to process \u2014 retry later by passing the file uri as video_url."
|
|
32788
|
-
});
|
|
32789
|
-
}
|
|
32790
|
-
await this.sleep(FILE_POLL_INTERVAL_MS);
|
|
32791
|
-
file2 = await this.call("GET", `/${name}`);
|
|
32792
|
-
}
|
|
32793
|
-
if (file2.state !== "ACTIVE") {
|
|
32794
|
-
throw new McpToolError(
|
|
32795
|
-
`${SERVICE} video file processing failed (state ${file2.state ?? "unknown"})${file2.error?.message ? `: ${file2.error.message}` : ""}`,
|
|
32796
|
-
{ hint: "Check the video is a supported format/codec and under 2 GB." }
|
|
32797
|
-
);
|
|
32798
|
-
}
|
|
32799
|
-
if (!file2.uri) {
|
|
32800
|
-
throw new McpToolError(`${SERVICE} did not return a uri for uploaded file ${name}`);
|
|
33147
|
+
if (entry.status === "running") return jobHandle(jobId, "running");
|
|
33148
|
+
if (entry.status === "failed") {
|
|
33149
|
+
throw new McpToolError(entry.error?.message ?? "Job failed.", entry.error?.hint ? { hint: entry.error.hint } : void 0);
|
|
32801
33150
|
}
|
|
32802
|
-
|
|
32803
|
-
|
|
32804
|
-
async listModels() {
|
|
32805
|
-
const data = await this.call("GET", "/models?pageSize=200");
|
|
32806
|
-
return filterImageModels(data.models ?? []);
|
|
33151
|
+
if (!entry.result) throw new McpToolError(`Job "${jobId}" completed without a recorded result.`);
|
|
33152
|
+
return entry.result;
|
|
32807
33153
|
}
|
|
32808
|
-
|
|
32809
|
-
|
|
32810
|
-
|
|
32811
|
-
|
|
32812
|
-
|
|
32813
|
-
|
|
32814
|
-
|
|
32815
|
-
|
|
32816
|
-
|
|
32817
|
-
|
|
32818
|
-
|
|
32819
|
-
|
|
32820
|
-
|
|
32821
|
-
|
|
32822
|
-
|
|
32823
|
-
|
|
32824
|
-
|
|
32825
|
-
|
|
32826
|
-
|
|
32827
|
-
|
|
32828
|
-
|
|
32829
|
-
|
|
32830
|
-
|
|
32831
|
-
|
|
32832
|
-
|
|
32833
|
-
|
|
32834
|
-
|
|
32835
|
-
|
|
32836
|
-
|
|
32837
|
-
|
|
32838
|
-
|
|
32839
|
-
|
|
32840
|
-
|
|
32841
|
-
|
|
32842
|
-
|
|
32843
|
-
|
|
32844
|
-
|
|
32845
|
-
|
|
32846
|
-
|
|
32847
|
-
|
|
32848
|
-
|
|
32849
|
-
|
|
32850
|
-
|
|
32851
|
-
|
|
32852
|
-
|
|
32853
|
-
const text = textParts.join("\n") || void 0;
|
|
32854
|
-
let grounding;
|
|
32855
|
-
if (groundingMeta) {
|
|
32856
|
-
const queries = (groundingMeta.webSearchQueries ?? []).filter(Boolean);
|
|
32857
|
-
const sources = (groundingMeta.groundingChunks ?? []).map((c) => c.web).filter((w) => !!w);
|
|
32858
|
-
if (queries.length > 0 || sources.length > 0) {
|
|
32859
|
-
grounding = {};
|
|
32860
|
-
if (queries.length > 0) grounding.queries = queries;
|
|
32861
|
-
if (sources.length > 0) grounding.sources = sources;
|
|
33154
|
+
};
|
|
33155
|
+
|
|
33156
|
+
// src/tools/_confirm.ts
|
|
33157
|
+
init_dist();
|
|
33158
|
+
init_images();
|
|
33159
|
+
function previewUnlessConfirmed(confirm, action, method, path, body) {
|
|
33160
|
+
if (confirm === true) return null;
|
|
33161
|
+
return textResult({
|
|
33162
|
+
dryRun: true,
|
|
33163
|
+
action,
|
|
33164
|
+
method,
|
|
33165
|
+
path,
|
|
33166
|
+
...body !== void 0 ? { willSend: body } : {},
|
|
33167
|
+
note: "Re-run with confirm: true to execute."
|
|
33168
|
+
});
|
|
33169
|
+
}
|
|
33170
|
+
async function previewLocalInputsUnlessConfirmed(confirm, action, endpoint, imagePaths, videoPath) {
|
|
33171
|
+
const hasLocal = (imagePaths?.length ?? 0) > 0 || Boolean(videoPath);
|
|
33172
|
+
if (confirm === true || !hasLocal) return null;
|
|
33173
|
+
const inputs = [];
|
|
33174
|
+
for (const p of imagePaths ?? []) inputs.push(await previewImageInput(p));
|
|
33175
|
+
if (videoPath) inputs.push(await previewVideoInput(videoPath));
|
|
33176
|
+
return previewUnlessConfirmed(confirm, action, "POST", endpoint, { inputs });
|
|
33177
|
+
}
|
|
33178
|
+
|
|
33179
|
+
// src/tools/generate.ts
|
|
33180
|
+
var GENERATE_ENDPOINT = "/v1beta/models/{model}:generateContent";
|
|
33181
|
+
var SEND_LOCAL_ACTION = "Send local file input(s) to the Gemini API";
|
|
33182
|
+
var REFINE_HINT = "To iteratively refine this image, use gemini_interact (pass the output file via `images` on the first call, then chain `previous_interaction_id` on each subsequent call).";
|
|
33183
|
+
function registerGenerateTools(server, client2) {
|
|
33184
|
+
server.registerTool(
|
|
33185
|
+
"gemini_image_generate",
|
|
33186
|
+
{
|
|
33187
|
+
description: "Generate image(s) from a text prompt with a Gemini image model (Nano Banana / Nano Banana Pro). If the result will likely be refined iteratively, prefer gemini_interact (multi-turn) as the entry point.",
|
|
33188
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
33189
|
+
inputSchema: {
|
|
33190
|
+
prompt: external_exports.string().min(1).describe("Text prompt describing the image"),
|
|
33191
|
+
count: external_exports.number().int().positive().max(8).optional().describe("Number of independent images (default 1)"),
|
|
33192
|
+
filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified prompt)"),
|
|
33193
|
+
images: external_exports.array(external_exports.string().min(1)).optional().describe("Paths to reference input images (image-conditioned generation)"),
|
|
33194
|
+
images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs"),
|
|
33195
|
+
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)"),
|
|
33196
|
+
video_path: videoPathSchema,
|
|
33197
|
+
confirm: schemaConfirm,
|
|
33198
|
+
...sharedImageSchema
|
|
32862
33199
|
}
|
|
32863
|
-
}
|
|
32864
|
-
|
|
32865
|
-
|
|
32866
|
-
|
|
32867
|
-
|
|
32868
|
-
|
|
32869
|
-
|
|
32870
|
-
|
|
32871
|
-
|
|
32872
|
-
|
|
32873
|
-
|
|
32874
|
-
|
|
32875
|
-
|
|
32876
|
-
|
|
32877
|
-
|
|
32878
|
-
|
|
32879
|
-
|
|
32880
|
-
|
|
32881
|
-
|
|
32882
|
-
|
|
32883
|
-
if (opts.searchTypes?.length) searchTool.search_types = opts.searchTypes;
|
|
32884
|
-
body.tools = [searchTool];
|
|
32885
|
-
}
|
|
32886
|
-
const data = await this.postInteraction(body, opts);
|
|
32887
|
-
const { images, text, grounding } = this.extractInteraction(data, opts.searchTypes);
|
|
32888
|
-
if (images.length === 0) {
|
|
32889
|
-
throw new McpToolError("Gemini returned no image", {
|
|
32890
|
-
hint: "The request may have been blocked by safety filters \u2014 try rephrasing the prompt."
|
|
32891
|
-
});
|
|
32892
|
-
}
|
|
32893
|
-
return { id: data.id, images, text, grounding };
|
|
32894
|
-
}
|
|
32895
|
-
/**
|
|
32896
|
-
* Generate video via the omni model (Interactions API, inline delivery). Same
|
|
32897
|
-
* plumbing as {@link interact} — text/image input parts, a `video`
|
|
32898
|
-
* response_format, optional `video_config.task`, and `previous_interaction_id`
|
|
32899
|
-
* for edits. Preview model: shapes are docs-derived; parsing stays tolerant.
|
|
32900
|
-
*/
|
|
32901
|
-
async generateVideo(opts) {
|
|
32902
|
-
const model = opts.model ?? DEFAULT_VIDEO_MODEL;
|
|
32903
|
-
const inputParts = [{ type: "text", text: opts.input }];
|
|
32904
|
-
for (const img of opts.images ?? []) inputParts.push({ type: "image", mime_type: img.mimeType, data: img.base64 });
|
|
32905
|
-
const responseFormat = { type: "video", delivery: "inline" };
|
|
32906
|
-
if (opts.aspectRatio) responseFormat.aspect_ratio = opts.aspectRatio;
|
|
32907
|
-
const body = { model, input: inputParts, response_format: responseFormat };
|
|
32908
|
-
if (opts.task) body.generation_config = { video_config: { task: opts.task } };
|
|
32909
|
-
if (opts.previousInteractionId !== void 0) body.previous_interaction_id = opts.previousInteractionId;
|
|
32910
|
-
const data = await this.postInteraction(body, opts);
|
|
32911
|
-
const { videos, text } = this.extractInteraction(data);
|
|
32912
|
-
if (videos.length === 0) {
|
|
32913
|
-
throw new McpToolError("Gemini returned no video", {
|
|
32914
|
-
hint: "The request may have been blocked by a safety filter, or the clip exceeded the ~4MB inline limit \u2014 try a shorter/simpler prompt or a different aspect ratio."
|
|
32915
|
-
});
|
|
32916
|
-
}
|
|
32917
|
-
return { id: data.id, videos, text };
|
|
32918
|
-
}
|
|
32919
|
-
/**
|
|
32920
|
-
* Generate music via a Lyria model (Interactions API, inline audio). Reuses
|
|
32921
|
-
* the interact plumbing with an `audio` response_format. Preview model.
|
|
32922
|
-
*/
|
|
32923
|
-
async generateMusic(opts) {
|
|
32924
|
-
const model = opts.model ?? DEFAULT_MUSIC_MODEL;
|
|
32925
|
-
const inputParts = [{ type: "text", text: opts.input }];
|
|
32926
|
-
for (const img of opts.images ?? []) inputParts.push({ type: "image", mime_type: img.mimeType, data: img.base64 });
|
|
32927
|
-
const responseFormat = { type: "audio" };
|
|
32928
|
-
if (opts.audioFormat) responseFormat.audio_format = opts.audioFormat;
|
|
32929
|
-
const body = { model, input: inputParts, response_format: responseFormat };
|
|
32930
|
-
if (opts.previousInteractionId !== void 0) body.previous_interaction_id = opts.previousInteractionId;
|
|
32931
|
-
const data = await this.postInteraction(body, opts);
|
|
32932
|
-
const { audios, text } = this.extractInteraction(data);
|
|
32933
|
-
if (audios.length === 0) {
|
|
32934
|
-
throw new McpToolError("Gemini returned no audio", {
|
|
32935
|
-
hint: "The request may have been blocked by a safety filter \u2014 try rephrasing the prompt."
|
|
33200
|
+
},
|
|
33201
|
+
async (args, extra) => {
|
|
33202
|
+
assertLocalInputsAvailable(client2.mediaSink, args);
|
|
33203
|
+
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images, args.video_path);
|
|
33204
|
+
if (gate) return gate;
|
|
33205
|
+
const count = args.count ?? 1;
|
|
33206
|
+
const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
33207
|
+
const fingerprint = fingerprintRequest("gemini_image_generate", {
|
|
33208
|
+
model,
|
|
33209
|
+
prompt: args.prompt,
|
|
33210
|
+
aspect_ratio: args.aspect_ratio,
|
|
33211
|
+
image_size: args.image_size,
|
|
33212
|
+
thinking_level: args.thinking_level,
|
|
33213
|
+
google_search: args.google_search,
|
|
33214
|
+
count,
|
|
33215
|
+
images: args.images,
|
|
33216
|
+
images_base64: args.images_base64,
|
|
33217
|
+
from_clipboard: args.from_clipboard,
|
|
33218
|
+
video_url: args.video_url,
|
|
33219
|
+
video_path: args.video_path
|
|
32936
33220
|
});
|
|
32937
|
-
|
|
32938
|
-
|
|
32939
|
-
|
|
32940
|
-
|
|
32941
|
-
|
|
32942
|
-
|
|
32943
|
-
|
|
32944
|
-
|
|
32945
|
-
|
|
32946
|
-
|
|
32947
|
-
|
|
32948
|
-
|
|
32949
|
-
|
|
32950
|
-
|
|
32951
|
-
|
|
32952
|
-
|
|
32953
|
-
|
|
32954
|
-
|
|
32955
|
-
|
|
32956
|
-
|
|
32957
|
-
|
|
32958
|
-
|
|
32959
|
-
|
|
32960
|
-
|
|
32961
|
-
|
|
32962
|
-
|
|
32963
|
-
|
|
32964
|
-
* interactions response. Only the `model_output` step is surfaced — `thought`
|
|
32965
|
-
* steps (internal reasoning / draft images) are dropped on purpose so they
|
|
32966
|
-
* can't leak into text or pollute the outputs. Tolerant of snake/camel MIME.
|
|
32967
|
-
*/
|
|
32968
|
-
extractInteraction(data, searchTypes) {
|
|
32969
|
-
const images = [];
|
|
32970
|
-
const videos = [];
|
|
32971
|
-
const audios = [];
|
|
32972
|
-
const textParts = [];
|
|
32973
|
-
const searchQueries = [];
|
|
32974
|
-
const searchSuggestions = /* @__PURE__ */ new Set();
|
|
32975
|
-
for (const step of data.steps ?? []) {
|
|
32976
|
-
if (step.type === "google_search_call") {
|
|
32977
|
-
for (const q of step.arguments?.queries ?? []) if (q?.trim()) searchQueries.push(q);
|
|
32978
|
-
continue;
|
|
32979
|
-
}
|
|
32980
|
-
if (step.type === "google_search_result") {
|
|
32981
|
-
if (searchTypes?.includes("image_search")) {
|
|
32982
|
-
for (const r of step.result ?? []) if (r.search_suggestions?.trim()) searchSuggestions.add(r.search_suggestions);
|
|
32983
|
-
}
|
|
32984
|
-
continue;
|
|
32985
|
-
}
|
|
32986
|
-
if (step.type !== "model_output") continue;
|
|
32987
|
-
for (const parts of [step.content ?? [], step.summary ?? []]) {
|
|
32988
|
-
for (const part of parts) {
|
|
32989
|
-
const mime = part.mime_type ?? part.mimeType;
|
|
32990
|
-
if (part.data) {
|
|
32991
|
-
if (part.type === "image") images.push({ base64: part.data, mimeType: mime ?? "image/jpeg" });
|
|
32992
|
-
else if (part.type === "video") videos.push({ base64: part.data, mimeType: mime ?? "video/mp4" });
|
|
32993
|
-
else if (part.type === "audio") audios.push({ base64: part.data, mimeType: mime ?? "audio/mpeg" });
|
|
32994
|
-
} else if (part.type === "text" && part.text?.trim()) {
|
|
32995
|
-
textParts.push(part.text);
|
|
33221
|
+
return client2.session.jobs.dispatch({ toolName: "gemini_image_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
33222
|
+
const seed = pickSeed(args.seed);
|
|
33223
|
+
const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
|
|
33224
|
+
const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
33225
|
+
const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args, client2));
|
|
33226
|
+
const named = [];
|
|
33227
|
+
let capturedText;
|
|
33228
|
+
let capturedGrounding;
|
|
33229
|
+
await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : "image"} (${model})`, async () => {
|
|
33230
|
+
for (let i = 0; i < count; i++) {
|
|
33231
|
+
const result = await client2.generate({
|
|
33232
|
+
prompt: args.prompt,
|
|
33233
|
+
images: refInputs.length > 0 ? refInputs : void 0,
|
|
33234
|
+
model: args.model,
|
|
33235
|
+
aspectRatio: args.aspect_ratio,
|
|
33236
|
+
imageSize: args.image_size,
|
|
33237
|
+
seed: seed + i,
|
|
33238
|
+
thinkingLevel: args.thinking_level,
|
|
33239
|
+
googleSearch: args.google_search,
|
|
33240
|
+
videoUrl: video.videoUrl,
|
|
33241
|
+
videoMimeType: video.videoMimeType,
|
|
33242
|
+
timeoutMs: args.timeout_ms
|
|
33243
|
+
});
|
|
33244
|
+
const { images: [img], text } = result;
|
|
33245
|
+
if (text && !capturedText) capturedText = text;
|
|
33246
|
+
if (result.grounding && !capturedGrounding) capturedGrounding = result.grounding;
|
|
33247
|
+
named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, "0")}` });
|
|
32996
33248
|
}
|
|
32997
|
-
}
|
|
32998
|
-
|
|
32999
|
-
|
|
33000
|
-
|
|
33001
|
-
|
|
33002
|
-
|
|
33003
|
-
|
|
33004
|
-
|
|
33249
|
+
});
|
|
33250
|
+
const meta3 = buildMeta(model, seed, args);
|
|
33251
|
+
if (capturedText) meta3.text = capturedText;
|
|
33252
|
+
if (capturedGrounding) meta3.grounding = capturedGrounding;
|
|
33253
|
+
if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
|
|
33254
|
+
meta3.hint = REFINE_HINT;
|
|
33255
|
+
const risk = timeoutRiskHint({ model, imageSize: args.image_size, count, persistsFiles: client2.mediaSink?.persistsFiles });
|
|
33256
|
+
if (risk) meta3.timeout_risk = risk;
|
|
33257
|
+
return emit(named, { ...args, sink: client2.mediaSink }, meta3);
|
|
33258
|
+
});
|
|
33005
33259
|
}
|
|
33006
|
-
|
|
33007
|
-
}
|
|
33008
|
-
};
|
|
33009
|
-
var client = new GeminiClient();
|
|
33010
|
-
|
|
33011
|
-
// src/tools/models.ts
|
|
33012
|
-
function registerModelTools(server) {
|
|
33260
|
+
);
|
|
33013
33261
|
server.registerTool(
|
|
33014
|
-
"
|
|
33262
|
+
"gemini_image_edit",
|
|
33015
33263
|
{
|
|
33016
|
-
description: "
|
|
33017
|
-
annotations: { readOnlyHint: true }
|
|
33264
|
+
description: "Edit or compose images: provide one or more input images (paths or base64), plus a text instruction. For a SERIES of successive edits to the same image, prefer gemini_interact (multi-turn) \u2014 it keeps edit context and avoids re-processing the full image each round; use gemini_image_edit for one-off edits or composing multiple distinct inputs. Gemini over-preserves the input; there is no edit-strength control \u2014 for large structural changes, reroll with a different `seed` or more forceful wording.",
|
|
33265
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
33266
|
+
inputSchema: {
|
|
33267
|
+
prompt: external_exports.string().min(1).describe("Instruction describing the edit or composition"),
|
|
33268
|
+
images: external_exports.array(external_exports.string().min(1)).optional().describe("Paths to input image file(s) (1 = edit, 2+ = compose)"),
|
|
33269
|
+
images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Input images as base64 strings or data URIs"),
|
|
33270
|
+
filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified prompt)"),
|
|
33271
|
+
confirm: schemaConfirm,
|
|
33272
|
+
...sharedImageSchema
|
|
33273
|
+
}
|
|
33018
33274
|
},
|
|
33019
|
-
async () => {
|
|
33020
|
-
|
|
33021
|
-
|
|
33275
|
+
async (args, extra) => {
|
|
33276
|
+
assertLocalInputsAvailable(client2.mediaSink, args);
|
|
33277
|
+
const hasPaths = (args.images?.length ?? 0) > 0;
|
|
33278
|
+
const hasBase64 = (args.images_base64?.length ?? 0) > 0;
|
|
33279
|
+
if (!hasPaths && !hasBase64 && !args.from_clipboard) {
|
|
33280
|
+
throw new McpToolError("Provide at least one input image via `images`, `images_base64`, or `from_clipboard`.");
|
|
33281
|
+
}
|
|
33282
|
+
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
|
|
33283
|
+
if (gate) return gate;
|
|
33284
|
+
const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
33285
|
+
const fingerprint = fingerprintRequest("gemini_image_edit", {
|
|
33286
|
+
model,
|
|
33287
|
+
prompt: args.prompt,
|
|
33288
|
+
aspect_ratio: args.aspect_ratio,
|
|
33289
|
+
image_size: args.image_size,
|
|
33290
|
+
thinking_level: args.thinking_level,
|
|
33291
|
+
google_search: args.google_search,
|
|
33292
|
+
images: args.images,
|
|
33293
|
+
images_base64: args.images_base64,
|
|
33294
|
+
from_clipboard: args.from_clipboard
|
|
33295
|
+
});
|
|
33296
|
+
return client2.session.jobs.dispatch({ toolName: "gemini_image_edit", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
33297
|
+
const seed = pickSeed(args.seed);
|
|
33298
|
+
const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
|
|
33299
|
+
const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
33300
|
+
const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client2.generate({
|
|
33301
|
+
prompt: args.prompt,
|
|
33302
|
+
images: inputs,
|
|
33303
|
+
model: args.model,
|
|
33304
|
+
aspectRatio: args.aspect_ratio,
|
|
33305
|
+
imageSize: args.image_size,
|
|
33306
|
+
seed,
|
|
33307
|
+
thinkingLevel: args.thinking_level,
|
|
33308
|
+
googleSearch: args.google_search,
|
|
33309
|
+
timeoutMs: args.timeout_ms
|
|
33310
|
+
}));
|
|
33311
|
+
const { images: [img], text } = result;
|
|
33312
|
+
const meta3 = buildMeta(model, seed, args);
|
|
33313
|
+
if (text) meta3.text = text;
|
|
33314
|
+
if (result.grounding) meta3.grounding = result.grounding;
|
|
33315
|
+
meta3.hint = REFINE_HINT;
|
|
33316
|
+
const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: client2.mediaSink?.persistsFiles });
|
|
33317
|
+
if (risk) meta3.timeout_risk = risk;
|
|
33318
|
+
return emit([{ image: img, base: slug }], { ...args, sink: client2.mediaSink }, meta3);
|
|
33319
|
+
});
|
|
33022
33320
|
}
|
|
33023
33321
|
);
|
|
33024
33322
|
}
|
|
33025
33323
|
|
|
33026
|
-
// src/tools/
|
|
33324
|
+
// src/tools/set.ts
|
|
33027
33325
|
init_zod();
|
|
33028
33326
|
init_dist();
|
|
33029
|
-
|
|
33030
|
-
|
|
33031
|
-
|
|
33032
|
-
|
|
33033
|
-
|
|
33034
|
-
|
|
33035
|
-
|
|
33036
|
-
|
|
33037
|
-
|
|
33038
|
-
|
|
33039
|
-
|
|
33040
|
-
|
|
33041
|
-
|
|
33042
|
-
|
|
33043
|
-
|
|
33044
|
-
|
|
33045
|
-
|
|
33046
|
-
}
|
|
33047
|
-
|
|
33048
|
-
|
|
33049
|
-
|
|
33050
|
-
|
|
33051
|
-
|
|
33052
|
-
|
|
33053
|
-
|
|
33054
|
-
|
|
33055
|
-
|
|
33056
|
-
|
|
33057
|
-
|
|
33058
|
-
|
|
33059
|
-
|
|
33060
|
-
|
|
33061
|
-
|
|
33062
|
-
|
|
33063
|
-
|
|
33064
|
-
|
|
33065
|
-
|
|
33066
|
-
|
|
33067
|
-
|
|
33068
|
-
|
|
33069
|
-
|
|
33070
|
-
|
|
33071
|
-
|
|
33072
|
-
|
|
33073
|
-
|
|
33074
|
-
|
|
33075
|
-
|
|
33076
|
-
|
|
33077
|
-
|
|
33078
|
-
|
|
33079
|
-
|
|
33080
|
-
|
|
33081
|
-
|
|
33082
|
-
|
|
33083
|
-
|
|
33084
|
-
|
|
33085
|
-
|
|
33086
|
-
|
|
33087
|
-
|
|
33088
|
-
|
|
33089
|
-
|
|
33090
|
-
|
|
33091
|
-
|
|
33092
|
-
|
|
33093
|
-
|
|
33094
|
-
|
|
33095
|
-
|
|
33096
|
-
|
|
33097
|
-
|
|
33098
|
-
|
|
33099
|
-
|
|
33100
|
-
|
|
33101
|
-
|
|
33102
|
-
|
|
33103
|
-
|
|
33104
|
-
|
|
33105
|
-
|
|
33106
|
-
|
|
33107
|
-
|
|
33108
|
-
|
|
33109
|
-
|
|
33110
|
-
}
|
|
33111
|
-
|
|
33112
|
-
mp4: "video/mp4",
|
|
33113
|
-
mpeg: "video/mpeg",
|
|
33114
|
-
mpg: "video/mpg",
|
|
33115
|
-
mov: "video/mov",
|
|
33116
|
-
avi: "video/avi",
|
|
33117
|
-
flv: "video/x-flv",
|
|
33118
|
-
webm: "video/webm",
|
|
33119
|
-
wmv: "video/wmv",
|
|
33120
|
-
"3gp": "video/3gpp",
|
|
33121
|
-
"3gpp": "video/3gpp"
|
|
33122
|
-
};
|
|
33123
|
-
function videoMimeType(p) {
|
|
33124
|
-
const ext = p.includes(".") ? p.slice(p.lastIndexOf(".") + 1).toLowerCase() : "";
|
|
33125
|
-
const mime = VIDEO_MIME_BY_EXT[ext];
|
|
33126
|
-
if (!mime) {
|
|
33127
|
-
throw new McpToolError(`Unsupported video format: ${ext ? `.${ext}` : p}`, {
|
|
33128
|
-
hint: `Supported extensions: ${Object.keys(VIDEO_MIME_BY_EXT).map((e) => `.${e}`).join(", ")}`
|
|
33129
|
-
});
|
|
33130
|
-
}
|
|
33131
|
-
return mime;
|
|
33132
|
-
}
|
|
33133
|
-
async function previewImageInput(path) {
|
|
33134
|
-
const resolved = resolveImagePath(path);
|
|
33135
|
-
const { size } = await stat(resolved);
|
|
33136
|
-
const fh = await open(resolved, "r");
|
|
33137
|
-
try {
|
|
33138
|
-
const head = Buffer.alloc(16);
|
|
33139
|
-
const { bytesRead } = await fh.read(head, 0, 16, 0);
|
|
33140
|
-
return { path: resolved, mimeType: sniffMimeBytes(head.subarray(0, bytesRead)), size };
|
|
33141
|
-
} finally {
|
|
33142
|
-
await fh.close();
|
|
33143
|
-
}
|
|
33144
|
-
}
|
|
33145
|
-
async function previewVideoInput(path) {
|
|
33146
|
-
const resolved = resolveVideoPath(path);
|
|
33147
|
-
const { size } = await stat(resolved);
|
|
33148
|
-
return { path: resolved, mimeType: videoMimeType(resolved), size };
|
|
33149
|
-
}
|
|
33150
|
-
async function readImageAsInline(path) {
|
|
33151
|
-
const resolved = resolveImagePath(path);
|
|
33152
|
-
const buf = await readFile2(resolved);
|
|
33153
|
-
const mimeType = sniffMimeBytes(buf);
|
|
33154
|
-
return { base64: buf.toString("base64"), mimeType };
|
|
33155
|
-
}
|
|
33156
|
-
function decodeImageInput(input) {
|
|
33157
|
-
const trimmed = input.trim();
|
|
33158
|
-
if (trimmed.startsWith("data:")) {
|
|
33159
|
-
const marker = trimmed.indexOf(";base64,");
|
|
33160
|
-
if (marker === -1) {
|
|
33161
|
-
throw new Error(`Unsupported data URI (expected ;base64,<data>): ${trimmed.slice(0, 48)}\u2026`);
|
|
33162
|
-
}
|
|
33163
|
-
const mimeType = trimmed.slice(5, trimmed.indexOf(";", 5)) || "image/png";
|
|
33164
|
-
return { mimeType, base64: trimmed.slice(marker + ";base64,".length) };
|
|
33165
|
-
}
|
|
33166
|
-
const buf = Buffer.from(trimmed, "base64");
|
|
33167
|
-
return { base64: trimmed, mimeType: sniffMimeBytes(buf) };
|
|
33168
|
-
}
|
|
33169
|
-
async function loadImageInputs(paths = [], base64s = []) {
|
|
33170
|
-
const fromPaths = await Promise.all(paths.map((p) => readImageAsInline(p)));
|
|
33171
|
-
const fromBase64s = base64s.map((b) => decodeImageInput(b));
|
|
33172
|
-
return [...fromPaths, ...fromBase64s];
|
|
33173
|
-
}
|
|
33174
|
-
async function gatherImageInputs(opts) {
|
|
33175
|
-
const clipboardImages = opts.from_clipboard ? [await (await Promise.resolve().then(() => (init_clipboard(), clipboard_exports))).readClipboardImage()] : [];
|
|
33176
|
-
const fileImages = await loadImageInputs(opts.images, opts.images_base64);
|
|
33177
|
-
return [...clipboardImages, ...fileImages];
|
|
33178
|
-
}
|
|
33179
|
-
function baseName(name) {
|
|
33180
|
-
const stripped = name.replace(/\.(png|jpe?g|webp|mp4|mov|webm|mp3|wav|ogg)$/i, "");
|
|
33181
|
-
const safe = stripped.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
33182
|
-
return safe || "image";
|
|
33183
|
-
}
|
|
33184
|
-
function resolveOutputDir(perCall) {
|
|
33185
|
-
return perCall?.trim() || readEnvVar("GEMINI_OUTPUT_DIR") || process.cwd();
|
|
33327
|
+
init_images();
|
|
33328
|
+
function registerSetTools(server, client2) {
|
|
33329
|
+
server.registerTool(
|
|
33330
|
+
"gemini_image_set",
|
|
33331
|
+
{
|
|
33332
|
+
description: "Generate a consistent SET of images: a master image from master_prompt, then one image per scene that references the master so the subject/style stays consistent. Provide `scenes` (explicit per-image prompts) OR `count` (variations of the master).",
|
|
33333
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
33334
|
+
inputSchema: {
|
|
33335
|
+
master_prompt: external_exports.string().min(1).describe("Prompt for the master/reference image"),
|
|
33336
|
+
scenes: external_exports.array(external_exports.string().min(1)).min(1).max(8).optional().describe("Per-image prompts (1-8); each references the master"),
|
|
33337
|
+
count: external_exports.number().int().positive().max(8).optional().describe("Number of variations of master_prompt (when scenes omitted)"),
|
|
33338
|
+
reference_mode: external_exports.enum(["master", "chain"]).optional().describe("master: every image references the master (default). chain: each references the previous."),
|
|
33339
|
+
basename: external_exports.string().optional().describe("Base filename prefix for output images (default: slugified master_prompt)"),
|
|
33340
|
+
master_images: external_exports.array(external_exports.string().min(1)).optional().describe("Reference image paths passed to the master generation call"),
|
|
33341
|
+
master_images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs for master generation"),
|
|
33342
|
+
confirm: schemaConfirm,
|
|
33343
|
+
...sharedImageSchema
|
|
33344
|
+
}
|
|
33345
|
+
},
|
|
33346
|
+
async (args, extra) => {
|
|
33347
|
+
assertLocalInputsAvailable(client2.mediaSink, args);
|
|
33348
|
+
if (args.scenes && args.count || !args.scenes && !args.count) {
|
|
33349
|
+
throw new McpToolError("Provide exactly one of `scenes` or `count`.");
|
|
33350
|
+
}
|
|
33351
|
+
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local master image input(s) to the Gemini API", "/v1beta/models/{model}:generateContent", args.master_images);
|
|
33352
|
+
if (gate) return gate;
|
|
33353
|
+
const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
33354
|
+
const fingerprint = fingerprintRequest("gemini_image_set", {
|
|
33355
|
+
model,
|
|
33356
|
+
master_prompt: args.master_prompt,
|
|
33357
|
+
scenes: args.scenes,
|
|
33358
|
+
count: args.count,
|
|
33359
|
+
reference_mode: args.reference_mode,
|
|
33360
|
+
aspect_ratio: args.aspect_ratio,
|
|
33361
|
+
image_size: args.image_size,
|
|
33362
|
+
thinking_level: args.thinking_level,
|
|
33363
|
+
google_search: args.google_search,
|
|
33364
|
+
master_images: args.master_images,
|
|
33365
|
+
master_images_base64: args.master_images_base64,
|
|
33366
|
+
from_clipboard: args.from_clipboard
|
|
33367
|
+
});
|
|
33368
|
+
return client2.session.jobs.dispatch({ toolName: "gemini_image_set", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
33369
|
+
const seed = pickSeed(args.seed);
|
|
33370
|
+
const cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search, timeoutMs: args.timeout_ms };
|
|
33371
|
+
const slug = args.basename ? baseName(args.basename) : slugify2(args.master_prompt);
|
|
33372
|
+
const masterRefInputs = await gatherImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard });
|
|
33373
|
+
const named = [];
|
|
33374
|
+
const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
|
|
33375
|
+
const result = await client2.generate({
|
|
33376
|
+
prompt: args.master_prompt,
|
|
33377
|
+
images: masterRefInputs.length > 0 ? masterRefInputs : void 0,
|
|
33378
|
+
seed,
|
|
33379
|
+
...cfg
|
|
33380
|
+
});
|
|
33381
|
+
const master = result.images[0];
|
|
33382
|
+
named.push({ image: master, base: `${slug}-master` });
|
|
33383
|
+
const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
|
|
33384
|
+
const mode = args.reference_mode ?? "master";
|
|
33385
|
+
if (mode === "chain") {
|
|
33386
|
+
let ref = master;
|
|
33387
|
+
for (let i = 0; i < scenePrompts.length; i++) {
|
|
33388
|
+
const { images: [img] } = await client2.generate({ prompt: scenePrompts[i], images: [ref], seed: seed + i + 1, ...cfg });
|
|
33389
|
+
named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` });
|
|
33390
|
+
ref = img;
|
|
33391
|
+
}
|
|
33392
|
+
} else {
|
|
33393
|
+
const scenes = await Promise.all(
|
|
33394
|
+
scenePrompts.map((p, i) => client2.generate({ prompt: p, images: [master], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
|
|
33395
|
+
);
|
|
33396
|
+
scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` }));
|
|
33397
|
+
}
|
|
33398
|
+
return result;
|
|
33399
|
+
});
|
|
33400
|
+
const masterText = masterResult.text;
|
|
33401
|
+
const meta3 = buildMeta(model, seed, args);
|
|
33402
|
+
if (masterText) meta3.text = masterText;
|
|
33403
|
+
if (masterResult.grounding) meta3.grounding = masterResult.grounding;
|
|
33404
|
+
const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length, persistsFiles: client2.mediaSink?.persistsFiles });
|
|
33405
|
+
if (risk) meta3.timeout_risk = risk;
|
|
33406
|
+
return emit(named, { ...args, sink: client2.mediaSink }, meta3);
|
|
33407
|
+
});
|
|
33408
|
+
}
|
|
33409
|
+
);
|
|
33186
33410
|
}
|
|
33187
33411
|
|
|
33188
|
-
// src/tools/
|
|
33412
|
+
// src/tools/interact.ts
|
|
33189
33413
|
init_zod();
|
|
33190
33414
|
init_dist();
|
|
33191
|
-
|
|
33192
|
-
|
|
33193
|
-
|
|
33194
|
-
|
|
33195
|
-
|
|
33196
|
-
|
|
33197
|
-
var
|
|
33198
|
-
|
|
33199
|
-
);
|
|
33200
|
-
|
|
33201
|
-
|
|
33202
|
-
|
|
33203
|
-
|
|
33204
|
-
|
|
33205
|
-
|
|
33206
|
-
|
|
33207
|
-
|
|
33208
|
-
|
|
33209
|
-
|
|
33210
|
-
|
|
33211
|
-
|
|
33212
|
-
|
|
33213
|
-
|
|
33214
|
-
|
|
33215
|
-
|
|
33216
|
-
|
|
33217
|
-
|
|
33218
|
-
|
|
33219
|
-
|
|
33220
|
-
|
|
33221
|
-
|
|
33222
|
-
|
|
33415
|
+
|
|
33416
|
+
// src/client.ts
|
|
33417
|
+
init_dist();
|
|
33418
|
+
import { basename } from "node:path";
|
|
33419
|
+
|
|
33420
|
+
// src/session.ts
|
|
33421
|
+
var SessionState = class {
|
|
33422
|
+
/** Idempotency + async-handle registry for this session (issues #52/#53). */
|
|
33423
|
+
jobs = new JobRegistry();
|
|
33424
|
+
/**
|
|
33425
|
+
* The most recent interaction id THIS session created — what
|
|
33426
|
+
* `continue_last: true` resumes. In-memory only, so "last" means "last in
|
|
33427
|
+
* this session"; a restart falls back to the newest sidecar on disk.
|
|
33428
|
+
*/
|
|
33429
|
+
lastInteractionId;
|
|
33430
|
+
/**
|
|
33431
|
+
* The same, for the media-specific tools, which keep their own chains.
|
|
33432
|
+
* `gemini_music_generate` IS served by the hosted connector, so this one
|
|
33433
|
+
* leaked across tenants exactly as `lastInteractionId` did; video is
|
|
33434
|
+
* stdio-only today but carries the identical hazard, so both are scoped here.
|
|
33435
|
+
*/
|
|
33436
|
+
lastMusicInteractionId;
|
|
33437
|
+
lastVideoInteractionId;
|
|
33438
|
+
/**
|
|
33439
|
+
* Absolute paths of every image this session has written. Used to drop a
|
|
33440
|
+
* chained call's re-attached prior output: the interaction state already
|
|
33441
|
+
* contains that image, and re-sending it as a fresh reference anchors the
|
|
33442
|
+
* model against the requested edit.
|
|
33443
|
+
*/
|
|
33444
|
+
writtenOutputs = /* @__PURE__ */ new Set();
|
|
33445
|
+
/** Forget everything. Tests use this; nothing in `src/` calls it. */
|
|
33446
|
+
reset() {
|
|
33447
|
+
this.jobs.reset();
|
|
33448
|
+
this.lastInteractionId = void 0;
|
|
33449
|
+
this.lastMusicInteractionId = void 0;
|
|
33450
|
+
this.lastVideoInteractionId = void 0;
|
|
33451
|
+
this.writtenOutputs.clear();
|
|
33223
33452
|
}
|
|
33224
|
-
|
|
33225
|
-
|
|
33226
|
-
|
|
33227
|
-
|
|
33228
|
-
|
|
33229
|
-
|
|
33230
|
-
|
|
33231
|
-
var
|
|
33232
|
-
|
|
33233
|
-
);
|
|
33234
|
-
|
|
33235
|
-
|
|
33453
|
+
};
|
|
33454
|
+
|
|
33455
|
+
// src/client.ts
|
|
33456
|
+
var BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
|
|
33457
|
+
var SERVICE = "Gemini";
|
|
33458
|
+
var INTERACTIONS_SERVICE = "Gemini Interactions";
|
|
33459
|
+
var REQUEST_TIMEOUT_MS = 6e4;
|
|
33460
|
+
var REQUEST_TIMEOUT_4K_MS = 12e4;
|
|
33461
|
+
function resolveTimeoutMs(perCallMs, imageSize) {
|
|
33462
|
+
if (perCallMs !== void 0 && perCallMs > 0) return perCallMs;
|
|
33463
|
+
const env = Number(readEnvVar("GEMINI_TIMEOUT_MS"));
|
|
33464
|
+
if (Number.isFinite(env) && env > 0) return env;
|
|
33465
|
+
return imageSize === "4K" ? REQUEST_TIMEOUT_4K_MS : REQUEST_TIMEOUT_MS;
|
|
33236
33466
|
}
|
|
33237
|
-
var
|
|
33238
|
-
|
|
33239
|
-
|
|
33240
|
-
|
|
33241
|
-
|
|
33242
|
-
|
|
33243
|
-
|
|
33244
|
-
|
|
33245
|
-
|
|
33246
|
-
|
|
33247
|
-
|
|
33248
|
-
|
|
33249
|
-
|
|
33250
|
-
|
|
33251
|
-
|
|
33467
|
+
var UPLOAD_BASE_URL = "https://generativelanguage.googleapis.com/upload/v1beta";
|
|
33468
|
+
var FILE_MAX_BYTES = 2 * 1024 ** 3;
|
|
33469
|
+
var FILE_POLL_INTERVAL_MS = 2e3;
|
|
33470
|
+
var FILE_POLL_MAX_ATTEMPTS = 150;
|
|
33471
|
+
var CHAIN_404_RETRY_BUDGET_MS = 12e4;
|
|
33472
|
+
var CHAIN_404_RETRY_BASE_MS = 2e3;
|
|
33473
|
+
var CHAIN_404_RETRY_MAX_SLEEP_MS = 3e4;
|
|
33474
|
+
function resolveChainRetryBudgetMs() {
|
|
33475
|
+
const env = Number(readEnvVar("GEMINI_CHAIN_RETRY_MS"));
|
|
33476
|
+
return Number.isFinite(env) && env >= 0 ? env : CHAIN_404_RETRY_BUDGET_MS;
|
|
33477
|
+
}
|
|
33478
|
+
var ChainedRequest404Error = class extends McpToolError {
|
|
33479
|
+
constructor(previousInteractionId, upstreamMessage, retries, opts) {
|
|
33480
|
+
super(
|
|
33481
|
+
`Gemini returned HTTP 404 for a chained request (previous_interaction_id: "${previousInteractionId}"), retried ${retries.attempts}\xD7 over ~${Math.round(retries.waitedMs / 1e3)}s for store lag. Upstream said: ${upstreamMessage}. This does not necessarily mean the interaction expired \u2014 an unknown model id or an expired files/\u2026 uri returns the same generic 404. (For reference: interactions are retained 55 days on the paid tier, 1 day on the free tier, and are scoped to the API key that created them.)`,
|
|
33482
|
+
opts
|
|
33483
|
+
);
|
|
33484
|
+
this.previousInteractionId = previousInteractionId;
|
|
33485
|
+
this.upstreamMessage = upstreamMessage;
|
|
33486
|
+
this.retries = retries;
|
|
33252
33487
|
}
|
|
33253
|
-
|
|
33254
|
-
|
|
33255
|
-
|
|
33256
|
-
|
|
33257
|
-
|
|
33258
|
-
|
|
33259
|
-
|
|
33260
|
-
|
|
33261
|
-
|
|
33262
|
-
|
|
33263
|
-
|
|
33264
|
-
|
|
33265
|
-
|
|
33266
|
-
|
|
33267
|
-
|
|
33268
|
-
|
|
33269
|
-
|
|
33270
|
-
|
|
33271
|
-
|
|
33272
|
-
|
|
33273
|
-
|
|
33488
|
+
previousInteractionId;
|
|
33489
|
+
upstreamMessage;
|
|
33490
|
+
retries;
|
|
33491
|
+
};
|
|
33492
|
+
var GeminiClient = class {
|
|
33493
|
+
/**
|
|
33494
|
+
* A key handed in explicitly (the connector's per-session key). When absent
|
|
33495
|
+
* the key is read from the environment at REQUEST time, not here — see
|
|
33496
|
+
* `requireKey`.
|
|
33497
|
+
*/
|
|
33498
|
+
explicitApiKey;
|
|
33499
|
+
apis = /* @__PURE__ */ new Map();
|
|
33500
|
+
fetchImpl;
|
|
33501
|
+
sleep;
|
|
33502
|
+
/**
|
|
33503
|
+
* The media destination for every tool this client is handed to. Public and
|
|
33504
|
+
* readonly: `tools/*` read `client.mediaSink` to decide disk-vs-URL output
|
|
33505
|
+
* AND whether disk-only *inputs* (local paths, clipboard, video upload) are
|
|
33506
|
+
* even available on this runtime.
|
|
33507
|
+
*/
|
|
33508
|
+
mediaSink;
|
|
33509
|
+
/**
|
|
33510
|
+
* This client's session memory — job registry, last-interaction id, written
|
|
33511
|
+
* outputs. One client is one session: the hosted connector builds a client
|
|
33512
|
+
* per authenticated user, so hanging the state here is what keeps it from
|
|
33513
|
+
* leaking between tenants sharing an isolate (see src/session.ts). The stdio
|
|
33514
|
+
* server is single-user, so its one singleton client is one session.
|
|
33515
|
+
*/
|
|
33516
|
+
session = new SessionState();
|
|
33517
|
+
constructor(opts = {}) {
|
|
33518
|
+
this.mediaSink = opts.mediaSink ?? createDiskSink();
|
|
33519
|
+
this.explicitApiKey = opts.apiKey?.trim() || void 0;
|
|
33520
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
33521
|
+
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
33274
33522
|
}
|
|
33275
|
-
|
|
33276
|
-
|
|
33277
|
-
|
|
33278
|
-
|
|
33279
|
-
|
|
33280
|
-
|
|
33281
|
-
|
|
33282
|
-
|
|
33283
|
-
|
|
33284
|
-
|
|
33285
|
-
|
|
33286
|
-
|
|
33287
|
-
|
|
33288
|
-
|
|
33289
|
-
|
|
33290
|
-
|
|
33291
|
-
|
|
33292
|
-
|
|
33293
|
-
|
|
33294
|
-
|
|
33295
|
-
|
|
33523
|
+
/**
|
|
33524
|
+
* The `createApiClient` pair for one timeout budget, built lazily and
|
|
33525
|
+
* memoized per distinct timeout (a handful of values in practice). Both
|
|
33526
|
+
* share: the key in the `x-goog-api-key` header (Gemini does not use
|
|
33527
|
+
* `Authorization: Bearer`) and the default 429 retry (once after 2s — the
|
|
33528
|
+
* free tier rate-limits aggressively). `getToken` calls requireKey() so key
|
|
33529
|
+
* resolution stays at REQUEST time: the server boots without a key and the
|
|
33530
|
+
* actionable config error (with its aistudio hint) surfaces on the first
|
|
33531
|
+
* tool call, not at startup.
|
|
33532
|
+
*/
|
|
33533
|
+
apisFor(timeoutMs) {
|
|
33534
|
+
let entry = this.apis.get(timeoutMs);
|
|
33535
|
+
if (!entry) {
|
|
33536
|
+
const shared = {
|
|
33537
|
+
tokenHeader: "x-goog-api-key",
|
|
33538
|
+
getToken: () => this.requireKey(),
|
|
33539
|
+
timeout: timeoutMs,
|
|
33540
|
+
fetchImpl: this.fetchImpl
|
|
33541
|
+
};
|
|
33542
|
+
entry = {
|
|
33543
|
+
api: createApiClient({ baseUrl: BASE_URL, serviceName: SERVICE, ...shared }),
|
|
33544
|
+
// Separate client for the Interactions API so errors name the right API.
|
|
33545
|
+
// The beta-era Api-Revision header is gone: the API went GA and requests
|
|
33546
|
+
// succeed without it (verified live 2026-07-06; see docs/GEMINI-API.md).
|
|
33547
|
+
// Don't re-add a pinned revision — it would freeze us on old semantics.
|
|
33548
|
+
interactionsApi: createApiClient({ baseUrl: BASE_URL, serviceName: INTERACTIONS_SERVICE, ...shared })
|
|
33549
|
+
};
|
|
33550
|
+
this.apis.set(timeoutMs, entry);
|
|
33296
33551
|
}
|
|
33297
|
-
return
|
|
33552
|
+
return entry;
|
|
33298
33553
|
}
|
|
33299
|
-
|
|
33300
|
-
|
|
33301
|
-
|
|
33302
|
-
|
|
33303
|
-
|
|
33304
|
-
|
|
33305
|
-
|
|
33306
|
-
|
|
33307
|
-
|
|
33308
|
-
|
|
33309
|
-
|
|
33310
|
-
|
|
33311
|
-
|
|
33312
|
-
|
|
33313
|
-
|
|
33314
|
-
|
|
33315
|
-
|
|
33316
|
-
|
|
33317
|
-
|
|
33318
|
-
|
|
33319
|
-
|
|
33320
|
-
|
|
33321
|
-
|
|
33322
|
-
|
|
33323
|
-
|
|
33554
|
+
/**
|
|
33555
|
+
* The API key for this request: an explicitly-injected key (the connector's
|
|
33556
|
+
* per-session key) else `$GEMINI_API_KEY`.
|
|
33557
|
+
*
|
|
33558
|
+
* Resolved HERE, at request time, not in the constructor — same as every
|
|
33559
|
+
* other env var this client reads. Two reasons:
|
|
33560
|
+
*
|
|
33561
|
+
* 1. It keeps the actionable config error deferred to the first tool call, so
|
|
33562
|
+
* the server still boots and answers a host's install-time `tools/list`
|
|
33563
|
+
* probe without a key. (That was already true; this preserves it.)
|
|
33564
|
+
* 2. It makes module-evaluation order irrelevant. `src/index.ts` loads `.env`
|
|
33565
|
+
* before serving, and reading the key in the constructor meant the
|
|
33566
|
+
* `client` singleton — constructed when `client.js` is first evaluated —
|
|
33567
|
+
* could latch "unset" before dotenv ran, depending on import ordering.
|
|
33568
|
+
* That is an invisible, silent failure (the key just stops working); not
|
|
33569
|
+
* depending on the order removes the hazard instead of documenting it.
|
|
33570
|
+
*/
|
|
33571
|
+
requireKey() {
|
|
33572
|
+
const key = this.explicitApiKey || readEnvVar("GEMINI_API_KEY");
|
|
33573
|
+
if (!key) {
|
|
33574
|
+
throw new McpToolError("GEMINI_API_KEY environment variable is required", {
|
|
33575
|
+
hint: "Create a key at https://aistudio.google.com/apikey and set GEMINI_API_KEY in your MCP host env or .env"
|
|
33576
|
+
});
|
|
33577
|
+
}
|
|
33578
|
+
return key;
|
|
33324
33579
|
}
|
|
33325
|
-
|
|
33326
|
-
|
|
33327
|
-
|
|
33328
|
-
if (jobs.size < JOB_MAX) break;
|
|
33329
|
-
remove(e.jobId, e);
|
|
33580
|
+
/** The default model after env override (no per-call arg). */
|
|
33581
|
+
defaultModel() {
|
|
33582
|
+
return resolveModel(void 0, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
33330
33583
|
}
|
|
33331
|
-
|
|
33332
|
-
|
|
33333
|
-
|
|
33334
|
-
|
|
33335
|
-
|
|
33336
|
-
|
|
33337
|
-
|
|
33338
|
-
|
|
33339
|
-
|
|
33340
|
-
|
|
33341
|
-
|
|
33342
|
-
|
|
33343
|
-
|
|
33344
|
-
|
|
33345
|
-
|
|
33346
|
-
|
|
33347
|
-
|
|
33348
|
-
|
|
33584
|
+
async call(method, path, body, timeoutMs) {
|
|
33585
|
+
const { api } = this.apisFor(timeoutMs ?? resolveTimeoutMs());
|
|
33586
|
+
return api.fetchJson(method, path, body !== void 0 ? { body } : {});
|
|
33587
|
+
}
|
|
33588
|
+
/**
|
|
33589
|
+
* Upload a local video to the Gemini Files API (resumable protocol) and wait
|
|
33590
|
+
* for it to become usable. Three live-verified steps (docs/GEMINI-API.md
|
|
33591
|
+
* "Files API — local video upload"):
|
|
33592
|
+
*
|
|
33593
|
+
* 1. `POST {UPLOAD_BASE_URL}/files` (command `start`) → the upload session
|
|
33594
|
+
* URL arrives in the `x-goog-upload-url` RESPONSE HEADER (body is empty).
|
|
33595
|
+
* 2. `POST <session url>` (command `upload, finalize`) with the bytes —
|
|
33596
|
+
* a file-backed Blob (`fileBlob`/`fs.openAsBlob`), so fetch STREAMS the
|
|
33597
|
+
* video from disk; it is never buffered in memory. Response: `{file:…}`.
|
|
33598
|
+
* 3. Poll `GET /v1beta/files/<id>` while `state` is `PROCESSING`, until
|
|
33599
|
+
* `ACTIVE` (the poll response is the File object UNWRAPPED).
|
|
33600
|
+
*
|
|
33601
|
+
* These two upload calls are raw `fetchImpl` (not `createApiClient`): step 1
|
|
33602
|
+
* needs the response *header* and step 2 posts a binary body — neither fits
|
|
33603
|
+
* `fetchJson`. The poll DOES go through the shared client (timeout + retry).
|
|
33604
|
+
* Uploads deliberately have no abort timeout: a multi-hundred-MB video can
|
|
33605
|
+
* legitimately take longer than any fixed budget.
|
|
33606
|
+
*
|
|
33607
|
+
* Returned files live ~48h (`expirationTime`); per-file cap is 2 GB
|
|
33608
|
+
* (enforced locally before any bytes are sent).
|
|
33609
|
+
*/
|
|
33610
|
+
async uploadVideo(path, mimeType) {
|
|
33611
|
+
const key = this.requireKey();
|
|
33612
|
+
const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: "Video" });
|
|
33613
|
+
const startPath = "/files";
|
|
33614
|
+
const startRes = await this.fetchImpl(`${UPLOAD_BASE_URL}${startPath}`, {
|
|
33615
|
+
method: "POST",
|
|
33616
|
+
headers: {
|
|
33617
|
+
"x-goog-api-key": key,
|
|
33618
|
+
"X-Goog-Upload-Protocol": "resumable",
|
|
33619
|
+
"X-Goog-Upload-Command": "start",
|
|
33620
|
+
"X-Goog-Upload-Header-Content-Length": String(blob.size),
|
|
33621
|
+
"X-Goog-Upload-Header-Content-Type": mimeType,
|
|
33622
|
+
"Content-Type": "application/json"
|
|
33623
|
+
},
|
|
33624
|
+
body: JSON.stringify({ file: { display_name: basename(path) } })
|
|
33625
|
+
});
|
|
33626
|
+
if (!startRes.ok) {
|
|
33627
|
+
throw new McpToolError(
|
|
33628
|
+
formatApiError(startRes.status, "POST", `/upload/v1beta${startPath}`, await startRes.text(), { service: SERVICE })
|
|
33629
|
+
);
|
|
33349
33630
|
}
|
|
33350
|
-
|
|
33351
|
-
|
|
33352
|
-
|
|
33353
|
-
|
|
33354
|
-
|
|
33355
|
-
...result,
|
|
33356
|
-
content: [{ type: "text", text: JSON.stringify({ reused: true, reused_job_id: jobId }, null, 2) }, ...content]
|
|
33357
|
-
};
|
|
33358
|
-
}
|
|
33359
|
-
function jobHandle(jobId, status) {
|
|
33360
|
-
return textResult({
|
|
33361
|
-
job_id: jobId,
|
|
33362
|
-
status,
|
|
33363
|
-
hint: `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done" (results are per-process and expire ~10 min after completion).`
|
|
33364
|
-
});
|
|
33365
|
-
}
|
|
33366
|
-
async function dispatch(opts, work) {
|
|
33367
|
-
const { toolName, fingerprint, idempotencyKey, async } = opts;
|
|
33368
|
-
const now = Date.now();
|
|
33369
|
-
let hit;
|
|
33370
|
-
if (idempotencyKey !== void 0) {
|
|
33371
|
-
const id = byKey.get(idempotencyKey);
|
|
33372
|
-
const entry2 = id ? jobs.get(id) : void 0;
|
|
33373
|
-
if (entry2 && entry2.status !== "failed" && !isExpired(entry2, now)) hit = entry2;
|
|
33374
|
-
} else {
|
|
33375
|
-
const id = runningByFingerprint.get(fingerprint);
|
|
33376
|
-
const entry2 = id ? jobs.get(id) : void 0;
|
|
33377
|
-
if (entry2 && entry2.status === "running") hit = entry2;
|
|
33378
|
-
}
|
|
33379
|
-
if (hit) {
|
|
33380
|
-
if (async) return jobHandle(hit.jobId, hit.status);
|
|
33381
|
-
return annotateReused(await hit.promise, hit.jobId);
|
|
33382
|
-
}
|
|
33383
|
-
const jobId = randomUUID();
|
|
33384
|
-
evict(now);
|
|
33385
|
-
const promise2 = work();
|
|
33386
|
-
const entry = { jobId, toolName, fingerprint, idempotencyKey, status: "running", promise: promise2, createdAt: now };
|
|
33387
|
-
jobs.set(jobId, entry);
|
|
33388
|
-
if (idempotencyKey !== void 0) byKey.set(idempotencyKey, jobId);
|
|
33389
|
-
runningByFingerprint.set(fingerprint, jobId);
|
|
33390
|
-
promise2.then(
|
|
33391
|
-
(res) => {
|
|
33392
|
-
entry.status = "done";
|
|
33393
|
-
entry.result = res;
|
|
33394
|
-
entry.settledAt = Date.now();
|
|
33395
|
-
},
|
|
33396
|
-
(err) => {
|
|
33397
|
-
entry.status = "failed";
|
|
33398
|
-
entry.error = { message: err instanceof Error ? err.message : String(err), hint: err?.hint };
|
|
33399
|
-
entry.settledAt = Date.now();
|
|
33631
|
+
const uploadUrl = startRes.headers.get("x-goog-upload-url");
|
|
33632
|
+
if (!uploadUrl) {
|
|
33633
|
+
throw new McpToolError(`${SERVICE} upload start did not return an x-goog-upload-url header`, {
|
|
33634
|
+
hint: "The Files API resumable-upload contract may have changed \u2014 see docs/GEMINI-API.md."
|
|
33635
|
+
});
|
|
33400
33636
|
}
|
|
33401
|
-
|
|
33402
|
-
|
|
33403
|
-
|
|
33404
|
-
|
|
33405
|
-
|
|
33406
|
-
}
|
|
33407
|
-
|
|
33408
|
-
const entry = jobs.get(jobId);
|
|
33409
|
-
if (!entry) {
|
|
33410
|
-
throw new McpToolError(`No job "${jobId}" \u2014 unknown or expired.`, {
|
|
33411
|
-
hint: "Jobs are per-process and evicted ~10 min after completion (or on server restart). If the generation likely finished, check the output dir / <image>.json sidecar instead of polling."
|
|
33637
|
+
const upRes = await this.fetchImpl(uploadUrl, {
|
|
33638
|
+
method: "POST",
|
|
33639
|
+
headers: {
|
|
33640
|
+
"X-Goog-Upload-Offset": "0",
|
|
33641
|
+
"X-Goog-Upload-Command": "upload, finalize"
|
|
33642
|
+
},
|
|
33643
|
+
body: blob
|
|
33412
33644
|
});
|
|
33645
|
+
if (!upRes.ok) {
|
|
33646
|
+
throw new McpToolError(
|
|
33647
|
+
formatApiError(upRes.status, "POST", "/upload/v1beta/files (finalize)", await upRes.text(), { service: SERVICE })
|
|
33648
|
+
);
|
|
33649
|
+
}
|
|
33650
|
+
let file2 = (await upRes.json()).file ?? {};
|
|
33651
|
+
const name = file2.name ?? "";
|
|
33652
|
+
if (!/^files\/[\w-]+$/.test(name)) {
|
|
33653
|
+
throw new McpToolError(`${SERVICE} upload returned an unexpected file name: ${name || "(none)"}`);
|
|
33654
|
+
}
|
|
33655
|
+
for (let attempt = 0; file2.state === "PROCESSING"; attempt++) {
|
|
33656
|
+
if (attempt >= FILE_POLL_MAX_ATTEMPTS) {
|
|
33657
|
+
throw new McpToolError(`${SERVICE} video file ${name} is still processing after ${FILE_POLL_INTERVAL_MS * FILE_POLL_MAX_ATTEMPTS / 6e4} minutes`, {
|
|
33658
|
+
hint: "Very long videos can take a while to process \u2014 retry later by passing the file uri as video_url."
|
|
33659
|
+
});
|
|
33660
|
+
}
|
|
33661
|
+
await this.sleep(FILE_POLL_INTERVAL_MS);
|
|
33662
|
+
file2 = await this.call("GET", `/${name}`);
|
|
33663
|
+
}
|
|
33664
|
+
if (file2.state !== "ACTIVE") {
|
|
33665
|
+
throw new McpToolError(
|
|
33666
|
+
`${SERVICE} video file processing failed (state ${file2.state ?? "unknown"})${file2.error?.message ? `: ${file2.error.message}` : ""}`,
|
|
33667
|
+
{ hint: "Check the video is a supported format/codec and under 2 GB." }
|
|
33668
|
+
);
|
|
33669
|
+
}
|
|
33670
|
+
if (!file2.uri) {
|
|
33671
|
+
throw new McpToolError(`${SERVICE} did not return a uri for uploaded file ${name}`);
|
|
33672
|
+
}
|
|
33673
|
+
return { name, uri: file2.uri, mimeType: file2.mimeType ?? mimeType, expirationTime: file2.expirationTime };
|
|
33413
33674
|
}
|
|
33414
|
-
|
|
33415
|
-
|
|
33416
|
-
|
|
33675
|
+
async listModels() {
|
|
33676
|
+
const data = await this.call("GET", "/models?pageSize=200");
|
|
33677
|
+
return filterImageModels(data.models ?? []);
|
|
33417
33678
|
}
|
|
33418
|
-
|
|
33419
|
-
|
|
33420
|
-
}
|
|
33421
|
-
|
|
33422
|
-
|
|
33423
|
-
|
|
33424
|
-
|
|
33425
|
-
|
|
33426
|
-
|
|
33427
|
-
|
|
33428
|
-
|
|
33429
|
-
|
|
33430
|
-
|
|
33431
|
-
|
|
33432
|
-
|
|
33433
|
-
|
|
33434
|
-
|
|
33435
|
-
|
|
33436
|
-
|
|
33437
|
-
|
|
33438
|
-
|
|
33439
|
-
|
|
33440
|
-
|
|
33441
|
-
|
|
33442
|
-
|
|
33443
|
-
|
|
33444
|
-
|
|
33445
|
-
|
|
33446
|
-
|
|
33447
|
-
|
|
33448
|
-
|
|
33449
|
-
|
|
33450
|
-
|
|
33451
|
-
|
|
33452
|
-
|
|
33453
|
-
|
|
33454
|
-
|
|
33455
|
-
prompt: external_exports.string().min(1).describe("Text prompt describing the image"),
|
|
33456
|
-
count: external_exports.number().int().positive().max(8).optional().describe("Number of independent images (default 1)"),
|
|
33457
|
-
filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified prompt)"),
|
|
33458
|
-
images: external_exports.array(external_exports.string().min(1)).optional().describe("Paths to reference input images (image-conditioned generation)"),
|
|
33459
|
-
images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs"),
|
|
33460
|
-
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)"),
|
|
33461
|
-
video_path: videoPathSchema,
|
|
33462
|
-
confirm: schemaConfirm,
|
|
33463
|
-
...sharedImageSchema
|
|
33679
|
+
async generate(opts) {
|
|
33680
|
+
const model = resolveModel(opts.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
33681
|
+
const parts = [{ text: opts.prompt }];
|
|
33682
|
+
for (const img of opts.images ?? []) {
|
|
33683
|
+
parts.push({ inline_data: { mime_type: img.mimeType, data: img.base64 } });
|
|
33684
|
+
}
|
|
33685
|
+
if (opts.videoUrl) {
|
|
33686
|
+
parts.push({ file_data: { file_uri: opts.videoUrl, mime_type: opts.videoMimeType ?? "video/mp4" } });
|
|
33687
|
+
}
|
|
33688
|
+
const generationConfig = { responseModalities: ["TEXT", "IMAGE"] };
|
|
33689
|
+
if (opts.aspectRatio || opts.imageSize) {
|
|
33690
|
+
const imageConfig = {};
|
|
33691
|
+
if (opts.aspectRatio) imageConfig.aspectRatio = opts.aspectRatio;
|
|
33692
|
+
if (opts.imageSize) imageConfig.imageSize = opts.imageSize;
|
|
33693
|
+
generationConfig.imageConfig = imageConfig;
|
|
33694
|
+
}
|
|
33695
|
+
if (opts.seed !== void 0) generationConfig.seed = opts.seed;
|
|
33696
|
+
if (opts.thinkingLevel !== void 0) generationConfig.thinkingConfig = { thinkingLevel: opts.thinkingLevel };
|
|
33697
|
+
const requestBody = { contents: [{ parts }], generationConfig };
|
|
33698
|
+
if (opts.googleSearch) requestBody.tools = [{ google_search: {} }];
|
|
33699
|
+
const data = await this.call(
|
|
33700
|
+
"POST",
|
|
33701
|
+
`/models/${model}:generateContent`,
|
|
33702
|
+
requestBody,
|
|
33703
|
+
resolveTimeoutMs(opts.timeoutMs, opts.imageSize)
|
|
33704
|
+
);
|
|
33705
|
+
const images = [];
|
|
33706
|
+
const textParts = [];
|
|
33707
|
+
let groundingMeta;
|
|
33708
|
+
for (const cand of data.candidates ?? []) {
|
|
33709
|
+
for (const part of cand.content?.parts ?? []) {
|
|
33710
|
+
const inline = part.inline_data ?? part.inlineData;
|
|
33711
|
+
if (inline?.data) {
|
|
33712
|
+
images.push({ base64: inline.data, mimeType: inline.mime_type ?? inline.mimeType ?? "image/jpeg" });
|
|
33713
|
+
} else if (typeof part.text === "string" && part.text.trim()) {
|
|
33714
|
+
textParts.push(part.text);
|
|
33715
|
+
}
|
|
33464
33716
|
}
|
|
33465
|
-
|
|
33466
|
-
|
|
33467
|
-
|
|
33468
|
-
|
|
33469
|
-
|
|
33470
|
-
const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
33471
|
-
const fingerprint = fingerprintRequest("gemini_image_generate", {
|
|
33472
|
-
model,
|
|
33473
|
-
prompt: args.prompt,
|
|
33474
|
-
aspect_ratio: args.aspect_ratio,
|
|
33475
|
-
image_size: args.image_size,
|
|
33476
|
-
thinking_level: args.thinking_level,
|
|
33477
|
-
google_search: args.google_search,
|
|
33478
|
-
count,
|
|
33479
|
-
images: args.images,
|
|
33480
|
-
images_base64: args.images_base64,
|
|
33481
|
-
from_clipboard: args.from_clipboard,
|
|
33482
|
-
video_url: args.video_url,
|
|
33483
|
-
video_path: args.video_path
|
|
33717
|
+
if (cand.groundingMetadata) groundingMeta = cand.groundingMetadata;
|
|
33718
|
+
}
|
|
33719
|
+
if (images.length === 0) {
|
|
33720
|
+
throw new McpToolError(`${SERVICE} returned no image`, {
|
|
33721
|
+
hint: "The request may have been blocked by safety filters \u2014 try rephrasing the prompt."
|
|
33484
33722
|
});
|
|
33485
|
-
|
|
33486
|
-
|
|
33487
|
-
|
|
33488
|
-
|
|
33489
|
-
|
|
33490
|
-
|
|
33491
|
-
|
|
33492
|
-
|
|
33493
|
-
|
|
33494
|
-
|
|
33495
|
-
|
|
33496
|
-
|
|
33497
|
-
|
|
33498
|
-
|
|
33499
|
-
|
|
33500
|
-
|
|
33501
|
-
|
|
33502
|
-
|
|
33503
|
-
|
|
33504
|
-
|
|
33505
|
-
|
|
33506
|
-
|
|
33507
|
-
|
|
33508
|
-
|
|
33509
|
-
|
|
33510
|
-
|
|
33511
|
-
|
|
33512
|
-
|
|
33513
|
-
|
|
33514
|
-
|
|
33515
|
-
|
|
33516
|
-
|
|
33517
|
-
|
|
33518
|
-
|
|
33519
|
-
|
|
33520
|
-
|
|
33521
|
-
|
|
33723
|
+
}
|
|
33724
|
+
const text = textParts.join("\n") || void 0;
|
|
33725
|
+
let grounding;
|
|
33726
|
+
if (groundingMeta) {
|
|
33727
|
+
const queries = (groundingMeta.webSearchQueries ?? []).filter(Boolean);
|
|
33728
|
+
const sources = (groundingMeta.groundingChunks ?? []).map((c) => c.web).filter((w) => !!w);
|
|
33729
|
+
if (queries.length > 0 || sources.length > 0) {
|
|
33730
|
+
grounding = {};
|
|
33731
|
+
if (queries.length > 0) grounding.queries = queries;
|
|
33732
|
+
if (sources.length > 0) grounding.sources = sources;
|
|
33733
|
+
}
|
|
33734
|
+
}
|
|
33735
|
+
return { images, text, grounding };
|
|
33736
|
+
}
|
|
33737
|
+
async interact(opts) {
|
|
33738
|
+
const model = resolveModel(opts.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
33739
|
+
const inputParts = [{ type: "text", text: opts.input }];
|
|
33740
|
+
for (const img of opts.images ?? []) {
|
|
33741
|
+
inputParts.push({ type: "image", mime_type: img.mimeType, data: img.base64 });
|
|
33742
|
+
}
|
|
33743
|
+
if (opts.videoUrl) {
|
|
33744
|
+
inputParts.push({ type: "video", uri: opts.videoUrl, mime_type: opts.videoMimeType ?? "video/mp4" });
|
|
33745
|
+
}
|
|
33746
|
+
const responseFormat = { type: "image", mime_type: "image/jpeg" };
|
|
33747
|
+
if (opts.aspectRatio) responseFormat.aspect_ratio = opts.aspectRatio;
|
|
33748
|
+
if (opts.imageSize) responseFormat.image_size = opts.imageSize;
|
|
33749
|
+
const body = { model, input: inputParts, response_format: responseFormat };
|
|
33750
|
+
if (opts.thinkingLevel !== void 0) body.generation_config = { thinking_level: opts.thinkingLevel };
|
|
33751
|
+
if (opts.previousInteractionId !== void 0) body.previous_interaction_id = opts.previousInteractionId;
|
|
33752
|
+
if (opts.googleSearch || opts.searchTypes?.length) {
|
|
33753
|
+
const searchTool = { type: "google_search" };
|
|
33754
|
+
if (opts.searchTypes?.length) searchTool.search_types = opts.searchTypes;
|
|
33755
|
+
body.tools = [searchTool];
|
|
33756
|
+
}
|
|
33757
|
+
const data = await this.postInteraction(body, opts);
|
|
33758
|
+
const { images, text, grounding } = this.extractInteraction(data, opts.searchTypes);
|
|
33759
|
+
if (images.length === 0) {
|
|
33760
|
+
throw new McpToolError("Gemini returned no image", {
|
|
33761
|
+
hint: "The request may have been blocked by safety filters \u2014 try rephrasing the prompt."
|
|
33762
|
+
});
|
|
33763
|
+
}
|
|
33764
|
+
return { id: data.id, images, text, grounding };
|
|
33765
|
+
}
|
|
33766
|
+
/**
|
|
33767
|
+
* Generate video via the omni model (Interactions API, inline delivery). Same
|
|
33768
|
+
* plumbing as {@link interact} — text/image input parts, a `video`
|
|
33769
|
+
* response_format, optional `video_config.task`, and `previous_interaction_id`
|
|
33770
|
+
* for edits. Preview model: shapes are docs-derived; parsing stays tolerant.
|
|
33771
|
+
*/
|
|
33772
|
+
async generateVideo(opts) {
|
|
33773
|
+
const model = opts.model ?? DEFAULT_VIDEO_MODEL;
|
|
33774
|
+
const inputParts = [{ type: "text", text: opts.input }];
|
|
33775
|
+
for (const img of opts.images ?? []) inputParts.push({ type: "image", mime_type: img.mimeType, data: img.base64 });
|
|
33776
|
+
const responseFormat = { type: "video", delivery: "inline" };
|
|
33777
|
+
if (opts.aspectRatio) responseFormat.aspect_ratio = opts.aspectRatio;
|
|
33778
|
+
const body = { model, input: inputParts, response_format: responseFormat };
|
|
33779
|
+
if (opts.task) body.generation_config = { video_config: { task: opts.task } };
|
|
33780
|
+
if (opts.previousInteractionId !== void 0) body.previous_interaction_id = opts.previousInteractionId;
|
|
33781
|
+
const data = await this.postInteraction(body, opts);
|
|
33782
|
+
const { videos, text } = this.extractInteraction(data);
|
|
33783
|
+
if (videos.length === 0) {
|
|
33784
|
+
throw new McpToolError("Gemini returned no video", {
|
|
33785
|
+
hint: "The request may have been blocked by a safety filter, or the clip exceeded the ~4MB inline limit \u2014 try a shorter/simpler prompt or a different aspect ratio."
|
|
33522
33786
|
});
|
|
33523
33787
|
}
|
|
33524
|
-
|
|
33525
|
-
|
|
33526
|
-
|
|
33527
|
-
|
|
33528
|
-
|
|
33529
|
-
|
|
33530
|
-
|
|
33531
|
-
|
|
33532
|
-
|
|
33533
|
-
|
|
33534
|
-
|
|
33535
|
-
|
|
33536
|
-
|
|
33537
|
-
|
|
33538
|
-
|
|
33539
|
-
|
|
33540
|
-
|
|
33541
|
-
|
|
33542
|
-
|
|
33543
|
-
throw new McpToolError("Provide at least one input image via `images`, `images_base64`, or `from_clipboard`.");
|
|
33544
|
-
}
|
|
33545
|
-
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
|
|
33546
|
-
if (gate) return gate;
|
|
33547
|
-
const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
33548
|
-
const fingerprint = fingerprintRequest("gemini_image_edit", {
|
|
33549
|
-
model,
|
|
33550
|
-
prompt: args.prompt,
|
|
33551
|
-
aspect_ratio: args.aspect_ratio,
|
|
33552
|
-
image_size: args.image_size,
|
|
33553
|
-
thinking_level: args.thinking_level,
|
|
33554
|
-
google_search: args.google_search,
|
|
33555
|
-
images: args.images,
|
|
33556
|
-
images_base64: args.images_base64,
|
|
33557
|
-
from_clipboard: args.from_clipboard
|
|
33558
|
-
});
|
|
33559
|
-
return dispatch({ toolName: "gemini_image_edit", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
33560
|
-
const seed = pickSeed(args.seed);
|
|
33561
|
-
const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
|
|
33562
|
-
const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
33563
|
-
const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client.generate({
|
|
33564
|
-
prompt: args.prompt,
|
|
33565
|
-
images: inputs,
|
|
33566
|
-
model: args.model,
|
|
33567
|
-
aspectRatio: args.aspect_ratio,
|
|
33568
|
-
imageSize: args.image_size,
|
|
33569
|
-
seed,
|
|
33570
|
-
thinkingLevel: args.thinking_level,
|
|
33571
|
-
googleSearch: args.google_search,
|
|
33572
|
-
timeoutMs: args.timeout_ms
|
|
33573
|
-
}));
|
|
33574
|
-
const { images: [img], text } = result;
|
|
33575
|
-
const meta3 = buildMeta(model, seed, args);
|
|
33576
|
-
if (text) meta3.text = text;
|
|
33577
|
-
if (result.grounding) meta3.grounding = result.grounding;
|
|
33578
|
-
meta3.hint = REFINE_HINT;
|
|
33579
|
-
const risk = timeoutRiskHint({ model, imageSize: args.image_size });
|
|
33580
|
-
if (risk) meta3.timeout_risk = risk;
|
|
33581
|
-
return emit([{ image: img, base: slug }], args, meta3);
|
|
33788
|
+
return { id: data.id, videos, text };
|
|
33789
|
+
}
|
|
33790
|
+
/**
|
|
33791
|
+
* Generate music via a Lyria model (Interactions API, inline audio). Reuses
|
|
33792
|
+
* the interact plumbing with an `audio` response_format. Preview model.
|
|
33793
|
+
*/
|
|
33794
|
+
async generateMusic(opts) {
|
|
33795
|
+
const model = opts.model ?? DEFAULT_MUSIC_MODEL;
|
|
33796
|
+
const inputParts = [{ type: "text", text: opts.input }];
|
|
33797
|
+
for (const img of opts.images ?? []) inputParts.push({ type: "image", mime_type: img.mimeType, data: img.base64 });
|
|
33798
|
+
const responseFormat = { type: "audio" };
|
|
33799
|
+
if (opts.audioFormat) responseFormat.audio_format = opts.audioFormat;
|
|
33800
|
+
const body = { model, input: inputParts, response_format: responseFormat };
|
|
33801
|
+
if (opts.previousInteractionId !== void 0) body.previous_interaction_id = opts.previousInteractionId;
|
|
33802
|
+
const data = await this.postInteraction(body, opts);
|
|
33803
|
+
const { audios, text } = this.extractInteraction(data);
|
|
33804
|
+
if (audios.length === 0) {
|
|
33805
|
+
throw new McpToolError("Gemini returned no audio", {
|
|
33806
|
+
hint: "The request may have been blocked by a safety filter \u2014 try rephrasing the prompt."
|
|
33582
33807
|
});
|
|
33583
33808
|
}
|
|
33584
|
-
|
|
33585
|
-
}
|
|
33586
|
-
|
|
33587
|
-
|
|
33588
|
-
|
|
33589
|
-
|
|
33590
|
-
|
|
33591
|
-
|
|
33592
|
-
|
|
33593
|
-
|
|
33594
|
-
|
|
33595
|
-
|
|
33596
|
-
|
|
33597
|
-
|
|
33598
|
-
|
|
33599
|
-
|
|
33600
|
-
|
|
33601
|
-
|
|
33602
|
-
|
|
33603
|
-
|
|
33604
|
-
|
|
33605
|
-
|
|
33809
|
+
return { id: data.id, audios, text };
|
|
33810
|
+
}
|
|
33811
|
+
/** POST `/interactions` with the chained-404 retry. Shared by all media. */
|
|
33812
|
+
async postInteraction(body, opts) {
|
|
33813
|
+
const { interactionsApi } = this.apisFor(resolveTimeoutMs(opts.timeoutMs, opts.imageSize));
|
|
33814
|
+
const budgetMs = resolveChainRetryBudgetMs();
|
|
33815
|
+
let waitedMs = 0;
|
|
33816
|
+
for (let attempt = 0; ; attempt++) {
|
|
33817
|
+
try {
|
|
33818
|
+
return await interactionsApi.fetchJson("POST", "/interactions", { body });
|
|
33819
|
+
} catch (err) {
|
|
33820
|
+
if (!(opts.previousInteractionId && err instanceof ApiError && err.status === 404)) throw err;
|
|
33821
|
+
const delay = Math.min(CHAIN_404_RETRY_BASE_MS * 2 ** attempt, CHAIN_404_RETRY_MAX_SLEEP_MS);
|
|
33822
|
+
if (waitedMs + delay <= budgetMs) {
|
|
33823
|
+
waitedMs += delay;
|
|
33824
|
+
await this.sleep(delay);
|
|
33825
|
+
continue;
|
|
33826
|
+
}
|
|
33827
|
+
throw new ChainedRequest404Error(opts.previousInteractionId, err.message, { attempts: attempt, waitedMs }, {
|
|
33828
|
+
hint: "Re-issue the request WITHOUT previous_interaction_id (re-attaching the prior output image if you were editing). If that also 404s, the interaction id is not the cause \u2014 check the model id and any files/\u2026 uri (they expire ~48h). gemini_interact does this probe automatically.",
|
|
33829
|
+
cause: err
|
|
33830
|
+
});
|
|
33606
33831
|
}
|
|
33607
|
-
}
|
|
33608
|
-
|
|
33609
|
-
|
|
33610
|
-
|
|
33832
|
+
}
|
|
33833
|
+
}
|
|
33834
|
+
/**
|
|
33835
|
+
* Collect `model_output` media (image/video/audio) + text + grounding from an
|
|
33836
|
+
* interactions response. Only the `model_output` step is surfaced — `thought`
|
|
33837
|
+
* steps (internal reasoning / draft images) are dropped on purpose so they
|
|
33838
|
+
* can't leak into text or pollute the outputs. Tolerant of snake/camel MIME.
|
|
33839
|
+
*/
|
|
33840
|
+
extractInteraction(data, searchTypes) {
|
|
33841
|
+
const images = [];
|
|
33842
|
+
const videos = [];
|
|
33843
|
+
const audios = [];
|
|
33844
|
+
const textParts = [];
|
|
33845
|
+
const searchQueries = [];
|
|
33846
|
+
const searchSuggestions = /* @__PURE__ */ new Set();
|
|
33847
|
+
for (const step of data.steps ?? []) {
|
|
33848
|
+
if (step.type === "google_search_call") {
|
|
33849
|
+
for (const q of step.arguments?.queries ?? []) if (q?.trim()) searchQueries.push(q);
|
|
33850
|
+
continue;
|
|
33611
33851
|
}
|
|
33612
|
-
|
|
33613
|
-
|
|
33614
|
-
|
|
33615
|
-
|
|
33616
|
-
|
|
33617
|
-
|
|
33618
|
-
|
|
33619
|
-
|
|
33620
|
-
|
|
33621
|
-
|
|
33622
|
-
|
|
33623
|
-
|
|
33624
|
-
|
|
33625
|
-
|
|
33626
|
-
|
|
33627
|
-
|
|
33628
|
-
});
|
|
33629
|
-
return dispatch({ toolName: "gemini_image_set", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
33630
|
-
const seed = pickSeed(args.seed);
|
|
33631
|
-
const cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search, timeoutMs: args.timeout_ms };
|
|
33632
|
-
const slug = args.basename ? baseName(args.basename) : slugify2(args.master_prompt);
|
|
33633
|
-
const masterRefInputs = await gatherImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard });
|
|
33634
|
-
const named = [];
|
|
33635
|
-
const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
|
|
33636
|
-
const result = await client.generate({
|
|
33637
|
-
prompt: args.master_prompt,
|
|
33638
|
-
images: masterRefInputs.length > 0 ? masterRefInputs : void 0,
|
|
33639
|
-
seed,
|
|
33640
|
-
...cfg
|
|
33641
|
-
});
|
|
33642
|
-
const master = result.images[0];
|
|
33643
|
-
named.push({ image: master, base: `${slug}-master` });
|
|
33644
|
-
const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
|
|
33645
|
-
const mode = args.reference_mode ?? "master";
|
|
33646
|
-
if (mode === "chain") {
|
|
33647
|
-
let ref = master;
|
|
33648
|
-
for (let i = 0; i < scenePrompts.length; i++) {
|
|
33649
|
-
const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref], seed: seed + i + 1, ...cfg });
|
|
33650
|
-
named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` });
|
|
33651
|
-
ref = img;
|
|
33652
|
-
}
|
|
33653
|
-
} else {
|
|
33654
|
-
const scenes = await Promise.all(
|
|
33655
|
-
scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
|
|
33656
|
-
);
|
|
33657
|
-
scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` }));
|
|
33852
|
+
if (step.type === "google_search_result") {
|
|
33853
|
+
if (searchTypes?.includes("image_search")) {
|
|
33854
|
+
for (const r of step.result ?? []) if (r.search_suggestions?.trim()) searchSuggestions.add(r.search_suggestions);
|
|
33855
|
+
}
|
|
33856
|
+
continue;
|
|
33857
|
+
}
|
|
33858
|
+
if (step.type !== "model_output") continue;
|
|
33859
|
+
for (const parts of [step.content ?? [], step.summary ?? []]) {
|
|
33860
|
+
for (const part of parts) {
|
|
33861
|
+
const mime = part.mime_type ?? part.mimeType;
|
|
33862
|
+
if (part.data) {
|
|
33863
|
+
if (part.type === "image") images.push({ base64: part.data, mimeType: mime ?? "image/jpeg" });
|
|
33864
|
+
else if (part.type === "video") videos.push({ base64: part.data, mimeType: mime ?? "video/mp4" });
|
|
33865
|
+
else if (part.type === "audio") audios.push({ base64: part.data, mimeType: mime ?? "audio/mpeg" });
|
|
33866
|
+
} else if (part.type === "text" && part.text?.trim()) {
|
|
33867
|
+
textParts.push(part.text);
|
|
33658
33868
|
}
|
|
33659
|
-
|
|
33660
|
-
|
|
33661
|
-
|
|
33662
|
-
|
|
33663
|
-
|
|
33664
|
-
|
|
33665
|
-
|
|
33666
|
-
|
|
33667
|
-
|
|
33869
|
+
}
|
|
33870
|
+
}
|
|
33871
|
+
}
|
|
33872
|
+
let grounding;
|
|
33873
|
+
if (searchQueries.length > 0 || searchSuggestions.size > 0) {
|
|
33874
|
+
grounding = {};
|
|
33875
|
+
if (searchQueries.length > 0) grounding.queries = searchQueries;
|
|
33876
|
+
if (searchSuggestions.size > 0) grounding.search_suggestions = [...searchSuggestions];
|
|
33877
|
+
}
|
|
33878
|
+
return { images, videos, audios, text: textParts.join("\n") || void 0, grounding };
|
|
33879
|
+
}
|
|
33880
|
+
};
|
|
33881
|
+
var client = new GeminiClient();
|
|
33882
|
+
|
|
33883
|
+
// src/tools/interact.ts
|
|
33884
|
+
init_images();
|
|
33885
|
+
|
|
33886
|
+
// src/sidecar.ts
|
|
33887
|
+
import { readdir, readFile as readFile3, stat as stat2 } from "node:fs/promises";
|
|
33888
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
33889
|
+
import { join as join4 } from "node:path";
|
|
33890
|
+
var SIDECAR_SUFFIX = ".json";
|
|
33891
|
+
async function readSidecars(dir) {
|
|
33892
|
+
let entries;
|
|
33893
|
+
try {
|
|
33894
|
+
entries = await readdir(dir);
|
|
33895
|
+
} catch {
|
|
33896
|
+
return [];
|
|
33897
|
+
}
|
|
33898
|
+
const records = [];
|
|
33899
|
+
for (const entry of entries) {
|
|
33900
|
+
if (!entry.endsWith(SIDECAR_SUFFIX)) continue;
|
|
33901
|
+
const sidecarPath = join4(dir, entry);
|
|
33902
|
+
try {
|
|
33903
|
+
const parsed = JSON.parse(await readFile3(sidecarPath, "utf8"));
|
|
33904
|
+
if (typeof parsed !== "object" || parsed === null) continue;
|
|
33905
|
+
const { interaction_id: interactionId, images } = parsed;
|
|
33906
|
+
if (typeof interactionId !== "string" || !interactionId) continue;
|
|
33907
|
+
const paths = Array.isArray(images) ? images.filter((p) => typeof p === "string") : [];
|
|
33908
|
+
records.push({
|
|
33909
|
+
sidecarPath,
|
|
33910
|
+
interactionId,
|
|
33911
|
+
images: paths.filter((p) => existsSync2(p)),
|
|
33912
|
+
mtimeMs: (await stat2(sidecarPath)).mtimeMs
|
|
33668
33913
|
});
|
|
33914
|
+
} catch {
|
|
33915
|
+
continue;
|
|
33669
33916
|
}
|
|
33670
|
-
|
|
33917
|
+
}
|
|
33918
|
+
return records.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
33919
|
+
}
|
|
33920
|
+
async function findInteractionImages(dir, interactionId) {
|
|
33921
|
+
const records = await readSidecars(dir);
|
|
33922
|
+
return records.find((r) => r.interactionId === interactionId)?.images ?? [];
|
|
33923
|
+
}
|
|
33924
|
+
async function latestInteractionId(dir) {
|
|
33925
|
+
return (await readSidecars(dir))[0]?.interactionId;
|
|
33671
33926
|
}
|
|
33672
33927
|
|
|
33673
33928
|
// src/tools/interact.ts
|
|
33674
|
-
init_zod();
|
|
33675
|
-
init_dist();
|
|
33676
|
-
var lastInteractionId;
|
|
33677
|
-
var writtenOutputs = /* @__PURE__ */ new Set();
|
|
33678
33929
|
var NEW_REFERENCES_ONLY = "NEW reference images only (e.g. a style or target photo). When chaining with previous_interaction_id, do NOT re-attach the prior turn's output \u2014 the interaction already contains it, and re-sending it anchors the model against your edit.";
|
|
33679
|
-
function splitReattachedOutputs(images) {
|
|
33930
|
+
function splitReattachedOutputs(images, writtenOutputs) {
|
|
33680
33931
|
const kept = [];
|
|
33681
33932
|
const dropped = [];
|
|
33682
33933
|
for (const p of images) {
|
|
@@ -33689,16 +33940,18 @@ function splitReattachedOutputs(images) {
|
|
|
33689
33940
|
}
|
|
33690
33941
|
return { kept, dropped };
|
|
33691
33942
|
}
|
|
33692
|
-
function registerInteractTools(server) {
|
|
33943
|
+
function registerInteractTools(server, client2) {
|
|
33944
|
+
const onDiskRuntime = client2.mediaSink?.persistsFiles ?? true;
|
|
33945
|
+
const recoveryDescription = onDiskRuntime ? "If a call times out on the client side, the generation usually still completes: the image plus a `<image>.json` sidecar recording its interaction id land in the output dir, and `continue_last: true` still resumes that interaction \u2014 check the output dir before re-issuing (a re-issue is a second billable generation). If a chained call 404s, this tool re-anchors itself on the prior output image and re-issues un-chained: success is reported as `chain_recovered` (the chain was the problem, and you get your image anyway); a second 404 is reported as the interaction id NOT being the cause (check the model id / files uri instead). " : "This connector has no filesystem: there is no output dir and no `<image>.json` sidecar, so a lost response cannot be recovered from disk and a 404 on a chained call cannot be re-anchored automatically. Capture the returned `interaction_id` from every result, and use `async: true` + gemini_get_result for long generations. ";
|
|
33693
33946
|
server.registerTool(
|
|
33694
33947
|
"gemini_interact",
|
|
33695
33948
|
{
|
|
33696
|
-
description: "Preferred tool for iterative or multi-step refinement of a single image \u2014 multi-turn generation/editing via Gemini's Interactions API. To refine, capture the returned interaction `id` and pass it as `previous_interaction_id` on the next call \u2014 do NOT start a new interaction or re-upload the image for each tweak. `continue_last: true` chains from this session's most recent interaction without threading the id.
|
|
33949
|
+
description: "Preferred tool for iterative or multi-step refinement of a single image \u2014 multi-turn generation/editing via Gemini's Interactions API. To refine, capture the returned interaction `id` and pass it as `previous_interaction_id` on the next call \u2014 do NOT start a new interaction or re-upload the image for each tweak. `continue_last: true` chains from this session's most recent interaction without threading the id. " + recoveryDescription + "Output is JPEG.",
|
|
33697
33950
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
33698
33951
|
inputSchema: {
|
|
33699
33952
|
input: external_exports.string().min(1).describe("Text prompt or editing instruction"),
|
|
33700
33953
|
previous_interaction_id: external_exports.string().optional().describe("ID from a prior gemini_interact call \u2014 continues that multi-turn conversation"),
|
|
33701
|
-
continue_last: external_exports.boolean().optional().describe("Continue from the most recent interaction this server created (convenience for previous_interaction_id; an explicit id wins)"),
|
|
33954
|
+
continue_last: external_exports.boolean().optional().describe("Continue from the most recent interaction this server created (convenience for previous_interaction_id; an explicit id wins). Survives a server restart by falling back to the newest <image>.json sidecar in the output dir."),
|
|
33702
33955
|
images: external_exports.array(external_exports.string().min(1)).optional().describe(`Paths to reference input images. ${NEW_REFERENCES_ONLY}`),
|
|
33703
33956
|
images_base64: external_exports.array(external_exports.string().min(1)).optional().describe(`Reference images as base64 strings or data URIs. ${NEW_REFERENCES_ONLY}`),
|
|
33704
33957
|
model: external_exports.string().optional().describe(`Model id override (default: server default; see gemini_list_models). ${MODEL_CHOICE_GUIDE}`),
|
|
@@ -33722,19 +33975,26 @@ function registerInteractTools(server) {
|
|
|
33722
33975
|
}
|
|
33723
33976
|
},
|
|
33724
33977
|
async (args, extra) => {
|
|
33978
|
+
assertLocalInputsAvailable(client2.mediaSink, args);
|
|
33979
|
+
const onDisk = client2.mediaSink?.persistsFiles ?? true;
|
|
33725
33980
|
let previousInteractionId = args.previous_interaction_id;
|
|
33981
|
+
let continuedFromSidecar = false;
|
|
33726
33982
|
if (!previousInteractionId && args.continue_last) {
|
|
33727
|
-
|
|
33983
|
+
previousInteractionId = client2.session.lastInteractionId;
|
|
33984
|
+
if (!previousInteractionId && onDisk) {
|
|
33985
|
+
previousInteractionId = await latestInteractionId(resolveOutputDir(args.output_dir));
|
|
33986
|
+
continuedFromSidecar = previousInteractionId !== void 0;
|
|
33987
|
+
}
|
|
33988
|
+
if (!previousInteractionId) {
|
|
33728
33989
|
throw new McpToolError("No previous interaction to continue in this session.", {
|
|
33729
|
-
hint: "Call gemini_interact once without continue_last first, or pass an explicit previous_interaction_id."
|
|
33990
|
+
hint: onDisk ? "Call gemini_interact once without continue_last first, or pass an explicit previous_interaction_id." : "Call gemini_interact once without continue_last first, or pass an explicit previous_interaction_id. (This hosted connector has no filesystem, so there are no <image>.json sidecars to recover an id from across restarts.)"
|
|
33730
33991
|
});
|
|
33731
33992
|
}
|
|
33732
|
-
previousInteractionId = lastInteractionId;
|
|
33733
33993
|
}
|
|
33734
33994
|
let images = args.images;
|
|
33735
33995
|
let dropped = [];
|
|
33736
33996
|
if (previousInteractionId && images?.length) {
|
|
33737
|
-
({ kept: images, dropped } = splitReattachedOutputs(images));
|
|
33997
|
+
({ kept: images, dropped } = splitReattachedOutputs(images, client2.session.writtenOutputs));
|
|
33738
33998
|
}
|
|
33739
33999
|
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local file input(s) to the Gemini Interactions API", "/v1beta/interactions", images, args.video_path);
|
|
33740
34000
|
if (gate) return gate;
|
|
@@ -33754,40 +34014,69 @@ function registerInteractTools(server) {
|
|
|
33754
34014
|
video_url: args.video_url,
|
|
33755
34015
|
video_path: args.video_path
|
|
33756
34016
|
});
|
|
33757
|
-
return dispatch({ toolName: "gemini_interact", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
34017
|
+
return client2.session.jobs.dispatch({ toolName: "gemini_interact", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
33758
34018
|
const inputs = await gatherImageInputs({ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
33759
|
-
const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args));
|
|
33760
|
-
const
|
|
34019
|
+
const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args, client2));
|
|
34020
|
+
const callOpts = {
|
|
33761
34021
|
input: args.input,
|
|
33762
|
-
images: inputs.length ? inputs : void 0,
|
|
33763
34022
|
model: args.model,
|
|
33764
34023
|
aspectRatio: args.aspect_ratio,
|
|
33765
34024
|
imageSize: args.image_size,
|
|
33766
34025
|
thinkingLevel: args.thinking_level,
|
|
33767
|
-
previousInteractionId,
|
|
33768
34026
|
googleSearch: args.google_search,
|
|
33769
34027
|
searchTypes: args.search_types,
|
|
33770
34028
|
videoUrl: video.videoUrl,
|
|
33771
34029
|
videoMimeType: video.videoMimeType,
|
|
33772
34030
|
timeoutMs: args.timeout_ms
|
|
33773
|
-
}
|
|
33774
|
-
|
|
34031
|
+
};
|
|
34032
|
+
let chainRecovered;
|
|
34033
|
+
const r = await withProgressHeartbeat(extra, "Generating image (Gemini Interactions API)", async () => {
|
|
34034
|
+
try {
|
|
34035
|
+
return await client2.interact({ ...callOpts, images: inputs.length ? inputs : void 0, previousInteractionId });
|
|
34036
|
+
} catch (err) {
|
|
34037
|
+
if (!(err instanceof ChainedRequest404Error)) throw err;
|
|
34038
|
+
if (!onDisk) throw err;
|
|
34039
|
+
const reanchorOn = await findInteractionImages(resolveOutputDir(args.output_dir), err.previousInteractionId);
|
|
34040
|
+
if (!reanchorOn.length) throw err;
|
|
34041
|
+
const carried = await Promise.all(reanchorOn.map((p) => readImageAsInline(p)));
|
|
34042
|
+
try {
|
|
34043
|
+
const probe = await client2.interact({ ...callOpts, images: [...carried, ...inputs], previousInteractionId: void 0 });
|
|
34044
|
+
chainRecovered = { expired_interaction_id: err.previousInteractionId, reanchored_on: reanchorOn };
|
|
34045
|
+
return probe;
|
|
34046
|
+
} catch (probeErr) {
|
|
34047
|
+
if (!(probeErr instanceof ApiError && probeErr.status === 404)) throw probeErr;
|
|
34048
|
+
throw new McpToolError(
|
|
34049
|
+
// Remediation goes in the MESSAGE, not just `hint`: MCP error
|
|
34050
|
+
// serialization surfaces the message and drops the hint, so a
|
|
34051
|
+
// hint-only fix is invisible exactly when it's needed.
|
|
34052
|
+
`The interaction id is not the cause of this 404: the same request re-issued WITHOUT previous_interaction_id ("${err.previousInteractionId}") also returned 404. Upstream said: ${probeErr.message}. Check that the model id ("${model}") resolves \u2014 see gemini_list_models \u2014 and that any files/\u2026 uri you referenced hasn't expired (~48h TTL). Re-chaining or re-anchoring will not help.`,
|
|
34053
|
+
{
|
|
34054
|
+
hint: `Check the model id ("${model}") resolves \u2014 see gemini_list_models \u2014 and that any files/\u2026 uri you referenced hasn't expired (~48h TTL). Re-chaining or re-anchoring will not help.`,
|
|
34055
|
+
cause: probeErr
|
|
34056
|
+
}
|
|
34057
|
+
);
|
|
34058
|
+
}
|
|
34059
|
+
}
|
|
34060
|
+
});
|
|
34061
|
+
client2.session.lastInteractionId = r.id;
|
|
33775
34062
|
const meta3 = { model, interaction_id: r.id };
|
|
33776
|
-
if (
|
|
34063
|
+
if (chainRecovered) meta3.chain_recovered = chainRecovered;
|
|
34064
|
+
else if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
|
|
34065
|
+
if (continuedFromSidecar) meta3.continued_from_sidecar = true;
|
|
33777
34066
|
if (dropped.length) meta3.dropped_previous_output = dropped;
|
|
33778
34067
|
meta3.hint = `To refine this image, call gemini_interact again with previous_interaction_id: "${r.id}" (or continue_last: true) \u2014 do NOT re-attach the returned image as an input; the interaction already contains it.`;
|
|
33779
34068
|
if (r.text) meta3.text = r.text;
|
|
33780
34069
|
if (r.grounding) meta3.grounding = r.grounding;
|
|
33781
34070
|
if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
|
|
33782
|
-
const risk = timeoutRiskHint({ model, imageSize: args.image_size });
|
|
34071
|
+
const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: onDisk });
|
|
33783
34072
|
if (risk) meta3.timeout_risk = risk;
|
|
33784
34073
|
const slug = args.filename ? baseName(args.filename) : slugify2(args.input);
|
|
33785
34074
|
const named = r.images.map((image, i) => ({
|
|
33786
34075
|
image,
|
|
33787
34076
|
base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, "0")}` : slug
|
|
33788
34077
|
}));
|
|
33789
|
-
return emit(named, args, meta3, async (paths) => {
|
|
33790
|
-
for (const p of paths) writtenOutputs.add(p);
|
|
34078
|
+
return emit(named, { ...args, sink: client2.mediaSink }, meta3, async (paths) => {
|
|
34079
|
+
for (const p of paths) client2.session.writtenOutputs.add(p);
|
|
33791
34080
|
try {
|
|
33792
34081
|
for (const p of paths) await writeSidecar(p, { ...meta3, images: paths });
|
|
33793
34082
|
} catch (err) {
|
|
@@ -33802,10 +34091,10 @@ function registerInteractTools(server) {
|
|
|
33802
34091
|
// src/tools/video.ts
|
|
33803
34092
|
init_zod();
|
|
33804
34093
|
init_dist();
|
|
34094
|
+
init_images();
|
|
33805
34095
|
var VIDEO_ASPECT_RATIOS = ["16:9", "9:16"];
|
|
33806
34096
|
var VIDEO_TASKS = ["text_to_video", "image_to_video", "reference_to_video", "edit"];
|
|
33807
|
-
|
|
33808
|
-
function registerVideoTools(server) {
|
|
34097
|
+
function registerVideoTools(server, client2) {
|
|
33809
34098
|
server.registerTool(
|
|
33810
34099
|
"gemini_video_generate",
|
|
33811
34100
|
{
|
|
@@ -33830,14 +34119,15 @@ function registerVideoTools(server) {
|
|
|
33830
34119
|
}
|
|
33831
34120
|
},
|
|
33832
34121
|
async (args, extra) => {
|
|
34122
|
+
assertLocalInputsAvailable(client2.mediaSink, args);
|
|
33833
34123
|
let previousInteractionId = args.previous_interaction_id;
|
|
33834
34124
|
if (!previousInteractionId && args.continue_last) {
|
|
33835
|
-
if (!lastVideoInteractionId) {
|
|
34125
|
+
if (!client2.session.lastVideoInteractionId) {
|
|
33836
34126
|
throw new McpToolError("No previous video interaction to continue in this session.", {
|
|
33837
34127
|
hint: "Generate a video first, or pass an explicit previous_interaction_id."
|
|
33838
34128
|
});
|
|
33839
34129
|
}
|
|
33840
|
-
previousInteractionId = lastVideoInteractionId;
|
|
34130
|
+
previousInteractionId = client2.session.lastVideoInteractionId;
|
|
33841
34131
|
}
|
|
33842
34132
|
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local image input(s) to the Gemini omni API", "/v1beta/interactions", args.images);
|
|
33843
34133
|
if (gate) return gate;
|
|
@@ -33852,9 +34142,9 @@ function registerVideoTools(server) {
|
|
|
33852
34142
|
from_clipboard: args.from_clipboard,
|
|
33853
34143
|
previous_interaction_id: previousInteractionId
|
|
33854
34144
|
});
|
|
33855
|
-
return dispatch({ toolName: "gemini_video_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
34145
|
+
return client2.session.jobs.dispatch({ toolName: "gemini_video_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
33856
34146
|
const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
33857
|
-
const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () =>
|
|
34147
|
+
const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client2.generateVideo({
|
|
33858
34148
|
input: args.prompt,
|
|
33859
34149
|
images: inputs.length ? inputs : void 0,
|
|
33860
34150
|
model: args.model,
|
|
@@ -33863,7 +34153,7 @@ function registerVideoTools(server) {
|
|
|
33863
34153
|
previousInteractionId,
|
|
33864
34154
|
timeoutMs: args.timeout_ms
|
|
33865
34155
|
}));
|
|
33866
|
-
lastVideoInteractionId = r.id;
|
|
34156
|
+
client2.session.lastVideoInteractionId = r.id;
|
|
33867
34157
|
const meta3 = { model, interaction_id: r.id };
|
|
33868
34158
|
if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
|
|
33869
34159
|
if (r.text) meta3.text = r.text;
|
|
@@ -33873,7 +34163,7 @@ function registerVideoTools(server) {
|
|
|
33873
34163
|
media,
|
|
33874
34164
|
base: r.videos.length > 1 ? `${slug}-${String(i + 1).padStart(2, "0")}` : slug
|
|
33875
34165
|
}));
|
|
33876
|
-
return emitMedia(named, "video", { output_dir: args.output_dir }, meta3);
|
|
34166
|
+
return emitMedia(named, "video", { output_dir: args.output_dir, sink: client2.mediaSink }, meta3);
|
|
33877
34167
|
});
|
|
33878
34168
|
}
|
|
33879
34169
|
);
|
|
@@ -33882,13 +34172,13 @@ function registerVideoTools(server) {
|
|
|
33882
34172
|
// src/tools/music.ts
|
|
33883
34173
|
init_zod();
|
|
33884
34174
|
init_dist();
|
|
34175
|
+
init_images();
|
|
33885
34176
|
var MUSIC_MODELS = ["lyria-3-clip-preview", "lyria-3-pro-preview"];
|
|
33886
34177
|
var AUDIO_FORMATS = ["mp3", "wav"];
|
|
33887
34178
|
function isProModel(model) {
|
|
33888
34179
|
return /pro/i.test(model);
|
|
33889
34180
|
}
|
|
33890
|
-
|
|
33891
|
-
function registerMusicTools(server) {
|
|
34181
|
+
function registerMusicTools(server, client2) {
|
|
33892
34182
|
server.registerTool(
|
|
33893
34183
|
"gemini_music_generate",
|
|
33894
34184
|
{
|
|
@@ -33913,6 +34203,7 @@ function registerMusicTools(server) {
|
|
|
33913
34203
|
}
|
|
33914
34204
|
},
|
|
33915
34205
|
async (args, extra) => {
|
|
34206
|
+
assertLocalInputsAvailable(client2.mediaSink, args);
|
|
33916
34207
|
const model = args.model ?? DEFAULT_MUSIC_MODEL;
|
|
33917
34208
|
if (args.audio_format === "wav" && !isProModel(model)) {
|
|
33918
34209
|
throw new McpToolError(`WAV output requires a Pro model; ${model} only produces MP3.`, {
|
|
@@ -33921,12 +34212,12 @@ function registerMusicTools(server) {
|
|
|
33921
34212
|
}
|
|
33922
34213
|
let previousInteractionId = args.previous_interaction_id;
|
|
33923
34214
|
if (!previousInteractionId && args.continue_last) {
|
|
33924
|
-
if (!lastMusicInteractionId) {
|
|
34215
|
+
if (!client2.session.lastMusicInteractionId) {
|
|
33925
34216
|
throw new McpToolError("No previous music interaction to continue in this session.", {
|
|
33926
34217
|
hint: "Generate music first, or pass an explicit previous_interaction_id."
|
|
33927
34218
|
});
|
|
33928
34219
|
}
|
|
33929
|
-
previousInteractionId = lastMusicInteractionId;
|
|
34220
|
+
previousInteractionId = client2.session.lastMusicInteractionId;
|
|
33930
34221
|
}
|
|
33931
34222
|
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local image input(s) to the Gemini Lyria API", "/v1beta/interactions", args.images);
|
|
33932
34223
|
if (gate) return gate;
|
|
@@ -33939,9 +34230,9 @@ function registerMusicTools(server) {
|
|
|
33939
34230
|
from_clipboard: args.from_clipboard,
|
|
33940
34231
|
previous_interaction_id: previousInteractionId
|
|
33941
34232
|
});
|
|
33942
|
-
return dispatch({ toolName: "gemini_music_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
34233
|
+
return client2.session.jobs.dispatch({ toolName: "gemini_music_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
33943
34234
|
const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
33944
|
-
const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () =>
|
|
34235
|
+
const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () => client2.generateMusic({
|
|
33945
34236
|
input: args.prompt,
|
|
33946
34237
|
images: inputs.length ? inputs : void 0,
|
|
33947
34238
|
model: args.model,
|
|
@@ -33949,7 +34240,7 @@ function registerMusicTools(server) {
|
|
|
33949
34240
|
previousInteractionId,
|
|
33950
34241
|
timeoutMs: args.timeout_ms
|
|
33951
34242
|
}));
|
|
33952
|
-
lastMusicInteractionId = r.id;
|
|
34243
|
+
client2.session.lastMusicInteractionId = r.id;
|
|
33953
34244
|
const meta3 = { model, interaction_id: r.id };
|
|
33954
34245
|
if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
|
|
33955
34246
|
if (r.text) meta3.text = r.text;
|
|
@@ -33959,7 +34250,7 @@ function registerMusicTools(server) {
|
|
|
33959
34250
|
media,
|
|
33960
34251
|
base: r.audios.length > 1 ? `${slug}-${String(i + 1).padStart(2, "0")}` : slug
|
|
33961
34252
|
}));
|
|
33962
|
-
return emitMedia(named, "audio", { inline: args.inline, output_dir: args.output_dir }, meta3);
|
|
34253
|
+
return emitMedia(named, "audio", { inline: args.inline, output_dir: args.output_dir, sink: client2.mediaSink }, meta3);
|
|
33963
34254
|
});
|
|
33964
34255
|
}
|
|
33965
34256
|
);
|
|
@@ -33967,24 +34258,26 @@ function registerMusicTools(server) {
|
|
|
33967
34258
|
|
|
33968
34259
|
// src/tools/jobs.ts
|
|
33969
34260
|
init_zod();
|
|
33970
|
-
function registerJobTools(server) {
|
|
34261
|
+
function registerJobTools(server, client2) {
|
|
33971
34262
|
server.registerTool(
|
|
33972
34263
|
"gemini_get_result",
|
|
33973
34264
|
{
|
|
33974
|
-
description: 'Retrieve an async generation started with `async: true`. Pass the returned `job_id`: while running it reports status "running"; on completion it returns the normal result (image paths / inline images + meta); on failure it raises the recorded error. Jobs
|
|
34265
|
+
description: 'Retrieve an async generation started with `async: true`. Pass the returned `job_id`: while running it reports status "running"; on completion it returns the normal result (image paths / inline images + meta); on failure it raises the recorded error. Jobs belong to your session and expire ~10 min after completion \u2014 if a job id is unknown, check the output dir / <image>.json sidecar.',
|
|
33975
34266
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
33976
34267
|
inputSchema: {
|
|
33977
34268
|
job_id: external_exports.string().min(1).describe("The job_id returned by a generation tool called with async: true")
|
|
33978
34269
|
}
|
|
33979
34270
|
},
|
|
33980
|
-
async (args) =>
|
|
34271
|
+
async (args) => client2.session.jobs.getResult(args.job_id)
|
|
33981
34272
|
);
|
|
33982
34273
|
}
|
|
33983
34274
|
|
|
33984
34275
|
// src/index.ts
|
|
34276
|
+
await loadStdioDotenv();
|
|
33985
34277
|
await runMcp({
|
|
33986
34278
|
name: "gemini-mcp",
|
|
33987
34279
|
version: VERSION,
|
|
33988
34280
|
banner: "[gemini-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.",
|
|
33989
|
-
tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerVideoTools, registerMusicTools, registerJobTools]
|
|
34281
|
+
tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerVideoTools, registerMusicTools, registerJobTools],
|
|
34282
|
+
deps: client
|
|
33990
34283
|
});
|