@chrischall/gemini-mcp 1.4.0 → 1.5.1
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 +64 -1
- package/dist/bundle.js +334 -30
- package/dist/client.js +24 -3
- package/dist/inputs.js +37 -1
- package/dist/jobs.js +46 -2
- package/dist/library.js +151 -0
- package/dist/media-cleanup.js +4 -1
- package/dist/media-endpoint.js +6 -0
- package/dist/media-url.js +2 -2
- package/dist/put-endpoint.js +122 -0
- package/dist/storage/media.js +23 -12
- package/dist/tools/generate.js +34 -10
- package/dist/tools/interact.js +3 -2
- package/dist/tools/library.js +186 -0
- package/dist/tools/music.js +3 -2
- package/dist/tools/set.js +37 -9
- package/dist/tools/shared.js +195 -2
- package/dist/tools/uploads.js +69 -0
- package/dist/tools/video.js +3 -2
- package/dist/upload-url.js +138 -0
- package/dist/version.js +1 -1
- package/dist/zip.js +113 -0
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/tools/shared.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult, McpToolError, readEnvVar } from '@chrischall/mcp-utils';
|
|
3
3
|
import { resolveVideoPath, videoMimeType } from '../images.js';
|
|
4
|
-
import { IMAGE_URL_MAX_BYTES } from '../fetch-image.js';
|
|
5
|
-
import { wholeMb } from '../bytes.js';
|
|
4
|
+
import { IMAGE_URL_MAX_BYTES, IMAGE_INLINE_MAX_BYTES } from '../fetch-image.js';
|
|
5
|
+
import { bytesToBase64, base64ToBytes, wholeMb } from '../bytes.js';
|
|
6
6
|
import { createDiskSink } from '../storage/media.js';
|
|
7
7
|
import { downloadFilename } from '../media-endpoint.js';
|
|
8
|
+
import { buildZip } from '../zip.js';
|
|
8
9
|
/**
|
|
9
10
|
* Fallback sink for call sites that pass no explicit one (every pre-existing
|
|
10
11
|
* test, and any caller constructed before the sink seam existed). The stdio
|
|
@@ -55,6 +56,136 @@ export const asyncSchema = z
|
|
|
55
56
|
.boolean()
|
|
56
57
|
.optional()
|
|
57
58
|
.describe('Run in the background and return a job_id immediately instead of the image, so a long (Pro/4K) generation cannot hit the host tools/call timeout (-32001). Poll gemini_get_result with the job_id to fetch the result (jobs are per-process and expire ~10 min after completion).');
|
|
59
|
+
/**
|
|
60
|
+
* Wait-then-hand-off budget shared by every generation tool: wait up to the
|
|
61
|
+
* budget for the in-band result, and past it return a `{ job_id }` handle
|
|
62
|
+
* (the work continues; the caller polls `gemini_get_result`). The middle
|
|
63
|
+
* ground between fully synchronous and `async: true`.
|
|
64
|
+
*/
|
|
65
|
+
export const maxWaitMsSchema = z
|
|
66
|
+
.number()
|
|
67
|
+
.int()
|
|
68
|
+
.positive()
|
|
69
|
+
.max(600_000)
|
|
70
|
+
.optional()
|
|
71
|
+
.describe('Wait up to this many ms for the result; if generation is still running when the budget expires, return ' +
|
|
72
|
+
'{ job_id, status: "running" } immediately instead (poll gemini_get_result). Keeps fast results in-band while a ' +
|
|
73
|
+
'slow batch can never trip the host tools/call timeout (-32001) — e.g. 20000 for multi-image sets. Ignored when async is set.');
|
|
74
|
+
/**
|
|
75
|
+
* Reference images already in THIS connector's object store, by `r2_key`.
|
|
76
|
+
* Hosted connector only — the server reads its own bucket (tenant-gated), so
|
|
77
|
+
* no bytes enter the conversation and no signed URL is needed.
|
|
78
|
+
*/
|
|
79
|
+
export function imagesR2KeysSchema(label = 'Reference images') {
|
|
80
|
+
return z
|
|
81
|
+
.array(z.string().min(1))
|
|
82
|
+
.optional()
|
|
83
|
+
.describe(`${label} by r2_key from THIS connector's store: a signed upload (gemini_get_upload_url → curl PUT) or an earlier ` +
|
|
84
|
+
"generation's media[].r2_key. The server reads its own bucket directly — no bytes in the conversation, no signed URL, " +
|
|
85
|
+
'no ~48h Files API expiry. Hosted connector only.');
|
|
86
|
+
}
|
|
87
|
+
/** Saved-character names attached to a generation (hosted connector only). */
|
|
88
|
+
export const charactersSchema = z
|
|
89
|
+
.array(z.string().min(1))
|
|
90
|
+
.max(8)
|
|
91
|
+
.optional()
|
|
92
|
+
.describe('Names of saved characters (see gemini_list_characters / gemini_save_character): each one\'s reference image and ' +
|
|
93
|
+
'description are attached to the request automatically, keeping recurring subjects consistent without re-sending anything. ' +
|
|
94
|
+
'Hosted connector only.');
|
|
95
|
+
/** Saved-style name applied to a generation (hosted connector only). */
|
|
96
|
+
export const styleSchema = z
|
|
97
|
+
.string()
|
|
98
|
+
.min(1)
|
|
99
|
+
.optional()
|
|
100
|
+
.describe('Name of a saved style preset (see gemini_list_styles / gemini_save_style): its prompt fragment — and reference image, ' +
|
|
101
|
+
'if it has one — is applied to the request automatically. Hosted connector only.');
|
|
102
|
+
/**
|
|
103
|
+
* Turn `characters: ["finn", …]` / `style: "…"` into reference-image inputs
|
|
104
|
+
* plus prompt text, from the account's saved library.
|
|
105
|
+
*
|
|
106
|
+
* A name that is not saved fails BEFORE any billable call, and names what IS
|
|
107
|
+
* saved — a generation silently missing its reference is a wasted charge that
|
|
108
|
+
* looks like a model failure.
|
|
109
|
+
*/
|
|
110
|
+
export async function resolveCharacterRefs(client, names, styleName) {
|
|
111
|
+
if (!names?.length && !styleName)
|
|
112
|
+
return { inputs: [] };
|
|
113
|
+
const library = client.library;
|
|
114
|
+
if (!library) {
|
|
115
|
+
throw new McpToolError('`characters` / `style` reference this account\'s saved library, which exists on the hosted connector only. ' +
|
|
116
|
+
'On this server pass the reference images directly (images / images_url / images_file_uris) and put the style text in the prompt.');
|
|
117
|
+
}
|
|
118
|
+
const inputs = [];
|
|
119
|
+
const described = [];
|
|
120
|
+
// Same size rule as every other funnel: a large reference is promoted to a
|
|
121
|
+
// Files API reference rather than inlined — generateContent caps a whole
|
|
122
|
+
// request near 20MB, and a crew of full-resolution characters would hit it.
|
|
123
|
+
const toInput = async (image, displayName) => {
|
|
124
|
+
if (image.bytes.byteLength <= IMAGE_INLINE_MAX_BYTES) {
|
|
125
|
+
return { base64: bytesToBase64(image.bytes), mimeType: image.mimeType };
|
|
126
|
+
}
|
|
127
|
+
const uploaded = await client.uploadBytes(image.bytes, image.mimeType, displayName);
|
|
128
|
+
return { uri: uploaded.uri, mimeType: uploaded.mimeType };
|
|
129
|
+
};
|
|
130
|
+
for (const name of names ?? []) {
|
|
131
|
+
const record = await library.getCharacter(name);
|
|
132
|
+
const image = record && (await library.readCharacterImage(name));
|
|
133
|
+
if (!record || !image) {
|
|
134
|
+
const available = (await library.listCharacters()).map((c) => c.name);
|
|
135
|
+
throw new McpToolError(`No saved character "${name}". ` +
|
|
136
|
+
(available.length ? `Saved characters: ${available.join(', ')}. ` : 'None are saved yet. ') +
|
|
137
|
+
'Save one with gemini_save_character.');
|
|
138
|
+
}
|
|
139
|
+
inputs.push(await toInput(image, name));
|
|
140
|
+
described.push(`${record.name} — ${record.description}`);
|
|
141
|
+
}
|
|
142
|
+
let styleFragment;
|
|
143
|
+
let styleImageAttached = false;
|
|
144
|
+
if (styleName) {
|
|
145
|
+
const record = await library.getStyle(styleName);
|
|
146
|
+
if (!record) {
|
|
147
|
+
const available = (await library.listStyles()).map((s) => s.name);
|
|
148
|
+
throw new McpToolError(`No saved style "${styleName}". ` +
|
|
149
|
+
(available.length ? `Saved styles: ${available.join(', ')}. ` : 'None are saved yet. ') +
|
|
150
|
+
'Save one with gemini_save_style.');
|
|
151
|
+
}
|
|
152
|
+
styleFragment = record.prompt_fragment;
|
|
153
|
+
if (record.image_key) {
|
|
154
|
+
const image = await library.readStyleImage(styleName);
|
|
155
|
+
if (image) {
|
|
156
|
+
inputs.push(await toInput(image, styleName));
|
|
157
|
+
styleImageAttached = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// The style note is POSITIONAL, never "the final attached image": every
|
|
162
|
+
// caller appends the caller's own reference images (images / images_url /
|
|
163
|
+
// images_file_uris / images_r2_keys) AFTER these inputs, so a "final image"
|
|
164
|
+
// claim would point the model at the caller's last subject photo whenever
|
|
165
|
+
// characters/style are combined with direct references. These inputs always
|
|
166
|
+
// lead the attachment order, so index-based wording stays true regardless
|
|
167
|
+
// of what follows.
|
|
168
|
+
const characterLine = described.length
|
|
169
|
+
? `Use the attached reference images for these characters, in order: ${described
|
|
170
|
+
.map((d, i) => `(${i + 1}) ${d}`)
|
|
171
|
+
.join('; ')}. Keep each character visually consistent with their reference image.` +
|
|
172
|
+
(styleImageAttached ? ` Attached image (${described.length + 1}) is a style reference, not a character.` : '')
|
|
173
|
+
: styleImageAttached
|
|
174
|
+
? 'The first attached image is a style reference.'
|
|
175
|
+
: undefined;
|
|
176
|
+
const meta = {};
|
|
177
|
+
if (names?.length)
|
|
178
|
+
meta.characters = names;
|
|
179
|
+
if (styleName)
|
|
180
|
+
meta.style = styleName;
|
|
181
|
+
return { inputs, characterLine, styleFragment, meta: Object.keys(meta).length ? meta : undefined };
|
|
182
|
+
}
|
|
183
|
+
/** Compose the effective prompt from the caller's text + library lines. */
|
|
184
|
+
export function composePrompt(prompt, refs) {
|
|
185
|
+
return [refs.characterLine, prompt, refs.styleFragment ? `Style: ${refs.styleFragment}` : undefined]
|
|
186
|
+
.filter(Boolean)
|
|
187
|
+
.join('\n\n');
|
|
188
|
+
}
|
|
58
189
|
/**
|
|
59
190
|
* Reference images the SERVER fetches, so the bytes never pass through the
|
|
60
191
|
* model's context window. Defined once and reused under both the `images_url`
|
|
@@ -105,6 +236,7 @@ export const sharedImageSchema = {
|
|
|
105
236
|
timeout_ms: timeoutMsSchema,
|
|
106
237
|
idempotency_key: idempotencyKeySchema,
|
|
107
238
|
async: asyncSchema,
|
|
239
|
+
max_wait_ms: maxWaitMsSchema,
|
|
108
240
|
};
|
|
109
241
|
/**
|
|
110
242
|
* Turn the `video_url` / `video_path` tool args into the client's video opts.
|
|
@@ -268,6 +400,7 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
|
|
|
268
400
|
const sink = opts.sink ?? DEFAULT_SINK;
|
|
269
401
|
const persisted = await sink.persist(named.map((n) => ({ base: n.base, base64: n.media.base64, mimeType: n.media.mimeType })), {
|
|
270
402
|
output_dir: opts.output_dir,
|
|
403
|
+
urlTtlMs: opts.urlTtlMs,
|
|
271
404
|
});
|
|
272
405
|
const refs = persisted.map((p) => p.ref);
|
|
273
406
|
// `onWritten` (sidecars, the written-outputs set) is meaningful only when the
|
|
@@ -320,6 +453,66 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
|
|
|
320
453
|
export async function emit(named, opts, meta, onWritten) {
|
|
321
454
|
return emitMedia(named.map((n) => ({ media: n.image, base: n.base })), 'image', opts, meta, onWritten);
|
|
322
455
|
}
|
|
456
|
+
/**
|
|
457
|
+
* Signed-URL lifetime for MULTI-image results (and their bundle zip) on
|
|
458
|
+
* object-storage sinks: ~7 days rather than the default ~48h, clamped by the
|
|
459
|
+
* sink to the retention window. A set is a batch someone comes back to; its
|
|
460
|
+
* links dying in two days while the objects live seven served nobody.
|
|
461
|
+
*/
|
|
462
|
+
export const SET_URL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
463
|
+
/**
|
|
464
|
+
* Total DECODED size cap for bundling a set into a zip. Zipping holds roughly
|
|
465
|
+
* 4× the set's bytes at peak (the decoded copies, the STORE zip, and the
|
|
466
|
+
* zip's own base64) on top of the base64 strings the result already carries —
|
|
467
|
+
* and a Cloudflare isolate gets ~128MB, where an OOM kill is uncatchable, so
|
|
468
|
+
* the try/catch in {@link persistBundle} cannot honor its best-effort promise
|
|
469
|
+
* against it. The guard runs on sizes ESTIMATED from base64 length, before a
|
|
470
|
+
* single byte is decoded.
|
|
471
|
+
*/
|
|
472
|
+
export const BUNDLE_MAX_TOTAL_BYTES = 24 * 1024 * 1024;
|
|
473
|
+
/**
|
|
474
|
+
* Bundle a multi-image result into ONE downloadable zip and return the meta
|
|
475
|
+
* fields to merge into the result (`bundle_url` + a detailed `bundle` object).
|
|
476
|
+
*
|
|
477
|
+
* Object-storage sinks only, and only when there is more than one image — the
|
|
478
|
+
* point is turning N downloads into one `curl`, which a single image already
|
|
479
|
+
* is. Best-effort: a result that cost N billable generations must never fail
|
|
480
|
+
* because zipping or storing the bundle did — but a skipped bundle is SAID
|
|
481
|
+
* (`bundle_skipped`), never silently absent, so the caller falls back to the
|
|
482
|
+
* per-image URLs instead of hunting for a field that will never appear.
|
|
483
|
+
*/
|
|
484
|
+
export async function persistBundle(named, sink, base) {
|
|
485
|
+
if (!sink || sink.persistsFiles || named.length < 2)
|
|
486
|
+
return {};
|
|
487
|
+
const totalBytes = named.reduce((sum, n) => sum + Math.floor((n.image.base64.length * 3) / 4), 0);
|
|
488
|
+
if (totalBytes > BUNDLE_MAX_TOTAL_BYTES) {
|
|
489
|
+
return {
|
|
490
|
+
bundle_skipped: `The set totals ~${wholeMb(totalBytes)}MB decoded, over the ${wholeMb(BUNDLE_MAX_TOTAL_BYTES)}MB bundling cap ` +
|
|
491
|
+
'(zipping peaks at ~4x that in Worker memory). Download the images individually via their media urls / curl_hints.',
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
try {
|
|
495
|
+
const { mediaExt } = await import('../images.js');
|
|
496
|
+
const zip = buildZip(named.map((n) => ({ name: `${n.base}.${mediaExt(n.image.mimeType)}`, bytes: base64ToBytes(n.image.base64) })));
|
|
497
|
+
const [persisted] = await sink.persist([{ base: `${base}-bundle`, base64: bytesToBase64(zip), mimeType: 'application/zip' }], { urlTtlMs: SET_URL_TTL_MS });
|
|
498
|
+
if (!persisted || persisted.unavailable) {
|
|
499
|
+
return { bundle_skipped: 'The bundle zip could not be stored; the images themselves are unaffected — download them individually via their media urls / curl_hints.' };
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
bundle_url: persisted.ref,
|
|
503
|
+
bundle: {
|
|
504
|
+
url: persisted.ref,
|
|
505
|
+
...(persisted.key ? { r2_key: persisted.key } : {}),
|
|
506
|
+
...(persisted.expiresAt ? { expires_at: persisted.expiresAt } : {}),
|
|
507
|
+
files: named.map((n) => n.base),
|
|
508
|
+
...(persisted.key ? { curl_hint: `curl -sS -o ${downloadFilename(persisted.key)} "${persisted.ref}"` } : {}),
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
return { bundle_skipped: 'Bundling failed; the images themselves are unaffected — download them individually via their media urls / curl_hints.' };
|
|
514
|
+
}
|
|
515
|
+
}
|
|
323
516
|
/**
|
|
324
517
|
* Inputs that can only come off a local filesystem. On the hosted connector
|
|
325
518
|
* (a Cloudflare Worker) none of them exist:
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { McpToolError, textResult } from '@chrischall/mcp-utils';
|
|
3
|
+
import { UPLOAD_MAX_BYTES, RASTER_IMAGE_TYPE_PATTERN } from '../upload-url.js';
|
|
4
|
+
import { wholeMb } from '../bytes.js';
|
|
5
|
+
/**
|
|
6
|
+
* `gemini_get_upload_url` — mint a short-lived signed PUT URL so a shell can
|
|
7
|
+
* upload a reference image with ZERO auth headers:
|
|
8
|
+
*
|
|
9
|
+
* curl -sS -X PUT -H "Content-Type: image/jpeg" --data-binary @photo.jpg "<upload_url>"
|
|
10
|
+
*
|
|
11
|
+
* This closes the last gap in the no-bytes-in-the-conversation story. The two
|
|
12
|
+
* existing byte routes each exclude a common caller: `images_base64` costs
|
|
13
|
+
* ~14k tokens per photo and corrupts silently on a truncated read, and
|
|
14
|
+
* `POST /upload` needs the MCP session's OAuth bearer token, which a sandboxed
|
|
15
|
+
* shell doesn't have. A signed URL is mintable by the session and usable by
|
|
16
|
+
* the shell.
|
|
17
|
+
*
|
|
18
|
+
* Hosted connector only — stdio has no HTTP surface to PUT against (and no
|
|
19
|
+
* need: it reads local paths directly). Gated on `client.uploadUrls`, the same
|
|
20
|
+
* pattern as `gemini_sign_media` / the library tools.
|
|
21
|
+
*/
|
|
22
|
+
export function registerUploadUrlTools(server, client) {
|
|
23
|
+
const minter = client.uploadUrls;
|
|
24
|
+
if (!minter)
|
|
25
|
+
return;
|
|
26
|
+
server.registerTool('gemini_get_upload_url', {
|
|
27
|
+
description: 'Mint a short-lived (~10 min) signed PUT URL for uploading one reference image to this connector with NO auth ' +
|
|
28
|
+
'headers — usable from any shell: curl -sS -X PUT -H "Content-Type: image/jpeg" --data-binary @photo.jpg "<upload_url>". ' +
|
|
29
|
+
'The response to the PUT returns an `r2_key` to reference the upload: pass it in `images_r2_keys` on generation ' +
|
|
30
|
+
'tools, save it permanently with gemini_save_character, or convert it to a ~48h Files API reference with ' +
|
|
31
|
+
'gemini_upload_file({ r2_key }). Prefer this over images_base64 (which costs ~14k tokens per photo) whenever the ' +
|
|
32
|
+
`caller has a shell. Images only, up to ${wholeMb(UPLOAD_MAX_BYTES)}MB.`,
|
|
33
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
34
|
+
inputSchema: {
|
|
35
|
+
filename: z
|
|
36
|
+
.string()
|
|
37
|
+
.min(1)
|
|
38
|
+
.max(120)
|
|
39
|
+
.describe('Filename of the upload (e.g. "finn.jpg") — sanitized into the object key and the suggested curl command'),
|
|
40
|
+
content_type: z
|
|
41
|
+
.string()
|
|
42
|
+
// Raster only — image/svg+xml is a scriptable document and /media
|
|
43
|
+
// serves from the connector's own origin (see RASTER_IMAGE_TYPE_PATTERN).
|
|
44
|
+
.regex(RASTER_IMAGE_TYPE_PATTERN, 'must be a raster image MIME type like image/jpeg (SVG is not accepted)')
|
|
45
|
+
.describe('MIME type of the bytes that will be PUT — raster images only (jpeg/png/webp/gif/avif/heic/heif/bmp/tiff; not SVG). The PUT must send exactly this Content-Type.'),
|
|
46
|
+
},
|
|
47
|
+
}, async (args) => {
|
|
48
|
+
let minted;
|
|
49
|
+
try {
|
|
50
|
+
minted = await minter.mint(args.filename, args.content_type);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
throw new McpToolError(err instanceof Error ? err.message : String(err));
|
|
54
|
+
}
|
|
55
|
+
return textResult({
|
|
56
|
+
upload_url: minted.url,
|
|
57
|
+
method: 'PUT',
|
|
58
|
+
r2_key: minted.key,
|
|
59
|
+
content_type: minted.contentType,
|
|
60
|
+
expires_at: new Date(minted.expiresAtMs).toISOString(),
|
|
61
|
+
max_bytes: UPLOAD_MAX_BYTES,
|
|
62
|
+
curl_hint: `curl -sS -X PUT -H "Content-Type: ${minted.contentType}" --data-binary @${args.filename} "${minted.url}"`,
|
|
63
|
+
hint: 'PUT the raw bytes within ~10 minutes; no Authorization header is needed (the signature in the URL is the auth). ' +
|
|
64
|
+
'Then reference the returned r2_key: images_r2_keys on generation tools (master_images_r2_keys on gemini_image_set), ' +
|
|
65
|
+
'gemini_save_character to keep it with no expiry, or gemini_upload_file({ r2_key }) for a ~48h files/… reference. ' +
|
|
66
|
+
'Uploads themselves follow the media retention schedule (default 7 days).',
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
package/dist/tools/video.js
CHANGED
|
@@ -3,7 +3,7 @@ import { McpToolError } from '@chrischall/mcp-utils';
|
|
|
3
3
|
import { slugify, baseName } from '../images.js';
|
|
4
4
|
import { resolveImageInputs } from '../inputs.js';
|
|
5
5
|
import { DEFAULT_VIDEO_MODEL } from '../models.js';
|
|
6
|
-
import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
|
|
6
|
+
import { emitMedia, timeoutMsSchema, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
|
|
7
7
|
import { fingerprintRequest } from '../jobs.js';
|
|
8
8
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
9
9
|
/** omni's own aspect-ratio enum — NOT the image tools' ASPECT_RATIOS. */
|
|
@@ -44,6 +44,7 @@ export function registerVideoTools(server, client) {
|
|
|
44
44
|
timeout_ms: timeoutMsSchema,
|
|
45
45
|
idempotency_key: idempotencyKeySchema,
|
|
46
46
|
async: asyncSchema,
|
|
47
|
+
max_wait_ms: maxWaitMsSchema,
|
|
47
48
|
confirm: schemaConfirm,
|
|
48
49
|
},
|
|
49
50
|
}, async (args, extra) => {
|
|
@@ -69,7 +70,7 @@ export function registerVideoTools(server, client) {
|
|
|
69
70
|
images_url: args.images_url, images_file_uris: args.images_file_uris,
|
|
70
71
|
previous_interaction_id: previousInteractionId,
|
|
71
72
|
});
|
|
72
|
-
return client.session.jobs.dispatch({ toolName: 'gemini_video_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
73
|
+
return client.session.jobs.dispatch({ toolName: 'gemini_video_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
|
|
73
74
|
const { inputs, report } = await resolveImageInputs(args, client);
|
|
74
75
|
const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client.generateVideo({
|
|
75
76
|
input: args.prompt,
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Short-lived signed PUT URLs — how reference bytes get INTO the connector
|
|
3
|
+
* without either base64 through the model's context or an OAuth bearer token.
|
|
4
|
+
*
|
|
5
|
+
* The problem these solve is the mirror image of `/media`: a signed GET lets a
|
|
6
|
+
* shell *download* generated media with no auth header, but uploading a
|
|
7
|
+
* reference photo still required `images_base64` (~14k tokens per JPEG, silently
|
|
8
|
+
* corrupting on a truncated read) or `POST /upload` (which 401s from any shell
|
|
9
|
+
* that doesn't hold the MCP session's bearer token). A signed PUT URL closes
|
|
10
|
+
* that gap: the authenticated MCP session mints it, and then a bare
|
|
11
|
+
* `curl -X PUT --data-binary @photo.jpg "<url>"` stores the bytes.
|
|
12
|
+
*
|
|
13
|
+
* Same HMAC secret as the media GET URLs, but a DIFFERENT payload shape, so the
|
|
14
|
+
* two kinds of signature can never be replayed across protocols:
|
|
15
|
+
*
|
|
16
|
+
* - media GET signs `<key>\n<exp>` (media-url.ts — do not change;
|
|
17
|
+
* every outstanding link was signed with that exact shape)
|
|
18
|
+
* - upload PUT signs `put\0<key>\0<ct>\0<exp>` (NUL separators)
|
|
19
|
+
*
|
|
20
|
+
* A media payload always contains a `\n` and never a NUL (the sink sanitises
|
|
21
|
+
* every key segment it mints); an upload payload always contains NULs and
|
|
22
|
+
* never a `\n` — the endpoint refuses keys and content types outside a strict
|
|
23
|
+
* charset (no whitespace, no control characters) BEFORE verifying, so no
|
|
24
|
+
* crafted value can straddle a field boundary and no byte string is valid
|
|
25
|
+
* under both interpretations in either direction.
|
|
26
|
+
*
|
|
27
|
+
* The signature also covers the content type, so the uploaded object's stored
|
|
28
|
+
* MIME is exactly what the mint call declared — a URL minted for `image/jpeg`
|
|
29
|
+
* cannot be used to store `text/html`.
|
|
30
|
+
*
|
|
31
|
+
* Like media-url.ts, this module is WebCrypto-only and free of any Cloudflare
|
|
32
|
+
* import so it tests under Node.
|
|
33
|
+
*/
|
|
34
|
+
import { base64UrlEncode, base64UrlDecode } from './media-url.js';
|
|
35
|
+
import { safeKeySegment } from './storage/media.js';
|
|
36
|
+
/** How long a minted upload URL stays usable. Deliberately short — it exists to
|
|
37
|
+
* be curled within the same conversation turn that minted it. */
|
|
38
|
+
export const UPLOAD_URL_TTL_MS = 10 * 60 * 1000;
|
|
39
|
+
/** Upload size cap enforced by the PUT endpoint (while reading, not from the
|
|
40
|
+
* declared length alone). Matches the `images_url` fetch cap — a reference
|
|
41
|
+
* photo, not an archive. */
|
|
42
|
+
export const UPLOAD_MAX_BYTES = 15 * 1024 * 1024;
|
|
43
|
+
/** Key prefix for signed uploads: `up/<tenant>/<date>/<random>-<name>`. Swept
|
|
44
|
+
* by the retention cron like generated media (`gen/`); the character library
|
|
45
|
+
* (`lib/`) is where anything meant to be permanent gets copied. */
|
|
46
|
+
export const UPLOAD_KEY_PREFIX = 'up';
|
|
47
|
+
/**
|
|
48
|
+
* The bytes an upload signature covers: method, object key, content type,
|
|
49
|
+
* expiry. NUL separators, written as the \u0000 ESCAPE and never a literal NUL
|
|
50
|
+
* byte — a literal one makes git classify this whole file as binary and stop
|
|
51
|
+
* diffing it (the same trap as fingerprintRequest in jobs.ts, and exactly how
|
|
52
|
+
* this security-critical module briefly shipped unreviewable). No crafted
|
|
53
|
+
* value can straddle a field boundary: the endpoint enforces a control-free
|
|
54
|
+
* charset on the key and content type before verifying (see
|
|
55
|
+
* `wellFormedUploadKey` / `acceptableUploadType`).
|
|
56
|
+
*/
|
|
57
|
+
function payload(key, contentType, expiresAtMs) {
|
|
58
|
+
return new TextEncoder().encode(`put\u0000${key}\u0000${contentType.toLowerCase()}\u0000${expiresAtMs}`);
|
|
59
|
+
}
|
|
60
|
+
/** Sign one upload: this key, this content type, until `expiresAtMs`. */
|
|
61
|
+
export async function signUploadKey(signingKey, key, contentType, expiresAtMs) {
|
|
62
|
+
return base64UrlEncode(await crypto.subtle.sign('HMAC', signingKey, payload(key, contentType, expiresAtMs)));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Check a `?ct=&exp=&sig=` triple against an object key. Same contract as
|
|
66
|
+
* `verifyMediaSignature`: constant-time comparison via `crypto.subtle.verify`,
|
|
67
|
+
* and signature validity is checked before expiry so a forgery is never
|
|
68
|
+
* confirmed as "merely expired".
|
|
69
|
+
*/
|
|
70
|
+
export async function verifyUploadSignature(signingKey, key, contentType, exp, sig, nowMs) {
|
|
71
|
+
if (!contentType || !exp || !sig)
|
|
72
|
+
return { ok: false, reason: 'missing' };
|
|
73
|
+
const expiresAtMs = Number(exp);
|
|
74
|
+
if (!Number.isFinite(expiresAtMs))
|
|
75
|
+
return { ok: false, reason: 'invalid' };
|
|
76
|
+
let valid;
|
|
77
|
+
try {
|
|
78
|
+
valid = await crypto.subtle.verify('HMAC', signingKey, base64UrlDecode(sig), payload(key, contentType, expiresAtMs));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return { ok: false, reason: 'invalid' };
|
|
82
|
+
}
|
|
83
|
+
if (!valid)
|
|
84
|
+
return { ok: false, reason: 'invalid' };
|
|
85
|
+
if (nowMs > expiresAtMs)
|
|
86
|
+
return { ok: false, reason: 'expired' };
|
|
87
|
+
return { ok: true };
|
|
88
|
+
}
|
|
89
|
+
/** `<base>/<key>?ct=…&exp=…&sig=…` — the URL handed back by the mint tool. */
|
|
90
|
+
export function buildUploadUrl(baseUrl, key, contentType, expiresAtMs, signature) {
|
|
91
|
+
const path = key.split('/').map(encodeURIComponent).join('/');
|
|
92
|
+
return `${baseUrl.replace(/\/+$/, '')}/${path}?ct=${encodeURIComponent(contentType.toLowerCase())}&exp=${expiresAtMs}&sig=${signature}`;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* RASTER image types only — an explicit allowlist, not `image/*`.
|
|
96
|
+
*
|
|
97
|
+
* The wildcard admitted `image/svg+xml`, and an SVG is a *document*: scripts
|
|
98
|
+
* inside it execute when a browser opens the `/media` link, which serves from
|
|
99
|
+
* the SAME ORIGIN as the connector's OAuth pages — stored XSS. Everything on
|
|
100
|
+
* this list is pure pixels, and it covers what Gemini accepts as reference
|
|
101
|
+
* input. `/media` also sends a no-script CSP as defense in depth, but the
|
|
102
|
+
* allowlist is the primary gate: don't widen it to a wildcard again.
|
|
103
|
+
*
|
|
104
|
+
* Shared by the mint tool's schema, the PUT endpoint, and the library's
|
|
105
|
+
* image sources (`gemini_save_character` / `_style`), so the gate cannot
|
|
106
|
+
* drift between the front door and the side doors.
|
|
107
|
+
*/
|
|
108
|
+
export const RASTER_IMAGE_TYPE_PATTERN = /^image\/(jpeg|png|webp|gif|avif|heic|heif|bmp|tiff)$/i;
|
|
109
|
+
/** True for the MIME types a signed upload will store: raster images only. */
|
|
110
|
+
export function acceptableUploadType(contentType) {
|
|
111
|
+
return RASTER_IMAGE_TYPE_PATTERN.test(contentType);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Build the minter. Keys are `up/<tenant>/<YYYY-MM-DD>/<random>-<safe-name>` —
|
|
115
|
+
* tenant-scoped at MINT time, which is what "scoped to the account that minted
|
|
116
|
+
* it" means mechanically: the signature only ever covers a key inside this
|
|
117
|
+
* session's own namespace, so a signed PUT cannot write into anyone else's.
|
|
118
|
+
*/
|
|
119
|
+
export function createUploadUrlMinter(opts) {
|
|
120
|
+
const now = opts.now ?? (() => new Date());
|
|
121
|
+
const randomId = opts.randomId ?? (() => crypto.randomUUID().slice(0, 8));
|
|
122
|
+
const ttlMs = opts.ttlMs ?? UPLOAD_URL_TTL_MS;
|
|
123
|
+
return {
|
|
124
|
+
async mint(filename, contentType) {
|
|
125
|
+
const ct = contentType.trim().toLowerCase();
|
|
126
|
+
if (!acceptableUploadType(ct)) {
|
|
127
|
+
throw new Error(`content_type must be a raster image type (image/jpeg, image/png, image/webp, image/gif, image/avif, image/heic, image/heif, image/bmp, image/tiff), got "${contentType}". ` +
|
|
128
|
+
'SVG is deliberately not accepted: it can carry scripts, and /media serves from the connector origin.');
|
|
129
|
+
}
|
|
130
|
+
const tenant = typeof opts.tenant === 'function' ? await opts.tenant() : opts.tenant;
|
|
131
|
+
const day = now().toISOString().slice(0, 10);
|
|
132
|
+
const key = `${UPLOAD_KEY_PREFIX}/${tenant}/${day}/${randomId()}-${safeKeySegment(filename)}`;
|
|
133
|
+
const expiresAtMs = now().getTime() + ttlMs;
|
|
134
|
+
const signature = await opts.sign(key, ct, expiresAtMs);
|
|
135
|
+
return { url: buildUploadUrl(opts.baseUrl, key, ct, expiresAtMs, signature), key, contentType: ct, expiresAtMs };
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
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.
|
|
2
|
+
export const VERSION = '1.5.1'; // x-release-please-version
|
package/dist/zip.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal ZIP writer (STORE only, no compression) for bundling a multi-image
|
|
3
|
+
* result into one downloadable archive.
|
|
4
|
+
*
|
|
5
|
+
* Why hand-rolled: the Worker has no filesystem and no archiver dependency,
|
|
6
|
+
* and the images being bundled are already-compressed PNG/JPEG — DEFLATE would
|
|
7
|
+
* buy nothing. STORE entries are just headers + raw bytes + CRC32, which is
|
|
8
|
+
* ~100 lines and runs identically under Node and workerd.
|
|
9
|
+
*
|
|
10
|
+
* Layout: [local header + name + data]* [central directory]* [EOCD]. No
|
|
11
|
+
* ZIP64 (a bundle is a handful of images, far under the 4 GB / 65k-entry
|
|
12
|
+
* limits), no encryption, no streaming.
|
|
13
|
+
*/
|
|
14
|
+
/** Standard CRC-32 (IEEE 802.3), table-driven. The table is immutable. */
|
|
15
|
+
const CRC_TABLE = (() => {
|
|
16
|
+
const table = new Uint32Array(256);
|
|
17
|
+
for (let n = 0; n < 256; n++) {
|
|
18
|
+
let c = n;
|
|
19
|
+
for (let k = 0; k < 8; k++)
|
|
20
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
21
|
+
table[n] = c >>> 0;
|
|
22
|
+
}
|
|
23
|
+
return table;
|
|
24
|
+
})();
|
|
25
|
+
export function crc32(bytes) {
|
|
26
|
+
let crc = 0xffffffff;
|
|
27
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
28
|
+
crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
|
|
29
|
+
}
|
|
30
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
31
|
+
}
|
|
32
|
+
/** MS-DOS date/time pair (ZIP's native timestamp format). */
|
|
33
|
+
function dosDateTime(at) {
|
|
34
|
+
const year = Math.max(1980, at.getUTCFullYear());
|
|
35
|
+
return {
|
|
36
|
+
time: (at.getUTCHours() << 11) | (at.getUTCMinutes() << 5) | (at.getUTCSeconds() >> 1),
|
|
37
|
+
date: ((year - 1980) << 9) | ((at.getUTCMonth() + 1) << 5) | at.getUTCDate(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const LOCAL_HEADER = 0x04034b50;
|
|
41
|
+
const CENTRAL_HEADER = 0x02014b50;
|
|
42
|
+
const EOCD = 0x06054b50;
|
|
43
|
+
/** Version 2.0 — the minimum that understands STORE + directories. */
|
|
44
|
+
const VERSION = 20;
|
|
45
|
+
/** General-purpose bit 11: names are UTF-8. */
|
|
46
|
+
const FLAG_UTF8 = 0x0800;
|
|
47
|
+
/**
|
|
48
|
+
* Build a STORE-only zip. `modifiedAt` stamps every entry (injectable so a
|
|
49
|
+
* given input always produces identical bytes; defaults to the epoch of the
|
|
50
|
+
* format, 1980).
|
|
51
|
+
*/
|
|
52
|
+
export function buildZip(entries, modifiedAt = new Date(Date.UTC(1980, 0, 1))) {
|
|
53
|
+
const { time, date } = dosDateTime(modifiedAt);
|
|
54
|
+
const encoder = new TextEncoder();
|
|
55
|
+
const parts = [];
|
|
56
|
+
const central = [];
|
|
57
|
+
let offset = 0;
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
const name = encoder.encode(entry.name);
|
|
60
|
+
const crc = crc32(entry.bytes);
|
|
61
|
+
const size = entry.bytes.byteLength;
|
|
62
|
+
const local = new Uint8Array(30 + name.byteLength);
|
|
63
|
+
const lv = new DataView(local.buffer);
|
|
64
|
+
lv.setUint32(0, LOCAL_HEADER, true);
|
|
65
|
+
lv.setUint16(4, VERSION, true);
|
|
66
|
+
lv.setUint16(6, FLAG_UTF8, true);
|
|
67
|
+
lv.setUint16(8, 0, true); // method: STORE
|
|
68
|
+
lv.setUint16(10, time, true);
|
|
69
|
+
lv.setUint16(12, date, true);
|
|
70
|
+
lv.setUint32(14, crc, true);
|
|
71
|
+
lv.setUint32(18, size, true); // compressed == uncompressed under STORE
|
|
72
|
+
lv.setUint32(22, size, true);
|
|
73
|
+
lv.setUint16(26, name.byteLength, true);
|
|
74
|
+
lv.setUint16(28, 0, true); // extra length
|
|
75
|
+
local.set(name, 30);
|
|
76
|
+
const dir = new Uint8Array(46 + name.byteLength);
|
|
77
|
+
const dv = new DataView(dir.buffer);
|
|
78
|
+
dv.setUint32(0, CENTRAL_HEADER, true);
|
|
79
|
+
dv.setUint16(4, VERSION, true); // version made by
|
|
80
|
+
dv.setUint16(6, VERSION, true); // version needed
|
|
81
|
+
dv.setUint16(8, FLAG_UTF8, true);
|
|
82
|
+
dv.setUint16(10, 0, true);
|
|
83
|
+
dv.setUint16(12, time, true);
|
|
84
|
+
dv.setUint16(14, date, true);
|
|
85
|
+
dv.setUint32(16, crc, true);
|
|
86
|
+
dv.setUint32(20, size, true);
|
|
87
|
+
dv.setUint32(24, size, true);
|
|
88
|
+
dv.setUint16(28, name.byteLength, true);
|
|
89
|
+
// extra len, comment len, disk start, internal attrs, external attrs = 0
|
|
90
|
+
dv.setUint32(42, offset, true);
|
|
91
|
+
dir.set(name, 46);
|
|
92
|
+
parts.push(local, entry.bytes);
|
|
93
|
+
central.push(dir);
|
|
94
|
+
offset += local.byteLength + size;
|
|
95
|
+
}
|
|
96
|
+
const centralSize = central.reduce((n, c) => n + c.byteLength, 0);
|
|
97
|
+
const eocd = new Uint8Array(22);
|
|
98
|
+
const ev = new DataView(eocd.buffer);
|
|
99
|
+
ev.setUint32(0, EOCD, true);
|
|
100
|
+
ev.setUint16(8, entries.length, true); // entries on this disk
|
|
101
|
+
ev.setUint16(10, entries.length, true); // entries total
|
|
102
|
+
ev.setUint32(12, centralSize, true);
|
|
103
|
+
ev.setUint32(16, offset, true); // central directory offset
|
|
104
|
+
// comment length = 0
|
|
105
|
+
const total = offset + centralSize + eocd.byteLength;
|
|
106
|
+
const out = new Uint8Array(total);
|
|
107
|
+
let cursor = 0;
|
|
108
|
+
for (const part of [...parts, ...central, eocd]) {
|
|
109
|
+
out.set(part, cursor);
|
|
110
|
+
cursor += part.byteLength;
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrischall/gemini-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"mcpName": "io.github.chrischall/gemini-mcp",
|
|
5
5
|
"description": "Gemini image-generation MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/gemini-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.5.1",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/gemini-mcp",
|
|
14
|
-
"version": "1.
|
|
14
|
+
"version": "1.5.1",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|