@chrischall/gemini-mcp 1.6.2 → 1.8.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/dist/bundle.js +808 -89
- package/dist/client.js +407 -34
- package/dist/fetch-image.js +19 -7
- package/dist/job-store.js +138 -0
- package/dist/jobs.js +328 -14
- package/dist/session.js +5 -4
- package/dist/storage/media.js +70 -0
- package/dist/tools/files.js +49 -0
- package/dist/tools/interact.js +9 -0
- package/dist/tools/jobs.js +132 -3
- package/dist/tools/music.js +5 -1
- package/dist/tools/shared.js +13 -3
- package/dist/tools/video.js +14 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/bundle.js
CHANGED
|
@@ -459,15 +459,15 @@ var init_parseUtil = __esm({
|
|
|
459
459
|
message: issueData.message
|
|
460
460
|
};
|
|
461
461
|
}
|
|
462
|
-
let
|
|
462
|
+
let errorMessage2 = "";
|
|
463
463
|
const maps = errorMaps.filter((m) => !!m).slice().reverse();
|
|
464
464
|
for (const map2 of maps) {
|
|
465
|
-
|
|
465
|
+
errorMessage2 = map2(fullIssue, { data, defaultError: errorMessage2 }).message;
|
|
466
466
|
}
|
|
467
467
|
return {
|
|
468
468
|
...issueData,
|
|
469
469
|
path: fullPath,
|
|
470
|
-
message:
|
|
470
|
+
message: errorMessage2
|
|
471
471
|
};
|
|
472
472
|
};
|
|
473
473
|
ParseStatus = class _ParseStatus {
|
|
@@ -20948,19 +20948,19 @@ var init_Refs = __esm({
|
|
|
20948
20948
|
});
|
|
20949
20949
|
|
|
20950
20950
|
// node_modules/zod-to-json-schema/dist/esm/errorMessages.js
|
|
20951
|
-
function addErrorMessage(res, key,
|
|
20951
|
+
function addErrorMessage(res, key, errorMessage2, refs) {
|
|
20952
20952
|
if (!refs?.errorMessages)
|
|
20953
20953
|
return;
|
|
20954
|
-
if (
|
|
20954
|
+
if (errorMessage2) {
|
|
20955
20955
|
res.errorMessage = {
|
|
20956
20956
|
...res.errorMessage,
|
|
20957
|
-
[key]:
|
|
20957
|
+
[key]: errorMessage2
|
|
20958
20958
|
};
|
|
20959
20959
|
}
|
|
20960
20960
|
}
|
|
20961
|
-
function setResponseValueAndErrors(res, key, value,
|
|
20961
|
+
function setResponseValueAndErrors(res, key, value, errorMessage2, refs) {
|
|
20962
20962
|
res[key] = value;
|
|
20963
|
-
addErrorMessage(res, key,
|
|
20963
|
+
addErrorMessage(res, key, errorMessage2, refs);
|
|
20964
20964
|
}
|
|
20965
20965
|
var init_errorMessages = __esm({
|
|
20966
20966
|
"node_modules/zod-to-json-schema/dist/esm/errorMessages.js"() {
|
|
@@ -22582,8 +22582,8 @@ var init_protocol = __esm({
|
|
|
22582
22582
|
if (queuedMessage.type === "response") {
|
|
22583
22583
|
resolver(message);
|
|
22584
22584
|
} else {
|
|
22585
|
-
const
|
|
22586
|
-
const error51 = new McpError(
|
|
22585
|
+
const errorMessage2 = message;
|
|
22586
|
+
const error51 = new McpError(errorMessage2.error.code, errorMessage2.error.message, errorMessage2.error.data);
|
|
22587
22587
|
resolver(error51);
|
|
22588
22588
|
}
|
|
22589
22589
|
} else {
|
|
@@ -30763,23 +30763,23 @@ var init_server2 = __esm({
|
|
|
30763
30763
|
const wrappedHandler = async (request, extra) => {
|
|
30764
30764
|
const validatedRequest = safeParse2(CallToolRequestSchema, request);
|
|
30765
30765
|
if (!validatedRequest.success) {
|
|
30766
|
-
const
|
|
30767
|
-
throw new McpError(ErrorCode.InvalidParams, `Invalid tools/call request: ${
|
|
30766
|
+
const errorMessage2 = validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error);
|
|
30767
|
+
throw new McpError(ErrorCode.InvalidParams, `Invalid tools/call request: ${errorMessage2}`);
|
|
30768
30768
|
}
|
|
30769
30769
|
const { params } = validatedRequest.data;
|
|
30770
30770
|
const result = await Promise.resolve(handler(request, extra));
|
|
30771
30771
|
if (params.task) {
|
|
30772
30772
|
const taskValidationResult = safeParse2(CreateTaskResultSchema, result);
|
|
30773
30773
|
if (!taskValidationResult.success) {
|
|
30774
|
-
const
|
|
30775
|
-
throw new McpError(ErrorCode.InvalidParams, `Invalid task creation result: ${
|
|
30774
|
+
const errorMessage2 = taskValidationResult.error instanceof Error ? taskValidationResult.error.message : String(taskValidationResult.error);
|
|
30775
|
+
throw new McpError(ErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage2}`);
|
|
30776
30776
|
}
|
|
30777
30777
|
return taskValidationResult.data;
|
|
30778
30778
|
}
|
|
30779
30779
|
const validationResult = safeParse2(CallToolResultSchema, result);
|
|
30780
30780
|
if (!validationResult.success) {
|
|
30781
|
-
const
|
|
30782
|
-
throw new McpError(ErrorCode.InvalidParams, `Invalid tools/call result: ${
|
|
30781
|
+
const errorMessage2 = validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error);
|
|
30782
|
+
throw new McpError(ErrorCode.InvalidParams, `Invalid tools/call result: ${errorMessage2}`);
|
|
30783
30783
|
}
|
|
30784
30784
|
return validationResult.data;
|
|
30785
30785
|
};
|
|
@@ -31380,12 +31380,12 @@ var init_mcp = __esm({
|
|
|
31380
31380
|
* @param errorMessage - The error message.
|
|
31381
31381
|
* @returns The tool error result.
|
|
31382
31382
|
*/
|
|
31383
|
-
createToolError(
|
|
31383
|
+
createToolError(errorMessage2) {
|
|
31384
31384
|
return {
|
|
31385
31385
|
content: [
|
|
31386
31386
|
{
|
|
31387
31387
|
type: "text",
|
|
31388
|
-
text:
|
|
31388
|
+
text: errorMessage2
|
|
31389
31389
|
}
|
|
31390
31390
|
],
|
|
31391
31391
|
isError: true
|
|
@@ -31403,8 +31403,8 @@ var init_mcp = __esm({
|
|
|
31403
31403
|
const parseResult = await safeParseAsync2(schemaToParse, args);
|
|
31404
31404
|
if (!parseResult.success) {
|
|
31405
31405
|
const error51 = "error" in parseResult ? parseResult.error : "Unknown error";
|
|
31406
|
-
const
|
|
31407
|
-
throw new McpError(ErrorCode.InvalidParams, `Input validation error: Invalid arguments for tool ${toolName}: ${
|
|
31406
|
+
const errorMessage2 = getParseErrorMessage(error51);
|
|
31407
|
+
throw new McpError(ErrorCode.InvalidParams, `Input validation error: Invalid arguments for tool ${toolName}: ${errorMessage2}`);
|
|
31408
31408
|
}
|
|
31409
31409
|
return parseResult.data;
|
|
31410
31410
|
}
|
|
@@ -31428,8 +31428,8 @@ var init_mcp = __esm({
|
|
|
31428
31428
|
const parseResult = await safeParseAsync2(outputObj, result.structuredContent);
|
|
31429
31429
|
if (!parseResult.success) {
|
|
31430
31430
|
const error51 = "error" in parseResult ? parseResult.error : "Unknown error";
|
|
31431
|
-
const
|
|
31432
|
-
throw new McpError(ErrorCode.InvalidParams, `Output validation error: Invalid structured content for tool ${toolName}: ${
|
|
31431
|
+
const errorMessage2 = getParseErrorMessage(error51);
|
|
31432
|
+
throw new McpError(ErrorCode.InvalidParams, `Output validation error: Invalid structured content for tool ${toolName}: ${errorMessage2}`);
|
|
31433
31433
|
}
|
|
31434
31434
|
}
|
|
31435
31435
|
/**
|
|
@@ -31641,8 +31641,8 @@ var init_mcp = __esm({
|
|
|
31641
31641
|
const parseResult = await safeParseAsync2(argsObj, request.params.arguments);
|
|
31642
31642
|
if (!parseResult.success) {
|
|
31643
31643
|
const error51 = "error" in parseResult ? parseResult.error : "Unknown error";
|
|
31644
|
-
const
|
|
31645
|
-
throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for prompt ${request.params.name}: ${
|
|
31644
|
+
const errorMessage2 = getParseErrorMessage(error51);
|
|
31645
|
+
throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for prompt ${request.params.name}: ${errorMessage2}`);
|
|
31646
31646
|
}
|
|
31647
31647
|
const args = parseResult.data;
|
|
31648
31648
|
const cb = prompt.callback;
|
|
@@ -32838,7 +32838,7 @@ async function loadStdioDotenv() {
|
|
|
32838
32838
|
}
|
|
32839
32839
|
|
|
32840
32840
|
// src/version.ts
|
|
32841
|
-
var VERSION = "1.
|
|
32841
|
+
var VERSION = "1.8.0";
|
|
32842
32842
|
|
|
32843
32843
|
// src/errors.ts
|
|
32844
32844
|
init_dist();
|
|
@@ -33078,15 +33078,15 @@ function fileNameFromUrl(url2, fallback) {
|
|
|
33078
33078
|
function describeUrl(current, requested) {
|
|
33079
33079
|
return current === requested ? current : `${current} (redirected from ${requested})`;
|
|
33080
33080
|
}
|
|
33081
|
-
async function readCapped(res, maxBytes, url2, requested) {
|
|
33081
|
+
async function readCapped(res, maxBytes, url2, requested, subject) {
|
|
33082
33082
|
const declared = Number(res.headers.get("content-length"));
|
|
33083
33083
|
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
33084
|
-
throw tooBig(url2, requested, declared, maxBytes);
|
|
33084
|
+
throw tooBig(url2, requested, declared, maxBytes, false, subject);
|
|
33085
33085
|
}
|
|
33086
33086
|
const reader = res.body?.getReader();
|
|
33087
33087
|
if (!reader) {
|
|
33088
33088
|
const buf = new Uint8Array(await res.arrayBuffer());
|
|
33089
|
-
if (buf.byteLength > maxBytes) throw tooBig(url2, requested, buf.byteLength, maxBytes);
|
|
33089
|
+
if (buf.byteLength > maxBytes) throw tooBig(url2, requested, buf.byteLength, maxBytes, false, subject);
|
|
33090
33090
|
return buf;
|
|
33091
33091
|
}
|
|
33092
33092
|
const chunks = [];
|
|
@@ -33100,7 +33100,7 @@ async function readCapped(res, maxBytes, url2, requested) {
|
|
|
33100
33100
|
if (total > maxBytes) {
|
|
33101
33101
|
await reader.cancel().catch(() => {
|
|
33102
33102
|
});
|
|
33103
|
-
throw tooBig(url2, requested, total, maxBytes, true);
|
|
33103
|
+
throw tooBig(url2, requested, total, maxBytes, true, subject);
|
|
33104
33104
|
}
|
|
33105
33105
|
chunks.push(value);
|
|
33106
33106
|
}
|
|
@@ -33127,12 +33127,18 @@ function fetchFailure(err, current, requested, timeoutMs) {
|
|
|
33127
33127
|
{ hint: "Check the URL is reachable from the server and serves the image without authentication." }
|
|
33128
33128
|
);
|
|
33129
33129
|
}
|
|
33130
|
-
function tooBig(url2, requested, size, max, partial2 = false) {
|
|
33130
|
+
function tooBig(url2, requested, size, max, partial2 = false, subject) {
|
|
33131
|
+
const what = subject ?? IMAGE_CAP_SUBJECT;
|
|
33131
33132
|
return new McpToolError(
|
|
33132
|
-
|
|
33133
|
-
{ hint:
|
|
33133
|
+
`${what.noun} at ${describeUrl(url2, requested)} is ${partial2 ? "over " : ""}${formatMb(size)}, above the ${formatMb(max)} limit${what.limitFor ? ` for ${what.limitFor}` : ""}.`,
|
|
33134
|
+
{ hint: what.hint }
|
|
33134
33135
|
);
|
|
33135
33136
|
}
|
|
33137
|
+
var IMAGE_CAP_SUBJECT = {
|
|
33138
|
+
noun: "Image",
|
|
33139
|
+
limitFor: "images_url",
|
|
33140
|
+
hint: "Resize or re-encode the image, or upload it yourself with gemini_upload_file and pass the returned file_uri."
|
|
33141
|
+
};
|
|
33136
33142
|
async function fetchRemoteImage(requestedUrl, opts = {}) {
|
|
33137
33143
|
const doFetch = opts.fetchImpl ?? fetch;
|
|
33138
33144
|
const maxBytes = opts.maxBytes ?? IMAGE_URL_MAX_BYTES;
|
|
@@ -33367,6 +33373,14 @@ async function tenantIdFor(secret) {
|
|
|
33367
33373
|
return [...new Uint8Array(digest).slice(0, 6)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
33368
33374
|
}
|
|
33369
33375
|
var KEY_PREFIX_DEFAULT = "gen";
|
|
33376
|
+
var MAX_LIST_PAGES = 10;
|
|
33377
|
+
function parseMediaKey(key, prefix) {
|
|
33378
|
+
const rest = key.slice(prefix.length);
|
|
33379
|
+
const match = /^(\d{4}-\d{2}-\d{2})\/(.+)$/.exec(rest);
|
|
33380
|
+
if (!match) return void 0;
|
|
33381
|
+
const name = match[2].replace(/^[0-9a-f]{4,}-/i, "").replace(/\.[A-Za-z0-9]+$/, "");
|
|
33382
|
+
return { day: match[1], name: name || match[2] };
|
|
33383
|
+
}
|
|
33370
33384
|
function safeKeySegment(base) {
|
|
33371
33385
|
const cleaned = base.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/\.{2,}/g, "-").replace(/-+/g, "-").replace(/^[-.]+|[-.]+$/g, "");
|
|
33372
33386
|
return cleaned || "media";
|
|
@@ -33402,6 +33416,40 @@ function createR2Sink(bucket, opts) {
|
|
|
33402
33416
|
mimeType: object3.httpMetadata?.contentType ?? "application/octet-stream"
|
|
33403
33417
|
};
|
|
33404
33418
|
},
|
|
33419
|
+
async listRecent(opts2 = {}) {
|
|
33420
|
+
return (await this.listRecentPage(opts2)).media;
|
|
33421
|
+
},
|
|
33422
|
+
async listRecentPage({ limit = 20, sinceDay } = {}) {
|
|
33423
|
+
if (!bucket.list) return { media: [], truncated: false, scannedPages: 0 };
|
|
33424
|
+
const prefix2 = await ownPrefix();
|
|
33425
|
+
const found = [];
|
|
33426
|
+
let cursor;
|
|
33427
|
+
let truncated = false;
|
|
33428
|
+
let scannedPages = 0;
|
|
33429
|
+
for (let page2 = 0; page2 < MAX_LIST_PAGES; page2++) {
|
|
33430
|
+
scannedPages = page2 + 1;
|
|
33431
|
+
const listed = await bucket.list({ prefix: prefix2, cursor, limit: 1e3 });
|
|
33432
|
+
for (const obj of listed.objects) {
|
|
33433
|
+
const parsed = parseMediaKey(obj.key, prefix2);
|
|
33434
|
+
if (!parsed) continue;
|
|
33435
|
+
if (sinceDay && parsed.day < sinceDay) continue;
|
|
33436
|
+
found.push({ ...parsed, key: obj.key, ...obj.size !== void 0 ? { sizeBytes: obj.size } : {} });
|
|
33437
|
+
}
|
|
33438
|
+
if (!listed.truncated || !listed.cursor) break;
|
|
33439
|
+
cursor = listed.cursor;
|
|
33440
|
+
if (page2 === MAX_LIST_PAGES - 1) truncated = true;
|
|
33441
|
+
}
|
|
33442
|
+
found.sort((a, b) => a.day < b.day ? 1 : a.day > b.day ? -1 : a.key < b.key ? 1 : -1);
|
|
33443
|
+
const page = found.slice(0, limit);
|
|
33444
|
+
const expiresAtMs2 = now().getTime() + ttlMs;
|
|
33445
|
+
const media = await Promise.all(
|
|
33446
|
+
page.map(async (entry) => {
|
|
33447
|
+
const link = await describe3(entry.key, expiresAtMs2);
|
|
33448
|
+
return { ...entry, ...link.unavailable ? {} : { url: link.ref, expiresAt: link.expiresAt } };
|
|
33449
|
+
})
|
|
33450
|
+
);
|
|
33451
|
+
return { media, truncated: truncated || found.length > limit, scannedPages };
|
|
33452
|
+
},
|
|
33405
33453
|
async resign(key) {
|
|
33406
33454
|
if (!await owned(key)) return void 0;
|
|
33407
33455
|
if (bucket.get && !await bucket.get(key)) return void 0;
|
|
@@ -33543,7 +33591,7 @@ var idempotencyKeySchema = external_exports.string().min(1).optional().describe(
|
|
|
33543
33591
|
"Opaque idempotency key: a repeat call with the same key returns the recorded result (reused: true) instead of billing a new generation. Set it when retrying after a host timeout (-32001) to avoid a duplicate charge."
|
|
33544
33592
|
);
|
|
33545
33593
|
var asyncSchema = external_exports.boolean().optional().describe(
|
|
33546
|
-
"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
|
|
33594
|
+
"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. PREFER `max_wait_ms` on the hosted connector: it runs where the executor is only guaranteed to stay alive while the request is open, so this option is served there as a bounded wait rather than an immediate hand-off."
|
|
33547
33595
|
);
|
|
33548
33596
|
var maxWaitMsSchema = external_exports.number().int().positive().max(6e5).optional().describe(
|
|
33549
33597
|
'Wait up to this many ms for the result; if generation is still running when the budget expires, return { job_id, status: "running" } immediately instead (poll gemini_get_result). Keeps fast results in-band while a slow batch can never trip the host tools/call timeout (-32001) \u2014 e.g. 20000 for multi-image sets. Ignored when async is set.'
|
|
@@ -33708,7 +33756,7 @@ function timeoutRiskHint(opts) {
|
|
|
33708
33756
|
const why = [proModel && "Pro model", fourK && "4K output", multi && `count=${opts.count}`].filter(Boolean).join(" + ");
|
|
33709
33757
|
const lead = `Slow config (${why}) can exceed a host's tools/call timeout (e.g. Claude Desktop ~30s, error -32001). `;
|
|
33710
33758
|
if (opts.persistsFiles === false) {
|
|
33711
|
-
return lead + `This connector has no filesystem, so a lost response cannot be recovered from disk \u2014 there is no output dir and no <image>.json sidecar.
|
|
33759
|
+
return lead + `This connector has no filesystem, so a lost response cannot be recovered from disk \u2014 there is no output dir and no <image>.json sidecar. Set \`max_wait_ms\` (e.g. 240000): keeping the request open is also what keeps the generation running here, and a batch that outlives the budget hands back a job_id to poll with gemini_get_result. For a faster in-band result use a Flash model or drop image_size to 1K/2K. If a result is ever lost, list what was already generated with gemini_list_recent_media before paying for it again.`;
|
|
33712
33760
|
}
|
|
33713
33761
|
return lead + `If the host gives up mid-generation the image is still written to disk (and gemini_interact leaves an <image>.json sidecar with the interaction id) \u2014 no rerun needed, just look in the output dir. For a faster in-band result use a Flash model, drop image_size to 1K/2K, or \u2014 in Claude Code \u2014 set MCP_TOOL_TIMEOUT.`;
|
|
33714
33762
|
}
|
|
@@ -33810,14 +33858,73 @@ function assertLocalInputsAvailable(sink, args) {
|
|
|
33810
33858
|
|
|
33811
33859
|
// src/jobs.ts
|
|
33812
33860
|
init_dist();
|
|
33813
|
-
import { randomUUID, createHash } from "node:crypto";
|
|
33861
|
+
import { randomUUID, createHash as createHash2 } from "node:crypto";
|
|
33862
|
+
|
|
33863
|
+
// src/job-store.ts
|
|
33864
|
+
import { createHash } from "node:crypto";
|
|
33865
|
+
var HEARTBEAT_MS = 6e4;
|
|
33866
|
+
var EXECUTOR_LOST_AFTER_MS = 3 * HEARTBEAT_MS;
|
|
33867
|
+
var PREFIX = "jobs";
|
|
33868
|
+
function keyHash(idempotencyKey) {
|
|
33869
|
+
return createHash("sha256").update(idempotencyKey).digest("hex").slice(0, 32);
|
|
33870
|
+
}
|
|
33871
|
+
function createBlobJobStore(bucket, opts) {
|
|
33872
|
+
const now = opts.now ?? (() => Date.now());
|
|
33873
|
+
const tenantOf = async () => typeof opts.tenant === "function" ? opts.tenant() : opts.tenant;
|
|
33874
|
+
const recordKey = async (jobId) => `${PREFIX}/${await tenantOf()}/${encodeURIComponent(jobId)}.json`;
|
|
33875
|
+
const pointerKey = async (k) => `${PREFIX}/${await tenantOf()}/keys/${keyHash(k)}.json`;
|
|
33876
|
+
async function writeJson(key, value) {
|
|
33877
|
+
try {
|
|
33878
|
+
await bucket.put(key, JSON.stringify(value), { httpMetadata: { contentType: "application/json" } });
|
|
33879
|
+
} catch {
|
|
33880
|
+
}
|
|
33881
|
+
}
|
|
33882
|
+
async function readJson(key) {
|
|
33883
|
+
try {
|
|
33884
|
+
const obj = await bucket.get(key);
|
|
33885
|
+
if (!obj) return void 0;
|
|
33886
|
+
return JSON.parse(new TextDecoder().decode(await obj.arrayBuffer()));
|
|
33887
|
+
} catch {
|
|
33888
|
+
return void 0;
|
|
33889
|
+
}
|
|
33890
|
+
}
|
|
33891
|
+
return {
|
|
33892
|
+
async put(record2) {
|
|
33893
|
+
await writeJson(await recordKey(record2.jobId), record2);
|
|
33894
|
+
},
|
|
33895
|
+
async get(jobId) {
|
|
33896
|
+
const record2 = await readJson(await recordKey(jobId));
|
|
33897
|
+
if (!record2 || typeof record2.status !== "string") return void 0;
|
|
33898
|
+
if (record2.status !== "running") return record2;
|
|
33899
|
+
if (now() - record2.updatedAt <= EXECUTOR_LOST_AFTER_MS) return record2;
|
|
33900
|
+
return {
|
|
33901
|
+
...record2,
|
|
33902
|
+
status: "failed",
|
|
33903
|
+
error: {
|
|
33904
|
+
message: `Job "${jobId}" was lost: its executor stopped reporting ${Math.round((now() - record2.updatedAt) / 1e3)}s ago, before the generation finished. This connector runs on a machine that stops when no request is in flight, so background work started with \`async: true\` does not reliably survive.`,
|
|
33905
|
+
hint: "Re-run with `max_wait_ms` (e.g. 240000) instead of `async: true` \u2014 holding the request open keeps the executor alive. Any images the lost run did finish are still stored: list them with gemini_list_recent_media before paying for a regeneration."
|
|
33906
|
+
}
|
|
33907
|
+
};
|
|
33908
|
+
},
|
|
33909
|
+
async putKey(idempotencyKey, jobId) {
|
|
33910
|
+
await writeJson(await pointerKey(idempotencyKey), { jobId });
|
|
33911
|
+
},
|
|
33912
|
+
async jobIdForKey(idempotencyKey) {
|
|
33913
|
+
const pointer = await readJson(await pointerKey(idempotencyKey));
|
|
33914
|
+
return typeof pointer?.jobId === "string" ? pointer.jobId : void 0;
|
|
33915
|
+
}
|
|
33916
|
+
};
|
|
33917
|
+
}
|
|
33918
|
+
|
|
33919
|
+
// src/jobs.ts
|
|
33814
33920
|
var JOB_TTL_MS = 6e5;
|
|
33921
|
+
var DURABLE_JOB_REUSE_MS = 24 * 60 * 60 * 1e3;
|
|
33815
33922
|
var JOB_MAX = 64;
|
|
33816
33923
|
function isExpired(entry, now) {
|
|
33817
33924
|
return entry.settledAt !== void 0 && now - entry.settledAt > JOB_TTL_MS;
|
|
33818
33925
|
}
|
|
33819
33926
|
function fingerprintRequest(toolName, parts) {
|
|
33820
|
-
return
|
|
33927
|
+
return createHash2("sha256").update(`${toolName}\0${JSON.stringify(parts)}`).digest("hex");
|
|
33821
33928
|
}
|
|
33822
33929
|
async function refreshMedia(result, sink) {
|
|
33823
33930
|
const first = result.content?.[0];
|
|
@@ -33896,13 +34003,23 @@ function annotateReused(result, jobId) {
|
|
|
33896
34003
|
content: [{ type: "text", text: JSON.stringify({ reused: true, reused_job_id: jobId }, null, 2) }, ...content]
|
|
33897
34004
|
};
|
|
33898
34005
|
}
|
|
33899
|
-
function jobHandle(jobId, status) {
|
|
34006
|
+
function jobHandle(jobId, status, durable = false) {
|
|
33900
34007
|
return textResult({
|
|
33901
34008
|
job_id: jobId,
|
|
33902
34009
|
status,
|
|
33903
|
-
hint: `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done" (results are per-process and expire ~10 min after completion).`
|
|
34010
|
+
hint: durable ? `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done". The job record is stored durably, so a poll still works if this connector restarts \u2014 but background work is NOT restarted, so a long generation is safer with max_wait_ms than with async.` : `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done" (results are per-process and expire ~10 min after completion).`
|
|
33904
34011
|
});
|
|
33905
34012
|
}
|
|
34013
|
+
var MAX_PERSISTED_RESULT_BYTES = 64 * 1024;
|
|
34014
|
+
function persistableResult(result) {
|
|
34015
|
+
if ((result.content ?? []).some((block) => block.type !== "text")) return { resultOmitted: "inline" };
|
|
34016
|
+
try {
|
|
34017
|
+
if (JSON.stringify(result).length > MAX_PERSISTED_RESULT_BYTES) return { resultOmitted: "too_large" };
|
|
34018
|
+
} catch {
|
|
34019
|
+
return { resultOmitted: "too_large" };
|
|
34020
|
+
}
|
|
34021
|
+
return { result };
|
|
34022
|
+
}
|
|
33906
34023
|
function awaitWithBudget(promise2, waitMs) {
|
|
33907
34024
|
return new Promise((resolve2, reject) => {
|
|
33908
34025
|
const timer = setTimeout(() => resolve2(void 0), waitMs);
|
|
@@ -33926,16 +34043,113 @@ var JobRegistry = class {
|
|
|
33926
34043
|
* never expire.
|
|
33927
34044
|
*/
|
|
33928
34045
|
resigner;
|
|
34046
|
+
/**
|
|
34047
|
+
* Durable backing for this registry, when the deployment has somewhere to put
|
|
34048
|
+
* it. Absent on a local stdio install, where the process that starts a
|
|
34049
|
+
* generation is the process that finishes it and a `Map` is the whole truth.
|
|
34050
|
+
*
|
|
34051
|
+
* Present on the hosted connector, where it is load-bearing: see the header of
|
|
34052
|
+
* `src/job-store.ts` for why an in-memory-only registry cannot work on a
|
|
34053
|
+
* machine that stops whenever no request is in flight.
|
|
34054
|
+
*/
|
|
34055
|
+
store;
|
|
34056
|
+
/**
|
|
34057
|
+
* When set, `async: true` is served as a bounded in-band wait instead of an
|
|
34058
|
+
* immediate hand-off.
|
|
34059
|
+
*
|
|
34060
|
+
* Set by the hosted client, because there the two are not the trade-off they
|
|
34061
|
+
* look like. Returning immediately releases the request, and the released
|
|
34062
|
+
* request is the thing keeping the executor alive — so `async: true` is
|
|
34063
|
+
* precisely the way to get a generation killed. Waiting holds the machine up
|
|
34064
|
+
* until the work is done. If the budget still expires the caller gets the same
|
|
34065
|
+
* job handle it asked for, now backed by a durable record.
|
|
34066
|
+
*/
|
|
34067
|
+
asyncWaitFallbackMs;
|
|
34068
|
+
/** Injectable clock (tests). */
|
|
34069
|
+
now = () => Date.now();
|
|
33929
34070
|
jobs = /* @__PURE__ */ new Map();
|
|
33930
34071
|
byKey = /* @__PURE__ */ new Map();
|
|
33931
34072
|
// idempotencyKey -> jobId
|
|
33932
34073
|
runningByFingerprint = /* @__PURE__ */ new Map();
|
|
33933
34074
|
// fingerprint -> jobId (only while running)
|
|
34075
|
+
/** Durable bookkeeping still in flight — awaited by `drain()` in tests. */
|
|
34076
|
+
pending = /* @__PURE__ */ new Set();
|
|
34077
|
+
heartbeatTimer;
|
|
33934
34078
|
/** Drop every job. Used by `SessionState.reset()` (tests) — not by handlers. */
|
|
33935
34079
|
reset() {
|
|
33936
34080
|
this.jobs.clear();
|
|
33937
34081
|
this.byKey.clear();
|
|
33938
34082
|
this.runningByFingerprint.clear();
|
|
34083
|
+
this.stopHeartbeat();
|
|
34084
|
+
}
|
|
34085
|
+
/** Await outstanding durable writes. Tests only — handlers never need it. */
|
|
34086
|
+
async drain() {
|
|
34087
|
+
for (let round = 0; round < 50; round++) {
|
|
34088
|
+
await new Promise((resolve2) => setTimeout(resolve2, 0));
|
|
34089
|
+
if (!this.pending.size) return;
|
|
34090
|
+
await Promise.allSettled([...this.pending]);
|
|
34091
|
+
}
|
|
34092
|
+
}
|
|
34093
|
+
/**
|
|
34094
|
+
* Re-stamp every running job's durable record.
|
|
34095
|
+
*
|
|
34096
|
+
* This is what separates "still working" from "the machine went away": a
|
|
34097
|
+
* reader compares the stamp against `EXECUTOR_LOST_AFTER_MS`, so a 12-scene
|
|
34098
|
+
* set that legitimately runs for ten minutes stays `running` while a job whose
|
|
34099
|
+
* child was killed goes terminal within three missed beats.
|
|
34100
|
+
*/
|
|
34101
|
+
async beat() {
|
|
34102
|
+
if (!this.store) return;
|
|
34103
|
+
await Promise.allSettled(
|
|
34104
|
+
[...this.jobs.values()].filter((e) => e.status === "running").map((e) => this.writeRecord(e, { onlyWhileRunning: true }))
|
|
34105
|
+
);
|
|
34106
|
+
}
|
|
34107
|
+
/**
|
|
34108
|
+
* Queue one durable write for `entry`, after any write already in flight for
|
|
34109
|
+
* it. `onlyWhileRunning` drops the write if the job settled in the meantime —
|
|
34110
|
+
* which is what makes a late heartbeat a no-op rather than a resurrection.
|
|
34111
|
+
*/
|
|
34112
|
+
writeRecord(entry, opts = {}) {
|
|
34113
|
+
const next = (entry.writeChain ?? Promise.resolve()).then(async () => {
|
|
34114
|
+
if (opts.onlyWhileRunning && entry.status !== "running") return;
|
|
34115
|
+
await this.store.put(this.recordFor(entry, this.now()));
|
|
34116
|
+
}, () => void 0);
|
|
34117
|
+
entry.writeChain = next;
|
|
34118
|
+
return next;
|
|
34119
|
+
}
|
|
34120
|
+
recordFor(entry, updatedAt) {
|
|
34121
|
+
return {
|
|
34122
|
+
jobId: entry.jobId,
|
|
34123
|
+
toolName: entry.toolName,
|
|
34124
|
+
fingerprint: entry.fingerprint,
|
|
34125
|
+
...entry.idempotencyKey !== void 0 ? { idempotencyKey: entry.idempotencyKey } : {},
|
|
34126
|
+
status: entry.status,
|
|
34127
|
+
createdAt: entry.createdAt,
|
|
34128
|
+
updatedAt,
|
|
34129
|
+
...entry.interactionId !== void 0 ? { interactionId: entry.interactionId } : {},
|
|
34130
|
+
...entry.error ? { error: entry.error } : {},
|
|
34131
|
+
...entry.status === "done" && entry.result ? persistableResult(entry.result) : {}
|
|
34132
|
+
};
|
|
34133
|
+
}
|
|
34134
|
+
/** Run `p` as durable bookkeeping: tracked for `drain()`, never thrown from. */
|
|
34135
|
+
track(p) {
|
|
34136
|
+
const wrapped = p.catch(() => void 0).finally(() => this.pending.delete(wrapped));
|
|
34137
|
+
this.pending.add(wrapped);
|
|
34138
|
+
}
|
|
34139
|
+
startHeartbeat() {
|
|
34140
|
+
if (this.heartbeatTimer || !this.store) return;
|
|
34141
|
+
this.heartbeatTimer = setInterval(() => {
|
|
34142
|
+
if (![...this.jobs.values()].some((e) => e.status === "running")) {
|
|
34143
|
+
this.stopHeartbeat();
|
|
34144
|
+
return;
|
|
34145
|
+
}
|
|
34146
|
+
this.track(this.beat());
|
|
34147
|
+
}, HEARTBEAT_MS);
|
|
34148
|
+
this.heartbeatTimer.unref?.();
|
|
34149
|
+
}
|
|
34150
|
+
stopHeartbeat() {
|
|
34151
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
34152
|
+
this.heartbeatTimer = void 0;
|
|
33939
34153
|
}
|
|
33940
34154
|
/** Drop expired entries, then enforce JOB_MAX by evicting oldest-settled first. */
|
|
33941
34155
|
evict(now) {
|
|
@@ -33971,8 +34185,11 @@ var JobRegistry = class {
|
|
|
33971
34185
|
* different registry and therefore a genuinely separate generation.
|
|
33972
34186
|
*/
|
|
33973
34187
|
async dispatch(opts, work) {
|
|
33974
|
-
const { toolName, fingerprint, idempotencyKey
|
|
33975
|
-
const
|
|
34188
|
+
const { toolName, fingerprint, idempotencyKey } = opts;
|
|
34189
|
+
const degrade = opts.async === true && this.asyncWaitFallbackMs !== void 0;
|
|
34190
|
+
const async = degrade ? false : opts.async;
|
|
34191
|
+
const waitMs = degrade ? opts.waitMs ?? this.asyncWaitFallbackMs : opts.waitMs;
|
|
34192
|
+
const now = this.now();
|
|
33976
34193
|
let hit;
|
|
33977
34194
|
if (idempotencyKey !== void 0) {
|
|
33978
34195
|
const id = this.byKey.get(idempotencyKey);
|
|
@@ -33983,6 +34200,10 @@ var JobRegistry = class {
|
|
|
33983
34200
|
const entry2 = id ? this.jobs.get(id) : void 0;
|
|
33984
34201
|
if (entry2 && entry2.status === "running") hit = entry2;
|
|
33985
34202
|
}
|
|
34203
|
+
if (!hit && idempotencyKey !== void 0 && this.store) {
|
|
34204
|
+
const durable = await this.durableByKey(idempotencyKey, fingerprint);
|
|
34205
|
+
if (durable) return durable;
|
|
34206
|
+
}
|
|
33986
34207
|
if (hit) {
|
|
33987
34208
|
if (async) return jobHandle(hit.jobId, hit.status);
|
|
33988
34209
|
const settled = waitMs !== void 0 && hit.status === "running" ? await awaitWithBudget(hit.promise, waitMs) : await hit.promise;
|
|
@@ -33991,32 +34212,80 @@ var JobRegistry = class {
|
|
|
33991
34212
|
}
|
|
33992
34213
|
const jobId = randomUUID();
|
|
33993
34214
|
this.evict(now);
|
|
33994
|
-
|
|
33995
|
-
|
|
34215
|
+
let entry;
|
|
34216
|
+
let earlyInteractionId;
|
|
34217
|
+
const ctx = {
|
|
34218
|
+
reportInteraction: (interactionId) => {
|
|
34219
|
+
if (!entry) {
|
|
34220
|
+
earlyInteractionId = interactionId;
|
|
34221
|
+
return;
|
|
34222
|
+
}
|
|
34223
|
+
if (entry.interactionId === interactionId) return;
|
|
34224
|
+
entry.interactionId = interactionId;
|
|
34225
|
+
if (this.store) this.track(this.writeRecord(entry));
|
|
34226
|
+
}
|
|
34227
|
+
};
|
|
34228
|
+
const promise2 = work(ctx);
|
|
34229
|
+
entry = {
|
|
34230
|
+
jobId,
|
|
34231
|
+
toolName,
|
|
34232
|
+
fingerprint,
|
|
34233
|
+
idempotencyKey,
|
|
34234
|
+
status: "running",
|
|
34235
|
+
promise: promise2,
|
|
34236
|
+
createdAt: now,
|
|
34237
|
+
...earlyInteractionId !== void 0 ? { interactionId: earlyInteractionId } : {}
|
|
34238
|
+
};
|
|
33996
34239
|
this.jobs.set(jobId, entry);
|
|
33997
34240
|
if (idempotencyKey !== void 0) this.byKey.set(idempotencyKey, jobId);
|
|
33998
34241
|
this.runningByFingerprint.set(fingerprint, jobId);
|
|
33999
|
-
|
|
34242
|
+
if (this.store) {
|
|
34243
|
+
this.track(this.writeRecord(entry));
|
|
34244
|
+
if (idempotencyKey !== void 0) this.track(this.store.putKey(idempotencyKey, jobId));
|
|
34245
|
+
this.startHeartbeat();
|
|
34246
|
+
}
|
|
34247
|
+
const settle = promise2.then(
|
|
34000
34248
|
(res) => {
|
|
34001
34249
|
entry.status = "done";
|
|
34002
34250
|
entry.result = res;
|
|
34003
|
-
entry.settledAt =
|
|
34251
|
+
entry.settledAt = this.now();
|
|
34004
34252
|
},
|
|
34005
34253
|
(err) => {
|
|
34006
34254
|
entry.status = "failed";
|
|
34007
34255
|
entry.error = { message: err instanceof Error ? err.message : String(err), hint: err?.hint };
|
|
34008
|
-
entry.settledAt =
|
|
34256
|
+
entry.settledAt = this.now();
|
|
34009
34257
|
}
|
|
34010
34258
|
).finally(() => {
|
|
34011
34259
|
if (this.runningByFingerprint.get(fingerprint) === jobId) this.runningByFingerprint.delete(fingerprint);
|
|
34012
34260
|
});
|
|
34013
|
-
if (
|
|
34261
|
+
if (this.store) void settle.then(() => this.track(this.writeRecord(entry)));
|
|
34262
|
+
if (async) return jobHandle(jobId, "running", this.store !== void 0);
|
|
34014
34263
|
if (waitMs !== void 0) {
|
|
34015
34264
|
const settled = await awaitWithBudget(promise2, waitMs);
|
|
34016
|
-
return settled ?? jobHandle(jobId, "running");
|
|
34265
|
+
return settled ?? jobHandle(jobId, "running", this.store !== void 0);
|
|
34017
34266
|
}
|
|
34018
34267
|
return promise2;
|
|
34019
34268
|
}
|
|
34269
|
+
/**
|
|
34270
|
+
* Resolve an idempotency key against the durable store — the path a retry
|
|
34271
|
+
* takes when the child that ran the original job is gone.
|
|
34272
|
+
*
|
|
34273
|
+
* `undefined` means "nothing durable to reuse", which includes a job that
|
|
34274
|
+
* FAILED: a retry after an error must really retry. A job still recorded as
|
|
34275
|
+
* running belongs to an executor this process cannot await, so the caller gets
|
|
34276
|
+
* the handle back rather than a promise that would never settle.
|
|
34277
|
+
*/
|
|
34278
|
+
async durableByKey(idempotencyKey, fingerprint) {
|
|
34279
|
+
const id = await this.store.jobIdForKey(idempotencyKey);
|
|
34280
|
+
if (!id) return void 0;
|
|
34281
|
+
const record2 = await this.store.get(id);
|
|
34282
|
+
if (!record2) return void 0;
|
|
34283
|
+
if (record2.fingerprint !== fingerprint) return void 0;
|
|
34284
|
+
if (this.now() - record2.updatedAt > DURABLE_JOB_REUSE_MS) return void 0;
|
|
34285
|
+
if (record2.status === "running") return jobHandle(id, "running", true);
|
|
34286
|
+
if (record2.status !== "done" || !record2.result) return void 0;
|
|
34287
|
+
return annotateReused(await refreshMedia(record2.result, this.resigner), id);
|
|
34288
|
+
}
|
|
34020
34289
|
/**
|
|
34021
34290
|
* Retrieve an async job by id (backs the `gemini_get_result` tool):
|
|
34022
34291
|
* `running` → a status handle; `done` → the recorded result payload;
|
|
@@ -34026,21 +34295,91 @@ var JobRegistry = class {
|
|
|
34026
34295
|
* the unknown-id path — the poll tool cannot be used to read another user's
|
|
34027
34296
|
* result even if they know the uuid.
|
|
34028
34297
|
*/
|
|
34029
|
-
|
|
34298
|
+
/**
|
|
34299
|
+
* The durable record for `jobId`, if there is one — including the
|
|
34300
|
+
* executor-lost rewrite. Read-only view for a caller that wants to act on a
|
|
34301
|
+
* record's contents (see `tools/jobs.ts` recovering an interaction id) rather
|
|
34302
|
+
* than just render it.
|
|
34303
|
+
*/
|
|
34304
|
+
async peekRecord(jobId) {
|
|
34305
|
+
return this.store ? await this.store.get(jobId) : void 0;
|
|
34306
|
+
}
|
|
34307
|
+
/**
|
|
34308
|
+
* Adopt a result produced OUTSIDE this registry — recovery reading a lost
|
|
34309
|
+
* job's output back off the upstream interaction (`tools/jobs.ts`).
|
|
34310
|
+
*
|
|
34311
|
+
* Without this the job stays `failed` and every later poll re-enters
|
|
34312
|
+
* recovery: another interaction fetch, another media download, and another
|
|
34313
|
+
* copy on disk (`…-2.mp4`, `…-3.mp4`) or in R2, with nothing telling the
|
|
34314
|
+
* caller they are duplicates. The ordinary done path is idempotent, so this
|
|
34315
|
+
* one has to be too.
|
|
34316
|
+
*/
|
|
34317
|
+
async adoptResult(jobId, toolName, result, interactionId) {
|
|
34318
|
+
const now = this.now();
|
|
34319
|
+
let entry = this.jobs.get(jobId);
|
|
34320
|
+
if (!entry) {
|
|
34321
|
+
entry = {
|
|
34322
|
+
jobId,
|
|
34323
|
+
toolName,
|
|
34324
|
+
fingerprint: `recovered:${jobId}`,
|
|
34325
|
+
status: "done",
|
|
34326
|
+
promise: Promise.resolve(result),
|
|
34327
|
+
createdAt: now
|
|
34328
|
+
};
|
|
34329
|
+
this.jobs.set(jobId, entry);
|
|
34330
|
+
}
|
|
34331
|
+
entry.status = "done";
|
|
34332
|
+
entry.result = result;
|
|
34333
|
+
entry.settledAt = now;
|
|
34334
|
+
if (interactionId !== void 0) entry.interactionId = interactionId;
|
|
34335
|
+
if (this.store) await this.writeRecord(entry);
|
|
34336
|
+
}
|
|
34337
|
+
async getResult(jobId) {
|
|
34030
34338
|
const entry = this.jobs.get(jobId);
|
|
34031
34339
|
if (!entry) {
|
|
34032
|
-
|
|
34033
|
-
|
|
34034
|
-
|
|
34340
|
+
const durable = this.store ? await this.store.get(jobId) : void 0;
|
|
34341
|
+
if (durable) return this.fromRecord(durable);
|
|
34342
|
+
throw new McpToolError(
|
|
34343
|
+
this.store ? `No job "${jobId}" \u2014 no record of it exists. Either the id is wrong, or the job is older than the ${JOB_RECORD_RETENTION_LABEL} job records are kept.` : `No job "${jobId}" \u2014 unknown or expired.`,
|
|
34344
|
+
{
|
|
34345
|
+
hint: this.store ? "Job records are stored durably here, so a restart alone does not lose one \u2014 an id with no record was never started under this account. Images from a completed run are listed by gemini_list_recent_media." : "Jobs live with your session and are evicted ~10 min after completion (or on server restart). If the generation likely finished, check the output dir / <image>.json sidecar instead of polling."
|
|
34346
|
+
}
|
|
34347
|
+
);
|
|
34035
34348
|
}
|
|
34036
|
-
if (entry.status === "running") return jobHandle(jobId, "running");
|
|
34349
|
+
if (entry.status === "running") return jobHandle(jobId, "running", this.store !== void 0);
|
|
34037
34350
|
if (entry.status === "failed") {
|
|
34038
34351
|
throw new McpToolError(entry.error?.message ?? "Job failed.", entry.error?.hint ? { hint: entry.error.hint } : void 0);
|
|
34039
34352
|
}
|
|
34040
34353
|
if (!entry.result) throw new McpToolError(`Job "${jobId}" completed without a recorded result.`);
|
|
34041
34354
|
return entry.result;
|
|
34042
34355
|
}
|
|
34356
|
+
/**
|
|
34357
|
+
* Render a durable record as a poll answer — the path taken when the child
|
|
34358
|
+
* that ran the job is gone.
|
|
34359
|
+
*
|
|
34360
|
+
* The three outcomes are kept distinct on purpose. Collapsing "your job
|
|
34361
|
+
* failed", "your job was killed" and "no such job" into one message is what
|
|
34362
|
+
* made the original bug read as an expiry problem when it was a machine
|
|
34363
|
+
* lifecycle problem.
|
|
34364
|
+
*/
|
|
34365
|
+
async fromRecord(record2) {
|
|
34366
|
+
if (record2.status === "running") return jobHandle(record2.jobId, "running", true);
|
|
34367
|
+
if (record2.status === "failed") {
|
|
34368
|
+
throw new McpToolError(record2.error?.message ?? "Job failed.", record2.error?.hint ? { hint: record2.error.hint } : void 0);
|
|
34369
|
+
}
|
|
34370
|
+
if (record2.resultOmitted) {
|
|
34371
|
+
throw new McpToolError(
|
|
34372
|
+
record2.resultOmitted === "inline" ? `Job "${record2.jobId}" finished, but its result was returned inline (base64 image data) and inline results are not stored, so there is nothing to replay. Re-run without \`inline: true\` so the media is stored and returned as URLs, which a poll can replay.` : `Job "${record2.jobId}" finished, but its result was too large to store (over ${Math.round(MAX_PERSISTED_RESULT_BYTES / 1024)} KB), so there is nothing to replay.`,
|
|
34373
|
+
{
|
|
34374
|
+
hint: "The generation itself completed \u2014 list what it produced with gemini_list_recent_media rather than paying for it again."
|
|
34375
|
+
}
|
|
34376
|
+
);
|
|
34377
|
+
}
|
|
34378
|
+
if (!record2.result) throw new McpToolError(`Job "${record2.jobId}" completed without a recorded result.`);
|
|
34379
|
+
return refreshMedia(record2.result, this.resigner);
|
|
34380
|
+
}
|
|
34043
34381
|
};
|
|
34382
|
+
var JOB_RECORD_RETENTION_LABEL = "30 days";
|
|
34044
34383
|
|
|
34045
34384
|
// src/tools/_confirm.ts
|
|
34046
34385
|
init_dist();
|
|
@@ -34739,6 +35078,21 @@ var UPLOAD_BASE_URL = "https://generativelanguage.googleapis.com/upload/v1beta";
|
|
|
34739
35078
|
var FILE_MAX_BYTES = 2 * 1024 ** 3;
|
|
34740
35079
|
var FILE_POLL_INTERVAL_MS = 2e3;
|
|
34741
35080
|
var FILE_POLL_MAX_ATTEMPTS = 150;
|
|
35081
|
+
var INTERACTION_POLL_INTERVAL_MS = 3e3;
|
|
35082
|
+
var TERMINAL_INTERACTION_STATUSES = /* @__PURE__ */ new Set([
|
|
35083
|
+
"completed",
|
|
35084
|
+
"failed",
|
|
35085
|
+
"cancelled",
|
|
35086
|
+
"incomplete",
|
|
35087
|
+
"budget_exceeded"
|
|
35088
|
+
]);
|
|
35089
|
+
var MEDIA_URI_HOST = "generativelanguage.googleapis.com";
|
|
35090
|
+
var MEDIA_DOWNLOAD_MAX_BYTES = 128 * 1024 * 1024;
|
|
35091
|
+
var MEDIA_CAP_SUBJECT = {
|
|
35092
|
+
noun: "Generated media",
|
|
35093
|
+
hint: "Ask for a shorter clip or a lower resolution \u2014 the file itself is still in the Files API for ~48h if you need it another way."
|
|
35094
|
+
};
|
|
35095
|
+
var INTERACTION_ID_RE = /^[\w-]+$/;
|
|
34742
35096
|
var CHAIN_404_RETRY_BUDGET_MS = 12e4;
|
|
34743
35097
|
var CHAIN_404_RETRY_BASE_MS = 2e3;
|
|
34744
35098
|
var CHAIN_404_RETRY_MAX_SLEEP_MS = 3e4;
|
|
@@ -34747,18 +35101,20 @@ function resolveChainRetryBudgetMs() {
|
|
|
34747
35101
|
return Number.isFinite(env) && env >= 0 ? env : CHAIN_404_RETRY_BUDGET_MS;
|
|
34748
35102
|
}
|
|
34749
35103
|
var ChainedRequest404Error = class extends McpToolError {
|
|
34750
|
-
constructor(previousInteractionId, upstreamMessage, retries, opts) {
|
|
35104
|
+
constructor(previousInteractionId, upstreamMessage, retries, opts, chainExists, verdict) {
|
|
34751
35105
|
super(
|
|
34752
|
-
`Gemini returned HTTP 404 for a chained request (previous_interaction_id: "${previousInteractionId}"), retried ${retries.attempts}\xD7 over ~${Math.round(retries.waitedMs / 1e3)}s for store lag. Upstream said: ${upstreamMessage}. This does not necessarily mean the interaction expired \u2014 an unknown model id or an expired files/\u2026 uri returns the same generic 404. (For reference: interactions are retained 55 days on the paid tier, 1 day on the free tier, and are scoped to the API key that created them.)
|
|
35106
|
+
`Gemini returned HTTP 404 for a chained request (previous_interaction_id: "${previousInteractionId}"), retried ${retries.attempts}\xD7 over ~${Math.round(retries.waitedMs / 1e3)}s for store lag. Upstream said: ${upstreamMessage}. ` + (verdict ?? "This does not necessarily mean the interaction expired \u2014 an unknown model id or an expired files/\u2026 uri returns the same generic 404. ") + "(For reference: interactions are retained 55 days on the paid tier, 1 day on the free tier, and are scoped to the API key that created them.)",
|
|
34753
35107
|
opts
|
|
34754
35108
|
);
|
|
34755
35109
|
this.previousInteractionId = previousInteractionId;
|
|
34756
35110
|
this.upstreamMessage = upstreamMessage;
|
|
34757
35111
|
this.retries = retries;
|
|
35112
|
+
this.chainExists = chainExists;
|
|
34758
35113
|
}
|
|
34759
35114
|
previousInteractionId;
|
|
34760
35115
|
upstreamMessage;
|
|
34761
35116
|
retries;
|
|
35117
|
+
chainExists;
|
|
34762
35118
|
};
|
|
34763
35119
|
function generatePart(img) {
|
|
34764
35120
|
return img.uri ? { file_data: { file_uri: img.uri, mime_type: img.mimeType } } : { inline_data: { mime_type: img.mimeType, data: img.base64 } };
|
|
@@ -34810,6 +35166,8 @@ var GeminiClient = class {
|
|
|
34810
35166
|
this.library = opts.library;
|
|
34811
35167
|
this.uploadUrls = opts.uploadUrls;
|
|
34812
35168
|
this.session.jobs.resigner = this.mediaSink;
|
|
35169
|
+
this.session.jobs.store = opts.jobStore;
|
|
35170
|
+
if (opts.jobStore) this.session.jobs.asyncWaitFallbackMs = HOSTED_ASYNC_WAIT_MS;
|
|
34813
35171
|
this.explicitApiKey = opts.apiKey?.trim() || void 0;
|
|
34814
35172
|
this.fetchImpl = opts.fetchImpl ?? ((...args) => fetch(...args));
|
|
34815
35173
|
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
@@ -35196,31 +35554,52 @@ var GeminiClient = class {
|
|
|
35196
35554
|
hint: "The request may have been blocked by safety filters \u2014 try rephrasing the prompt."
|
|
35197
35555
|
});
|
|
35198
35556
|
}
|
|
35199
|
-
return { id: data.id, images, text, grounding };
|
|
35557
|
+
return { id: data.id, images: await this.downloadAll(images), text, grounding };
|
|
35200
35558
|
}
|
|
35201
35559
|
/**
|
|
35202
|
-
* Generate video via the omni model (Interactions API
|
|
35203
|
-
*
|
|
35204
|
-
*
|
|
35205
|
-
*
|
|
35560
|
+
* Generate video via the omni model (Interactions API). Same plumbing as
|
|
35561
|
+
* {@link interact} — text/image input parts, a `video` response_format,
|
|
35562
|
+
* optional `video_config.task`, and `previous_interaction_id` for edits.
|
|
35563
|
+
*
|
|
35564
|
+
* Delivered by uri unless the caller says otherwise, so the clip is not
|
|
35565
|
+
* capped by the ~4MB inline limit; the bytes are downloaded here either way.
|
|
35566
|
+
* Verified live 2026-08-22 end to end (a real MP4 off the wire), which is
|
|
35567
|
+
* why this path no longer carries the "docs-derived" caveat that music
|
|
35568
|
+
* still does.
|
|
35206
35569
|
*/
|
|
35207
35570
|
async generateVideo(opts) {
|
|
35208
35571
|
const model = opts.model ?? DEFAULT_VIDEO_MODEL;
|
|
35209
35572
|
const inputParts = [{ type: "text", text: opts.input }];
|
|
35210
35573
|
for (const img of opts.images ?? []) inputParts.push(interactPart(img));
|
|
35211
|
-
const
|
|
35212
|
-
|
|
35213
|
-
|
|
35214
|
-
|
|
35215
|
-
|
|
35216
|
-
|
|
35574
|
+
const delivery = opts.delivery ?? "uri";
|
|
35575
|
+
const buildBody = (withDelivery) => {
|
|
35576
|
+
const responseFormat = { type: "video" };
|
|
35577
|
+
if (withDelivery) responseFormat.delivery = delivery;
|
|
35578
|
+
if (opts.aspectRatio) responseFormat.aspect_ratio = opts.aspectRatio;
|
|
35579
|
+
const body2 = { model, input: inputParts, response_format: responseFormat };
|
|
35580
|
+
if (opts.task) body2.generation_config = { video_config: { task: opts.task } };
|
|
35581
|
+
if (opts.previousInteractionId !== void 0) body2.previous_interaction_id = opts.previousInteractionId;
|
|
35582
|
+
if (opts.background === true) body2.background = true;
|
|
35583
|
+
return body2;
|
|
35584
|
+
};
|
|
35585
|
+
const timeoutMs = resolveTimeoutMs(opts.timeoutMs, void 0);
|
|
35586
|
+
let body = buildBody(true);
|
|
35587
|
+
let started;
|
|
35588
|
+
try {
|
|
35589
|
+
started = await this.startInteraction(body, opts, timeoutMs);
|
|
35590
|
+
} catch (err) {
|
|
35591
|
+
if (!isDeliveryUnsupported(err)) throw err;
|
|
35592
|
+
body = buildBody(false);
|
|
35593
|
+
started = await this.startInteraction(body, opts, timeoutMs);
|
|
35594
|
+
}
|
|
35595
|
+
const data = await this.settleInteraction(started, body, timeoutMs);
|
|
35217
35596
|
const { videos, text } = this.extractInteraction(data);
|
|
35218
35597
|
if (videos.length === 0) {
|
|
35219
35598
|
throw new McpToolError("Gemini returned no video", {
|
|
35220
|
-
hint: "The request may have been blocked by a safety filter
|
|
35599
|
+
hint: "The request may have been blocked by a safety filter \u2014 try a shorter/simpler prompt or a different aspect ratio."
|
|
35221
35600
|
});
|
|
35222
35601
|
}
|
|
35223
|
-
return { id: data.id, videos, text };
|
|
35602
|
+
return { id: data.id, videos: await this.downloadAll(videos), text };
|
|
35224
35603
|
}
|
|
35225
35604
|
/**
|
|
35226
35605
|
* Generate music via a Lyria model (Interactions API, inline audio). Reuses
|
|
@@ -35234,6 +35613,7 @@ var GeminiClient = class {
|
|
|
35234
35613
|
if (opts.audioFormat) responseFormat.audio_format = opts.audioFormat;
|
|
35235
35614
|
const body = { model, input: inputParts, response_format: responseFormat };
|
|
35236
35615
|
if (opts.previousInteractionId !== void 0) body.previous_interaction_id = opts.previousInteractionId;
|
|
35616
|
+
if (opts.background === true) body.background = true;
|
|
35237
35617
|
const data = await this.postInteraction(body, opts);
|
|
35238
35618
|
const { audios, text } = this.extractInteraction(data);
|
|
35239
35619
|
if (audios.length === 0) {
|
|
@@ -35241,11 +35621,37 @@ var GeminiClient = class {
|
|
|
35241
35621
|
hint: "The request may have been blocked by a safety filter \u2014 try rephrasing the prompt."
|
|
35242
35622
|
});
|
|
35243
35623
|
}
|
|
35244
|
-
return { id: data.id, audios, text };
|
|
35624
|
+
return { id: data.id, audios: await this.downloadAll(audios), text };
|
|
35245
35625
|
}
|
|
35246
|
-
/**
|
|
35626
|
+
/**
|
|
35627
|
+
* POST `/interactions`, wait out the store lag on a chained 404, and — when
|
|
35628
|
+
* the request was backgrounded — poll until the interaction reaches a
|
|
35629
|
+
* terminal state. Shared by all media.
|
|
35630
|
+
*/
|
|
35247
35631
|
async postInteraction(body, opts) {
|
|
35248
|
-
const
|
|
35632
|
+
const timeoutMs = resolveTimeoutMs(opts.timeoutMs, opts.imageSize);
|
|
35633
|
+
return await this.settleInteraction(await this.startInteraction(body, opts, timeoutMs), body, timeoutMs);
|
|
35634
|
+
}
|
|
35635
|
+
/**
|
|
35636
|
+
* The POST half: issue the request and announce the id.
|
|
35637
|
+
*
|
|
35638
|
+
* Split from the wait so a caller can retry the POST — and ONLY the POST —
|
|
35639
|
+
* without the retry predicate ever seeing an error from after the generation
|
|
35640
|
+
* started. Past this point the work is running and billable.
|
|
35641
|
+
*/
|
|
35642
|
+
async startInteraction(body, opts, timeoutMs) {
|
|
35643
|
+
const started = await this.postInteractionOnce(body, opts, timeoutMs);
|
|
35644
|
+
if (started.id) opts.onInteractionStarted?.(started.id);
|
|
35645
|
+
return started;
|
|
35646
|
+
}
|
|
35647
|
+
/** The wait half: poll a backgrounded request to a terminal state. */
|
|
35648
|
+
async settleInteraction(started, body, timeoutMs) {
|
|
35649
|
+
if (body.background !== true) return started;
|
|
35650
|
+
return await this.awaitInteraction(started, timeoutMs);
|
|
35651
|
+
}
|
|
35652
|
+
/** The POST itself, with the chained-404 retry + diagnosis. */
|
|
35653
|
+
async postInteractionOnce(body, opts, timeoutMs) {
|
|
35654
|
+
const { interactionsApi } = this.apisFor(timeoutMs);
|
|
35249
35655
|
const budgetMs = resolveChainRetryBudgetMs();
|
|
35250
35656
|
let waitedMs = 0;
|
|
35251
35657
|
for (let attempt = 0; ; attempt++) {
|
|
@@ -35259,13 +35665,183 @@ var GeminiClient = class {
|
|
|
35259
35665
|
await this.sleep(delay);
|
|
35260
35666
|
continue;
|
|
35261
35667
|
}
|
|
35262
|
-
|
|
35263
|
-
|
|
35264
|
-
|
|
35265
|
-
|
|
35668
|
+
const { chainExists, verdict } = await this.diagnoseChain(opts.previousInteractionId);
|
|
35669
|
+
throw new ChainedRequest404Error(
|
|
35670
|
+
opts.previousInteractionId,
|
|
35671
|
+
err.message,
|
|
35672
|
+
{ attempts: attempt, waitedMs },
|
|
35673
|
+
{
|
|
35674
|
+
hint: chainExists === true ? "The chain is alive, so re-issuing without previous_interaction_id would re-bill and still fail. Check the model id and any files/\u2026 uri instead." : "Re-issue the request WITHOUT previous_interaction_id (re-attaching the prior output image if you were editing). If that also 404s, the interaction id is not the cause \u2014 check the model id and any files/\u2026 uri (they expire ~48h). gemini_interact does this probe automatically.",
|
|
35675
|
+
cause: err
|
|
35676
|
+
},
|
|
35677
|
+
chainExists,
|
|
35678
|
+
verdict
|
|
35679
|
+
);
|
|
35266
35680
|
}
|
|
35267
35681
|
}
|
|
35268
35682
|
}
|
|
35683
|
+
/**
|
|
35684
|
+
* Ask the API whether the chained interaction is actually gone.
|
|
35685
|
+
*
|
|
35686
|
+
* Three answers, three remediations — and a fourth state that matters just as
|
|
35687
|
+
* much: if the probe itself cannot be completed we return `undefined`, so the
|
|
35688
|
+
* caller says "unknown" rather than inventing "gone".
|
|
35689
|
+
*/
|
|
35690
|
+
async diagnoseChain(id) {
|
|
35691
|
+
try {
|
|
35692
|
+
await this.getInteraction(id);
|
|
35693
|
+
return {
|
|
35694
|
+
chainExists: true,
|
|
35695
|
+
verdict: `That interaction still exists (GET /interactions/${id} returned it), so the chain is NOT the cause \u2014 a generic 404 also covers an unknown model id and an expired files/\u2026 uri (~48h). Check those. `
|
|
35696
|
+
};
|
|
35697
|
+
} catch (err) {
|
|
35698
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
35699
|
+
return {
|
|
35700
|
+
chainExists: false,
|
|
35701
|
+
verdict: `That interaction no longer exists (GET /interactions/${id} returned 404 too) \u2014 it expired or was deleted, so re-anchor on the image it produced. That does not necessarily mean it was the cause, though: an unknown model id or an expired files/\u2026 uri returns the same generic 404, so read the upstream text above. `
|
|
35702
|
+
};
|
|
35703
|
+
}
|
|
35704
|
+
if (err instanceof ApiError && err.status === 400) {
|
|
35705
|
+
return {
|
|
35706
|
+
chainExists: false,
|
|
35707
|
+
verdict: `"${id}" is not a valid interaction id \u2014 the API rejected the name as malformed, so it was never a live chain. Check how the id was captured. `
|
|
35708
|
+
};
|
|
35709
|
+
}
|
|
35710
|
+
return {};
|
|
35711
|
+
}
|
|
35712
|
+
}
|
|
35713
|
+
/**
|
|
35714
|
+
* Poll a backgrounded interaction to a terminal state.
|
|
35715
|
+
*
|
|
35716
|
+
* ⚠️ **A poll of work that is still in flight answers `403
|
|
35717
|
+
* permission_denied`** — "There was a problem processing your request. You
|
|
35718
|
+
* will not be charged." (verified live 2026-08-22, against a paid key that
|
|
35719
|
+
* GETs the very same interaction fine once it completes). It is a transient
|
|
35720
|
+
* state dressed as an authorization failure, and taking it at face value
|
|
35721
|
+
* fails every backgrounded generation a few seconds in. A 404 is transient
|
|
35722
|
+
* here too — the same store lag the chained POST retries.
|
|
35723
|
+
*
|
|
35724
|
+
* Neither is distinguishable from the real thing, so the caller's timeout is
|
|
35725
|
+
* what bounds it, and the last poll error rides along in the timeout message
|
|
35726
|
+
* so a genuine permission problem is not invisible.
|
|
35727
|
+
*
|
|
35728
|
+
* A body with no `status` field is likewise treated as "still running"
|
|
35729
|
+
* unless a `model_output` step is already present.
|
|
35730
|
+
*/
|
|
35731
|
+
async awaitInteraction(started, budgetMs) {
|
|
35732
|
+
let current = started;
|
|
35733
|
+
let waitedMs = 0;
|
|
35734
|
+
let lastPollError;
|
|
35735
|
+
for (; ; ) {
|
|
35736
|
+
const status = current.status;
|
|
35737
|
+
if (!status && (current.steps ?? []).some((step2) => step2.type === "model_output")) return current;
|
|
35738
|
+
if (status && TERMINAL_INTERACTION_STATUSES.has(status)) {
|
|
35739
|
+
if (status === "completed") return current;
|
|
35740
|
+
throw new McpToolError(
|
|
35741
|
+
`${SERVICE} interaction ${current.id} ${status} (background execution).`,
|
|
35742
|
+
{ hint: "A safety filter or an unsupported prompt is the usual cause \u2014 rephrase and retry. The interaction id above can be inspected while it is retained." }
|
|
35743
|
+
);
|
|
35744
|
+
}
|
|
35745
|
+
if (waitedMs + INTERACTION_POLL_INTERVAL_MS > budgetMs) {
|
|
35746
|
+
throw new McpToolError(
|
|
35747
|
+
`${SERVICE} interaction ${current.id} is still running after ${Math.round(waitedMs / 1e3)}s. It continues on Google's side \u2014 the work is not lost.` + (lastPollError ? ` Last poll said: ${lastPollError}` : ""),
|
|
35748
|
+
{ hint: "Raise timeout_ms (or max_wait_ms) and retry, or keep the interaction id: the generation completes upstream regardless of this request." }
|
|
35749
|
+
);
|
|
35750
|
+
}
|
|
35751
|
+
await this.sleep(INTERACTION_POLL_INTERVAL_MS);
|
|
35752
|
+
waitedMs += INTERACTION_POLL_INTERVAL_MS;
|
|
35753
|
+
try {
|
|
35754
|
+
current = await this.getInteraction(current.id);
|
|
35755
|
+
lastPollError = void 0;
|
|
35756
|
+
} catch (err) {
|
|
35757
|
+
if (!isTransientPoll(err)) throw err;
|
|
35758
|
+
lastPollError = err instanceof Error ? err.message : String(err);
|
|
35759
|
+
}
|
|
35760
|
+
}
|
|
35761
|
+
}
|
|
35762
|
+
/**
|
|
35763
|
+
* Fetch one interaction. Backs the background poll and the chained-404
|
|
35764
|
+
* diagnosis; also the honest answer to "is that id still alive?".
|
|
35765
|
+
*/
|
|
35766
|
+
async getInteraction(id) {
|
|
35767
|
+
if (!INTERACTION_ID_RE.test(id)) {
|
|
35768
|
+
throw new McpToolError(`Not a valid interaction id: "${id}"`, {
|
|
35769
|
+
hint: "An interaction id is the opaque `id` from a previous response (e.g. v1_Chd\u2026), with no slashes or query string."
|
|
35770
|
+
});
|
|
35771
|
+
}
|
|
35772
|
+
const { interactionsApi } = this.apisFor(resolveTimeoutMs(void 0));
|
|
35773
|
+
return await interactionsApi.fetchJson("GET", `/interactions/${id}`);
|
|
35774
|
+
}
|
|
35775
|
+
/**
|
|
35776
|
+
* Fetch a stored interaction and resolve its media to bytes.
|
|
35777
|
+
*
|
|
35778
|
+
* The recovery read: a generation whose executor died is still retrievable
|
|
35779
|
+
* by id for as long as the interaction is retained (55 days paid, 1 day
|
|
35780
|
+
* free). Returns the status alongside the media so a caller can tell "still
|
|
35781
|
+
* running" from "finished with nothing".
|
|
35782
|
+
*/
|
|
35783
|
+
async fetchInteractionMedia(id) {
|
|
35784
|
+
const data = await this.getInteraction(id);
|
|
35785
|
+
const { images, videos, audios, text } = this.extractInteraction(data);
|
|
35786
|
+
return {
|
|
35787
|
+
status: data.status,
|
|
35788
|
+
images: await this.downloadAll(images),
|
|
35789
|
+
videos: await this.downloadAll(videos),
|
|
35790
|
+
audios: await this.downloadAll(audios),
|
|
35791
|
+
text
|
|
35792
|
+
};
|
|
35793
|
+
}
|
|
35794
|
+
/**
|
|
35795
|
+
* Resolve one extracted medium to bytes.
|
|
35796
|
+
*
|
|
35797
|
+
* `delivery: 'uri'` hands back a Files API link, and that link **needs the
|
|
35798
|
+
* api key** (verified: 403 without it), so it is not something a caller could
|
|
35799
|
+
* use — the bytes have to arrive here exactly as inline delivery would. The
|
|
35800
|
+
* uri is API-supplied but addresses a request we make with credentials
|
|
35801
|
+
* attached, so the host is checked before it is fetched.
|
|
35802
|
+
*/
|
|
35803
|
+
async downloadMedia(media) {
|
|
35804
|
+
if (media.base64) return { base64: media.base64, mimeType: media.mimeType };
|
|
35805
|
+
const requested = media.uri ?? "";
|
|
35806
|
+
const doFetch = this.fetchImpl;
|
|
35807
|
+
const key = this.requireKey();
|
|
35808
|
+
let current = requested;
|
|
35809
|
+
for (let hop = 0; ; hop++) {
|
|
35810
|
+
const url2 = assertMediaHost(current, requested);
|
|
35811
|
+
const res = await doFetch(url2.toString(), {
|
|
35812
|
+
redirect: "manual",
|
|
35813
|
+
headers: { "x-goog-api-key": key }
|
|
35814
|
+
});
|
|
35815
|
+
if (res.status >= 300 && res.status < 400) {
|
|
35816
|
+
const location = res.headers?.get("location");
|
|
35817
|
+
if (!location) {
|
|
35818
|
+
throw new McpToolError(`${SERVICE} media download returned HTTP ${res.status} with no Location header for ${current}`);
|
|
35819
|
+
}
|
|
35820
|
+
if (hop >= MAX_REDIRECTS) {
|
|
35821
|
+
throw new McpToolError(`${SERVICE} media download for ${requested} exceeded ${MAX_REDIRECTS} redirects (last hop: ${location}).`);
|
|
35822
|
+
}
|
|
35823
|
+
current = new URL(location, url2).toString();
|
|
35824
|
+
continue;
|
|
35825
|
+
}
|
|
35826
|
+
if (!res.ok) {
|
|
35827
|
+
throw new McpToolError(
|
|
35828
|
+
`${SERVICE} media download failed (HTTP ${res.status}) for ${current}`,
|
|
35829
|
+
{ hint: "Generated files live in the Files API for ~48h and the download requires the api key that created them." }
|
|
35830
|
+
);
|
|
35831
|
+
}
|
|
35832
|
+
const bytes = await readCapped(res, MEDIA_DOWNLOAD_MAX_BYTES, current, requested, MEDIA_CAP_SUBJECT);
|
|
35833
|
+
return {
|
|
35834
|
+
base64: bytesToBase64(bytes),
|
|
35835
|
+
mimeType: media.mimeType || res.headers?.get("content-type") || "application/octet-stream"
|
|
35836
|
+
};
|
|
35837
|
+
}
|
|
35838
|
+
}
|
|
35839
|
+
/** {@link downloadMedia} over a list, preserving order. */
|
|
35840
|
+
async downloadAll(media) {
|
|
35841
|
+
const out = [];
|
|
35842
|
+
for (const m of media) out.push(await this.downloadMedia(m));
|
|
35843
|
+
return out;
|
|
35844
|
+
}
|
|
35269
35845
|
/**
|
|
35270
35846
|
* Collect `model_output` media (image/video/audio) + text + grounding from an
|
|
35271
35847
|
* interactions response. Only the `model_output` step is surfaced — `thought`
|
|
@@ -35294,12 +35870,13 @@ var GeminiClient = class {
|
|
|
35294
35870
|
for (const parts of [step2.content ?? [], step2.summary ?? []]) {
|
|
35295
35871
|
for (const part of parts) {
|
|
35296
35872
|
const mime = part.mime_type ?? part.mimeType;
|
|
35297
|
-
if (part.
|
|
35298
|
-
if (part.
|
|
35299
|
-
|
|
35300
|
-
|
|
35301
|
-
|
|
35302
|
-
|
|
35873
|
+
if (part.type === "text") {
|
|
35874
|
+
if (part.text?.trim()) textParts.push(part.text);
|
|
35875
|
+
} else if (part.data || part.uri) {
|
|
35876
|
+
const carrier = part.data ? { base64: part.data } : { uri: part.uri };
|
|
35877
|
+
if (part.type === "image") images.push({ ...carrier, mimeType: mime ?? "image/jpeg" });
|
|
35878
|
+
else if (part.type === "video") videos.push({ ...carrier, mimeType: mime ?? "video/mp4" });
|
|
35879
|
+
else if (part.type === "audio") audios.push({ ...carrier, mimeType: mime ?? "audio/mpeg" });
|
|
35303
35880
|
}
|
|
35304
35881
|
}
|
|
35305
35882
|
}
|
|
@@ -35313,6 +35890,27 @@ var GeminiClient = class {
|
|
|
35313
35890
|
return { images, videos, audios, text: textParts.join("\n") || void 0, grounding };
|
|
35314
35891
|
}
|
|
35315
35892
|
};
|
|
35893
|
+
function assertMediaHost(candidate, requested) {
|
|
35894
|
+
let parsed;
|
|
35895
|
+
try {
|
|
35896
|
+
parsed = new URL(candidate);
|
|
35897
|
+
} catch {
|
|
35898
|
+
throw new McpToolError(`${SERVICE} returned a media reference that is not a URL: ${candidate || "(empty)"}`);
|
|
35899
|
+
}
|
|
35900
|
+
if (parsed.protocol !== "https:" || parsed.hostname !== MEDIA_URI_HOST) {
|
|
35901
|
+
throw new McpToolError(
|
|
35902
|
+
`${SERVICE} media download was pointed at an unexpected host (${parsed.hostname || candidate}); expected ${MEDIA_URI_HOST}` + (candidate === requested ? "" : ` (redirected from ${requested})`),
|
|
35903
|
+
{ hint: "Generated media is served from the Files API on that host only. Report this if it reproduces \u2014 it should not happen." }
|
|
35904
|
+
);
|
|
35905
|
+
}
|
|
35906
|
+
return parsed;
|
|
35907
|
+
}
|
|
35908
|
+
function isTransientPoll(err) {
|
|
35909
|
+
return err instanceof ApiError && (err.status === 403 || err.status === 404);
|
|
35910
|
+
}
|
|
35911
|
+
function isDeliveryUnsupported(err) {
|
|
35912
|
+
return err instanceof ApiError && err.status === 400 && /delivery/i.test(err.message);
|
|
35913
|
+
}
|
|
35316
35914
|
function hostedStorage() {
|
|
35317
35915
|
const blob = blobStoreFromEnv();
|
|
35318
35916
|
if (!blob) return {};
|
|
@@ -35339,9 +35937,14 @@ function hostedStorage() {
|
|
|
35339
35937
|
tenant
|
|
35340
35938
|
}),
|
|
35341
35939
|
library: createR2Library(blob.bucket, { tenant }),
|
|
35342
|
-
uploadUrls: createUploadUrlMinter({ links: blob.links, sign: blob.signWrite, tenant })
|
|
35940
|
+
uploadUrls: createUploadUrlMinter({ links: blob.links, sign: blob.signWrite, tenant }),
|
|
35941
|
+
// Job records go in the same store as the media, for the same reason: this
|
|
35942
|
+
// runtime's machine stops whenever no request is in flight, taking the
|
|
35943
|
+
// child — and an in-memory registry — with it (src/job-store.ts).
|
|
35944
|
+
jobStore: createBlobJobStore(blob.bucket, { tenant })
|
|
35343
35945
|
};
|
|
35344
35946
|
}
|
|
35947
|
+
var HOSTED_ASYNC_WAIT_MS = 24e4;
|
|
35345
35948
|
var BLOB_URL_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
35346
35949
|
var BLOB_MAX_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
35347
35950
|
var client = new GeminiClient(hostedStorage());
|
|
@@ -35508,6 +36111,7 @@ function registerInteractTools(server, client2) {
|
|
|
35508
36111
|
return await client2.interact({ ...callOpts, images: inputs.length ? inputs : void 0, previousInteractionId });
|
|
35509
36112
|
} catch (err) {
|
|
35510
36113
|
if (!(err instanceof ChainedRequest404Error)) throw err;
|
|
36114
|
+
if (err.chainExists === true) throw err;
|
|
35511
36115
|
if (!onDisk) throw err;
|
|
35512
36116
|
const reanchorOn = await findInteractionImages(resolveOutputDir(args.output_dir), err.previousInteractionId);
|
|
35513
36117
|
if (!reanchorOn.length) throw err;
|
|
@@ -35568,6 +36172,7 @@ init_dist();
|
|
|
35568
36172
|
init_images();
|
|
35569
36173
|
var VIDEO_ASPECT_RATIOS = ["16:9", "9:16"];
|
|
35570
36174
|
var VIDEO_TASKS = ["text_to_video", "image_to_video", "reference_to_video", "edit"];
|
|
36175
|
+
var VIDEO_DELIVERY = ["inline", "uri"];
|
|
35571
36176
|
function registerVideoTools(server, client2) {
|
|
35572
36177
|
server.registerTool(
|
|
35573
36178
|
"gemini_video_generate",
|
|
@@ -35586,6 +36191,8 @@ function registerVideoTools(server, client2) {
|
|
|
35586
36191
|
filename: external_exports.string().optional().describe("Base filename for the output video (extension stripped; default: slugified prompt)"),
|
|
35587
36192
|
output_dir: external_exports.string().optional().describe("Directory to write the video to (default: $GEMINI_OUTPUT_DIR or cwd)"),
|
|
35588
36193
|
model: external_exports.string().regex(/^[\w.-]+$/, 'must be a bare model id (letters, digits, ".", "_", "-")').optional().describe(`Model id override (default: ${DEFAULT_VIDEO_MODEL})`),
|
|
36194
|
+
delivery: external_exports.enum(VIDEO_DELIVERY).optional().describe('How the clip comes back: "uri" (default \u2014 a Files API link the server downloads, no size ceiling) or "inline" (base64, capped ~4MB)'),
|
|
36195
|
+
background: external_exports.boolean().optional().describe("Run the generation on Google's side and poll it, so a killed job can be recovered by gemini_get_result. Trade-off: retrieving a backgrounded interaction is unreliable today (some become permanently unreadable), so the default is off"),
|
|
35589
36196
|
previous_interaction_id: external_exports.string().optional().describe('Interaction id to edit/continue (with task: "edit")'),
|
|
35590
36197
|
continue_last: external_exports.boolean().optional().describe("Continue from the most recent video interaction this server created (explicit previous_interaction_id wins)"),
|
|
35591
36198
|
timeout_ms: timeoutMsSchema,
|
|
@@ -35621,7 +36228,7 @@ function registerVideoTools(server, client2) {
|
|
|
35621
36228
|
images_file_uris: args.images_file_uris,
|
|
35622
36229
|
previous_interaction_id: previousInteractionId
|
|
35623
36230
|
});
|
|
35624
|
-
return client2.session.jobs.dispatch({ toolName: "gemini_video_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
|
|
36231
|
+
return client2.session.jobs.dispatch({ toolName: "gemini_video_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async (ctx) => {
|
|
35625
36232
|
const { inputs, report } = await resolveImageInputs(args, client2);
|
|
35626
36233
|
const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client2.generateVideo({
|
|
35627
36234
|
input: args.prompt,
|
|
@@ -35630,7 +36237,13 @@ function registerVideoTools(server, client2) {
|
|
|
35630
36237
|
aspectRatio: args.aspect_ratio,
|
|
35631
36238
|
task: args.task,
|
|
35632
36239
|
previousInteractionId,
|
|
35633
|
-
timeoutMs: args.timeout_ms
|
|
36240
|
+
timeoutMs: args.timeout_ms,
|
|
36241
|
+
delivery: args.delivery,
|
|
36242
|
+
background: args.background,
|
|
36243
|
+
// Hand the id to the job record the moment it exists: if this
|
|
36244
|
+
// machine stops mid-generation, that id is the only route back to
|
|
36245
|
+
// an output that finishes upstream anyway.
|
|
36246
|
+
onInteractionStarted: ctx.reportInteraction
|
|
35634
36247
|
}));
|
|
35635
36248
|
client2.session.lastVideoInteractionId = r.id;
|
|
35636
36249
|
const meta3 = { model, interaction_id: r.id };
|
|
@@ -35678,6 +36291,7 @@ function registerMusicTools(server, client2) {
|
|
|
35678
36291
|
inline: external_exports.boolean().optional().describe("Return base64 audio inline instead of writing to disk"),
|
|
35679
36292
|
previous_interaction_id: external_exports.string().optional().describe("Interaction id to continue from"),
|
|
35680
36293
|
continue_last: external_exports.boolean().optional().describe("Continue from the most recent music interaction this server created (explicit previous_interaction_id wins)"),
|
|
36294
|
+
background: external_exports.boolean().optional().describe("Run the generation on Google's side and poll it, so a killed job can be recovered by gemini_get_result. Off by default \u2014 see gemini_video_generate"),
|
|
35681
36295
|
timeout_ms: timeoutMsSchema,
|
|
35682
36296
|
idempotency_key: idempotencyKeySchema,
|
|
35683
36297
|
async: asyncSchema,
|
|
@@ -35715,7 +36329,7 @@ function registerMusicTools(server, client2) {
|
|
|
35715
36329
|
images_file_uris: args.images_file_uris,
|
|
35716
36330
|
previous_interaction_id: previousInteractionId
|
|
35717
36331
|
});
|
|
35718
|
-
return client2.session.jobs.dispatch({ toolName: "gemini_music_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
|
|
36332
|
+
return client2.session.jobs.dispatch({ toolName: "gemini_music_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async (ctx) => {
|
|
35719
36333
|
const { inputs, report } = await resolveImageInputs(args, client2);
|
|
35720
36334
|
const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () => client2.generateMusic({
|
|
35721
36335
|
input: args.prompt,
|
|
@@ -35723,7 +36337,10 @@ function registerMusicTools(server, client2) {
|
|
|
35723
36337
|
model: args.model,
|
|
35724
36338
|
audioFormat: args.audio_format,
|
|
35725
36339
|
previousInteractionId,
|
|
35726
|
-
timeoutMs: args.timeout_ms
|
|
36340
|
+
timeoutMs: args.timeout_ms,
|
|
36341
|
+
background: args.background,
|
|
36342
|
+
// See gemini_video_generate: the id is what survives the executor.
|
|
36343
|
+
onInteractionStarted: ctx.reportInteraction
|
|
35727
36344
|
}));
|
|
35728
36345
|
client2.session.lastMusicInteractionId = r.id;
|
|
35729
36346
|
const meta3 = { model, interaction_id: r.id };
|
|
@@ -35744,19 +36361,84 @@ function registerMusicTools(server, client2) {
|
|
|
35744
36361
|
|
|
35745
36362
|
// src/tools/jobs.ts
|
|
35746
36363
|
init_zod();
|
|
36364
|
+
init_dist();
|
|
36365
|
+
init_images();
|
|
35747
36366
|
function registerJobTools(server, client2) {
|
|
35748
36367
|
server.registerTool(
|
|
35749
36368
|
"gemini_get_result",
|
|
35750
36369
|
{
|
|
35751
|
-
description: 'Retrieve
|
|
35752
|
-
|
|
36370
|
+
description: 'Retrieve a generation started with `async: true` or handed off by `max_wait_ms`. Pass the returned `job_id`: while running it reports status "running"; on completion it returns the normal result (image URLs/paths + meta); on failure it raises the recorded error, including the case where the generation was killed before it finished. On the hosted connector job records are stored durably and survive a restart; on a local stdio server they live with the process and expire ~10 min after completion, where the output dir / <image>.json sidecar is the fallback. A killed video/music job started with `background: true` is recovered from its upstream interaction when it finished there.',
|
|
36371
|
+
// NOT read-only any more: recovering a killed job writes the media it
|
|
36372
|
+
// pulls back, exactly as the generation tool would have.
|
|
36373
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
35753
36374
|
inputSchema: {
|
|
35754
|
-
job_id: external_exports.string().min(1).describe("The job_id returned by a generation tool called with async: true")
|
|
36375
|
+
job_id: external_exports.string().min(1).describe("The job_id returned by a generation tool called with async: true"),
|
|
36376
|
+
output_dir: external_exports.string().optional().describe("Where to write media recovered from a killed job (default: $GEMINI_OUTPUT_DIR or cwd)")
|
|
35755
36377
|
}
|
|
35756
36378
|
},
|
|
35757
|
-
async (args) =>
|
|
36379
|
+
async (args) => {
|
|
36380
|
+
try {
|
|
36381
|
+
return await client2.session.jobs.getResult(args.job_id);
|
|
36382
|
+
} catch (err) {
|
|
36383
|
+
const recovered = await recoverFromInteraction(client2, args.job_id, args.output_dir, err);
|
|
36384
|
+
if (recovered) return recovered;
|
|
36385
|
+
throw err;
|
|
36386
|
+
}
|
|
36387
|
+
}
|
|
35758
36388
|
);
|
|
35759
36389
|
}
|
|
36390
|
+
async function recoverFromInteraction(client2, jobId, outputDir, original) {
|
|
36391
|
+
const record2 = await client2.session.jobs.peekRecord(jobId);
|
|
36392
|
+
const interactionId = record2?.interactionId;
|
|
36393
|
+
if (!interactionId || record2?.status !== "failed") return void 0;
|
|
36394
|
+
let found;
|
|
36395
|
+
try {
|
|
36396
|
+
found = await client2.fetchInteractionMedia(interactionId);
|
|
36397
|
+
} catch (probeErr) {
|
|
36398
|
+
if (isInFlight(probeErr)) return stillRunning(jobId, interactionId);
|
|
36399
|
+
throw new McpToolError(
|
|
36400
|
+
`${errorMessage(original)} Its upstream interaction "${interactionId}" could not be read either (${errorMessage(probeErr)}), so the output is not recoverable.`,
|
|
36401
|
+
{ hint: "Any media the lost run did write is still listed by gemini_list_recent_media. Otherwise the generation has to be re-run." }
|
|
36402
|
+
);
|
|
36403
|
+
}
|
|
36404
|
+
const [kind, media] = found.videos.length ? ["video", found.videos] : found.audios.length ? ["audio", found.audios] : found.images.length ? ["image", found.images] : ["image", []];
|
|
36405
|
+
if (media.length === 0) {
|
|
36406
|
+
if (found.status && !TERMINAL_INTERACTION_STATUSES.has(found.status)) return stillRunning(jobId, interactionId);
|
|
36407
|
+
return void 0;
|
|
36408
|
+
}
|
|
36409
|
+
const slug = slugify2(`recovered-${interactionId}`.slice(0, 60));
|
|
36410
|
+
const named = media.map((m, i) => ({
|
|
36411
|
+
media: m,
|
|
36412
|
+
base: media.length > 1 ? `${slug}-${String(i + 1).padStart(2, "0")}` : slug
|
|
36413
|
+
}));
|
|
36414
|
+
const result = await emitMedia(named, kind, { output_dir: outputDir, sink: client2.mediaSink }, {
|
|
36415
|
+
job_id: jobId,
|
|
36416
|
+
recovered_from_interaction: interactionId,
|
|
36417
|
+
note: recoveryNote(record2.error?.message),
|
|
36418
|
+
...found.text ? { text: found.text } : {}
|
|
36419
|
+
});
|
|
36420
|
+
await client2.session.jobs.adoptResult(jobId, record2.toolName, result, interactionId);
|
|
36421
|
+
return result;
|
|
36422
|
+
}
|
|
36423
|
+
function recoveryNote(recordedError) {
|
|
36424
|
+
const cause = /was lost:/.test(recordedError ?? "") ? "The executor that started this job was reclaimed before it could return." : "This job failed here before it could return its output.";
|
|
36425
|
+
return `${cause} The generation had already completed upstream and its output was recovered \u2014 it was not re-run, and you were not billed twice.`;
|
|
36426
|
+
}
|
|
36427
|
+
function stillRunning(jobId, interactionId) {
|
|
36428
|
+
return textResult({
|
|
36429
|
+
job_id: jobId,
|
|
36430
|
+
status: "running",
|
|
36431
|
+
interaction_id: interactionId,
|
|
36432
|
+
note: "The executor that started this job is gone, but the generation is still running upstream. Poll again \u2014 it has not been lost, and re-running it would bill a second time."
|
|
36433
|
+
});
|
|
36434
|
+
}
|
|
36435
|
+
function isInFlight(err) {
|
|
36436
|
+
const status = err?.status;
|
|
36437
|
+
return status === 403 || status === 404;
|
|
36438
|
+
}
|
|
36439
|
+
function errorMessage(err) {
|
|
36440
|
+
return err instanceof Error ? err.message : String(err);
|
|
36441
|
+
}
|
|
35760
36442
|
|
|
35761
36443
|
// src/tools/files.ts
|
|
35762
36444
|
init_zod();
|
|
@@ -35928,6 +36610,43 @@ function registerFileTools(server, client2) {
|
|
|
35928
36610
|
});
|
|
35929
36611
|
}
|
|
35930
36612
|
);
|
|
36613
|
+
server.registerTool(
|
|
36614
|
+
"gemini_list_recent_media",
|
|
36615
|
+
{
|
|
36616
|
+
description: "List images/video/audio this connector recently generated for your account, newest first, with fresh openable URLs. Use it when a result was lost \u2014 a generation whose response never arrived, or an async job whose id came back unknown \u2014 to find what already exists BEFORE re-running (and re-paying for) it. Keywords: lost result, orphaned job, missing image, what did I generate, recover, recent media.",
|
|
36617
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
36618
|
+
inputSchema: {
|
|
36619
|
+
limit: external_exports.number().int().positive().max(100).optional().describe("How many to return (default 20, newest first)"),
|
|
36620
|
+
since_day: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Only media stored on or after this UTC day, e.g. "2026-08-18"')
|
|
36621
|
+
}
|
|
36622
|
+
},
|
|
36623
|
+
async (args) => {
|
|
36624
|
+
const sink = client2.mediaSink;
|
|
36625
|
+
if (!sink?.listRecent) {
|
|
36626
|
+
throw new McpToolError("This server writes generated files to disk \u2014 list the output directory instead.", {
|
|
36627
|
+
hint: "gemini_list_recent_media applies to the hosted connector only."
|
|
36628
|
+
});
|
|
36629
|
+
}
|
|
36630
|
+
const limit = args.limit ?? 20;
|
|
36631
|
+
const page = sink.listRecentPage ? await sink.listRecentPage({ limit, sinceDay: args.since_day }) : { media: await sink.listRecent({ limit, sinceDay: args.since_day }), truncated: false };
|
|
36632
|
+
const found = page.media;
|
|
36633
|
+
return textResult({
|
|
36634
|
+
count: found.length,
|
|
36635
|
+
// Never let a bounded walk pass for the whole picture: concluding an
|
|
36636
|
+
// image is gone is what makes someone pay to generate it twice.
|
|
36637
|
+
...page.truncated ? { truncated: true } : {},
|
|
36638
|
+
media: found.map((m) => ({
|
|
36639
|
+
r2_key: m.key,
|
|
36640
|
+
day: m.day,
|
|
36641
|
+
name: m.name,
|
|
36642
|
+
...m.sizeBytes !== void 0 ? { size_bytes: m.sizeBytes } : {},
|
|
36643
|
+
...m.url ? { url: m.url, curl_hint: `curl -sS -o ${downloadFilename(m.key)} "${m.url}"` } : {},
|
|
36644
|
+
...m.expiresAt ? { expires_at: m.expiresAt } : {}
|
|
36645
|
+
})),
|
|
36646
|
+
hint: found.length ? "Ordered by the day each object was stored (the store keeps no finer timestamp), newest first. Re-sign any expired link with gemini_sign_media." + (page.truncated ? " More media exists than is listed here \u2014 narrow with since_day, or raise limit, before concluding something is missing." : "") : "Nothing stored in that window. Media is swept on a retention schedule, so an older generation may already be gone."
|
|
36647
|
+
});
|
|
36648
|
+
}
|
|
36649
|
+
);
|
|
35931
36650
|
}
|
|
35932
36651
|
server.registerTool(
|
|
35933
36652
|
"gemini_delete_file",
|