@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/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
- 'This does not necessarily mean the interaction expired — an unknown model id or an expired files/… uri returns the same generic 404. ' +
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, inline delivery). Same
613
- * plumbing as {@link interact} — text/image input parts, a `video`
614
- * response_format, optional `video_config.task`, and `previous_interaction_id`
615
- * for edits. Preview model: shapes are docs-derived; parsing stays tolerant.
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 responseFormat = { type: 'video', delivery: 'inline' };
623
- if (opts.aspectRatio)
624
- responseFormat.aspect_ratio = opts.aspectRatio;
625
- const body = { model, input: inputParts, response_format: responseFormat };
626
- if (opts.task)
627
- body.generation_config = { video_config: { task: opts.task } };
628
- if (opts.previousInteractionId !== undefined)
629
- body.previous_interaction_id = opts.previousInteractionId;
630
- const data = await this.postInteraction(body, opts);
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, or the clip exceeded the ~4MB inline limit — try a shorter/simpler prompt or a different aspect ratio.',
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
- /** POST `/interactions` with the chained-404 retry. Shared by all media. */
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 { interactionsApi } = this.apisFor(resolveTimeoutMs(opts.timeoutMs, opts.imageSize));
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. So the
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: '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.',
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
- if (part.data) {
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({ base64: part.data, mimeType: mime ?? 'image/jpeg' });
1049
+ images.push({ ...carrier, mimeType: mime ?? 'image/jpeg' });
739
1050
  else if (part.type === 'video')
740
- videos.push({ base64: part.data, mimeType: mime ?? 'video/mp4' });
1051
+ videos.push({ ...carrier, mimeType: mime ?? 'video/mp4' });
741
1052
  else if (part.type === 'audio')
742
- audios.push({ base64: part.data, mimeType: mime ?? 'audio/mpeg' });
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).
@@ -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
- function tooBig(url, requested, size, max, partial = false) {
291
- return new McpToolError(`Image at ${describeUrl(url, requested)} is ${partial ? 'over' : ''} ${formatMb(size)}, above the ${formatMb(max)} limit for images_url.`, { hint: 'Resize or re-encode the image, or upload it yourself with gemini_upload_file and pass the returned file_uri.' });
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