@chrischall/gemini-mcp 1.0.3 → 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 +773 -56
- 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 +3 -3
- 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")
|
|
@@ -27186,6 +27186,7 @@ var require_fast_uri = __commonJS({
|
|
|
27186
27186
|
return uriTokens.join("");
|
|
27187
27187
|
}
|
|
27188
27188
|
var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
|
|
27189
|
+
var AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/;
|
|
27189
27190
|
function getParseError(parsed, matches) {
|
|
27190
27191
|
if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
|
|
27191
27192
|
return 'URI path must start with "/" when authority is present.';
|
|
@@ -27215,6 +27216,11 @@ var require_fast_uri = __commonJS({
|
|
|
27215
27216
|
uri = "//" + uri;
|
|
27216
27217
|
}
|
|
27217
27218
|
}
|
|
27219
|
+
const authorityMatch = uri.match(AUTHORITY_PREFIX);
|
|
27220
|
+
if (authorityMatch !== null && authorityMatch[1].indexOf("\\") !== -1) {
|
|
27221
|
+
parsed.error = "URI authority must not contain a literal backslash.";
|
|
27222
|
+
malformedAuthorityOrPort = true;
|
|
27223
|
+
}
|
|
27218
27224
|
const matches = uri.match(URI_PARSE);
|
|
27219
27225
|
if (matches) {
|
|
27220
27226
|
parsed.scheme = matches[1];
|
|
@@ -27258,7 +27264,7 @@ var require_fast_uri = __commonJS({
|
|
|
27258
27264
|
if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport)) {
|
|
27259
27265
|
if (parsed.host && (options.domainHost || schemeHandler && schemeHandler.domainHost) && isIP === false && nonSimpleDomain(parsed.host)) {
|
|
27260
27266
|
try {
|
|
27261
|
-
parsed.host = URL
|
|
27267
|
+
parsed.host = new URL("http://" + parsed.host).hostname;
|
|
27262
27268
|
} catch (e) {
|
|
27263
27269
|
parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e;
|
|
27264
27270
|
}
|
|
@@ -32802,7 +32808,7 @@ async function loadStdioDotenv() {
|
|
|
32802
32808
|
}
|
|
32803
32809
|
|
|
32804
32810
|
// src/version.ts
|
|
32805
|
-
var VERSION = "1.0
|
|
32811
|
+
var VERSION = "1.1.0";
|
|
32806
32812
|
|
|
32807
32813
|
// src/tools/models.ts
|
|
32808
32814
|
init_dist();
|
|
@@ -32842,6 +32848,355 @@ function filterImageModels(raw) {
|
|
|
32842
32848
|
// src/tools/generate.ts
|
|
32843
32849
|
init_images();
|
|
32844
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
|
+
|
|
32845
33200
|
// src/tools/shared.ts
|
|
32846
33201
|
init_zod();
|
|
32847
33202
|
init_dist();
|
|
@@ -32877,6 +33232,16 @@ var idempotencyKeySchema = external_exports.string().min(1).optional().describe(
|
|
|
32877
33232
|
var asyncSchema = external_exports.boolean().optional().describe(
|
|
32878
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)."
|
|
32879
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
|
+
}
|
|
32880
33245
|
var sharedImageSchema = {
|
|
32881
33246
|
// Bare id only: the model is interpolated into the URL path
|
|
32882
33247
|
// (`/models/${model}:generateContent`), so slashes/colons/queries must be
|
|
@@ -32998,7 +33363,7 @@ function assertLocalInputsAvailable(sink, args) {
|
|
|
32998
33363
|
if (args.from_clipboard) unavailable.push("`from_clipboard` (reads the macOS clipboard via child_process)");
|
|
32999
33364
|
if (args.video_path) unavailable.push("`video_path` (uploads a file streamed from disk)");
|
|
33000
33365
|
if (unavailable.length === 0) return;
|
|
33001
|
-
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`.";
|
|
33002
33367
|
throw new McpToolError(
|
|
33003
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}`,
|
|
33004
33369
|
{ hint: fix }
|
|
@@ -33191,7 +33556,9 @@ function registerGenerateTools(server, client2) {
|
|
|
33191
33556
|
count: external_exports.number().int().positive().max(8).optional().describe("Number of independent images (default 1)"),
|
|
33192
33557
|
filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified prompt)"),
|
|
33193
33558
|
images: external_exports.array(external_exports.string().min(1)).optional().describe("Paths to reference input images (image-conditioned generation)"),
|
|
33194
|
-
|
|
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"),
|
|
33195
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)"),
|
|
33196
33563
|
video_path: videoPathSchema,
|
|
33197
33564
|
confirm: schemaConfirm,
|
|
@@ -33215,13 +33582,15 @@ function registerGenerateTools(server, client2) {
|
|
|
33215
33582
|
images: args.images,
|
|
33216
33583
|
images_base64: args.images_base64,
|
|
33217
33584
|
from_clipboard: args.from_clipboard,
|
|
33585
|
+
images_url: args.images_url,
|
|
33586
|
+
images_file_uris: args.images_file_uris,
|
|
33218
33587
|
video_url: args.video_url,
|
|
33219
33588
|
video_path: args.video_path
|
|
33220
33589
|
});
|
|
33221
33590
|
return client2.session.jobs.dispatch({ toolName: "gemini_image_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
33222
33591
|
const seed = pickSeed(args.seed);
|
|
33223
33592
|
const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
|
|
33224
|
-
const refInputs = await
|
|
33593
|
+
const { inputs: refInputs, report } = await withProgressHeartbeat(extra, "Resolving reference images", () => resolveImageInputs(args, client2));
|
|
33225
33594
|
const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args, client2));
|
|
33226
33595
|
const named = [];
|
|
33227
33596
|
let capturedText;
|
|
@@ -33250,6 +33619,7 @@ function registerGenerateTools(server, client2) {
|
|
|
33250
33619
|
const meta3 = buildMeta(model, seed, args);
|
|
33251
33620
|
if (capturedText) meta3.text = capturedText;
|
|
33252
33621
|
if (capturedGrounding) meta3.grounding = capturedGrounding;
|
|
33622
|
+
if (report) meta3.image_inputs = report;
|
|
33253
33623
|
if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
|
|
33254
33624
|
meta3.hint = REFINE_HINT;
|
|
33255
33625
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, count, persistsFiles: client2.mediaSink?.persistsFiles });
|
|
@@ -33266,7 +33636,9 @@ function registerGenerateTools(server, client2) {
|
|
|
33266
33636
|
inputSchema: {
|
|
33267
33637
|
prompt: external_exports.string().min(1).describe("Instruction describing the edit or composition"),
|
|
33268
33638
|
images: external_exports.array(external_exports.string().min(1)).optional().describe("Paths to input image file(s) (1 = edit, 2+ = compose)"),
|
|
33269
|
-
|
|
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"),
|
|
33270
33642
|
filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified prompt)"),
|
|
33271
33643
|
confirm: schemaConfirm,
|
|
33272
33644
|
...sharedImageSchema
|
|
@@ -33274,11 +33646,7 @@ function registerGenerateTools(server, client2) {
|
|
|
33274
33646
|
},
|
|
33275
33647
|
async (args, extra) => {
|
|
33276
33648
|
assertLocalInputsAvailable(client2.mediaSink, args);
|
|
33277
|
-
|
|
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
|
-
}
|
|
33649
|
+
requireImageInput(args);
|
|
33282
33650
|
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
|
|
33283
33651
|
if (gate) return gate;
|
|
33284
33652
|
const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
@@ -33291,12 +33659,14 @@ function registerGenerateTools(server, client2) {
|
|
|
33291
33659
|
google_search: args.google_search,
|
|
33292
33660
|
images: args.images,
|
|
33293
33661
|
images_base64: args.images_base64,
|
|
33294
|
-
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
|
|
33295
33665
|
});
|
|
33296
33666
|
return client2.session.jobs.dispatch({ toolName: "gemini_image_edit", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
33297
33667
|
const seed = pickSeed(args.seed);
|
|
33298
33668
|
const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
|
|
33299
|
-
const inputs = await
|
|
33669
|
+
const { inputs, report } = await withProgressHeartbeat(extra, "Resolving input images", () => resolveImageInputs(args, client2));
|
|
33300
33670
|
const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client2.generate({
|
|
33301
33671
|
prompt: args.prompt,
|
|
33302
33672
|
images: inputs,
|
|
@@ -33312,6 +33682,7 @@ function registerGenerateTools(server, client2) {
|
|
|
33312
33682
|
const meta3 = buildMeta(model, seed, args);
|
|
33313
33683
|
if (text) meta3.text = text;
|
|
33314
33684
|
if (result.grounding) meta3.grounding = result.grounding;
|
|
33685
|
+
if (report) meta3.image_inputs = report;
|
|
33315
33686
|
meta3.hint = REFINE_HINT;
|
|
33316
33687
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: client2.mediaSink?.persistsFiles });
|
|
33317
33688
|
if (risk) meta3.timeout_risk = risk;
|
|
@@ -33338,7 +33709,9 @@ function registerSetTools(server, client2) {
|
|
|
33338
33709
|
reference_mode: external_exports.enum(["master", "chain"]).optional().describe("master: every image references the master (default). chain: each references the previous."),
|
|
33339
33710
|
basename: external_exports.string().optional().describe("Base filename prefix for output images (default: slugified master_prompt)"),
|
|
33340
33711
|
master_images: external_exports.array(external_exports.string().min(1)).optional().describe("Reference image paths passed to the master generation call"),
|
|
33341
|
-
|
|
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"),
|
|
33342
33715
|
confirm: schemaConfirm,
|
|
33343
33716
|
...sharedImageSchema
|
|
33344
33717
|
}
|
|
@@ -33363,13 +33736,24 @@ function registerSetTools(server, client2) {
|
|
|
33363
33736
|
google_search: args.google_search,
|
|
33364
33737
|
master_images: args.master_images,
|
|
33365
33738
|
master_images_base64: args.master_images_base64,
|
|
33366
|
-
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
|
|
33367
33742
|
});
|
|
33368
33743
|
return client2.session.jobs.dispatch({ toolName: "gemini_image_set", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
33369
33744
|
const seed = pickSeed(args.seed);
|
|
33370
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 };
|
|
33371
33746
|
const slug = args.basename ? baseName(args.basename) : slugify2(args.master_prompt);
|
|
33372
|
-
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;
|
|
33373
33757
|
const named = [];
|
|
33374
33758
|
const masterResult = await withProgressHeartbeat(extra, `Generating image set (${model})`, async () => {
|
|
33375
33759
|
const result = await client2.generate({
|
|
@@ -33385,13 +33769,13 @@ function registerSetTools(server, client2) {
|
|
|
33385
33769
|
if (mode === "chain") {
|
|
33386
33770
|
let ref = master;
|
|
33387
33771
|
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 });
|
|
33772
|
+
const { images: [img] } = await client2.generate({ prompt: scenePrompts[i], images: [ref, ...carried.inputs], seed: seed + i + 1, ...cfg });
|
|
33389
33773
|
named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` });
|
|
33390
33774
|
ref = img;
|
|
33391
33775
|
}
|
|
33392
33776
|
} else {
|
|
33393
33777
|
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]))
|
|
33778
|
+
scenePrompts.map((p, i) => client2.generate({ prompt: p, images: [master, ...carried.inputs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0]))
|
|
33395
33779
|
);
|
|
33396
33780
|
scenes.forEach((img, i) => named.push({ image: img, base: `${slug}-${String(i + 1).padStart(2, "0")}` }));
|
|
33397
33781
|
}
|
|
@@ -33401,6 +33785,7 @@ function registerSetTools(server, client2) {
|
|
|
33401
33785
|
const meta3 = buildMeta(model, seed, args);
|
|
33402
33786
|
if (masterText) meta3.text = masterText;
|
|
33403
33787
|
if (masterResult.grounding) meta3.grounding = masterResult.grounding;
|
|
33788
|
+
if (report) meta3.image_inputs = report;
|
|
33404
33789
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length, persistsFiles: client2.mediaSink?.persistsFiles });
|
|
33405
33790
|
if (risk) meta3.timeout_risk = risk;
|
|
33406
33791
|
return emit(named, { ...args, sink: client2.mediaSink }, meta3);
|
|
@@ -33442,6 +33827,42 @@ var SessionState = class {
|
|
|
33442
33827
|
* model against the requested edit.
|
|
33443
33828
|
*/
|
|
33444
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
|
+
}
|
|
33445
33866
|
/** Forget everything. Tests use this; nothing in `src/` calls it. */
|
|
33446
33867
|
reset() {
|
|
33447
33868
|
this.jobs.reset();
|
|
@@ -33449,6 +33870,8 @@ var SessionState = class {
|
|
|
33449
33870
|
this.lastMusicInteractionId = void 0;
|
|
33450
33871
|
this.lastVideoInteractionId = void 0;
|
|
33451
33872
|
this.writtenOutputs.clear();
|
|
33873
|
+
this.uploadCache.clear();
|
|
33874
|
+
this.referenceCounts.clear();
|
|
33452
33875
|
}
|
|
33453
33876
|
};
|
|
33454
33877
|
|
|
@@ -33489,6 +33912,22 @@ var ChainedRequest404Error = class extends McpToolError {
|
|
|
33489
33912
|
upstreamMessage;
|
|
33490
33913
|
retries;
|
|
33491
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
|
+
}
|
|
33492
33931
|
var GeminiClient = class {
|
|
33493
33932
|
/**
|
|
33494
33933
|
* A key handed in explicitly (the connector's per-session key). When absent
|
|
@@ -33577,6 +34016,14 @@ var GeminiClient = class {
|
|
|
33577
34016
|
}
|
|
33578
34017
|
return key;
|
|
33579
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
|
+
}
|
|
33580
34027
|
/** The default model after env override (no per-call arg). */
|
|
33581
34028
|
defaultModel() {
|
|
33582
34029
|
return resolveModel(void 0, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
@@ -33608,8 +34055,60 @@ var GeminiClient = class {
|
|
|
33608
34055
|
* (enforced locally before any bytes are sent).
|
|
33609
34056
|
*/
|
|
33610
34057
|
async uploadVideo(path, mimeType) {
|
|
33611
|
-
const key = this.requireKey();
|
|
33612
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();
|
|
33613
34112
|
const startPath = "/files";
|
|
33614
34113
|
const startRes = await this.fetchImpl(`${UPLOAD_BASE_URL}${startPath}`, {
|
|
33615
34114
|
method: "POST",
|
|
@@ -33617,11 +34116,11 @@ var GeminiClient = class {
|
|
|
33617
34116
|
"x-goog-api-key": key,
|
|
33618
34117
|
"X-Goog-Upload-Protocol": "resumable",
|
|
33619
34118
|
"X-Goog-Upload-Command": "start",
|
|
33620
|
-
"X-Goog-Upload-Header-Content-Length": String(
|
|
34119
|
+
"X-Goog-Upload-Header-Content-Length": String(contentLength),
|
|
33621
34120
|
"X-Goog-Upload-Header-Content-Type": mimeType,
|
|
33622
34121
|
"Content-Type": "application/json"
|
|
33623
34122
|
},
|
|
33624
|
-
body: JSON.stringify({ file: { display_name:
|
|
34123
|
+
body: JSON.stringify({ file: { display_name: displayName } })
|
|
33625
34124
|
});
|
|
33626
34125
|
if (!startRes.ok) {
|
|
33627
34126
|
throw new McpToolError(
|
|
@@ -33634,13 +34133,17 @@ var GeminiClient = class {
|
|
|
33634
34133
|
hint: "The Files API resumable-upload contract may have changed \u2014 see docs/GEMINI-API.md."
|
|
33635
34134
|
});
|
|
33636
34135
|
}
|
|
34136
|
+
const streaming = !(body instanceof Blob);
|
|
33637
34137
|
const upRes = await this.fetchImpl(uploadUrl, {
|
|
33638
34138
|
method: "POST",
|
|
33639
34139
|
headers: {
|
|
33640
34140
|
"X-Goog-Upload-Offset": "0",
|
|
33641
|
-
"X-Goog-Upload-Command": "upload, finalize"
|
|
34141
|
+
"X-Goog-Upload-Command": "upload, finalize",
|
|
34142
|
+
...streaming ? { "Content-Length": String(contentLength) } : {}
|
|
33642
34143
|
},
|
|
33643
|
-
body
|
|
34144
|
+
body,
|
|
34145
|
+
// Required by undici/workerd to send a ReadableStream body at all.
|
|
34146
|
+
...streaming ? { duplex: "half" } : {}
|
|
33644
34147
|
});
|
|
33645
34148
|
if (!upRes.ok) {
|
|
33646
34149
|
throw new McpToolError(
|
|
@@ -33654,8 +34157,8 @@ var GeminiClient = class {
|
|
|
33654
34157
|
}
|
|
33655
34158
|
for (let attempt = 0; file2.state === "PROCESSING"; attempt++) {
|
|
33656
34159
|
if (attempt >= FILE_POLL_MAX_ATTEMPTS) {
|
|
33657
|
-
throw new McpToolError(`${SERVICE}
|
|
33658
|
-
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."
|
|
33659
34162
|
});
|
|
33660
34163
|
}
|
|
33661
34164
|
await this.sleep(FILE_POLL_INTERVAL_MS);
|
|
@@ -33663,14 +34166,78 @@ var GeminiClient = class {
|
|
|
33663
34166
|
}
|
|
33664
34167
|
if (file2.state !== "ACTIVE") {
|
|
33665
34168
|
throw new McpToolError(
|
|
33666
|
-
`${SERVICE}
|
|
33667
|
-
{ 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." }
|
|
33668
34171
|
);
|
|
33669
34172
|
}
|
|
33670
34173
|
if (!file2.uri) {
|
|
33671
34174
|
throw new McpToolError(`${SERVICE} did not return a uri for uploaded file ${name}`);
|
|
33672
34175
|
}
|
|
33673
|
-
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}`);
|
|
33674
34241
|
}
|
|
33675
34242
|
async listModels() {
|
|
33676
34243
|
const data = await this.call("GET", "/models?pageSize=200");
|
|
@@ -33679,9 +34246,7 @@ var GeminiClient = class {
|
|
|
33679
34246
|
async generate(opts) {
|
|
33680
34247
|
const model = resolveModel(opts.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
33681
34248
|
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
|
-
}
|
|
34249
|
+
for (const img of opts.images ?? []) parts.push(generatePart(img));
|
|
33685
34250
|
if (opts.videoUrl) {
|
|
33686
34251
|
parts.push({ file_data: { file_uri: opts.videoUrl, mime_type: opts.videoMimeType ?? "video/mp4" } });
|
|
33687
34252
|
}
|
|
@@ -33737,9 +34302,7 @@ var GeminiClient = class {
|
|
|
33737
34302
|
async interact(opts) {
|
|
33738
34303
|
const model = resolveModel(opts.model, readEnvVar("GEMINI_IMAGE_MODEL"));
|
|
33739
34304
|
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
|
-
}
|
|
34305
|
+
for (const img of opts.images ?? []) inputParts.push(interactPart(img));
|
|
33743
34306
|
if (opts.videoUrl) {
|
|
33744
34307
|
inputParts.push({ type: "video", uri: opts.videoUrl, mime_type: opts.videoMimeType ?? "video/mp4" });
|
|
33745
34308
|
}
|
|
@@ -33772,7 +34335,7 @@ var GeminiClient = class {
|
|
|
33772
34335
|
async generateVideo(opts) {
|
|
33773
34336
|
const model = opts.model ?? DEFAULT_VIDEO_MODEL;
|
|
33774
34337
|
const inputParts = [{ type: "text", text: opts.input }];
|
|
33775
|
-
for (const img of opts.images ?? []) inputParts.push(
|
|
34338
|
+
for (const img of opts.images ?? []) inputParts.push(interactPart(img));
|
|
33776
34339
|
const responseFormat = { type: "video", delivery: "inline" };
|
|
33777
34340
|
if (opts.aspectRatio) responseFormat.aspect_ratio = opts.aspectRatio;
|
|
33778
34341
|
const body = { model, input: inputParts, response_format: responseFormat };
|
|
@@ -33794,7 +34357,7 @@ var GeminiClient = class {
|
|
|
33794
34357
|
async generateMusic(opts) {
|
|
33795
34358
|
const model = opts.model ?? DEFAULT_MUSIC_MODEL;
|
|
33796
34359
|
const inputParts = [{ type: "text", text: opts.input }];
|
|
33797
|
-
for (const img of opts.images ?? []) inputParts.push(
|
|
34360
|
+
for (const img of opts.images ?? []) inputParts.push(interactPart(img));
|
|
33798
34361
|
const responseFormat = { type: "audio" };
|
|
33799
34362
|
if (opts.audioFormat) responseFormat.audio_format = opts.audioFormat;
|
|
33800
34363
|
const body = { model, input: inputParts, response_format: responseFormat };
|
|
@@ -33953,7 +34516,11 @@ function registerInteractTools(server, client2) {
|
|
|
33953
34516
|
previous_interaction_id: external_exports.string().optional().describe("ID from a prior gemini_interact call \u2014 continues that multi-turn conversation"),
|
|
33954
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."),
|
|
33955
34518
|
images: external_exports.array(external_exports.string().min(1)).optional().describe(`Paths to reference input images. ${NEW_REFERENCES_ONLY}`),
|
|
33956
|
-
|
|
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
|
+
),
|
|
33957
34524
|
model: external_exports.string().optional().describe(`Model id override (default: server default; see gemini_list_models). ${MODEL_CHOICE_GUIDE}`),
|
|
33958
34525
|
aspect_ratio: external_exports.enum(ASPECT_RATIOS).optional().describe("Output aspect ratio"),
|
|
33959
34526
|
image_size: external_exports.enum(IMAGE_SIZES).optional().describe("Output resolution (512 = 0.5K, Flash-only)"),
|
|
@@ -34011,11 +34578,13 @@ function registerInteractTools(server, client2) {
|
|
|
34011
34578
|
images,
|
|
34012
34579
|
images_base64: args.images_base64,
|
|
34013
34580
|
from_clipboard: args.from_clipboard,
|
|
34581
|
+
images_url: args.images_url,
|
|
34582
|
+
images_file_uris: args.images_file_uris,
|
|
34014
34583
|
video_url: args.video_url,
|
|
34015
34584
|
video_path: args.video_path
|
|
34016
34585
|
});
|
|
34017
34586
|
return client2.session.jobs.dispatch({ toolName: "gemini_interact", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
34018
|
-
const inputs = await
|
|
34587
|
+
const { inputs, report } = await resolveImageInputs({ ...args, images }, client2);
|
|
34019
34588
|
const video = await withProgressHeartbeat(extra, "Uploading video to the Gemini Files API", () => resolveVideoInput(args, client2));
|
|
34020
34589
|
const callOpts = {
|
|
34021
34590
|
input: args.input,
|
|
@@ -34067,6 +34636,7 @@ function registerInteractTools(server, client2) {
|
|
|
34067
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.`;
|
|
34068
34637
|
if (r.text) meta3.text = r.text;
|
|
34069
34638
|
if (r.grounding) meta3.grounding = r.grounding;
|
|
34639
|
+
if (report) meta3.image_inputs = report;
|
|
34070
34640
|
if (video.videoFileMeta) meta3.video_file = video.videoFileMeta;
|
|
34071
34641
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: onDisk });
|
|
34072
34642
|
if (risk) meta3.timeout_risk = risk;
|
|
@@ -34272,12 +34842,159 @@ function registerJobTools(server, client2) {
|
|
|
34272
34842
|
);
|
|
34273
34843
|
}
|
|
34274
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
|
+
|
|
34275
34992
|
// src/index.ts
|
|
34276
34993
|
await loadStdioDotenv();
|
|
34277
34994
|
await runMcp({
|
|
34278
34995
|
name: "gemini-mcp",
|
|
34279
34996
|
version: VERSION,
|
|
34280
34997
|
banner: "[gemini-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.",
|
|
34281
|
-
tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerVideoTools, registerMusicTools, registerJobTools],
|
|
34998
|
+
tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerVideoTools, registerMusicTools, registerJobTools, registerFileTools],
|
|
34282
34999
|
deps: client
|
|
34283
35000
|
});
|