@chrischall/gemini-mcp 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/jobs.js CHANGED
@@ -294,6 +294,7 @@ export class JobRegistry {
294
294
  status: entry.status,
295
295
  createdAt: entry.createdAt,
296
296
  updatedAt,
297
+ ...(entry.interactionId !== undefined ? { interactionId: entry.interactionId } : {}),
297
298
  ...(entry.error ? { error: entry.error } : {}),
298
299
  ...(entry.status === 'done' && entry.result ? persistableResult(entry.result) : {}),
299
300
  };
@@ -405,8 +406,31 @@ export class JobRegistry {
405
406
  }
406
407
  const jobId = randomUUID();
407
408
  this.evict(now);
408
- const promise = work();
409
- const entry = { jobId, toolName, fingerprint, idempotencyKey, status: 'running', promise, createdAt: now };
409
+ // The work starts before `entry` can exist (the entry holds its promise),
410
+ // yet a well-behaved job reports its interaction id in its very first
411
+ // `await` — synchronously, from here's point of view. Dropping that report
412
+ // would lose exactly the ids that arrive fastest, so it is held until the
413
+ // entry is there and folded into the record's first write.
414
+ let entry;
415
+ let earlyInteractionId;
416
+ const ctx = {
417
+ reportInteraction: (interactionId) => {
418
+ if (!entry) {
419
+ earlyInteractionId = interactionId;
420
+ return;
421
+ }
422
+ if (entry.interactionId === interactionId)
423
+ return;
424
+ entry.interactionId = interactionId;
425
+ if (this.store)
426
+ this.track(this.writeRecord(entry));
427
+ },
428
+ };
429
+ const promise = work(ctx);
430
+ entry = {
431
+ jobId, toolName, fingerprint, idempotencyKey, status: 'running', promise, createdAt: now,
432
+ ...(earlyInteractionId !== undefined ? { interactionId: earlyInteractionId } : {}),
433
+ };
410
434
  this.jobs.set(jobId, entry);
411
435
  if (idempotencyKey !== undefined)
412
436
  this.byKey.set(idempotencyKey, jobId);
@@ -483,6 +507,51 @@ export class JobRegistry {
483
507
  * the unknown-id path — the poll tool cannot be used to read another user's
484
508
  * result even if they know the uuid.
485
509
  */
510
+ /**
511
+ * The durable record for `jobId`, if there is one — including the
512
+ * executor-lost rewrite. Read-only view for a caller that wants to act on a
513
+ * record's contents (see `tools/jobs.ts` recovering an interaction id) rather
514
+ * than just render it.
515
+ */
516
+ async peekRecord(jobId) {
517
+ return this.store ? await this.store.get(jobId) : undefined;
518
+ }
519
+ /**
520
+ * Adopt a result produced OUTSIDE this registry — recovery reading a lost
521
+ * job's output back off the upstream interaction (`tools/jobs.ts`).
522
+ *
523
+ * Without this the job stays `failed` and every later poll re-enters
524
+ * recovery: another interaction fetch, another media download, and another
525
+ * copy on disk (`…-2.mp4`, `…-3.mp4`) or in R2, with nothing telling the
526
+ * caller they are duplicates. The ordinary done path is idempotent, so this
527
+ * one has to be too.
528
+ */
529
+ async adoptResult(jobId, toolName, result, interactionId) {
530
+ const now = this.now();
531
+ let entry = this.jobs.get(jobId);
532
+ if (!entry) {
533
+ // A different child ran the job, so there is nothing in memory to update.
534
+ // The synthetic entry exists only to render the record and to answer the
535
+ // next poll from this session; its fingerprint is namespaced so it can
536
+ // never collide with (or dedup against) a real request identity.
537
+ entry = {
538
+ jobId,
539
+ toolName,
540
+ fingerprint: `recovered:${jobId}`,
541
+ status: 'done',
542
+ promise: Promise.resolve(result),
543
+ createdAt: now,
544
+ };
545
+ this.jobs.set(jobId, entry);
546
+ }
547
+ entry.status = 'done';
548
+ entry.result = result;
549
+ entry.settledAt = now;
550
+ if (interactionId !== undefined)
551
+ entry.interactionId = interactionId;
552
+ if (this.store)
553
+ await this.writeRecord(entry);
554
+ }
486
555
  async getResult(jobId) {
487
556
  const entry = this.jobs.get(jobId);
488
557
  if (!entry) {
@@ -3,7 +3,7 @@ import { readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveModel } from '../models.js';
4
4
  import { slugify, baseName } from '../images.js';
5
5
  import { resolveImageInputs, requireImageInput } from '../inputs.js';
6
- import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesR2KeysSchema, charactersSchema, styleSchema, resolveCharacterRefs, composePrompt } from './shared.js';
6
+ import { emit, resolveAspectRatio, ASPECT_RATIOS, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesR2KeysSchema, charactersSchema, styleSchema, resolveCharacterRefs, composePrompt } from './shared.js';
7
7
  import { fingerprintRequest } from '../jobs.js';
8
8
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
9
9
  const GENERATE_ENDPOINT = '/v1beta/models/{model}:generateContent';
@@ -46,10 +46,15 @@ export function registerGenerateTools(server, client) {
46
46
  return gate;
47
47
  const count = args.count ?? 1;
48
48
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
49
+ // Resolve shape BEFORE fingerprinting: `orientation: 'portrait'` and
50
+ // `aspect_ratio: '9:16'` are the same upstream request, so they must
51
+ // dedup to the same job rather than billing twice (hence the resolved
52
+ // ratio in the fingerprint below, and no `orientation` alongside it).
53
+ const aspectRatio = resolveAspectRatio(args, ASPECT_RATIOS);
49
54
  // Fingerprint the request identity (excl. server-picked seed / output path)
50
55
  // so a blind re-issue after a host timeout dedups instead of re-billing.
51
56
  const fingerprint = fingerprintRequest('gemini_image_generate', {
52
- model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
57
+ model, prompt: args.prompt, aspect_ratio: aspectRatio, image_size: args.image_size,
53
58
  thinking_level: args.thinking_level, google_search: args.google_search, count,
54
59
  images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
55
60
  images_url: args.images_url, images_file_uris: args.images_file_uris, images_r2_keys: args.images_r2_keys,
@@ -92,7 +97,7 @@ export function registerGenerateTools(server, client) {
92
97
  prompt,
93
98
  images: refInputs.length > 0 ? refInputs : undefined,
94
99
  model: args.model,
95
- aspectRatio: args.aspect_ratio,
100
+ aspectRatio,
96
101
  imageSize: args.image_size,
97
102
  seed: seed + i,
98
103
  thinkingLevel: args.thinking_level,
@@ -116,7 +121,7 @@ export function registerGenerateTools(server, client) {
116
121
  });
117
122
  if (named.length === 0)
118
123
  throw firstError;
119
- const meta = buildMeta(model, seed, args);
124
+ const meta = buildMeta(model, seed, { ...args, aspect_ratio: aspectRatio });
120
125
  if (capturedText)
121
126
  meta.text = capturedText;
122
127
  if (capturedGrounding)
@@ -171,8 +176,13 @@ export function registerGenerateTools(server, client) {
171
176
  if (gate)
172
177
  return gate;
173
178
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
179
+ // Resolve shape BEFORE fingerprinting: `orientation: 'portrait'` and
180
+ // `aspect_ratio: '9:16'` are the same upstream request, so they must
181
+ // dedup to the same job rather than billing twice (hence the resolved
182
+ // ratio in the fingerprint below, and no `orientation` alongside it).
183
+ const aspectRatio = resolveAspectRatio(args, ASPECT_RATIOS);
174
184
  const fingerprint = fingerprintRequest('gemini_image_edit', {
175
- model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
185
+ model, prompt: args.prompt, aspect_ratio: aspectRatio, image_size: args.image_size,
176
186
  thinking_level: args.thinking_level, google_search: args.google_search,
177
187
  images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
178
188
  images_url: args.images_url, images_file_uris: args.images_file_uris, images_r2_keys: args.images_r2_keys,
@@ -189,7 +199,7 @@ export function registerGenerateTools(server, client) {
189
199
  prompt,
190
200
  images: inputs,
191
201
  model: args.model,
192
- aspectRatio: args.aspect_ratio,
202
+ aspectRatio,
193
203
  imageSize: args.image_size,
194
204
  seed,
195
205
  thinkingLevel: args.thinking_level,
@@ -197,7 +207,7 @@ export function registerGenerateTools(server, client) {
197
207
  timeoutMs: args.timeout_ms,
198
208
  }));
199
209
  const { images: [img], text } = result;
200
- const meta = buildMeta(model, seed, args);
210
+ const meta = buildMeta(model, seed, { ...args, aspect_ratio: aspectRatio });
201
211
  if (text)
202
212
  meta.text = text;
203
213
  if (result.grounding)
@@ -5,7 +5,7 @@ import { ChainedRequest404Error } from '../client.js';
5
5
  import { slugify, baseName, resolveImagePath, writeSidecar, resolveOutputDir, readImageAsInline } from '../images.js';
6
6
  import { resolveImageInputs } from '../inputs.js';
7
7
  import { findInteractionImages, latestInteractionId } from '../sidecar.js';
8
- import { emit, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
8
+ import { emit, resolveAspectRatio, orientationSchema, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
9
9
  import { fingerprintRequest } from '../jobs.js';
10
10
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
11
11
  // The last-interaction id and the written-outputs set used to live here, at
@@ -87,7 +87,11 @@ export function registerInteractTools(server, client) {
87
87
  .string()
88
88
  .optional()
89
89
  .describe(`Model id override (default: server default; see gemini_list_models). ${MODEL_CHOICE_GUIDE}`),
90
- aspect_ratio: z.enum(ASPECT_RATIOS).optional().describe('Output aspect ratio'),
90
+ aspect_ratio: z
91
+ .enum(ASPECT_RATIOS)
92
+ .optional()
93
+ .describe('Exact output aspect ratio. For a plain landscape/portrait/square request, `orientation` is the shorthand; this wins if both are given.'),
94
+ orientation: orientationSchema,
91
95
  image_size: z
92
96
  .enum(IMAGE_SIZES)
93
97
  .optional()
@@ -173,8 +177,10 @@ export function registerInteractTools(server, client) {
173
177
  if (gate)
174
178
  return gate;
175
179
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
180
+ // Resolved before the fingerprint so "portrait" and "9:16" are one job.
181
+ const aspectRatio = resolveAspectRatio(args, ASPECT_RATIOS);
176
182
  const fingerprint = fingerprintRequest('gemini_interact', {
177
- model, input: args.input, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
183
+ model, input: args.input, aspect_ratio: aspectRatio, image_size: args.image_size,
178
184
  thinking_level: args.thinking_level, previous_interaction_id: previousInteractionId,
179
185
  google_search: args.google_search, search_types: args.search_types,
180
186
  images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
@@ -187,7 +193,7 @@ export function registerInteractTools(server, client) {
187
193
  const callOpts = {
188
194
  input: args.input,
189
195
  model: args.model,
190
- aspectRatio: args.aspect_ratio,
196
+ aspectRatio,
191
197
  imageSize: args.image_size,
192
198
  thinkingLevel: args.thinking_level,
193
199
  googleSearch: args.google_search,
@@ -215,6 +221,15 @@ export function registerInteractTools(server, client) {
215
221
  catch (err) {
216
222
  if (!(err instanceof ChainedRequest404Error))
217
223
  throw err;
224
+ // The client already asked: if the interaction is ALIVE, the 404 was
225
+ // about something else (the model id, an expired files/… uri), and
226
+ // re-anchoring would bill a second generation to answer a question
227
+ // that is already answered — while silently dropping the
228
+ // conversation context, returning a plausible image the model built
229
+ // without the prior turn. Only probe when the chain is gone or the
230
+ // probe itself could not be completed.
231
+ if (err.chainExists === true)
232
+ throw err;
218
233
  // Re-anchoring needs the prior output image, which only the sidecar
219
234
  // index can locate — and that index lives on disk.
220
235
  if (!onDisk)
@@ -1,4 +1,8 @@
1
1
  import { z } from 'zod';
2
+ import { McpToolError, textResult } from '@chrischall/mcp-utils';
3
+ import { TERMINAL_INTERACTION_STATUSES } from '../client.js';
4
+ import { emitMedia } from './shared.js';
5
+ import { slugify } from '../images.js';
2
6
  /**
3
7
  * Poll tool for the async generation pattern (issue #52). A generation tool
4
8
  * called with `async: true` returns a `job_id` immediately (instead of blocking
@@ -15,10 +19,131 @@ export function registerJobTools(server, client) {
15
19
  'while running it reports status "running"; on completion it returns the normal result (image URLs/paths + meta); ' +
16
20
  'on failure it raises the recorded error, including the case where the generation was killed before it finished. ' +
17
21
  'On the hosted connector job records are stored durably and survive a restart; on a local stdio server they live ' +
18
- 'with the process and expire ~10 min after completion, where the output dir / <image>.json sidecar is the fallback.',
19
- annotations: { readOnlyHint: true, openWorldHint: false },
22
+ 'with the process and expire ~10 min after completion, where the output dir / <image>.json sidecar is the fallback. ' +
23
+ 'A killed video/music job started with `background: true` is recovered from its upstream interaction when it finished there.',
24
+ // NOT read-only any more: recovering a killed job writes the media it
25
+ // pulls back, exactly as the generation tool would have.
26
+ annotations: { readOnlyHint: false, openWorldHint: true },
20
27
  inputSchema: {
21
28
  job_id: z.string().min(1).describe('The job_id returned by a generation tool called with async: true'),
29
+ output_dir: z.string().optional().describe('Where to write media recovered from a killed job (default: $GEMINI_OUTPUT_DIR or cwd)'),
22
30
  },
23
- }, async (args) => client.session.jobs.getResult(args.job_id));
31
+ }, async (args) => {
32
+ try {
33
+ return await client.session.jobs.getResult(args.job_id);
34
+ }
35
+ catch (err) {
36
+ // The interesting failure is "your executor died mid-generation". If
37
+ // that job had reported an upstream interaction id, the generation
38
+ // itself may well have finished on Google's side — the machine
39
+ // stopping killed the *waiting*, not the work.
40
+ const recovered = await recoverFromInteraction(client, args.job_id, args.output_dir, err);
41
+ if (recovered)
42
+ return recovered;
43
+ throw err;
44
+ }
45
+ });
46
+ }
47
+ /**
48
+ * Try to answer a failed poll from the upstream interaction instead.
49
+ *
50
+ * Returns `undefined` when there is nothing to recover from — no record, no id,
51
+ * or an interaction that cannot be read — leaving the original (accurate)
52
+ * error to stand. Recovery must never convert a real loss into a false success,
53
+ * so every path that does not produce media falls through.
54
+ */
55
+ async function recoverFromInteraction(client, jobId, outputDir, original) {
56
+ const record = await client.session.jobs.peekRecord(jobId);
57
+ const interactionId = record?.interactionId;
58
+ if (!interactionId || record?.status !== 'failed')
59
+ return undefined;
60
+ let found;
61
+ try {
62
+ found = await client.fetchInteractionMedia(interactionId);
63
+ }
64
+ catch (probeErr) {
65
+ // A 403 (and a 404, the store lag) means the work is STILL RUNNING — that
66
+ // is what a GET of an in-flight interaction answers, verified live:
67
+ // t+20s 403, t+40s completed with the clip. Reporting "unrecoverable"
68
+ // there is the worst wrong answer available, because the caller re-pays
69
+ // for a generation that was about to land.
70
+ if (isInFlight(probeErr))
71
+ return stillRunning(jobId, interactionId);
72
+ // Anything else: a backgrounded interaction can settle into a permanent
73
+ // 400 and never become retrievable. Say so on top of the real error rather
74
+ // than pretending the output is somewhere.
75
+ throw new McpToolError(`${errorMessage(original)} Its upstream interaction "${interactionId}" could not be read either (${errorMessage(probeErr)}), so the output is not recoverable.`, { hint: 'Any media the lost run did write is still listed by gemini_list_recent_media. Otherwise the generation has to be re-run.' });
76
+ }
77
+ const [kind, media] = found.videos.length
78
+ ? ['video', found.videos]
79
+ : found.audios.length
80
+ ? ['audio', found.audios]
81
+ : found.images.length
82
+ ? ['image', found.images]
83
+ : ['image', []];
84
+ if (media.length === 0) {
85
+ // Still running upstream is a genuinely different answer from lost: the
86
+ // caller should poll again, not re-pay. But a TERMINAL status with no media
87
+ // (failed, cancelled, …) is not that — telling someone to poll again would
88
+ // loop them forever on a generation that is never going to arrive, so the
89
+ // real error stands.
90
+ if (found.status && !TERMINAL_INTERACTION_STATUSES.has(found.status))
91
+ return stillRunning(jobId, interactionId);
92
+ return undefined;
93
+ }
94
+ const slug = slugify(`recovered-${interactionId}`.slice(0, 60));
95
+ const named = media.map((m, i) => ({
96
+ media: m,
97
+ base: media.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
98
+ }));
99
+ const result = await emitMedia(named, kind, { output_dir: outputDir, sink: client.mediaSink }, {
100
+ job_id: jobId,
101
+ recovered_from_interaction: interactionId,
102
+ note: recoveryNote(record.error?.message),
103
+ ...(found.text ? { text: found.text } : {}),
104
+ });
105
+ // Settle the job with what we just recovered. Otherwise it stays `failed`
106
+ // and the next poll re-fetches the interaction, re-downloads the media and
107
+ // writes another copy — the ordinary done path is idempotent, so this one
108
+ // has to be too.
109
+ await client.session.jobs.adoptResult(jobId, record.toolName, result, interactionId);
110
+ return result;
111
+ }
112
+ /**
113
+ * Why this job needed recovering — only claimed when it is known.
114
+ *
115
+ * Recovery fires for any failed job that got as far as an interaction id, and
116
+ * that is broader than "the machine stopped": a backgrounded call whose own
117
+ * wait expired, or a sync call that failed after the POST returned, both land
118
+ * here with a perfectly healthy executor. `job-store.ts` is the only thing that
119
+ * writes the executor-lost message, so it is the only thing that licenses the
120
+ * executor-lost sentence. The two facts that hold on every path — not re-run,
121
+ * not billed twice — are stated either way.
122
+ */
123
+ function recoveryNote(recordedError) {
124
+ const cause = /was lost:/.test(recordedError ?? '')
125
+ ? 'The executor that started this job was reclaimed before it could return.'
126
+ : 'This job failed here before it could return its output.';
127
+ return `${cause} The generation had already completed upstream and its output was recovered — it was not re-run, and you were not billed twice.`;
128
+ }
129
+ /** The generation outlived its executor and is not finished yet. */
130
+ function stillRunning(jobId, interactionId) {
131
+ return textResult({
132
+ job_id: jobId,
133
+ status: 'running',
134
+ interaction_id: interactionId,
135
+ note: 'The executor that started this job is gone, but the generation is still running upstream. Poll again — it has not been lost, and re-running it would bill a second time.',
136
+ });
137
+ }
138
+ /**
139
+ * Does this probe failure mean "still working"? 403 is what a GET of an
140
+ * in-flight background interaction returns (verified live), and 404 is the
141
+ * interactions store's eventual consistency.
142
+ */
143
+ function isInFlight(err) {
144
+ const status = err?.status;
145
+ return status === 403 || status === 404;
146
+ }
147
+ function errorMessage(err) {
148
+ return err instanceof Error ? err.message : String(err);
24
149
  }
@@ -36,6 +36,7 @@ export function registerMusicTools(server, client) {
36
36
  inline: z.boolean().optional().describe('Return base64 audio inline instead of writing to disk'),
37
37
  previous_interaction_id: z.string().optional().describe('Interaction id to continue from'),
38
38
  continue_last: z.boolean().optional().describe('Continue from the most recent music interaction this server created (explicit previous_interaction_id wins)'),
39
+ background: z.boolean().optional().describe('Run the generation on Google\'s side and poll it, so a killed job can be recovered by gemini_get_result. Off by default — see gemini_video_generate'),
39
40
  timeout_ms: timeoutMsSchema,
40
41
  idempotency_key: idempotencyKeySchema,
41
42
  async: asyncSchema,
@@ -68,7 +69,7 @@ export function registerMusicTools(server, client) {
68
69
  images_url: args.images_url, images_file_uris: args.images_file_uris,
69
70
  previous_interaction_id: previousInteractionId,
70
71
  });
71
- return client.session.jobs.dispatch({ toolName: 'gemini_music_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
72
+ return client.session.jobs.dispatch({ toolName: 'gemini_music_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async (ctx) => {
72
73
  const { inputs, report } = await resolveImageInputs(args, client);
73
74
  const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () => client.generateMusic({
74
75
  input: args.prompt,
@@ -77,6 +78,9 @@ export function registerMusicTools(server, client) {
77
78
  audioFormat: args.audio_format,
78
79
  previousInteractionId,
79
80
  timeoutMs: args.timeout_ms,
81
+ background: args.background,
82
+ // See gemini_video_generate: the id is what survives the executor.
83
+ onInteractionStarted: ctx.reportInteraction,
80
84
  }));
81
85
  client.session.lastMusicInteractionId = r.id;
82
86
  const meta = { model, interaction_id: r.id };
package/dist/tools/set.js CHANGED
@@ -3,7 +3,7 @@ import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveModel } from '../models.js';
4
4
  import { slugify, baseName } from '../images.js';
5
5
  import { resolveImageInputs } from '../inputs.js';
6
- import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesR2KeysSchema, charactersSchema, styleSchema, resolveCharacterRefs, composePrompt, persistBundle, SET_URL_TTL_MS } from './shared.js';
6
+ import { emit, resolveAspectRatio, ASPECT_RATIOS, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesR2KeysSchema, charactersSchema, styleSchema, resolveCharacterRefs, composePrompt, persistBundle, SET_URL_TTL_MS } from './shared.js';
7
7
  import { fingerprintRequest } from '../jobs.js';
8
8
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
9
9
  export function registerSetTools(server, client) {
@@ -39,9 +39,14 @@ export function registerSetTools(server, client) {
39
39
  if (gate)
40
40
  return gate;
41
41
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
42
+ // Resolve shape BEFORE fingerprinting: `orientation: 'portrait'` and
43
+ // `aspect_ratio: '9:16'` are the same upstream request, so they must
44
+ // dedup to the same job rather than billing twice (hence the resolved
45
+ // ratio in the fingerprint below, and no `orientation` alongside it).
46
+ const aspectRatio = resolveAspectRatio(args, ASPECT_RATIOS);
42
47
  const fingerprint = fingerprintRequest('gemini_image_set', {
43
48
  model, master_prompt: args.master_prompt, scenes: args.scenes, count: args.count,
44
- reference_mode: args.reference_mode, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
49
+ reference_mode: args.reference_mode, aspect_ratio: aspectRatio, image_size: args.image_size,
45
50
  thinking_level: args.thinking_level, google_search: args.google_search,
46
51
  master_images: args.master_images, master_images_base64: args.master_images_base64, from_clipboard: args.from_clipboard,
47
52
  master_images_url: args.master_images_url, master_images_file_uris: args.master_images_file_uris,
@@ -49,7 +54,7 @@ export function registerSetTools(server, client) {
49
54
  });
50
55
  return client.session.jobs.dispatch({ toolName: 'gemini_image_set', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
51
56
  const seed = pickSeed(args.seed);
52
- 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 };
57
+ const cfg = { model: args.model, aspectRatio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search, timeoutMs: args.timeout_ms };
53
58
  const slug = args.basename ? baseName(args.basename) : slugify(args.master_prompt);
54
59
  // 1. master (optionally seeded from reference images).
55
60
  //
@@ -141,7 +146,7 @@ export function registerSetTools(server, client) {
141
146
  return result;
142
147
  });
143
148
  const masterText = masterResult.text;
144
- const meta = buildMeta(model, seed, args);
149
+ const meta = buildMeta(model, seed, { ...args, aspect_ratio: aspectRatio });
145
150
  if (masterText)
146
151
  meta.text = masterText;
147
152
  if (masterResult.grounding)
@@ -16,6 +16,65 @@ const DEFAULT_SINK = createDiskSink();
16
16
  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'];
17
17
  /** Supported output resolutions. `512` is Flash-only (0.5K); `1K`/`2K`/`4K` work on all image models. */
18
18
  export const IMAGE_SIZES = ['512', '1K', '2K', '4K'];
19
+ /**
20
+ * The words people actually use for shape, as a shorthand over
21
+ * {@link ASPECT_RATIOS}.
22
+ *
23
+ * Landscape and portrait output was always *possible* — every generation tool
24
+ * has taken `aspect_ratio` since the beginning. But that parameter is a
25
+ * 14-value enum described as "Output aspect ratio", with nothing linking
26
+ * "portrait" to `9:16`, so a request phrased the way a person phrases it did
27
+ * not reliably reach the API. A capability that cannot be addressed is, from
28
+ * the caller's side, a missing one.
29
+ */
30
+ export const ORIENTATIONS = ['landscape', 'portrait', 'square'];
31
+ /**
32
+ * One mapping, deliberately shared by images and video.
33
+ *
34
+ * `16:9`/`9:16` rather than a photo ratio like `3:2` so that "portrait" means
35
+ * the same shape whatever is being generated — omni offers only these two, and
36
+ * an orientation that silently meant something different per tool would be a
37
+ * worse shorthand than no shorthand. Anyone wanting 35mm or print proportions
38
+ * still names the ratio outright, which always wins (see
39
+ * {@link resolveAspectRatio}).
40
+ */
41
+ const ORIENTATION_RATIO = {
42
+ landscape: '16:9',
43
+ portrait: '9:16',
44
+ square: '1:1',
45
+ };
46
+ /**
47
+ * Settle `aspect_ratio` and `orientation` into the one value sent upstream.
48
+ *
49
+ * An explicit ratio wins: it is the more specific request, and a caller who
50
+ * named `21:9` while also saying "landscape" meant `21:9`. `supported` is the
51
+ * calling tool's own enum — the image tools accept all of
52
+ * {@link ASPECT_RATIOS}, omni accepts two — and an orientation outside it is
53
+ * REFUSED rather than rounded to the nearest shape. Quietly substituting
54
+ * `16:9` for an unsupported `square` would hand back a picture the caller
55
+ * never asked for and give them no way to tell it had been swapped.
56
+ */
57
+ export function resolveAspectRatio(opts, supported) {
58
+ if (opts.aspect_ratio)
59
+ return opts.aspect_ratio;
60
+ if (!opts.orientation)
61
+ return undefined;
62
+ const ratio = ORIENTATION_RATIO[opts.orientation];
63
+ if (!supported.includes(ratio)) {
64
+ // Remediation belongs in the MESSAGE — the host drops `hint`.
65
+ throw new McpToolError(`orientation "${opts.orientation}" (${ratio}) is not available for this model, which supports ${supported.join(' and ')}. ` +
66
+ `Ask for ${supported.map((r) => `"${r}"`).join(' or ')} via aspect_ratio, or choose another orientation.`, { hint: `Supported aspect ratios: ${supported.join(', ')}.` });
67
+ }
68
+ return ratio;
69
+ }
70
+ /** The `orientation` parameter, worded so the shorthand is discoverable. */
71
+ export const orientationSchema = z
72
+ .enum(ORIENTATIONS)
73
+ .optional()
74
+ .describe('Shape of the output, in plain terms: "landscape" (wide, 16:9), "portrait" (tall, 9:16) or "square" (1:1). ' +
75
+ 'Use this for a request phrased as landscape/portrait/vertical/horizontal. For any other proportion — ' +
76
+ '35mm photo (3:2), print (4:3), social (4:5), cinematic (21:9) — name it with aspect_ratio instead, ' +
77
+ 'which overrides this when both are given.');
19
78
  /**
20
79
  * When to pick which Nano Banana model — shared by every tool's `model` param
21
80
  * so the guidance can't drift between them.
@@ -228,7 +287,11 @@ export const sharedImageSchema = {
228
287
  .regex(/^[\w.-]+$/, 'must be a bare model id (letters, digits, ".", "_", "-")')
229
288
  .optional()
230
289
  .describe(`Model id override (default: server default; see gemini_list_models). ${MODEL_CHOICE_GUIDE}`),
231
- aspect_ratio: z.enum(ASPECT_RATIOS).optional().describe('Output aspect ratio'),
290
+ aspect_ratio: z
291
+ .enum(ASPECT_RATIOS)
292
+ .optional()
293
+ .describe('Exact output aspect ratio. For a plain landscape/portrait/square request, `orientation` is the shorthand; this wins if both are given.'),
294
+ orientation: orientationSchema,
232
295
  image_size: z.enum(IMAGE_SIZES).optional().describe('Output resolution (512 = 0.5K, Flash-only)'),
233
296
  output_dir: z.string().optional().describe('Directory to write images to (default: $GEMINI_OUTPUT_DIR or cwd)'),
234
297
  inline: z.boolean().optional().describe('Return base64 images inline instead of writing to disk'),
@@ -384,8 +447,12 @@ export function timeoutRiskHint(opts) {
384
447
  /** Build the result metadata echoed to the caller (omitting unset optionals). */
385
448
  export function buildMeta(model, seed, opts) {
386
449
  const meta = { model, seed };
450
+ // The RESOLVED ratio, and the orientation only when one was asked for — so a
451
+ // caller who said "portrait" can see both what they asked and what it became.
387
452
  if (opts.aspect_ratio)
388
453
  meta.aspect_ratio = opts.aspect_ratio;
454
+ if (opts.orientation)
455
+ meta.orientation = opts.orientation;
389
456
  if (opts.image_size)
390
457
  meta.image_size = opts.image_size;
391
458
  return meta;
@@ -3,12 +3,17 @@ import { McpToolError } from '@chrischall/mcp-utils';
3
3
  import { slugify, baseName } from '../images.js';
4
4
  import { resolveImageInputs } from '../inputs.js';
5
5
  import { DEFAULT_VIDEO_MODEL } from '../models.js';
6
- import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
6
+ import { emitMedia, resolveAspectRatio, orientationSchema, timeoutMsSchema, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
7
7
  import { fingerprintRequest } from '../jobs.js';
8
8
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
9
9
  /** omni's own aspect-ratio enum — NOT the image tools' ASPECT_RATIOS. */
10
10
  const VIDEO_ASPECT_RATIOS = ['16:9', '9:16'];
11
11
  const VIDEO_TASKS = ['text_to_video', 'image_to_video', 'reference_to_video', 'edit'];
12
+ // `delivery` is transport, not content: uri hands back a Files API link the
13
+ // client downloads with the api key, inline returns base64 capped at ~4MB.
14
+ // Left unset here so the client's verified default (uri) applies; this is an
15
+ // escape hatch for a video model that rejects delivery outright.
16
+ const VIDEO_DELIVERY = ['inline', 'uri'];
12
17
  // The most-recent video interaction id (for continue_last) lives on
13
18
  // `client.session`, NOT at module scope — one Cloudflare isolate serves many
14
19
  // authenticated connector sessions, so a module-level id would let one user's
@@ -25,7 +30,8 @@ export function registerVideoTools(server, client) {
25
30
  annotations: { readOnlyHint: false, openWorldHint: true },
26
31
  inputSchema: {
27
32
  prompt: z.string().min(1).describe('Description of the video to generate (or the edit instruction when task=edit)'),
28
- aspect_ratio: z.enum(VIDEO_ASPECT_RATIOS).optional().describe('Output aspect ratio (omni: 16:9 or 9:16)'),
33
+ aspect_ratio: z.enum(VIDEO_ASPECT_RATIOS).optional().describe('Exact output aspect ratio (omni: 16:9 or 9:16). `orientation` is the plain-language shorthand; this wins if both are given.'),
34
+ orientation: orientationSchema,
29
35
  task: z.enum(VIDEO_TASKS).optional().describe('text_to_video (default), image_to_video / reference_to_video (need image input), or edit (needs previous_interaction_id)'),
30
36
  images: z.array(z.string().min(1)).optional().describe('Reference image path(s) for image_to_video / reference_to_video'),
31
37
  images_url: imagesUrlSchema('Reference stills'),
@@ -39,6 +45,8 @@ export function registerVideoTools(server, client) {
39
45
  .regex(/^[\w.-]+$/, 'must be a bare model id (letters, digits, ".", "_", "-")')
40
46
  .optional()
41
47
  .describe(`Model id override (default: ${DEFAULT_VIDEO_MODEL})`),
48
+ delivery: z.enum(VIDEO_DELIVERY).optional().describe('How the clip comes back: "uri" (default — a Files API link the server downloads, no size ceiling) or "inline" (base64, capped ~4MB)'),
49
+ background: z.boolean().optional().describe('Run the generation on Google\'s side and poll it, so a killed job can be recovered by gemini_get_result. Trade-off: retrieving a backgrounded interaction is unreliable today (some become permanently unreadable), so the default is off'),
42
50
  previous_interaction_id: z.string().optional().describe('Interaction id to edit/continue (with task: "edit")'),
43
51
  continue_last: z.boolean().optional().describe('Continue from the most recent video interaction this server created (explicit previous_interaction_id wins)'),
44
52
  timeout_ms: timeoutMsSchema,
@@ -64,22 +72,32 @@ export function registerVideoTools(server, client) {
64
72
  if (gate)
65
73
  return gate;
66
74
  const model = args.model?.trim() || DEFAULT_VIDEO_MODEL;
75
+ // omni has no square, so `orientation: 'square'` is refused here rather
76
+ // than rounded to 16:9 — see resolveAspectRatio. Resolved before the
77
+ // fingerprint so "portrait" and "9:16" dedup to one billable job.
78
+ const aspectRatio = resolveAspectRatio(args, VIDEO_ASPECT_RATIOS);
67
79
  const fingerprint = fingerprintRequest('gemini_video_generate', {
68
- model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, task: args.task,
80
+ model, prompt: args.prompt, aspect_ratio: aspectRatio, task: args.task,
69
81
  images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
70
82
  images_url: args.images_url, images_file_uris: args.images_file_uris,
71
83
  previous_interaction_id: previousInteractionId,
72
84
  });
73
- return client.session.jobs.dispatch({ toolName: 'gemini_video_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
85
+ return client.session.jobs.dispatch({ toolName: 'gemini_video_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async (ctx) => {
74
86
  const { inputs, report } = await resolveImageInputs(args, client);
75
87
  const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client.generateVideo({
76
88
  input: args.prompt,
77
89
  images: inputs.length ? inputs : undefined,
78
90
  model: args.model,
79
- aspectRatio: args.aspect_ratio,
91
+ aspectRatio,
80
92
  task: args.task,
81
93
  previousInteractionId,
82
94
  timeoutMs: args.timeout_ms,
95
+ delivery: args.delivery,
96
+ background: args.background,
97
+ // Hand the id to the job record the moment it exists: if this
98
+ // machine stops mid-generation, that id is the only route back to
99
+ // an output that finishes upstream anyway.
100
+ onInteractionStarted: ctx.reportInteraction,
83
101
  }));
84
102
  client.session.lastVideoInteractionId = r.id;
85
103
  const meta = { model, interaction_id: r.id };
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.7.0'; // x-release-please-version
2
+ export const VERSION = '1.9.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/gemini-mcp",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "mcpName": "io.github.chrischall/gemini-mcp",
5
5
  "description": "Gemini image-generation MCP server for Claude — developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc -p tsconfig.test.json"
46
46
  },
47
47
  "dependencies": {
48
- "@chrischall/mcp-utils": "^0.14.0",
48
+ "@chrischall/mcp-utils": "^0.15.0",
49
49
  "@modelcontextprotocol/sdk": "^1.29.0",
50
50
  "dotenv": "^17.4.0",
51
51
  "zod": "^4.4.2"