@chrischall/gemini-mcp 1.0.4 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +74 -1
- package/dist/bundle.js +766 -55
- package/dist/client.js +194 -17
- package/dist/fetch-image.js +339 -0
- package/dist/index.js +2 -1
- package/dist/inputs.js +186 -0
- package/dist/session.js +39 -0
- package/dist/tools/files.js +194 -0
- package/dist/tools/generate.js +21 -12
- package/dist/tools/interact.js +11 -4
- package/dist/tools/set.js +29 -7
- package/dist/tools/shared.js +43 -6
- package/dist/upload-endpoint.js +79 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/gemini-mcp/SKILL.md +60 -4
package/dist/bundle.js
CHANGED
|
@@ -1109,11 +1109,11 @@ var init_types = __esm({
|
|
|
1109
1109
|
status.dirty();
|
|
1110
1110
|
}
|
|
1111
1111
|
} else if (check2.kind === "length") {
|
|
1112
|
-
const
|
|
1112
|
+
const tooBig2 = input.data.length > check2.value;
|
|
1113
1113
|
const tooSmall = input.data.length < check2.value;
|
|
1114
|
-
if (
|
|
1114
|
+
if (tooBig2 || tooSmall) {
|
|
1115
1115
|
ctx = this._getOrReturnCtx(input, ctx);
|
|
1116
|
-
if (
|
|
1116
|
+
if (tooBig2) {
|
|
1117
1117
|
addIssueToContext(ctx, {
|
|
1118
1118
|
code: ZodIssueCode.too_big,
|
|
1119
1119
|
maximum: check2.value,
|
|
@@ -1660,8 +1660,8 @@ var init_types = __esm({
|
|
|
1660
1660
|
status.dirty();
|
|
1661
1661
|
}
|
|
1662
1662
|
} else if (check2.kind === "max") {
|
|
1663
|
-
const
|
|
1664
|
-
if (
|
|
1663
|
+
const tooBig2 = check2.inclusive ? input.data > check2.value : input.data >= check2.value;
|
|
1664
|
+
if (tooBig2) {
|
|
1665
1665
|
ctx = this._getOrReturnCtx(input, ctx);
|
|
1666
1666
|
addIssueToContext(ctx, {
|
|
1667
1667
|
code: ZodIssueCode.too_big,
|
|
@@ -1877,8 +1877,8 @@ var init_types = __esm({
|
|
|
1877
1877
|
status.dirty();
|
|
1878
1878
|
}
|
|
1879
1879
|
} else if (check2.kind === "max") {
|
|
1880
|
-
const
|
|
1881
|
-
if (
|
|
1880
|
+
const tooBig2 = check2.inclusive ? input.data > check2.value : input.data >= check2.value;
|
|
1881
|
+
if (tooBig2) {
|
|
1882
1882
|
ctx = this._getOrReturnCtx(input, ctx);
|
|
1883
1883
|
addIssueToContext(ctx, {
|
|
1884
1884
|
code: ZodIssueCode.too_big,
|
|
@@ -2292,13 +2292,13 @@ var init_types = __esm({
|
|
|
2292
2292
|
return INVALID;
|
|
2293
2293
|
}
|
|
2294
2294
|
if (def.exactLength !== null) {
|
|
2295
|
-
const
|
|
2295
|
+
const tooBig2 = ctx.data.length > def.exactLength.value;
|
|
2296
2296
|
const tooSmall = ctx.data.length < def.exactLength.value;
|
|
2297
|
-
if (
|
|
2297
|
+
if (tooBig2 || tooSmall) {
|
|
2298
2298
|
addIssueToContext(ctx, {
|
|
2299
|
-
code:
|
|
2299
|
+
code: tooBig2 ? ZodIssueCode.too_big : ZodIssueCode.too_small,
|
|
2300
2300
|
minimum: tooSmall ? def.exactLength.value : void 0,
|
|
2301
|
-
maximum:
|
|
2301
|
+
maximum: tooBig2 ? def.exactLength.value : void 0,
|
|
2302
2302
|
type: "array",
|
|
2303
2303
|
inclusive: true,
|
|
2304
2304
|
exact: true,
|
|
@@ -5462,10 +5462,10 @@ var init_checks = __esm({
|
|
|
5462
5462
|
const size = input.size;
|
|
5463
5463
|
if (size === def.size)
|
|
5464
5464
|
return;
|
|
5465
|
-
const
|
|
5465
|
+
const tooBig2 = size > def.size;
|
|
5466
5466
|
payload.issues.push({
|
|
5467
5467
|
origin: getSizableOrigin(input),
|
|
5468
|
-
...
|
|
5468
|
+
...tooBig2 ? { code: "too_big", maximum: def.size } : { code: "too_small", minimum: def.size },
|
|
5469
5469
|
inclusive: true,
|
|
5470
5470
|
exact: true,
|
|
5471
5471
|
input: payload.value,
|
|
@@ -5551,10 +5551,10 @@ var init_checks = __esm({
|
|
|
5551
5551
|
if (length === def.length)
|
|
5552
5552
|
return;
|
|
5553
5553
|
const origin = getLengthableOrigin(input);
|
|
5554
|
-
const
|
|
5554
|
+
const tooBig2 = length > def.length;
|
|
5555
5555
|
payload.issues.push({
|
|
5556
5556
|
origin,
|
|
5557
|
-
...
|
|
5557
|
+
...tooBig2 ? { code: "too_big", maximum: def.length } : { code: "too_small", minimum: def.length },
|
|
5558
5558
|
inclusive: true,
|
|
5559
5559
|
exact: true,
|
|
5560
5560
|
input: payload.value,
|
|
@@ -25669,11 +25669,11 @@ var require_resolve = __commonJS({
|
|
|
25669
25669
|
return id ? id.replace(TRAILING_SLASH_HASH, "") : "";
|
|
25670
25670
|
}
|
|
25671
25671
|
exports.normalizeId = normalizeId;
|
|
25672
|
-
function
|
|
25672
|
+
function resolveUrl2(resolver, baseId, id) {
|
|
25673
25673
|
id = normalizeId(id);
|
|
25674
25674
|
return resolver.resolve(baseId, id);
|
|
25675
25675
|
}
|
|
25676
|
-
exports.resolveUrl =
|
|
25676
|
+
exports.resolveUrl = resolveUrl2;
|
|
25677
25677
|
var ANCHOR = /^[a-z_][-a-z0-9._]*$/i;
|
|
25678
25678
|
function getSchemaRefs(schema, baseId) {
|
|
25679
25679
|
if (typeof schema == "boolean")
|
|
@@ -32808,7 +32808,7 @@ async function loadStdioDotenv() {
|
|
|
32808
32808
|
}
|
|
32809
32809
|
|
|
32810
32810
|
// src/version.ts
|
|
32811
|
-
var VERSION = "1.0
|
|
32811
|
+
var VERSION = "1.1.0";
|
|
32812
32812
|
|
|
32813
32813
|
// src/tools/models.ts
|
|
32814
32814
|
init_dist();
|
|
@@ -32848,6 +32848,355 @@ function filterImageModels(raw) {
|
|
|
32848
32848
|
// src/tools/generate.ts
|
|
32849
32849
|
init_images();
|
|
32850
32850
|
|
|
32851
|
+
// src/inputs.ts
|
|
32852
|
+
init_dist();
|
|
32853
|
+
|
|
32854
|
+
// src/fetch-image.ts
|
|
32855
|
+
init_dist();
|
|
32856
|
+
var IMAGE_URL_MAX_BYTES = 15 * 1024 * 1024;
|
|
32857
|
+
var IMAGE_INLINE_MAX_BYTES = 6 * 1024 * 1024;
|
|
32858
|
+
var MAX_REDIRECTS = 5;
|
|
32859
|
+
var DEFAULT_FETCH_TIMEOUT_MS = 6e4;
|
|
32860
|
+
function ipv4Octets(host) {
|
|
32861
|
+
const parts = host.split(".");
|
|
32862
|
+
if (parts.length !== 4) return void 0;
|
|
32863
|
+
const octets = parts.map((p) => /^\d{1,3}$/.test(p) ? Number(p) : NaN);
|
|
32864
|
+
return octets.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) ? octets : void 0;
|
|
32865
|
+
}
|
|
32866
|
+
function isPrivateIpv4([a, b]) {
|
|
32867
|
+
if (a === 0 || a === 127) return true;
|
|
32868
|
+
if (a === 10) return true;
|
|
32869
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
32870
|
+
if (a === 192 && b === 168) return true;
|
|
32871
|
+
if (a === 169 && b === 254) return true;
|
|
32872
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
32873
|
+
if (a === 192 && b === 0) return true;
|
|
32874
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
32875
|
+
if (a >= 224) return true;
|
|
32876
|
+
return false;
|
|
32877
|
+
}
|
|
32878
|
+
function ipv6Groups(host) {
|
|
32879
|
+
if (!host.includes(":")) return void 0;
|
|
32880
|
+
let text = host;
|
|
32881
|
+
const tail = [];
|
|
32882
|
+
const dotted = /:((?:\d{1,3}\.){3}\d{1,3})$/.exec(text);
|
|
32883
|
+
if (dotted) {
|
|
32884
|
+
const octets = ipv4Octets(dotted[1]);
|
|
32885
|
+
if (!octets) return void 0;
|
|
32886
|
+
tail.push(octets[0] << 8 | octets[1], octets[2] << 8 | octets[3]);
|
|
32887
|
+
text = text.slice(0, dotted.index);
|
|
32888
|
+
}
|
|
32889
|
+
const halves = text.split("::");
|
|
32890
|
+
if (halves.length > 2) return void 0;
|
|
32891
|
+
const parse3 = (part) => {
|
|
32892
|
+
if (part === "") return [];
|
|
32893
|
+
const out = [];
|
|
32894
|
+
for (const piece of part.split(":")) {
|
|
32895
|
+
if (!/^[0-9a-f]{1,4}$/.test(piece)) return void 0;
|
|
32896
|
+
out.push(parseInt(piece, 16));
|
|
32897
|
+
}
|
|
32898
|
+
return out;
|
|
32899
|
+
};
|
|
32900
|
+
const head = parse3(halves[0]);
|
|
32901
|
+
const rest = halves.length === 2 ? parse3(halves[1]) : [];
|
|
32902
|
+
if (!head || !rest) return void 0;
|
|
32903
|
+
const explicit = [...head, ...rest, ...tail];
|
|
32904
|
+
if (halves.length === 2) {
|
|
32905
|
+
if (explicit.length > 7) return void 0;
|
|
32906
|
+
return [...head, ...Array(8 - explicit.length).fill(0), ...rest, ...tail];
|
|
32907
|
+
}
|
|
32908
|
+
return explicit.length === 8 ? explicit : void 0;
|
|
32909
|
+
}
|
|
32910
|
+
var V4_BEARING_PREFIXES = [
|
|
32911
|
+
[0, 0, 0, 0, 0, 65535],
|
|
32912
|
+
// ::ffff:0:0/96 — IPv4-mapped
|
|
32913
|
+
[0, 0, 0, 0, 0, 0],
|
|
32914
|
+
// ::/96 — IPv4-compatible (deprecated)
|
|
32915
|
+
[0, 0, 0, 0, 65535, 0],
|
|
32916
|
+
// ::ffff:0:0:0/96 — IPv4-translated (deprecated SIIT)
|
|
32917
|
+
[100, 65435, 0, 0, 0, 0]
|
|
32918
|
+
// 64:ff9b::/96 — NAT64 well-known (RFC 6052)
|
|
32919
|
+
];
|
|
32920
|
+
function isPrivateIpv6(groups) {
|
|
32921
|
+
if (groups.every((g) => g === 0)) return true;
|
|
32922
|
+
if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return true;
|
|
32923
|
+
if ((groups[0] & 65472) === 65152) return true;
|
|
32924
|
+
if ((groups[0] & 65024) === 64512) return true;
|
|
32925
|
+
if (V4_BEARING_PREFIXES.some((prefix) => prefix.every((g, i) => groups[i] === g))) {
|
|
32926
|
+
return isPrivateIpv4([groups[6] >> 8, groups[6] & 255, groups[7] >> 8, groups[7] & 255]);
|
|
32927
|
+
}
|
|
32928
|
+
return false;
|
|
32929
|
+
}
|
|
32930
|
+
function assertPublicHttpsUrl(raw, requested) {
|
|
32931
|
+
let url2;
|
|
32932
|
+
try {
|
|
32933
|
+
url2 = new URL(raw);
|
|
32934
|
+
} catch {
|
|
32935
|
+
throw new McpToolError(`Not a valid URL: ${raw}`, {
|
|
32936
|
+
hint: "images_url entries must be absolute https:// URLs."
|
|
32937
|
+
});
|
|
32938
|
+
}
|
|
32939
|
+
if (url2.protocol !== "https:") {
|
|
32940
|
+
throw new McpToolError(`Refusing to fetch ${describeUrl(raw, requested)}: only https:// URLs are fetched (got "${url2.protocol}").`, {
|
|
32941
|
+
hint: "Use an https:// URL, or send the bytes with images_base64 / gemini_upload_file."
|
|
32942
|
+
});
|
|
32943
|
+
}
|
|
32944
|
+
const rawHost = url2.hostname.toLowerCase();
|
|
32945
|
+
const isIpv6Literal = rawHost.startsWith("[") && rawHost.endsWith("]");
|
|
32946
|
+
const host = isIpv6Literal ? rawHost.slice(1, -1) : rawHost;
|
|
32947
|
+
const v4 = isIpv6Literal ? void 0 : ipv4Octets(host);
|
|
32948
|
+
const v6 = isIpv6Literal ? ipv6Groups(host) : void 0;
|
|
32949
|
+
const blocked = isIpv6Literal ? v6 === void 0 || isPrivateIpv6(v6) : host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || (v4 ? isPrivateIpv4(v4) : false);
|
|
32950
|
+
if (blocked) {
|
|
32951
|
+
throw new McpToolError(
|
|
32952
|
+
`Refusing to fetch ${describeUrl(raw, requested)}: "${url2.hostname}" is a private, loopback or link-local address, not a public host.`,
|
|
32953
|
+
{ hint: "images_url is fetched by the server, so it must point at a publicly reachable https:// image." }
|
|
32954
|
+
);
|
|
32955
|
+
}
|
|
32956
|
+
return url2;
|
|
32957
|
+
}
|
|
32958
|
+
function describeUrl(current, requested) {
|
|
32959
|
+
return current === requested ? current : `${current} (redirected from ${requested})`;
|
|
32960
|
+
}
|
|
32961
|
+
async function readCapped(res, maxBytes, url2, requested) {
|
|
32962
|
+
const declared = Number(res.headers.get("content-length"));
|
|
32963
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
32964
|
+
throw tooBig(url2, requested, declared, maxBytes);
|
|
32965
|
+
}
|
|
32966
|
+
const reader = res.body?.getReader();
|
|
32967
|
+
if (!reader) {
|
|
32968
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
32969
|
+
if (buf.byteLength > maxBytes) throw tooBig(url2, requested, buf.byteLength, maxBytes);
|
|
32970
|
+
return buf;
|
|
32971
|
+
}
|
|
32972
|
+
const chunks = [];
|
|
32973
|
+
let total = 0;
|
|
32974
|
+
try {
|
|
32975
|
+
for (; ; ) {
|
|
32976
|
+
const { done, value } = await reader.read();
|
|
32977
|
+
if (done) break;
|
|
32978
|
+
if (!value) continue;
|
|
32979
|
+
total += value.byteLength;
|
|
32980
|
+
if (total > maxBytes) {
|
|
32981
|
+
await reader.cancel().catch(() => {
|
|
32982
|
+
});
|
|
32983
|
+
throw tooBig(url2, requested, total, maxBytes, true);
|
|
32984
|
+
}
|
|
32985
|
+
chunks.push(value);
|
|
32986
|
+
}
|
|
32987
|
+
} finally {
|
|
32988
|
+
reader.releaseLock?.();
|
|
32989
|
+
}
|
|
32990
|
+
const out = new Uint8Array(total);
|
|
32991
|
+
let offset = 0;
|
|
32992
|
+
for (const c of chunks) {
|
|
32993
|
+
out.set(c, offset);
|
|
32994
|
+
offset += c.byteLength;
|
|
32995
|
+
}
|
|
32996
|
+
return out;
|
|
32997
|
+
}
|
|
32998
|
+
function fetchFailure(err, current, requested, timeoutMs) {
|
|
32999
|
+
const name = err?.name;
|
|
33000
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
33001
|
+
return new McpToolError(`Fetching ${describeUrl(current, requested)} timed out after ${timeoutMs}ms.`, {
|
|
33002
|
+
hint: "The server accepted the connection but did not finish sending the image. Try a different URL, or raise timeout_ms."
|
|
33003
|
+
});
|
|
33004
|
+
}
|
|
33005
|
+
return new McpToolError(
|
|
33006
|
+
`Could not fetch ${describeUrl(current, requested)}: ${err instanceof Error ? err.message : String(err)}`,
|
|
33007
|
+
{ hint: "Check the URL is reachable from the server and serves the image without authentication." }
|
|
33008
|
+
);
|
|
33009
|
+
}
|
|
33010
|
+
function tooBig(url2, requested, size, max, partial2 = false) {
|
|
33011
|
+
const mb2 = (n) => `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
33012
|
+
return new McpToolError(
|
|
33013
|
+
`Image at ${describeUrl(url2, requested)} is ${partial2 ? "over" : ""} ${mb2(size)}, above the ${mb2(max)} limit for images_url.`,
|
|
33014
|
+
{ hint: "Resize or re-encode the image, or upload it yourself with gemini_upload_file and pass the returned file_uri." }
|
|
33015
|
+
);
|
|
33016
|
+
}
|
|
33017
|
+
async function fetchRemoteImage(requestedUrl, opts = {}) {
|
|
33018
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
33019
|
+
const maxBytes = opts.maxBytes ?? IMAGE_URL_MAX_BYTES;
|
|
33020
|
+
const accept = opts.accept ?? ["image/"];
|
|
33021
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
33022
|
+
let current = requestedUrl;
|
|
33023
|
+
for (let hop = 0; ; hop++) {
|
|
33024
|
+
const url2 = assertPublicHttpsUrl(current, requestedUrl);
|
|
33025
|
+
const signal = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : void 0;
|
|
33026
|
+
let res;
|
|
33027
|
+
try {
|
|
33028
|
+
res = await doFetch(url2.toString(), {
|
|
33029
|
+
redirect: "manual",
|
|
33030
|
+
headers: { accept: accept.map((p) => `${p.replace(/\/$/, "")}/*`).join(", ") },
|
|
33031
|
+
...signal ? { signal } : {}
|
|
33032
|
+
});
|
|
33033
|
+
} catch (err) {
|
|
33034
|
+
throw fetchFailure(err, current, requestedUrl, timeoutMs);
|
|
33035
|
+
}
|
|
33036
|
+
if (res.status >= 300 && res.status < 400) {
|
|
33037
|
+
const location = res.headers.get("location");
|
|
33038
|
+
if (!location) {
|
|
33039
|
+
throw new McpToolError(`Fetching ${describeUrl(current, requestedUrl)} returned HTTP ${res.status} with no Location header.`);
|
|
33040
|
+
}
|
|
33041
|
+
if (hop >= MAX_REDIRECTS) {
|
|
33042
|
+
throw new McpToolError(`Fetching ${requestedUrl} exceeded ${MAX_REDIRECTS} redirects (last hop: ${location}).`);
|
|
33043
|
+
}
|
|
33044
|
+
current = new URL(location, url2).toString();
|
|
33045
|
+
continue;
|
|
33046
|
+
}
|
|
33047
|
+
if (!res.ok) {
|
|
33048
|
+
throw new McpToolError(`Fetching ${describeUrl(current, requestedUrl)} returned HTTP ${res.status} ${res.statusText || ""}`.trim(), {
|
|
33049
|
+
hint: "The URL must be publicly readable \u2014 a login page or signed-URL expiry shows up here as 401/403/404."
|
|
33050
|
+
});
|
|
33051
|
+
}
|
|
33052
|
+
const contentType = (res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
33053
|
+
if (!accept.some((prefix) => contentType.startsWith(prefix))) {
|
|
33054
|
+
const expected = accept.map((p) => `${p.replace(/\/$/, "")}/*`).join(" or ");
|
|
33055
|
+
throw new McpToolError(
|
|
33056
|
+
`${describeUrl(current, requestedUrl)} is not ${accept.length === 1 && accept[0] === "image/" ? "an image" : "usable media"}: the server returned Content-Type "${contentType || "(none)"}", expected ${expected}.`,
|
|
33057
|
+
{ hint: "Link directly to the media file, not to the page that displays it." }
|
|
33058
|
+
);
|
|
33059
|
+
}
|
|
33060
|
+
let bytes;
|
|
33061
|
+
try {
|
|
33062
|
+
bytes = await readCapped(res, maxBytes, current, requestedUrl);
|
|
33063
|
+
} catch (err) {
|
|
33064
|
+
if (err instanceof McpToolError) throw err;
|
|
33065
|
+
throw fetchFailure(err, current, requestedUrl, timeoutMs);
|
|
33066
|
+
}
|
|
33067
|
+
if (bytes.byteLength === 0) {
|
|
33068
|
+
throw new McpToolError(`${describeUrl(current, requestedUrl)} returned an empty body.`);
|
|
33069
|
+
}
|
|
33070
|
+
return { bytes, mimeType: contentType, size: bytes.byteLength, finalUrl: current, requestedUrl };
|
|
33071
|
+
}
|
|
33072
|
+
}
|
|
33073
|
+
|
|
33074
|
+
// src/inputs.ts
|
|
33075
|
+
function hasImageInput(args) {
|
|
33076
|
+
return Boolean(
|
|
33077
|
+
args.images?.length || args.images_base64?.length || args.images_url?.length || args.images_file_uris?.length || args.from_clipboard
|
|
33078
|
+
);
|
|
33079
|
+
}
|
|
33080
|
+
var ASSUMED_TTL_MS = 47 * 60 * 60 * 1e3;
|
|
33081
|
+
function expiresAtMs(expirationTime, now) {
|
|
33082
|
+
if (expirationTime) {
|
|
33083
|
+
const t = Date.parse(expirationTime);
|
|
33084
|
+
if (Number.isFinite(t)) return t;
|
|
33085
|
+
}
|
|
33086
|
+
return now + ASSUMED_TTL_MS;
|
|
33087
|
+
}
|
|
33088
|
+
async function resolveImageInputs(args, client2) {
|
|
33089
|
+
const report = {};
|
|
33090
|
+
const inputs = [];
|
|
33091
|
+
const onDisk = client2.mediaSink?.persistsFiles ?? true;
|
|
33092
|
+
if (args.from_clipboard) {
|
|
33093
|
+
inputs.push(await (await Promise.resolve().then(() => (init_clipboard(), clipboard_exports))).readClipboardImage());
|
|
33094
|
+
}
|
|
33095
|
+
for (const path of args.images ?? []) {
|
|
33096
|
+
inputs.push(await resolveLocalPath(path, client2, onDisk, report));
|
|
33097
|
+
}
|
|
33098
|
+
if (args.images_base64?.length) {
|
|
33099
|
+
const { decodeImageInput: decodeImageInput2 } = await Promise.resolve().then(() => (init_images(), images_exports));
|
|
33100
|
+
for (const b64 of args.images_base64) inputs.push(decodeImageInput2(b64));
|
|
33101
|
+
}
|
|
33102
|
+
const fetched = /* @__PURE__ */ new Map();
|
|
33103
|
+
for (const url2 of args.images_url ?? []) {
|
|
33104
|
+
const hit = fetched.get(url2);
|
|
33105
|
+
if (hit) {
|
|
33106
|
+
inputs.push(hit);
|
|
33107
|
+
continue;
|
|
33108
|
+
}
|
|
33109
|
+
const resolved = await resolveUrl(url2, client2, report);
|
|
33110
|
+
fetched.set(url2, resolved);
|
|
33111
|
+
inputs.push(resolved);
|
|
33112
|
+
}
|
|
33113
|
+
const seenUris = /* @__PURE__ */ new Map();
|
|
33114
|
+
for (const ref of args.images_file_uris ?? []) {
|
|
33115
|
+
const hit = seenUris.get(ref);
|
|
33116
|
+
if (hit) {
|
|
33117
|
+
inputs.push(hit);
|
|
33118
|
+
continue;
|
|
33119
|
+
}
|
|
33120
|
+
const file2 = await client2.getFile(ref);
|
|
33121
|
+
const input = { uri: file2.uri, mimeType: file2.mimeType };
|
|
33122
|
+
seenUris.set(ref, input);
|
|
33123
|
+
(report.file_uris ??= []).push(file2.name);
|
|
33124
|
+
inputs.push(input);
|
|
33125
|
+
}
|
|
33126
|
+
return { inputs, report: Object.keys(report).length > 0 ? report : void 0 };
|
|
33127
|
+
}
|
|
33128
|
+
async function resolveLocalPath(path, client2, onDisk, report) {
|
|
33129
|
+
const { readImageAsInline: readImageAsInline2, resolveImagePath: resolveImagePath2 } = await Promise.resolve().then(() => (init_images(), images_exports));
|
|
33130
|
+
if (!onDisk) return readImageAsInline2(path);
|
|
33131
|
+
const { stat: stat3 } = await import("node:fs/promises");
|
|
33132
|
+
const resolved = resolveImagePath2(path);
|
|
33133
|
+
let key;
|
|
33134
|
+
try {
|
|
33135
|
+
const { mtimeMs, size } = await stat3(resolved);
|
|
33136
|
+
key = `path\0${resolved}\0${mtimeMs}\0${size}`;
|
|
33137
|
+
} catch {
|
|
33138
|
+
return readImageAsInline2(path);
|
|
33139
|
+
}
|
|
33140
|
+
const cached2 = client2.session.cachedUpload(key);
|
|
33141
|
+
if (cached2) return { uri: cached2.uri, mimeType: cached2.mimeType };
|
|
33142
|
+
const references = client2.session.countReference(key);
|
|
33143
|
+
const inline = await readImageAsInline2(resolved);
|
|
33144
|
+
if (references < 2) return inline;
|
|
33145
|
+
try {
|
|
33146
|
+
const bytes = Uint8Array.from(atob(inline.base64), (c) => c.charCodeAt(0));
|
|
33147
|
+
const displayName = resolved.split(/[\\/]/).pop() || "image";
|
|
33148
|
+
const uploaded = await client2.uploadBytes(bytes, inline.mimeType, displayName);
|
|
33149
|
+
client2.session.uploadCache.set(key, {
|
|
33150
|
+
name: uploaded.name,
|
|
33151
|
+
uri: uploaded.uri,
|
|
33152
|
+
mimeType: uploaded.mimeType,
|
|
33153
|
+
expiresAtMs: expiresAtMs(uploaded.expirationTime, Date.now())
|
|
33154
|
+
});
|
|
33155
|
+
(report.auto_uploaded ??= []).push({ path: resolved, file_uri: uploaded.name, expires: uploaded.expirationTime });
|
|
33156
|
+
return { uri: uploaded.uri, mimeType: uploaded.mimeType };
|
|
33157
|
+
} catch {
|
|
33158
|
+
return inline;
|
|
33159
|
+
}
|
|
33160
|
+
}
|
|
33161
|
+
async function resolveUrl(url2, client2, report) {
|
|
33162
|
+
const fetched = await client2.fetchRemoteImage(url2);
|
|
33163
|
+
const entry = {
|
|
33164
|
+
url: url2,
|
|
33165
|
+
mime_type: fetched.mimeType,
|
|
33166
|
+
bytes: fetched.size
|
|
33167
|
+
};
|
|
33168
|
+
(report.fetched_urls ??= []).push(entry);
|
|
33169
|
+
if (fetched.size <= IMAGE_INLINE_MAX_BYTES) {
|
|
33170
|
+
return { base64: encodeBase64(fetched.bytes), mimeType: fetched.mimeType };
|
|
33171
|
+
}
|
|
33172
|
+
const displayName = fileNameFromUrl(url2);
|
|
33173
|
+
const uploaded = await client2.uploadBytes(fetched.bytes, fetched.mimeType, displayName);
|
|
33174
|
+
entry.file_uri = uploaded.name;
|
|
33175
|
+
return { uri: uploaded.uri, mimeType: uploaded.mimeType };
|
|
33176
|
+
}
|
|
33177
|
+
function fileNameFromUrl(url2) {
|
|
33178
|
+
try {
|
|
33179
|
+
const name = new URL(url2).pathname.split("/").filter(Boolean).pop();
|
|
33180
|
+
return name ? decodeURIComponent(name).slice(0, 120) : "image";
|
|
33181
|
+
} catch {
|
|
33182
|
+
return "image";
|
|
33183
|
+
}
|
|
33184
|
+
}
|
|
33185
|
+
function encodeBase64(bytes) {
|
|
33186
|
+
let binary = "";
|
|
33187
|
+
const CHUNK = 32768;
|
|
33188
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
33189
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
33190
|
+
}
|
|
33191
|
+
return btoa(binary);
|
|
33192
|
+
}
|
|
33193
|
+
function requireImageInput(args) {
|
|
33194
|
+
if (hasImageInput(args)) return;
|
|
33195
|
+
throw new McpToolError(
|
|
33196
|
+
"Provide at least one input image via `images_url` (an https URL the server fetches), `images_file_uris` (from gemini_upload_file), `images` (a local path), `images_base64`, or `from_clipboard`."
|
|
33197
|
+
);
|
|
33198
|
+
}
|
|
33199
|
+
|
|
32851
33200
|
// src/tools/shared.ts
|
|
32852
33201
|
init_zod();
|
|
32853
33202
|
init_dist();
|
|
@@ -32883,6 +33232,16 @@ var idempotencyKeySchema = external_exports.string().min(1).optional().describe(
|
|
|
32883
33232
|
var asyncSchema = external_exports.boolean().optional().describe(
|
|
32884
33233
|
"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)."
|
|
32885
33234
|
);
|
|
33235
|
+
function imagesUrlSchema(label = "Reference images") {
|
|
33236
|
+
return external_exports.array(external_exports.string().url()).optional().describe(
|
|
33237
|
+
`${label} as public https URLs \u2014 the SERVER downloads them, so no image bytes travel through the conversation. Preferred over images_base64, which costs ~14k tokens per photo and breaks if a file read was truncated. Max ${Math.round(IMAGE_URL_MAX_BYTES / (1024 * 1024))}MB each; must be a directly-linked image (Content-Type image/*).`
|
|
33238
|
+
);
|
|
33239
|
+
}
|
|
33240
|
+
function imagesFileUrisSchema(label = "Reference images") {
|
|
33241
|
+
return external_exports.array(external_exports.string().min(1)).optional().describe(
|
|
33242
|
+
`${label} by Gemini Files API reference ("files/<id>", or the full uri) from gemini_upload_file or POST /upload. Upload once, then reference it across as many calls as you like \u2014 no bytes are re-sent and none enter the conversation. Files are retained ~48h, after which the reference stops resolving.`
|
|
33243
|
+
);
|
|
33244
|
+
}
|
|
32886
33245
|
var sharedImageSchema = {
|
|
32887
33246
|
// Bare id only: the model is interpolated into the URL path
|
|
32888
33247
|
// (`/models/${model}:generateContent`), so slashes/colons/queries must be
|
|
@@ -33004,7 +33363,7 @@ function assertLocalInputsAvailable(sink, args) {
|
|
|
33004
33363
|
if (args.from_clipboard) unavailable.push("`from_clipboard` (reads the macOS clipboard via child_process)");
|
|
33005
33364
|
if (args.video_path) unavailable.push("`video_path` (uploads a file streamed from disk)");
|
|
33006
33365
|
if (unavailable.length === 0) return;
|
|
33007
|
-
const fix = "
|
|
33366
|
+
const fix = "Reference images WITHOUT putting bytes in the conversation, in order of preference: (1) `images_url` \u2014 public https URLs the server fetches itself; (2) `images_file_uris` \u2014 upload once with `gemini_upload_file` (it takes a `url` or `data_base64`) and reuse the returned files/\u2026 uri; (3) `POST /upload` on this connector with the raw bytes as the body (`curl -X POST <connector-url>/upload -H 'Authorization: Bearer <token>' -H 'Content-Type: image/jpeg' --data-binary @photo.jpg`), then pass the returned file_uri. As a last resort `images_base64` still works, but it costs ~14k tokens per photo and breaks on a truncated read. Reference video by `video_url` (a public YouTube URL, or a files/\u2026 uri uploaded elsewhere) instead of `video_path`.";
|
|
33008
33367
|
throw new McpToolError(
|
|
33009
33368
|
`${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}`,
|
|
33010
33369
|
{ hint: fix }
|
|
@@ -33197,7 +33556,9 @@ function registerGenerateTools(server, client2) {
|
|
|
33197
33556
|
count: external_exports.number().int().positive().max(8).optional().describe("Number of independent images (default 1)"),
|
|
33198
33557
|
filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified prompt)"),
|
|
33199
33558
|
images: external_exports.array(external_exports.string().min(1)).optional().describe("Paths to reference input images (image-conditioned generation)"),
|
|
33200
|
-
|
|
33559
|
+
images_url: imagesUrlSchema(),
|
|
33560
|
+
images_file_uris: imagesFileUrisSchema(),
|
|
33561
|
+
images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation"),
|
|
33201
33562
|
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)"),
|
|
33202
33563
|
video_path: videoPathSchema,
|
|
33203
33564
|
confirm: schemaConfirm,
|
|
@@ -33221,13 +33582,15 @@ function registerGenerateTools(server, client2) {
|
|
|
33221
33582
|
images: args.images,
|
|
33222
33583
|
images_base64: args.images_base64,
|
|
33223
33584
|
from_clipboard: args.from_clipboard,
|
|
33585
|
+
images_url: args.images_url,
|
|
33586
|
+
images_file_uris: args.images_file_uris,
|
|
33224
33587
|
video_url: args.video_url,
|
|
33225
33588
|
video_path: args.video_path
|
|
33226
33589
|
});
|
|
33227
33590
|
return client2.session.jobs.dispatch({ toolName: "gemini_image_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
33228
33591
|
const seed = pickSeed(args.seed);
|
|
33229
33592
|
const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
|
|
33230
|
-
const refInputs = await
|
|
33593
|
+
const { inputs: refInputs, report } = await withProgressHeartbeat(extra, "Resolving reference images", () => resolveImageInputs(args, client2));
|
|
33231
33594
|
const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args, client2));
|
|
33232
33595
|
const named = [];
|
|
33233
33596
|
let capturedText;
|
|
@@ -33256,6 +33619,7 @@ function registerGenerateTools(server, client2) {
|
|
|
33256
33619
|
const meta3 = buildMeta(model, seed, args);
|
|
33257
33620
|
if (capturedText) meta3.text = capturedText;
|
|
33258
33621
|
if (capturedGrounding) meta3.grounding = capturedGrounding;
|
|
33622
|
+
if (report) meta3.image_inputs = report;
|
|
33259
33623
|
if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
|
|
33260
33624
|
meta3.hint = REFINE_HINT;
|
|
33261
33625
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, count, persistsFiles: client2.mediaSink?.persistsFiles });
|
|
@@ -33272,7 +33636,9 @@ function registerGenerateTools(server, client2) {
|
|
|
33272
33636
|
inputSchema: {
|
|
33273
33637
|
prompt: external_exports.string().min(1).describe("Instruction describing the edit or composition"),
|
|
33274
33638
|
images: external_exports.array(external_exports.string().min(1)).optional().describe("Paths to input image file(s) (1 = edit, 2+ = compose)"),
|
|
33275
|
-
|
|
33639
|
+
images_url: imagesUrlSchema("Input images"),
|
|
33640
|
+
images_file_uris: imagesFileUrisSchema("Input images"),
|
|
33641
|
+
images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Input images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation"),
|
|
33276
33642
|
filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified prompt)"),
|
|
33277
33643
|
confirm: schemaConfirm,
|
|
33278
33644
|
...sharedImageSchema
|
|
@@ -33280,11 +33646,7 @@ function registerGenerateTools(server, client2) {
|
|
|
33280
33646
|
},
|
|
33281
33647
|
async (args, extra) => {
|
|
33282
33648
|
assertLocalInputsAvailable(client2.mediaSink, args);
|
|
33283
|
-
|
|
33284
|
-
const hasBase64 = (args.images_base64?.length ?? 0) > 0;
|
|
33285
|
-
if (!hasPaths && !hasBase64 && !args.from_clipboard) {
|
|
33286
|
-
throw new McpToolError("Provide at least one input image via `images`, `images_base64`, or `from_clipboard`.");
|
|
33287
|
-
}
|
|
33649
|
+
requireImageInput(args);
|
|
33288
33650
|
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
|
|
33289
33651
|
if (gate) return gate;
|
|
33290
33652
|
const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
@@ -33297,12 +33659,14 @@ function registerGenerateTools(server, client2) {
|
|
|
33297
33659
|
google_search: args.google_search,
|
|
33298
33660
|
images: args.images,
|
|
33299
33661
|
images_base64: args.images_base64,
|
|
33300
|
-
from_clipboard: args.from_clipboard
|
|
33662
|
+
from_clipboard: args.from_clipboard,
|
|
33663
|
+
images_url: args.images_url,
|
|
33664
|
+
images_file_uris: args.images_file_uris
|
|
33301
33665
|
});
|
|
33302
33666
|
return client2.session.jobs.dispatch({ toolName: "gemini_image_edit", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
33303
33667
|
const seed = pickSeed(args.seed);
|
|
33304
33668
|
const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
|
|
33305
|
-
const inputs = await
|
|
33669
|
+
const { inputs, report } = await withProgressHeartbeat(extra, "Resolving input images", () => resolveImageInputs(args, client2));
|
|
33306
33670
|
const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client2.generate({
|
|
33307
33671
|
prompt: args.prompt,
|
|
33308
33672
|
images: inputs,
|
|
@@ -33318,6 +33682,7 @@ function registerGenerateTools(server, client2) {
|
|
|
33318
33682
|
const meta3 = buildMeta(model, seed, args);
|
|
33319
33683
|
if (text) meta3.text = text;
|
|
33320
33684
|
if (result.grounding) meta3.grounding = result.grounding;
|
|
33685
|
+
if (report) meta3.image_inputs = report;
|
|
33321
33686
|
meta3.hint = REFINE_HINT;
|
|
33322
33687
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: client2.mediaSink?.persistsFiles });
|
|
33323
33688
|
if (risk) meta3.timeout_risk = risk;
|
|
@@ -33344,7 +33709,9 @@ function registerSetTools(server, client2) {
|
|
|
33344
33709
|
reference_mode: external_exports.enum(["master", "chain"]).optional().describe("master: every image references the master (default). chain: each references the previous."),
|
|
33345
33710
|
basename: external_exports.string().optional().describe("Base filename prefix for output images (default: slugified master_prompt)"),
|
|
33346
33711
|
master_images: external_exports.array(external_exports.string().min(1)).optional().describe("Reference image paths passed to the master generation call"),
|
|
33347
|
-
|
|
33712
|
+
master_images_url: imagesUrlSchema("Reference images passed to the master AND to every scene call (fetched once)"),
|
|
33713
|
+
master_images_file_uris: imagesFileUrisSchema("Reference images passed to the master AND to every scene call"),
|
|
33714
|
+
master_images_base64: external_exports.array(external_exports.string().min(1)).optional().describe("Reference images as base64 strings or data URIs for master generation. Last resort: prefer master_images_url or master_images_file_uris, which keep image bytes out of the conversation"),
|
|
33348
33715
|
confirm: schemaConfirm,
|
|
33349
33716
|
...sharedImageSchema
|
|
33350
33717
|
}
|
|
@@ -33369,13 +33736,24 @@ function registerSetTools(server, client2) {
|
|
|
33369
33736
|
google_search: args.google_search,
|
|
33370
33737
|
master_images: args.master_images,
|
|
33371
33738
|
master_images_base64: args.master_images_base64,
|
|
33372
|
-
from_clipboard: args.from_clipboard
|
|
33739
|
+
from_clipboard: args.from_clipboard,
|
|
33740
|
+
master_images_url: args.master_images_url,
|
|
33741
|
+
master_images_file_uris: args.master_images_file_uris
|
|
33373
33742
|
});
|
|
33374
33743
|
return client2.session.jobs.dispatch({ toolName: "gemini_image_set", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
33375
33744
|
const seed = pickSeed(args.seed);
|
|
33376
33745
|
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 };
|
|
33377
33746
|
const slug = args.basename ? baseName(args.basename) : slugify2(args.master_prompt);
|
|
33378
|
-
const
|
|
33747
|
+
const carried = await resolveImageInputs(
|
|
33748
|
+
{ images_url: args.master_images_url, images_file_uris: args.master_images_file_uris },
|
|
33749
|
+
client2
|
|
33750
|
+
);
|
|
33751
|
+
const masterOnly = await resolveImageInputs(
|
|
33752
|
+
{ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard },
|
|
33753
|
+
client2
|
|
33754
|
+
);
|
|
33755
|
+
const masterRefInputs = [...masterOnly.inputs, ...carried.inputs];
|
|
33756
|
+
const report = carried.report || masterOnly.report ? { ...masterOnly.report, ...carried.report } : void 0;
|
|
33379
33757
|
const named = [];
|
|
33380
33758
|
const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
|
|
33381
33759
|
const result = await client2.generate({
|
|
@@ -33391,13 +33769,13 @@ function registerSetTools(server, client2) {
|
|
|
33391
33769
|
if (mode === "chain") {
|
|
33392
33770
|
let ref = master;
|
|
33393
33771
|
for (let i = 0; i < scenePrompts.length; i++) {
|
|
33394
|
-
const { images: [img] } = await client2.generate({ prompt: scenePrompts[i], images: [ref], seed: seed + i + 1, ...cfg });
|
|
33772
|
+
const { images: [img] } = await client2.generate({ prompt: scenePrompts[i], images: [ref, ...carried.inputs], seed: seed + i + 1, ...cfg });
|
|
33395
33773
|
named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` });
|
|
33396
33774
|
ref = img;
|
|
33397
33775
|
}
|
|
33398
33776
|
} else {
|
|
33399
33777
|
const scenes = await Promise.all(
|
|
33400
|
-
scenePrompts.map((p, i) => client2.generate({ prompt: p, images: [master], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
|
|
33778
|
+
scenePrompts.map((p, i) => client2.generate({ prompt: p, images: [master, ...carried.inputs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
|
|
33401
33779
|
);
|
|
33402
33780
|
scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` }));
|
|
33403
33781
|
}
|
|
@@ -33407,6 +33785,7 @@ function registerSetTools(server, client2) {
|
|
|
33407
33785
|
const meta3 = buildMeta(model, seed, args);
|
|
33408
33786
|
if (masterText) meta3.text = masterText;
|
|
33409
33787
|
if (masterResult.grounding) meta3.grounding = masterResult.grounding;
|
|
33788
|
+
if (report) meta3.image_inputs = report;
|
|
33410
33789
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length, persistsFiles: client2.mediaSink?.persistsFiles });
|
|
33411
33790
|
if (risk) meta3.timeout_risk = risk;
|
|
33412
33791
|
return emit(named, { ...args, sink: client2.mediaSink }, meta3);
|
|
@@ -33448,6 +33827,42 @@ var SessionState = class {
|
|
|
33448
33827
|
* model against the requested edit.
|
|
33449
33828
|
*/
|
|
33450
33829
|
writtenOutputs = /* @__PURE__ */ new Set();
|
|
33830
|
+
/**
|
|
33831
|
+
* Files API uploads this session can reuse, keyed by an opaque identity
|
|
33832
|
+
* string (for a local path: absolute path + mtime + size, so an edited file
|
|
33833
|
+
* is a different key rather than a stale hit).
|
|
33834
|
+
*
|
|
33835
|
+
* Backs the stdio "referenced twice → upload once" optimization: the second
|
|
33836
|
+
* time a session sends the same picture, the bytes are replaced by a
|
|
33837
|
+
* `files/…` reference that costs nothing to repeat. Session-scoped, like
|
|
33838
|
+
* everything else here — a uri is minted by ONE user's API key and is only
|
|
33839
|
+
* readable with that key, so sharing this map across the connector's tenants
|
|
33840
|
+
* would hand out references another user cannot use and this user did not
|
|
33841
|
+
* create.
|
|
33842
|
+
*/
|
|
33843
|
+
uploadCache = /* @__PURE__ */ new Map();
|
|
33844
|
+
/**
|
|
33845
|
+
* How many times each identity has been referenced this session. The
|
|
33846
|
+
* threshold for auto-uploading is "more than once", so the count has to
|
|
33847
|
+
* outlive the call that first saw it.
|
|
33848
|
+
*/
|
|
33849
|
+
referenceCounts = /* @__PURE__ */ new Map();
|
|
33850
|
+
/** Record a reference and return the running total (1 on first sight). */
|
|
33851
|
+
countReference(key) {
|
|
33852
|
+
const next = (this.referenceCounts.get(key) ?? 0) + 1;
|
|
33853
|
+
this.referenceCounts.set(key, next);
|
|
33854
|
+
return next;
|
|
33855
|
+
}
|
|
33856
|
+
/** A cached upload, or `undefined` if absent or past its Files API TTL. */
|
|
33857
|
+
cachedUpload(key, now = Date.now()) {
|
|
33858
|
+
const hit = this.uploadCache.get(key);
|
|
33859
|
+
if (!hit) return void 0;
|
|
33860
|
+
if (hit.expiresAtMs <= now) {
|
|
33861
|
+
this.uploadCache.delete(key);
|
|
33862
|
+
return void 0;
|
|
33863
|
+
}
|
|
33864
|
+
return hit;
|
|
33865
|
+
}
|
|
33451
33866
|
/** Forget everything. Tests use this; nothing in `src/` calls it. */
|
|
33452
33867
|
reset() {
|
|
33453
33868
|
this.jobs.reset();
|
|
@@ -33455,6 +33870,8 @@ var SessionState = class {
|
|
|
33455
33870
|
this.lastMusicInteractionId = void 0;
|
|
33456
33871
|
this.lastVideoInteractionId = void 0;
|
|
33457
33872
|
this.writtenOutputs.clear();
|
|
33873
|
+
this.uploadCache.clear();
|
|
33874
|
+
this.referenceCounts.clear();
|
|
33458
33875
|
}
|
|
33459
33876
|
};
|
|
33460
33877
|
|
|
@@ -33495,6 +33912,22 @@ var ChainedRequest404Error = class extends McpToolError {
|
|
|
33495
33912
|
upstreamMessage;
|
|
33496
33913
|
retries;
|
|
33497
33914
|
};
|
|
33915
|
+
function generatePart(img) {
|
|
33916
|
+
return img.uri ? { file_data: { file_uri: img.uri, mime_type: img.mimeType } } : { inline_data: { mime_type: img.mimeType, data: img.base64 } };
|
|
33917
|
+
}
|
|
33918
|
+
function interactPart(img) {
|
|
33919
|
+
return img.uri ? { type: "image", uri: img.uri, mime_type: img.mimeType } : { type: "image", mime_type: img.mimeType, data: img.base64 };
|
|
33920
|
+
}
|
|
33921
|
+
function normalizeFileName(ref) {
|
|
33922
|
+
const trimmed = ref.trim();
|
|
33923
|
+
const match = /(?:^|\/)(files\/[\w-]+)\/?$/.exec(trimmed);
|
|
33924
|
+
if (!match) {
|
|
33925
|
+
throw new McpToolError(`Not a Gemini Files API reference: "${ref}"`, {
|
|
33926
|
+
hint: "Pass the `files/<id>` name (or the full https://\u2026/v1beta/files/<id> uri) returned by gemini_upload_file / POST /upload."
|
|
33927
|
+
});
|
|
33928
|
+
}
|
|
33929
|
+
return match[1];
|
|
33930
|
+
}
|
|
33498
33931
|
var GeminiClient = class {
|
|
33499
33932
|
/**
|
|
33500
33933
|
* A key handed in explicitly (the connector's per-session key). When absent
|
|
@@ -33583,6 +34016,14 @@ var GeminiClient = class {
|
|
|
33583
34016
|
}
|
|
33584
34017
|
return key;
|
|
33585
34018
|
}
|
|
34019
|
+
/**
|
|
34020
|
+
* Fetch one `images_url` reference server-side. Routed through the client so
|
|
34021
|
+
* it uses the SAME injected `fetchImpl` as every upstream call — which is
|
|
34022
|
+
* what makes it mockable in tests and correct on the Worker.
|
|
34023
|
+
*/
|
|
34024
|
+
async fetchRemoteImage(url2, opts = {}) {
|
|
34025
|
+
return fetchRemoteImage(url2, { ...opts, fetchImpl: this.fetchImpl, timeoutMs: resolveTimeoutMs(opts.timeoutMs) });
|
|
34026
|
+
}
|
|
33586
34027
|
/** The default model after env override (no per-call arg). */
|
|
33587
34028
|
defaultModel() {
|
|
33588
34029
|
return resolveModel(void 0, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
@@ -33614,8 +34055,60 @@ var GeminiClient = class {
|
|
|
33614
34055
|
* (enforced locally before any bytes are sent).
|
|
33615
34056
|
*/
|
|
33616
34057
|
async uploadVideo(path, mimeType) {
|
|
33617
|
-
const key = this.requireKey();
|
|
33618
34058
|
const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: "Video" });
|
|
34059
|
+
return this.uploadToFilesApi(blob, mimeType, basename(path), blob.size);
|
|
34060
|
+
}
|
|
34061
|
+
/**
|
|
34062
|
+
* Upload already-in-memory bytes (a fetched `images_url`, a `data_base64`
|
|
34063
|
+
* payload, an inline `gemini_upload_file` body) to the Files API. Same three
|
|
34064
|
+
* steps as {@link uploadVideo}, minus the filesystem — which is what makes it
|
|
34065
|
+
* usable on the hosted connector, where there is no disk to stream from.
|
|
34066
|
+
*/
|
|
34067
|
+
async uploadBytes(bytes, mimeType, displayName) {
|
|
34068
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
34069
|
+
if (view.byteLength > FILE_MAX_BYTES) {
|
|
34070
|
+
throw new McpToolError(`File is ${view.byteLength} bytes, over the Gemini Files API limit of ${FILE_MAX_BYTES} bytes (2 GB).`);
|
|
34071
|
+
}
|
|
34072
|
+
const body = new Blob([view], { type: mimeType });
|
|
34073
|
+
return this.uploadToFilesApi(body, mimeType, displayName, view.byteLength);
|
|
34074
|
+
}
|
|
34075
|
+
/**
|
|
34076
|
+
* Upload a request body straight through to the Files API without buffering
|
|
34077
|
+
* it — the direct-upload HTTP endpoint (`POST /upload` on the Worker) hands
|
|
34078
|
+
* us a `ReadableStream` and a `Content-Length`, and the whole point of that
|
|
34079
|
+
* endpoint is that the bytes never materialize anywhere they'd cost context
|
|
34080
|
+
* or memory.
|
|
34081
|
+
*
|
|
34082
|
+
* `contentLength` is REQUIRED because the resumable protocol's start step
|
|
34083
|
+
* declares it up front (`X-Goog-Upload-Header-Content-Length`); a stream of
|
|
34084
|
+
* unknown length has to be buffered by the caller first.
|
|
34085
|
+
*/
|
|
34086
|
+
async uploadStream(stream, mimeType, displayName, contentLength) {
|
|
34087
|
+
if (contentLength > FILE_MAX_BYTES) {
|
|
34088
|
+
throw new McpToolError(`File is ${contentLength} bytes, over the Gemini Files API limit of ${FILE_MAX_BYTES} bytes (2 GB).`);
|
|
34089
|
+
}
|
|
34090
|
+
return this.uploadToFilesApi(stream, mimeType, displayName, contentLength);
|
|
34091
|
+
}
|
|
34092
|
+
/**
|
|
34093
|
+
* The resumable upload itself. Three live-verified steps (docs/GEMINI-API.md
|
|
34094
|
+
* "Files API — local video upload"):
|
|
34095
|
+
*
|
|
34096
|
+
* 1. `POST {UPLOAD_BASE_URL}/files` (command `start`) → the upload session
|
|
34097
|
+
* URL arrives in the `x-goog-upload-url` RESPONSE HEADER (body is empty).
|
|
34098
|
+
* 2. `POST <session url>` (command `upload, finalize`) with the bytes.
|
|
34099
|
+
* Response: `{file:…}`.
|
|
34100
|
+
* 3. Poll `GET /v1beta/files/<id>` while `state` is `PROCESSING`, until
|
|
34101
|
+
* `ACTIVE` (the poll response is the File object UNWRAPPED). Images are
|
|
34102
|
+
* usually `ACTIVE` immediately; video is what processes.
|
|
34103
|
+
*
|
|
34104
|
+
* These two upload calls are raw `fetchImpl` (not `createApiClient`): step 1
|
|
34105
|
+
* needs the response *header* and step 2 posts a binary body — neither fits
|
|
34106
|
+
* `fetchJson`. The poll DOES go through the shared client (timeout + retry).
|
|
34107
|
+
* Uploads deliberately have no abort timeout: a multi-hundred-MB file can
|
|
34108
|
+
* legitimately take longer than any fixed budget.
|
|
34109
|
+
*/
|
|
34110
|
+
async uploadToFilesApi(body, mimeType, displayName, contentLength) {
|
|
34111
|
+
const key = this.requireKey();
|
|
33619
34112
|
const startPath = "/files";
|
|
33620
34113
|
const startRes = await this.fetchImpl(`${UPLOAD_BASE_URL}${startPath}`, {
|
|
33621
34114
|
method: "POST",
|
|
@@ -33623,11 +34116,11 @@ var GeminiClient = class {
|
|
|
33623
34116
|
"x-goog-api-key": key,
|
|
33624
34117
|
"X-Goog-Upload-Protocol": "resumable",
|
|
33625
34118
|
"X-Goog-Upload-Command": "start",
|
|
33626
|
-
"X-Goog-Upload-Header-Content-Length": String(
|
|
34119
|
+
"X-Goog-Upload-Header-Content-Length": String(contentLength),
|
|
33627
34120
|
"X-Goog-Upload-Header-Content-Type": mimeType,
|
|
33628
34121
|
"Content-Type": "application/json"
|
|
33629
34122
|
},
|
|
33630
|
-
body: JSON.stringify({ file: { display_name:
|
|
34123
|
+
body: JSON.stringify({ file: { display_name: displayName } })
|
|
33631
34124
|
});
|
|
33632
34125
|
if (!startRes.ok) {
|
|
33633
34126
|
throw new McpToolError(
|
|
@@ -33640,13 +34133,17 @@ var GeminiClient = class {
|
|
|
33640
34133
|
hint: "The Files API resumable-upload contract may have changed \u2014 see docs/GEMINI-API.md."
|
|
33641
34134
|
});
|
|
33642
34135
|
}
|
|
34136
|
+
const streaming = !(body instanceof Blob);
|
|
33643
34137
|
const upRes = await this.fetchImpl(uploadUrl, {
|
|
33644
34138
|
method: "POST",
|
|
33645
34139
|
headers: {
|
|
33646
34140
|
"X-Goog-Upload-Offset": "0",
|
|
33647
|
-
"X-Goog-Upload-Command": "upload, finalize"
|
|
34141
|
+
"X-Goog-Upload-Command": "upload, finalize",
|
|
34142
|
+
...streaming ? { "Content-Length": String(contentLength) } : {}
|
|
33648
34143
|
},
|
|
33649
|
-
body
|
|
34144
|
+
body,
|
|
34145
|
+
// Required by undici/workerd to send a ReadableStream body at all.
|
|
34146
|
+
...streaming ? { duplex: "half" } : {}
|
|
33650
34147
|
});
|
|
33651
34148
|
if (!upRes.ok) {
|
|
33652
34149
|
throw new McpToolError(
|
|
@@ -33660,8 +34157,8 @@ var GeminiClient = class {
|
|
|
33660
34157
|
}
|
|
33661
34158
|
for (let attempt = 0; file2.state === "PROCESSING"; attempt++) {
|
|
33662
34159
|
if (attempt >= FILE_POLL_MAX_ATTEMPTS) {
|
|
33663
|
-
throw new McpToolError(`${SERVICE}
|
|
33664
|
-
hint: "Very long videos can take a while to process \u2014 retry later by passing the file uri as video_url."
|
|
34160
|
+
throw new McpToolError(`${SERVICE} file ${name} is still processing after ${FILE_POLL_INTERVAL_MS * FILE_POLL_MAX_ATTEMPTS / 6e4} minutes`, {
|
|
34161
|
+
hint: "Very long videos can take a while to process \u2014 retry later by passing the file uri as video_url / images_file_uris."
|
|
33665
34162
|
});
|
|
33666
34163
|
}
|
|
33667
34164
|
await this.sleep(FILE_POLL_INTERVAL_MS);
|
|
@@ -33669,14 +34166,78 @@ var GeminiClient = class {
|
|
|
33669
34166
|
}
|
|
33670
34167
|
if (file2.state !== "ACTIVE") {
|
|
33671
34168
|
throw new McpToolError(
|
|
33672
|
-
`${SERVICE}
|
|
33673
|
-
{ hint: "Check the
|
|
34169
|
+
`${SERVICE} file processing failed (state ${file2.state ?? "unknown"})${file2.error?.message ? `: ${file2.error.message}` : ""}`,
|
|
34170
|
+
{ hint: "Check the file is a supported format/codec and under 2 GB." }
|
|
33674
34171
|
);
|
|
33675
34172
|
}
|
|
33676
34173
|
if (!file2.uri) {
|
|
33677
34174
|
throw new McpToolError(`${SERVICE} did not return a uri for uploaded file ${name}`);
|
|
33678
34175
|
}
|
|
33679
|
-
return {
|
|
34176
|
+
return {
|
|
34177
|
+
name,
|
|
34178
|
+
uri: file2.uri,
|
|
34179
|
+
mimeType: file2.mimeType ?? mimeType,
|
|
34180
|
+
expirationTime: file2.expirationTime,
|
|
34181
|
+
sizeBytes: file2.sizeBytes,
|
|
34182
|
+
displayName: file2.displayName
|
|
34183
|
+
};
|
|
34184
|
+
}
|
|
34185
|
+
/**
|
|
34186
|
+
* List the files this API key currently has in the Files API. Retention is
|
|
34187
|
+
* ~48h, so the list is naturally short-lived — an empty list after a day is
|
|
34188
|
+
* expiry, not a bug.
|
|
34189
|
+
*/
|
|
34190
|
+
async listFiles(pageSize = 100) {
|
|
34191
|
+
const size = Math.min(Math.max(Math.trunc(pageSize), 1), 100);
|
|
34192
|
+
const data = await this.call("GET", `/files?pageSize=${size}`);
|
|
34193
|
+
return (data.files ?? []).filter((f) => !!f.name && !!f.uri).map((f) => ({
|
|
34194
|
+
name: f.name,
|
|
34195
|
+
uri: f.uri,
|
|
34196
|
+
mimeType: f.mimeType ?? "application/octet-stream",
|
|
34197
|
+
state: f.state,
|
|
34198
|
+
createTime: f.createTime,
|
|
34199
|
+
expirationTime: f.expirationTime,
|
|
34200
|
+
sizeBytes: f.sizeBytes,
|
|
34201
|
+
displayName: f.displayName
|
|
34202
|
+
}));
|
|
34203
|
+
}
|
|
34204
|
+
/**
|
|
34205
|
+
* Fetch one file's metadata. Used to resolve a caller-supplied
|
|
34206
|
+
* `images_file_uris` entry to its real MIME type — and, as a side effect, to
|
|
34207
|
+
* fail fast with a clear "that file is gone" instead of the Interactions
|
|
34208
|
+
* API's generic 404 after a billable request was already built.
|
|
34209
|
+
*/
|
|
34210
|
+
async getFile(ref) {
|
|
34211
|
+
const resource = normalizeFileName(ref);
|
|
34212
|
+
const file2 = await this.call("GET", `/${resource}`);
|
|
34213
|
+
if (!file2.uri) {
|
|
34214
|
+
throw new McpToolError(`${SERVICE} returned no uri for ${resource}`, {
|
|
34215
|
+
hint: "Files expire ~48h after upload \u2014 re-upload with gemini_upload_file (or POST /upload) and use the new uri."
|
|
34216
|
+
});
|
|
34217
|
+
}
|
|
34218
|
+
if (file2.state && file2.state !== "ACTIVE") {
|
|
34219
|
+
throw new McpToolError(`${SERVICE} file ${resource} is not usable yet (state ${file2.state}).`, {
|
|
34220
|
+
hint: "Wait for the upload to finish processing, then retry."
|
|
34221
|
+
});
|
|
34222
|
+
}
|
|
34223
|
+
return {
|
|
34224
|
+
name: file2.name ?? resource,
|
|
34225
|
+
uri: file2.uri,
|
|
34226
|
+
mimeType: file2.mimeType ?? "application/octet-stream",
|
|
34227
|
+
expirationTime: file2.expirationTime,
|
|
34228
|
+
sizeBytes: file2.sizeBytes,
|
|
34229
|
+
displayName: file2.displayName
|
|
34230
|
+
};
|
|
34231
|
+
}
|
|
34232
|
+
/**
|
|
34233
|
+
* Delete one uploaded file. `name` is interpolated into the URL path, so it
|
|
34234
|
+
* is validated to a bare `files/<id>` for the same reason the model id is
|
|
34235
|
+
* (see {@link uploadToFilesApi}); a full `https://…/v1beta/files/<id>` uri is
|
|
34236
|
+
* accepted and reduced to that form, since that is what every result echoes.
|
|
34237
|
+
*/
|
|
34238
|
+
async deleteFile(name) {
|
|
34239
|
+
const resource = normalizeFileName(name);
|
|
34240
|
+
await this.call("DELETE", `/${resource}`);
|
|
33680
34241
|
}
|
|
33681
34242
|
async listModels() {
|
|
33682
34243
|
const data = await this.call("GET", "/models?pageSize=200");
|
|
@@ -33685,9 +34246,7 @@ var GeminiClient = class {
|
|
|
33685
34246
|
async generate(opts) {
|
|
33686
34247
|
const model = resolveModel(opts.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
33687
34248
|
const parts = [{ text: opts.prompt }];
|
|
33688
|
-
for (const img of opts.images ?? [])
|
|
33689
|
-
parts.push({ inline_data: { mime_type: img.mimeType, data: img.base64 } });
|
|
33690
|
-
}
|
|
34249
|
+
for (const img of opts.images ?? []) parts.push(generatePart(img));
|
|
33691
34250
|
if (opts.videoUrl) {
|
|
33692
34251
|
parts.push({ file_data: { file_uri: opts.videoUrl, mime_type: opts.videoMimeType ?? "video/mp4" } });
|
|
33693
34252
|
}
|
|
@@ -33743,9 +34302,7 @@ var GeminiClient = class {
|
|
|
33743
34302
|
async interact(opts) {
|
|
33744
34303
|
const model = resolveModel(opts.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
33745
34304
|
const inputParts = [{ type: "text", text: opts.input }];
|
|
33746
|
-
for (const img of opts.images ?? [])
|
|
33747
|
-
inputParts.push({ type: "image", mime_type: img.mimeType, data: img.base64 });
|
|
33748
|
-
}
|
|
34305
|
+
for (const img of opts.images ?? []) inputParts.push(interactPart(img));
|
|
33749
34306
|
if (opts.videoUrl) {
|
|
33750
34307
|
inputParts.push({ type: "video", uri: opts.videoUrl, mime_type: opts.videoMimeType ?? "video/mp4" });
|
|
33751
34308
|
}
|
|
@@ -33778,7 +34335,7 @@ var GeminiClient = class {
|
|
|
33778
34335
|
async generateVideo(opts) {
|
|
33779
34336
|
const model = opts.model ?? DEFAULT_VIDEO_MODEL;
|
|
33780
34337
|
const inputParts = [{ type: "text", text: opts.input }];
|
|
33781
|
-
for (const img of opts.images ?? []) inputParts.push(
|
|
34338
|
+
for (const img of opts.images ?? []) inputParts.push(interactPart(img));
|
|
33782
34339
|
const responseFormat = { type: "video", delivery: "inline" };
|
|
33783
34340
|
if (opts.aspectRatio) responseFormat.aspect_ratio = opts.aspectRatio;
|
|
33784
34341
|
const body = { model, input: inputParts, response_format: responseFormat };
|
|
@@ -33800,7 +34357,7 @@ var GeminiClient = class {
|
|
|
33800
34357
|
async generateMusic(opts) {
|
|
33801
34358
|
const model = opts.model ?? DEFAULT_MUSIC_MODEL;
|
|
33802
34359
|
const inputParts = [{ type: "text", text: opts.input }];
|
|
33803
|
-
for (const img of opts.images ?? []) inputParts.push(
|
|
34360
|
+
for (const img of opts.images ?? []) inputParts.push(interactPart(img));
|
|
33804
34361
|
const responseFormat = { type: "audio" };
|
|
33805
34362
|
if (opts.audioFormat) responseFormat.audio_format = opts.audioFormat;
|
|
33806
34363
|
const body = { model, input: inputParts, response_format: responseFormat };
|
|
@@ -33959,7 +34516,11 @@ function registerInteractTools(server, client2) {
|
|
|
33959
34516
|
previous_interaction_id: external_exports.string().optional().describe("ID from a prior gemini_interact call \u2014 continues that multi-turn conversation"),
|
|
33960
34517
|
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."),
|
|
33961
34518
|
images: external_exports.array(external_exports.string().min(1)).optional().describe(`Paths to reference input images. ${NEW_REFERENCES_ONLY}`),
|
|
33962
|
-
|
|
34519
|
+
images_url: imagesUrlSchema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
|
|
34520
|
+
images_file_uris: imagesFileUrisSchema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
|
|
34521
|
+
images_base64: external_exports.array(external_exports.string().min(1)).optional().describe(
|
|
34522
|
+
`Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation. ${NEW_REFERENCES_ONLY}`
|
|
34523
|
+
),
|
|
33963
34524
|
model: external_exports.string().optional().describe(`Model id override (default: server default; see gemini_list_models). ${MODEL_CHOICE_GUIDE}`),
|
|
33964
34525
|
aspect_ratio: external_exports.enum(ASPECT_RATIOS).optional().describe("Output aspect ratio"),
|
|
33965
34526
|
image_size: external_exports.enum(IMAGE_SIZES).optional().describe("Output resolution (512 = 0.5K, Flash-only)"),
|
|
@@ -34017,11 +34578,13 @@ function registerInteractTools(server, client2) {
|
|
|
34017
34578
|
images,
|
|
34018
34579
|
images_base64: args.images_base64,
|
|
34019
34580
|
from_clipboard: args.from_clipboard,
|
|
34581
|
+
images_url: args.images_url,
|
|
34582
|
+
images_file_uris: args.images_file_uris,
|
|
34020
34583
|
video_url: args.video_url,
|
|
34021
34584
|
video_path: args.video_path
|
|
34022
34585
|
});
|
|
34023
34586
|
return client2.session.jobs.dispatch({ toolName: "gemini_interact", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
34024
|
-
const inputs = await
|
|
34587
|
+
const { inputs, report } = await resolveImageInputs({ ...args, images }, client2);
|
|
34025
34588
|
const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args, client2));
|
|
34026
34589
|
const callOpts = {
|
|
34027
34590
|
input: args.input,
|
|
@@ -34073,6 +34636,7 @@ function registerInteractTools(server, client2) {
|
|
|
34073
34636
|
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.`;
|
|
34074
34637
|
if (r.text) meta3.text = r.text;
|
|
34075
34638
|
if (r.grounding) meta3.grounding = r.grounding;
|
|
34639
|
+
if (report) meta3.image_inputs = report;
|
|
34076
34640
|
if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
|
|
34077
34641
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: onDisk });
|
|
34078
34642
|
if (risk) meta3.timeout_risk = risk;
|
|
@@ -34278,12 +34842,159 @@ function registerJobTools(server, client2) {
|
|
|
34278
34842
|
);
|
|
34279
34843
|
}
|
|
34280
34844
|
|
|
34845
|
+
// src/tools/files.ts
|
|
34846
|
+
init_zod();
|
|
34847
|
+
init_dist();
|
|
34848
|
+
var UPLOAD_URL_MAX_BYTES_DISK = 100 * 1024 * 1024;
|
|
34849
|
+
var UPLOAD_URL_MAX_BYTES_HOSTED = 25 * 1024 * 1024;
|
|
34850
|
+
var mb = (bytes) => Math.round(bytes / (1024 * 1024));
|
|
34851
|
+
var UPLOAD_ACCEPT = ["image/", "video/", "audio/"];
|
|
34852
|
+
var RETENTION_NOTE = "Gemini retains uploaded files for ~48h. Until then this reference can be reused across any number of calls (images_file_uris on gemini_image_generate / _edit / _set / gemini_interact, or video_url) without re-sending the bytes.";
|
|
34853
|
+
function uploadResult(file2) {
|
|
34854
|
+
return textResult({
|
|
34855
|
+
// The `files/<id>` form is what every tool parameter documents, so it leads.
|
|
34856
|
+
file_uri: file2.name,
|
|
34857
|
+
// The absolute uri, for callers that would rather pass it through verbatim.
|
|
34858
|
+
uri: file2.uri,
|
|
34859
|
+
name: file2.name,
|
|
34860
|
+
mime_type: file2.mimeType,
|
|
34861
|
+
...file2.sizeBytes ? { size_bytes: Number(file2.sizeBytes) } : {},
|
|
34862
|
+
...file2.displayName ? { display_name: file2.displayName } : {},
|
|
34863
|
+
expires: file2.expirationTime ?? "~48h from upload",
|
|
34864
|
+
hint: `Pass "${file2.name}" in images_file_uris (or video_url for video) on any image tool. ${RETENTION_NOTE}`
|
|
34865
|
+
});
|
|
34866
|
+
}
|
|
34867
|
+
function registerFileTools(server, client2) {
|
|
34868
|
+
const onDisk = client2.mediaSink?.persistsFiles ?? true;
|
|
34869
|
+
const urlMaxBytes = onDisk ? UPLOAD_URL_MAX_BYTES_DISK : UPLOAD_URL_MAX_BYTES_HOSTED;
|
|
34870
|
+
server.registerTool(
|
|
34871
|
+
"gemini_upload_file",
|
|
34872
|
+
{
|
|
34873
|
+
description: "Upload an image (or video/audio) to the Gemini Files API ONCE and get back a reusable `files/<id>` reference. Use this instead of pasting base64 into a tool call: the reference is a short string, so no image bytes ever enter the conversation, and it can be reused across many generations until it expires (~48h). Provide exactly one of `url` (the server downloads it), `data_base64`" + (onDisk ? ", or `path` (a local file)." : ".") + (onDisk ? "" : " This connector also accepts raw bytes over HTTP: POST them to /upload with the same Authorization header and a Content-Type of image/*, which avoids base64 entirely."),
|
|
34874
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
34875
|
+
inputSchema: {
|
|
34876
|
+
url: external_exports.string().url().optional().describe(
|
|
34877
|
+
`Public https URL the SERVER downloads and uploads (image/video/audio, up to ${mb(urlMaxBytes)}MB). No bytes pass through the conversation.` + (onDisk ? "" : " Larger files: POST the raw bytes to /upload, which streams instead of buffering.")
|
|
34878
|
+
),
|
|
34879
|
+
data_base64: external_exports.string().min(1).optional().describe("Raw base64 or a data: URI. Last resort \u2014 this is the one form that costs model context (~14k tokens for a modest JPEG)."),
|
|
34880
|
+
path: external_exports.string().min(1).optional().describe(
|
|
34881
|
+
onDisk ? "Path to a local file (absolute, or resolved against $GEMINI_INPUT_DIR). Confirm-gated like every other local-file input." : "Unavailable on this hosted connector (no filesystem) \u2014 use `url`, `data_base64`, or POST /upload."
|
|
34882
|
+
),
|
|
34883
|
+
mime_type: external_exports.string().regex(/^[\w.+-]+\/[\w.+-]+$/, "must be a MIME type like image/png").optional().describe("Override the detected MIME type (sniffed from the bytes / taken from the server response otherwise)"),
|
|
34884
|
+
display_name: external_exports.string().min(1).optional().describe("Human-readable name recorded against the upload"),
|
|
34885
|
+
confirm: schemaConfirm
|
|
34886
|
+
}
|
|
34887
|
+
},
|
|
34888
|
+
async (args, extra) => {
|
|
34889
|
+
const sources = [args.url && "url", args.data_base64 && "data_base64", args.path && "path"].filter(Boolean);
|
|
34890
|
+
if (sources.length !== 1) {
|
|
34891
|
+
throw new McpToolError(
|
|
34892
|
+
sources.length === 0 ? "Provide exactly one of `url`, `data_base64`" + (onDisk ? " or `path`." : ".") : `Provide exactly one source, not ${sources.length} (${sources.join(", ")}).`
|
|
34893
|
+
);
|
|
34894
|
+
}
|
|
34895
|
+
if (args.path) {
|
|
34896
|
+
if (!onDisk) {
|
|
34897
|
+
throw new McpToolError(
|
|
34898
|
+
"`path` is unavailable on the hosted connector: it runs on a Cloudflare Worker, which has no filesystem. Use `url` (the server downloads it), `data_base64`, or POST the raw bytes to /upload with the same Authorization header.",
|
|
34899
|
+
{ hint: "Use `url`, `data_base64`, or POST /upload." }
|
|
34900
|
+
);
|
|
34901
|
+
}
|
|
34902
|
+
const gate = await previewLocalInputsUnlessConfirmed(
|
|
34903
|
+
args.confirm,
|
|
34904
|
+
"Upload a local file to the Gemini Files API",
|
|
34905
|
+
"/upload/v1beta/files",
|
|
34906
|
+
[args.path]
|
|
34907
|
+
);
|
|
34908
|
+
if (gate) return gate;
|
|
34909
|
+
}
|
|
34910
|
+
const uploaded = await withProgressHeartbeat(extra, "Uploading to the Gemini Files API", async () => {
|
|
34911
|
+
if (args.url) {
|
|
34912
|
+
const fetched = await client2.fetchRemoteImage(args.url, { maxBytes: urlMaxBytes, accept: UPLOAD_ACCEPT });
|
|
34913
|
+
return client2.uploadBytes(
|
|
34914
|
+
fetched.bytes,
|
|
34915
|
+
args.mime_type ?? fetched.mimeType,
|
|
34916
|
+
args.display_name ?? nameFromUrl(args.url)
|
|
34917
|
+
);
|
|
34918
|
+
}
|
|
34919
|
+
if (args.data_base64) {
|
|
34920
|
+
const { decodeImageInput: decodeImageInput2 } = await Promise.resolve().then(() => (init_images(), images_exports));
|
|
34921
|
+
const decoded = decodeImageInput2(args.data_base64);
|
|
34922
|
+
const bytes2 = Uint8Array.from(atob(decoded.base64), (c) => c.charCodeAt(0));
|
|
34923
|
+
return client2.uploadBytes(bytes2, args.mime_type ?? decoded.mimeType, args.display_name ?? "upload");
|
|
34924
|
+
}
|
|
34925
|
+
const { readImageAsInline: readImageAsInline2, resolveImagePath: resolveImagePath2 } = await Promise.resolve().then(() => (init_images(), images_exports));
|
|
34926
|
+
const resolved = resolveImagePath2(args.path);
|
|
34927
|
+
const inline = await readImageAsInline2(resolved);
|
|
34928
|
+
const bytes = Uint8Array.from(atob(inline.base64), (c) => c.charCodeAt(0));
|
|
34929
|
+
return client2.uploadBytes(
|
|
34930
|
+
bytes,
|
|
34931
|
+
args.mime_type ?? inline.mimeType,
|
|
34932
|
+
args.display_name ?? (resolved.split(/[\\/]/).pop() || "upload")
|
|
34933
|
+
);
|
|
34934
|
+
});
|
|
34935
|
+
return uploadResult(uploaded);
|
|
34936
|
+
}
|
|
34937
|
+
);
|
|
34938
|
+
server.registerTool(
|
|
34939
|
+
"gemini_list_files",
|
|
34940
|
+
{
|
|
34941
|
+
description: "List the files currently uploaded to the Gemini Files API under this API key, with their `files/<id>` references, MIME types and expiry times. Retention is ~48h, so an entry that has vanished has expired rather than failed.",
|
|
34942
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
34943
|
+
inputSchema: {
|
|
34944
|
+
page_size: external_exports.number().int().positive().max(100).optional().describe("Maximum files to return (1-100, default 100)")
|
|
34945
|
+
}
|
|
34946
|
+
},
|
|
34947
|
+
async (args) => {
|
|
34948
|
+
const files = await client2.listFiles(args.page_size ?? 100);
|
|
34949
|
+
return textResult({
|
|
34950
|
+
count: files.length,
|
|
34951
|
+
files: files.map((f) => ({
|
|
34952
|
+
file_uri: f.name,
|
|
34953
|
+
uri: f.uri,
|
|
34954
|
+
mime_type: f.mimeType,
|
|
34955
|
+
...f.displayName ? { display_name: f.displayName } : {},
|
|
34956
|
+
...f.sizeBytes ? { size_bytes: Number(f.sizeBytes) } : {},
|
|
34957
|
+
...f.state ? { state: f.state } : {},
|
|
34958
|
+
...f.createTime ? { created: f.createTime } : {},
|
|
34959
|
+
...f.expirationTime ? { expires: f.expirationTime } : {}
|
|
34960
|
+
})),
|
|
34961
|
+
hint: files.length ? `Pass any file_uri above in images_file_uris on an image tool. ${RETENTION_NOTE}` : "No files uploaded (or all have expired \u2014 retention is ~48h). Upload one with gemini_upload_file."
|
|
34962
|
+
});
|
|
34963
|
+
}
|
|
34964
|
+
);
|
|
34965
|
+
server.registerTool(
|
|
34966
|
+
"gemini_delete_file",
|
|
34967
|
+
{
|
|
34968
|
+
description: "Delete an uploaded file from the Gemini Files API before its ~48h expiry. Any tool call still referencing it will then fail with a generic 404, so delete only references you are finished with.",
|
|
34969
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
34970
|
+
inputSchema: {
|
|
34971
|
+
file_uri: external_exports.string().min(1).describe("The `files/<id>` reference (or full uri) to delete"),
|
|
34972
|
+
confirm: schemaConfirm
|
|
34973
|
+
}
|
|
34974
|
+
},
|
|
34975
|
+
async (args) => {
|
|
34976
|
+
const gate = previewUnlessConfirmed(args.confirm, "Delete an uploaded Gemini file", "DELETE", `/v1beta/${args.file_uri}`);
|
|
34977
|
+
if (gate) return gate;
|
|
34978
|
+
await client2.deleteFile(args.file_uri);
|
|
34979
|
+
return textResult({ deleted: args.file_uri });
|
|
34980
|
+
}
|
|
34981
|
+
);
|
|
34982
|
+
}
|
|
34983
|
+
function nameFromUrl(url2) {
|
|
34984
|
+
try {
|
|
34985
|
+
const name = new URL(url2).pathname.split("/").filter(Boolean).pop();
|
|
34986
|
+
return name ? decodeURIComponent(name).slice(0, 120) : "upload";
|
|
34987
|
+
} catch {
|
|
34988
|
+
return "upload";
|
|
34989
|
+
}
|
|
34990
|
+
}
|
|
34991
|
+
|
|
34281
34992
|
// src/index.ts
|
|
34282
34993
|
await loadStdioDotenv();
|
|
34283
34994
|
await runMcp({
|
|
34284
34995
|
name: "gemini-mcp",
|
|
34285
34996
|
version: VERSION,
|
|
34286
34997
|
banner: "[gemini-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.",
|
|
34287
|
-
tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerVideoTools, registerMusicTools, registerJobTools],
|
|
34998
|
+
tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerVideoTools, registerMusicTools, registerJobTools, registerFileTools],
|
|
34288
34999
|
deps: client
|
|
34289
35000
|
});
|