@chrischall/gemini-mcp 1.7.0 → 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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bundle.js +474 -70
- package/dist/client.js +382 -34
- package/dist/fetch-image.js +19 -7
- package/dist/jobs.js +71 -2
- package/dist/tools/interact.js +9 -0
- package/dist/tools/jobs.js +128 -3
- package/dist/tools/music.js +5 -1
- package/dist/tools/video.js +14 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
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
|
-
|
|
409
|
-
|
|
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) {
|
package/dist/tools/interact.js
CHANGED
|
@@ -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)
|
package/dist/tools/jobs.js
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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
|
}
|
package/dist/tools/music.js
CHANGED
|
@@ -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/video.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
9
|
+
"version": "1.8.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/gemini-mcp",
|
|
14
|
-
"version": "1.
|
|
14
|
+
"version": "1.8.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|