@chrischall/gemini-mcp 1.6.2 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -45,6 +45,30 @@ export async function tenantIdFor(secret) {
45
45
  // `gen`, not `media`: the objects are served at `/media/<key>`, and a `media`
46
46
  // prefix would make every URL read `/media/media/…`.
47
47
  const KEY_PREFIX_DEFAULT = 'gen';
48
+ /**
49
+ * How many listing pages one `listRecent` call will walk. A listing walk is
50
+ * unbounded work and this runs on a tool call, so it is capped — and the cap
51
+ * is REPORTED (see `listRecentPage`) rather than silently truncating.
52
+ */
53
+ const MAX_LIST_PAGES = 10;
54
+ /**
55
+ * Split a stored key back into the day it was written and the name it was asked
56
+ * for — the inverse of the key format `persist` builds.
57
+ *
58
+ * `undefined` for anything that is not a dated object key, so a stray object
59
+ * under the prefix is skipped rather than turned into a listing failure.
60
+ */
61
+ function parseMediaKey(key, prefix) {
62
+ const rest = key.slice(prefix.length);
63
+ const match = /^(\d{4}-\d{2}-\d{2})\/(.+)$/.exec(rest);
64
+ if (!match)
65
+ return undefined;
66
+ // Drop the random id `persist` prepends and the extension, leaving the
67
+ // caller's own slug — which is what makes a listing recognisable to a human
68
+ // looking for "the summer camp ones".
69
+ const name = match[2].replace(/^[0-9a-f]{4,}-/i, '').replace(/\.[A-Za-z0-9]+$/, '');
70
+ return { day: match[1], name: name || match[2] };
71
+ }
48
72
  /** Object-key-safe name: no slashes, no dot-segments, never empty. */
49
73
  export function safeKeySegment(base) {
50
74
  const cleaned = base
@@ -103,6 +127,52 @@ export function createR2Sink(bucket, opts) {
103
127
  mimeType: object.httpMetadata?.contentType ?? 'application/octet-stream',
104
128
  };
105
129
  },
130
+ async listRecent(opts = {}) {
131
+ return (await this.listRecentPage(opts)).media;
132
+ },
133
+ async listRecentPage({ limit = 20, sinceDay } = {}) {
134
+ if (!bucket.list)
135
+ return { media: [], truncated: false, scannedPages: 0 };
136
+ // Scoped at the STORE, not filtered afterwards: asking only for our own
137
+ // prefix means another account's keys are never in hand to leak by a
138
+ // filtering mistake.
139
+ const prefix = await ownPrefix();
140
+ const found = [];
141
+ let cursor;
142
+ let truncated = false;
143
+ let scannedPages = 0;
144
+ // Bounded: a listing walk is unbounded work, and this runs on a tool call.
145
+ for (let page = 0; page < MAX_LIST_PAGES; page++) {
146
+ scannedPages = page + 1;
147
+ const listed = await bucket.list({ prefix, cursor, limit: 1000 });
148
+ for (const obj of listed.objects) {
149
+ const parsed = parseMediaKey(obj.key, prefix);
150
+ if (!parsed)
151
+ continue; // not a dated object key — skip, never throw
152
+ if (sinceDay && parsed.day < sinceDay)
153
+ continue;
154
+ found.push({ ...parsed, key: obj.key, ...(obj.size !== undefined ? { sizeBytes: obj.size } : {}) });
155
+ }
156
+ if (!listed.truncated || !listed.cursor)
157
+ break;
158
+ cursor = listed.cursor;
159
+ // Ran out of budget with more to read: the answer is a subset, and
160
+ // must say so rather than passing for the whole picture.
161
+ if (page === MAX_LIST_PAGES - 1)
162
+ truncated = true;
163
+ }
164
+ // Keys sort lexicographically by day, and the id after it is random, so
165
+ // ordering is by day — which is the honest grain: the store records no
166
+ // per-object timestamp we could do better with.
167
+ found.sort((a, b) => (a.day < b.day ? 1 : a.day > b.day ? -1 : a.key < b.key ? 1 : -1));
168
+ const page = found.slice(0, limit);
169
+ const expiresAtMs = now().getTime() + ttlMs;
170
+ const media = await Promise.all(page.map(async (entry) => {
171
+ const link = await describe(entry.key, expiresAtMs);
172
+ return { ...entry, ...(link.unavailable ? {} : { url: link.ref, expiresAt: link.expiresAt }) };
173
+ }));
174
+ return { media, truncated: truncated || found.length > limit, scannedPages };
175
+ },
106
176
  async resign(key) {
107
177
  if (!(await owned(key)))
108
178
  return undefined;
@@ -228,6 +228,55 @@ export function registerFileTools(server, client) {
228
228
  hint: 'In a Claude chat client, download this URL into your sandbox and present it as an output file — inline image blocks and markdown embeds do not render for the end user.',
229
229
  });
230
230
  });
231
+ // The recovery path for media whose RESULT was lost rather than never
232
+ // produced — a background job killed with its machine still wrote its
233
+ // images, but every reference to them went with the response. Without this
234
+ // the only way to see them again is to pay for the generation twice.
235
+ server.registerTool('gemini_list_recent_media', {
236
+ description: 'List images/video/audio this connector recently generated for your account, newest first, with fresh openable URLs. ' +
237
+ 'Use it when a result was lost — a generation whose response never arrived, or an async job whose id came back unknown — ' +
238
+ 'to find what already exists BEFORE re-running (and re-paying for) it. Keywords: lost result, orphaned job, ' +
239
+ 'missing image, what did I generate, recover, recent media.',
240
+ annotations: { readOnlyHint: true, openWorldHint: false },
241
+ inputSchema: {
242
+ limit: z.number().int().positive().max(100).optional().describe('How many to return (default 20, newest first)'),
243
+ since_day: z
244
+ .string()
245
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
246
+ .optional()
247
+ .describe('Only media stored on or after this UTC day, e.g. "2026-08-18"'),
248
+ },
249
+ }, async (args) => {
250
+ const sink = client.mediaSink;
251
+ if (!sink?.listRecent) {
252
+ throw new McpToolError('This server writes generated files to disk — list the output directory instead.', {
253
+ hint: 'gemini_list_recent_media applies to the hosted connector only.',
254
+ });
255
+ }
256
+ const limit = args.limit ?? 20;
257
+ const page = sink.listRecentPage
258
+ ? await sink.listRecentPage({ limit, sinceDay: args.since_day })
259
+ : { media: await sink.listRecent({ limit, sinceDay: args.since_day }), truncated: false };
260
+ const found = page.media;
261
+ return textResult({
262
+ count: found.length,
263
+ // Never let a bounded walk pass for the whole picture: concluding an
264
+ // image is gone is what makes someone pay to generate it twice.
265
+ ...(page.truncated ? { truncated: true } : {}),
266
+ media: found.map((m) => ({
267
+ r2_key: m.key,
268
+ day: m.day,
269
+ name: m.name,
270
+ ...(m.sizeBytes !== undefined ? { size_bytes: m.sizeBytes } : {}),
271
+ ...(m.url ? { url: m.url, curl_hint: `curl -sS -o ${downloadFilename(m.key)} "${m.url}"` } : {}),
272
+ ...(m.expiresAt ? { expires_at: m.expiresAt } : {}),
273
+ })),
274
+ hint: found.length
275
+ ? 'Ordered by the day each object was stored (the store keeps no finer timestamp), newest first. Re-sign any expired link with gemini_sign_media.' +
276
+ (page.truncated ? ' More media exists than is listed here — narrow with since_day, or raise limit, before concluding something is missing.' : '')
277
+ : 'Nothing stored in that window. Media is swept on a retention schedule, so an older generation may already be gone.',
278
+ });
279
+ });
231
280
  }
232
281
  server.registerTool('gemini_delete_file', {
233
282
  description: 'Delete an uploaded file, image or photo (by file_uri) from the Gemini Files API before its ~48h expiry. Any tool call still referencing it ' +
@@ -215,6 +215,15 @@ export function registerInteractTools(server, client) {
215
215
  catch (err) {
216
216
  if (!(err instanceof ChainedRequest404Error))
217
217
  throw err;
218
+ // The client already asked: if the interaction is ALIVE, the 404 was
219
+ // about something else (the model id, an expired files/… uri), and
220
+ // re-anchoring would bill a second generation to answer a question
221
+ // that is already answered — while silently dropping the
222
+ // conversation context, returning a plausible image the model built
223
+ // without the prior turn. Only probe when the chain is gone or the
224
+ // probe itself could not be completed.
225
+ if (err.chainExists === true)
226
+ throw err;
218
227
  // Re-anchoring needs the prior output image, which only the sidecar
219
228
  // index can locate — and that index lives on disk.
220
229
  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
@@ -11,10 +15,135 @@ import { z } from 'zod';
11
15
  // other session's result by job id — see src/session.ts.
12
16
  export function registerJobTools(server, client) {
13
17
  server.registerTool('gemini_get_result', {
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.',
15
- annotations: { readOnlyHint: true, openWorldHint: false },
18
+ description: 'Retrieve a generation started with `async: true` or handed off by `max_wait_ms`. Pass the returned `job_id`: ' +
19
+ 'while running it reports status "running"; on completion it returns the normal result (image URLs/paths + meta); ' +
20
+ 'on failure it raises the recorded error, including the case where the generation was killed before it finished. ' +
21
+ 'On the hosted connector job records are stored durably and survive a restart; on a local stdio server they live ' +
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 },
16
27
  inputSchema: {
17
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)'),
18
30
  },
19
- }, 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);
20
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 };
@@ -55,7 +55,10 @@ export const idempotencyKeySchema = z
55
55
  export const asyncSchema = z
56
56
  .boolean()
57
57
  .optional()
58
- .describe('Run in the background and return a job_id immediately instead of the image, so a long (Pro/4K) generation cannot hit the host tools/call timeout (-32001). Poll gemini_get_result with the job_id to fetch the result (jobs are per-process and expire ~10 min after completion).');
58
+ .describe('Run in the background and return a job_id immediately instead of the image, so a long (Pro/4K) generation cannot hit ' +
59
+ 'the host tools/call timeout (-32001). Poll gemini_get_result with the job_id to fetch the result. ' +
60
+ 'PREFER `max_wait_ms` on the hosted connector: it runs where the executor is only guaranteed to stay alive while the ' +
61
+ 'request is open, so this option is served there as a bounded wait rather than an immediate hand-off.');
59
62
  /**
60
63
  * Wait-then-hand-off budget shared by every generation tool: wait up to the
61
64
  * budget for the in-band result, and past it return a `{ job_id }` handle
@@ -361,10 +364,17 @@ export function timeoutRiskHint(opts) {
361
364
  .join(' + ');
362
365
  const lead = `Slow config (${why}) can exceed a host's tools/call timeout (e.g. Claude Desktop ~30s, error -32001). `;
363
366
  if (opts.persistsFiles === false) {
367
+ // Deliberately NOT `async: true`. This connector runs on a machine that is
368
+ // stopped whenever no request is in flight, so an immediate hand-off is
369
+ // what gets a generation killed mid-flight; holding the request open is
370
+ // what keeps it alive. Recommending async here was recommending the
371
+ // failure mode (see src/job-store.ts).
364
372
  return (lead +
365
373
  `This connector has no filesystem, so a lost response cannot be recovered from disk — there is no output dir and no ` +
366
- `<image>.json sidecar. Use \`async: true\` to get a job_id immediately and poll gemini_get_result, ` +
367
- `or use a Flash model / drop image_size to 1K/2K for a faster in-band result.`);
374
+ `<image>.json sidecar. Set \`max_wait_ms\` (e.g. 240000): keeping the request open is also what keeps the generation ` +
375
+ `running here, and a batch that outlives the budget hands back a job_id to poll with gemini_get_result. ` +
376
+ `For a faster in-band result use a Flash model or drop image_size to 1K/2K. If a result is ever lost, ` +
377
+ `list what was already generated with gemini_list_recent_media before paying for it again.`);
368
378
  }
369
379
  return (lead +
370
380
  `If the host gives up mid-generation the image is still written to disk (and gemini_interact leaves an ` +
@@ -9,6 +9,11 @@ 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
@@ -39,6 +44,8 @@ export function registerVideoTools(server, client) {
39
44
  .regex(/^[\w.-]+$/, 'must be a bare model id (letters, digits, ".", "_", "-")')
40
45
  .optional()
41
46
  .describe(`Model id override (default: ${DEFAULT_VIDEO_MODEL})`),
47
+ 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)'),
48
+ 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
49
  previous_interaction_id: z.string().optional().describe('Interaction id to edit/continue (with task: "edit")'),
43
50
  continue_last: z.boolean().optional().describe('Continue from the most recent video interaction this server created (explicit previous_interaction_id wins)'),
44
51
  timeout_ms: timeoutMsSchema,
@@ -70,7 +77,7 @@ export function registerVideoTools(server, client) {
70
77
  images_url: args.images_url, images_file_uris: args.images_file_uris,
71
78
  previous_interaction_id: previousInteractionId,
72
79
  });
73
- return client.session.jobs.dispatch({ toolName: 'gemini_video_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
80
+ 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
81
  const { inputs, report } = await resolveImageInputs(args, client);
75
82
  const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client.generateVideo({
76
83
  input: args.prompt,
@@ -80,6 +87,12 @@ export function registerVideoTools(server, client) {
80
87
  task: args.task,
81
88
  previousInteractionId,
82
89
  timeoutMs: args.timeout_ms,
90
+ delivery: args.delivery,
91
+ background: args.background,
92
+ // Hand the id to the job record the moment it exists: if this
93
+ // machine stops mid-generation, that id is the only route back to
94
+ // an output that finishes upstream anyway.
95
+ onInteractionStarted: ctx.reportInteraction,
83
96
  }));
84
97
  client.session.lastVideoInteractionId = r.id;
85
98
  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.6.2'; // x-release-please-version
2
+ export const VERSION = '1.8.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/gemini-mcp",
3
- "version": "1.6.2",
3
+ "version": "1.8.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>",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/gemini-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.6.2",
9
+ "version": "1.8.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "1.6.2",
14
+ "version": "1.8.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },