@chrischall/gemini-mcp 1.0.2 → 1.0.4

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
@@ -1,9 +1,14 @@
1
- import { basename, dirname, join } from 'node:path';
2
- import { fileURLToPath } from 'node:url';
3
- import { loadDotenvSafely, readEnvVar, McpToolError, ApiError, createApiClient, formatApiError, fileBlob } from '@chrischall/mcp-utils';
1
+ import { basename } from 'node:path';
2
+ import { readEnvVar, McpToolError, ApiError, createApiClient, formatApiError, fileBlob } from '@chrischall/mcp-utils';
4
3
  import { resolveModel, filterImageModels, DEFAULT_VIDEO_MODEL, DEFAULT_MUSIC_MODEL } from './models.js';
5
- const __dirname = dirname(fileURLToPath(import.meta.url));
6
- await loadDotenvSafely({ path: join(__dirname, '..', '.env'), override: false });
4
+ import { createDiskSink } from './storage/media.js';
5
+ import { SessionState } from './session.js';
6
+ // NOTE: this module must stay SIDE-EFFECT-FREE at module scope — no I/O, no
7
+ // top-level await, no `import.meta.url`. `src/worker.ts` imports it, so every
8
+ // line here runs during Cloudflare isolate startup, where global-scope I/O is
9
+ // forbidden and wrangler's bundle leaves `import.meta.url` undefined. The
10
+ // stdio `.env` bootstrap that used to live here now lives in `src/dotenv.ts`,
11
+ // imported only by `src/index.ts`. Guarded by tests/connector-boot.test.ts.
7
12
  const BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'; // v1 lacks gemini-3-pro-image; confirmed via Task 5
8
13
  const SERVICE = 'Gemini';
9
14
  const INTERACTIONS_SERVICE = 'Gemini Interactions';
@@ -38,30 +43,87 @@ const FILE_MAX_BYTES = 2 * 1024 ** 3;
38
43
  const FILE_POLL_INTERVAL_MS = 2_000;
39
44
  const FILE_POLL_MAX_ATTEMPTS = 150;
40
45
  // The interactions store is eventually consistent: a freshly returned id can
41
- // 404 for a short window right after its turn completes (observed live
42
- // 2026-07-06 — an id that 404'd on an immediate chain resolved fine minutes
43
- // later). A chained 404 generated nothing (no cost), so retry before giving up:
44
- // attempts wait 2s then 4s (~6s total).
45
- const CHAIN_404_RETRIES = 2;
46
- const CHAIN_404_RETRY_MS = 2_000;
46
+ // 404 while the SAME id + key resolves fine *minutes* later (observed live
47
+ // 2026-07-06, verified against a real failing id created moments before the
48
+ // failure — docs/GEMINI-API.md). The lag is intermittent and load-dependent, so
49
+ // a heavy turn (4K / Pro / thinking:high) is the likeliest to hit it — which is
50
+ // exactly the turn a caller most wants to chain from.
51
+ //
52
+ // This budget used to be two fixed retries (~6s total) against a lag the repo's
53
+ // own notes measured in minutes, so every rapid iteration surfaced as an expired
54
+ // chain. Match the budget to the observed lag instead: exponential backoff
55
+ // (capped per-sleep) until the budget is spent. A 404'd chained call generates
56
+ // nothing and is not billed, so waiting costs only time — and under `async: true`
57
+ // nothing is even blocking on it.
58
+ const CHAIN_404_RETRY_BUDGET_MS = 120_000;
59
+ const CHAIN_404_RETRY_BASE_MS = 2_000;
60
+ const CHAIN_404_RETRY_MAX_SLEEP_MS = 30_000;
61
+ /** Retry budget for a chained 404: `$GEMINI_CHAIN_RETRY_MS` → 120s. */
62
+ export function resolveChainRetryBudgetMs() {
63
+ const env = Number(readEnvVar('GEMINI_CHAIN_RETRY_MS'));
64
+ return Number.isFinite(env) && env >= 0 ? env : CHAIN_404_RETRY_BUDGET_MS;
65
+ }
66
+ /**
67
+ * A request carrying `previous_interaction_id` returned HTTP 404, after the
68
+ * store-lag retries were exhausted.
69
+ *
70
+ * Deliberately NOT named "chain expired": the only 404 body observed live is
71
+ * generic ("Requested entity was not found.") and never says *which* entity was
72
+ * missing. An unknown/renamed model id and an expired Files API `files/…` uri
73
+ * (~48h TTL) produce the same status and the same generic text, so a 404 on a
74
+ * chained request is evidence of *a* missing entity, not proof it was the
75
+ * interaction. Asserting otherwise fabricates a cause and hides the real one —
76
+ * which is why the upstream text is carried in the message rather than replaced.
77
+ *
78
+ * Its own type (rather than a message to regex) so `tools/interact.ts` can key
79
+ * its sidecar re-anchor off it; that re-issue doubles as the probe that tells
80
+ * the cases apart (see `chain_recovered` / `chain_not_the_cause` there).
81
+ */
82
+ export class ChainedRequest404Error extends McpToolError {
83
+ previousInteractionId;
84
+ upstreamMessage;
85
+ retries;
86
+ constructor(previousInteractionId,
87
+ /** The upstream error text, verbatim (already redacted + truncated). */
88
+ upstreamMessage,
89
+ /** How hard we waited out the store lag before giving up. */
90
+ retries, opts) {
91
+ 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}. ` +
92
+ 'This does not necessarily mean the interaction expired — an unknown model id or an expired files/… uri returns the same generic 404. ' +
93
+ '(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);
94
+ this.previousInteractionId = previousInteractionId;
95
+ this.upstreamMessage = upstreamMessage;
96
+ this.retries = retries;
97
+ }
98
+ }
47
99
  export class GeminiClient {
48
- apiKey;
49
- configError;
100
+ /**
101
+ * A key handed in explicitly (the connector's per-session key). When absent
102
+ * the key is read from the environment at REQUEST time, not here — see
103
+ * `requireKey`.
104
+ */
105
+ explicitApiKey;
50
106
  apis = new Map();
51
107
  fetchImpl;
52
108
  sleep;
109
+ /**
110
+ * The media destination for every tool this client is handed to. Public and
111
+ * readonly: `tools/*` read `client.mediaSink` to decide disk-vs-URL output
112
+ * AND whether disk-only *inputs* (local paths, clipboard, video upload) are
113
+ * even available on this runtime.
114
+ */
115
+ mediaSink;
116
+ /**
117
+ * This client's session memory — job registry, last-interaction id, written
118
+ * outputs. One client is one session: the hosted connector builds a client
119
+ * per authenticated user, so hanging the state here is what keeps it from
120
+ * leaking between tenants sharing an isolate (see src/session.ts). The stdio
121
+ * server is single-user, so its one singleton client is one session.
122
+ */
123
+ session = new SessionState();
53
124
  constructor(opts = {}) {
54
- const key = readEnvVar('GEMINI_API_KEY');
55
- if (!key) {
56
- this.apiKey = null;
57
- this.configError = new McpToolError('GEMINI_API_KEY environment variable is required', {
58
- hint: 'Create a key at https://aistudio.google.com/apikey and set GEMINI_API_KEY in your MCP host env or .env',
59
- });
60
- }
61
- else {
62
- this.apiKey = key;
63
- this.configError = null;
64
- }
125
+ this.mediaSink = opts.mediaSink ?? createDiskSink();
126
+ this.explicitApiKey = opts.apiKey?.trim() || undefined;
65
127
  this.fetchImpl = opts.fetchImpl ?? fetch;
66
128
  this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
67
129
  }
@@ -96,10 +158,31 @@ export class GeminiClient {
96
158
  }
97
159
  return entry;
98
160
  }
161
+ /**
162
+ * The API key for this request: an explicitly-injected key (the connector's
163
+ * per-session key) else `$GEMINI_API_KEY`.
164
+ *
165
+ * Resolved HERE, at request time, not in the constructor — same as every
166
+ * other env var this client reads. Two reasons:
167
+ *
168
+ * 1. It keeps the actionable config error deferred to the first tool call, so
169
+ * the server still boots and answers a host's install-time `tools/list`
170
+ * probe without a key. (That was already true; this preserves it.)
171
+ * 2. It makes module-evaluation order irrelevant. `src/index.ts` loads `.env`
172
+ * before serving, and reading the key in the constructor meant the
173
+ * `client` singleton — constructed when `client.js` is first evaluated —
174
+ * could latch "unset" before dotenv ran, depending on import ordering.
175
+ * That is an invisible, silent failure (the key just stops working); not
176
+ * depending on the order removes the hazard instead of documenting it.
177
+ */
99
178
  requireKey() {
100
- if (this.configError)
101
- throw this.configError;
102
- return this.apiKey;
179
+ const key = this.explicitApiKey || readEnvVar('GEMINI_API_KEY');
180
+ if (!key) {
181
+ throw new McpToolError('GEMINI_API_KEY environment variable is required', {
182
+ hint: 'Create a key at https://aistudio.google.com/apikey and set GEMINI_API_KEY in your MCP host env or .env',
183
+ });
184
+ }
185
+ return key;
103
186
  }
104
187
  /** The default model after env override (no per-call arg). */
105
188
  defaultModel() {
@@ -352,24 +435,35 @@ export class GeminiClient {
352
435
  /** POST `/interactions` with the chained-404 retry. Shared by all media. */
353
436
  async postInteraction(body, opts) {
354
437
  const { interactionsApi } = this.apisFor(resolveTimeoutMs(opts.timeoutMs, opts.imageSize));
438
+ const budgetMs = resolveChainRetryBudgetMs();
439
+ let waitedMs = 0;
355
440
  for (let attempt = 0;; attempt++) {
356
441
  try {
357
442
  return await interactionsApi.fetchJson('POST', '/interactions', { body });
358
443
  }
359
444
  catch (err) {
360
- // A 404 on a chained call means the referenced interaction wasn't found:
361
- // (a) store lag right after the id was created — retry with backoff; or
362
- // (b) genuinely gone — interactions are retained 55 days (paid) / 1 day
363
- // (free), scoped to the API key's project. The chain is recoverable from
364
- // the last output file, so the exhausted-retries error says how.
445
+ // A 404 on a chained call is worth retrying: the interactions store is
446
+ // eventually consistent, so a freshly returned id can 404 briefly.
447
+ //
448
+ // What it is NOT is a diagnosis. We only know the status; the body is
449
+ // generic ("Requested entity was not found.") and covers a bad model id
450
+ // and an expired files/… uri just as well as a dead interaction. So the
451
+ // error carries the upstream text and says what it doesn't know — the
452
+ // caller (tools/interact.ts) probes to find out which it was.
365
453
  if (!(opts.previousInteractionId && err instanceof ApiError && err.status === 404))
366
454
  throw err;
367
- if (attempt < CHAIN_404_RETRIES) {
368
- await this.sleep(CHAIN_404_RETRY_MS * (attempt + 1));
455
+ // Exponential backoff until the budget is spent. Sized against the
456
+ // observed lag (minutes), not against how long a human likes to wait —
457
+ // the alternative is telling the caller their chain is dead while the
458
+ // API is seconds from serving it.
459
+ const delay = Math.min(CHAIN_404_RETRY_BASE_MS * 2 ** attempt, CHAIN_404_RETRY_MAX_SLEEP_MS);
460
+ if (waitedMs + delay <= budgetMs) {
461
+ waitedMs += delay;
462
+ await this.sleep(delay);
369
463
  continue;
370
464
  }
371
- throw new McpToolError(`Previous interaction "${opts.previousInteractionId}" was not found (retried ${CHAIN_404_RETRIES}× — the interactions store can lag briefly after a turn completes). If the id is old: 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.`, {
372
- hint: 'Retry once more in a few seconds; if it keeps failing, start a new chain by re-attaching the last output as an input plus the same instruction, then chain the NEW interaction id.',
465
+ throw new ChainedRequest404Error(opts.previousInteractionId, err.message, { attempts: attempt, waitedMs }, {
466
+ 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.',
373
467
  cause: err,
374
468
  });
375
469
  }
package/dist/dotenv.js ADDED
@@ -0,0 +1,29 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { loadDotenvSafely } from '@chrischall/mcp-utils';
4
+ /**
5
+ * The stdio server's `.env` bootstrap. **Imported only by `src/index.ts`.**
6
+ *
7
+ * This used to run at module scope in `src/client.ts`, which put it in the
8
+ * hosted connector's import graph (`src/worker.ts` → `./client.js`) and so ran
9
+ * it during Worker isolate startup. Two things go wrong there:
10
+ *
11
+ * 1. wrangler's bundle leaves `import.meta.url` undefined, so
12
+ * `fileURLToPath(import.meta.url)` threw and the isolate never booted —
13
+ * `The Workers runtime failed to start`. Not a test failure: a deploy
14
+ * failure, invisible to the workers-pool suite (see
15
+ * `tests/connector-boot.test.ts` for why).
16
+ * 2. Even had it resolved, it is async I/O at global scope, which Workers
17
+ * forbids — and there is no `.env` on a Worker regardless. Config arrives
18
+ * through bindings.
19
+ *
20
+ * Keeping this in its own module, off `client.ts`, is what makes `client.ts`
21
+ * side-effect-free and therefore safe to import from a Worker.
22
+ *
23
+ * `import.meta.url` is read inside the function body, not at module scope, so
24
+ * merely importing this file is still inert.
25
+ */
26
+ export async function loadStdioDotenv() {
27
+ const here = dirname(fileURLToPath(import.meta.url));
28
+ await loadDotenvSafely({ path: join(here, '..', '.env'), override: false });
29
+ }
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { runMcp } from '@chrischall/mcp-utils';
3
+ import { loadStdioDotenv } from './dotenv.js';
3
4
  import { VERSION } from './version.js';
4
5
  import { registerModelTools } from './tools/models.js';
5
6
  import { registerGenerateTools } from './tools/generate.js';
@@ -8,12 +9,27 @@ import { registerInteractTools } from './tools/interact.js';
8
9
  import { registerVideoTools } from './tools/video.js';
9
10
  import { registerMusicTools } from './tools/music.js';
10
11
  import { registerJobTools } from './tools/jobs.js';
11
- // The GeminiClient is a module-level singleton (imported by each tool module)
12
- // that defers its config error to the first request — so the server boots and
13
- // answers the host's install-time tools/list probe even without GEMINI_API_KEY.
12
+ import { client } from './client.js';
13
+ // Load `.env` here rather than in client.ts, which the Cloudflare Worker
14
+ // imports: `import.meta.url` + async I/O at module scope crashes the isolate at
15
+ // startup (see src/dotenv.ts, guarded by tests/connector-boot.test.ts).
16
+ //
17
+ // Ordering is deliberately NOT load-bearing: GeminiClient reads
18
+ // $GEMINI_API_KEY at request time, so it does not matter that the `client`
19
+ // singleton is constructed (by the import above) before this line runs.
20
+ await loadStdioDotenv();
21
+ // The registrars take their GeminiClient as an argument (they import only the
22
+ // *type*), so a non-stdio entry point can build one client per authenticated
23
+ // user. This stdio entry point threads the env-driven module-level singleton
24
+ // through runMcp's `deps`, which passes it as each registrar's second argument.
25
+ // That singleton defers its config error to the first request — so the server
26
+ // boots and answers the host's install-time tools/list probe even without
27
+ // GEMINI_API_KEY. It also owns this process's session state (job registry,
28
+ // last-interaction memory); stdio is single-user, so one client is one session.
14
29
  await runMcp({
15
30
  name: 'gemini-mcp',
16
31
  version: VERSION,
17
32
  banner: '[gemini-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.',
18
33
  tools: [registerModelTools, registerGenerateTools, registerSetTools, registerInteractTools, registerVideoTools, registerMusicTools, registerJobTools],
34
+ deps: client,
19
35
  });
package/dist/jobs.js CHANGED
Binary file
@@ -0,0 +1,55 @@
1
+ import { JobRegistry } from './jobs.js';
2
+ /**
3
+ * Everything the server remembers *between tool calls on behalf of one user*.
4
+ *
5
+ * All of this used to be module-level (`jobs`/`byKey`/`runningByFingerprint` in
6
+ * jobs.ts, `lastInteractionId`/`writtenOutputs` in tools/interact.ts). That is
7
+ * correct for stdio, where a process serves exactly one user — and a
8
+ * cross-tenant leak on the hosted connector, where Cloudflare shares ONE Worker
9
+ * isolate across many Durable Object instances. Module state there is shared by
10
+ * every authenticated claude.ai session in the isolate: the per-session
11
+ * `GeminiClient` isolated the API key but not the memory around it, so a
12
+ * colliding `idempotency_key` replayed another user's result, an identical
13
+ * prompt attached to another user's in-flight (billable) job, and
14
+ * `continue_last: true` resumed another user's interaction under your key.
15
+ *
16
+ * So session memory hangs off the `GeminiClient` — the one object the connector
17
+ * already builds per authenticated session (`buildClient` in src/worker.ts) and
18
+ * threads into every registrar. Handlers reach it as `client.session`.
19
+ *
20
+ * The rule this encodes: **`src/` must hold no module-level mutable state.**
21
+ * If you need to remember something across calls, put it here.
22
+ */
23
+ export class SessionState {
24
+ /** Idempotency + async-handle registry for this session (issues #52/#53). */
25
+ jobs = new JobRegistry();
26
+ /**
27
+ * The most recent interaction id THIS session created — what
28
+ * `continue_last: true` resumes. In-memory only, so "last" means "last in
29
+ * this session"; a restart falls back to the newest sidecar on disk.
30
+ */
31
+ lastInteractionId;
32
+ /**
33
+ * The same, for the media-specific tools, which keep their own chains.
34
+ * `gemini_music_generate` IS served by the hosted connector, so this one
35
+ * leaked across tenants exactly as `lastInteractionId` did; video is
36
+ * stdio-only today but carries the identical hazard, so both are scoped here.
37
+ */
38
+ lastMusicInteractionId;
39
+ lastVideoInteractionId;
40
+ /**
41
+ * Absolute paths of every image this session has written. Used to drop a
42
+ * chained call's re-attached prior output: the interaction state already
43
+ * contains that image, and re-sending it as a fresh reference anchors the
44
+ * model against the requested edit.
45
+ */
46
+ writtenOutputs = new Set();
47
+ /** Forget everything. Tests use this; nothing in `src/` calls it. */
48
+ reset() {
49
+ this.jobs.reset();
50
+ this.lastInteractionId = undefined;
51
+ this.lastMusicInteractionId = undefined;
52
+ this.lastVideoInteractionId = undefined;
53
+ this.writtenOutputs.clear();
54
+ }
55
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Reading the `<image>.json` sidecars `gemini_interact` writes next to every
3
+ * generated image (see `writeSidecar` in images.ts).
4
+ *
5
+ * The sidecars were introduced so an interaction id could survive a lost MCP
6
+ * response (host timeout). They double as an on-disk index of the whole chain,
7
+ * which is what makes the two chain recoveries in `tools/interact.ts` possible:
8
+ *
9
+ * - `continue_last` outliving the server process — the in-memory
10
+ * `lastInteractionId` dies with the process, but the newest sidecar in the
11
+ * output dir still names the interaction to resume.
12
+ * - re-anchoring after an interaction id is genuinely gone upstream — the
13
+ * sidecar for that id names the image file to re-attach, which is exactly
14
+ * the manual recovery the old error message asked the caller to perform.
15
+ */
16
+ import { readdir, readFile, stat } from 'node:fs/promises';
17
+ import { existsSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ const SIDECAR_SUFFIX = '.json';
20
+ /**
21
+ * All parseable sidecars in `dir`, newest first. Never throws: a missing dir,
22
+ * an unreadable file, malformed JSON, or a record with no `interaction_id` is
23
+ * skipped. Recovery is best-effort by definition — it must not turn a
24
+ * recoverable chain into a hard failure.
25
+ */
26
+ export async function readSidecars(dir) {
27
+ let entries;
28
+ try {
29
+ entries = await readdir(dir);
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ const records = [];
35
+ for (const entry of entries) {
36
+ if (!entry.endsWith(SIDECAR_SUFFIX))
37
+ continue;
38
+ const sidecarPath = join(dir, entry);
39
+ try {
40
+ const parsed = JSON.parse(await readFile(sidecarPath, 'utf8'));
41
+ if (typeof parsed !== 'object' || parsed === null)
42
+ continue;
43
+ const { interaction_id: interactionId, images } = parsed;
44
+ if (typeof interactionId !== 'string' || !interactionId)
45
+ continue;
46
+ const paths = Array.isArray(images) ? images.filter((p) => typeof p === 'string') : [];
47
+ records.push({
48
+ sidecarPath,
49
+ interactionId,
50
+ images: paths.filter((p) => existsSync(p)),
51
+ mtimeMs: (await stat(sidecarPath)).mtimeMs,
52
+ });
53
+ }
54
+ catch {
55
+ continue;
56
+ }
57
+ }
58
+ return records.sort((a, b) => b.mtimeMs - a.mtimeMs);
59
+ }
60
+ /**
61
+ * Output images recorded for `interactionId` (still on disk), or `[]` if that
62
+ * interaction has no sidecar here. Matching is by id, never "the newest image"
63
+ * — re-anchoring on the wrong picture would silently corrupt the edit chain.
64
+ */
65
+ export async function findInteractionImages(dir, interactionId) {
66
+ const records = await readSidecars(dir);
67
+ return records.find((r) => r.interactionId === interactionId)?.images ?? [];
68
+ }
69
+ /** The newest sidecar's interaction id, or undefined if `dir` has none. */
70
+ export async function latestInteractionId(dir) {
71
+ return (await readSidecars(dir))[0]?.interactionId;
72
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Where generated media goes.
3
+ *
4
+ * The stdio server writes images/video/audio to the local filesystem. The
5
+ * hosted connector (a Cloudflare Worker) has **no filesystem at all** — no
6
+ * `writeFile`, no output dir, and therefore no `<image>.json` sidecar — so it
7
+ * puts objects into an R2 bucket and hands back URLs instead of paths.
8
+ *
9
+ * `emit()` / `emitMedia()` (tools/shared.ts) route every byte through one of
10
+ * these, so the tool handlers themselves stay transport-neutral. The disk sink
11
+ * is a thin wrapper over the pre-existing `resolveOutputDir` + `writeMedia`
12
+ * behaviour and must stay byte-for-byte equivalent to it.
13
+ */
14
+ /**
15
+ * The stdio sink: `resolveOutputDir(output_dir)` → `writeMedia` per item, in
16
+ * order (so `uniquePath`'s `name`/`name-2`/`name-3` sequencing is preserved).
17
+ *
18
+ * `node:fs` is reached through a dynamic import so this module stays loadable
19
+ * in a Worker, where the R2 sink is the only one ever constructed.
20
+ */
21
+ export function createDiskSink() {
22
+ return {
23
+ kind: 'disk',
24
+ persistsFiles: true,
25
+ async persist(items, opts) {
26
+ const { writeMedia, resolveOutputDir } = await import('../images.js');
27
+ const dir = resolveOutputDir(opts.output_dir);
28
+ const refs = [];
29
+ // Sequential on purpose: uniquePath checks the filesystem, so writing two
30
+ // colliding names concurrently would race onto the same path.
31
+ for (const it of items)
32
+ refs.push(await writeMedia(dir, it.base, it.base64, it.mimeType));
33
+ return refs;
34
+ },
35
+ note: () => undefined,
36
+ };
37
+ }
38
+ const KEY_PREFIX_DEFAULT = 'media';
39
+ /** Object-key-safe name: no slashes, no dot-segments, never empty. */
40
+ function safeKeySegment(base) {
41
+ const cleaned = base
42
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
43
+ .replace(/\.{2,}/g, '-')
44
+ .replace(/-+/g, '-')
45
+ .replace(/^[-.]+|[-.]+$/g, '');
46
+ return cleaned || 'media';
47
+ }
48
+ /**
49
+ * The hosted-connector sink: one R2 object per generated item.
50
+ *
51
+ * Keys are `<prefix>/<YYYY-MM-DD>/<random>-<base>.<ext>`. The random component
52
+ * is what guarantees uniqueness — R2 `put` overwrites silently, so unlike the
53
+ * disk sink there is no "does it already exist" probe to lean on.
54
+ */
55
+ export function createR2Sink(bucket, opts) {
56
+ const prefix = opts.prefix ?? KEY_PREFIX_DEFAULT;
57
+ const now = opts.now ?? (() => new Date());
58
+ const randomId = opts.randomId ?? (() => crypto.randomUUID().slice(0, 8));
59
+ const base = opts.publicBaseUrl?.replace(/\/+$/, '');
60
+ return {
61
+ kind: 'r2',
62
+ persistsFiles: false,
63
+ async persist(items, _opts) {
64
+ // `mediaExt` is pure string work but lives in images.ts next to node:fs
65
+ // imports; the dynamic import keeps that module off a Worker's eager
66
+ // module graph.
67
+ const { mediaExt } = await import('../images.js');
68
+ const day = now().toISOString().slice(0, 10);
69
+ const refs = [];
70
+ for (const it of items) {
71
+ const key = `${prefix}/${day}/${randomId()}-${safeKeySegment(it.base)}.${mediaExt(it.mimeType)}`;
72
+ const bytes = decodeBase64(it.base64);
73
+ await bucket.put(key, bytes, { httpMetadata: { contentType: it.mimeType } });
74
+ refs.push(base ? `${base}/${key}` : `r2://${opts.bucketName ?? 'media'}/${key}`);
75
+ }
76
+ return refs;
77
+ },
78
+ note: () => base
79
+ ? 'Generated media was uploaded to R2; the values above are public URLs, not local file paths. ' +
80
+ 'This hosted connector has no filesystem, so `output_dir` is ignored and no <image>.json sidecar is written — capture interaction_id from this result to chain further turns.'
81
+ : 'Generated media was uploaded to R2, which is not publicly served: the values above are object refs (r2://bucket/key), NOT fetchable URLs. ' +
82
+ 'Set MEDIA_PUBLIC_BASE_URL on the Worker to get URLs, or pass inline: true to receive the bytes directly. ' +
83
+ 'This hosted connector has no filesystem, so `output_dir` is ignored and no <image>.json sidecar is written — capture interaction_id from this result to chain further turns.',
84
+ };
85
+ }
86
+ /**
87
+ * base64 → bytes without `Buffer`. `Buffer` exists in a Worker under
88
+ * `nodejs_compat`, but `atob` is the platform primitive on both runtimes and
89
+ * keeps this module free of any Node dependency.
90
+ */
91
+ function decodeBase64(b64) {
92
+ const binary = atob(b64);
93
+ const bytes = new Uint8Array(binary.length);
94
+ for (let i = 0; i < binary.length; i++)
95
+ bytes[i] = binary.charCodeAt(i);
96
+ return bytes;
97
+ }
@@ -1,10 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveModel } from '../models.js';
4
- import { client } from '../client.js';
5
4
  import { slugify, baseName, gatherImageInputs } from '../images.js';
6
- import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat } from './shared.js';
7
- import { dispatch, fingerprintRequest } from '../jobs.js';
5
+ import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
6
+ import { fingerprintRequest } from '../jobs.js';
8
7
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
9
8
  const GENERATE_ENDPOINT = '/v1beta/models/{model}:generateContent';
10
9
  const SEND_LOCAL_ACTION = 'Send local file input(s) to the Gemini API';
@@ -12,7 +11,7 @@ const SEND_LOCAL_ACTION = 'Send local file input(s) to the Gemini API';
12
11
  // results point follow-up edits at gemini_interact instead of an id.
13
12
  const REFINE_HINT = 'To iteratively refine this image, use gemini_interact (pass the output file via `images` on the first call, ' +
14
13
  'then chain `previous_interaction_id` on each subsequent call).';
15
- export function registerGenerateTools(server) {
14
+ export function registerGenerateTools(server, client) {
16
15
  server.registerTool('gemini_image_generate', {
17
16
  description: 'Generate image(s) from a text prompt with a Gemini image model (Nano Banana / Nano Banana Pro). ' +
18
17
  'If the result will likely be refined iteratively, prefer gemini_interact (multi-turn) as the entry point.',
@@ -29,6 +28,10 @@ export function registerGenerateTools(server) {
29
28
  ...sharedImageSchema,
30
29
  },
31
30
  }, async (args, extra) => {
31
+ // Local-path / clipboard / disk-upload inputs simply do not exist on the
32
+ // hosted connector. Fail here — before the confirm gate previews files and
33
+ // before any billable call — with the base64/URL alternative.
34
+ assertLocalInputsAvailable(client.mediaSink, args);
32
35
  // Confirm-gate local file inputs: a prompt-injected `images`/`video_path`
33
36
  // could exfiltrate a local file. Pure text-to-image calls (no local input)
34
37
  // are unaffected. Dry-run makes NO API call.
@@ -45,13 +48,13 @@ export function registerGenerateTools(server) {
45
48
  images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
46
49
  video_url: args.video_url, video_path: args.video_path,
47
50
  });
48
- return dispatch({ toolName: 'gemini_image_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
51
+ return client.session.jobs.dispatch({ toolName: 'gemini_image_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
49
52
  const seed = pickSeed(args.seed);
50
53
  const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
51
54
  const refInputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
52
55
  // Upload ONCE (before the count loop) — every generate call references
53
56
  // the same files/… uri.
54
- const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args));
57
+ const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args, client));
55
58
  const named = [];
56
59
  let capturedText;
57
60
  // For count>1, surface the FIRST call's grounding (each call grounds
@@ -90,10 +93,10 @@ export function registerGenerateTools(server) {
90
93
  if (video.videoFileMeta)
91
94
  meta.video_file = video.videoFileMeta;
92
95
  meta.hint = REFINE_HINT;
93
- const risk = timeoutRiskHint({ model, imageSize: args.image_size, count });
96
+ const risk = timeoutRiskHint({ model, imageSize: args.image_size, count, persistsFiles: client.mediaSink?.persistsFiles });
94
97
  if (risk)
95
98
  meta.timeout_risk = risk;
96
- return emit(named, args, meta);
99
+ return emit(named, { ...args, sink: client.mediaSink }, meta);
97
100
  });
98
101
  });
99
102
  server.registerTool('gemini_image_edit', {
@@ -111,6 +114,7 @@ export function registerGenerateTools(server) {
111
114
  ...sharedImageSchema,
112
115
  },
113
116
  }, async (args, extra) => {
117
+ assertLocalInputsAvailable(client.mediaSink, args);
114
118
  const hasPaths = (args.images?.length ?? 0) > 0;
115
119
  const hasBase64 = (args.images_base64?.length ?? 0) > 0;
116
120
  if (!hasPaths && !hasBase64 && !args.from_clipboard) {
@@ -127,7 +131,7 @@ export function registerGenerateTools(server) {
127
131
  thinking_level: args.thinking_level, google_search: args.google_search,
128
132
  images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
129
133
  });
130
- return dispatch({ toolName: 'gemini_image_edit', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
134
+ return client.session.jobs.dispatch({ toolName: 'gemini_image_edit', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
131
135
  const seed = pickSeed(args.seed);
132
136
  const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
133
137
  const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
@@ -149,10 +153,10 @@ export function registerGenerateTools(server) {
149
153
  if (result.grounding)
150
154
  meta.grounding = result.grounding;
151
155
  meta.hint = REFINE_HINT;
152
- const risk = timeoutRiskHint({ model, imageSize: args.image_size });
156
+ const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: client.mediaSink?.persistsFiles });
153
157
  if (risk)
154
158
  meta.timeout_risk = risk;
155
- return emit([{ image: img, base: slug }], args, meta);
159
+ return emit([{ image: img, base: slug }], { ...args, sink: client.mediaSink }, meta);
156
160
  });
157
161
  });
158
162
  }