@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +64 -1
- package/dist/bundle.js +330 -27
- package/dist/client.js +6 -0
- 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/inputs.js
CHANGED
|
@@ -7,6 +7,7 @@ export function hasImageInput(args) {
|
|
|
7
7
|
args.images_base64?.length ||
|
|
8
8
|
args.images_url?.length ||
|
|
9
9
|
args.images_file_uris?.length ||
|
|
10
|
+
args.images_r2_keys?.length ||
|
|
10
11
|
args.from_clipboard);
|
|
11
12
|
}
|
|
12
13
|
/** Conservative TTL when the API doesn't tell us: documented retention is ~48h. */
|
|
@@ -75,6 +76,40 @@ export async function resolveImageInputs(args, client) {
|
|
|
75
76
|
(report.file_uris ??= []).push(file.name);
|
|
76
77
|
inputs.push(input);
|
|
77
78
|
}
|
|
79
|
+
// 6. r2_keys — the connector reads its OWN store (tenant-gated), so a signed
|
|
80
|
+
// upload or an earlier generation becomes a reference without a fetch, a
|
|
81
|
+
// signature, or a byte in the conversation. Deduped within the call, same as
|
|
82
|
+
// URLs, and — also same as URLs — a large object is promoted to a Files API
|
|
83
|
+
// reference rather than inlined, because `generateContent` caps a whole
|
|
84
|
+
// request near 20MB and two large inline references would fail it.
|
|
85
|
+
// `readStoredMedia` throws an actionable error for a swept/foreign key.
|
|
86
|
+
const seenKeys = new Map();
|
|
87
|
+
for (const key of args.images_r2_keys ?? []) {
|
|
88
|
+
const hit = seenKeys.get(key);
|
|
89
|
+
if (hit) {
|
|
90
|
+
inputs.push(hit);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const stored = await client.readStoredMedia(key);
|
|
94
|
+
const entry = {
|
|
95
|
+
r2_key: key.trim(),
|
|
96
|
+
mime_type: stored.mimeType,
|
|
97
|
+
bytes: stored.bytes.byteLength,
|
|
98
|
+
};
|
|
99
|
+
(report.r2_keys ??= []).push(entry);
|
|
100
|
+
let input;
|
|
101
|
+
if (stored.bytes.byteLength <= IMAGE_INLINE_MAX_BYTES) {
|
|
102
|
+
input = { base64: bytesToBase64(stored.bytes), mimeType: stored.mimeType };
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
const displayName = key.trim().split('/').pop() || 'image';
|
|
106
|
+
const uploaded = await client.uploadBytes(stored.bytes, stored.mimeType, displayName);
|
|
107
|
+
entry.file_uri = uploaded.name;
|
|
108
|
+
input = { uri: uploaded.uri, mimeType: uploaded.mimeType };
|
|
109
|
+
}
|
|
110
|
+
seenKeys.set(key, input);
|
|
111
|
+
inputs.push(input);
|
|
112
|
+
}
|
|
78
113
|
return { inputs, report: Object.keys(report).length > 0 ? report : undefined };
|
|
79
114
|
}
|
|
80
115
|
/**
|
|
@@ -161,5 +196,6 @@ export function requireImageInput(args) {
|
|
|
161
196
|
if (hasImageInput(args))
|
|
162
197
|
return;
|
|
163
198
|
throw new McpToolError('Provide at least one input image via `images_url` (an https URL the server fetches), `images_file_uris` ' +
|
|
164
|
-
'(from gemini_upload_file), `
|
|
199
|
+
'(from gemini_upload_file), `images_r2_keys` (a hosted-connector upload or earlier generation), ' +
|
|
200
|
+
'`images` (a local path), `images_base64`, or `from_clipboard`.');
|
|
165
201
|
}
|
package/dist/jobs.js
CHANGED
|
@@ -86,6 +86,25 @@ async function refreshMedia(result, sink) {
|
|
|
86
86
|
body.media = entries;
|
|
87
87
|
if (mediaKeyName)
|
|
88
88
|
body[mediaKeyName] = refs;
|
|
89
|
+
// A multi-image set also carries a bundle zip; its link expires like any
|
|
90
|
+
// other and re-signs from its own r2_key. Best-effort, like the rest.
|
|
91
|
+
const bundle = body.bundle;
|
|
92
|
+
if (bundle && typeof bundle.r2_key === 'string') {
|
|
93
|
+
try {
|
|
94
|
+
const fresh = await sink.resign(bundle.r2_key);
|
|
95
|
+
if (fresh) {
|
|
96
|
+
const { expires_at: _staleBundle, ...rest } = bundle;
|
|
97
|
+
body.bundle = {
|
|
98
|
+
...rest,
|
|
99
|
+
url: fresh.ref,
|
|
100
|
+
...(fresh.expiresAt ? { expires_at: fresh.expiresAt } : {}),
|
|
101
|
+
curl_hint: `curl -sS -o ${downloadFilename(fresh.key ?? 'media')} "${fresh.ref}"`,
|
|
102
|
+
};
|
|
103
|
+
body.bundle_url = fresh.ref;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch { /* an un-refreshed bundle link is still a valid replay */ }
|
|
107
|
+
}
|
|
89
108
|
body.media_urls_refreshed = true;
|
|
90
109
|
return { ...result, content: [{ ...first, text: JSON.stringify(body, null, 2) }, ...(result.content ?? []).slice(1)] };
|
|
91
110
|
}
|
|
@@ -119,6 +138,19 @@ function jobHandle(jobId, status) {
|
|
|
119
138
|
hint: `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done" (results are per-process and expire ~10 min after completion).`,
|
|
120
139
|
});
|
|
121
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Await `promise` for at most `waitMs`; `undefined` means the budget expired
|
|
143
|
+
* with the work still pending (rejections propagate as usual). The timer never
|
|
144
|
+
* holds the process open, and a late rejection after the budget expired is
|
|
145
|
+
* already handled by the registry's own settle handlers.
|
|
146
|
+
*/
|
|
147
|
+
function awaitWithBudget(promise, waitMs) {
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
const timer = setTimeout(() => resolve(undefined), waitMs);
|
|
150
|
+
timer.unref?.();
|
|
151
|
+
promise.then((result) => { clearTimeout(timer); resolve(result); }, (err) => { clearTimeout(timer); reject(err); });
|
|
152
|
+
});
|
|
153
|
+
}
|
|
122
154
|
/**
|
|
123
155
|
* One session's jobs. Construct one per session and never share it — see the
|
|
124
156
|
* cross-tenant note at the top of this file. Reachable as
|
|
@@ -181,7 +213,7 @@ export class JobRegistry {
|
|
|
181
213
|
* different registry and therefore a genuinely separate generation.
|
|
182
214
|
*/
|
|
183
215
|
async dispatch(opts, work) {
|
|
184
|
-
const { toolName, fingerprint, idempotencyKey, async } = opts;
|
|
216
|
+
const { toolName, fingerprint, idempotencyKey, async, waitMs } = opts;
|
|
185
217
|
const now = Date.now();
|
|
186
218
|
let hit;
|
|
187
219
|
if (idempotencyKey !== undefined) {
|
|
@@ -199,10 +231,15 @@ export class JobRegistry {
|
|
|
199
231
|
if (hit) {
|
|
200
232
|
if (async)
|
|
201
233
|
return jobHandle(hit.jobId, hit.status);
|
|
234
|
+
const settled = waitMs !== undefined && hit.status === 'running'
|
|
235
|
+
? await awaitWithBudget(hit.promise, waitMs)
|
|
236
|
+
: await hit.promise;
|
|
237
|
+
if (settled === undefined)
|
|
238
|
+
return jobHandle(hit.jobId, 'running');
|
|
202
239
|
// Refresh before annotating: a recorded result can outlive the signed
|
|
203
240
|
// URLs inside it, and replaying an expired link is its own kind of wrong
|
|
204
241
|
// answer — it looks like success.
|
|
205
|
-
return annotateReused(await refreshMedia(
|
|
242
|
+
return annotateReused(await refreshMedia(settled, this.resigner), hit.jobId);
|
|
206
243
|
}
|
|
207
244
|
const jobId = randomUUID();
|
|
208
245
|
this.evict(now);
|
|
@@ -222,6 +259,13 @@ export class JobRegistry {
|
|
|
222
259
|
});
|
|
223
260
|
if (async)
|
|
224
261
|
return jobHandle(jobId, 'running');
|
|
262
|
+
if (waitMs !== undefined) {
|
|
263
|
+
const settled = await awaitWithBudget(promise, waitMs);
|
|
264
|
+
// Budget expired with the work still running: hand back the job id — the
|
|
265
|
+
// work continues and the caller polls, exactly as with `async: true`,
|
|
266
|
+
// except a fast result would have been returned in-band.
|
|
267
|
+
return settled ?? jobHandle(jobId, 'running');
|
|
268
|
+
}
|
|
225
269
|
return promise;
|
|
226
270
|
}
|
|
227
271
|
/**
|
package/dist/library.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-account reference library: named characters (a reference image + a
|
|
3
|
+
* description) and named styles (a reusable prompt fragment, optionally with a
|
|
4
|
+
* reference image of its own).
|
|
5
|
+
*
|
|
6
|
+
* Why it exists: keeping a recurring subject consistent across generations
|
|
7
|
+
* means re-supplying the same reference image and the same description every
|
|
8
|
+
* session — re-uploading the photo, re-typing the description, re-explaining
|
|
9
|
+
* the style. The library makes each of those a NAME: save once, then pass
|
|
10
|
+
* `characters: ["finn"]` / `style: "bold-cartoon-sports"` on any generation.
|
|
11
|
+
*
|
|
12
|
+
* Storage is the connector's R2 bucket under `lib/<tenant>/…`:
|
|
13
|
+
*
|
|
14
|
+
* - `lib/<tenant>/characters/<name>.json` — the record
|
|
15
|
+
* - `lib/<tenant>/characters/<name>.img` — the reference image bytes
|
|
16
|
+
* - `lib/<tenant>/styles/<name>.json` (+ `.img` when a reference was given)
|
|
17
|
+
*
|
|
18
|
+
* Two properties are load-bearing:
|
|
19
|
+
*
|
|
20
|
+
* - **No expiry.** The retention cron sweeps `gen/`, `media/` and `up/` but
|
|
21
|
+
* deliberately never `lib/` (media-cleanup.ts) — a saved character outlives
|
|
22
|
+
* every signed URL and every upload. That is why saving COPIES the image
|
|
23
|
+
* bytes into `lib/` rather than storing an `r2_key` pointer: a pointer into
|
|
24
|
+
* `gen/` or `up/` would dangle after the next sweep.
|
|
25
|
+
* - **Tenant scoping by construction.** Every key this module touches is
|
|
26
|
+
* derived from the session's own tenant id; caller-supplied names are
|
|
27
|
+
* validated against a strict slug charset, so no name can address another
|
|
28
|
+
* account's namespace (or escape `lib/` at all).
|
|
29
|
+
*
|
|
30
|
+
* Worker-safe: no node imports, no module-scope state, structural bucket type.
|
|
31
|
+
*/
|
|
32
|
+
/** Key prefix for the library — the one prefix the retention sweep must skip. */
|
|
33
|
+
export const LIBRARY_KEY_PREFIX = 'lib';
|
|
34
|
+
/**
|
|
35
|
+
* Library names are slugs: 1–40 chars, lowercase alphanumerics plus `-`/`_`,
|
|
36
|
+
* starting alphanumeric. Doubles as key-safety (no `/`, `.`, whitespace).
|
|
37
|
+
*/
|
|
38
|
+
export const LIBRARY_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/;
|
|
39
|
+
export function isValidLibraryName(name) {
|
|
40
|
+
return LIBRARY_NAME_PATTERN.test(name);
|
|
41
|
+
}
|
|
42
|
+
/** Build the R2-backed library for one session. */
|
|
43
|
+
export function createR2Library(bucket, opts) {
|
|
44
|
+
const now = opts.now ?? (() => new Date());
|
|
45
|
+
let tenantCache;
|
|
46
|
+
const tenantId = () => (tenantCache ??= Promise.resolve(typeof opts.tenant === 'function' ? opts.tenant() : opts.tenant));
|
|
47
|
+
const assertName = (name) => {
|
|
48
|
+
if (!isValidLibraryName(name)) {
|
|
49
|
+
throw new Error(`Invalid library name "${name}" — use 1-40 lowercase letters, digits, "-" or "_" (e.g. "finn", "bold-cartoon-sports").`);
|
|
50
|
+
}
|
|
51
|
+
return name;
|
|
52
|
+
};
|
|
53
|
+
const keyFor = async (kind, name, ext) => `${LIBRARY_KEY_PREFIX}/${await tenantId()}/${kind}/${assertName(name)}.${ext}`;
|
|
54
|
+
async function readJson(key) {
|
|
55
|
+
const object = await bucket.get(key);
|
|
56
|
+
if (!object)
|
|
57
|
+
return undefined;
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(new TextDecoder().decode(await object.arrayBuffer()));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// A malformed record is unreadable, not fatal — callers treat it as absent.
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function readImage(kind, name) {
|
|
67
|
+
const object = await bucket.get(await keyFor(kind, name, 'img'));
|
|
68
|
+
if (!object)
|
|
69
|
+
return undefined;
|
|
70
|
+
return {
|
|
71
|
+
bytes: new Uint8Array(await object.arrayBuffer()),
|
|
72
|
+
mimeType: object.httpMetadata?.contentType ?? 'application/octet-stream',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
async function listRecords(kind) {
|
|
76
|
+
const prefix = `${LIBRARY_KEY_PREFIX}/${await tenantId()}/${kind}/`;
|
|
77
|
+
const keys = [];
|
|
78
|
+
let cursor;
|
|
79
|
+
for (;;) {
|
|
80
|
+
const page = await bucket.list({ prefix, cursor, limit: 1000 });
|
|
81
|
+
keys.push(...page.objects.map((o) => o.key).filter((k) => k.endsWith('.json')));
|
|
82
|
+
if (!page.truncated)
|
|
83
|
+
break;
|
|
84
|
+
cursor = page.cursor;
|
|
85
|
+
}
|
|
86
|
+
const records = await Promise.all(keys.map((k) => readJson(k)));
|
|
87
|
+
return records
|
|
88
|
+
.filter((r) => r !== undefined)
|
|
89
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
90
|
+
}
|
|
91
|
+
async function deleteEntry(kind, name) {
|
|
92
|
+
const jsonKey = await keyFor(kind, name, 'json');
|
|
93
|
+
const existed = (await bucket.get(jsonKey)) !== null;
|
|
94
|
+
await bucket.delete([jsonKey, await keyFor(kind, name, 'img')]);
|
|
95
|
+
return existed;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
async saveCharacter({ name, description, image }) {
|
|
99
|
+
const jsonKey = await keyFor('characters', name, 'json');
|
|
100
|
+
const imageKey = await keyFor('characters', name, 'img');
|
|
101
|
+
const at = now().toISOString();
|
|
102
|
+
const existing = await readJson(jsonKey);
|
|
103
|
+
const record = {
|
|
104
|
+
name,
|
|
105
|
+
description,
|
|
106
|
+
image_key: imageKey,
|
|
107
|
+
mime_type: image.mimeType,
|
|
108
|
+
created_at: existing?.created_at ?? at,
|
|
109
|
+
updated_at: at,
|
|
110
|
+
};
|
|
111
|
+
await bucket.put(imageKey, image.bytes, { httpMetadata: { contentType: image.mimeType } });
|
|
112
|
+
await bucket.put(jsonKey, JSON.stringify(record, null, 2), { httpMetadata: { contentType: 'application/json' } });
|
|
113
|
+
return record;
|
|
114
|
+
},
|
|
115
|
+
async getCharacter(name) {
|
|
116
|
+
return readJson(await keyFor('characters', name, 'json'));
|
|
117
|
+
},
|
|
118
|
+
readCharacterImage: (name) => readImage('characters', name),
|
|
119
|
+
listCharacters: () => listRecords('characters'),
|
|
120
|
+
deleteCharacter: (name) => deleteEntry('characters', name),
|
|
121
|
+
async saveStyle({ name, promptFragment, image }) {
|
|
122
|
+
const jsonKey = await keyFor('styles', name, 'json');
|
|
123
|
+
const imageKey = await keyFor('styles', name, 'img');
|
|
124
|
+
const at = now().toISOString();
|
|
125
|
+
const existing = await readJson(jsonKey);
|
|
126
|
+
const record = {
|
|
127
|
+
name,
|
|
128
|
+
prompt_fragment: promptFragment,
|
|
129
|
+
...(image ? { image_key: imageKey, mime_type: image.mimeType } : {}),
|
|
130
|
+
created_at: existing?.created_at ?? at,
|
|
131
|
+
updated_at: at,
|
|
132
|
+
};
|
|
133
|
+
if (image) {
|
|
134
|
+
await bucket.put(imageKey, image.bytes, { httpMetadata: { contentType: image.mimeType } });
|
|
135
|
+
}
|
|
136
|
+
else if (existing?.image_key) {
|
|
137
|
+
// Re-saving without an image REPLACES the style — a stale reference
|
|
138
|
+
// image left behind would silently keep steering generations.
|
|
139
|
+
await bucket.delete(imageKey);
|
|
140
|
+
}
|
|
141
|
+
await bucket.put(jsonKey, JSON.stringify(record, null, 2), { httpMetadata: { contentType: 'application/json' } });
|
|
142
|
+
return record;
|
|
143
|
+
},
|
|
144
|
+
async getStyle(name) {
|
|
145
|
+
return readJson(await keyFor('styles', name, 'json'));
|
|
146
|
+
},
|
|
147
|
+
readStyleImage: (name) => readImage('styles', name),
|
|
148
|
+
listStyles: () => listRecords('styles'),
|
|
149
|
+
deleteStyle: (name) => deleteEntry('styles', name),
|
|
150
|
+
};
|
|
151
|
+
}
|
package/dist/media-cleanup.js
CHANGED
|
@@ -17,8 +17,11 @@ const DEFAULT_MAX_OBJECTS = 5_000;
|
|
|
17
17
|
* `gen/` is where media is written now; `media/` is where it was written before
|
|
18
18
|
* the rename that made URLs read `/media/gen/…` instead of `/media/media/…`.
|
|
19
19
|
* Both are swept — retention that ignores the existing backlog is not retention.
|
|
20
|
+
* `up/` (signed uploads) is swept on the same schedule: a reference photo worth
|
|
21
|
+
* keeping gets copied into the character library (`lib/`), which is the ONE
|
|
22
|
+
* prefix deliberately absent here — saved characters and styles have no expiry.
|
|
20
23
|
*/
|
|
21
|
-
const LEGACY_AND_CURRENT_PREFIXES = ['gen/', 'media/'];
|
|
24
|
+
const LEGACY_AND_CURRENT_PREFIXES = ['gen/', 'media/', 'up/'];
|
|
22
25
|
/** R2 accepts bulk deletes in batches; 1000 is the documented ceiling. */
|
|
23
26
|
const DELETE_BATCH = 1_000;
|
|
24
27
|
/** Days → ms, defaulting to 7 for anything missing or nonsensical. */
|
package/dist/media-endpoint.js
CHANGED
|
@@ -59,6 +59,12 @@ function baseHeaders(key, object, size) {
|
|
|
59
59
|
// The bytes are model output being handed to a browser — never let a
|
|
60
60
|
// sniffed content type turn an image response into something executable.
|
|
61
61
|
'x-content-type-options': 'nosniff',
|
|
62
|
+
// Defense in depth against stored XSS: this route serves user-influenced
|
|
63
|
+
// bytes from the SAME ORIGIN as the connector's OAuth pages, so if a
|
|
64
|
+
// scriptable document type (SVG, HTML) ever slipped past the raster-only
|
|
65
|
+
// upload gates, this neutralizes it — no script, no fetch, and a unique
|
|
66
|
+
// opaque origin via sandbox. Pixels don't need any of what this disables.
|
|
67
|
+
'content-security-policy': "default-src 'none'; sandbox",
|
|
62
68
|
// Range support is cheap on R2 and is what makes a resumable `curl -C -`
|
|
63
69
|
// work against a large video.
|
|
64
70
|
'accept-ranges': 'bytes',
|
package/dist/media-url.js
CHANGED
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
export const MEDIA_URL_TTL_MS = 48 * 60 * 60 * 1000;
|
|
20
20
|
/** KV key holding the lazily-generated signing secret (zero-config path). */
|
|
21
21
|
export const SIGNING_SECRET_KV_KEY = 'media:url-signing-secret';
|
|
22
|
-
function base64UrlEncode(bytes) {
|
|
22
|
+
export function base64UrlEncode(bytes) {
|
|
23
23
|
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
24
24
|
let binary = '';
|
|
25
25
|
for (let i = 0; i < view.length; i++)
|
|
26
26
|
binary += String.fromCharCode(view[i]);
|
|
27
27
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
28
28
|
}
|
|
29
|
-
function base64UrlDecode(text) {
|
|
29
|
+
export function base64UrlDecode(text) {
|
|
30
30
|
const padded = text.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(text.length / 4) * 4, '=');
|
|
31
31
|
const binary = atob(padded);
|
|
32
32
|
const out = new Uint8Array(binary.length);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { loadSigningKey } from './media-url.js';
|
|
2
|
+
import { verifyUploadSignature, UPLOAD_MAX_BYTES, UPLOAD_KEY_PREFIX, acceptableUploadType } from './upload-url.js';
|
|
3
|
+
import { wholeMb } from './bytes.js';
|
|
4
|
+
function json(body, status) {
|
|
5
|
+
return new Response(JSON.stringify(body, null, 2), {
|
|
6
|
+
status,
|
|
7
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
/** Every error names the fix — the caller here is a shell, not a model. */
|
|
11
|
+
function fail(status, error, hint) {
|
|
12
|
+
return json({ error, hint }, status);
|
|
13
|
+
}
|
|
14
|
+
/** A key this endpoint is willing to even verify a signature for: the upload
|
|
15
|
+
* prefix, safe segment charset, no dot-segments, no empty segments. */
|
|
16
|
+
export function wellFormedUploadKey(key) {
|
|
17
|
+
if (!key.startsWith(`${UPLOAD_KEY_PREFIX}/`))
|
|
18
|
+
return false;
|
|
19
|
+
const segments = key.split('/');
|
|
20
|
+
if (segments.length < 2)
|
|
21
|
+
return false;
|
|
22
|
+
return segments.every((s) => s.length > 0 && s !== '.' && s !== '..' && /^[A-Za-z0-9._-]+$/.test(s));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Build the `/put` handler. `basePath` is stripped from the pathname to get the
|
|
26
|
+
* R2 key, mirroring the `/media` handler so the two never drift.
|
|
27
|
+
*/
|
|
28
|
+
export function createSignedPutHandler(env, basePath = '/put/') {
|
|
29
|
+
const maxBytes = env.maxBytes ?? UPLOAD_MAX_BYTES;
|
|
30
|
+
return async function handleSignedPut(request) {
|
|
31
|
+
if (request.method === 'OPTIONS') {
|
|
32
|
+
return new Response(null, { status: 204, headers: { allow: 'PUT, OPTIONS' } });
|
|
33
|
+
}
|
|
34
|
+
if (request.method !== 'PUT') {
|
|
35
|
+
return fail(405, `Method ${request.method} not allowed; signed upload URLs are PUT-only.`, 'Send the file bytes with: curl -sS -X PUT -H "Content-Type: <the signed type>" --data-binary @photo.jpg "<upload_url>"');
|
|
36
|
+
}
|
|
37
|
+
const url = new URL(request.url);
|
|
38
|
+
if (!url.pathname.startsWith(basePath))
|
|
39
|
+
return fail(404, 'Not found.', 'Use the exact upload_url returned by gemini_get_upload_url.');
|
|
40
|
+
const key = url.pathname
|
|
41
|
+
.slice(basePath.length)
|
|
42
|
+
.split('/')
|
|
43
|
+
.map((segment) => {
|
|
44
|
+
try {
|
|
45
|
+
return decodeURIComponent(segment);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return segment;
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
.join('/');
|
|
52
|
+
// Charset-checked BEFORE signature verification: the cross-protocol safety
|
|
53
|
+
// argument in upload-url.ts rests on keys never containing control chars.
|
|
54
|
+
if (!wellFormedUploadKey(key)) {
|
|
55
|
+
return fail(404, 'Not a valid upload key.', 'Use the exact upload_url returned by gemini_get_upload_url — the key path cannot be altered.');
|
|
56
|
+
}
|
|
57
|
+
const ct = url.searchParams.get('ct');
|
|
58
|
+
if (ct && !acceptableUploadType(ct)) {
|
|
59
|
+
return fail(415, `Unsupported content type "${ct}" — signed uploads accept raster image types only ` +
|
|
60
|
+
'(jpeg/png/webp/gif/avif/heic/heif/bmp/tiff; not SVG, which can carry scripts).', 'Mint the URL with a raster image content_type (e.g. image/jpeg).');
|
|
61
|
+
}
|
|
62
|
+
const now = env.now?.() ?? Date.now();
|
|
63
|
+
// Read-only key load: verification must never CREATE a signing secret (see
|
|
64
|
+
// loadSigningKey — a stale KV read would clobber the secret every
|
|
65
|
+
// outstanding media link was signed with).
|
|
66
|
+
const signingKey = await loadSigningKey(env.secret, env.store);
|
|
67
|
+
const check = signingKey
|
|
68
|
+
? await verifyUploadSignature(signingKey, key, ct, url.searchParams.get('exp'), url.searchParams.get('sig'), now)
|
|
69
|
+
: { ok: false, reason: 'invalid' };
|
|
70
|
+
if (!check.ok) {
|
|
71
|
+
if (check.reason === 'expired') {
|
|
72
|
+
return fail(410, 'This upload URL has expired (they are valid for ~10 minutes).', 'Mint a fresh one with gemini_get_upload_url and retry.');
|
|
73
|
+
}
|
|
74
|
+
return fail(403, 'Invalid or missing upload signature.', 'Use the upload_url exactly as returned by gemini_get_upload_url, including the ct, exp and sig query parameters.');
|
|
75
|
+
}
|
|
76
|
+
const sentType = (request.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
|
|
77
|
+
if (sentType !== ct) {
|
|
78
|
+
return fail(415, `Content-Type "${sentType || '(none)'}" does not match the type this URL was minted for ("${ct}").`, `Send -H "Content-Type: ${ct}", or mint a new URL with the right content_type.`);
|
|
79
|
+
}
|
|
80
|
+
const declared = Number(request.headers.get('content-length'));
|
|
81
|
+
if (!request.headers.get('content-length') || !Number.isFinite(declared) || declared <= 0) {
|
|
82
|
+
return fail(411, 'Content-Length is required (and must be > 0).', 'curl sets it automatically with --data-binary @file.');
|
|
83
|
+
}
|
|
84
|
+
if (declared > maxBytes) {
|
|
85
|
+
return fail(413, `Upload is ${wholeMb(declared)}MB; the cap is ${wholeMb(maxBytes)}MB.`, 'Downscale the image before uploading — a reference photo does not need to be full-resolution.');
|
|
86
|
+
}
|
|
87
|
+
if (!request.body) {
|
|
88
|
+
return fail(400, 'Request had no body.', 'Send the file bytes as the request body, e.g. --data-binary @photo.jpg.');
|
|
89
|
+
}
|
|
90
|
+
// Enforce the cap on bytes actually READ, not the declared length — a
|
|
91
|
+
// hostile client can under-declare and stream more.
|
|
92
|
+
const chunks = [];
|
|
93
|
+
let total = 0;
|
|
94
|
+
const reader = request.body.getReader();
|
|
95
|
+
for (;;) {
|
|
96
|
+
const { done, value } = await reader.read();
|
|
97
|
+
if (done)
|
|
98
|
+
break;
|
|
99
|
+
total += value.byteLength;
|
|
100
|
+
if (total > maxBytes) {
|
|
101
|
+
await reader.cancel().catch(() => { });
|
|
102
|
+
return fail(413, `Upload exceeded the ${wholeMb(maxBytes)}MB cap while being read.`, 'Downscale the image before uploading.');
|
|
103
|
+
}
|
|
104
|
+
chunks.push(value);
|
|
105
|
+
}
|
|
106
|
+
const body = new Uint8Array(total);
|
|
107
|
+
let offset = 0;
|
|
108
|
+
for (const chunk of chunks) {
|
|
109
|
+
body.set(chunk, offset);
|
|
110
|
+
offset += chunk.byteLength;
|
|
111
|
+
}
|
|
112
|
+
await env.bucket.put(key, body, { httpMetadata: { contentType: ct ?? 'application/octet-stream' } });
|
|
113
|
+
return json({
|
|
114
|
+
r2_key: key,
|
|
115
|
+
size_bytes: total,
|
|
116
|
+
content_type: ct,
|
|
117
|
+
hint: `Uploaded. Reference it by r2_key: pass it in images_r2_keys on a generation tool (master_images_r2_keys on gemini_image_set), ` +
|
|
118
|
+
`save it permanently with gemini_save_character, or convert it to a ~48h Gemini Files API reference with gemini_upload_file({ r2_key }). ` +
|
|
119
|
+
`Uploads follow the connector's media retention schedule (MEDIA_TTL_DAYS, default 7 days); saved characters are kept indefinitely.`,
|
|
120
|
+
}, 201);
|
|
121
|
+
};
|
|
122
|
+
}
|
package/dist/storage/media.js
CHANGED
|
@@ -45,7 +45,7 @@ export async function tenantIdFor(secret) {
|
|
|
45
45
|
// prefix would make every URL read `/media/media/…`.
|
|
46
46
|
const KEY_PREFIX_DEFAULT = 'gen';
|
|
47
47
|
/** Object-key-safe name: no slashes, no dot-segments, never empty. */
|
|
48
|
-
function safeKeySegment(base) {
|
|
48
|
+
export function safeKeySegment(base) {
|
|
49
49
|
const cleaned = base
|
|
50
50
|
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
51
51
|
.replace(/\.{2,}/g, '-')
|
|
@@ -67,18 +67,27 @@ export function createR2Sink(bucket, opts) {
|
|
|
67
67
|
const publicBase = opts.publicBaseUrl?.replace(/\/+$/, '');
|
|
68
68
|
const signedBase = opts.signedBaseUrl?.replace(/\/+$/, '');
|
|
69
69
|
const ttlMs = opts.urlTtlMs ?? 0;
|
|
70
|
-
|
|
71
|
-
let
|
|
72
|
-
const
|
|
73
|
-
|
|
70
|
+
const readPrefixes = [prefix, ...(opts.readPrefixes ?? [])];
|
|
71
|
+
let tenantCache;
|
|
72
|
+
const tenantId = () => (tenantCache ??= Promise.resolve(typeof opts.tenant === 'function' ? opts.tenant() : opts.tenant));
|
|
73
|
+
/** `<prefix>/` or `<prefix>/<tenant>/` — where every WRITE goes. */
|
|
74
|
+
const ownPrefix = async () => {
|
|
75
|
+
const tenant = await tenantId();
|
|
74
76
|
return tenant ? `${prefix}/${tenant}/` : `${prefix}/`;
|
|
75
|
-
}
|
|
77
|
+
};
|
|
76
78
|
/**
|
|
77
|
-
* Ownership gate for caller-supplied keys
|
|
78
|
-
*
|
|
79
|
-
*
|
|
79
|
+
* Ownership gate for caller-supplied keys: this session's own namespace
|
|
80
|
+
* under any readable prefix (`gen/<tenant>/…`, plus e.g. `up/`/`lib/` on the
|
|
81
|
+
* Worker). A foreign key is refused without touching storage, so the refusal
|
|
82
|
+
* is indistinguishable from a swept object. With no tenant configured there
|
|
83
|
+
* is nothing to enforce (single-user shapes).
|
|
80
84
|
*/
|
|
81
|
-
const owned = async (key) =>
|
|
85
|
+
const owned = async (key) => {
|
|
86
|
+
if (!opts.tenant)
|
|
87
|
+
return true;
|
|
88
|
+
const tenant = await tenantId();
|
|
89
|
+
return readPrefixes.some((p) => key.startsWith(`${p}/${tenant}/`));
|
|
90
|
+
};
|
|
82
91
|
return {
|
|
83
92
|
kind: 'r2',
|
|
84
93
|
persistsFiles: false,
|
|
@@ -102,14 +111,16 @@ export function createR2Sink(bucket, opts) {
|
|
|
102
111
|
return undefined;
|
|
103
112
|
return describe(key, now().getTime() + ttlMs);
|
|
104
113
|
},
|
|
105
|
-
async persist(items,
|
|
114
|
+
async persist(items, persistOpts) {
|
|
106
115
|
// `mediaExt` is pure string work but lives in images.ts next to node:fs
|
|
107
116
|
// imports; the dynamic import keeps that module off a Worker's eager
|
|
108
117
|
// module graph.
|
|
109
118
|
const { mediaExt } = await import('../images.js');
|
|
110
119
|
const keyBase = await ownPrefix();
|
|
111
120
|
const day = now().toISOString().slice(0, 10);
|
|
112
|
-
|
|
121
|
+
// Per-persist TTL override, clamped so a link never outlives its object.
|
|
122
|
+
const effectiveTtlMs = Math.min(persistOpts.urlTtlMs ?? ttlMs, opts.maxUrlTtlMs ?? Infinity);
|
|
123
|
+
const expiresAtMs = now().getTime() + effectiveTtlMs;
|
|
113
124
|
const refs = [];
|
|
114
125
|
for (const it of items) {
|
|
115
126
|
const key = `${keyBase}${day}/${randomId()}-${safeKeySegment(it.base)}.${mediaExt(it.mimeType)}`;
|
package/dist/tools/generate.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readEnvVar } from '@chrischall/mcp-utils';
|
|
|
3
3
|
import { resolveModel } from '../models.js';
|
|
4
4
|
import { slugify, baseName } from '../images.js';
|
|
5
5
|
import { resolveImageInputs, requireImageInput } from '../inputs.js';
|
|
6
|
-
import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
|
|
6
|
+
import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesR2KeysSchema, charactersSchema, styleSchema, resolveCharacterRefs, composePrompt } from './shared.js';
|
|
7
7
|
import { fingerprintRequest } from '../jobs.js';
|
|
8
8
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
9
9
|
const GENERATE_ENDPOINT = '/v1beta/models/{model}:generateContent';
|
|
@@ -24,7 +24,10 @@ export function registerGenerateTools(server, client) {
|
|
|
24
24
|
images: z.array(z.string().min(1)).optional().describe('Paths to reference input images (image-conditioned generation)'),
|
|
25
25
|
images_url: imagesUrlSchema(),
|
|
26
26
|
images_file_uris: imagesFileUrisSchema(),
|
|
27
|
+
images_r2_keys: imagesR2KeysSchema(),
|
|
27
28
|
images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation'),
|
|
29
|
+
characters: charactersSchema,
|
|
30
|
+
style: styleSchema,
|
|
28
31
|
video_url: z.string().url().optional().describe('Public YouTube URL (or a previously uploaded Files API uri) as a video reference (video→image; use a Flash model e.g. gemini-3.1-flash-image)'),
|
|
29
32
|
video_path: videoPathSchema,
|
|
30
33
|
confirm: schemaConfirm,
|
|
@@ -49,15 +52,22 @@ export function registerGenerateTools(server, client) {
|
|
|
49
52
|
model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
|
|
50
53
|
thinking_level: args.thinking_level, google_search: args.google_search, count,
|
|
51
54
|
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
52
|
-
images_url: args.images_url, images_file_uris: args.images_file_uris,
|
|
55
|
+
images_url: args.images_url, images_file_uris: args.images_file_uris, images_r2_keys: args.images_r2_keys,
|
|
56
|
+
characters: args.characters, style: args.style,
|
|
53
57
|
video_url: args.video_url, video_path: args.video_path,
|
|
54
58
|
});
|
|
55
|
-
return client.session.jobs.dispatch({ toolName: 'gemini_image_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
59
|
+
return client.session.jobs.dispatch({ toolName: 'gemini_image_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
|
|
56
60
|
const seed = pickSeed(args.seed);
|
|
57
61
|
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
62
|
+
// Saved characters/style resolve first (and fail fast on an unknown
|
|
63
|
+
// name — before anything billable), so their reference images lead the
|
|
64
|
+
// input order the prompt preamble describes.
|
|
65
|
+
const charRefs = await resolveCharacterRefs(client, args.characters, args.style);
|
|
66
|
+
const prompt = composePrompt(args.prompt, charRefs);
|
|
58
67
|
// Resolved ONCE, before the count loop: a URL is fetched (and a large
|
|
59
68
|
// one uploaded) a single time no matter how many images are generated.
|
|
60
|
-
const { inputs:
|
|
69
|
+
const { inputs: directInputs, report } = await withProgressHeartbeat(extra, 'Resolving reference images', () => resolveImageInputs(args, client));
|
|
70
|
+
const refInputs = [...charRefs.inputs, ...directInputs];
|
|
61
71
|
// Upload ONCE (before the count loop) — every generate call references
|
|
62
72
|
// the same files/… uri.
|
|
63
73
|
const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args, client));
|
|
@@ -79,7 +89,7 @@ export function registerGenerateTools(server, client) {
|
|
|
79
89
|
// Distinct seed per image (seed+0 for the single-image case == echoed seed)
|
|
80
90
|
// so count>1 yields N *different* images, not N duplicates.
|
|
81
91
|
const result = await client.generate({
|
|
82
|
-
prompt
|
|
92
|
+
prompt,
|
|
83
93
|
images: refInputs.length > 0 ? refInputs : undefined,
|
|
84
94
|
model: args.model,
|
|
85
95
|
aspectRatio: args.aspect_ratio,
|
|
@@ -113,6 +123,8 @@ export function registerGenerateTools(server, client) {
|
|
|
113
123
|
meta.grounding = capturedGrounding;
|
|
114
124
|
if (report)
|
|
115
125
|
meta.image_inputs = report;
|
|
126
|
+
if (charRefs.meta)
|
|
127
|
+
Object.assign(meta, charRefs.meta);
|
|
116
128
|
if (failed.length) {
|
|
117
129
|
meta.failed_images = failed;
|
|
118
130
|
meta.partial =
|
|
@@ -139,14 +151,20 @@ export function registerGenerateTools(server, client) {
|
|
|
139
151
|
images: z.array(z.string().min(1)).optional().describe('Paths to input image file(s) (1 = edit, 2+ = compose)'),
|
|
140
152
|
images_url: imagesUrlSchema('Input images'),
|
|
141
153
|
images_file_uris: imagesFileUrisSchema('Input images'),
|
|
154
|
+
images_r2_keys: imagesR2KeysSchema('Input images'),
|
|
142
155
|
images_base64: z.array(z.string().min(1)).optional().describe('Input images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation'),
|
|
156
|
+
characters: charactersSchema,
|
|
157
|
+
style: styleSchema,
|
|
143
158
|
filename: z.string().optional().describe('Base filename for the output image (extension stripped; default: slugified prompt)'),
|
|
144
159
|
confirm: schemaConfirm,
|
|
145
160
|
...sharedImageSchema,
|
|
146
161
|
},
|
|
147
162
|
}, async (args, extra) => {
|
|
148
163
|
assertLocalInputsAvailable(client.mediaSink, args);
|
|
149
|
-
|
|
164
|
+
// Saved characters count as input images for the required-input check —
|
|
165
|
+
// the library attaches their references inside the handler.
|
|
166
|
+
if (!args.characters?.length)
|
|
167
|
+
requireImageInput(args);
|
|
150
168
|
// Confirm-gate local file inputs (see gemini_image_generate). base64 /
|
|
151
169
|
// clipboard inputs are not local-path reads and pass through ungated.
|
|
152
170
|
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
|
|
@@ -157,14 +175,18 @@ export function registerGenerateTools(server, client) {
|
|
|
157
175
|
model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
|
|
158
176
|
thinking_level: args.thinking_level, google_search: args.google_search,
|
|
159
177
|
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
160
|
-
images_url: args.images_url, images_file_uris: args.images_file_uris,
|
|
178
|
+
images_url: args.images_url, images_file_uris: args.images_file_uris, images_r2_keys: args.images_r2_keys,
|
|
179
|
+
characters: args.characters, style: args.style,
|
|
161
180
|
});
|
|
162
|
-
return client.session.jobs.dispatch({ toolName: 'gemini_image_edit', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
181
|
+
return client.session.jobs.dispatch({ toolName: 'gemini_image_edit', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
|
|
163
182
|
const seed = pickSeed(args.seed);
|
|
164
183
|
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
165
|
-
const
|
|
184
|
+
const charRefs = await resolveCharacterRefs(client, args.characters, args.style);
|
|
185
|
+
const prompt = composePrompt(args.prompt, charRefs);
|
|
186
|
+
const { inputs: directInputs, report } = await withProgressHeartbeat(extra, 'Resolving input images', () => resolveImageInputs(args, client));
|
|
187
|
+
const inputs = [...charRefs.inputs, ...directInputs];
|
|
166
188
|
const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client.generate({
|
|
167
|
-
prompt
|
|
189
|
+
prompt,
|
|
168
190
|
images: inputs,
|
|
169
191
|
model: args.model,
|
|
170
192
|
aspectRatio: args.aspect_ratio,
|
|
@@ -182,6 +204,8 @@ export function registerGenerateTools(server, client) {
|
|
|
182
204
|
meta.grounding = result.grounding;
|
|
183
205
|
if (report)
|
|
184
206
|
meta.image_inputs = report;
|
|
207
|
+
if (charRefs.meta)
|
|
208
|
+
Object.assign(meta, charRefs.meta);
|
|
185
209
|
meta.hint = REFINE_HINT;
|
|
186
210
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: client.mediaSink?.persistsFiles });
|
|
187
211
|
if (risk)
|