@chrischall/gemini-mcp 1.6.1 → 1.7.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/blob-store.js +18 -2
- package/dist/bundle.js +942 -617
- package/dist/client.js +31 -2
- package/dist/errors.js +120 -0
- package/dist/index.js +6 -25
- package/dist/job-store.js +138 -0
- package/dist/jobs.js +257 -12
- package/dist/media-url.js +10 -3
- package/dist/registrars.js +50 -0
- package/dist/session.js +5 -4
- package/dist/signed-url.js +42 -0
- package/dist/storage/media.js +76 -3
- package/dist/tools/files.js +49 -0
- package/dist/tools/jobs.js +5 -1
- package/dist/tools/shared.js +13 -3
- package/dist/tools/uploads.js +42 -19
- package/dist/upload-url.js +24 -4
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/jobs.js
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
import { randomUUID, createHash } from 'node:crypto';
|
|
2
2
|
import { textResult, McpToolError } from '@chrischall/mcp-utils';
|
|
3
3
|
import { downloadFilename } from './media-name.js';
|
|
4
|
+
import { HEARTBEAT_MS } from './job-store.js';
|
|
4
5
|
/** Recently-completed jobs are reusable by key for this long, then evicted. */
|
|
5
6
|
const JOB_TTL_MS = 600_000; // 10 min
|
|
7
|
+
/**
|
|
8
|
+
* How long a DURABLE record stays reusable by idempotency key.
|
|
9
|
+
*
|
|
10
|
+
* Longer than {@link JOB_TTL_MS} on purpose: the in-memory window is bounded by
|
|
11
|
+
* a process that no longer exists, and the case this covers — a retry after the
|
|
12
|
+
* hosted machine went to sleep — can easily land further out than ten minutes.
|
|
13
|
+
* Still bounded, because a stored record survives for the blob store's ~30-day
|
|
14
|
+
* retention and silently replaying a month-old generation is a wrong answer
|
|
15
|
+
* that looks like a cache hit.
|
|
16
|
+
*
|
|
17
|
+
* 24h is also the longest a signed media link can live here, so a replay inside
|
|
18
|
+
* this window is one whose URLs can still be re-minted from their keys.
|
|
19
|
+
*/
|
|
20
|
+
const DURABLE_JOB_REUSE_MS = 24 * 60 * 60 * 1000;
|
|
6
21
|
/** Hard cap on retained entries (oldest-settled evicted first). */
|
|
7
22
|
const JOB_MAX = 64;
|
|
8
23
|
function isExpired(entry, now) {
|
|
@@ -131,13 +146,38 @@ function annotateReused(result, jobId) {
|
|
|
131
146
|
};
|
|
132
147
|
}
|
|
133
148
|
/** Immediate handle for an async call — the caller polls `gemini_get_result`. */
|
|
134
|
-
function jobHandle(jobId, status) {
|
|
149
|
+
function jobHandle(jobId, status, durable = false) {
|
|
135
150
|
return textResult({
|
|
136
151
|
job_id: jobId,
|
|
137
152
|
status,
|
|
138
|
-
hint:
|
|
153
|
+
hint: durable
|
|
154
|
+
? `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.`
|
|
155
|
+
: `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).`,
|
|
139
156
|
});
|
|
140
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Whether a result is worth persisting to the durable store.
|
|
160
|
+
*
|
|
161
|
+
* A hosted result is a small JSON manifest of `r2_key`s, which is exactly what
|
|
162
|
+
* a restart needs — the keys outlive every signed URL and `refreshMedia`
|
|
163
|
+
* re-signs from them. An `inline: true` result is raw base64 image bytes: too
|
|
164
|
+
* big to keep, and pointless to keep, since nothing about it survives usefully.
|
|
165
|
+
* Recording the omission is what lets a poll say *why* rather than reporting a
|
|
166
|
+
* success with nothing in it.
|
|
167
|
+
*/
|
|
168
|
+
const MAX_PERSISTED_RESULT_BYTES = 64 * 1024;
|
|
169
|
+
function persistableResult(result) {
|
|
170
|
+
if ((result.content ?? []).some((block) => block.type !== 'text'))
|
|
171
|
+
return { resultOmitted: 'inline' };
|
|
172
|
+
try {
|
|
173
|
+
if (JSON.stringify(result).length > MAX_PERSISTED_RESULT_BYTES)
|
|
174
|
+
return { resultOmitted: 'too_large' };
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return { resultOmitted: 'too_large' };
|
|
178
|
+
}
|
|
179
|
+
return { result };
|
|
180
|
+
}
|
|
141
181
|
/**
|
|
142
182
|
* Await `promise` for at most `waitMs`; `undefined` means the budget expired
|
|
143
183
|
* with the work still pending (rejections propagate as usual). The timer never
|
|
@@ -163,14 +203,123 @@ export class JobRegistry {
|
|
|
163
203
|
* never expire.
|
|
164
204
|
*/
|
|
165
205
|
resigner;
|
|
206
|
+
/**
|
|
207
|
+
* Durable backing for this registry, when the deployment has somewhere to put
|
|
208
|
+
* it. Absent on a local stdio install, where the process that starts a
|
|
209
|
+
* generation is the process that finishes it and a `Map` is the whole truth.
|
|
210
|
+
*
|
|
211
|
+
* Present on the hosted connector, where it is load-bearing: see the header of
|
|
212
|
+
* `src/job-store.ts` for why an in-memory-only registry cannot work on a
|
|
213
|
+
* machine that stops whenever no request is in flight.
|
|
214
|
+
*/
|
|
215
|
+
store;
|
|
216
|
+
/**
|
|
217
|
+
* When set, `async: true` is served as a bounded in-band wait instead of an
|
|
218
|
+
* immediate hand-off.
|
|
219
|
+
*
|
|
220
|
+
* Set by the hosted client, because there the two are not the trade-off they
|
|
221
|
+
* look like. Returning immediately releases the request, and the released
|
|
222
|
+
* request is the thing keeping the executor alive — so `async: true` is
|
|
223
|
+
* precisely the way to get a generation killed. Waiting holds the machine up
|
|
224
|
+
* until the work is done. If the budget still expires the caller gets the same
|
|
225
|
+
* job handle it asked for, now backed by a durable record.
|
|
226
|
+
*/
|
|
227
|
+
asyncWaitFallbackMs;
|
|
228
|
+
/** Injectable clock (tests). */
|
|
229
|
+
now = () => Date.now();
|
|
166
230
|
jobs = new Map();
|
|
167
231
|
byKey = new Map(); // idempotencyKey -> jobId
|
|
168
232
|
runningByFingerprint = new Map(); // fingerprint -> jobId (only while running)
|
|
233
|
+
/** Durable bookkeeping still in flight — awaited by `drain()` in tests. */
|
|
234
|
+
pending = new Set();
|
|
235
|
+
heartbeatTimer;
|
|
169
236
|
/** Drop every job. Used by `SessionState.reset()` (tests) — not by handlers. */
|
|
170
237
|
reset() {
|
|
171
238
|
this.jobs.clear();
|
|
172
239
|
this.byKey.clear();
|
|
173
240
|
this.runningByFingerprint.clear();
|
|
241
|
+
this.stopHeartbeat();
|
|
242
|
+
}
|
|
243
|
+
/** Await outstanding durable writes. Tests only — handlers never need it. */
|
|
244
|
+
async drain() {
|
|
245
|
+
// Yield a macrotask first so a settle handler that has already fired gets
|
|
246
|
+
// its write queued before we look. Deliberately flushes only what is
|
|
247
|
+
// ISSUABLE: a job still running has no terminal write to wait for, and
|
|
248
|
+
// blocking on one would hang any test that drains before finishing a job.
|
|
249
|
+
for (let round = 0; round < 50; round++) {
|
|
250
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
251
|
+
if (!this.pending.size)
|
|
252
|
+
return;
|
|
253
|
+
await Promise.allSettled([...this.pending]);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Re-stamp every running job's durable record.
|
|
258
|
+
*
|
|
259
|
+
* This is what separates "still working" from "the machine went away": a
|
|
260
|
+
* reader compares the stamp against `EXECUTOR_LOST_AFTER_MS`, so a 12-scene
|
|
261
|
+
* set that legitimately runs for ten minutes stays `running` while a job whose
|
|
262
|
+
* child was killed goes terminal within three missed beats.
|
|
263
|
+
*/
|
|
264
|
+
async beat() {
|
|
265
|
+
if (!this.store)
|
|
266
|
+
return;
|
|
267
|
+
await Promise.allSettled([...this.jobs.values()]
|
|
268
|
+
.filter((e) => e.status === 'running')
|
|
269
|
+
// The status is re-read INSIDE the queued task, not sampled here: a job
|
|
270
|
+
// that finishes while this beat waits its turn must not have `running`
|
|
271
|
+
// written back over its terminal record.
|
|
272
|
+
.map((e) => this.writeRecord(e, { onlyWhileRunning: true })));
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Queue one durable write for `entry`, after any write already in flight for
|
|
276
|
+
* it. `onlyWhileRunning` drops the write if the job settled in the meantime —
|
|
277
|
+
* which is what makes a late heartbeat a no-op rather than a resurrection.
|
|
278
|
+
*/
|
|
279
|
+
writeRecord(entry, opts = {}) {
|
|
280
|
+
const next = (entry.writeChain ?? Promise.resolve()).then(async () => {
|
|
281
|
+
if (opts.onlyWhileRunning && entry.status !== 'running')
|
|
282
|
+
return;
|
|
283
|
+
await this.store.put(this.recordFor(entry, this.now()));
|
|
284
|
+
}, () => undefined);
|
|
285
|
+
entry.writeChain = next;
|
|
286
|
+
return next;
|
|
287
|
+
}
|
|
288
|
+
recordFor(entry, updatedAt) {
|
|
289
|
+
return {
|
|
290
|
+
jobId: entry.jobId,
|
|
291
|
+
toolName: entry.toolName,
|
|
292
|
+
fingerprint: entry.fingerprint,
|
|
293
|
+
...(entry.idempotencyKey !== undefined ? { idempotencyKey: entry.idempotencyKey } : {}),
|
|
294
|
+
status: entry.status,
|
|
295
|
+
createdAt: entry.createdAt,
|
|
296
|
+
updatedAt,
|
|
297
|
+
...(entry.error ? { error: entry.error } : {}),
|
|
298
|
+
...(entry.status === 'done' && entry.result ? persistableResult(entry.result) : {}),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
/** Run `p` as durable bookkeeping: tracked for `drain()`, never thrown from. */
|
|
302
|
+
track(p) {
|
|
303
|
+
const wrapped = p.catch(() => undefined).finally(() => this.pending.delete(wrapped));
|
|
304
|
+
this.pending.add(wrapped);
|
|
305
|
+
}
|
|
306
|
+
startHeartbeat() {
|
|
307
|
+
if (this.heartbeatTimer || !this.store)
|
|
308
|
+
return;
|
|
309
|
+
this.heartbeatTimer = setInterval(() => {
|
|
310
|
+
if (![...this.jobs.values()].some((e) => e.status === 'running')) {
|
|
311
|
+
this.stopHeartbeat();
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
this.track(this.beat());
|
|
315
|
+
}, HEARTBEAT_MS);
|
|
316
|
+
// Never hold the process open for bookkeeping.
|
|
317
|
+
this.heartbeatTimer.unref?.();
|
|
318
|
+
}
|
|
319
|
+
stopHeartbeat() {
|
|
320
|
+
if (this.heartbeatTimer)
|
|
321
|
+
clearInterval(this.heartbeatTimer);
|
|
322
|
+
this.heartbeatTimer = undefined;
|
|
174
323
|
}
|
|
175
324
|
/** Drop expired entries, then enforce JOB_MAX by evicting oldest-settled first. */
|
|
176
325
|
evict(now) {
|
|
@@ -213,8 +362,13 @@ export class JobRegistry {
|
|
|
213
362
|
* different registry and therefore a genuinely separate generation.
|
|
214
363
|
*/
|
|
215
364
|
async dispatch(opts, work) {
|
|
216
|
-
const { toolName, fingerprint, idempotencyKey
|
|
217
|
-
|
|
365
|
+
const { toolName, fingerprint, idempotencyKey } = opts;
|
|
366
|
+
// Hosted, an immediate hand-off is what gets the executor reclaimed, so
|
|
367
|
+
// `async` is honoured by HOLDING the request rather than releasing it.
|
|
368
|
+
const degrade = opts.async === true && this.asyncWaitFallbackMs !== undefined;
|
|
369
|
+
const async = degrade ? false : opts.async;
|
|
370
|
+
const waitMs = degrade ? (opts.waitMs ?? this.asyncWaitFallbackMs) : opts.waitMs;
|
|
371
|
+
const now = this.now();
|
|
218
372
|
let hit;
|
|
219
373
|
if (idempotencyKey !== undefined) {
|
|
220
374
|
const id = this.byKey.get(idempotencyKey);
|
|
@@ -228,6 +382,14 @@ export class JobRegistry {
|
|
|
228
382
|
if (entry && entry.status === 'running')
|
|
229
383
|
hit = entry;
|
|
230
384
|
}
|
|
385
|
+
// Nothing in memory, but the key may name a job an earlier child ran. Only
|
|
386
|
+
// an explicit key unlocks this — the same rule the in-memory path uses, so
|
|
387
|
+
// a deliberate same-prompt variation is never silently deduplicated.
|
|
388
|
+
if (!hit && idempotencyKey !== undefined && this.store) {
|
|
389
|
+
const durable = await this.durableByKey(idempotencyKey, fingerprint);
|
|
390
|
+
if (durable)
|
|
391
|
+
return durable;
|
|
392
|
+
}
|
|
231
393
|
if (hit) {
|
|
232
394
|
if (async)
|
|
233
395
|
return jobHandle(hit.jobId, hit.status);
|
|
@@ -249,25 +411,69 @@ export class JobRegistry {
|
|
|
249
411
|
if (idempotencyKey !== undefined)
|
|
250
412
|
this.byKey.set(idempotencyKey, jobId);
|
|
251
413
|
this.runningByFingerprint.set(fingerprint, jobId);
|
|
252
|
-
|
|
414
|
+
// Write the "running" record BEFORE the work can finish. A record that only
|
|
415
|
+
// appeared on completion would be missing for exactly the window this whole
|
|
416
|
+
// mechanism exists to cover.
|
|
417
|
+
if (this.store) {
|
|
418
|
+
this.track(this.writeRecord(entry));
|
|
419
|
+
if (idempotencyKey !== undefined)
|
|
420
|
+
this.track(this.store.putKey(idempotencyKey, jobId));
|
|
421
|
+
this.startHeartbeat();
|
|
422
|
+
}
|
|
423
|
+
const settle = promise.then((res) => { entry.status = 'done'; entry.result = res; entry.settledAt = this.now(); }, (err) => {
|
|
253
424
|
entry.status = 'failed';
|
|
254
425
|
entry.error = { message: err instanceof Error ? err.message : String(err), hint: err?.hint };
|
|
255
|
-
entry.settledAt =
|
|
426
|
+
entry.settledAt = this.now();
|
|
256
427
|
}).finally(() => {
|
|
257
428
|
if (this.runningByFingerprint.get(fingerprint) === jobId)
|
|
258
429
|
this.runningByFingerprint.delete(fingerprint);
|
|
259
430
|
});
|
|
431
|
+
if (this.store)
|
|
432
|
+
void settle.then(() => this.track(this.writeRecord(entry)));
|
|
260
433
|
if (async)
|
|
261
|
-
return jobHandle(jobId, 'running');
|
|
434
|
+
return jobHandle(jobId, 'running', this.store !== undefined);
|
|
262
435
|
if (waitMs !== undefined) {
|
|
263
436
|
const settled = await awaitWithBudget(promise, waitMs);
|
|
264
437
|
// Budget expired with the work still running: hand back the job id — the
|
|
265
438
|
// work continues and the caller polls, exactly as with `async: true`,
|
|
266
439
|
// except a fast result would have been returned in-band.
|
|
267
|
-
return settled ?? jobHandle(jobId, 'running');
|
|
440
|
+
return settled ?? jobHandle(jobId, 'running', this.store !== undefined);
|
|
268
441
|
}
|
|
269
442
|
return promise;
|
|
270
443
|
}
|
|
444
|
+
/**
|
|
445
|
+
* Resolve an idempotency key against the durable store — the path a retry
|
|
446
|
+
* takes when the child that ran the original job is gone.
|
|
447
|
+
*
|
|
448
|
+
* `undefined` means "nothing durable to reuse", which includes a job that
|
|
449
|
+
* FAILED: a retry after an error must really retry. A job still recorded as
|
|
450
|
+
* running belongs to an executor this process cannot await, so the caller gets
|
|
451
|
+
* the handle back rather than a promise that would never settle.
|
|
452
|
+
*/
|
|
453
|
+
async durableByKey(idempotencyKey, fingerprint) {
|
|
454
|
+
const id = await this.store.jobIdForKey(idempotencyKey);
|
|
455
|
+
if (!id)
|
|
456
|
+
return undefined;
|
|
457
|
+
const record = await this.store.get(id);
|
|
458
|
+
if (!record)
|
|
459
|
+
return undefined;
|
|
460
|
+
// An idempotency key is a habit as often as it is a promise — "1", "test",
|
|
461
|
+
// "retry". In memory that was survivable because the entry expired in ten
|
|
462
|
+
// minutes; a durable record lives for the store's retention, so replaying
|
|
463
|
+
// on the key ALONE would hand back a different prompt's image, weeks later,
|
|
464
|
+
// and label it a cache hit. The fingerprint is what makes this idempotency
|
|
465
|
+
// rather than a name lookup: same key + different request is a genuinely
|
|
466
|
+
// new generation.
|
|
467
|
+
if (record.fingerprint !== fingerprint)
|
|
468
|
+
return undefined;
|
|
469
|
+
if (this.now() - record.updatedAt > DURABLE_JOB_REUSE_MS)
|
|
470
|
+
return undefined;
|
|
471
|
+
if (record.status === 'running')
|
|
472
|
+
return jobHandle(id, 'running', true);
|
|
473
|
+
if (record.status !== 'done' || !record.result)
|
|
474
|
+
return undefined;
|
|
475
|
+
return annotateReused(await refreshMedia(record.result, this.resigner), id);
|
|
476
|
+
}
|
|
271
477
|
/**
|
|
272
478
|
* Retrieve an async job by id (backs the `gemini_get_result` tool):
|
|
273
479
|
* `running` → a status handle; `done` → the recorded result payload;
|
|
@@ -277,15 +483,22 @@ export class JobRegistry {
|
|
|
277
483
|
* the unknown-id path — the poll tool cannot be used to read another user's
|
|
278
484
|
* result even if they know the uuid.
|
|
279
485
|
*/
|
|
280
|
-
getResult(jobId) {
|
|
486
|
+
async getResult(jobId) {
|
|
281
487
|
const entry = this.jobs.get(jobId);
|
|
282
488
|
if (!entry) {
|
|
283
|
-
|
|
284
|
-
|
|
489
|
+
const durable = this.store ? await this.store.get(jobId) : undefined;
|
|
490
|
+
if (durable)
|
|
491
|
+
return this.fromRecord(durable);
|
|
492
|
+
throw new McpToolError(this.store
|
|
493
|
+
? `No job "${jobId}" — no record of it exists. Either the id is wrong, or the job is older than the ${JOB_RECORD_RETENTION_LABEL} job records are kept.`
|
|
494
|
+
: `No job "${jobId}" — unknown or expired.`, {
|
|
495
|
+
hint: this.store
|
|
496
|
+
? 'Job records are stored durably here, so a restart alone does not lose one — an id with no record was never started under this account. Images from a completed run are listed by gemini_list_recent_media.'
|
|
497
|
+
: 'Jobs live with your session and are evicted ~10 min after completion (or on server restart). If the generation likely finished, check the output dir / <image>.json sidecar instead of polling.',
|
|
285
498
|
});
|
|
286
499
|
}
|
|
287
500
|
if (entry.status === 'running')
|
|
288
|
-
return jobHandle(jobId, 'running');
|
|
501
|
+
return jobHandle(jobId, 'running', this.store !== undefined);
|
|
289
502
|
if (entry.status === 'failed') {
|
|
290
503
|
throw new McpToolError(entry.error?.message ?? 'Job failed.', entry.error?.hint ? { hint: entry.error.hint } : undefined);
|
|
291
504
|
}
|
|
@@ -296,4 +509,36 @@ export class JobRegistry {
|
|
|
296
509
|
throw new McpToolError(`Job "${jobId}" completed without a recorded result.`);
|
|
297
510
|
return entry.result;
|
|
298
511
|
}
|
|
512
|
+
/**
|
|
513
|
+
* Render a durable record as a poll answer — the path taken when the child
|
|
514
|
+
* that ran the job is gone.
|
|
515
|
+
*
|
|
516
|
+
* The three outcomes are kept distinct on purpose. Collapsing "your job
|
|
517
|
+
* failed", "your job was killed" and "no such job" into one message is what
|
|
518
|
+
* made the original bug read as an expiry problem when it was a machine
|
|
519
|
+
* lifecycle problem.
|
|
520
|
+
*/
|
|
521
|
+
async fromRecord(record) {
|
|
522
|
+
if (record.status === 'running')
|
|
523
|
+
return jobHandle(record.jobId, 'running', true);
|
|
524
|
+
if (record.status === 'failed') {
|
|
525
|
+
throw new McpToolError(record.error?.message ?? 'Job failed.', record.error?.hint ? { hint: record.error.hint } : undefined);
|
|
526
|
+
}
|
|
527
|
+
if (record.resultOmitted) {
|
|
528
|
+
// Both reasons mean "the generation happened, the payload is not here",
|
|
529
|
+
// but only one of them is the caller's to change.
|
|
530
|
+
throw new McpToolError(record.resultOmitted === 'inline'
|
|
531
|
+
? `Job "${record.jobId}" finished, but its result was returned inline (base64 image data) and inline results are not stored, so there is nothing to replay. Re-run without \`inline: true\` so the media is stored and returned as URLs, which a poll can replay.`
|
|
532
|
+
: `Job "${record.jobId}" finished, but its result was too large to store (over ${Math.round(MAX_PERSISTED_RESULT_BYTES / 1024)} KB), so there is nothing to replay.`, {
|
|
533
|
+
hint: 'The generation itself completed — list what it produced with gemini_list_recent_media rather than paying for it again.',
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
if (!record.result)
|
|
537
|
+
throw new McpToolError(`Job "${record.jobId}" completed without a recorded result.`);
|
|
538
|
+
// Re-sign before replaying: the record outlives the signed URLs inside it,
|
|
539
|
+
// and handing back a dead link is a failure that looks like a success.
|
|
540
|
+
return refreshMedia(record.result, this.resigner);
|
|
541
|
+
}
|
|
299
542
|
}
|
|
543
|
+
/** How long the host keeps a stored object, quoted in the unknown-job message. */
|
|
544
|
+
const JOB_RECORD_RETENTION_LABEL = '30 days';
|
package/dist/media-url.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* Kept free of any Cloudflare import so the whole thing is testable under Node:
|
|
16
16
|
* WebCrypto is the only primitive used, and it exists on both runtimes.
|
|
17
17
|
*/
|
|
18
|
+
import { signedLinks } from './signed-url.js';
|
|
18
19
|
/** How long a freshly minted media URL stays valid. Matches Files API retention. */
|
|
19
20
|
export const MEDIA_URL_TTL_MS = 48 * 60 * 60 * 1000;
|
|
20
21
|
/** KV key holding the lazily-generated signing secret (zero-config path). */
|
|
@@ -128,8 +129,14 @@ export async function verifyMediaSignature(signingKey, key, exp, sig, nowMs) {
|
|
|
128
129
|
return { ok: false, reason: 'expired' };
|
|
129
130
|
return { ok: true };
|
|
130
131
|
}
|
|
131
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* `<base>/<key>?exp=…&sig=…` — the URL handed back to the caller.
|
|
134
|
+
*
|
|
135
|
+
* Delegates to {@link signedLinks} so this and `buildUploadUrl` cannot drift
|
|
136
|
+
* apart the way they did across the re-host. Prefer taking a {@link SignedLinks}
|
|
137
|
+
* over a base-URL string in new code; this wrapper exists for callers that
|
|
138
|
+
* already hold a base.
|
|
139
|
+
*/
|
|
132
140
|
export function buildMediaUrl(baseUrl, key, expiresAtMs, signature) {
|
|
133
|
-
|
|
134
|
-
return `${baseUrl.replace(/\/+$/, '')}/${path}?exp=${expiresAtMs}&sig=${signature}`;
|
|
141
|
+
return signedLinks(baseUrl).media(key, expiresAtMs, signature);
|
|
135
142
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tool roster, and the one place error reporting is attached to it.
|
|
3
|
+
*
|
|
4
|
+
* This lives apart from `index.ts` because that file is an ENTRY POINT: it has
|
|
5
|
+
* top-level `await loadStdioDotenv()` and `await runMcp(…)`, so importing it
|
|
6
|
+
* starts a server on stdio. Nothing can assert what it wires without booting
|
|
7
|
+
* it — which is precisely how `.map(reporting)` could be deleted with the
|
|
8
|
+
* suite still green, leaving hosted failures unattributed again (the exact
|
|
9
|
+
* regression this repo just spent a debugging round trip on). Here the list is
|
|
10
|
+
* an ordinary value, and `tests/registrars.test.ts` runs a registrar through it.
|
|
11
|
+
*/
|
|
12
|
+
import { surfaceToolErrors } from './errors.js';
|
|
13
|
+
import { registerModelTools } from './tools/models.js';
|
|
14
|
+
import { registerGenerateTools } from './tools/generate.js';
|
|
15
|
+
import { registerSetTools } from './tools/set.js';
|
|
16
|
+
import { registerInteractTools } from './tools/interact.js';
|
|
17
|
+
import { registerVideoTools } from './tools/video.js';
|
|
18
|
+
import { registerMusicTools } from './tools/music.js';
|
|
19
|
+
import { registerJobTools } from './tools/jobs.js';
|
|
20
|
+
import { registerFileTools } from './tools/files.js';
|
|
21
|
+
import { registerUploadUrlTools } from './tools/uploads.js';
|
|
22
|
+
import { registerLibraryTools } from './tools/library.js';
|
|
23
|
+
/**
|
|
24
|
+
* Give one registrar a server that reports failures in full.
|
|
25
|
+
*
|
|
26
|
+
* A hosted upload-URL failure reached the client as a bare "Error occurred
|
|
27
|
+
* during tool execution" — no message, no failing step. That wrapper is the
|
|
28
|
+
* client's, but it is only ever the whole story when our own text is empty, so
|
|
29
|
+
* every handler's throw is turned into a message naming the tool, the real
|
|
30
|
+
* error class, and the `cause` chain MCP serialization drops (src/errors.ts).
|
|
31
|
+
*/
|
|
32
|
+
export const reporting = (register) => (server, deps) => register(surfaceToolErrors(server), deps);
|
|
33
|
+
/**
|
|
34
|
+
* Every registrar, already wrapped. `index.ts` passes this straight to
|
|
35
|
+
* `runMcp`; the last two self-gate on the client having somewhere to put bytes
|
|
36
|
+
* (hosted with a blob store they register, on a local install they no-op
|
|
37
|
+
* rather than advertising a tool that cannot work).
|
|
38
|
+
*/
|
|
39
|
+
export const TOOL_REGISTRARS = [
|
|
40
|
+
registerModelTools,
|
|
41
|
+
registerGenerateTools,
|
|
42
|
+
registerSetTools,
|
|
43
|
+
registerInteractTools,
|
|
44
|
+
registerVideoTools,
|
|
45
|
+
registerMusicTools,
|
|
46
|
+
registerJobTools,
|
|
47
|
+
registerFileTools,
|
|
48
|
+
registerUploadUrlTools,
|
|
49
|
+
registerLibraryTools,
|
|
50
|
+
].map(reporting);
|
package/dist/session.js
CHANGED
|
@@ -5,16 +5,17 @@ import { JobRegistry } from './jobs.js';
|
|
|
5
5
|
* All of this used to be module-level (`jobs`/`byKey`/`runningByFingerprint` in
|
|
6
6
|
* jobs.ts, `lastInteractionId`/`writtenOutputs` in tools/interact.ts). That is
|
|
7
7
|
* correct for stdio, where a process serves exactly one user — and a
|
|
8
|
-
* cross-tenant leak on the hosted connector, where
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* cross-tenant leak on the hosted connector, where ONE child process serves
|
|
9
|
+
* every session of this registration at once (mcp-host runs the stdio server as
|
|
10
|
+
* a supervised child). Module state there is shared by
|
|
11
|
+
* every authenticated claude.ai session on that child: the per-session
|
|
11
12
|
* `GeminiClient` isolated the API key but not the memory around it, so a
|
|
12
13
|
* colliding `idempotency_key` replayed another user's result, an identical
|
|
13
14
|
* prompt attached to another user's in-flight (billable) job, and
|
|
14
15
|
* `continue_last: true` resumed another user's interaction under your key.
|
|
15
16
|
*
|
|
16
17
|
* So session memory hangs off the `GeminiClient` — the one object the connector
|
|
17
|
-
* already builds per authenticated session
|
|
18
|
+
* already builds per authenticated session and
|
|
18
19
|
* threads into every registrar. Handlers reach it as `client.session`.
|
|
19
20
|
*
|
|
20
21
|
* The rule this encodes: **`src/` must hold no module-level mutable state.**
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place that knows how a link into the object store is SHAPED.
|
|
3
|
+
*
|
|
4
|
+
* There are two kinds of signed link — a media GET (`?exp=&sig=`) and an
|
|
5
|
+
* upload PUT (`?ct=&exp=&sig=`) — and before this module they were two
|
|
6
|
+
* independent copies of the same string building, in two files, each holding
|
|
7
|
+
* its own base URL. That is exactly how the re-host from the retired Cloudflare
|
|
8
|
+
* connector to mcp-host split them: the media builder was moved onto the new
|
|
9
|
+
* `https://host/b/<registrationId>` base and the upload builder was a separate
|
|
10
|
+
* edit in a separate file, so nothing structural said the two had to agree.
|
|
11
|
+
*
|
|
12
|
+
* Both builders now delegate here, and {@link signedLinks} binds ONE base URL
|
|
13
|
+
* to both of them at once, so the next re-host either moves both or neither.
|
|
14
|
+
*
|
|
15
|
+
* Byte-compatible with what shipped: the path is percent-encoded per segment
|
|
16
|
+
* (separators survive, everything else is encoded — the gateway decodes per
|
|
17
|
+
* segment and verifies against the LOGICAL key), and the query parameters keep
|
|
18
|
+
* their historical order. Every link ever minted still parses.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* `<base>/<encoded key>?<params>`.
|
|
22
|
+
*
|
|
23
|
+
* Parameter order is insertion order and is part of the observable shape —
|
|
24
|
+
* nothing verifies against it, but the curl commands in `curl_hint` and every
|
|
25
|
+
* link in a user's history read the same way across a redeploy.
|
|
26
|
+
*/
|
|
27
|
+
export function signedObjectUrl(baseUrl, key, params) {
|
|
28
|
+
const path = key.split('/').map(encodeURIComponent).join('/');
|
|
29
|
+
const query = new URLSearchParams();
|
|
30
|
+
for (const [name, value] of Object.entries(params))
|
|
31
|
+
query.set(name, String(value));
|
|
32
|
+
return `${baseUrl.replace(/\/+$/, '')}/${path}?${query}`;
|
|
33
|
+
}
|
|
34
|
+
/** Bind one base URL to both link shapes. */
|
|
35
|
+
export function signedLinks(baseUrl) {
|
|
36
|
+
const base = baseUrl.replace(/\/+$/, '');
|
|
37
|
+
return {
|
|
38
|
+
base,
|
|
39
|
+
media: (key, expiresAtMs, signature) => signedObjectUrl(base, key, { exp: expiresAtMs, sig: signature }),
|
|
40
|
+
upload: (key, contentType, expiresAtMs, signature) => signedObjectUrl(base, key, { ct: contentType.toLowerCase(), exp: expiresAtMs, sig: signature }),
|
|
41
|
+
};
|
|
42
|
+
}
|
package/dist/storage/media.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* behaviour and must stay byte-for-byte equivalent to it.
|
|
13
13
|
*/
|
|
14
14
|
import { base64ToBytes } from '../bytes.js';
|
|
15
|
+
import { signedLinks } from '../signed-url.js';
|
|
15
16
|
/**
|
|
16
17
|
* The stdio sink: `resolveOutputDir(output_dir)` → `writeMedia` per item, in
|
|
17
18
|
* order (so `uniquePath`'s `name`/`name-2`/`name-3` sequencing is preserved).
|
|
@@ -44,6 +45,30 @@ export async function tenantIdFor(secret) {
|
|
|
44
45
|
// `gen`, not `media`: the objects are served at `/media/<key>`, and a `media`
|
|
45
46
|
// prefix would make every URL read `/media/media/…`.
|
|
46
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
|
+
}
|
|
47
72
|
/** Object-key-safe name: no slashes, no dot-segments, never empty. */
|
|
48
73
|
export function safeKeySegment(base) {
|
|
49
74
|
const cleaned = base
|
|
@@ -65,7 +90,7 @@ export function createR2Sink(bucket, opts) {
|
|
|
65
90
|
const now = opts.now ?? (() => new Date());
|
|
66
91
|
const randomId = opts.randomId ?? (() => crypto.randomUUID().slice(0, 8));
|
|
67
92
|
const publicBase = opts.publicBaseUrl?.replace(/\/+$/, '');
|
|
68
|
-
const signedBase = opts.signedBaseUrl?.replace(/\/+$/, '');
|
|
93
|
+
const signedBase = opts.links?.base ?? opts.signedBaseUrl?.replace(/\/+$/, '');
|
|
69
94
|
const ttlMs = opts.urlTtlMs ?? 0;
|
|
70
95
|
const readPrefixes = [prefix, ...(opts.readPrefixes ?? [])];
|
|
71
96
|
let tenantCache;
|
|
@@ -102,6 +127,52 @@ export function createR2Sink(bucket, opts) {
|
|
|
102
127
|
mimeType: object.httpMetadata?.contentType ?? 'application/octet-stream',
|
|
103
128
|
};
|
|
104
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
|
+
},
|
|
105
176
|
async resign(key) {
|
|
106
177
|
if (!(await owned(key)))
|
|
107
178
|
return undefined;
|
|
@@ -145,9 +216,11 @@ export function createR2Sink(bucket, opts) {
|
|
|
145
216
|
if (publicBase)
|
|
146
217
|
return { ref: `${publicBase}/${key}`, key };
|
|
147
218
|
if (signedBase && opts.sign) {
|
|
148
|
-
const { buildMediaUrl } = await import('../media-url.js');
|
|
149
219
|
const signature = await opts.sign(key, expiresAtMs);
|
|
150
|
-
|
|
220
|
+
// `signedLinks` is the ONE shape-owner shared with the upload-URL minter
|
|
221
|
+
// (src/signed-url.ts) — never hand-build the query here.
|
|
222
|
+
const link = (opts.links ?? signedLinks(signedBase)).media(key, expiresAtMs, signature);
|
|
223
|
+
return { ref: link, key, expiresAt: new Date(expiresAtMs).toISOString() };
|
|
151
224
|
}
|
|
152
225
|
// Neither configured. Unreachable in the Worker (which always passes its
|
|
153
226
|
// own origin), so this is a misconfiguration rather than a mode — say so
|