@chrischall/gemini-mcp 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.0'; // 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.0",
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.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "1.4.0",
14
+ "version": "1.5.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },