@chrischall/gemini-mcp 1.0.2 → 1.0.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/README.md +34 -0
- package/dist/bundle.js +1327 -1028
- package/dist/client.js +130 -36
- package/dist/dotenv.js +29 -0
- package/dist/index.js +19 -3
- package/dist/jobs.js +0 -0
- package/dist/session.js +55 -0
- package/dist/sidecar.js +72 -0
- package/dist/storage/media.js +97 -0
- package/dist/tools/generate.js +15 -11
- package/dist/tools/interact.js +116 -47
- package/dist/tools/jobs.js +7 -4
- package/dist/tools/models.js +1 -2
- package/dist/tools/music.js +14 -13
- package/dist/tools/set.js +7 -7
- package/dist/tools/shared.js +72 -10
- package/dist/tools/video.js +16 -14
- package/dist/version.js +1 -1
- package/package.json +15 -4
- package/server.json +2 -2
package/dist/tools/interact.js
CHANGED
|
@@ -1,31 +1,17 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
|
|
2
|
+
import { McpToolError, ApiError, readEnvVar } from '@chrischall/mcp-utils';
|
|
3
3
|
import { resolveModel } from '../models.js';
|
|
4
|
-
import {
|
|
5
|
-
import { slugify, baseName, gatherImageInputs, resolveImagePath, writeSidecar } from '../images.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
4
|
+
import { ChainedRequest404Error } from '../client.js';
|
|
5
|
+
import { slugify, baseName, gatherImageInputs, resolveImagePath, writeSidecar, resolveOutputDir, readImageAsInline } from '../images.js';
|
|
6
|
+
import { findInteractionImages, latestInteractionId } from '../sidecar.js';
|
|
7
|
+
import { emit, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
|
|
8
|
+
import { fingerprintRequest } from '../jobs.js';
|
|
8
9
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
9
|
-
// The
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
//
|
|
14
|
-
// drop a chained call's re-attached prior output: the interaction state
|
|
15
|
-
// already contains that image, and re-sending it as a fresh reference anchors
|
|
16
|
-
// the model against the requested edit (observed in the wild — callers pass
|
|
17
|
-
// the last result path back via `images` despite the description saying not to).
|
|
18
|
-
const writtenOutputs = new Set();
|
|
19
|
-
/**
|
|
20
|
-
* Test-only: clear the module-level session memory (`lastInteractionId` and the
|
|
21
|
-
* written-outputs set) so tests that assert the "no prior interaction" path are
|
|
22
|
-
* order-independent — they can't rely on module-load state once other tests have
|
|
23
|
-
* run first (e.g. under `--sequence.shuffle`).
|
|
24
|
-
*/
|
|
25
|
-
export function __resetInteractSessionForTest() {
|
|
26
|
-
lastInteractionId = undefined;
|
|
27
|
-
writtenOutputs.clear();
|
|
28
|
-
}
|
|
10
|
+
// The last-interaction id and the written-outputs set used to live here, at
|
|
11
|
+
// module scope. On the hosted connector that leaks across tenants — one
|
|
12
|
+
// Cloudflare isolate serves many authenticated sessions, so `continue_last`
|
|
13
|
+
// from user B would resume user A's interaction under B's key. Both now live
|
|
14
|
+
// on `client.session` (src/session.ts), one per authenticated session.
|
|
29
15
|
/** Shared warning for the reference-image params. */
|
|
30
16
|
const NEW_REFERENCES_ONLY = 'NEW reference images only (e.g. a style or target photo). When chaining with previous_interaction_id, ' +
|
|
31
17
|
"do NOT re-attach the prior turn's output — the interaction already contains it, and re-sending it " +
|
|
@@ -35,7 +21,7 @@ const NEW_REFERENCES_ONLY = 'NEW reference images only (e.g. a style or target p
|
|
|
35
21
|
* this server itself generated (which the interaction already contains).
|
|
36
22
|
* Unresolvable paths are kept so `gatherImageInputs` surfaces its usual error.
|
|
37
23
|
*/
|
|
38
|
-
function splitReattachedOutputs(images) {
|
|
24
|
+
function splitReattachedOutputs(images, writtenOutputs) {
|
|
39
25
|
const kept = [];
|
|
40
26
|
const dropped = [];
|
|
41
27
|
for (const p of images) {
|
|
@@ -48,16 +34,29 @@ function splitReattachedOutputs(images) {
|
|
|
48
34
|
}
|
|
49
35
|
return { kept, dropped };
|
|
50
36
|
}
|
|
51
|
-
export function registerInteractTools(server) {
|
|
37
|
+
export function registerInteractTools(server, client) {
|
|
38
|
+
// The sidecar/re-anchor half of this description is only true where there is
|
|
39
|
+
// a filesystem. Describing it anyway on the hosted connector would tell the
|
|
40
|
+
// model to go looking in an output dir that does not exist — so the
|
|
41
|
+
// description is built from the sink's capability, not hardcoded.
|
|
42
|
+
const onDiskRuntime = client.mediaSink?.persistsFiles ?? true;
|
|
43
|
+
const recoveryDescription = onDiskRuntime
|
|
44
|
+
? 'If a call times out on the client side, the generation usually still completes: the image plus a ' +
|
|
45
|
+
'`<image>.json` sidecar recording its interaction id land in the output dir, and `continue_last: true` ' +
|
|
46
|
+
'still resumes that interaction — check the output dir before re-issuing (a re-issue is a second billable generation). ' +
|
|
47
|
+
'If a chained call 404s, this tool re-anchors itself on the prior output image and re-issues un-chained: ' +
|
|
48
|
+
'success is reported as `chain_recovered` (the chain was the problem, and you get your image anyway); ' +
|
|
49
|
+
'a second 404 is reported as the interaction id NOT being the cause (check the model id / files uri instead). '
|
|
50
|
+
: 'This connector has no filesystem: there is no output dir and no `<image>.json` sidecar, so a lost response ' +
|
|
51
|
+
'cannot be recovered from disk and a 404 on a chained call cannot be re-anchored automatically. Capture the ' +
|
|
52
|
+
'returned `interaction_id` from every result, and use `async: true` + gemini_get_result for long generations. ';
|
|
52
53
|
server.registerTool('gemini_interact', {
|
|
53
54
|
description: 'Preferred tool for iterative or multi-step refinement of a single image — ' +
|
|
54
55
|
"multi-turn generation/editing via Gemini's Interactions API. " +
|
|
55
56
|
'To refine, capture the returned interaction `id` and pass it as `previous_interaction_id` ' +
|
|
56
57
|
'on the next call — do NOT start a new interaction or re-upload the image for each tweak. ' +
|
|
57
58
|
'`continue_last: true` chains from this session\'s most recent interaction without threading the id. ' +
|
|
58
|
-
|
|
59
|
-
'`<image>.json` sidecar recording its interaction id land in the output dir, and `continue_last: true` ' +
|
|
60
|
-
'still resumes that interaction — check the output dir before re-issuing (a re-issue is a second billable generation). ' +
|
|
59
|
+
recoveryDescription +
|
|
61
60
|
'Output is JPEG.',
|
|
62
61
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
63
62
|
inputSchema: {
|
|
@@ -69,7 +68,7 @@ export function registerInteractTools(server) {
|
|
|
69
68
|
continue_last: z
|
|
70
69
|
.boolean()
|
|
71
70
|
.optional()
|
|
72
|
-
.describe('Continue from the most recent interaction this server created (convenience for previous_interaction_id; an explicit id wins)'),
|
|
71
|
+
.describe('Continue from the most recent interaction this server created (convenience for previous_interaction_id; an explicit id wins). Survives a server restart by falling back to the newest <image>.json sidecar in the output dir.'),
|
|
73
72
|
images: z
|
|
74
73
|
.array(z.string().min(1))
|
|
75
74
|
.optional()
|
|
@@ -128,19 +127,36 @@ export function registerInteractTools(server) {
|
|
|
128
127
|
confirm: schemaConfirm,
|
|
129
128
|
},
|
|
130
129
|
}, async (args, extra) => {
|
|
130
|
+
assertLocalInputsAvailable(client.mediaSink, args);
|
|
131
|
+
// Every sidecar-backed recovery below reads the output dir off disk. A
|
|
132
|
+
// sink with no filesystem has none, so those paths are skipped entirely
|
|
133
|
+
// rather than left to silently read an empty directory.
|
|
134
|
+
const onDisk = client.mediaSink?.persistsFiles ?? true;
|
|
131
135
|
let previousInteractionId = args.previous_interaction_id;
|
|
136
|
+
// `continue_last` resumes the in-memory id first, then falls back to the
|
|
137
|
+
// newest sidecar in the output dir. The in-memory id dies with the server
|
|
138
|
+
// process, but the interaction itself lives on upstream — without the
|
|
139
|
+
// sidecar fallback an MCP restart mid-workflow reads as an expired chain
|
|
140
|
+
// and costs the caller a manual re-anchor for no reason.
|
|
141
|
+
let continuedFromSidecar = false;
|
|
132
142
|
if (!previousInteractionId && args.continue_last) {
|
|
133
|
-
|
|
143
|
+
previousInteractionId = client.session.lastInteractionId;
|
|
144
|
+
if (!previousInteractionId && onDisk) {
|
|
145
|
+
previousInteractionId = await latestInteractionId(resolveOutputDir(args.output_dir));
|
|
146
|
+
continuedFromSidecar = previousInteractionId !== undefined;
|
|
147
|
+
}
|
|
148
|
+
if (!previousInteractionId) {
|
|
134
149
|
throw new McpToolError('No previous interaction to continue in this session.', {
|
|
135
|
-
hint:
|
|
150
|
+
hint: onDisk
|
|
151
|
+
? 'Call gemini_interact once without continue_last first, or pass an explicit previous_interaction_id.'
|
|
152
|
+
: 'Call gemini_interact once without continue_last first, or pass an explicit previous_interaction_id. (This hosted connector has no filesystem, so there are no <image>.json sidecars to recover an id from across restarts.)',
|
|
136
153
|
});
|
|
137
154
|
}
|
|
138
|
-
previousInteractionId = lastInteractionId;
|
|
139
155
|
}
|
|
140
156
|
let images = args.images;
|
|
141
157
|
let dropped = [];
|
|
142
158
|
if (previousInteractionId && images?.length) {
|
|
143
|
-
({ kept: images, dropped } = splitReattachedOutputs(images));
|
|
159
|
+
({ kept: images, dropped } = splitReattachedOutputs(images, client.session.writtenOutputs));
|
|
144
160
|
}
|
|
145
161
|
// Confirm-gate local file inputs AFTER the re-attach split, so the preview
|
|
146
162
|
// reflects the paths actually sent (dropped prior-output paths aren't). A
|
|
@@ -157,27 +173,78 @@ export function registerInteractTools(server) {
|
|
|
157
173
|
images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
158
174
|
video_url: args.video_url, video_path: args.video_path,
|
|
159
175
|
});
|
|
160
|
-
return dispatch({ toolName: 'gemini_interact', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
176
|
+
return client.session.jobs.dispatch({ toolName: 'gemini_interact', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
161
177
|
const inputs = await gatherImageInputs({ images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
162
|
-
const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args));
|
|
163
|
-
const
|
|
178
|
+
const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args, client));
|
|
179
|
+
const callOpts = {
|
|
164
180
|
input: args.input,
|
|
165
|
-
images: inputs.length ? inputs : undefined,
|
|
166
181
|
model: args.model,
|
|
167
182
|
aspectRatio: args.aspect_ratio,
|
|
168
183
|
imageSize: args.image_size,
|
|
169
184
|
thinkingLevel: args.thinking_level,
|
|
170
|
-
previousInteractionId,
|
|
171
185
|
googleSearch: args.google_search,
|
|
172
186
|
searchTypes: args.search_types,
|
|
173
187
|
videoUrl: video.videoUrl,
|
|
174
188
|
videoMimeType: video.videoMimeType,
|
|
175
189
|
timeoutMs: args.timeout_ms,
|
|
176
|
-
}
|
|
177
|
-
|
|
190
|
+
};
|
|
191
|
+
// A 404 on a chained request proves *something* was missing, not that it
|
|
192
|
+
// was the interaction (the upstream body is generic — see
|
|
193
|
+
// ChainedRequest404Error). So the recovery doubles as the experiment that
|
|
194
|
+
// settles it: re-attach that turn's output image — located by id via its
|
|
195
|
+
// sidecar, never "the newest image", since re-anchoring on the wrong
|
|
196
|
+
// picture would silently corrupt the edit — and re-issue WITHOUT the
|
|
197
|
+
// chain. Succeeds → the chain really was the problem, and the caller gets
|
|
198
|
+
// their image. 404s again → the id was never the cause, and saying so
|
|
199
|
+
// beats sending them to chase an interaction that was fine all along.
|
|
200
|
+
// Neither 404 generates anything, so a success costs the one generation
|
|
201
|
+
// the manual re-anchor would have cost.
|
|
202
|
+
let chainRecovered;
|
|
203
|
+
const r = await withProgressHeartbeat(extra, 'Generating image (Gemini Interactions API)', async () => {
|
|
204
|
+
try {
|
|
205
|
+
return await client.interact({ ...callOpts, images: inputs.length ? inputs : undefined, previousInteractionId });
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
if (!(err instanceof ChainedRequest404Error))
|
|
209
|
+
throw err;
|
|
210
|
+
// Re-anchoring needs the prior output image, which only the sidecar
|
|
211
|
+
// index can locate — and that index lives on disk.
|
|
212
|
+
if (!onDisk)
|
|
213
|
+
throw err;
|
|
214
|
+
const reanchorOn = await findInteractionImages(resolveOutputDir(args.output_dir), err.previousInteractionId);
|
|
215
|
+
if (!reanchorOn.length)
|
|
216
|
+
throw err;
|
|
217
|
+
const carried = await Promise.all(reanchorOn.map((p) => readImageAsInline(p)));
|
|
218
|
+
try {
|
|
219
|
+
const probe = await client.interact({ ...callOpts, images: [...carried, ...inputs], previousInteractionId: undefined });
|
|
220
|
+
chainRecovered = { expired_interaction_id: err.previousInteractionId, reanchored_on: reanchorOn };
|
|
221
|
+
return probe;
|
|
222
|
+
}
|
|
223
|
+
catch (probeErr) {
|
|
224
|
+
if (!(probeErr instanceof ApiError && probeErr.status === 404))
|
|
225
|
+
throw probeErr;
|
|
226
|
+
throw new McpToolError(
|
|
227
|
+
// Remediation goes in the MESSAGE, not just `hint`: MCP error
|
|
228
|
+
// serialization surfaces the message and drops the hint, so a
|
|
229
|
+
// hint-only fix is invisible exactly when it's needed.
|
|
230
|
+
`The interaction id is not the cause of this 404: the same request re-issued WITHOUT previous_interaction_id ("${err.previousInteractionId}") also returned 404. Upstream said: ${probeErr.message}. ` +
|
|
231
|
+
`Check that the model id ("${model}") resolves — see gemini_list_models — and that any files/… uri you referenced hasn't expired (~48h TTL). Re-chaining or re-anchoring will not help.`, {
|
|
232
|
+
hint: `Check the model id ("${model}") resolves — see gemini_list_models — and that any files/… uri you referenced hasn't expired (~48h TTL). Re-chaining or re-anchoring will not help.`,
|
|
233
|
+
cause: probeErr,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
client.session.lastInteractionId = r.id;
|
|
178
239
|
const meta = { model, interaction_id: r.id };
|
|
179
|
-
|
|
240
|
+
// On recovery the prior id was NOT used — reporting it as
|
|
241
|
+
// previous_interaction_id would misrepresent what the model saw.
|
|
242
|
+
if (chainRecovered)
|
|
243
|
+
meta.chain_recovered = chainRecovered;
|
|
244
|
+
else if (previousInteractionId)
|
|
180
245
|
meta.previous_interaction_id = previousInteractionId;
|
|
246
|
+
if (continuedFromSidecar)
|
|
247
|
+
meta.continued_from_sidecar = true;
|
|
181
248
|
if (dropped.length)
|
|
182
249
|
meta.dropped_previous_output = dropped;
|
|
183
250
|
meta.hint =
|
|
@@ -189,7 +256,7 @@ export function registerInteractTools(server) {
|
|
|
189
256
|
meta.grounding = r.grounding;
|
|
190
257
|
if (video.videoFileMeta)
|
|
191
258
|
meta.video_file = video.videoFileMeta;
|
|
192
|
-
const risk = timeoutRiskHint({ model, imageSize: args.image_size });
|
|
259
|
+
const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: onDisk });
|
|
193
260
|
if (risk)
|
|
194
261
|
meta.timeout_risk = risk;
|
|
195
262
|
const slug = args.filename ? baseName(args.filename) : slugify(args.input);
|
|
@@ -197,10 +264,12 @@ export function registerInteractTools(server) {
|
|
|
197
264
|
image,
|
|
198
265
|
base: r.images.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
|
|
199
266
|
}));
|
|
200
|
-
// writeImage returns already-resolved absolute paths.
|
|
201
|
-
|
|
267
|
+
// writeImage returns already-resolved absolute paths. `emit` only
|
|
268
|
+
// invokes this callback for a filesystem-backed sink, so there is no
|
|
269
|
+
// branch here claiming a sidecar the hosted connector cannot write.
|
|
270
|
+
return emit(named, { ...args, sink: client.mediaSink }, meta, async (paths) => {
|
|
202
271
|
for (const p of paths)
|
|
203
|
-
writtenOutputs.add(p);
|
|
272
|
+
client.session.writtenOutputs.add(p);
|
|
204
273
|
// Sidecar per image so the interaction id survives a lost MCP response
|
|
205
274
|
// (host timeout). Best-effort: the image is already on disk and the id
|
|
206
275
|
// is in the result — a sidecar failure shouldn't fail the call.
|
package/dist/tools/jobs.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { getJobResult } from '../jobs.js';
|
|
3
2
|
/**
|
|
4
3
|
* Poll tool for the async generation pattern (issue #52). A generation tool
|
|
5
4
|
* called with `async: true` returns a `job_id` immediately (instead of blocking
|
|
6
5
|
* past a host's tools/call timeout); this tool retrieves that job's status and,
|
|
7
6
|
* once complete, its normal result payload.
|
|
8
7
|
*/
|
|
9
|
-
|
|
8
|
+
// The client is REQUIRED, not decorative: the job registry hangs off it
|
|
9
|
+
// (`client.session.jobs`) so each authenticated connector session polls its own
|
|
10
|
+
// jobs. Reading a module-level registry here would let any session fetch any
|
|
11
|
+
// other session's result by job id — see src/session.ts.
|
|
12
|
+
export function registerJobTools(server, client) {
|
|
10
13
|
server.registerTool('gemini_get_result', {
|
|
11
|
-
description: 'Retrieve an async generation started with `async: true`. Pass the returned `job_id`: while running it reports status "running"; on completion it returns the normal result (image paths / inline images + meta); on failure it raises the recorded error. Jobs
|
|
14
|
+
description: 'Retrieve an async generation started with `async: true`. Pass the returned `job_id`: while running it reports status "running"; on completion it returns the normal result (image paths / inline images + meta); on failure it raises the recorded error. Jobs belong to your session and expire ~10 min after completion — if a job id is unknown, check the output dir / <image>.json sidecar.',
|
|
12
15
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
13
16
|
inputSchema: {
|
|
14
17
|
job_id: z.string().min(1).describe('The job_id returned by a generation tool called with async: true'),
|
|
15
18
|
},
|
|
16
|
-
}, async (args) =>
|
|
19
|
+
}, async (args) => client.session.jobs.getResult(args.job_id));
|
|
17
20
|
}
|
package/dist/tools/models.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { textResult } from '@chrischall/mcp-utils';
|
|
2
|
-
|
|
3
|
-
export function registerModelTools(server) {
|
|
2
|
+
export function registerModelTools(server, client) {
|
|
4
3
|
server.registerTool('gemini_list_models', {
|
|
5
4
|
description: 'List the Gemini image-generation models available to your API key (Nano Banana / Nano Banana Pro family), and the current default model.',
|
|
6
5
|
annotations: { readOnlyHint: true },
|
package/dist/tools/music.js
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { McpToolError } from '@chrischall/mcp-utils';
|
|
3
|
-
import { client } from '../client.js';
|
|
4
3
|
import { slugify, baseName, gatherImageInputs } from '../images.js';
|
|
5
4
|
import { DEFAULT_MUSIC_MODEL } from '../models.js';
|
|
6
|
-
import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat } from './shared.js';
|
|
7
|
-
import {
|
|
5
|
+
import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
|
|
6
|
+
import { fingerprintRequest } from '../jobs.js';
|
|
8
7
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
9
8
|
const MUSIC_MODELS = ['lyria-3-clip-preview', 'lyria-3-pro-preview'];
|
|
10
9
|
const AUDIO_FORMATS = ['mp3', 'wav'];
|
|
11
10
|
/** WAV is Lyria-3-Pro-only (docs); reject it on the clip model up front. */
|
|
12
11
|
function isProModel(model) { return /pro/i.test(model); }
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
// The most-recent music interaction id (for continue_last) lives on
|
|
13
|
+
// `client.session`, NOT at module scope: one Cloudflare isolate serves many
|
|
14
|
+
// authenticated connector sessions, so a module-level id would let one user's
|
|
15
|
+
// continue_last resume another user's interaction under their own key.
|
|
16
|
+
// See src/session.ts.
|
|
17
|
+
export function registerMusicTools(server, client) {
|
|
18
18
|
server.registerTool('gemini_music_generate', {
|
|
19
19
|
description: 'Generate music from a text prompt (mood, genre, instruments, structure, or lyrics inline) via a Lyria model ' +
|
|
20
20
|
'(preview): lyria-3-clip-preview (~30s clips, default) or lyria-3-pro-preview (longer, WAV-capable). Written to ' +
|
|
@@ -39,6 +39,7 @@ export function registerMusicTools(server) {
|
|
|
39
39
|
confirm: schemaConfirm,
|
|
40
40
|
},
|
|
41
41
|
}, async (args, extra) => {
|
|
42
|
+
assertLocalInputsAvailable(client.mediaSink, args);
|
|
42
43
|
const model = args.model ?? DEFAULT_MUSIC_MODEL;
|
|
43
44
|
if (args.audio_format === 'wav' && !isProModel(model)) {
|
|
44
45
|
throw new McpToolError(`WAV output requires a Pro model; ${model} only produces MP3.`, {
|
|
@@ -47,12 +48,12 @@ export function registerMusicTools(server) {
|
|
|
47
48
|
}
|
|
48
49
|
let previousInteractionId = args.previous_interaction_id;
|
|
49
50
|
if (!previousInteractionId && args.continue_last) {
|
|
50
|
-
if (!lastMusicInteractionId) {
|
|
51
|
+
if (!client.session.lastMusicInteractionId) {
|
|
51
52
|
throw new McpToolError('No previous music interaction to continue in this session.', {
|
|
52
53
|
hint: 'Generate music first, or pass an explicit previous_interaction_id.',
|
|
53
54
|
});
|
|
54
55
|
}
|
|
55
|
-
previousInteractionId = lastMusicInteractionId;
|
|
56
|
+
previousInteractionId = client.session.lastMusicInteractionId;
|
|
56
57
|
}
|
|
57
58
|
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, 'Send local image input(s) to the Gemini Lyria API', '/v1beta/interactions', args.images);
|
|
58
59
|
if (gate)
|
|
@@ -62,7 +63,7 @@ export function registerMusicTools(server) {
|
|
|
62
63
|
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
63
64
|
previous_interaction_id: previousInteractionId,
|
|
64
65
|
});
|
|
65
|
-
return dispatch({ toolName: 'gemini_music_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
66
|
+
return client.session.jobs.dispatch({ toolName: 'gemini_music_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
66
67
|
const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
67
68
|
const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () => client.generateMusic({
|
|
68
69
|
input: args.prompt,
|
|
@@ -72,7 +73,7 @@ export function registerMusicTools(server) {
|
|
|
72
73
|
previousInteractionId,
|
|
73
74
|
timeoutMs: args.timeout_ms,
|
|
74
75
|
}));
|
|
75
|
-
lastMusicInteractionId = r.id;
|
|
76
|
+
client.session.lastMusicInteractionId = r.id;
|
|
76
77
|
const meta = { model, interaction_id: r.id };
|
|
77
78
|
if (previousInteractionId)
|
|
78
79
|
meta.previous_interaction_id = previousInteractionId;
|
|
@@ -84,7 +85,7 @@ export function registerMusicTools(server) {
|
|
|
84
85
|
media,
|
|
85
86
|
base: r.audios.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
|
|
86
87
|
}));
|
|
87
|
-
return emitMedia(named, 'audio', { inline: args.inline, output_dir: args.output_dir }, meta);
|
|
88
|
+
return emitMedia(named, 'audio', { inline: args.inline, output_dir: args.output_dir, sink: client.mediaSink }, meta);
|
|
88
89
|
});
|
|
89
90
|
});
|
|
90
91
|
}
|
package/dist/tools/set.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
|
|
3
3
|
import { resolveModel } from '../models.js';
|
|
4
|
-
import { client } from '../client.js';
|
|
5
4
|
import { slugify, baseName, gatherImageInputs } from '../images.js';
|
|
6
|
-
import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProgressHeartbeat } from './shared.js';
|
|
7
|
-
import {
|
|
5
|
+
import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
|
|
6
|
+
import { fingerprintRequest } from '../jobs.js';
|
|
8
7
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
9
|
-
export function registerSetTools(server) {
|
|
8
|
+
export function registerSetTools(server, client) {
|
|
10
9
|
server.registerTool('gemini_image_set', {
|
|
11
10
|
description: 'Generate a consistent SET of images: a master image from master_prompt, then one image per scene that references the master so the subject/style stays consistent. Provide `scenes` (explicit per-image prompts) OR `count` (variations of the master).',
|
|
12
11
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
@@ -22,6 +21,7 @@ export function registerSetTools(server) {
|
|
|
22
21
|
...sharedImageSchema,
|
|
23
22
|
},
|
|
24
23
|
}, async (args, extra) => {
|
|
24
|
+
assertLocalInputsAvailable(client.mediaSink, args);
|
|
25
25
|
// `scenes` is min(1) at the schema, so a non-undefined value is non-empty.
|
|
26
26
|
if ((args.scenes && args.count) || (!args.scenes && !args.count)) {
|
|
27
27
|
throw new McpToolError('Provide exactly one of `scenes` or `count`.');
|
|
@@ -38,7 +38,7 @@ export function registerSetTools(server) {
|
|
|
38
38
|
thinking_level: args.thinking_level, google_search: args.google_search,
|
|
39
39
|
master_images: args.master_images, master_images_base64: args.master_images_base64, from_clipboard: args.from_clipboard,
|
|
40
40
|
});
|
|
41
|
-
return dispatch({ toolName: 'gemini_image_set', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
41
|
+
return client.session.jobs.dispatch({ toolName: 'gemini_image_set', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
42
42
|
const seed = pickSeed(args.seed);
|
|
43
43
|
const cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search, timeoutMs: args.timeout_ms };
|
|
44
44
|
const slug = args.basename ? baseName(args.basename) : slugify(args.master_prompt);
|
|
@@ -81,10 +81,10 @@ export function registerSetTools(server) {
|
|
|
81
81
|
meta.grounding = masterResult.grounding;
|
|
82
82
|
// A set is master + N scenes in one tools/call — the most timeout-prone
|
|
83
83
|
// tool. Effective image count drives the risk hint.
|
|
84
|
-
const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length });
|
|
84
|
+
const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length, persistsFiles: client.mediaSink?.persistsFiles });
|
|
85
85
|
if (risk)
|
|
86
86
|
meta.timeout_risk = risk;
|
|
87
|
-
return emit(named, args, meta);
|
|
87
|
+
return emit(named, { ...args, sink: client.mediaSink }, meta);
|
|
88
88
|
});
|
|
89
89
|
});
|
|
90
90
|
}
|
package/dist/tools/shared.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult, McpToolError, readEnvVar } from '@chrischall/mcp-utils';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { resolveVideoPath, videoMimeType } from '../images.js';
|
|
4
|
+
import { createDiskSink } from '../storage/media.js';
|
|
5
|
+
/**
|
|
6
|
+
* Fallback sink for call sites that pass no explicit one (every pre-existing
|
|
7
|
+
* test, and any caller constructed before the sink seam existed). The stdio
|
|
8
|
+
* server's behaviour is therefore unchanged by omission, not by convention.
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_SINK = createDiskSink();
|
|
5
11
|
/** Supported output aspect ratios (Gemini image API). */
|
|
6
12
|
export const ASPECT_RATIOS = ['1:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9', '1:4', '4:1', '1:8', '8:1'];
|
|
7
13
|
/** Supported output resolutions. `512` is Flash-only (0.5K); `1K`/`2K`/`4K` work on all image models. */
|
|
@@ -80,7 +86,7 @@ export const sharedImageSchema = {
|
|
|
80
86
|
* The uploaded file (uri + expiry) is echoed via `videoFileMeta` so a caller
|
|
81
87
|
* can pass the uri straight back as `video_url` instead of re-uploading.
|
|
82
88
|
*/
|
|
83
|
-
export async function resolveVideoInput(args) {
|
|
89
|
+
export async function resolveVideoInput(args, client) {
|
|
84
90
|
if (args.video_url && args.video_path) {
|
|
85
91
|
throw new McpToolError('Provide `video_url` OR `video_path`, not both.');
|
|
86
92
|
}
|
|
@@ -177,6 +183,10 @@ export async function withProgressHeartbeat(extra, message, fn) {
|
|
|
177
183
|
* `meta.timeout_risk` so a caller that *does* get the result is steered off the
|
|
178
184
|
* slow path next time and knows the image still landed on disk when a host bails.
|
|
179
185
|
* `undefined` for fast configs (no field added).
|
|
186
|
+
*
|
|
187
|
+
* `persistsFiles: false` (the hosted connector) swaps the disk-recovery half of
|
|
188
|
+
* the advice for the async job handle — pointing a Worker's caller at "look in
|
|
189
|
+
* the output dir" would be advice about a filesystem that does not exist.
|
|
180
190
|
*/
|
|
181
191
|
export function timeoutRiskHint(opts) {
|
|
182
192
|
// Delimiter-anchored so real Pro ids (gemini-3-pro-image, nano-banana-pro)
|
|
@@ -189,7 +199,14 @@ export function timeoutRiskHint(opts) {
|
|
|
189
199
|
const why = [proModel && 'Pro model', fourK && '4K output', multi && `count=${opts.count}`]
|
|
190
200
|
.filter(Boolean)
|
|
191
201
|
.join(' + ');
|
|
192
|
-
|
|
202
|
+
const lead = `Slow config (${why}) can exceed a host's tools/call timeout (e.g. Claude Desktop ~30s, error -32001). `;
|
|
203
|
+
if (opts.persistsFiles === false) {
|
|
204
|
+
return (lead +
|
|
205
|
+
`This connector has no filesystem, so a lost response cannot be recovered from disk — there is no output dir and no ` +
|
|
206
|
+
`<image>.json sidecar. Use \`async: true\` to get a job_id immediately and poll gemini_get_result, ` +
|
|
207
|
+
`or use a Flash model / drop image_size to 1K/2K for a faster in-band result.`);
|
|
208
|
+
}
|
|
209
|
+
return (lead +
|
|
193
210
|
`If the host gives up mid-generation the image is still written to disk (and gemini_interact leaves an ` +
|
|
194
211
|
`<image>.json sidecar with the interaction id) — no rerun needed, just look in the output dir. ` +
|
|
195
212
|
`For a faster in-band result use a Flash model, drop image_size to 1K/2K, or — in Claude Code — set MCP_TOOL_TIMEOUT.`);
|
|
@@ -220,14 +237,23 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
|
|
|
220
237
|
}
|
|
221
238
|
return { content: blocks };
|
|
222
239
|
}
|
|
223
|
-
const
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
240
|
+
const sink = opts.sink ?? DEFAULT_SINK;
|
|
241
|
+
const refs = await sink.persist(named.map((n) => ({ base: n.base, base64: n.media.base64, mimeType: n.media.mimeType })), {
|
|
242
|
+
output_dir: opts.output_dir,
|
|
243
|
+
});
|
|
244
|
+
// `onWritten` (sidecars, the written-outputs set) is meaningful only when the
|
|
245
|
+
// refs are real files. On a sink with no filesystem there is nothing to sit
|
|
246
|
+
// next to, so it is skipped rather than handed URLs it would misinterpret.
|
|
247
|
+
if (sink.persistsFiles)
|
|
248
|
+
await onWritten?.(refs);
|
|
228
249
|
// Note a downgraded inline-video request so the caller isn't left wondering.
|
|
229
250
|
const note = opts.inline && kind === 'video' ? { inline_unsupported: 'video has no MCP inline content type — written to disk instead' } : {};
|
|
230
|
-
|
|
251
|
+
// Say where the bytes actually went whenever they are NOT local paths — the
|
|
252
|
+
// key is the same (`images`/`videos`/`audios`), so without this a caller would
|
|
253
|
+
// reasonably read a URL as a file path.
|
|
254
|
+
const storageNote = sink.note();
|
|
255
|
+
const storage = sink.persistsFiles ? {} : { storage: sink.kind, storage_note: storageNote };
|
|
256
|
+
return textResult({ [`${kind}s`]: refs, ...note, ...storage, ...meta });
|
|
231
257
|
}
|
|
232
258
|
/**
|
|
233
259
|
* Image convenience wrapper over {@link emitMedia} — preserves the original
|
|
@@ -236,3 +262,39 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
|
|
|
236
262
|
export async function emit(named, opts, meta, onWritten) {
|
|
237
263
|
return emitMedia(named.map((n) => ({ media: n.image, base: n.base })), 'image', opts, meta, onWritten);
|
|
238
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* Inputs that can only come off a local filesystem. On the hosted connector
|
|
267
|
+
* (a Cloudflare Worker) none of them exist:
|
|
268
|
+
*
|
|
269
|
+
* - `images` / `master_images` — file paths read with `node:fs`.
|
|
270
|
+
* - `from_clipboard` — shells out to `osascript`/`sips` via `child_process`.
|
|
271
|
+
* - `video_path` — the Files API upload streams the file off disk.
|
|
272
|
+
*
|
|
273
|
+
* Called at the top of every generation handler, BEFORE any billable upstream
|
|
274
|
+
* call, so a caller gets a clear "use images_base64 / video_url instead"
|
|
275
|
+
* instead of an obscure module-resolution or ENOENT crash. A no-op on the disk
|
|
276
|
+
* sink, so the stdio behaviour (including its own "Image not found" errors) is
|
|
277
|
+
* untouched.
|
|
278
|
+
*/
|
|
279
|
+
export function assertLocalInputsAvailable(sink, args) {
|
|
280
|
+
if ((sink ?? DEFAULT_SINK).persistsFiles)
|
|
281
|
+
return;
|
|
282
|
+
const unavailable = [];
|
|
283
|
+
if (args.images?.length)
|
|
284
|
+
unavailable.push('`images` (local file paths)');
|
|
285
|
+
if (args.master_images?.length)
|
|
286
|
+
unavailable.push('`master_images` (local file paths)');
|
|
287
|
+
if (args.from_clipboard)
|
|
288
|
+
unavailable.push('`from_clipboard` (reads the macOS clipboard via child_process)');
|
|
289
|
+
if (args.video_path)
|
|
290
|
+
unavailable.push('`video_path` (uploads a file streamed from disk)');
|
|
291
|
+
if (unavailable.length === 0)
|
|
292
|
+
return;
|
|
293
|
+
// Remediation goes in the MESSAGE, not just `hint`: MCP error serialization
|
|
294
|
+
// surfaces the message and drops the hint, so hint-only advice is invisible
|
|
295
|
+
// exactly when it is needed.
|
|
296
|
+
const fix = 'Send image bytes as `images_base64` (raw base64 or a data: URI) instead of `images`/`master_images`/`from_clipboard`, ' +
|
|
297
|
+
'and reference video by `video_url` (a public YouTube URL, or a files/… uri uploaded elsewhere) instead of `video_path`.';
|
|
298
|
+
throw new McpToolError(`${unavailable.join(', ')} ${unavailable.length > 1 ? 'are' : 'is'} unavailable on the hosted connector: ` +
|
|
299
|
+
`it runs on a Cloudflare Worker, which has no filesystem and no local clipboard. ${fix}`, { hint: fix });
|
|
300
|
+
}
|
package/dist/tools/video.js
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { McpToolError } from '@chrischall/mcp-utils';
|
|
3
|
-
import { client } from '../client.js';
|
|
4
3
|
import { slugify, baseName, gatherImageInputs } from '../images.js';
|
|
5
4
|
import { DEFAULT_VIDEO_MODEL } from '../models.js';
|
|
6
|
-
import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat } from './shared.js';
|
|
7
|
-
import {
|
|
5
|
+
import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
|
|
6
|
+
import { fingerprintRequest } from '../jobs.js';
|
|
8
7
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
9
8
|
/** omni's own aspect-ratio enum — NOT the image tools' ASPECT_RATIOS. */
|
|
10
9
|
const VIDEO_ASPECT_RATIOS = ['16:9', '9:16'];
|
|
11
10
|
const VIDEO_TASKS = ['text_to_video', 'image_to_video', 'reference_to_video', 'edit'];
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
let
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
// The most-recent video interaction id (for continue_last) lives on
|
|
12
|
+
// `client.session`, NOT at module scope — one Cloudflare isolate serves many
|
|
13
|
+
// authenticated connector sessions, so a module-level id would let one user's
|
|
14
|
+
// continue_last resume another user's interaction under their own key. Video is
|
|
15
|
+
// not registered on the Worker today, but the hazard is identical, so it is
|
|
16
|
+
// scoped the same way. It stays separate from the image interact memory: a
|
|
17
|
+
// "last video" and a "last image" are distinct chains. See src/session.ts.
|
|
18
|
+
export function registerVideoTools(server, client) {
|
|
18
19
|
server.registerTool('gemini_video_generate', {
|
|
19
20
|
description: 'Generate a short video via the Gemini omni model (preview): text→video, image→video / reference→video ' +
|
|
20
21
|
'(supply reference image[s]), or edit a prior video (task: "edit" + previous_interaction_id / continue_last). ' +
|
|
@@ -43,14 +44,15 @@ export function registerVideoTools(server) {
|
|
|
43
44
|
confirm: schemaConfirm,
|
|
44
45
|
},
|
|
45
46
|
}, async (args, extra) => {
|
|
47
|
+
assertLocalInputsAvailable(client.mediaSink, args);
|
|
46
48
|
let previousInteractionId = args.previous_interaction_id;
|
|
47
49
|
if (!previousInteractionId && args.continue_last) {
|
|
48
|
-
if (!lastVideoInteractionId) {
|
|
50
|
+
if (!client.session.lastVideoInteractionId) {
|
|
49
51
|
throw new McpToolError('No previous video interaction to continue in this session.', {
|
|
50
52
|
hint: 'Generate a video first, or pass an explicit previous_interaction_id.',
|
|
51
53
|
});
|
|
52
54
|
}
|
|
53
|
-
previousInteractionId = lastVideoInteractionId;
|
|
55
|
+
previousInteractionId = client.session.lastVideoInteractionId;
|
|
54
56
|
}
|
|
55
57
|
// Confirm-gate local file inputs (a prompt-injected `images` path could
|
|
56
58
|
// exfiltrate a local file); base64 / clipboard pass through ungated.
|
|
@@ -63,7 +65,7 @@ export function registerVideoTools(server) {
|
|
|
63
65
|
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
64
66
|
previous_interaction_id: previousInteractionId,
|
|
65
67
|
});
|
|
66
|
-
return dispatch({ toolName: 'gemini_video_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
68
|
+
return client.session.jobs.dispatch({ toolName: 'gemini_video_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
67
69
|
const inputs = await gatherImageInputs({ images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard });
|
|
68
70
|
const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client.generateVideo({
|
|
69
71
|
input: args.prompt,
|
|
@@ -74,7 +76,7 @@ export function registerVideoTools(server) {
|
|
|
74
76
|
previousInteractionId,
|
|
75
77
|
timeoutMs: args.timeout_ms,
|
|
76
78
|
}));
|
|
77
|
-
lastVideoInteractionId = r.id;
|
|
79
|
+
client.session.lastVideoInteractionId = r.id;
|
|
78
80
|
const meta = { model, interaction_id: r.id };
|
|
79
81
|
if (previousInteractionId)
|
|
80
82
|
meta.previous_interaction_id = previousInteractionId;
|
|
@@ -86,7 +88,7 @@ export function registerVideoTools(server) {
|
|
|
86
88
|
media,
|
|
87
89
|
base: r.videos.length > 1 ? `${slug}-${String(i + 1).padStart(2, '0')}` : slug,
|
|
88
90
|
}));
|
|
89
|
-
return emitMedia(named, 'video', { output_dir: args.output_dir }, meta);
|
|
91
|
+
return emitMedia(named, 'video', { output_dir: args.output_dir, sink: client.mediaSink }, meta);
|
|
90
92
|
});
|
|
91
93
|
});
|
|
92
94
|
}
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Single source of the server version. release-please bumps the literal below. */
|
|
2
|
-
export const VERSION = '1.0.
|
|
2
|
+
export const VERSION = '1.0.4'; // x-release-please-version
|