@chrischall/gemini-mcp 1.12.0 → 1.14.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/README.md +12 -3
- package/dist/bundle.js +7402 -2377
- package/dist/client.js +38 -35
- package/dist/inputs.js +80 -3
- package/dist/jobs.js +49 -15
- package/dist/models.js +14 -4
- package/dist/pricing.js +9 -1
- package/dist/session.js +7 -6
- package/dist/storage/media.js +265 -42
- package/dist/tools/_confirm.js +2 -2
- package/dist/tools/files.js +73 -17
- package/dist/tools/generate.js +5 -5
- package/dist/tools/interact.js +65 -32
- package/dist/tools/jobs.js +2 -2
- package/dist/tools/library.js +7 -7
- package/dist/tools/models.js +2 -2
- package/dist/tools/music.js +31 -44
- package/dist/tools/set.js +3 -3
- package/dist/tools/shared.js +74 -43
- package/dist/tools/uploads.js +8 -8
- package/dist/tools/usage.js +2 -2
- package/dist/tools/video.js +27 -10
- package/dist/upload-url.js +3 -3
- package/dist/version.js +1 -1
- package/mint.yaml +6 -0
- package/package.json +4 -4
- package/server.json +2 -2
- package/skills/gemini-mcp/SKILL.md +16 -8
package/dist/client.js
CHANGED
|
@@ -175,7 +175,7 @@ export function normalizeFileName(ref) {
|
|
|
175
175
|
const match = /(?:^|\/)(files\/[\w-]+)\/?$/.exec(trimmed);
|
|
176
176
|
if (!match) {
|
|
177
177
|
throw new McpToolError(`Not a Gemini Files API reference: "${ref}"`, {
|
|
178
|
-
hint: 'Pass the `files/<id>` name (or the full https://…/v1beta/files/<id> uri) returned by gemini_upload_file
|
|
178
|
+
hint: 'Pass the `files/<id>` name (or the full https://…/v1beta/files/<id> uri) returned by gemini_upload_file.',
|
|
179
179
|
});
|
|
180
180
|
}
|
|
181
181
|
return match[1];
|
|
@@ -372,8 +372,13 @@ export class GeminiClient {
|
|
|
372
372
|
* payload, an inline `gemini_upload_file` body) to the Files API. Same three
|
|
373
373
|
* steps as {@link uploadVideo}, minus the filesystem — which is what makes it
|
|
374
374
|
* usable on the hosted connector, where there is no disk to stream from.
|
|
375
|
+
*
|
|
376
|
+
* `signal` bounds the two HTTP round trips. It exists for callers that
|
|
377
|
+
* upload OPPORTUNISTICALLY — the base64 promotion in `inputs.ts` puts an
|
|
378
|
+
* upload in front of a generation the caller did not ask to wait for, so a
|
|
379
|
+
* stalled upload must be abandonable rather than open-ended.
|
|
375
380
|
*/
|
|
376
|
-
async uploadBytes(bytes, mimeType, displayName) {
|
|
381
|
+
async uploadBytes(bytes, mimeType, displayName, signal) {
|
|
377
382
|
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
378
383
|
if (view.byteLength > FILE_MAX_BYTES) {
|
|
379
384
|
throw new McpToolError(`File is ${view.byteLength} bytes, over the Gemini Files API limit of ${FILE_MAX_BYTES} bytes (2 GB).`);
|
|
@@ -386,24 +391,7 @@ export class GeminiClient {
|
|
|
386
391
|
// `ArrayBuffer`-backed views, while a `Uint8Array` is generic in its buffer
|
|
387
392
|
// type. The runtime accepts any view.
|
|
388
393
|
const body = new Blob([view], { type: mimeType });
|
|
389
|
-
return this.uploadToFilesApi(body, mimeType, displayName, view.byteLength);
|
|
390
|
-
}
|
|
391
|
-
/**
|
|
392
|
-
* Upload a request body straight through to the Files API without buffering
|
|
393
|
-
* it — the direct-upload HTTP endpoint (`POST /upload` on the Worker) hands
|
|
394
|
-
* us a `ReadableStream` and a `Content-Length`, and the whole point of that
|
|
395
|
-
* endpoint is that the bytes never materialize anywhere they'd cost context
|
|
396
|
-
* or memory.
|
|
397
|
-
*
|
|
398
|
-
* `contentLength` is REQUIRED because the resumable protocol's start step
|
|
399
|
-
* declares it up front (`X-Goog-Upload-Header-Content-Length`); a stream of
|
|
400
|
-
* unknown length has to be buffered by the caller first.
|
|
401
|
-
*/
|
|
402
|
-
async uploadStream(stream, mimeType, displayName, contentLength) {
|
|
403
|
-
if (contentLength > FILE_MAX_BYTES) {
|
|
404
|
-
throw new McpToolError(`File is ${contentLength} bytes, over the Gemini Files API limit of ${FILE_MAX_BYTES} bytes (2 GB).`);
|
|
405
|
-
}
|
|
406
|
-
return this.uploadToFilesApi(stream, mimeType, displayName, contentLength);
|
|
394
|
+
return this.uploadToFilesApi(body, mimeType, displayName, view.byteLength, signal);
|
|
407
395
|
}
|
|
408
396
|
/**
|
|
409
397
|
* The resumable upload itself. Three live-verified steps (docs/GEMINI-API.md
|
|
@@ -420,10 +408,15 @@ export class GeminiClient {
|
|
|
420
408
|
* These two upload calls are raw `fetchImpl` (not `createApiClient`): step 1
|
|
421
409
|
* needs the response *header* and step 2 posts a binary body — neither fits
|
|
422
410
|
* `fetchJson`. The poll DOES go through the shared client (timeout + retry).
|
|
423
|
-
* Uploads
|
|
424
|
-
* legitimately take longer than any fixed budget.
|
|
411
|
+
* Uploads carry no abort timeout by default: a multi-hundred-MB file can
|
|
412
|
+
* legitimately take longer than any fixed budget. A caller that is uploading
|
|
413
|
+
* OPPORTUNISTICALLY passes its own `signal` (see `uploadBytes`).
|
|
425
414
|
*/
|
|
426
|
-
async uploadToFilesApi(body, mimeType, displayName, contentLength
|
|
415
|
+
async uploadToFilesApi(body, mimeType, displayName, contentLength,
|
|
416
|
+
/** Bounds the two upload round trips. The PROCESSING poll below is left
|
|
417
|
+
* alone: it is already bounded by FILE_POLL_MAX_ATTEMPTS, and it runs for
|
|
418
|
+
* video rather than for the images this is used to bound. */
|
|
419
|
+
signal) {
|
|
427
420
|
const key = this.requireKey();
|
|
428
421
|
// Local alias so the calls below carry NO receiver: `this.fetchImpl(...)`
|
|
429
422
|
// would hand native fetch this GeminiClient as `this`, which workerd
|
|
@@ -442,6 +435,7 @@ export class GeminiClient {
|
|
|
442
435
|
'Content-Type': 'application/json',
|
|
443
436
|
},
|
|
444
437
|
body: JSON.stringify({ file: { display_name: displayName } }),
|
|
438
|
+
...(signal ? { signal } : {}),
|
|
445
439
|
});
|
|
446
440
|
if (!startRes.ok) {
|
|
447
441
|
throw new McpToolError(formatApiError(startRes.status, 'POST', `/upload/v1beta${startPath}`, await startRes.text(), { service: SERVICE }));
|
|
@@ -454,23 +448,19 @@ export class GeminiClient {
|
|
|
454
448
|
}
|
|
455
449
|
// 2. upload + finalize — single shot; the session URL is self-authorizing
|
|
456
450
|
// (no api-key header needed; verified live).
|
|
457
|
-
//
|
|
451
|
+
// The Blob body is left EXACTLY as the live-verified video upload sent it:
|
|
458
452
|
// fetch derives Content-Length from the blob, and setting the header by
|
|
459
|
-
// hand is the kind of change that breaks a verified path for no gain.
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
// accept (this half is docs-derived; see docs/GEMINI-API.md).
|
|
463
|
-
const streaming = !(body instanceof Blob);
|
|
453
|
+
// hand is the kind of change that breaks a verified path for no gain.
|
|
454
|
+
// (A ReadableStream branch lived here for the retired direct-upload HTTP
|
|
455
|
+
// endpoint. Nothing streams into this function any more.)
|
|
464
456
|
const upRes = await doFetch(uploadUrl, {
|
|
465
457
|
method: 'POST',
|
|
466
458
|
headers: {
|
|
467
459
|
'X-Goog-Upload-Offset': '0',
|
|
468
460
|
'X-Goog-Upload-Command': 'upload, finalize',
|
|
469
|
-
...(streaming ? { 'Content-Length': String(contentLength) } : {}),
|
|
470
461
|
},
|
|
471
462
|
body: body,
|
|
472
|
-
|
|
473
|
-
...(streaming ? { duplex: 'half' } : {}),
|
|
463
|
+
...(signal ? { signal } : {}),
|
|
474
464
|
});
|
|
475
465
|
if (!upRes.ok) {
|
|
476
466
|
throw new McpToolError(formatApiError(upRes.status, 'POST', '/upload/v1beta/files (finalize)', await upRes.text(), { service: SERVICE }));
|
|
@@ -541,7 +531,7 @@ export class GeminiClient {
|
|
|
541
531
|
const file = await this.call('GET', `/${resource}`);
|
|
542
532
|
if (!file.uri) {
|
|
543
533
|
throw new McpToolError(`${SERVICE} returned no uri for ${resource}`, {
|
|
544
|
-
hint: 'Files expire ~48h after upload — re-upload with gemini_upload_file
|
|
534
|
+
hint: 'Files expire ~48h after upload — re-upload with gemini_upload_file and use the new uri.',
|
|
545
535
|
});
|
|
546
536
|
}
|
|
547
537
|
if (file.state && file.state !== 'ACTIVE') {
|
|
@@ -706,6 +696,8 @@ export class GeminiClient {
|
|
|
706
696
|
responseFormat.delivery = delivery;
|
|
707
697
|
if (opts.aspectRatio)
|
|
708
698
|
responseFormat.aspect_ratio = opts.aspectRatio;
|
|
699
|
+
if (opts.resolution)
|
|
700
|
+
responseFormat.resolution = opts.resolution;
|
|
709
701
|
const body = { model, input: inputParts, response_format: responseFormat };
|
|
710
702
|
if (opts.task)
|
|
711
703
|
body.generation_config = { video_config: { task: opts.task } };
|
|
@@ -755,9 +747,20 @@ export class GeminiClient {
|
|
|
755
747
|
const inputParts = [{ type: 'text', text: opts.input }];
|
|
756
748
|
for (const img of opts.images ?? [])
|
|
757
749
|
inputParts.push(interactPart(img));
|
|
750
|
+
// No format field. The one the server used to send — `audio_format` —
|
|
751
|
+
// does not exist: every call carrying it answered `400 Unknown parameter
|
|
752
|
+
// 'audio_format' at 'response_format'`, on every Lyria model, which is
|
|
753
|
+
// what a docs-derived shape buys you. The real field is `mime_type`
|
|
754
|
+
// (`audio/mp3 | audio/ogg_opus | audio/l16 | audio/wav | audio/alaw |
|
|
755
|
+
// audio/mulaw`), and as of 2026-09-09 every value but MP3 is refused
|
|
756
|
+
// per-model — `Audio MIME type AUDIO_WAV is not supported for
|
|
757
|
+
// models/lyria-3.5`, and the same for the clip model. So there is no
|
|
758
|
+
// format lever to expose yet; when there is, it goes here as `mime_type`.
|
|
759
|
+
//
|
|
760
|
+
// `delivery` is absent for the same class of reason: the enum accepts
|
|
761
|
+
// `uri`, the runtime answers `Audio delivery mode is not supported.`
|
|
762
|
+
// (re-verified 2026-09-09). Lyria audio always comes back inline base64.
|
|
758
763
|
const responseFormat = { type: 'audio' };
|
|
759
|
-
if (opts.audioFormat)
|
|
760
|
-
responseFormat.audio_format = opts.audioFormat;
|
|
761
764
|
const body = { model, input: inputParts, response_format: responseFormat };
|
|
762
765
|
if (opts.previousInteractionId !== undefined)
|
|
763
766
|
body.previous_interaction_id = opts.previousInteractionId;
|
package/dist/inputs.js
CHANGED
|
@@ -10,6 +10,14 @@ export function hasImageInput(args) {
|
|
|
10
10
|
args.images_r2_keys?.length ||
|
|
11
11
|
args.from_clipboard);
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* How long the opportunistic base64 upload gets before it is abandoned.
|
|
15
|
+
*
|
|
16
|
+
* The promotion adds two round trips in front of a generation nobody asked to
|
|
17
|
+
* wait for, and `uploadToFilesApi` is otherwise untimed. Past this the call
|
|
18
|
+
* proceeds inline: slower to repeat, but not stalled.
|
|
19
|
+
*/
|
|
20
|
+
const BASE64_UPLOAD_TIMEOUT_MS = 30_000;
|
|
13
21
|
/** Conservative TTL when the API doesn't tell us: documented retention is ~48h. */
|
|
14
22
|
const ASSUMED_TTL_MS = 47 * 60 * 60 * 1000;
|
|
15
23
|
function expiresAtMs(expirationTime, now) {
|
|
@@ -44,11 +52,14 @@ export async function resolveImageInputs(args, client) {
|
|
|
44
52
|
for (const path of args.images ?? []) {
|
|
45
53
|
inputs.push(await resolveLocalPath(path, client, onDisk, report));
|
|
46
54
|
}
|
|
47
|
-
// 3. raw base64 / data URIs
|
|
55
|
+
// 3. raw base64 / data URIs — the one form that already cost the caller
|
|
56
|
+
// tokens, so it is promoted to a Files API upload immediately (see
|
|
57
|
+
// resolveBase64).
|
|
48
58
|
if (args.images_base64?.length) {
|
|
49
59
|
const { decodeImageInput } = await import('./images.js');
|
|
50
|
-
for (const b64 of args.images_base64)
|
|
51
|
-
inputs.push(decodeImageInput(b64));
|
|
60
|
+
for (const [index, b64] of args.images_base64.entries()) {
|
|
61
|
+
inputs.push(await resolveBase64(decodeImageInput(b64), index, client, report));
|
|
62
|
+
}
|
|
52
63
|
}
|
|
53
64
|
// 4. https URLs, fetched by the server. Deduped within the call.
|
|
54
65
|
const fetched = new Map();
|
|
@@ -169,6 +180,72 @@ async function resolveLocalPath(path, client, onDisk, report) {
|
|
|
169
180
|
return inline;
|
|
170
181
|
}
|
|
171
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* One base64 input → a Files API reference, uploaded on FIRST sighting.
|
|
185
|
+
*
|
|
186
|
+
* Every other form in this funnel keeps bytes out of the conversation
|
|
187
|
+
* altogether. `images_base64` cannot: by the time the server sees it the
|
|
188
|
+
* caller has already spent ~14k tokens per photo emitting it. So the rule is
|
|
189
|
+
* deliberately not the local-path rule (inline once, upload on the second
|
|
190
|
+
* sighting) — waiting for a second sighting here means waiting until the cost
|
|
191
|
+
* this is meant to prevent has been paid twice. Uploading now buys a
|
|
192
|
+
* `files/<id>` that the report hands back, and the next call references that
|
|
193
|
+
* instead of pasting anything.
|
|
194
|
+
*
|
|
195
|
+
* Identity is the bytes, so the same image pasted into two calls uploads once.
|
|
196
|
+
* Best-effort: an upload failure falls back to inline, because a promotion
|
|
197
|
+
* that fails the call is worse than one that does not happen.
|
|
198
|
+
*/
|
|
199
|
+
async function resolveBase64(inline, index, client, report) {
|
|
200
|
+
if (!inline.base64)
|
|
201
|
+
return inline;
|
|
202
|
+
// EVERYTHING is inside the try, decoding included. `decodeImageInput` does
|
|
203
|
+
// not validate a data: payload, so a URL-safe alphabet or a truncated paste
|
|
204
|
+
// reaches `atob` here and throws — and a promotion that fails the generation
|
|
205
|
+
// it was meant to make cheaper is worse than no promotion.
|
|
206
|
+
try {
|
|
207
|
+
const bytes = base64ToBytes(inline.base64);
|
|
208
|
+
// NUL-separated, written as the \u0000 escape — see resolveLocalPath for
|
|
209
|
+
// why a literal NUL must not appear in this file.
|
|
210
|
+
const key = `b64\u0000${inline.mimeType}\u0000${bytes.byteLength}\u0000${await digestOf(bytes)}`;
|
|
211
|
+
const cached = client.session.cachedUpload(key);
|
|
212
|
+
if (cached) {
|
|
213
|
+
// Reported on the cache hit as well. The description tells callers to
|
|
214
|
+
// reuse `base64_uploaded[].file_uri`, and the SECOND paste of a photo is
|
|
215
|
+
// precisely the call that needs to hear it — staying silent there tells
|
|
216
|
+
// the one caller who is repeating themselves nothing.
|
|
217
|
+
(report.base64_uploaded ??= []).push({ index, file_uri: cached.name, bytes: bytes.byteLength });
|
|
218
|
+
return { uri: cached.uri, mimeType: cached.mimeType };
|
|
219
|
+
}
|
|
220
|
+
const uploaded = await client.uploadBytes(bytes, inline.mimeType, `pasted-${index + 1}`, AbortSignal.timeout(BASE64_UPLOAD_TIMEOUT_MS));
|
|
221
|
+
client.session.uploadCache.set(key, {
|
|
222
|
+
name: uploaded.name,
|
|
223
|
+
uri: uploaded.uri,
|
|
224
|
+
mimeType: uploaded.mimeType,
|
|
225
|
+
expiresAtMs: expiresAtMs(uploaded.expirationTime, Date.now()),
|
|
226
|
+
});
|
|
227
|
+
(report.base64_uploaded ??= []).push({
|
|
228
|
+
index,
|
|
229
|
+
file_uri: uploaded.name,
|
|
230
|
+
bytes: bytes.byteLength,
|
|
231
|
+
...(uploaded.expirationTime ? { expires: uploaded.expirationTime } : {}),
|
|
232
|
+
});
|
|
233
|
+
return { uri: uploaded.uri, mimeType: uploaded.mimeType };
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return inline;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* SHA-256 of the bytes, hex. Content identity for the upload cache — two
|
|
241
|
+
* pastes of the same photo must resolve to one upload even when the base64
|
|
242
|
+
* text differs (a data: prefix, different line wrapping).
|
|
243
|
+
*/
|
|
244
|
+
async function digestOf(bytes) {
|
|
245
|
+
const view = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
246
|
+
const hash = await crypto.subtle.digest('SHA-256', view);
|
|
247
|
+
return [...new Uint8Array(hash)].map((n) => n.toString(16).padStart(2, '0')).join('');
|
|
248
|
+
}
|
|
172
249
|
/** One https URL → inline bytes, or a Files API reference when it's large. */
|
|
173
250
|
async function resolveUrl(url, client, report) {
|
|
174
251
|
const fetched = await client.fetchRemoteImage(url);
|
package/dist/jobs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID, createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { McpToolError, minifiedResult } from '@chrischall/mcp-utils';
|
|
3
3
|
import { downloadFilename } from './media-name.js';
|
|
4
4
|
import { HEARTBEAT_MS } from './job-store.js';
|
|
5
5
|
import { annotateReusedUsage } from './usage.js';
|
|
@@ -39,6 +39,17 @@ function isExpired(entry, now) {
|
|
|
39
39
|
export function fingerprintRequest(toolName, parts) {
|
|
40
40
|
return createHash('sha256').update(`${toolName}\u0000${JSON.stringify(parts)}`).digest('hex');
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* The in-memory replay key: an idempotency key is only ever honoured for the
|
|
44
|
+
* REQUEST it was first used with.
|
|
45
|
+
*
|
|
46
|
+
* Same NUL separator, same reason, as {@link fingerprintRequest} — and the
|
|
47
|
+
* fingerprint is already namespaced by tool, so two tools handed the same "1"
|
|
48
|
+
* cannot collide either.
|
|
49
|
+
*/
|
|
50
|
+
function replayKey(idempotencyKey, fingerprint) {
|
|
51
|
+
return `${idempotencyKey}\u0000${fingerprint}`;
|
|
52
|
+
}
|
|
42
53
|
/**
|
|
43
54
|
* Re-mint any media URLs a recorded result carries, before it is replayed.
|
|
44
55
|
*
|
|
@@ -150,10 +161,19 @@ function annotateReused(result, jobId) {
|
|
|
150
161
|
};
|
|
151
162
|
}
|
|
152
163
|
/** Immediate handle for an async call — the caller polls `gemini_get_result`. */
|
|
153
|
-
|
|
154
|
-
|
|
164
|
+
/**
|
|
165
|
+
* `interactionId` is on the handle, not held back until the job settles.
|
|
166
|
+
*
|
|
167
|
+
* It is the one thing that outlives a lost response: the generation continues
|
|
168
|
+
* upstream whatever happens to this request, and the id is how a later call
|
|
169
|
+
* chains onto it or recovers its output. Withholding it until the settle is
|
|
170
|
+
* withholding it exactly in the case where the settle never comes.
|
|
171
|
+
*/
|
|
172
|
+
function jobHandle(jobId, status, durable = false, interactionId) {
|
|
173
|
+
return minifiedResult({
|
|
155
174
|
job_id: jobId,
|
|
156
175
|
status,
|
|
176
|
+
...(interactionId ? { interaction_id: interactionId } : {}),
|
|
157
177
|
hint: durable
|
|
158
178
|
? `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done". The job record is stored durably, so a poll still works if this connector restarts — but background work is NOT restarted, so a long generation is safer with max_wait_ms than with async.`
|
|
159
179
|
: `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done" (results are per-process and expire ~10 min after completion).`,
|
|
@@ -232,7 +252,18 @@ export class JobRegistry {
|
|
|
232
252
|
/** Injectable clock (tests). */
|
|
233
253
|
now = () => Date.now();
|
|
234
254
|
jobs = new Map();
|
|
235
|
-
|
|
255
|
+
/**
|
|
256
|
+
* `idempotencyKey + fingerprint` -> jobId. Keyed on BOTH, never the key
|
|
257
|
+
* alone: an idempotency_key is a habit as often as a promise ("1", "test",
|
|
258
|
+
* "retry"), so a key-only lookup hands a caller a different prompt's result
|
|
259
|
+
* and labels it `reused`. This is the rule `durableByKey` has always applied
|
|
260
|
+
* to the 24h window; the 10-minute in-memory window now applies it too.
|
|
261
|
+
*
|
|
262
|
+
* Composite rather than a stored comparison so that reusing a key for a
|
|
263
|
+
* second request cannot evict the first: both pairings coexist, and a retry
|
|
264
|
+
* of either still replays.
|
|
265
|
+
*/
|
|
266
|
+
byKey = new Map();
|
|
236
267
|
runningByFingerprint = new Map(); // fingerprint -> jobId (only while running)
|
|
237
268
|
/** Durable bookkeeping still in flight — awaited by `drain()` in tests. */
|
|
238
269
|
pending = new Set();
|
|
@@ -345,8 +376,11 @@ export class JobRegistry {
|
|
|
345
376
|
}
|
|
346
377
|
remove(id, entry) {
|
|
347
378
|
this.jobs.delete(id);
|
|
348
|
-
if (entry.idempotencyKey !== undefined
|
|
349
|
-
|
|
379
|
+
if (entry.idempotencyKey !== undefined) {
|
|
380
|
+
const key = replayKey(entry.idempotencyKey, entry.fingerprint);
|
|
381
|
+
if (this.byKey.get(key) === id)
|
|
382
|
+
this.byKey.delete(key);
|
|
383
|
+
}
|
|
350
384
|
if (this.runningByFingerprint.get(entry.fingerprint) === id)
|
|
351
385
|
this.runningByFingerprint.delete(entry.fingerprint);
|
|
352
386
|
}
|
|
@@ -376,7 +410,7 @@ export class JobRegistry {
|
|
|
376
410
|
const now = this.now();
|
|
377
411
|
let hit;
|
|
378
412
|
if (idempotencyKey !== undefined) {
|
|
379
|
-
const id = this.byKey.get(idempotencyKey);
|
|
413
|
+
const id = this.byKey.get(replayKey(idempotencyKey, fingerprint));
|
|
380
414
|
const entry = id ? this.jobs.get(id) : undefined;
|
|
381
415
|
if (entry && entry.status !== 'failed' && !isExpired(entry, now))
|
|
382
416
|
hit = entry;
|
|
@@ -397,12 +431,12 @@ export class JobRegistry {
|
|
|
397
431
|
}
|
|
398
432
|
if (hit) {
|
|
399
433
|
if (async)
|
|
400
|
-
return jobHandle(hit.jobId, hit.status);
|
|
434
|
+
return jobHandle(hit.jobId, hit.status, this.store !== undefined, hit.interactionId);
|
|
401
435
|
const settled = waitMs !== undefined && hit.status === 'running'
|
|
402
436
|
? await awaitWithBudget(hit.promise, waitMs)
|
|
403
437
|
: await hit.promise;
|
|
404
438
|
if (settled === undefined)
|
|
405
|
-
return jobHandle(hit.jobId, 'running');
|
|
439
|
+
return jobHandle(hit.jobId, 'running', this.store !== undefined, hit.interactionId);
|
|
406
440
|
// Refresh before annotating: a recorded result can outlive the signed
|
|
407
441
|
// URLs inside it, and replaying an expired link is its own kind of wrong
|
|
408
442
|
// answer — it looks like success.
|
|
@@ -437,7 +471,7 @@ export class JobRegistry {
|
|
|
437
471
|
};
|
|
438
472
|
this.jobs.set(jobId, entry);
|
|
439
473
|
if (idempotencyKey !== undefined)
|
|
440
|
-
this.byKey.set(idempotencyKey, jobId);
|
|
474
|
+
this.byKey.set(replayKey(idempotencyKey, fingerprint), jobId);
|
|
441
475
|
this.runningByFingerprint.set(fingerprint, jobId);
|
|
442
476
|
// Write the "running" record BEFORE the work can finish. A record that only
|
|
443
477
|
// appeared on completion would be missing for exactly the window this whole
|
|
@@ -459,13 +493,13 @@ export class JobRegistry {
|
|
|
459
493
|
if (this.store)
|
|
460
494
|
void settle.then(() => this.track(this.writeRecord(entry)));
|
|
461
495
|
if (async)
|
|
462
|
-
return jobHandle(jobId, 'running', this.store !== undefined);
|
|
496
|
+
return jobHandle(jobId, 'running', this.store !== undefined, this.jobs.get(jobId)?.interactionId);
|
|
463
497
|
if (waitMs !== undefined) {
|
|
464
498
|
const settled = await awaitWithBudget(promise, waitMs);
|
|
465
499
|
// Budget expired with the work still running: hand back the job id — the
|
|
466
500
|
// work continues and the caller polls, exactly as with `async: true`,
|
|
467
501
|
// except a fast result would have been returned in-band.
|
|
468
|
-
return settled ?? jobHandle(jobId, 'running', this.store !== undefined);
|
|
502
|
+
return settled ?? jobHandle(jobId, 'running', this.store !== undefined, this.jobs.get(jobId)?.interactionId);
|
|
469
503
|
}
|
|
470
504
|
return promise;
|
|
471
505
|
}
|
|
@@ -497,7 +531,7 @@ export class JobRegistry {
|
|
|
497
531
|
if (this.now() - record.updatedAt > DURABLE_JOB_REUSE_MS)
|
|
498
532
|
return undefined;
|
|
499
533
|
if (record.status === 'running')
|
|
500
|
-
return jobHandle(id, 'running', true);
|
|
534
|
+
return jobHandle(id, 'running', true, record.interactionId);
|
|
501
535
|
if (record.status !== 'done' || !record.result)
|
|
502
536
|
return undefined;
|
|
503
537
|
return annotateReused(await refreshMedia(record.result, this.resigner), id);
|
|
@@ -571,7 +605,7 @@ export class JobRegistry {
|
|
|
571
605
|
});
|
|
572
606
|
}
|
|
573
607
|
if (entry.status === 'running')
|
|
574
|
-
return jobHandle(jobId, 'running', this.store !== undefined);
|
|
608
|
+
return jobHandle(jobId, 'running', this.store !== undefined, entry.interactionId);
|
|
575
609
|
if (entry.status === 'failed') {
|
|
576
610
|
throw new McpToolError(entry.error?.message ?? 'Job failed.', entry.error?.hint ? { hint: entry.error.hint } : undefined);
|
|
577
611
|
}
|
|
@@ -593,7 +627,7 @@ export class JobRegistry {
|
|
|
593
627
|
*/
|
|
594
628
|
async fromRecord(record) {
|
|
595
629
|
if (record.status === 'running')
|
|
596
|
-
return jobHandle(record.jobId, 'running', true);
|
|
630
|
+
return jobHandle(record.jobId, 'running', true, record.interactionId);
|
|
597
631
|
if (record.status === 'failed') {
|
|
598
632
|
throw new McpToolError(record.error?.message ?? 'Job failed.', record.error?.hint ? { hint: record.error.hint } : undefined);
|
|
599
633
|
}
|
package/dist/models.js
CHANGED
|
@@ -5,10 +5,20 @@
|
|
|
5
5
|
* `model` param when the task warrants it (see MODEL_CHOICE_GUIDE).
|
|
6
6
|
*/
|
|
7
7
|
export const DEFAULT_IMAGE_MODEL = 'gemini-3.1-flash-image';
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
*
|
|
8
|
+
/**
|
|
9
|
+
* Default video model (omni, Interactions API) — overridable per-call.
|
|
10
|
+
*
|
|
11
|
+
* GA since 2026-08-27. Its predecessor `gemini-omni-flash-preview` shuts down
|
|
12
|
+
* 2026-09-30, so this is a migration and not a preference; the GA model bills
|
|
13
|
+
* at the same rate and adds `resolution` and the `extend` task.
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_VIDEO_MODEL = 'gemini-omni-1.1-flash';
|
|
16
|
+
/**
|
|
17
|
+
* Default music model (Lyria clips, Interactions API) — 30s, MP3, the cheapest
|
|
18
|
+
* of the three at $0.04 a song. `lyria-3.5` (minutes-long, vocals) and
|
|
19
|
+
* `lyria-3-pro-preview` are the opt-ins at twice the price, so neither is the
|
|
20
|
+
* default. All need a funded account.
|
|
21
|
+
*/
|
|
12
22
|
export const DEFAULT_MUSIC_MODEL = 'lyria-3-clip-preview';
|
|
13
23
|
/** Per-call → env override → hardcoded default. Blank/whitespace counts as unset. */
|
|
14
24
|
export function resolveModel(perCall, envOverride) {
|
package/dist/pricing.js
CHANGED
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
*/
|
|
50
50
|
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
51
51
|
/** The date the shipped rates were read from Google's pricing page. */
|
|
52
|
-
export const PRICED_AT = '2026-
|
|
52
|
+
export const PRICED_AT = '2026-09-09';
|
|
53
53
|
/**
|
|
54
54
|
* Paid-tier rates, USD per 1M tokens. Image generation has no free tier.
|
|
55
55
|
* `-preview` ids are priced as their GA twin (see {@link normalizeModel}).
|
|
@@ -73,9 +73,17 @@ export const RATE_CARD = Object.freeze({
|
|
|
73
73
|
// from flash-image), along with a $0.50 input and $3.00 text rate that were
|
|
74
74
|
// both a third of the real figures. Every number here is now quoted.
|
|
75
75
|
'gemini-omni-flash': { input: 1.5, text_output: 9.0, video_output: 17.5 },
|
|
76
|
+
// The GA model (2026-08-27) bills at the preview's rates. It needs its own
|
|
77
|
+
// key: `normalizeModel` strips a `-preview` suffix and nothing else, so
|
|
78
|
+
// `gemini-omni-1.1-flash` would otherwise price as undefined — and undefined
|
|
79
|
+
// is what the server's DEFAULT video model would have reported.
|
|
80
|
+
'gemini-omni-1.1-flash': { input: 1.5, text_output: 9.0, video_output: 17.5 },
|
|
76
81
|
// Lyria bills per song, so tokens describe the work and not the bill.
|
|
77
82
|
'lyria-3-clip': { input: 0, text_output: 0, per_generation: 0.04 },
|
|
78
83
|
'lyria-3-pro': { input: 0, text_output: 0, per_generation: 0.08 },
|
|
84
|
+
// lyria-3.5 (2026-09-03): minutes-long songs with vocals, $0.08 a song. A
|
|
85
|
+
// bare id with no `-preview` to strip, so it is listed rather than derived.
|
|
86
|
+
'lyria-3.5': { input: 0, text_output: 0, per_generation: 0.08 },
|
|
79
87
|
});
|
|
80
88
|
/** `foo-preview` prices as `foo` — the fleet uses both spellings. */
|
|
81
89
|
function normalizeModel(model) {
|
package/dist/session.js
CHANGED
|
@@ -33,12 +33,14 @@ export class SessionState {
|
|
|
33
33
|
*/
|
|
34
34
|
lastInteractionId;
|
|
35
35
|
/**
|
|
36
|
-
* The same, for
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
36
|
+
* The same, for `gemini_video_generate`, which keeps its own chain. Video is
|
|
37
|
+
* stdio-only today, but it carries the identical cross-tenant hazard
|
|
38
|
+
* `lastInteractionId` did, so it is scoped here rather than at module level.
|
|
39
|
+
*
|
|
40
|
+
* There is deliberately no music equivalent: Lyria is single-turn (a chained
|
|
41
|
+
* call 400s on the track it is handed back), so `gemini_music_generate` has
|
|
42
|
+
* no continue_last to remember.
|
|
40
43
|
*/
|
|
41
|
-
lastMusicInteractionId;
|
|
42
44
|
lastVideoInteractionId;
|
|
43
45
|
/**
|
|
44
46
|
* Absolute paths of every image this session has written. Used to drop a
|
|
@@ -138,7 +140,6 @@ export class SessionState {
|
|
|
138
140
|
this.costUsd = undefined;
|
|
139
141
|
this.pricedCalls = 0;
|
|
140
142
|
this.lastInteractionId = undefined;
|
|
141
|
-
this.lastMusicInteractionId = undefined;
|
|
142
143
|
this.lastVideoInteractionId = undefined;
|
|
143
144
|
this.writtenOutputs.clear();
|
|
144
145
|
this.uploadCache.clear();
|