@chrischall/gemini-mcp 1.7.0 → 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 +474 -70
- package/dist/client.js +382 -34
- package/dist/fetch-image.js +19 -7
- package/dist/jobs.js +71 -2
- package/dist/tools/interact.js +9 -0
- package/dist/tools/jobs.js +128 -3
- package/dist/tools/music.js +5 -1
- 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
|
@@ -7,7 +7,8 @@ import { createUploadUrlMinter } from './upload-url.js';
|
|
|
7
7
|
import { blobStoreFromEnv } from './blob-store.js';
|
|
8
8
|
import { createBlobJobStore } from './job-store.js';
|
|
9
9
|
import { SessionState } from './session.js';
|
|
10
|
-
import { fetchRemoteImage } from './fetch-image.js';
|
|
10
|
+
import { fetchRemoteImage, readCapped, MAX_REDIRECTS } from './fetch-image.js';
|
|
11
|
+
import { bytesToBase64 } from './bytes.js';
|
|
11
12
|
// NOTE: this module must stay SIDE-EFFECT-FREE at module scope — no I/O, no
|
|
12
13
|
// top-level await, no `import.meta.url`. `src/worker.ts` imports it, so every
|
|
13
14
|
// line here runs during Cloudflare isolate startup, where global-scope I/O is
|
|
@@ -47,6 +48,39 @@ const FILE_MAX_BYTES = 2 * 1024 ** 3;
|
|
|
47
48
|
// in ~1s (verified); longer videos take proportionally longer to process.
|
|
48
49
|
const FILE_POLL_INTERVAL_MS = 2_000;
|
|
49
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-]+$/;
|
|
50
84
|
// The interactions store is eventually consistent: a freshly returned id can
|
|
51
85
|
// 404 while the SAME id + key resolves fine *minutes* later (observed live
|
|
52
86
|
// 2026-07-06, verified against a real failing id created moments before the
|
|
@@ -88,17 +122,30 @@ export class ChainedRequest404Error extends McpToolError {
|
|
|
88
122
|
previousInteractionId;
|
|
89
123
|
upstreamMessage;
|
|
90
124
|
retries;
|
|
125
|
+
chainExists;
|
|
91
126
|
constructor(previousInteractionId,
|
|
92
127
|
/** The upstream error text, verbatim (already redacted + truncated). */
|
|
93
128
|
upstreamMessage,
|
|
94
129
|
/** How hard we waited out the store lag before giving up. */
|
|
95
|
-
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) {
|
|
96
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}. ` +
|
|
97
|
-
|
|
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. ') +
|
|
98
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);
|
|
99
145
|
this.previousInteractionId = previousInteractionId;
|
|
100
146
|
this.upstreamMessage = upstreamMessage;
|
|
101
147
|
this.retries = retries;
|
|
148
|
+
this.chainExists = chainExists;
|
|
102
149
|
}
|
|
103
150
|
}
|
|
104
151
|
/** `generateContent` part for one reference image (by uri, else inline). */
|
|
@@ -606,35 +653,69 @@ export class GeminiClient {
|
|
|
606
653
|
hint: 'The request may have been blocked by safety filters — try rephrasing the prompt.',
|
|
607
654
|
});
|
|
608
655
|
}
|
|
609
|
-
return { id: data.id, images, text, grounding };
|
|
656
|
+
return { id: data.id, images: await this.downloadAll(images), text, grounding };
|
|
610
657
|
}
|
|
611
658
|
/**
|
|
612
|
-
* Generate video via the omni model (Interactions API
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
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.
|
|
616
668
|
*/
|
|
617
669
|
async generateVideo(opts) {
|
|
618
670
|
const model = opts.model ?? DEFAULT_VIDEO_MODEL;
|
|
619
671
|
const inputParts = [{ type: 'text', text: opts.input }];
|
|
620
672
|
for (const img of opts.images ?? [])
|
|
621
673
|
inputParts.push(interactPart(img));
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
responseFormat
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
body
|
|
630
|
-
|
|
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);
|
|
631
712
|
const { videos, text } = this.extractInteraction(data);
|
|
632
713
|
if (videos.length === 0) {
|
|
633
714
|
throw new McpToolError('Gemini returned no video', {
|
|
634
|
-
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.',
|
|
635
716
|
});
|
|
636
717
|
}
|
|
637
|
-
return { id: data.id, videos, text };
|
|
718
|
+
return { id: data.id, videos: await this.downloadAll(videos), text };
|
|
638
719
|
}
|
|
639
720
|
/**
|
|
640
721
|
* Generate music via a Lyria model (Interactions API, inline audio). Reuses
|
|
@@ -651,6 +732,9 @@ export class GeminiClient {
|
|
|
651
732
|
const body = { model, input: inputParts, response_format: responseFormat };
|
|
652
733
|
if (opts.previousInteractionId !== undefined)
|
|
653
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;
|
|
654
738
|
const data = await this.postInteraction(body, opts);
|
|
655
739
|
const { audios, text } = this.extractInteraction(data);
|
|
656
740
|
if (audios.length === 0) {
|
|
@@ -658,11 +742,44 @@ export class GeminiClient {
|
|
|
658
742
|
hint: 'The request may have been blocked by a safety filter — try rephrasing the prompt.',
|
|
659
743
|
});
|
|
660
744
|
}
|
|
661
|
-
return { id: data.id, audios, text };
|
|
745
|
+
return { id: data.id, audios: await this.downloadAll(audios), text };
|
|
662
746
|
}
|
|
663
|
-
/**
|
|
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
|
+
*/
|
|
664
752
|
async postInteraction(body, opts) {
|
|
665
|
-
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);
|
|
666
783
|
const budgetMs = resolveChainRetryBudgetMs();
|
|
667
784
|
let waitedMs = 0;
|
|
668
785
|
for (let attempt = 0;; attempt++) {
|
|
@@ -675,9 +792,7 @@ export class GeminiClient {
|
|
|
675
792
|
//
|
|
676
793
|
// What it is NOT is a diagnosis. We only know the status; the body is
|
|
677
794
|
// generic ("Requested entity was not found.") and covers a bad model id
|
|
678
|
-
// and an expired files/… uri just as well as a dead interaction.
|
|
679
|
-
// error carries the upstream text and says what it doesn't know — the
|
|
680
|
-
// caller (tools/interact.ts) probes to find out which it was.
|
|
795
|
+
// and an expired files/… uri just as well as a dead interaction.
|
|
681
796
|
if (!(opts.previousInteractionId && err instanceof ApiError && err.status === 404))
|
|
682
797
|
throw err;
|
|
683
798
|
// Exponential backoff until the budget is spent. Sized against the
|
|
@@ -690,13 +805,198 @@ export class GeminiClient {
|
|
|
690
805
|
await this.sleep(delay);
|
|
691
806
|
continue;
|
|
692
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);
|
|
693
813
|
throw new ChainedRequest404Error(opts.previousInteractionId, err.message, { attempts: attempt, waitedMs }, {
|
|
694
|
-
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.',
|
|
695
817
|
cause: err,
|
|
696
|
-
});
|
|
818
|
+
}, chainExists, verdict);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
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;
|
|
697
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
|
+
};
|
|
698
991
|
}
|
|
699
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
|
+
}
|
|
700
1000
|
/**
|
|
701
1001
|
* Collect `model_output` media (image/video/audio) + text + grounding from an
|
|
702
1002
|
* interactions response. Only the `model_output` step is surfaced — `thought`
|
|
@@ -733,16 +1033,24 @@ export class GeminiClient {
|
|
|
733
1033
|
for (const parts of [step.content ?? [], step.summary ?? []]) {
|
|
734
1034
|
for (const part of parts) {
|
|
735
1035
|
const mime = part.mime_type ?? part.mimeType;
|
|
736
|
-
|
|
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 };
|
|
737
1048
|
if (part.type === 'image')
|
|
738
|
-
images.push({
|
|
1049
|
+
images.push({ ...carrier, mimeType: mime ?? 'image/jpeg' });
|
|
739
1050
|
else if (part.type === 'video')
|
|
740
|
-
videos.push({
|
|
1051
|
+
videos.push({ ...carrier, mimeType: mime ?? 'video/mp4' });
|
|
741
1052
|
else if (part.type === 'audio')
|
|
742
|
-
audios.push({
|
|
743
|
-
}
|
|
744
|
-
else if (part.type === 'text' && part.text?.trim()) {
|
|
745
|
-
textParts.push(part.text);
|
|
1053
|
+
audios.push({ ...carrier, mimeType: mime ?? 'audio/mpeg' });
|
|
746
1054
|
}
|
|
747
1055
|
}
|
|
748
1056
|
}
|
|
@@ -758,6 +1066,46 @@ export class GeminiClient {
|
|
|
758
1066
|
return { images, videos, audios, text: textParts.join('\n') || undefined, grounding };
|
|
759
1067
|
}
|
|
760
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
|
+
}
|
|
761
1109
|
/**
|
|
762
1110
|
* Everything that needs somewhere to put bytes, when the host offers a blob
|
|
763
1111
|
* store (`MCP_BLOB_BASE_URL` / `MCP_BLOB_SIGNING_KEY`, injected by mcp-host).
|
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
|