@chrischall/gemini-mcp 2.1.2 → 2.1.4
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 +531 -139
- package/dist/client.js +22 -7
- package/dist/images.js +52 -2
- package/dist/library.js +3 -2
- package/dist/storage/media.js +3 -2
- package/dist/tenant.js +93 -0
- package/dist/tools/files.js +24 -9
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/server.json +2 -2
package/dist/client.js
CHANGED
|
@@ -6,6 +6,7 @@ import { createDiskSink, createR2Sink, tenantIdFor } from './storage/media.js';
|
|
|
6
6
|
import { createR2Library } from './library.js';
|
|
7
7
|
import { createUploadUrlMinter } from './upload-url.js';
|
|
8
8
|
import { blobStoreFromEnv } from './blob-store.js';
|
|
9
|
+
import { pinnedTenant } from './tenant.js';
|
|
9
10
|
import { createBlobJobStore } from './job-store.js';
|
|
10
11
|
import { SessionState } from './session.js';
|
|
11
12
|
import { fetchRemoteImage, readCapped, MAX_REDIRECTS } from './fetch-image.js';
|
|
@@ -367,6 +368,17 @@ export class GeminiClient {
|
|
|
367
368
|
const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: 'Video' });
|
|
368
369
|
return this.uploadToFilesApi(blob, mimeType, basename(path), blob.size);
|
|
369
370
|
}
|
|
371
|
+
/**
|
|
372
|
+
* Upload ANY local file (image, video, audio) by streaming it from disk —
|
|
373
|
+
* the {@link uploadVideo} path without the video label. `gemini_upload_file`
|
|
374
|
+
* `path` uses this: reading the file into a Buffer, base64-encoding it and
|
|
375
|
+
* decoding it back held ~3x the file in memory and hit V8's max string
|
|
376
|
+
* length on a few-hundred-MB video (chrischall/fleet-audit#117).
|
|
377
|
+
*/
|
|
378
|
+
async uploadFile(path, mimeType, displayName = basename(path)) {
|
|
379
|
+
const blob = await fileBlob(path, { type: mimeType, maxBytes: FILE_MAX_BYTES, label: 'File' });
|
|
380
|
+
return this.uploadToFilesApi(blob, mimeType, displayName, blob.size);
|
|
381
|
+
}
|
|
370
382
|
/**
|
|
371
383
|
* Upload already-in-memory bytes (a fetched `images_url`, a `data_base64`
|
|
372
384
|
* payload, an inline `gemini_upload_file` body) to the Files API. Same three
|
|
@@ -1155,15 +1167,18 @@ function isDeliveryUnsupported(err) {
|
|
|
1155
1167
|
*
|
|
1156
1168
|
* Side-effect-free at module scope: reading two env vars and closing over them.
|
|
1157
1169
|
*/
|
|
1158
|
-
function hostedStorage() {
|
|
1159
|
-
const blob = blobStoreFromEnv();
|
|
1170
|
+
export function hostedStorage(blob = blobStoreFromEnv()) {
|
|
1160
1171
|
if (!blob)
|
|
1161
1172
|
return {};
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
1166
|
-
|
|
1173
|
+
// One registration is one user here, so the namespace is belt-and-braces
|
|
1174
|
+
// rather than the tenancy boundary — that is the blob store's
|
|
1175
|
+
// per-registration key. It must be STABLE, though: it used to be a hash of
|
|
1176
|
+
// GEMINI_API_KEY, so rotating the key orphaned the whole library, every job
|
|
1177
|
+
// record and every chain sidecar (chrischall/fleet-audit#116). It is now
|
|
1178
|
+
// pinned in the store on first use — derived from the current key so the
|
|
1179
|
+
// data already there stays reachable — and never re-derived after that.
|
|
1180
|
+
// Lazy because the key is read at request time (deferred-config-error).
|
|
1181
|
+
const tenant = pinnedTenant(blob.bucket, () => tenantIdFor(readEnvVar('GEMINI_API_KEY') ?? 'local'));
|
|
1167
1182
|
return {
|
|
1168
1183
|
mediaSink: createR2Sink(blob.bucket, {
|
|
1169
1184
|
// The SAME `links` object the minter gets below. Media GETs and upload
|
package/dist/images.js
CHANGED
|
@@ -31,8 +31,12 @@ export async function uniquePath(dir, base, ext) {
|
|
|
31
31
|
}
|
|
32
32
|
return candidate;
|
|
33
33
|
}
|
|
34
|
-
/** Sniff MIME type from the first bytes of an image buffer. */
|
|
34
|
+
/** Sniff MIME type from the first bytes of an image buffer (PNG fallback). */
|
|
35
35
|
function sniffMimeBytes(buf) {
|
|
36
|
+
return sniffImageMime(buf) ?? 'image/png';
|
|
37
|
+
}
|
|
38
|
+
/** PNG/JPEG/WebP from the leading bytes, or `undefined` when it is none of them. */
|
|
39
|
+
function sniffImageMime(buf) {
|
|
36
40
|
// PNG: 89 50 4E 47
|
|
37
41
|
if (buf.length >= 4 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
|
|
38
42
|
return 'image/png';
|
|
@@ -47,7 +51,7 @@ function sniffMimeBytes(buf) {
|
|
|
47
51
|
buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) {
|
|
48
52
|
return 'image/webp';
|
|
49
53
|
}
|
|
50
|
-
return
|
|
54
|
+
return undefined;
|
|
51
55
|
}
|
|
52
56
|
/** File extension for a media MIME (image/video/audio). Falls back to the MIME
|
|
53
57
|
* subtype, so an unforeseen preview type still writes with a sane extension. */
|
|
@@ -185,6 +189,52 @@ export async function previewVideoInput(path) {
|
|
|
185
189
|
const { size } = await stat(resolved);
|
|
186
190
|
return { path: resolved, mimeType: videoMimeType(resolved), size };
|
|
187
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Image and audio types the Files API accepts, by extension. Video comes from
|
|
194
|
+
* {@link VIDEO_MIME_BY_EXT} so the two lists cannot disagree.
|
|
195
|
+
*/
|
|
196
|
+
const UPLOAD_MIME_BY_EXT = {
|
|
197
|
+
png: 'image/png',
|
|
198
|
+
jpg: 'image/jpeg',
|
|
199
|
+
jpeg: 'image/jpeg',
|
|
200
|
+
webp: 'image/webp',
|
|
201
|
+
gif: 'image/gif',
|
|
202
|
+
heic: 'image/heic',
|
|
203
|
+
heif: 'image/heif',
|
|
204
|
+
wav: 'audio/wav',
|
|
205
|
+
mp3: 'audio/mp3',
|
|
206
|
+
aiff: 'audio/aiff',
|
|
207
|
+
aif: 'audio/aiff',
|
|
208
|
+
aac: 'audio/aac',
|
|
209
|
+
ogg: 'audio/ogg',
|
|
210
|
+
flac: 'audio/flac',
|
|
211
|
+
...VIDEO_MIME_BY_EXT,
|
|
212
|
+
};
|
|
213
|
+
async function readHead(path, n = 16) {
|
|
214
|
+
const fh = await open(path, 'r');
|
|
215
|
+
try {
|
|
216
|
+
const head = Buffer.alloc(n);
|
|
217
|
+
const { bytesRead } = await fh.read(head, 0, n, 0);
|
|
218
|
+
return head.subarray(0, bytesRead);
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
await fh.close();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* The MIME a local file should be uploaded to the Files API as, or `undefined`
|
|
226
|
+
* when it cannot be identified. Bytes win for the image formats we can sniff
|
|
227
|
+
* (a PNG saved as `.jpg` is still a PNG); otherwise the extension decides,
|
|
228
|
+
* over image, video AND audio types. Never guesses: `gemini_upload_file` used
|
|
229
|
+
* to label every unrecognised file image/png (chrischall/fleet-audit#117).
|
|
230
|
+
*/
|
|
231
|
+
export async function detectUploadMime(resolvedPath) {
|
|
232
|
+
const sniffed = sniffImageMime(await readHead(resolvedPath));
|
|
233
|
+
if (sniffed)
|
|
234
|
+
return sniffed;
|
|
235
|
+
const ext = resolvedPath.includes('.') ? resolvedPath.slice(resolvedPath.lastIndexOf('.') + 1).toLowerCase() : '';
|
|
236
|
+
return UPLOAD_MIME_BY_EXT[ext];
|
|
237
|
+
}
|
|
188
238
|
/** Read an image file into `{ base64, mimeType }` for an inline_data part. */
|
|
189
239
|
export async function readImageAsInline(path) {
|
|
190
240
|
const resolved = resolveImagePath(path);
|
package/dist/library.js
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*
|
|
30
30
|
* Worker-safe: no node imports, no module-scope state, structural bucket type.
|
|
31
31
|
*/
|
|
32
|
+
import { tenantResolver } from './tenant.js';
|
|
32
33
|
/** Key prefix for the library — the one prefix the retention sweep must skip. */
|
|
33
34
|
export const LIBRARY_KEY_PREFIX = 'lib';
|
|
34
35
|
/**
|
|
@@ -42,8 +43,8 @@ export function isValidLibraryName(name) {
|
|
|
42
43
|
/** Build the R2-backed library for one session. */
|
|
43
44
|
export function createR2Library(bucket, opts) {
|
|
44
45
|
const now = opts.now ?? (() => new Date());
|
|
45
|
-
|
|
46
|
-
const tenantId = (
|
|
46
|
+
// Cached per library; a rejected resolution is retried, not cached.
|
|
47
|
+
const tenantId = tenantResolver(opts.tenant);
|
|
47
48
|
const assertName = (name) => {
|
|
48
49
|
if (!isValidLibraryName(name)) {
|
|
49
50
|
throw new Error(`Invalid library name "${name}" — use 1-40 lowercase letters, digits, "-" or "_" (e.g. "finn", "bold-cartoon-sports").`);
|
package/dist/storage/media.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { base64ToBytes } from '../bytes.js';
|
|
16
16
|
import { signedLinks } from '../signed-url.js';
|
|
17
|
+
import { tenantResolver } from '../tenant.js';
|
|
17
18
|
/**
|
|
18
19
|
* The stdio sink: `resolveOutputDir(output_dir)` → `writeMedia` per item, in
|
|
19
20
|
* order (so `uniquePath`'s `name`/`name-2`/`name-3` sequencing is preserved).
|
|
@@ -141,8 +142,8 @@ export function createR2Sink(bucket, opts) {
|
|
|
141
142
|
const signedBase = opts.links?.base ?? opts.signedBaseUrl?.replace(/\/+$/, '');
|
|
142
143
|
const ttlMs = opts.urlTtlMs ?? 0;
|
|
143
144
|
const readPrefixes = [prefix, ...(opts.readPrefixes ?? [])];
|
|
144
|
-
|
|
145
|
-
const tenantId = (
|
|
145
|
+
// Cached per sink; a rejected resolution is retried, not cached.
|
|
146
|
+
const tenantId = tenantResolver(opts.tenant);
|
|
146
147
|
/** `<prefix>/` or `<prefix>/<tenant>/` — where every WRITE goes. */
|
|
147
148
|
const ownPrefix = async () => {
|
|
148
149
|
const tenant = await tenantId();
|
package/dist/tenant.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hosted namespace (`lib/<tenant>/`, `jobs/<tenant>/`, `gen/<tenant>/`,
|
|
3
|
+
* `up/<tenant>/`), pinned once per registration.
|
|
4
|
+
*
|
|
5
|
+
* It used to be `tenantIdFor(GEMINI_API_KEY)`, recomputed every process. That
|
|
6
|
+
* made the API key — the one credential the healthcheck tells the user to
|
|
7
|
+
* rotate on `credential_rejected` — the address of everything persistent: a
|
|
8
|
+
* rotation silently moved the whole namespace, and the character/style library
|
|
9
|
+
* (advertised as having NO expiry), durable job records and chain sidecars all
|
|
10
|
+
* came back empty (chrischall/fleet-audit#116).
|
|
11
|
+
*
|
|
12
|
+
* The real tenancy boundary is the blob store's per-registration key, so the
|
|
13
|
+
* tenant only needs to be stable, not secret-derived. It is now read from a
|
|
14
|
+
* small pin object in the registration's own store; the first process to find
|
|
15
|
+
* none derives it the old way and writes it. Deriving from the CURRENT key on
|
|
16
|
+
* first use is deliberate: that is exactly where every existing deployment's
|
|
17
|
+
* data already lives, so the change needs no migration.
|
|
18
|
+
*/
|
|
19
|
+
/** Under `lib/` so the host's retention prune never reclaims it (blob-store.ts). */
|
|
20
|
+
export const TENANT_PIN_KEY = 'lib/tenant.json';
|
|
21
|
+
/** Same shape `tenantIdFor` produces; anything else in the pin is ignored. */
|
|
22
|
+
const TENANT_SHAPE = /^[0-9a-f]{12}$/;
|
|
23
|
+
async function readPin(bucket) {
|
|
24
|
+
const object = await bucket.get(TENANT_PIN_KEY);
|
|
25
|
+
if (!object)
|
|
26
|
+
return undefined;
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(new TextDecoder().decode(await object.arrayBuffer()));
|
|
29
|
+
return typeof parsed.tenant === 'string' && TENANT_SHAPE.test(parsed.tenant) ? parsed.tenant : undefined;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A memoised tenant resolver for the hosted stores.
|
|
37
|
+
*
|
|
38
|
+
* Only a fully successful resolution is cached: a failed read rejects and is
|
|
39
|
+
* retried on the next call, and a failed pin WRITE still returns the derived
|
|
40
|
+
* tenant (so a transient blob hiccup does not fail the tool call) but leaves
|
|
41
|
+
* the pin to be written next time.
|
|
42
|
+
*/
|
|
43
|
+
export function pinnedTenant(bucket, derive) {
|
|
44
|
+
let cached;
|
|
45
|
+
async function resolve() {
|
|
46
|
+
const pinned = await readPin(bucket);
|
|
47
|
+
if (pinned)
|
|
48
|
+
return { tenant: pinned, pinned: true };
|
|
49
|
+
const tenant = await derive();
|
|
50
|
+
try {
|
|
51
|
+
await bucket.put(TENANT_PIN_KEY, JSON.stringify({ tenant }), {
|
|
52
|
+
httpMetadata: { contentType: 'application/json' },
|
|
53
|
+
});
|
|
54
|
+
return { tenant, pinned: true };
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { tenant, pinned: false };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return () => {
|
|
61
|
+
if (cached)
|
|
62
|
+
return cached;
|
|
63
|
+
const attempt = resolve().then(({ tenant, pinned }) => {
|
|
64
|
+
if (!pinned && cached === attempt)
|
|
65
|
+
cached = undefined;
|
|
66
|
+
return tenant;
|
|
67
|
+
});
|
|
68
|
+
cached = attempt;
|
|
69
|
+
attempt.catch(() => {
|
|
70
|
+
if (cached === attempt)
|
|
71
|
+
cached = undefined;
|
|
72
|
+
});
|
|
73
|
+
return attempt;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Memoise a tenant source (a static value, or a possibly-async resolver) for
|
|
78
|
+
* one sink/library. Concurrent callers share one in-flight resolution and a
|
|
79
|
+
* success is cached for the process, but a REJECTED resolution is dropped, not
|
|
80
|
+
* cached: the hosted tenant is read from the store (`pinnedTenant`), and one
|
|
81
|
+
* transient failure must not wedge its consumer for the rest of the process.
|
|
82
|
+
*/
|
|
83
|
+
export function tenantResolver(source) {
|
|
84
|
+
let cache;
|
|
85
|
+
return () => {
|
|
86
|
+
if (cache)
|
|
87
|
+
return cache;
|
|
88
|
+
const attempt = Promise.resolve(typeof source === 'function' ? source() : source);
|
|
89
|
+
attempt.catch(() => { if (cache === attempt)
|
|
90
|
+
cache = undefined; });
|
|
91
|
+
return (cache = attempt);
|
|
92
|
+
};
|
|
93
|
+
}
|
package/dist/tools/files.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
2
3
|
import { McpToolError, minifiedResult } from '@chrischall/mcp-utils';
|
|
3
4
|
import { withProgressHeartbeat } from './shared.js';
|
|
4
5
|
import { base64ToBytes, bytesToBase64, formatMb, wholeMb } from '../bytes.js';
|
|
5
6
|
import { fileNameFromUrl } from '../fetch-image.js';
|
|
6
7
|
import { downloadFilename } from '../media-name.js';
|
|
7
|
-
import { previewUnlessConfirmed,
|
|
8
|
+
import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
8
9
|
/**
|
|
9
10
|
* First-class Gemini Files API access.
|
|
10
11
|
*
|
|
@@ -134,18 +135,34 @@ export function registerFileTools(server, client) {
|
|
|
134
135
|
? 'Provide exactly one of `url`, `data_base64`' + (onDisk ? ' or `path`.' : ' or `r2_key`.')
|
|
135
136
|
: `Provide exactly one source, not ${sources.length} (${sources.join(', ')}).`);
|
|
136
137
|
}
|
|
138
|
+
let localUpload;
|
|
137
139
|
if (args.path) {
|
|
138
140
|
if (!onDisk) {
|
|
139
141
|
throw new McpToolError('`path` is unavailable on the hosted connector: it runs on a remote machine with no access to your filesystem. ' +
|
|
140
142
|
'Use `url` (the server downloads it), `r2_key` after PUTting the file to a URL from gemini_get_upload_url, or ' +
|
|
141
143
|
'`data_base64` if you cannot make HTTP requests at all.', { hint: 'Use `url`, `r2_key` (gemini_get_upload_url), or `data_base64`.' });
|
|
142
144
|
}
|
|
145
|
+
// Resolve and identify BEFORE the gate, so the preview shows the MIME
|
|
146
|
+
// that will actually be sent. An unidentifiable file is refused rather
|
|
147
|
+
// than labelled image/png — the old image-only sniffer's default, which
|
|
148
|
+
// shipped every video, audio clip, GIF and HEIC mislabelled (#117).
|
|
149
|
+
const { resolveImagePath, detectUploadMime } = await import('../images.js');
|
|
150
|
+
const resolved = resolveImagePath(args.path);
|
|
151
|
+
const mimeType = args.mime_type ?? (await detectUploadMime(resolved));
|
|
152
|
+
if (!mimeType) {
|
|
153
|
+
throw new McpToolError(`Cannot tell what kind of file ${resolved} is from its bytes or extension — pass \`mime_type\` ` +
|
|
154
|
+
'(e.g. video/mp4, audio/mp3, image/gif) so it is not uploaded under the wrong type.', { hint: 'Pass mime_type explicitly.' });
|
|
155
|
+
}
|
|
143
156
|
// Same gate as every other local-file input: a prompt-injected `path`
|
|
144
157
|
// would otherwise ship an arbitrary local file to Google, and an
|
|
145
158
|
// uploaded file is readable by anyone holding the api key for ~48h.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
return
|
|
159
|
+
if (args.confirm !== true) {
|
|
160
|
+
const { size } = await stat(resolved);
|
|
161
|
+
return previewUnlessConfirmed(args.confirm, 'Upload a local file to the Gemini Files API', 'POST', '/upload/v1beta/files', {
|
|
162
|
+
inputs: [{ path: resolved, mimeType, size }],
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
localUpload = { path: resolved, mimeType };
|
|
149
166
|
}
|
|
150
167
|
const uploaded = await withProgressHeartbeat(extra, 'Uploading to the Gemini Files API', async () => {
|
|
151
168
|
if (args.url) {
|
|
@@ -172,11 +189,9 @@ export function registerFileTools(server, client) {
|
|
|
172
189
|
const bytes = base64ToBytes(decoded.base64);
|
|
173
190
|
return client.uploadBytes(bytes, args.mime_type ?? decoded.mimeType, args.display_name ?? 'upload');
|
|
174
191
|
}
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
const bytes = base64ToBytes(inline.base64);
|
|
179
|
-
return client.uploadBytes(bytes, args.mime_type ?? inline.mimeType, args.display_name ?? (resolved.split(/[\\/]/).pop() || 'upload'));
|
|
192
|
+
// Streamed from disk (fileBlob), never buffered and re-encoded.
|
|
193
|
+
const local = localUpload;
|
|
194
|
+
return client.uploadFile(local.path, local.mimeType, args.display_name ?? (local.path.split(/[\\/]/).pop() || 'upload'));
|
|
180
195
|
});
|
|
181
196
|
return uploadResult(uploaded);
|
|
182
197
|
});
|
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 = '2.1.
|
|
2
|
+
export const VERSION = '2.1.4'; // x-release-please-version
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrischall/gemini-mcp",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.4",
|
|
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>",
|
|
@@ -46,10 +46,10 @@
|
|
|
46
46
|
"typecheck": "tsc -p tsconfig.test.json"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@chrischall/mcp-utils": "^
|
|
49
|
+
"@chrischall/mcp-utils": "^2.4.0",
|
|
50
50
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
51
51
|
"dotenv": "^17.4.0",
|
|
52
|
-
"zod": "^4.6.
|
|
52
|
+
"zod": "^4.6.5"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@modelcontextprotocol/client": "^2.0.0",
|
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": "2.1.
|
|
9
|
+
"version": "2.1.4",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/gemini-mcp",
|
|
14
|
-
"version": "2.1.
|
|
14
|
+
"version": "2.1.4",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|