@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/dist/client.js CHANGED
@@ -5,6 +5,7 @@ import { createDiskSink, createR2Sink, tenantIdFor } from './storage/media.js';
5
5
  import { createR2Library } from './library.js';
6
6
  import { createUploadUrlMinter } from './upload-url.js';
7
7
  import { blobStoreFromEnv } from './blob-store.js';
8
+ import { createBlobJobStore } from './job-store.js';
8
9
  import { SessionState } from './session.js';
9
10
  import { fetchRemoteImage } from './fetch-image.js';
10
11
  // NOTE: this module must stay SIDE-EFFECT-FREE at module scope — no I/O, no
@@ -167,6 +168,14 @@ export class GeminiClient {
167
168
  // A replayed idempotent result must not hand back an expired media URL, so
168
169
  // the job registry needs a way to re-sign from the stable r2_key.
169
170
  this.session.jobs.resigner = this.mediaSink;
171
+ // Durable job records, where the deployment has somewhere to keep them.
172
+ // Without this the hosted connector loses a job the moment its machine
173
+ // stops — which it does as soon as no request is in flight (job-store.ts).
174
+ this.session.jobs.store = opts.jobStore;
175
+ // And on that runtime, `async: true` is served by HOLDING the request:
176
+ // releasing it is what lets the executor be reclaimed mid-generation.
177
+ if (opts.jobStore)
178
+ this.session.jobs.asyncWaitFallbackMs = HOSTED_ASYNC_WAIT_MS;
170
179
  this.explicitApiKey = opts.apiKey?.trim() || undefined;
171
180
  // Never store the global `fetch` raw on the instance: it would be invoked
172
181
  // as `this.fetchImpl(...)`, handing native fetch this GeminiClient as its
@@ -776,7 +785,11 @@ function hostedStorage() {
776
785
  const tenant = () => tenantIdFor(readEnvVar('GEMINI_API_KEY') ?? 'local');
777
786
  return {
778
787
  mediaSink: createR2Sink(blob.bucket, {
779
- signedBaseUrl: blob.baseUrl,
788
+ // The SAME `links` object the minter gets below. Media GETs and upload
789
+ // PUTs are one store reached two ways; handing each its own base URL is
790
+ // how the re-host moved the media links to /b/<registrationId>/ and left
791
+ // the upload links behind. One object, two shapes, no second edit.
792
+ links: blob.links,
780
793
  sign: blob.signRead,
781
794
  urlTtlMs: BLOB_URL_TTL_MS,
782
795
  // Hard ceiling on any PER-PERSIST override. `gemini_image_set` asks for
@@ -792,9 +805,25 @@ function hostedStorage() {
792
805
  tenant,
793
806
  }),
794
807
  library: createR2Library(blob.bucket, { tenant }),
795
- uploadUrls: createUploadUrlMinter({ baseUrl: blob.baseUrl, sign: blob.signWrite, tenant }),
808
+ uploadUrls: createUploadUrlMinter({ links: blob.links, sign: blob.signWrite, tenant }),
809
+ // Job records go in the same store as the media, for the same reason: this
810
+ // runtime's machine stops whenever no request is in flight, taking the
811
+ // child — and an in-memory registry — with it (src/job-store.ts).
812
+ jobStore: createBlobJobStore(blob.bucket, { tenant }),
796
813
  };
797
814
  }
815
+ /**
816
+ * How long a hosted `async: true` call holds the request open before handing
817
+ * back a job id.
818
+ *
819
+ * Not a tuning knob so much as the shape of the platform: holding the request
820
+ * is what keeps the executor alive, so this is "how long we are willing to keep
821
+ * the machine up for one generation". Four minutes is the value observed to
822
+ * work in practice against claude.ai, and comfortably covers a Pro/4K image or
823
+ * a modest set; anything slower hands back a durable job id rather than
824
+ * silently dying, which is the behaviour this whole change is for.
825
+ */
826
+ const HOSTED_ASYNC_WAIT_MS = 240_000;
798
827
  /**
799
828
  * How long a freshly-minted media link lives.
800
829
  *
package/dist/errors.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Making a failure say what actually failed.
3
+ *
4
+ * A hosted `gemini_get_upload_url` call came back to the client as
5
+ * `{"error": "Error occurred during tool execution", "request_id": "req_…"}` —
6
+ * no message, no failing step, no stack. That outer wrapper is the client's and
7
+ * not ours to change, but it is only ever reached when what we hand back is
8
+ * itself uninformative, and two of this repo's habits make that likely:
9
+ *
10
+ * - a thrown non-`McpToolError` surfaces as bare `err.message`, which for a
11
+ * runtime fault (`Illegal invocation`, `fetch failed`) names neither the
12
+ * tool nor the operation that was in flight; and
13
+ * - `cause` is dropped by MCP serialization, so a wrapped error arrives with
14
+ * its actual root cause deleted — the trap `ChainedRequest404Error` in
15
+ * client.ts already documents for `hint`, which the host drops too.
16
+ *
17
+ * So: {@link describeError} flattens the whole chain into the message,
18
+ * {@link step} labels the operation, and {@link surfaceToolErrors} wraps a
19
+ * server so EVERY registered handler gets both without each registrar
20
+ * remembering to. `index.ts` applies it to every registrar in the list.
21
+ */
22
+ import { McpToolError } from '@chrischall/mcp-utils';
23
+ /** How far down a `cause` chain to walk before assuming it is pathological. */
24
+ const MAX_CAUSE_DEPTH = 5;
25
+ /**
26
+ * One flat, self-describing line for anything throwable — including the
27
+ * `cause` chain, which MCP serialization deletes.
28
+ *
29
+ * The error's class name is prefixed only when it carries information: a bare
30
+ * `Error` says nothing a reader does not already know, while `TypeError:
31
+ * Illegal invocation` is the entire diagnosis of the workerd receiver bug (see
32
+ * the fetchImpl note in CLAUDE.md).
33
+ */
34
+ export function describeError(err) {
35
+ const seen = new Set();
36
+ const describe = (value, depth) => {
37
+ if (!(value instanceof Error)) {
38
+ if (typeof value === 'string')
39
+ return value;
40
+ try {
41
+ return typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value);
42
+ }
43
+ catch {
44
+ return String(value);
45
+ }
46
+ }
47
+ // A generic `Error` name is noise; anything else is the diagnosis.
48
+ const named = value.name && value.name !== 'Error' && value.name !== 'McpToolError'
49
+ ? `${value.name}: ${value.message}`
50
+ : value.message;
51
+ const hint = value instanceof McpToolError && value.hint && !value.message.includes(value.hint)
52
+ ? ` ${value.hint}`
53
+ : '';
54
+ const cause = value.cause;
55
+ // Self-referential and over-deep chains are real (a retry wrapper that
56
+ // re-wraps its own error); walking one forever is not an option.
57
+ if (cause === undefined || cause === null || depth >= MAX_CAUSE_DEPTH || seen.has(cause)) {
58
+ return `${named}${hint}`;
59
+ }
60
+ seen.add(cause);
61
+ return `${named}${hint} [caused by: ${describe(cause, depth + 1)}]`;
62
+ };
63
+ seen.add(err);
64
+ return describe(err, 0);
65
+ }
66
+ /**
67
+ * Run one named operation, and on failure say which one it was.
68
+ *
69
+ * Only the INNERMOST step labels: an already-labelled failure passes straight
70
+ * through, so a nested `step` reports `signing the upload URL: …` rather than
71
+ * `minting: signing the upload URL: …`. The label is what the caller needs;
72
+ * the call stack above it is not.
73
+ */
74
+ export async function step(label, run) {
75
+ try {
76
+ return await run();
77
+ }
78
+ catch (err) {
79
+ if (err instanceof StepError)
80
+ throw err;
81
+ throw new StepError(`${label}: ${describeError(err)}`, err);
82
+ }
83
+ }
84
+ /** An `McpToolError` that has already been attributed to a step. */
85
+ class StepError extends McpToolError {
86
+ constructor(message, cause) {
87
+ super(message, { cause });
88
+ this.name = 'McpToolError';
89
+ }
90
+ }
91
+ /**
92
+ * Wrap a server so every tool registered through it reports failures fully.
93
+ *
94
+ * A `Proxy` rather than a spread: `McpServer` methods are bound to real private
95
+ * state, and registrars reach for more than `registerTool` (`server.server`,
96
+ * `registerResource`). Everything but `registerTool` passes through untouched,
97
+ * and a successful result is returned byte-identically — this only ever changes
98
+ * what an exception looks like.
99
+ */
100
+ export function surfaceToolErrors(server) {
101
+ return new Proxy(server, {
102
+ get(target, prop, receiver) {
103
+ const value = Reflect.get(target, prop, receiver);
104
+ if (prop !== 'registerTool' || typeof value !== 'function') {
105
+ return typeof value === 'function' ? value.bind(target) : value;
106
+ }
107
+ return (name, config, handler) => value.call(target, name, config, async (...args) => {
108
+ try {
109
+ return await handler(...args);
110
+ }
111
+ catch (err) {
112
+ // Named for the tool AND the underlying error. When the client
113
+ // still shows its own generic wrapper, this text is what a
114
+ // maintainer has to work from — so it carries everything.
115
+ throw new McpToolError(`${name} failed: ${describeError(err)}`, { cause: err });
116
+ }
117
+ });
118
+ },
119
+ });
120
+ }
package/dist/index.js CHANGED
@@ -2,16 +2,7 @@
2
2
  import { runMcp } from '@chrischall/mcp-utils';
3
3
  import { loadStdioDotenv } from './dotenv.js';
4
4
  import { VERSION } from './version.js';
5
- import { registerModelTools } from './tools/models.js';
6
- import { registerGenerateTools } from './tools/generate.js';
7
- import { registerSetTools } from './tools/set.js';
8
- import { registerInteractTools } from './tools/interact.js';
9
- import { registerVideoTools } from './tools/video.js';
10
- import { registerMusicTools } from './tools/music.js';
11
- import { registerJobTools } from './tools/jobs.js';
12
- import { registerFileTools } from './tools/files.js';
13
- import { registerUploadUrlTools } from './tools/uploads.js';
14
- import { registerLibraryTools } from './tools/library.js';
5
+ import { TOOL_REGISTRARS } from './registrars.js';
15
6
  import { client } from './client.js';
16
7
  // Load `.env` here rather than in client.ts: `import.meta.url` + async I/O at
17
8
  // module scope is not safe in every runtime this module graph reaches (see
@@ -29,24 +20,14 @@ await loadStdioDotenv();
29
20
  // boots and answers the host's install-time tools/list probe even without
30
21
  // GEMINI_API_KEY. It also owns this process's session state (job registry,
31
22
  // last-interaction memory); stdio is single-user, so one client is one session.
23
+ //
24
+ // The roster itself lives in registrars.ts: this file cannot be imported
25
+ // without booting a server, and a list nothing can import is a list nothing
26
+ // can test (see the note there).
32
27
  await runMcp({
33
28
  name: 'gemini-mcp',
34
29
  version: VERSION,
35
30
  banner: '[gemini-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.',
36
- tools: [
37
- registerModelTools,
38
- registerGenerateTools,
39
- registerSetTools,
40
- registerInteractTools,
41
- registerVideoTools,
42
- registerMusicTools,
43
- registerJobTools,
44
- registerFileTools,
45
- // Both gate on the client having somewhere to put bytes: hosted with a
46
- // blob store they register, and on a local install they no-op rather than
47
- // advertising a tool that cannot work.
48
- registerUploadUrlTools,
49
- registerLibraryTools,
50
- ],
31
+ tools: TOOL_REGISTRARS,
51
32
  deps: client,
52
33
  });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The durable half of the job registry (`src/jobs.ts`).
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * `JobRegistry` keeps jobs in a `Map`, which is exactly right for stdio: the
7
+ * process that starts a generation is the process that finishes it, and if it
8
+ * dies the user is sitting at a terminal watching it die.
9
+ *
10
+ * Hosted, that premise is false in a specific and non-obvious way. The
11
+ * connector is this same stdio server run as a Node child on an mcp-host Fly
12
+ * machine configured `auto_stop_machines = "stop"` with
13
+ * `min_machines_running = 0`. Fly stops that machine when nothing is in
14
+ * flight — mcp-host's own docs put it plainly: *"an open connection is exactly
15
+ * what stops the machine stopping"*. An `async: true` call is a request that
16
+ * answers in milliseconds, so it holds nothing open. The machine stops, the
17
+ * child is killed, and the generation dies **with the only record that it ever
18
+ * existed**. Polling `gemini_get_result` a couple of minutes later reports the
19
+ * id as unknown, which reads like an expiry bug and is actually a machine that
20
+ * went to sleep.
21
+ *
22
+ * This is also why a long `max_wait_ms` works where `async: true` does not: the
23
+ * wait keeps the HTTP request open, and the open request keeps the machine up.
24
+ *
25
+ * ## What durability can and cannot buy here
26
+ *
27
+ * It buys the RECORD, not the EXECUTION. There is no Workflows, no Queue, no
28
+ * Durable Object and no KV in reach — the child's environment is allowlist-only
29
+ * (mcp-host `spawn-env.ts`), and the only durable thing it can address is the
30
+ * blob store its media already goes to. Nothing here resumes a killed
31
+ * generation; re-running it would re-bill it, which is the cost this whole
32
+ * mechanism exists to avoid.
33
+ *
34
+ * So the contract is: a job never silently vanishes, and a job whose executor
35
+ * died ends in a **terminal** state that says so. The bytes a dead job managed
36
+ * to write are still in `gen/`, which is what `gemini_list_recent_media` is
37
+ * for.
38
+ *
39
+ * ## Liveness is a heartbeat, not a guess
40
+ *
41
+ * "Still running" and "the machine ate it" are indistinguishable from a single
42
+ * stored status, and a timeout long enough to be safe for a 12-scene set is far
43
+ * too long to be useful. So a running job re-stamps `updatedAt` on an interval
44
+ * ({@link HEARTBEAT_MS}), and a record whose stamp is older than
45
+ * {@link EXECUTOR_LOST_AFTER_MS} is read as `failed` — decided at READ time, so
46
+ * no boot scan is needed and a machine that never comes back still resolves its
47
+ * jobs the moment anybody asks.
48
+ *
49
+ * Every operation is best-effort and swallows its errors. A durable store that
50
+ * is down must degrade to the in-memory behaviour that came before it, never
51
+ * fail a generation the user is paying for.
52
+ */
53
+ import { createHash } from 'node:crypto';
54
+ /** How often a running job re-stamps its record so a reader can see it is alive. */
55
+ export const HEARTBEAT_MS = 60_000;
56
+ /**
57
+ * How stale a `running` record may get before its executor is presumed dead.
58
+ *
59
+ * Three missed heartbeats: long enough that a paused event loop mid-generation
60
+ * is not mistaken for a dead machine, short enough that a caller learns the
61
+ * truth in minutes rather than being told to keep polling forever.
62
+ */
63
+ export const EXECUTOR_LOST_AFTER_MS = 3 * HEARTBEAT_MS;
64
+ /** Where a job record lives, relative to the store root. */
65
+ const PREFIX = 'jobs';
66
+ /**
67
+ * An idempotency key is an arbitrary caller-supplied string: it can contain
68
+ * slashes, spaces, or `..`. Hashing it makes the key space closed by
69
+ * construction rather than by escaping, so no crafted key can address an object
70
+ * outside this tenant's prefix.
71
+ */
72
+ function keyHash(idempotencyKey) {
73
+ return createHash('sha256').update(idempotencyKey).digest('hex').slice(0, 32);
74
+ }
75
+ /** Job records over the mcp-host blob store. */
76
+ export function createBlobJobStore(bucket, opts) {
77
+ const now = opts.now ?? (() => Date.now());
78
+ const tenantOf = async () => (typeof opts.tenant === 'function' ? opts.tenant() : opts.tenant);
79
+ const recordKey = async (jobId) => `${PREFIX}/${await tenantOf()}/${encodeURIComponent(jobId)}.json`;
80
+ const pointerKey = async (k) => `${PREFIX}/${await tenantOf()}/keys/${keyHash(k)}.json`;
81
+ async function writeJson(key, value) {
82
+ try {
83
+ await bucket.put(key, JSON.stringify(value), { httpMetadata: { contentType: 'application/json' } });
84
+ }
85
+ catch {
86
+ // A durable write that fails leaves the in-memory registry exactly as
87
+ // capable as it was before this module existed. Failing the generation
88
+ // instead would trade a recoverable gap for a lost paid result.
89
+ }
90
+ }
91
+ async function readJson(key) {
92
+ try {
93
+ const obj = await bucket.get(key);
94
+ if (!obj)
95
+ return undefined;
96
+ return JSON.parse(new TextDecoder().decode(await obj.arrayBuffer()));
97
+ }
98
+ catch {
99
+ return undefined;
100
+ }
101
+ }
102
+ return {
103
+ async put(record) {
104
+ await writeJson(await recordKey(record.jobId), record);
105
+ },
106
+ async get(jobId) {
107
+ const record = await readJson(await recordKey(jobId));
108
+ if (!record || typeof record.status !== 'string')
109
+ return undefined;
110
+ if (record.status !== 'running')
111
+ return record;
112
+ if (now() - record.updatedAt <= EXECUTOR_LOST_AFTER_MS)
113
+ return record;
114
+ // Terminal, and honest about which kind of gone it is. Derived on read
115
+ // rather than persisted: the process that could have written the
116
+ // transition is by definition the one that is no longer there.
117
+ return {
118
+ ...record,
119
+ status: 'failed',
120
+ error: {
121
+ message: `Job "${jobId}" was lost: its executor stopped reporting ${Math.round((now() - record.updatedAt) / 1000)}s ` +
122
+ `ago, before the generation finished. This connector runs on a machine that stops when no request is ` +
123
+ `in flight, so background work started with \`async: true\` does not reliably survive.`,
124
+ hint: 'Re-run with `max_wait_ms` (e.g. 240000) instead of `async: true` — holding the request open keeps the ' +
125
+ 'executor alive. Any images the lost run did finish are still stored: list them with ' +
126
+ 'gemini_list_recent_media before paying for a regeneration.',
127
+ },
128
+ };
129
+ },
130
+ async putKey(idempotencyKey, jobId) {
131
+ await writeJson(await pointerKey(idempotencyKey), { jobId });
132
+ },
133
+ async jobIdForKey(idempotencyKey) {
134
+ const pointer = await readJson(await pointerKey(idempotencyKey));
135
+ return typeof pointer?.jobId === 'string' ? pointer.jobId : undefined;
136
+ },
137
+ };
138
+ }