@chrischall/gemini-mcp 1.0.2 → 1.0.3
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/README.md +34 -0
- package/dist/bundle.js +1320 -1027
- package/dist/client.js +130 -36
- package/dist/dotenv.js +29 -0
- package/dist/index.js +19 -3
- package/dist/jobs.js +0 -0
- package/dist/session.js +55 -0
- package/dist/sidecar.js +72 -0
- package/dist/storage/media.js +97 -0
- package/dist/tools/generate.js +15 -11
- package/dist/tools/interact.js +116 -47
- package/dist/tools/jobs.js +7 -4
- package/dist/tools/models.js +1 -2
- package/dist/tools/music.js +14 -13
- package/dist/tools/set.js +7 -7
- package/dist/tools/shared.js +72 -10
- package/dist/tools/video.js +16 -14
- package/dist/version.js +1 -1
- package/package.json +15 -4
- package/server.json +2 -2
package/dist/client.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import { basename
|
|
2
|
-
import {
|
|
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
|
-
|
|
6
|
-
|
|
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
|
|
42
|
-
// 2026-07-06
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
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
|
-
|
|
368
|
-
|
|
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
|
|
372
|
-
hint: '
|
|
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
|
-
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
package/dist/session.js
ADDED
|
@@ -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
|
+
}
|
package/dist/sidecar.js
ADDED
|
@@ -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
|
+
}
|
package/dist/tools/generate.js
CHANGED
|
@@ -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 {
|
|
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
|
}
|