@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.
@@ -1,31 +1,17 @@
1
1
  import { z } from 'zod';
2
- import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
2
+ import { McpToolError, ApiError, readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveModel } from '../models.js';
4
- import { client } from '../client.js';
5
- import { slugify, baseName, gatherImageInputs, resolveImagePath, writeSidecar } from '../images.js';
6
- import { emit, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, withProgressHeartbeat } from './shared.js';
7
- import { dispatch, fingerprintRequest } from '../jobs.js';
4
+ import { ChainedRequest404Error } from '../client.js';
5
+ import { slugify, baseName, gatherImageInputs, resolveImagePath, writeSidecar, resolveOutputDir, readImageAsInline } from '../images.js';
6
+ import { findInteractionImages, latestInteractionId } from '../sidecar.js';
7
+ import { emit, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
8
+ import { fingerprintRequest } from '../jobs.js';
8
9
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
9
- // The most recent interaction id this server process created — what
10
- // `continue_last: true` resumes. In-memory only: an MCP server lives for the
11
- // host session, so "last" means "last in this session".
12
- let lastInteractionId;
13
- // Absolute paths of every image this tool has written this session. Used to
14
- // drop a chained call's re-attached prior output: the interaction state
15
- // already contains that image, and re-sending it as a fresh reference anchors
16
- // the model against the requested edit (observed in the wild — callers pass
17
- // the last result path back via `images` despite the description saying not to).
18
- const writtenOutputs = new Set();
19
- /**
20
- * Test-only: clear the module-level session memory (`lastInteractionId` and the
21
- * written-outputs set) so tests that assert the "no prior interaction" path are
22
- * order-independent — they can't rely on module-load state once other tests have
23
- * run first (e.g. under `--sequence.shuffle`).
24
- */
25
- export function __resetInteractSessionForTest() {
26
- lastInteractionId = undefined;
27
- writtenOutputs.clear();
28
- }
10
+ // The last-interaction id and the written-outputs set used to live here, at
11
+ // module scope. On the hosted connector that leaks across tenants — one
12
+ // Cloudflare isolate serves many authenticated sessions, so `continue_last`
13
+ // from user B would resume user A's interaction under B's key. Both now live
14
+ // on `client.session` (src/session.ts), one per authenticated session.
29
15
  /** Shared warning for the reference-image params. */
30
16
  const NEW_REFERENCES_ONLY = 'NEW reference images only (e.g. a style or target photo). When chaining with previous_interaction_id, ' +
31
17
  "do NOT re-attach the prior turn's output — the interaction already contains it, and re-sending it " +
@@ -35,7 +21,7 @@ const NEW_REFERENCES_ONLY = 'NEW reference images only (e.g. a style or target p
35
21
  * this server itself generated (which the interaction already contains).
36
22
  * Unresolvable paths are kept so `gatherImageInputs` surfaces its usual error.
37
23
  */
38
- function splitReattachedOutputs(images) {
24
+ function splitReattachedOutputs(images, writtenOutputs) {
39
25
  const kept = [];
40
26
  const dropped = [];
41
27
  for (const p of images) {
@@ -48,16 +34,29 @@ function splitReattachedOutputs(images) {
48
34
  }
49
35
  return { kept, dropped };
50
36
  }
51
- export function registerInteractTools(server) {
37
+ export function registerInteractTools(server, client) {
38
+ // The sidecar/re-anchor half of this description is only true where there is
39
+ // a filesystem. Describing it anyway on the hosted connector would tell the
40
+ // model to go looking in an output dir that does not exist — so the
41
+ // description is built from the sink's capability, not hardcoded.
42
+ const onDiskRuntime = client.mediaSink?.persistsFiles ?? true;
43
+ const recoveryDescription = onDiskRuntime
44
+ ? 'If a call times out on the client side, the generation usually still completes: the image plus a ' +
45
+ '`<image>.json` sidecar recording its interaction id land in the output dir, and `continue_last: true` ' +
46
+ 'still resumes that interaction — check the output dir before re-issuing (a re-issue is a second billable generation). ' +
47
+ 'If a chained call 404s, this tool re-anchors itself on the prior output image and re-issues un-chained: ' +
48
+ 'success is reported as `chain_recovered` (the chain was the problem, and you get your image anyway); ' +
49
+ 'a second 404 is reported as the interaction id NOT being the cause (check the model id / files uri instead). '
50
+ : 'This connector has no filesystem: there is no output dir and no `<image>.json` sidecar, so a lost response ' +
51
+ 'cannot be recovered from disk and a 404 on a chained call cannot be re-anchored automatically. Capture the ' +
52
+ 'returned `interaction_id` from every result, and use `async: true` + gemini_get_result for long generations. ';
52
53
  server.registerTool('gemini_interact', {
53
54
  description: 'Preferred tool for iterative or multi-step refinement of a single image — ' +
54
55
  "multi-turn generation/editing via Gemini's Interactions API. " +
55
56
  'To refine, capture the returned interaction `id` and pass it as `previous_interaction_id` ' +
56
57
  'on the next call — do NOT start a new interaction or re-upload the image for each tweak. ' +
57
58
  '`continue_last: true` chains from this session\'s most recent interaction without threading the id. ' +
58
- 'If a call times out on the client side, the generation usually still completes: the image plus a ' +
59
- '`<image>.json` sidecar recording its interaction id land in the output dir, and `continue_last: true` ' +
60
- 'still resumes that interaction — check the output dir before re-issuing (a re-issue is a second billable generation). ' +
59
+ recoveryDescription +
61
60
  'Output is JPEG.',
62
61
  annotations: { readOnlyHint: false, openWorldHint: true },
63
62
  inputSchema: {
@@ -69,7 +68,7 @@ export function registerInteractTools(server) {
69
68
  continue_last: z
70
69
  .boolean()
71
70
  .optional()
72
- .describe('Continue from the most recent interaction this server created (convenience for previous_interaction_id; an explicit id wins)'),
71
+ .describe('Continue from the most recent interaction this server created (convenience for previous_interaction_id; an explicit id wins). Survives a server restart by falling back to the newest <image>.json sidecar in the output dir.'),
73
72
  images: z
74
73
  .array(z.string().min(1))
75
74
  .optional()
@@ -128,19 +127,36 @@ export function registerInteractTools(server) {
128
127
  confirm: schemaConfirm,
129
128
  },
130
129
  }, async (args, extra) => {
130
+ assertLocalInputsAvailable(client.mediaSink, args);
131
+ // Every sidecar-backed recovery below reads the output dir off disk. A
132
+ // sink with no filesystem has none, so those paths are skipped entirely
133
+ // rather than left to silently read an empty directory.
134
+ const onDisk = client.mediaSink?.persistsFiles ?? true;
131
135
  let previousInteractionId = args.previous_interaction_id;
136
+ // `continue_last` resumes the in-memory id first, then falls back to the
137
+ // newest sidecar in the output dir. The in-memory id dies with the server
138
+ // process, but the interaction itself lives on upstream — without the
139
+ // sidecar fallback an MCP restart mid-workflow reads as an expired chain
140
+ // and costs the caller a manual re-anchor for no reason.
141
+ let continuedFromSidecar = false;
132
142
  if (!previousInteractionId && args.continue_last) {
133
- if (!lastInteractionId) {
143
+ previousInteractionId = client.session.lastInteractionId;
144
+ if (!previousInteractionId && onDisk) {
145
+ previousInteractionId = await latestInteractionId(resolveOutputDir(args.output_dir));
146
+ continuedFromSidecar = previousInteractionId !== undefined;
147
+ }
148
+ if (!previousInteractionId) {
134
149
  throw new McpToolError('No previous interaction to continue in this session.', {
135
- hint: 'Call gemini_interact once without continue_last first, or pass an explicit previous_interaction_id.',
150
+ hint: onDisk
151
+ ? 'Call gemini_interact once without continue_last first, or pass an explicit previous_interaction_id.'
152
+ : 'Call gemini_interact once without continue_last first, or pass an explicit previous_interaction_id. (This hosted connector has no filesystem, so there are no <image>.json sidecars to recover an id from across restarts.)',
136
153
  });
137
154
  }
138
- previousInteractionId = lastInteractionId;
139
155
  }
140
156
  let images = args.images;
141
157
  let dropped = [];
142
158
  if (previousInteractionId && images?.length) {
143
- ({ kept: images, dropped } = splitReattachedOutputs(images));
159
+ ({ kept: images, dropped } = splitReattachedOutputs(images, client.session.writtenOutputs));
144
160
  }
145
161
  // Confirm-gate local file inputs AFTER the re-attach split, so the preview
146
162
  // reflects the paths actually sent (dropped prior-output paths aren't). A
@@ -157,27 +173,78 @@ export function registerInteractTools(server) {
157
173
  images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
158
174
  video_url: args.video_url, video_path: args.video_path,
159
175
  });
160
- return dispatch({ toolName: 'gemini_interact', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
176
+ return client.session.jobs.dispatch({ toolName: 'gemini_interact', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
161
177
  const inputs = await gatherImageInputs({ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
162
- const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args));
163
- const r = await withProgressHeartbeat(extra, 'Generating image (Gemini Interactions API)', () => client.interact({
178
+ const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args, client));
179
+ const callOpts = {
164
180
  input: args.input,
165
- images: inputs.length ? inputs : undefined,
166
181
  model: args.model,
167
182
  aspectRatio: args.aspect_ratio,
168
183
  imageSize: args.image_size,
169
184
  thinkingLevel: args.thinking_level,
170
- previousInteractionId,
171
185
  googleSearch: args.google_search,
172
186
  searchTypes: args.search_types,
173
187
  videoUrl: video.videoUrl,
174
188
  videoMimeType: video.videoMimeType,
175
189
  timeoutMs: args.timeout_ms,
176
- }));
177
- lastInteractionId = r.id;
190
+ };
191
+ // A 404 on a chained request proves *something* was missing, not that it
192
+ // was the interaction (the upstream body is generic — see
193
+ // ChainedRequest404Error). So the recovery doubles as the experiment that
194
+ // settles it: re-attach that turn's output image — located by id via its
195
+ // sidecar, never "the newest image", since re-anchoring on the wrong
196
+ // picture would silently corrupt the edit — and re-issue WITHOUT the
197
+ // chain. Succeeds → the chain really was the problem, and the caller gets
198
+ // their image. 404s again → the id was never the cause, and saying so
199
+ // beats sending them to chase an interaction that was fine all along.
200
+ // Neither 404 generates anything, so a success costs the one generation
201
+ // the manual re-anchor would have cost.
202
+ let chainRecovered;
203
+ const r = await withProgressHeartbeat(extra, 'Generating image (Gemini Interactions API)', async () => {
204
+ try {
205
+ return await client.interact({ ...callOpts, images: inputs.length ? inputs : undefined, previousInteractionId });
206
+ }
207
+ catch (err) {
208
+ if (!(err instanceof ChainedRequest404Error))
209
+ throw err;
210
+ // Re-anchoring needs the prior output image, which only the sidecar
211
+ // index can locate — and that index lives on disk.
212
+ if (!onDisk)
213
+ throw err;
214
+ const reanchorOn = await findInteractionImages(resolveOutputDir(args.output_dir), err.previousInteractionId);
215
+ if (!reanchorOn.length)
216
+ throw err;
217
+ const carried = await Promise.all(reanchorOn.map((p) => readImageAsInline(p)));
218
+ try {
219
+ const probe = await client.interact({ ...callOpts, images: [...carried, ...inputs], previousInteractionId: undefined });
220
+ chainRecovered = { expired_interaction_id: err.previousInteractionId, reanchored_on: reanchorOn };
221
+ return probe;
222
+ }
223
+ catch (probeErr) {
224
+ if (!(probeErr instanceof ApiError && probeErr.status === 404))
225
+ throw probeErr;
226
+ throw new McpToolError(
227
+ // Remediation goes in the MESSAGE, not just `hint`: MCP error
228
+ // serialization surfaces the message and drops the hint, so a
229
+ // hint-only fix is invisible exactly when it's needed.
230
+ `The interaction id is not the cause of this 404: the same request re-issued WITHOUT previous_interaction_id ("${err.previousInteractionId}") also returned 404. Upstream said: ${probeErr.message}. ` +
231
+ `Check that the model id ("${model}") resolves — see gemini_list_models — and that any files/… uri you referenced hasn't expired (~48h TTL). Re-chaining or re-anchoring will not help.`, {
232
+ hint: `Check the model id ("${model}") resolves — see gemini_list_models — and that any files/… uri you referenced hasn't expired (~48h TTL). Re-chaining or re-anchoring will not help.`,
233
+ cause: probeErr,
234
+ });
235
+ }
236
+ }
237
+ });
238
+ client.session.lastInteractionId = r.id;
178
239
  const meta = { model, interaction_id: r.id };
179
- if (previousInteractionId)
240
+ // On recovery the prior id was NOT used — reporting it as
241
+ // previous_interaction_id would misrepresent what the model saw.
242
+ if (chainRecovered)
243
+ meta.chain_recovered = chainRecovered;
244
+ else if (previousInteractionId)
180
245
  meta.previous_interaction_id = previousInteractionId;
246
+ if (continuedFromSidecar)
247
+ meta.continued_from_sidecar = true;
181
248
  if (dropped.length)
182
249
  meta.dropped_previous_output = dropped;
183
250
  meta.hint =
@@ -189,7 +256,7 @@ export function registerInteractTools(server) {
189
256
  meta.grounding = r.grounding;
190
257
  if (video.videoFileMeta)
191
258
  meta.video_file = video.videoFileMeta;
192
- const risk = timeoutRiskHint({ model, imageSize: args.image_size });
259
+ const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: onDisk });
193
260
  if (risk)
194
261
  meta.timeout_risk = risk;
195
262
  const slug = args.filename ? baseName(args.filename) : slugify(args.input);
@@ -197,10 +264,12 @@ export function registerInteractTools(server) {
197
264
  image,
198
265
  base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
199
266
  }));
200
- // writeImage returns already-resolved absolute paths.
201
- return emit(named, args, meta, async (paths) => {
267
+ // writeImage returns already-resolved absolute paths. `emit` only
268
+ // invokes this callback for a filesystem-backed sink, so there is no
269
+ // branch here claiming a sidecar the hosted connector cannot write.
270
+ return emit(named, { ...args, sink: client.mediaSink }, meta, async (paths) => {
202
271
  for (const p of paths)
203
- writtenOutputs.add(p);
272
+ client.session.writtenOutputs.add(p);
204
273
  // Sidecar per image so the interaction id survives a lost MCP response
205
274
  // (host timeout). Best-effort: the image is already on disk and the id
206
275
  // is in the result — a sidecar failure shouldn't fail the call.
@@ -1,17 +1,20 @@
1
1
  import { z } from 'zod';
2
- import { getJobResult } from '../jobs.js';
3
2
  /**
4
3
  * Poll tool for the async generation pattern (issue #52). A generation tool
5
4
  * called with `async: true` returns a `job_id` immediately (instead of blocking
6
5
  * past a host's tools/call timeout); this tool retrieves that job's status and,
7
6
  * once complete, its normal result payload.
8
7
  */
9
- export function registerJobTools(server) {
8
+ // The client is REQUIRED, not decorative: the job registry hangs off it
9
+ // (`client.session.jobs`) so each authenticated connector session polls its own
10
+ // jobs. Reading a module-level registry here would let any session fetch any
11
+ // other session's result by job id — see src/session.ts.
12
+ export function registerJobTools(server, client) {
10
13
  server.registerTool('gemini_get_result', {
11
- description: 'Retrieve an async generation started with `async: true`. Pass the returned `job_id`: while running it reports status "running"; on completion it returns the normal result (image paths / inline images + meta); on failure it raises the recorded error. Jobs are per-process and expire ~10 min after completion — if a job id is unknown, check the output dir / <image>.json sidecar.',
14
+ description: 'Retrieve an async generation started with `async: true`. Pass the returned `job_id`: while running it reports status "running"; on completion it returns the normal result (image paths / inline images + meta); on failure it raises the recorded error. Jobs belong to your session and expire ~10 min after completion — if a job id is unknown, check the output dir / <image>.json sidecar.',
12
15
  annotations: { readOnlyHint: true, openWorldHint: false },
13
16
  inputSchema: {
14
17
  job_id: z.string().min(1).describe('The job_id returned by a generation tool called with async: true'),
15
18
  },
16
- }, async (args) => getJobResult(args.job_id));
19
+ }, async (args) => client.session.jobs.getResult(args.job_id));
17
20
  }
@@ -1,6 +1,5 @@
1
1
  import { textResult } from '@chrischall/mcp-utils';
2
- import { client } from '../client.js';
3
- export function registerModelTools(server) {
2
+ export function registerModelTools(server, client) {
4
3
  server.registerTool('gemini_list_models', {
5
4
  description: 'List the Gemini image-generation models available to your API key (Nano Banana / Nano Banana Pro family), and the current default model.',
6
5
  annotations: { readOnlyHint: true },
@@ -1,20 +1,20 @@
1
1
  import { z } from 'zod';
2
2
  import { McpToolError } from '@chrischall/mcp-utils';
3
- import { client } from '../client.js';
4
3
  import { slugify, baseName, gatherImageInputs } from '../images.js';
5
4
  import { DEFAULT_MUSIC_MODEL } from '../models.js';
6
- import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat } from './shared.js';
7
- import { dispatch, fingerprintRequest } from '../jobs.js';
5
+ import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
6
+ import { fingerprintRequest } from '../jobs.js';
8
7
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
9
8
  const MUSIC_MODELS = ['lyria-3-clip-preview', 'lyria-3-pro-preview'];
10
9
  const AUDIO_FORMATS = ['mp3', 'wav'];
11
10
  /** WAV is Lyria-3-Pro-only (docs); reject it on the clip model up front. */
12
11
  function isProModel(model) { return /pro/i.test(model); }
13
- // Most-recent music interaction id (this process), for continue_last.
14
- let lastMusicInteractionId;
15
- /** Test-only: clear the module-level session memory. */
16
- export function __resetMusicMemory() { lastMusicInteractionId = undefined; }
17
- export function registerMusicTools(server) {
12
+ // The most-recent music interaction id (for continue_last) lives on
13
+ // `client.session`, NOT at module scope: one Cloudflare isolate serves many
14
+ // authenticated connector sessions, so a module-level id would let one user's
15
+ // continue_last resume another user's interaction under their own key.
16
+ // See src/session.ts.
17
+ export function registerMusicTools(server, client) {
18
18
  server.registerTool('gemini_music_generate', {
19
19
  description: 'Generate music from a text prompt (mood, genre, instruments, structure, or lyrics inline) via a Lyria model ' +
20
20
  '(preview): lyria-3-clip-preview (~30s clips, default) or lyria-3-pro-preview (longer, WAV-capable). Written to ' +
@@ -39,6 +39,7 @@ export function registerMusicTools(server) {
39
39
  confirm: schemaConfirm,
40
40
  },
41
41
  }, async (args, extra) => {
42
+ assertLocalInputsAvailable(client.mediaSink, args);
42
43
  const model = args.model ?? DEFAULT_MUSIC_MODEL;
43
44
  if (args.audio_format === 'wav' && !isProModel(model)) {
44
45
  throw new McpToolError(`WAV output requires a Pro model; ${model} only produces MP3.`, {
@@ -47,12 +48,12 @@ export function registerMusicTools(server) {
47
48
  }
48
49
  let previousInteractionId = args.previous_interaction_id;
49
50
  if (!previousInteractionId && args.continue_last) {
50
- if (!lastMusicInteractionId) {
51
+ if (!client.session.lastMusicInteractionId) {
51
52
  throw new McpToolError('No previous music interaction to continue in this session.', {
52
53
  hint: 'Generate music first, or pass an explicit previous_interaction_id.',
53
54
  });
54
55
  }
55
- previousInteractionId = lastMusicInteractionId;
56
+ previousInteractionId = client.session.lastMusicInteractionId;
56
57
  }
57
58
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, 'Send local image input(s) to the Gemini Lyria API', '/v1beta/interactions', args.images);
58
59
  if (gate)
@@ -62,7 +63,7 @@ export function registerMusicTools(server) {
62
63
  images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
63
64
  previous_interaction_id: previousInteractionId,
64
65
  });
65
- return dispatch({ toolName: 'gemini_music_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
66
+ return client.session.jobs.dispatch({ toolName: 'gemini_music_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
66
67
  const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
67
68
  const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () => client.generateMusic({
68
69
  input: args.prompt,
@@ -72,7 +73,7 @@ export function registerMusicTools(server) {
72
73
  previousInteractionId,
73
74
  timeoutMs: args.timeout_ms,
74
75
  }));
75
- lastMusicInteractionId = r.id;
76
+ client.session.lastMusicInteractionId = r.id;
76
77
  const meta = { model, interaction_id: r.id };
77
78
  if (previousInteractionId)
78
79
  meta.previous_interaction_id = previousInteractionId;
@@ -84,7 +85,7 @@ export function registerMusicTools(server) {
84
85
  media,
85
86
  base: r.audios.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
86
87
  }));
87
- return emitMedia(named, 'audio', { inline: args.inline, output_dir: args.output_dir }, meta);
88
+ return emitMedia(named, 'audio', { inline: args.inline, output_dir: args.output_dir, sink: client.mediaSink }, meta);
88
89
  });
89
90
  });
90
91
  }
package/dist/tools/set.js CHANGED
@@ -1,12 +1,11 @@
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, withProgressHeartbeat } from './shared.js';
7
- import { dispatch, fingerprintRequest } from '../jobs.js';
5
+ import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
6
+ import { fingerprintRequest } from '../jobs.js';
8
7
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
9
- export function registerSetTools(server) {
8
+ export function registerSetTools(server, client) {
10
9
  server.registerTool('gemini_image_set', {
11
10
  description: 'Generate a consistent SET of images: a master image from master_prompt, then one image per scene that references the master so the subject/style stays consistent. Provide `scenes` (explicit per-image prompts) OR `count` (variations of the master).',
12
11
  annotations: { readOnlyHint: false, openWorldHint: true },
@@ -22,6 +21,7 @@ export function registerSetTools(server) {
22
21
  ...sharedImageSchema,
23
22
  },
24
23
  }, async (args, extra) => {
24
+ assertLocalInputsAvailable(client.mediaSink, args);
25
25
  // `scenes` is min(1) at the schema, so a non-undefined value is non-empty.
26
26
  if ((args.scenes && args.count) || (!args.scenes && !args.count)) {
27
27
  throw new McpToolError('Provide exactly one of `scenes` or `count`.');
@@ -38,7 +38,7 @@ export function registerSetTools(server) {
38
38
  thinking_level: args.thinking_level, google_search: args.google_search,
39
39
  master_images: args.master_images, master_images_base64: args.master_images_base64, from_clipboard: args.from_clipboard,
40
40
  });
41
- return dispatch({ toolName: 'gemini_image_set', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
41
+ return client.session.jobs.dispatch({ toolName: 'gemini_image_set', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
42
42
  const seed = pickSeed(args.seed);
43
43
  const cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search, timeoutMs: args.timeout_ms };
44
44
  const slug = args.basename ? baseName(args.basename) : slugify(args.master_prompt);
@@ -81,10 +81,10 @@ export function registerSetTools(server) {
81
81
  meta.grounding = masterResult.grounding;
82
82
  // A set is master + N scenes in one tools/call — the most timeout-prone
83
83
  // tool. Effective image count drives the risk hint.
84
- const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length });
84
+ const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length, persistsFiles: client.mediaSink?.persistsFiles });
85
85
  if (risk)
86
86
  meta.timeout_risk = risk;
87
- return emit(named, args, meta);
87
+ return emit(named, { ...args, sink: client.mediaSink }, meta);
88
88
  });
89
89
  });
90
90
  }
@@ -1,7 +1,13 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, McpToolError, readEnvVar } from '@chrischall/mcp-utils';
3
- import { client } from '../client.js';
4
- import { writeMedia, resolveOutputDir, resolveVideoPath, videoMimeType } from '../images.js';
3
+ import { resolveVideoPath, videoMimeType } from '../images.js';
4
+ import { createDiskSink } from '../storage/media.js';
5
+ /**
6
+ * Fallback sink for call sites that pass no explicit one (every pre-existing
7
+ * test, and any caller constructed before the sink seam existed). The stdio
8
+ * server's behaviour is therefore unchanged by omission, not by convention.
9
+ */
10
+ const DEFAULT_SINK = createDiskSink();
5
11
  /** Supported output aspect ratios (Gemini image API). */
6
12
  export const ASPECT_RATIOS = ['1:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9', '1:4', '4:1', '1:8', '8:1'];
7
13
  /** Supported output resolutions. `512` is Flash-only (0.5K); `1K`/`2K`/`4K` work on all image models. */
@@ -80,7 +86,7 @@ export const sharedImageSchema = {
80
86
  * The uploaded file (uri + expiry) is echoed via `videoFileMeta` so a caller
81
87
  * can pass the uri straight back as `video_url` instead of re-uploading.
82
88
  */
83
- export async function resolveVideoInput(args) {
89
+ export async function resolveVideoInput(args, client) {
84
90
  if (args.video_url && args.video_path) {
85
91
  throw new McpToolError('Provide `video_url` OR `video_path`, not both.');
86
92
  }
@@ -177,6 +183,10 @@ export async function withProgressHeartbeat(extra, message, fn) {
177
183
  * `meta.timeout_risk` so a caller that *does* get the result is steered off the
178
184
  * slow path next time and knows the image still landed on disk when a host bails.
179
185
  * `undefined` for fast configs (no field added).
186
+ *
187
+ * `persistsFiles: false` (the hosted connector) swaps the disk-recovery half of
188
+ * the advice for the async job handle — pointing a Worker's caller at "look in
189
+ * the output dir" would be advice about a filesystem that does not exist.
180
190
  */
181
191
  export function timeoutRiskHint(opts) {
182
192
  // Delimiter-anchored so real Pro ids (gemini-3-pro-image, nano-banana-pro)
@@ -189,7 +199,14 @@ export function timeoutRiskHint(opts) {
189
199
  const why = [proModel && 'Pro model', fourK && '4K output', multi && `count=${opts.count}`]
190
200
  .filter(Boolean)
191
201
  .join(' + ');
192
- return (`Slow config (${why}) can exceed a host's tools/call timeout (e.g. Claude Desktop ~30s, error -32001). ` +
202
+ const lead = `Slow config (${why}) can exceed a host's tools/call timeout (e.g. Claude Desktop ~30s, error -32001). `;
203
+ if (opts.persistsFiles === false) {
204
+ return (lead +
205
+ `This connector has no filesystem, so a lost response cannot be recovered from disk — there is no output dir and no ` +
206
+ `<image>.json sidecar. Use \`async: true\` to get a job_id immediately and poll gemini_get_result, ` +
207
+ `or use a Flash model / drop image_size to 1K/2K for a faster in-band result.`);
208
+ }
209
+ return (lead +
193
210
  `If the host gives up mid-generation the image is still written to disk (and gemini_interact leaves an ` +
194
211
  `<image>.json sidecar with the interaction id) — no rerun needed, just look in the output dir. ` +
195
212
  `For a faster in-band result use a Flash model, drop image_size to 1K/2K, or — in Claude Code — set MCP_TOOL_TIMEOUT.`);
@@ -220,14 +237,23 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
220
237
  }
221
238
  return { content: blocks };
222
239
  }
223
- const dir = resolveOutputDir(opts.output_dir);
224
- const paths = [];
225
- for (const n of named)
226
- paths.push(await writeMedia(dir, n.base, n.media.base64, n.media.mimeType));
227
- await onWritten?.(paths);
240
+ const sink = opts.sink ?? DEFAULT_SINK;
241
+ const refs = await sink.persist(named.map((n) => ({ base: n.base, base64: n.media.base64, mimeType: n.media.mimeType })), {
242
+ output_dir: opts.output_dir,
243
+ });
244
+ // `onWritten` (sidecars, the written-outputs set) is meaningful only when the
245
+ // refs are real files. On a sink with no filesystem there is nothing to sit
246
+ // next to, so it is skipped rather than handed URLs it would misinterpret.
247
+ if (sink.persistsFiles)
248
+ await onWritten?.(refs);
228
249
  // Note a downgraded inline-video request so the caller isn't left wondering.
229
250
  const note = opts.inline && kind === 'video' ? { inline_unsupported: 'video has no MCP inline content type — written to disk instead' } : {};
230
- return textResult({ [`${kind}s`]: paths, ...note, ...meta });
251
+ // Say where the bytes actually went whenever they are NOT local paths — the
252
+ // key is the same (`images`/`videos`/`audios`), so without this a caller would
253
+ // reasonably read a URL as a file path.
254
+ const storageNote = sink.note();
255
+ const storage = sink.persistsFiles ? {} : { storage: sink.kind, storage_note: storageNote };
256
+ return textResult({ [`${kind}s`]: refs, ...note, ...storage, ...meta });
231
257
  }
232
258
  /**
233
259
  * Image convenience wrapper over {@link emitMedia} — preserves the original
@@ -236,3 +262,39 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
236
262
  export async function emit(named, opts, meta, onWritten) {
237
263
  return emitMedia(named.map((n) => ({ media: n.image, base: n.base })), 'image', opts, meta, onWritten);
238
264
  }
265
+ /**
266
+ * Inputs that can only come off a local filesystem. On the hosted connector
267
+ * (a Cloudflare Worker) none of them exist:
268
+ *
269
+ * - `images` / `master_images` — file paths read with `node:fs`.
270
+ * - `from_clipboard` — shells out to `osascript`/`sips` via `child_process`.
271
+ * - `video_path` — the Files API upload streams the file off disk.
272
+ *
273
+ * Called at the top of every generation handler, BEFORE any billable upstream
274
+ * call, so a caller gets a clear "use images_base64 / video_url instead"
275
+ * instead of an obscure module-resolution or ENOENT crash. A no-op on the disk
276
+ * sink, so the stdio behaviour (including its own "Image not found" errors) is
277
+ * untouched.
278
+ */
279
+ export function assertLocalInputsAvailable(sink, args) {
280
+ if ((sink ?? DEFAULT_SINK).persistsFiles)
281
+ return;
282
+ const unavailable = [];
283
+ if (args.images?.length)
284
+ unavailable.push('`images` (local file paths)');
285
+ if (args.master_images?.length)
286
+ unavailable.push('`master_images` (local file paths)');
287
+ if (args.from_clipboard)
288
+ unavailable.push('`from_clipboard` (reads the macOS clipboard via child_process)');
289
+ if (args.video_path)
290
+ unavailable.push('`video_path` (uploads a file streamed from disk)');
291
+ if (unavailable.length === 0)
292
+ return;
293
+ // Remediation goes in the MESSAGE, not just `hint`: MCP error serialization
294
+ // surfaces the message and drops the hint, so hint-only advice is invisible
295
+ // exactly when it is needed.
296
+ const fix = 'Send image bytes as `images_base64` (raw base64 or a data: URI) instead of `images`/`master_images`/`from_clipboard`, ' +
297
+ 'and reference video by `video_url` (a public YouTube URL, or a files/… uri uploaded elsewhere) instead of `video_path`.';
298
+ throw new McpToolError(`${unavailable.join(', ')} ${unavailable.length > 1 ? 'are' : 'is'} unavailable on the hosted connector: ` +
299
+ `it runs on a Cloudflare Worker, which has no filesystem and no local clipboard. ${fix}`, { hint: fix });
300
+ }
@@ -1,20 +1,21 @@
1
1
  import { z } from 'zod';
2
2
  import { McpToolError } from '@chrischall/mcp-utils';
3
- import { client } from '../client.js';
4
3
  import { slugify, baseName, gatherImageInputs } from '../images.js';
5
4
  import { DEFAULT_VIDEO_MODEL } from '../models.js';
6
- import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat } from './shared.js';
7
- import { dispatch, fingerprintRequest } from '../jobs.js';
5
+ import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
6
+ import { fingerprintRequest } from '../jobs.js';
8
7
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
9
8
  /** omni's own aspect-ratio enum — NOT the image tools' ASPECT_RATIOS. */
10
9
  const VIDEO_ASPECT_RATIOS = ['16:9', '9:16'];
11
10
  const VIDEO_TASKS = ['text_to_video', 'image_to_video', 'reference_to_video', 'edit'];
12
- // Most-recent video interaction id (this process), for continue_last. Separate
13
- // from the image interact memory — a "last video" and "last image" are distinct.
14
- let lastVideoInteractionId;
15
- /** Test-only: clear the module-level session memory. */
16
- export function __resetVideoMemory() { lastVideoInteractionId = undefined; }
17
- export function registerVideoTools(server) {
11
+ // The most-recent video interaction id (for continue_last) lives on
12
+ // `client.session`, NOT at module scope — one Cloudflare isolate serves many
13
+ // authenticated connector sessions, so a module-level id would let one user's
14
+ // continue_last resume another user's interaction under their own key. Video is
15
+ // not registered on the Worker today, but the hazard is identical, so it is
16
+ // scoped the same way. It stays separate from the image interact memory: a
17
+ // "last video" and a "last image" are distinct chains. See src/session.ts.
18
+ export function registerVideoTools(server, client) {
18
19
  server.registerTool('gemini_video_generate', {
19
20
  description: 'Generate a short video via the Gemini omni model (preview): text→video, image→video / reference→video ' +
20
21
  '(supply reference image[s]), or edit a prior video (task: "edit" + previous_interaction_id / continue_last). ' +
@@ -43,14 +44,15 @@ export function registerVideoTools(server) {
43
44
  confirm: schemaConfirm,
44
45
  },
45
46
  }, async (args, extra) => {
47
+ assertLocalInputsAvailable(client.mediaSink, args);
46
48
  let previousInteractionId = args.previous_interaction_id;
47
49
  if (!previousInteractionId && args.continue_last) {
48
- if (!lastVideoInteractionId) {
50
+ if (!client.session.lastVideoInteractionId) {
49
51
  throw new McpToolError('No previous video interaction to continue in this session.', {
50
52
  hint: 'Generate a video first, or pass an explicit previous_interaction_id.',
51
53
  });
52
54
  }
53
- previousInteractionId = lastVideoInteractionId;
55
+ previousInteractionId = client.session.lastVideoInteractionId;
54
56
  }
55
57
  // Confirm-gate local file inputs (a prompt-injected `images` path could
56
58
  // exfiltrate a local file); base64 / clipboard pass through ungated.
@@ -63,7 +65,7 @@ export function registerVideoTools(server) {
63
65
  images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
64
66
  previous_interaction_id: previousInteractionId,
65
67
  });
66
- return dispatch({ toolName: 'gemini_video_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
68
+ return client.session.jobs.dispatch({ toolName: 'gemini_video_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
67
69
  const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
68
70
  const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client.generateVideo({
69
71
  input: args.prompt,
@@ -74,7 +76,7 @@ export function registerVideoTools(server) {
74
76
  previousInteractionId,
75
77
  timeoutMs: args.timeout_ms,
76
78
  }));
77
- lastVideoInteractionId = r.id;
79
+ client.session.lastVideoInteractionId = r.id;
78
80
  const meta = { model, interaction_id: r.id };
79
81
  if (previousInteractionId)
80
82
  meta.previous_interaction_id = previousInteractionId;
@@ -86,7 +88,7 @@ export function registerVideoTools(server) {
86
88
  media,
87
89
  base: r.videos.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
88
90
  }));
89
- return emitMedia(named, 'video', { output_dir: args.output_dir }, meta);
91
+ return emitMedia(named, 'video', { output_dir: args.output_dir, sink: client.mediaSink }, meta);
90
92
  });
91
93
  });
92
94
  }
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Single source of the server version. release-please bumps the literal below. */
2
- export const VERSION = '1.0.2'; // x-release-please-version
2
+ export const VERSION = '1.0.3'; // x-release-please-version