@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/client.js
CHANGED
|
@@ -5,8 +5,10 @@ import { createDiskSink, createR2Sink, tenantIdFor } from './storage/media.js';
|
|
|
5
5
|
import { createR2Library } from './library.js';
|
|
6
6
|
import { createUploadUrlMinter } from './upload-url.js';
|
|
7
7
|
import { blobStoreFromEnv } from './blob-store.js';
|
|
8
|
+
import { createBlobJobStore } from './job-store.js';
|
|
8
9
|
import { SessionState } from './session.js';
|
|
9
|
-
import { fetchRemoteImage } from './fetch-image.js';
|
|
10
|
+
import { fetchRemoteImage, readCapped, MAX_REDIRECTS } from './fetch-image.js';
|
|
11
|
+
import { bytesToBase64 } from './bytes.js';
|
|
10
12
|
// NOTE: this module must stay SIDE-EFFECT-FREE at module scope — no I/O, no
|
|
11
13
|
// top-level await, no `import.meta.url`. `src/worker.ts` imports it, so every
|
|
12
14
|
// line here runs during Cloudflare isolate startup, where global-scope I/O is
|
|
@@ -46,6 +48,39 @@ const FILE_MAX_BYTES = 2 * 1024 ** 3;
|
|
|
46
48
|
// in ~1s (verified); longer videos take proportionally longer to process.
|
|
47
49
|
const FILE_POLL_INTERVAL_MS = 2_000;
|
|
48
50
|
const FILE_POLL_MAX_ATTEMPTS = 150;
|
|
51
|
+
// Background execution (verified live 2026-08-22): `background: true` returns
|
|
52
|
+
// `in_progress` immediately and the work continues server-side, polled via
|
|
53
|
+
// `GET /interactions/{id}`. Supported by omni (video) and Lyria (music) and
|
|
54
|
+
// NOT by the image models, which 400 with "does not support background
|
|
55
|
+
// interactions" — so only generateVideo/generateMusic send it.
|
|
56
|
+
//
|
|
57
|
+
// The poll stays IN BAND, bounded by the caller's own timeout: on mcp-host the
|
|
58
|
+
// machine stops whenever no request is in flight, so answering early and
|
|
59
|
+
// polling in the background would race a shutdown it loses. Background buys a
|
|
60
|
+
// short-lived upstream socket and an id that outlives us, not a fire-and-forget.
|
|
61
|
+
const INTERACTION_POLL_INTERVAL_MS = 3_000;
|
|
62
|
+
// Statuses that mean the work is over. Anything else — including no status
|
|
63
|
+
// field at all, which two consecutive live polls returned — means keep waiting.
|
|
64
|
+
export const TERMINAL_INTERACTION_STATUSES = new Set([
|
|
65
|
+
'completed', 'failed', 'cancelled', 'incomplete', 'budget_exceeded',
|
|
66
|
+
]);
|
|
67
|
+
// Generated media comes back as a Files API link on this host and no other. The
|
|
68
|
+
// uri is API-supplied, but it addresses a fetch we make with the API key
|
|
69
|
+
// attached, so it is validated like any other outbound URL in this repo.
|
|
70
|
+
const MEDIA_URI_HOST = 'generativelanguage.googleapis.com';
|
|
71
|
+
// Cap on a single generated-media download. Generous next to what the models
|
|
72
|
+
// actually emit (a 10s 720p clip measured 2.6MB; 10s of 4k is ~60MB) and still
|
|
73
|
+
// bounded, because the bytes are base64'd in memory afterwards — roughly 1.33×
|
|
74
|
+
// again — before the sink ever sees them.
|
|
75
|
+
const MEDIA_DOWNLOAD_MAX_BYTES = 128 * 1024 * 1024;
|
|
76
|
+
/** How an over-cap generated-media download describes itself. */
|
|
77
|
+
const MEDIA_CAP_SUBJECT = {
|
|
78
|
+
noun: 'Generated media',
|
|
79
|
+
hint: 'Ask for a shorter clip or a lower resolution — the file itself is still in the Files API for ~48h if you need it another way.',
|
|
80
|
+
};
|
|
81
|
+
// A well-formed interaction id (`v1_Chd…`). Interpolated into the URL path, so
|
|
82
|
+
// it is guarded the same way the model id and file name are.
|
|
83
|
+
const INTERACTION_ID_RE = /^[\w-]+$/;
|
|
49
84
|
// The interactions store is eventually consistent: a freshly returned id can
|
|
50
85
|
// 404 while the SAME id + key resolves fine *minutes* later (observed live
|
|
51
86
|
// 2026-07-06, verified against a real failing id created moments before the
|
|
@@ -87,17 +122,30 @@ export class ChainedRequest404Error extends McpToolError {
|
|
|
87
122
|
previousInteractionId;
|
|
88
123
|
upstreamMessage;
|
|
89
124
|
retries;
|
|
125
|
+
chainExists;
|
|
90
126
|
constructor(previousInteractionId,
|
|
91
127
|
/** The upstream error text, verbatim (already redacted + truncated). */
|
|
92
128
|
upstreamMessage,
|
|
93
129
|
/** How hard we waited out the store lag before giving up. */
|
|
94
|
-
retries, opts
|
|
130
|
+
retries, opts,
|
|
131
|
+
/**
|
|
132
|
+
* What `GET /interactions/{id}` said afterwards: `false` the interaction is
|
|
133
|
+
* gone (or was never a valid id), `true` it is alive so the 404 was about
|
|
134
|
+
* something else, `undefined` the probe itself could not be completed.
|
|
135
|
+
* Undefined is deliberately distinct from false — "we don't know" must not
|
|
136
|
+
* read as "it's gone".
|
|
137
|
+
*/
|
|
138
|
+
chainExists,
|
|
139
|
+
/** The one sentence the probe earned us; appended to the message. */
|
|
140
|
+
verdict) {
|
|
95
141
|
super(`Gemini returned HTTP 404 for a chained request (previous_interaction_id: "${previousInteractionId}"), retried ${retries.attempts}× over ~${Math.round(retries.waitedMs / 1000)}s for store lag. Upstream said: ${upstreamMessage}. ` +
|
|
96
|
-
|
|
142
|
+
(verdict ??
|
|
143
|
+
'This does not necessarily mean the interaction expired — an unknown model id or an expired files/… uri returns the same generic 404. ') +
|
|
97
144
|
'(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.)', opts);
|
|
98
145
|
this.previousInteractionId = previousInteractionId;
|
|
99
146
|
this.upstreamMessage = upstreamMessage;
|
|
100
147
|
this.retries = retries;
|
|
148
|
+
this.chainExists = chainExists;
|
|
101
149
|
}
|
|
102
150
|
}
|
|
103
151
|
/** `generateContent` part for one reference image (by uri, else inline). */
|
|
@@ -167,6 +215,14 @@ export class GeminiClient {
|
|
|
167
215
|
// A replayed idempotent result must not hand back an expired media URL, so
|
|
168
216
|
// the job registry needs a way to re-sign from the stable r2_key.
|
|
169
217
|
this.session.jobs.resigner = this.mediaSink;
|
|
218
|
+
// Durable job records, where the deployment has somewhere to keep them.
|
|
219
|
+
// Without this the hosted connector loses a job the moment its machine
|
|
220
|
+
// stops — which it does as soon as no request is in flight (job-store.ts).
|
|
221
|
+
this.session.jobs.store = opts.jobStore;
|
|
222
|
+
// And on that runtime, `async: true` is served by HOLDING the request:
|
|
223
|
+
// releasing it is what lets the executor be reclaimed mid-generation.
|
|
224
|
+
if (opts.jobStore)
|
|
225
|
+
this.session.jobs.asyncWaitFallbackMs = HOSTED_ASYNC_WAIT_MS;
|
|
170
226
|
this.explicitApiKey = opts.apiKey?.trim() || undefined;
|
|
171
227
|
// Never store the global `fetch` raw on the instance: it would be invoked
|
|
172
228
|
// as `this.fetchImpl(...)`, handing native fetch this GeminiClient as its
|
|
@@ -597,35 +653,69 @@ export class GeminiClient {
|
|
|
597
653
|
hint: 'The request may have been blocked by safety filters — try rephrasing the prompt.',
|
|
598
654
|
});
|
|
599
655
|
}
|
|
600
|
-
return { id: data.id, images, text, grounding };
|
|
656
|
+
return { id: data.id, images: await this.downloadAll(images), text, grounding };
|
|
601
657
|
}
|
|
602
658
|
/**
|
|
603
|
-
* Generate video via the omni model (Interactions API
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
659
|
+
* Generate video via the omni model (Interactions API). Same plumbing as
|
|
660
|
+
* {@link interact} — text/image input parts, a `video` response_format,
|
|
661
|
+
* optional `video_config.task`, and `previous_interaction_id` for edits.
|
|
662
|
+
*
|
|
663
|
+
* Delivered by uri unless the caller says otherwise, so the clip is not
|
|
664
|
+
* capped by the ~4MB inline limit; the bytes are downloaded here either way.
|
|
665
|
+
* Verified live 2026-08-22 end to end (a real MP4 off the wire), which is
|
|
666
|
+
* why this path no longer carries the "docs-derived" caveat that music
|
|
667
|
+
* still does.
|
|
607
668
|
*/
|
|
608
669
|
async generateVideo(opts) {
|
|
609
670
|
const model = opts.model ?? DEFAULT_VIDEO_MODEL;
|
|
610
671
|
const inputParts = [{ type: 'text', text: opts.input }];
|
|
611
672
|
for (const img of opts.images ?? [])
|
|
612
673
|
inputParts.push(interactPart(img));
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
responseFormat
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
body
|
|
621
|
-
|
|
674
|
+
const delivery = opts.delivery ?? 'uri';
|
|
675
|
+
const buildBody = (withDelivery) => {
|
|
676
|
+
const responseFormat = { type: 'video' };
|
|
677
|
+
if (withDelivery)
|
|
678
|
+
responseFormat.delivery = delivery;
|
|
679
|
+
if (opts.aspectRatio)
|
|
680
|
+
responseFormat.aspect_ratio = opts.aspectRatio;
|
|
681
|
+
const body = { model, input: inputParts, response_format: responseFormat };
|
|
682
|
+
if (opts.task)
|
|
683
|
+
body.generation_config = { video_config: { task: opts.task } };
|
|
684
|
+
if (opts.previousInteractionId !== undefined)
|
|
685
|
+
body.previous_interaction_id = opts.previousInteractionId;
|
|
686
|
+
// OPT-IN, not the default — see the background note on MusicOpts and
|
|
687
|
+
// docs/GEMINI-API.md: omni accepts `background: true`, but polling the
|
|
688
|
+
// resulting interaction is unreliable enough to lose a paid generation.
|
|
689
|
+
if (opts.background === true)
|
|
690
|
+
body.background = true;
|
|
691
|
+
return body;
|
|
692
|
+
};
|
|
693
|
+
// The fallback wraps the POST ALONE. `delivery` is model-implemented, not
|
|
694
|
+
// just schema-valid: omni takes it, another video model may answer
|
|
695
|
+
// "<Media> delivery mode is not supported." A 400 on the POST generated
|
|
696
|
+
// nothing, so re-issuing without the field is free — but a 400 arriving
|
|
697
|
+
// any later (from a background poll, say) is a 400 about a generation that
|
|
698
|
+
// is already running, and re-issuing THAT would pay for the clip twice.
|
|
699
|
+
const timeoutMs = resolveTimeoutMs(opts.timeoutMs, undefined);
|
|
700
|
+
let body = buildBody(true);
|
|
701
|
+
let started;
|
|
702
|
+
try {
|
|
703
|
+
started = await this.startInteraction(body, opts, timeoutMs);
|
|
704
|
+
}
|
|
705
|
+
catch (err) {
|
|
706
|
+
if (!isDeliveryUnsupported(err))
|
|
707
|
+
throw err;
|
|
708
|
+
body = buildBody(false);
|
|
709
|
+
started = await this.startInteraction(body, opts, timeoutMs);
|
|
710
|
+
}
|
|
711
|
+
const data = await this.settleInteraction(started, body, timeoutMs);
|
|
622
712
|
const { videos, text } = this.extractInteraction(data);
|
|
623
713
|
if (videos.length === 0) {
|
|
624
714
|
throw new McpToolError('Gemini returned no video', {
|
|
625
|
-
hint: 'The request may have been blocked by a safety filter
|
|
715
|
+
hint: 'The request may have been blocked by a safety filter — try a shorter/simpler prompt or a different aspect ratio.',
|
|
626
716
|
});
|
|
627
717
|
}
|
|
628
|
-
return { id: data.id, videos, text };
|
|
718
|
+
return { id: data.id, videos: await this.downloadAll(videos), text };
|
|
629
719
|
}
|
|
630
720
|
/**
|
|
631
721
|
* Generate music via a Lyria model (Interactions API, inline audio). Reuses
|
|
@@ -642,6 +732,9 @@ export class GeminiClient {
|
|
|
642
732
|
const body = { model, input: inputParts, response_format: responseFormat };
|
|
643
733
|
if (opts.previousInteractionId !== undefined)
|
|
644
734
|
body.previous_interaction_id = opts.previousInteractionId;
|
|
735
|
+
// Opt-in for the same reason video's is (see VideoOpts.background).
|
|
736
|
+
if (opts.background === true)
|
|
737
|
+
body.background = true;
|
|
645
738
|
const data = await this.postInteraction(body, opts);
|
|
646
739
|
const { audios, text } = this.extractInteraction(data);
|
|
647
740
|
if (audios.length === 0) {
|
|
@@ -649,11 +742,44 @@ export class GeminiClient {
|
|
|
649
742
|
hint: 'The request may have been blocked by a safety filter — try rephrasing the prompt.',
|
|
650
743
|
});
|
|
651
744
|
}
|
|
652
|
-
return { id: data.id, audios, text };
|
|
745
|
+
return { id: data.id, audios: await this.downloadAll(audios), text };
|
|
653
746
|
}
|
|
654
|
-
/**
|
|
747
|
+
/**
|
|
748
|
+
* POST `/interactions`, wait out the store lag on a chained 404, and — when
|
|
749
|
+
* the request was backgrounded — poll until the interaction reaches a
|
|
750
|
+
* terminal state. Shared by all media.
|
|
751
|
+
*/
|
|
655
752
|
async postInteraction(body, opts) {
|
|
656
|
-
const
|
|
753
|
+
const timeoutMs = resolveTimeoutMs(opts.timeoutMs, opts.imageSize);
|
|
754
|
+
return await this.settleInteraction(await this.startInteraction(body, opts, timeoutMs), body, timeoutMs);
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* The POST half: issue the request and announce the id.
|
|
758
|
+
*
|
|
759
|
+
* Split from the wait so a caller can retry the POST — and ONLY the POST —
|
|
760
|
+
* without the retry predicate ever seeing an error from after the generation
|
|
761
|
+
* started. Past this point the work is running and billable.
|
|
762
|
+
*/
|
|
763
|
+
async startInteraction(body, opts, timeoutMs) {
|
|
764
|
+
const started = await this.postInteractionOnce(body, opts, timeoutMs);
|
|
765
|
+
// Announce the id before waiting on anything: a caller that persists it can
|
|
766
|
+
// recover the output even if this process never sees the result.
|
|
767
|
+
if (started.id)
|
|
768
|
+
opts.onInteractionStarted?.(started.id);
|
|
769
|
+
return started;
|
|
770
|
+
}
|
|
771
|
+
/** The wait half: poll a backgrounded request to a terminal state. */
|
|
772
|
+
async settleInteraction(started, body, timeoutMs) {
|
|
773
|
+
// Polling only happens for a request we backgrounded. A synchronous POST
|
|
774
|
+
// already carries its output, and some responses legitimately arrive with
|
|
775
|
+
// no `status` at all — polling those would hang on a finished generation.
|
|
776
|
+
if (body.background !== true)
|
|
777
|
+
return started;
|
|
778
|
+
return await this.awaitInteraction(started, timeoutMs);
|
|
779
|
+
}
|
|
780
|
+
/** The POST itself, with the chained-404 retry + diagnosis. */
|
|
781
|
+
async postInteractionOnce(body, opts, timeoutMs) {
|
|
782
|
+
const { interactionsApi } = this.apisFor(timeoutMs);
|
|
657
783
|
const budgetMs = resolveChainRetryBudgetMs();
|
|
658
784
|
let waitedMs = 0;
|
|
659
785
|
for (let attempt = 0;; attempt++) {
|
|
@@ -666,9 +792,7 @@ export class GeminiClient {
|
|
|
666
792
|
//
|
|
667
793
|
// What it is NOT is a diagnosis. We only know the status; the body is
|
|
668
794
|
// generic ("Requested entity was not found.") and covers a bad model id
|
|
669
|
-
// and an expired files/… uri just as well as a dead interaction.
|
|
670
|
-
// error carries the upstream text and says what it doesn't know — the
|
|
671
|
-
// caller (tools/interact.ts) probes to find out which it was.
|
|
795
|
+
// and an expired files/… uri just as well as a dead interaction.
|
|
672
796
|
if (!(opts.previousInteractionId && err instanceof ApiError && err.status === 404))
|
|
673
797
|
throw err;
|
|
674
798
|
// Exponential backoff until the budget is spent. Sized against the
|
|
@@ -681,13 +805,198 @@ export class GeminiClient {
|
|
|
681
805
|
await this.sleep(delay);
|
|
682
806
|
continue;
|
|
683
807
|
}
|
|
808
|
+
// The budget is spent, so now ASK about the one entity we can ask
|
|
809
|
+
// about. The probe is free (a 404'd POST generated nothing, and a GET
|
|
810
|
+
// generates nothing), and it turns the guess into a fact — or into an
|
|
811
|
+
// explicit "unknown", which is not the same as "gone".
|
|
812
|
+
const { chainExists, verdict } = await this.diagnoseChain(opts.previousInteractionId);
|
|
684
813
|
throw new ChainedRequest404Error(opts.previousInteractionId, err.message, { attempts: attempt, waitedMs }, {
|
|
685
|
-
hint:
|
|
814
|
+
hint: chainExists === true
|
|
815
|
+
? 'The chain is alive, so re-issuing without previous_interaction_id would re-bill and still fail. Check the model id and any files/… uri instead.'
|
|
816
|
+
: '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 — check the model id and any files/… uri (they expire ~48h). gemini_interact does this probe automatically.',
|
|
686
817
|
cause: err,
|
|
687
|
-
});
|
|
818
|
+
}, chainExists, verdict);
|
|
688
819
|
}
|
|
689
820
|
}
|
|
690
821
|
}
|
|
822
|
+
/**
|
|
823
|
+
* Ask the API whether the chained interaction is actually gone.
|
|
824
|
+
*
|
|
825
|
+
* Three answers, three remediations — and a fourth state that matters just as
|
|
826
|
+
* much: if the probe itself cannot be completed we return `undefined`, so the
|
|
827
|
+
* caller says "unknown" rather than inventing "gone".
|
|
828
|
+
*/
|
|
829
|
+
async diagnoseChain(id) {
|
|
830
|
+
try {
|
|
831
|
+
await this.getInteraction(id);
|
|
832
|
+
return {
|
|
833
|
+
chainExists: true,
|
|
834
|
+
verdict: `That interaction still exists (GET /interactions/${id} returned it), so the chain is NOT the cause — ` +
|
|
835
|
+
'a generic 404 also covers an unknown model id and an expired files/… uri (~48h). Check those. ',
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
catch (err) {
|
|
839
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
840
|
+
return {
|
|
841
|
+
chainExists: false,
|
|
842
|
+
verdict: `That interaction no longer exists (GET /interactions/${id} returned 404 too) — it expired or was deleted, so re-anchor on the image it produced. ` +
|
|
843
|
+
'That does not necessarily mean it was the cause, though: an unknown model id or an expired files/… uri returns the same generic 404, so read the upstream text above. ',
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
if (err instanceof ApiError && err.status === 400) {
|
|
847
|
+
return {
|
|
848
|
+
chainExists: false,
|
|
849
|
+
verdict: `"${id}" is not a valid interaction id — the API rejected the name as malformed, so it was never a live chain. Check how the id was captured. `,
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
return {};
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Poll a backgrounded interaction to a terminal state.
|
|
857
|
+
*
|
|
858
|
+
* ⚠️ **A poll of work that is still in flight answers `403
|
|
859
|
+
* permission_denied`** — "There was a problem processing your request. You
|
|
860
|
+
* will not be charged." (verified live 2026-08-22, against a paid key that
|
|
861
|
+
* GETs the very same interaction fine once it completes). It is a transient
|
|
862
|
+
* state dressed as an authorization failure, and taking it at face value
|
|
863
|
+
* fails every backgrounded generation a few seconds in. A 404 is transient
|
|
864
|
+
* here too — the same store lag the chained POST retries.
|
|
865
|
+
*
|
|
866
|
+
* Neither is distinguishable from the real thing, so the caller's timeout is
|
|
867
|
+
* what bounds it, and the last poll error rides along in the timeout message
|
|
868
|
+
* so a genuine permission problem is not invisible.
|
|
869
|
+
*
|
|
870
|
+
* A body with no `status` field is likewise treated as "still running"
|
|
871
|
+
* unless a `model_output` step is already present.
|
|
872
|
+
*/
|
|
873
|
+
async awaitInteraction(started, budgetMs) {
|
|
874
|
+
let current = started;
|
|
875
|
+
let waitedMs = 0;
|
|
876
|
+
let lastPollError;
|
|
877
|
+
for (;;) {
|
|
878
|
+
const status = current.status;
|
|
879
|
+
// Absent status means "still running" ONLY while nothing has been
|
|
880
|
+
// produced. A body carrying a model_output step is finished whatever it
|
|
881
|
+
// says about status — polling that forever would hang on a done job.
|
|
882
|
+
if (!status && (current.steps ?? []).some((step) => step.type === 'model_output'))
|
|
883
|
+
return current;
|
|
884
|
+
if (status && TERMINAL_INTERACTION_STATUSES.has(status)) {
|
|
885
|
+
if (status === 'completed')
|
|
886
|
+
return current;
|
|
887
|
+
throw new McpToolError(`${SERVICE} interaction ${current.id} ${status} (background execution).`, { hint: 'A safety filter or an unsupported prompt is the usual cause — rephrase and retry. The interaction id above can be inspected while it is retained.' });
|
|
888
|
+
}
|
|
889
|
+
if (waitedMs + INTERACTION_POLL_INTERVAL_MS > budgetMs) {
|
|
890
|
+
// The generation did NOT stop when we did: it is still running and
|
|
891
|
+
// still billable upstream, so an error that drops the id throws the
|
|
892
|
+
// work away.
|
|
893
|
+
throw new McpToolError(`${SERVICE} interaction ${current.id} is still running after ${Math.round(waitedMs / 1000)}s. It continues on Google's side — the work is not lost.` +
|
|
894
|
+
(lastPollError ? ` Last poll said: ${lastPollError}` : ''), { hint: 'Raise timeout_ms (or max_wait_ms) and retry, or keep the interaction id: the generation completes upstream regardless of this request.' });
|
|
895
|
+
}
|
|
896
|
+
await this.sleep(INTERACTION_POLL_INTERVAL_MS);
|
|
897
|
+
waitedMs += INTERACTION_POLL_INTERVAL_MS;
|
|
898
|
+
try {
|
|
899
|
+
current = await this.getInteraction(current.id);
|
|
900
|
+
lastPollError = undefined;
|
|
901
|
+
}
|
|
902
|
+
catch (err) {
|
|
903
|
+
if (!isTransientPoll(err))
|
|
904
|
+
throw err;
|
|
905
|
+
lastPollError = err instanceof Error ? err.message : String(err);
|
|
906
|
+
// Keep the last known interaction (its id is all the next poll needs).
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Fetch one interaction. Backs the background poll and the chained-404
|
|
912
|
+
* diagnosis; also the honest answer to "is that id still alive?".
|
|
913
|
+
*/
|
|
914
|
+
async getInteraction(id) {
|
|
915
|
+
if (!INTERACTION_ID_RE.test(id)) {
|
|
916
|
+
throw new McpToolError(`Not a valid interaction id: "${id}"`, {
|
|
917
|
+
hint: 'An interaction id is the opaque `id` from a previous response (e.g. v1_Chd…), with no slashes or query string.',
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
const { interactionsApi } = this.apisFor(resolveTimeoutMs(undefined));
|
|
921
|
+
return await interactionsApi.fetchJson('GET', `/interactions/${id}`);
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Fetch a stored interaction and resolve its media to bytes.
|
|
925
|
+
*
|
|
926
|
+
* The recovery read: a generation whose executor died is still retrievable
|
|
927
|
+
* by id for as long as the interaction is retained (55 days paid, 1 day
|
|
928
|
+
* free). Returns the status alongside the media so a caller can tell "still
|
|
929
|
+
* running" from "finished with nothing".
|
|
930
|
+
*/
|
|
931
|
+
async fetchInteractionMedia(id) {
|
|
932
|
+
const data = await this.getInteraction(id);
|
|
933
|
+
const { images, videos, audios, text } = this.extractInteraction(data);
|
|
934
|
+
return {
|
|
935
|
+
status: data.status,
|
|
936
|
+
images: await this.downloadAll(images),
|
|
937
|
+
videos: await this.downloadAll(videos),
|
|
938
|
+
audios: await this.downloadAll(audios),
|
|
939
|
+
text,
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Resolve one extracted medium to bytes.
|
|
944
|
+
*
|
|
945
|
+
* `delivery: 'uri'` hands back a Files API link, and that link **needs the
|
|
946
|
+
* api key** (verified: 403 without it), so it is not something a caller could
|
|
947
|
+
* use — the bytes have to arrive here exactly as inline delivery would. The
|
|
948
|
+
* uri is API-supplied but addresses a request we make with credentials
|
|
949
|
+
* attached, so the host is checked before it is fetched.
|
|
950
|
+
*/
|
|
951
|
+
async downloadMedia(media) {
|
|
952
|
+
if (media.base64)
|
|
953
|
+
return { base64: media.base64, mimeType: media.mimeType };
|
|
954
|
+
const requested = media.uri ?? '';
|
|
955
|
+
// Local alias so the calls below carry NO receiver (see uploadToFilesApi).
|
|
956
|
+
const doFetch = this.fetchImpl;
|
|
957
|
+
const key = this.requireKey();
|
|
958
|
+
let current = requested;
|
|
959
|
+
for (let hop = 0;; hop++) {
|
|
960
|
+
// EVERY hop is revalidated, not just the first. This request carries the
|
|
961
|
+
// API key, so a redirect — which the response, not us, controls — must
|
|
962
|
+
// never be able to walk that credential onto another host. Same rule
|
|
963
|
+
// fetch-image.ts enforces, and stricter: there is exactly one host
|
|
964
|
+
// generated media is ever served from.
|
|
965
|
+
const url = assertMediaHost(current, requested);
|
|
966
|
+
const res = await doFetch(url.toString(), {
|
|
967
|
+
redirect: 'manual',
|
|
968
|
+
headers: { 'x-goog-api-key': key },
|
|
969
|
+
});
|
|
970
|
+
if (res.status >= 300 && res.status < 400) {
|
|
971
|
+
const location = res.headers?.get('location');
|
|
972
|
+
if (!location) {
|
|
973
|
+
throw new McpToolError(`${SERVICE} media download returned HTTP ${res.status} with no Location header for ${current}`);
|
|
974
|
+
}
|
|
975
|
+
if (hop >= MAX_REDIRECTS) {
|
|
976
|
+
throw new McpToolError(`${SERVICE} media download for ${requested} exceeded ${MAX_REDIRECTS} redirects (last hop: ${location}).`);
|
|
977
|
+
}
|
|
978
|
+
current = new URL(location, url).toString();
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
if (!res.ok) {
|
|
982
|
+
throw new McpToolError(`${SERVICE} media download failed (HTTP ${res.status}) for ${current}`, { hint: 'Generated files live in the Files API for ~48h and the download requires the api key that created them.' });
|
|
983
|
+
}
|
|
984
|
+
// Capped while streaming rather than buffered whole: a generated clip is
|
|
985
|
+
// small, but `arrayBuffer()` on a surprise is an unbounded allocation.
|
|
986
|
+
const bytes = await readCapped(res, MEDIA_DOWNLOAD_MAX_BYTES, current, requested, MEDIA_CAP_SUBJECT);
|
|
987
|
+
return {
|
|
988
|
+
base64: bytesToBase64(bytes),
|
|
989
|
+
mimeType: media.mimeType || res.headers?.get('content-type') || 'application/octet-stream',
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
/** {@link downloadMedia} over a list, preserving order. */
|
|
994
|
+
async downloadAll(media) {
|
|
995
|
+
const out = [];
|
|
996
|
+
for (const m of media)
|
|
997
|
+
out.push(await this.downloadMedia(m));
|
|
998
|
+
return out;
|
|
999
|
+
}
|
|
691
1000
|
/**
|
|
692
1001
|
* Collect `model_output` media (image/video/audio) + text + grounding from an
|
|
693
1002
|
* interactions response. Only the `model_output` step is surfaced — `thought`
|
|
@@ -724,16 +1033,24 @@ export class GeminiClient {
|
|
|
724
1033
|
for (const parts of [step.content ?? [], step.summary ?? []]) {
|
|
725
1034
|
for (const part of parts) {
|
|
726
1035
|
const mime = part.mime_type ?? part.mimeType;
|
|
727
|
-
|
|
1036
|
+
// Branch on what the part SAYS IT IS, never on which fields it
|
|
1037
|
+
// happens to carry: a text part may also carry a uri (grounded
|
|
1038
|
+
// responses annotate them), and keying off `uri` would file it as
|
|
1039
|
+
// media and drop its text on the floor.
|
|
1040
|
+
if (part.type === 'text') {
|
|
1041
|
+
if (part.text?.trim())
|
|
1042
|
+
textParts.push(part.text);
|
|
1043
|
+
}
|
|
1044
|
+
else if (part.data || part.uri) {
|
|
1045
|
+
// `delivery: 'uri'` replaces `data` with a Files API link — the part
|
|
1046
|
+
// carries one or the other, never both. downloadMedia() resolves it.
|
|
1047
|
+
const carrier = part.data ? { base64: part.data } : { uri: part.uri };
|
|
728
1048
|
if (part.type === 'image')
|
|
729
|
-
images.push({
|
|
1049
|
+
images.push({ ...carrier, mimeType: mime ?? 'image/jpeg' });
|
|
730
1050
|
else if (part.type === 'video')
|
|
731
|
-
videos.push({
|
|
1051
|
+
videos.push({ ...carrier, mimeType: mime ?? 'video/mp4' });
|
|
732
1052
|
else if (part.type === 'audio')
|
|
733
|
-
audios.push({
|
|
734
|
-
}
|
|
735
|
-
else if (part.type === 'text' && part.text?.trim()) {
|
|
736
|
-
textParts.push(part.text);
|
|
1053
|
+
audios.push({ ...carrier, mimeType: mime ?? 'audio/mpeg' });
|
|
737
1054
|
}
|
|
738
1055
|
}
|
|
739
1056
|
}
|
|
@@ -749,6 +1066,46 @@ export class GeminiClient {
|
|
|
749
1066
|
return { images, videos, audios, text: textParts.join('\n') || undefined, grounding };
|
|
750
1067
|
}
|
|
751
1068
|
}
|
|
1069
|
+
/**
|
|
1070
|
+
* A 400 that means "this model does not implement response_format.delivery".
|
|
1071
|
+
* Verified live 2026-08-22: image and audio answer "<Media> delivery mode is
|
|
1072
|
+
* not supported."; a value outside the enum answers "…not supported for
|
|
1073
|
+
* 'response_format.delivery'". Both are cheap to recover from — the request
|
|
1074
|
+
* never reached generation.
|
|
1075
|
+
*/
|
|
1076
|
+
/**
|
|
1077
|
+
* Is this poll failure the API being unhelpful about work in progress?
|
|
1078
|
+
*
|
|
1079
|
+
* `403 permission_denied` is what a GET of an in-flight background interaction
|
|
1080
|
+
* returns (verified live), and `404` is the same eventual-consistency lag the
|
|
1081
|
+
* chained POST already waits out. Both resolve on their own; a real 403 simply
|
|
1082
|
+
* runs out the caller's clock and is reported with its text.
|
|
1083
|
+
*/
|
|
1084
|
+
/**
|
|
1085
|
+
* One hop of a generated-media download, checked before it is fetched.
|
|
1086
|
+
* Generated media is served from exactly one host over https; anything else is
|
|
1087
|
+
* either a change we need to notice or a redirect trying to collect our key.
|
|
1088
|
+
*/
|
|
1089
|
+
function assertMediaHost(candidate, requested) {
|
|
1090
|
+
let parsed;
|
|
1091
|
+
try {
|
|
1092
|
+
parsed = new URL(candidate);
|
|
1093
|
+
}
|
|
1094
|
+
catch {
|
|
1095
|
+
throw new McpToolError(`${SERVICE} returned a media reference that is not a URL: ${candidate || '(empty)'}`);
|
|
1096
|
+
}
|
|
1097
|
+
if (parsed.protocol !== 'https:' || parsed.hostname !== MEDIA_URI_HOST) {
|
|
1098
|
+
throw new McpToolError(`${SERVICE} media download was pointed at an unexpected host (${parsed.hostname || candidate}); expected ${MEDIA_URI_HOST}` +
|
|
1099
|
+
(candidate === requested ? '' : ` (redirected from ${requested})`), { hint: 'Generated media is served from the Files API on that host only. Report this if it reproduces — it should not happen.' });
|
|
1100
|
+
}
|
|
1101
|
+
return parsed;
|
|
1102
|
+
}
|
|
1103
|
+
function isTransientPoll(err) {
|
|
1104
|
+
return err instanceof ApiError && (err.status === 403 || err.status === 404);
|
|
1105
|
+
}
|
|
1106
|
+
function isDeliveryUnsupported(err) {
|
|
1107
|
+
return err instanceof ApiError && err.status === 400 && /delivery/i.test(err.message);
|
|
1108
|
+
}
|
|
752
1109
|
/**
|
|
753
1110
|
* Everything that needs somewhere to put bytes, when the host offers a blob
|
|
754
1111
|
* store (`MCP_BLOB_BASE_URL` / `MCP_BLOB_SIGNING_KEY`, injected by mcp-host).
|
|
@@ -797,8 +1154,24 @@ function hostedStorage() {
|
|
|
797
1154
|
}),
|
|
798
1155
|
library: createR2Library(blob.bucket, { tenant }),
|
|
799
1156
|
uploadUrls: createUploadUrlMinter({ links: blob.links, sign: blob.signWrite, tenant }),
|
|
1157
|
+
// Job records go in the same store as the media, for the same reason: this
|
|
1158
|
+
// runtime's machine stops whenever no request is in flight, taking the
|
|
1159
|
+
// child — and an in-memory registry — with it (src/job-store.ts).
|
|
1160
|
+
jobStore: createBlobJobStore(blob.bucket, { tenant }),
|
|
800
1161
|
};
|
|
801
1162
|
}
|
|
1163
|
+
/**
|
|
1164
|
+
* How long a hosted `async: true` call holds the request open before handing
|
|
1165
|
+
* back a job id.
|
|
1166
|
+
*
|
|
1167
|
+
* Not a tuning knob so much as the shape of the platform: holding the request
|
|
1168
|
+
* is what keeps the executor alive, so this is "how long we are willing to keep
|
|
1169
|
+
* the machine up for one generation". Four minutes is the value observed to
|
|
1170
|
+
* work in practice against claude.ai, and comfortably covers a Pro/4K image or
|
|
1171
|
+
* a modest set; anything slower hands back a durable job id rather than
|
|
1172
|
+
* silently dying, which is the behaviour this whole change is for.
|
|
1173
|
+
*/
|
|
1174
|
+
const HOSTED_ASYNC_WAIT_MS = 240_000;
|
|
802
1175
|
/**
|
|
803
1176
|
* How long a freshly-minted media link lives.
|
|
804
1177
|
*
|
package/dist/fetch-image.js
CHANGED
|
@@ -37,7 +37,7 @@ export const IMAGE_URL_MAX_BYTES = 15 * 1024 * 1024;
|
|
|
37
37
|
*/
|
|
38
38
|
export const IMAGE_INLINE_MAX_BYTES = 6 * 1024 * 1024;
|
|
39
39
|
/** Redirect hops followed before giving up. */
|
|
40
|
-
const MAX_REDIRECTS = 5;
|
|
40
|
+
export const MAX_REDIRECTS = 5;
|
|
41
41
|
/**
|
|
42
42
|
* Per-hop abort budget. Matches the client's default upstream timeout so a URL
|
|
43
43
|
* fetch is bounded like every other network call this server makes; callers
|
|
@@ -235,10 +235,10 @@ function describeUrl(current, requested) {
|
|
|
235
235
|
return current === requested ? current : `${current} (redirected from ${requested})`;
|
|
236
236
|
}
|
|
237
237
|
/** Read a response body, aborting as soon as it exceeds `maxBytes`. */
|
|
238
|
-
async function readCapped(res, maxBytes, url, requested) {
|
|
238
|
+
export async function readCapped(res, maxBytes, url, requested, subject) {
|
|
239
239
|
const declared = Number(res.headers.get('content-length'));
|
|
240
240
|
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
241
|
-
throw tooBig(url, requested, declared, maxBytes);
|
|
241
|
+
throw tooBig(url, requested, declared, maxBytes, false, subject);
|
|
242
242
|
}
|
|
243
243
|
const reader = res.body?.getReader();
|
|
244
244
|
if (!reader) {
|
|
@@ -246,7 +246,7 @@ async function readCapped(res, maxBytes, url, requested) {
|
|
|
246
246
|
// then apply the same cap. Correct, just less eager.
|
|
247
247
|
const buf = new Uint8Array(await res.arrayBuffer());
|
|
248
248
|
if (buf.byteLength > maxBytes)
|
|
249
|
-
throw tooBig(url, requested, buf.byteLength, maxBytes);
|
|
249
|
+
throw tooBig(url, requested, buf.byteLength, maxBytes, false, subject);
|
|
250
250
|
return buf;
|
|
251
251
|
}
|
|
252
252
|
const chunks = [];
|
|
@@ -261,7 +261,7 @@ async function readCapped(res, maxBytes, url, requested) {
|
|
|
261
261
|
total += value.byteLength;
|
|
262
262
|
if (total > maxBytes) {
|
|
263
263
|
await reader.cancel().catch(() => { });
|
|
264
|
-
throw tooBig(url, requested, total, maxBytes, true);
|
|
264
|
+
throw tooBig(url, requested, total, maxBytes, true, subject);
|
|
265
265
|
}
|
|
266
266
|
chunks.push(value);
|
|
267
267
|
}
|
|
@@ -287,9 +287,21 @@ function fetchFailure(err, current, requested, timeoutMs) {
|
|
|
287
287
|
}
|
|
288
288
|
return new McpToolError(`Could not fetch ${describeUrl(current, requested)}: ${err instanceof Error ? err.message : String(err)}`, { hint: 'Check the URL is reachable from the server and serves the image without authentication.' });
|
|
289
289
|
}
|
|
290
|
-
|
|
291
|
-
|
|
290
|
+
/**
|
|
291
|
+
* `subject` names what was too big and how to act on it. It defaults to the
|
|
292
|
+
* `images_url` wording because that is where the cap started, but the reader is
|
|
293
|
+
* shared now — a generated-video download reusing it must not tell the caller
|
|
294
|
+
* to resize an image.
|
|
295
|
+
*/
|
|
296
|
+
function tooBig(url, requested, size, max, partial = false, subject) {
|
|
297
|
+
const what = subject ?? IMAGE_CAP_SUBJECT;
|
|
298
|
+
return new McpToolError(`${what.noun} at ${describeUrl(url, requested)} is ${partial ? 'over ' : ''}${formatMb(size)}, above the ${formatMb(max)} limit${what.limitFor ? ` for ${what.limitFor}` : ''}.`, { hint: what.hint });
|
|
292
299
|
}
|
|
300
|
+
const IMAGE_CAP_SUBJECT = {
|
|
301
|
+
noun: 'Image',
|
|
302
|
+
limitFor: 'images_url',
|
|
303
|
+
hint: 'Resize or re-encode the image, or upload it yourself with gemini_upload_file and pass the returned file_uri.',
|
|
304
|
+
};
|
|
293
305
|
/**
|
|
294
306
|
* Fetch one image URL server-side, validating protocol, destination, response
|
|
295
307
|
* status, content type and size. Every failure names the offending URL in the
|