@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.
@@ -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
+ }
@@ -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.4.0'; // x-release-please-version
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.4.0",
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.4.0",
9
+ "version": "1.5.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "1.4.0",
14
+ "version": "1.5.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },