@chrischall/gemini-mcp 1.1.0 → 1.3.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 +63 -2
- package/dist/bundle.js +143 -125
- package/dist/bytes.js +46 -0
- package/dist/connector-origin.js +34 -0
- package/dist/fetch-image.js +24 -2
- package/dist/images.js +0 -19
- package/dist/inputs.js +6 -27
- package/dist/media-cleanup.js +75 -0
- package/dist/media-endpoint.js +72 -0
- package/dist/media-url.js +135 -0
- package/dist/storage/media.js +34 -23
- package/dist/tools/files.js +15 -18
- package/dist/tools/interact.js +6 -4
- package/dist/tools/music.js +10 -4
- package/dist/tools/shared.js +28 -3
- package/dist/tools/video.js +10 -4
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/gemini-mcp/SKILL.md +5 -2
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tell an MCP session which hostname it was reached on.
|
|
3
|
+
*
|
|
4
|
+
* This is the single load-bearing link in the zero-config media-URL path: the
|
|
5
|
+
* origin captured here becomes `https://<host>/media/…` for every generated
|
|
6
|
+
* image. If it goes missing, generations silently stop coming back with
|
|
7
|
+
* openable links — the exact failure the media work exists to end — so it lives
|
|
8
|
+
* in its own module, free of any Cloudflare import, purely so it can be tested.
|
|
9
|
+
* (`src/worker.ts` pulls in `agents` and cannot load under Node at all.)
|
|
10
|
+
*
|
|
11
|
+
* Deriving the origin per request rather than configuring it is what makes a
|
|
12
|
+
* fork, a `*.workers.dev` preview and the custom domain all mint correct links
|
|
13
|
+
* with nothing to set. It rides on `ctx.props` — the object the OAuth provider
|
|
14
|
+
* has already populated with the decrypted grant — rather than a module-level
|
|
15
|
+
* variable, because one isolate serves many authenticated sessions (session.ts).
|
|
16
|
+
*/
|
|
17
|
+
/** Property name the session reads back off its props. */
|
|
18
|
+
export const CONNECTOR_ORIGIN_PROP = 'connectorOrigin';
|
|
19
|
+
/**
|
|
20
|
+
* Wrap a handler so `ctx.props.connectorOrigin` is set before it runs.
|
|
21
|
+
*
|
|
22
|
+
* Existing props are preserved — they carry the user's API key, and dropping
|
|
23
|
+
* them would break authentication rather than merely lose a URL.
|
|
24
|
+
*/
|
|
25
|
+
export function withConnectorOrigin(inner) {
|
|
26
|
+
return {
|
|
27
|
+
fetch(request, env, ctx) {
|
|
28
|
+
const carrier = (ctx ?? {});
|
|
29
|
+
const existing = (typeof carrier.props === 'object' && carrier.props !== null ? carrier.props : {});
|
|
30
|
+
carrier.props = { ...existing, [CONNECTOR_ORIGIN_PROP]: new URL(request.url).origin };
|
|
31
|
+
return inner.fetch(request, env, ctx);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
package/dist/fetch-image.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { McpToolError } from '@chrischall/mcp-utils';
|
|
2
|
+
import { formatMb } from './bytes.js';
|
|
2
3
|
/**
|
|
3
4
|
* Server-side fetching of `images_url` references.
|
|
4
5
|
*
|
|
@@ -207,6 +208,28 @@ function assertPublicHttpsUrl(raw, requested) {
|
|
|
207
208
|
}
|
|
208
209
|
return url;
|
|
209
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* Last path segment of a URL, for the Files API display name. Cosmetic — the
|
|
213
|
+
* API keys on the resource name it returns, not on this — so an unparseable
|
|
214
|
+
* URL degrades to `fallback` rather than failing the upload.
|
|
215
|
+
*
|
|
216
|
+
* `fallback` is REQUIRED, deliberately. This function was created by merging
|
|
217
|
+
* two byte-identical copies that differed only in their default (`'image'` in
|
|
218
|
+
* `inputs.ts`, `'upload'` in `tools/files.ts`), and taking one of those
|
|
219
|
+
* defaults silently changed the other caller's behaviour — `gemini_upload_file`
|
|
220
|
+
* started naming a fallback upload "image" even for an mp4, and disagreed with
|
|
221
|
+
* the other two branches of its own handler. With no default, merging callers
|
|
222
|
+
* that disagree stops being something you can do by accident.
|
|
223
|
+
*/
|
|
224
|
+
export function fileNameFromUrl(url, fallback) {
|
|
225
|
+
try {
|
|
226
|
+
const name = new URL(url).pathname.split('/').filter(Boolean).pop();
|
|
227
|
+
return name ? decodeURIComponent(name).slice(0, 120) : fallback;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return fallback;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
210
233
|
/** "<url>" — or "<url> (redirected from <requested>)" when they differ. */
|
|
211
234
|
function describeUrl(current, requested) {
|
|
212
235
|
return current === requested ? current : `${current} (redirected from ${requested})`;
|
|
@@ -265,8 +288,7 @@ function fetchFailure(err, current, requested, timeoutMs) {
|
|
|
265
288
|
return new McpToolError(`Could not fetch ${describeUrl(current, requested)}: ${err instanceof Error ? err.message : String(err)}`, { hint: 'Check the URL is reachable from the server and serves the image without authentication.' });
|
|
266
289
|
}
|
|
267
290
|
function tooBig(url, requested, size, max, partial = false) {
|
|
268
|
-
|
|
269
|
-
return new McpToolError(`Image at ${describeUrl(url, requested)} is ${partial ? 'over' : ''} ${mb(size)}, above the ${mb(max)} limit for images_url.`, { hint: 'Resize or re-encode the image, or upload it yourself with gemini_upload_file and pass the returned file_uri.' });
|
|
291
|
+
return new McpToolError(`Image at ${describeUrl(url, requested)} is ${partial ? 'over' : ''} ${formatMb(size)}, above the ${formatMb(max)} limit for images_url.`, { hint: 'Resize or re-encode the image, or upload it yourself with gemini_upload_file and pass the returned file_uri.' });
|
|
270
292
|
}
|
|
271
293
|
/**
|
|
272
294
|
* Fetch one image URL server-side, validating protocol, destination, response
|
package/dist/images.js
CHANGED
|
@@ -215,25 +215,6 @@ export function decodeImageInput(input) {
|
|
|
215
215
|
const buf = Buffer.from(trimmed, 'base64');
|
|
216
216
|
return { base64: trimmed, mimeType: sniffMimeBytes(buf) };
|
|
217
217
|
}
|
|
218
|
-
/** Load images from file paths (via readImageAsInline) and raw base64 strings (via decodeImageInput), paths first. */
|
|
219
|
-
export async function loadImageInputs(paths = [], base64s = []) {
|
|
220
|
-
const fromPaths = await Promise.all(paths.map((p) => readImageAsInline(p)));
|
|
221
|
-
const fromBase64s = base64s.map((b) => decodeImageInput(b));
|
|
222
|
-
return [...fromPaths, ...fromBase64s];
|
|
223
|
-
}
|
|
224
|
-
/**
|
|
225
|
-
* Aggregate image inputs from all sources: clipboard (macOS only), file paths, and base64 strings.
|
|
226
|
-
* Clipboard image is prepended if `from_clipboard` is true.
|
|
227
|
-
*/
|
|
228
|
-
export async function gatherImageInputs(opts) {
|
|
229
|
-
// Only load the clipboard module when actually requested (keeps the child_process
|
|
230
|
-
// path off the default code path).
|
|
231
|
-
const clipboardImages = opts.from_clipboard
|
|
232
|
-
? [await (await import('./clipboard.js')).readClipboardImage()]
|
|
233
|
-
: [];
|
|
234
|
-
const fileImages = await loadImageInputs(opts.images, opts.images_base64);
|
|
235
|
-
return [...clipboardImages, ...fileImages];
|
|
236
|
-
}
|
|
237
218
|
/** Caller-supplied filename → safe base name (no extension). */
|
|
238
219
|
export function baseName(name) {
|
|
239
220
|
// Strip a known media extension (image / video / audio) so writeMedia doesn't
|
package/dist/inputs.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { McpToolError } from '@chrischall/mcp-utils';
|
|
2
|
-
import { IMAGE_INLINE_MAX_BYTES } from './fetch-image.js';
|
|
2
|
+
import { IMAGE_INLINE_MAX_BYTES, fileNameFromUrl } from './fetch-image.js';
|
|
3
|
+
import { base64ToBytes, bytesToBase64 } from './bytes.js';
|
|
3
4
|
/** True when this call carries at least one image input of any kind. */
|
|
4
5
|
export function hasImageInput(args) {
|
|
5
6
|
return Boolean(args.images?.length ||
|
|
@@ -117,7 +118,7 @@ async function resolveLocalPath(path, client, onDisk, report) {
|
|
|
117
118
|
// Best-effort — an upload failure must not fail a call that inline handles
|
|
118
119
|
// perfectly well.
|
|
119
120
|
try {
|
|
120
|
-
const bytes =
|
|
121
|
+
const bytes = base64ToBytes(inline.base64);
|
|
121
122
|
const displayName = resolved.split(/[\\/]/).pop() || 'image';
|
|
122
123
|
const uploaded = await client.uploadBytes(bytes, inline.mimeType, displayName);
|
|
123
124
|
client.session.uploadCache.set(key, {
|
|
@@ -143,36 +144,14 @@ async function resolveUrl(url, client, report) {
|
|
|
143
144
|
};
|
|
144
145
|
(report.fetched_urls ??= []).push(entry);
|
|
145
146
|
if (fetched.size <= IMAGE_INLINE_MAX_BYTES) {
|
|
146
|
-
return { base64:
|
|
147
|
+
return { base64: bytesToBase64(fetched.bytes), mimeType: fetched.mimeType };
|
|
147
148
|
}
|
|
148
|
-
|
|
149
|
+
// 'image' here: this path is `images_url`, which only ever carries images.
|
|
150
|
+
const displayName = fileNameFromUrl(url, 'image');
|
|
149
151
|
const uploaded = await client.uploadBytes(fetched.bytes, fetched.mimeType, displayName);
|
|
150
152
|
entry.file_uri = uploaded.name;
|
|
151
153
|
return { uri: uploaded.uri, mimeType: uploaded.mimeType };
|
|
152
154
|
}
|
|
153
|
-
/** Last path segment of a URL, for the Files API display name. */
|
|
154
|
-
function fileNameFromUrl(url) {
|
|
155
|
-
try {
|
|
156
|
-
const name = new URL(url).pathname.split('/').filter(Boolean).pop();
|
|
157
|
-
return name ? decodeURIComponent(name).slice(0, 120) : 'image';
|
|
158
|
-
}
|
|
159
|
-
catch {
|
|
160
|
-
return 'image';
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
/**
|
|
164
|
-
* bytes → base64 without `Buffer`. `btoa` is the primitive both runtimes share
|
|
165
|
-
* (see `storage/media.ts`, which decodes the same way); chunked because
|
|
166
|
-
* `String.fromCharCode(...huge)` blows the argument limit.
|
|
167
|
-
*/
|
|
168
|
-
function encodeBase64(bytes) {
|
|
169
|
-
let binary = '';
|
|
170
|
-
const CHUNK = 0x8000;
|
|
171
|
-
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
172
|
-
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
173
|
-
}
|
|
174
|
-
return btoa(binary);
|
|
175
|
-
}
|
|
176
155
|
/**
|
|
177
156
|
* Guard for tools that REQUIRE at least one reference image
|
|
178
157
|
* (`gemini_image_edit`). Names every accepted param, in the order a caller
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retention for generated media.
|
|
3
|
+
*
|
|
4
|
+
* Every hosted generation writes an object to R2 and nothing ever removed it,
|
|
5
|
+
* so the bucket grew without bound — and the signed links pointing into it are
|
|
6
|
+
* only useful for a couple of days anyway. A scheduled sweep keeps the two in
|
|
7
|
+
* step: objects live `MEDIA_TTL_DAYS` (default 7), and a signed URL's lifetime
|
|
8
|
+
* is clamped to that, so a link never outlives the object behind it.
|
|
9
|
+
*
|
|
10
|
+
* R2 lifecycle rules would be the cheaper mechanism, but they are configured on
|
|
11
|
+
* the bucket rather than in `wrangler.jsonc`, so a fresh deploy of this repo
|
|
12
|
+
* would silently have no retention at all. A cron in the Worker ships with the
|
|
13
|
+
* code.
|
|
14
|
+
*/
|
|
15
|
+
const DEFAULT_MAX_OBJECTS = 5_000;
|
|
16
|
+
/**
|
|
17
|
+
* `gen/` is where media is written now; `media/` is where it was written before
|
|
18
|
+
* the rename that made URLs read `/media/gen/…` instead of `/media/media/…`.
|
|
19
|
+
* Both are swept — retention that ignores the existing backlog is not retention.
|
|
20
|
+
*/
|
|
21
|
+
const LEGACY_AND_CURRENT_PREFIXES = ['gen/', 'media/'];
|
|
22
|
+
/** R2 accepts bulk deletes in batches; 1000 is the documented ceiling. */
|
|
23
|
+
const DELETE_BATCH = 1_000;
|
|
24
|
+
/** Days → ms, defaulting to 7 for anything missing or nonsensical. */
|
|
25
|
+
export function retentionMsFromDays(days) {
|
|
26
|
+
const n = Number(days);
|
|
27
|
+
return (Number.isFinite(n) && n > 0 ? n : 7) * 24 * 60 * 60 * 1000;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Delete generated media older than `retentionMs`. Returns counts rather than
|
|
31
|
+
* logging them, so the caller decides what to do with the numbers and the sweep
|
|
32
|
+
* itself stays testable.
|
|
33
|
+
*/
|
|
34
|
+
export async function cleanupExpiredMedia(bucket, retentionMs, opts = {}) {
|
|
35
|
+
const now = opts.now?.() ?? Date.now();
|
|
36
|
+
const cutoff = now - retentionMs;
|
|
37
|
+
const maxObjects = opts.maxObjects ?? DEFAULT_MAX_OBJECTS;
|
|
38
|
+
const prefixes = opts.prefixes ?? LEGACY_AND_CURRENT_PREFIXES;
|
|
39
|
+
let scanned = 0;
|
|
40
|
+
let deleted = 0;
|
|
41
|
+
let truncated = false;
|
|
42
|
+
const doomed = [];
|
|
43
|
+
const flush = async (all) => {
|
|
44
|
+
while (doomed.length >= (all ? 1 : DELETE_BATCH)) {
|
|
45
|
+
const batch = doomed.splice(0, DELETE_BATCH);
|
|
46
|
+
await bucket.delete(batch);
|
|
47
|
+
deleted += batch.length;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
for (const prefix of prefixes) {
|
|
51
|
+
let cursor;
|
|
52
|
+
for (;;) {
|
|
53
|
+
if (scanned >= maxObjects) {
|
|
54
|
+
truncated = true;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
const page = await bucket.list({ prefix, cursor, limit: Math.min(1000, maxObjects - scanned) });
|
|
58
|
+
for (const object of page.objects) {
|
|
59
|
+
scanned++;
|
|
60
|
+
if (object.uploaded.getTime() < cutoff)
|
|
61
|
+
doomed.push(object.key);
|
|
62
|
+
}
|
|
63
|
+
await flush(false);
|
|
64
|
+
if (!page.truncated)
|
|
65
|
+
break;
|
|
66
|
+
if (scanned >= maxObjects) {
|
|
67
|
+
truncated = true;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
cursor = page.cursor;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
await flush(true);
|
|
74
|
+
return { scanned, deleted, truncated };
|
|
75
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { loadSigningKey, verifyMediaSignature } from './media-url.js';
|
|
2
|
+
function problem(status, message) {
|
|
3
|
+
return new Response(`${message}\n`, {
|
|
4
|
+
status,
|
|
5
|
+
headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' },
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Build the `/media` handler. `basePath` is stripped from the pathname to get
|
|
10
|
+
* the R2 key, so the key keeps its own prefix and the two never drift.
|
|
11
|
+
*/
|
|
12
|
+
export function createMediaHandler(env, basePath = '/media/') {
|
|
13
|
+
return async function handleMedia(request) {
|
|
14
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
15
|
+
return problem(405, `Method ${request.method} not allowed; media links are GET-only.`);
|
|
16
|
+
}
|
|
17
|
+
const url = new URL(request.url);
|
|
18
|
+
if (!url.pathname.startsWith(basePath))
|
|
19
|
+
return problem(404, 'Not found');
|
|
20
|
+
// decodeURIComponent per segment, mirroring how the URL was built. A key
|
|
21
|
+
// cannot contain `..` (the sink sanitises every segment), and R2 has no
|
|
22
|
+
// path traversal to exploit anyway — a key is an opaque string, not a path.
|
|
23
|
+
const key = url.pathname
|
|
24
|
+
.slice(basePath.length)
|
|
25
|
+
.split('/')
|
|
26
|
+
.map((segment) => {
|
|
27
|
+
try {
|
|
28
|
+
return decodeURIComponent(segment);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return segment;
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
.join('/');
|
|
35
|
+
if (!key)
|
|
36
|
+
return problem(404, 'Not found');
|
|
37
|
+
const now = env.now?.() ?? Date.now();
|
|
38
|
+
// Read-only: verification must never CREATE a signing secret. A stale KV
|
|
39
|
+
// read here would otherwise clobber the secret every outstanding link was
|
|
40
|
+
// signed with (see loadSigningKey). No secret ⇒ nothing was legitimately
|
|
41
|
+
// signed ⇒ refuse, without touching storage or the bucket.
|
|
42
|
+
const signingKey = await loadSigningKey(env.secret, env.store);
|
|
43
|
+
const check = signingKey
|
|
44
|
+
? await verifyMediaSignature(signingKey, key, url.searchParams.get('exp'), url.searchParams.get('sig'), now)
|
|
45
|
+
: { ok: false, reason: 'invalid' };
|
|
46
|
+
if (!check.ok) {
|
|
47
|
+
if (check.reason === 'expired') {
|
|
48
|
+
return problem(410, 'This media link has expired. Generated media is retained for a limited time — re-run the generation to get a fresh link.');
|
|
49
|
+
}
|
|
50
|
+
return problem(403, 'Invalid or missing media signature. Media links must be used exactly as returned by the tool, including the exp and sig parameters.');
|
|
51
|
+
}
|
|
52
|
+
const object = await env.bucket.get(key);
|
|
53
|
+
if (!object) {
|
|
54
|
+
return problem(404, 'That media object no longer exists — it may have been cleaned up. Re-run the generation to get a fresh one.');
|
|
55
|
+
}
|
|
56
|
+
const headers = new Headers({
|
|
57
|
+
'content-type': object.httpMetadata?.contentType ?? 'application/octet-stream',
|
|
58
|
+
// The URL is signed and expiring, so it is safe to cache for its lifetime;
|
|
59
|
+
// `private` keeps shared caches from serving it to anyone else.
|
|
60
|
+
'cache-control': 'private, max-age=3600',
|
|
61
|
+
'content-disposition': `inline; filename="${key.split('/').pop() ?? 'media'}"`,
|
|
62
|
+
// The bytes are model output being handed to a browser — never let a
|
|
63
|
+
// sniffed content type turn an image response into something executable.
|
|
64
|
+
'x-content-type-options': 'nosniff',
|
|
65
|
+
});
|
|
66
|
+
if (object.size !== undefined)
|
|
67
|
+
headers.set('content-length', String(object.size));
|
|
68
|
+
if (request.method === 'HEAD')
|
|
69
|
+
return new Response(null, { status: 200, headers });
|
|
70
|
+
return new Response(object.body, { status: 200, headers });
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed URLs for generated media on the hosted connector.
|
|
3
|
+
*
|
|
4
|
+
* The problem these exist to solve: a chat client shows the user whatever the
|
|
5
|
+
* assistant *says*, and MCP inline image blocks are not that. On claude.ai and
|
|
6
|
+
* the mobile app the bytes arrive in the model's context and are never rendered
|
|
7
|
+
* — so a generation that billed successfully is invisible. A URL is the only
|
|
8
|
+
* portable answer: a human can tap it and an agent can curl it.
|
|
9
|
+
*
|
|
10
|
+
* That means the media route cannot sit behind the connector's OAuth, because
|
|
11
|
+
* a browser opening a link carries no bearer token. The signature IS the
|
|
12
|
+
* authorization: a URL is valid for one key until it expires, and nothing else
|
|
13
|
+
* in the bucket is reachable without one.
|
|
14
|
+
*
|
|
15
|
+
* Kept free of any Cloudflare import so the whole thing is testable under Node:
|
|
16
|
+
* WebCrypto is the only primitive used, and it exists on both runtimes.
|
|
17
|
+
*/
|
|
18
|
+
/** How long a freshly minted media URL stays valid. Matches Files API retention. */
|
|
19
|
+
export const MEDIA_URL_TTL_MS = 48 * 60 * 60 * 1000;
|
|
20
|
+
/** KV key holding the lazily-generated signing secret (zero-config path). */
|
|
21
|
+
export const SIGNING_SECRET_KV_KEY = 'media:url-signing-secret';
|
|
22
|
+
function base64UrlEncode(bytes) {
|
|
23
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
24
|
+
let binary = '';
|
|
25
|
+
for (let i = 0; i < view.length; i++)
|
|
26
|
+
binary += String.fromCharCode(view[i]);
|
|
27
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
28
|
+
}
|
|
29
|
+
function base64UrlDecode(text) {
|
|
30
|
+
const padded = text.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(text.length / 4) * 4, '=');
|
|
31
|
+
const binary = atob(padded);
|
|
32
|
+
const out = new Uint8Array(binary.length);
|
|
33
|
+
for (let i = 0; i < binary.length; i++)
|
|
34
|
+
out[i] = binary.charCodeAt(i);
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
/** Import raw secret material as an HMAC key. */
|
|
38
|
+
async function importKey(material) {
|
|
39
|
+
return crypto.subtle.importKey('raw', new TextEncoder().encode(material), { name: 'HMAC', hash: 'SHA-256' }, false, [
|
|
40
|
+
'sign',
|
|
41
|
+
'verify',
|
|
42
|
+
]);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The HMAC key for **signing**, creating one if this deployment has never
|
|
46
|
+
* signed anything.
|
|
47
|
+
*
|
|
48
|
+
* `MEDIA_URL_SECRET` wins when an operator sets one. Otherwise a random 32-byte
|
|
49
|
+
* secret is generated once and persisted in KV — which is what makes the whole
|
|
50
|
+
* feature work with **zero configuration**, the entire point of this change.
|
|
51
|
+
*
|
|
52
|
+
* Creating is confined to this function, and this function is called only when
|
|
53
|
+
* minting a URL. {@link loadSigningKey} is what the verify path uses. That
|
|
54
|
+
* split is load-bearing, not stylistic — see the warning there.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately NOT cached in a module-level variable: `src/` holds no
|
|
57
|
+
* module-scope mutable state (see session.ts), and a KV read per sign is cheap
|
|
58
|
+
* next to an image generation. There is a narrow first-use race — two isolates
|
|
59
|
+
* generating simultaneously — so the value is re-read after writing and the
|
|
60
|
+
* stored one wins, collapsing the window to URLs minted in that same instant.
|
|
61
|
+
*/
|
|
62
|
+
export async function resolveSigningKey(secret, store) {
|
|
63
|
+
let material = secret?.trim();
|
|
64
|
+
if (!material) {
|
|
65
|
+
material = (await store.get(SIGNING_SECRET_KV_KEY)) ?? undefined;
|
|
66
|
+
if (!material) {
|
|
67
|
+
const generated = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
|
|
68
|
+
await store.put(SIGNING_SECRET_KV_KEY, generated);
|
|
69
|
+
// Re-read so a concurrent generator's value wins consistently for both.
|
|
70
|
+
material = (await store.get(SIGNING_SECRET_KV_KEY)) ?? generated;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return importKey(material);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The HMAC key for **verifying**. Read-only: returns `undefined` when no secret
|
|
77
|
+
* is stored, and never creates one.
|
|
78
|
+
*
|
|
79
|
+
* This is not a stylistic split. KV is eventually consistent — a `put` on one
|
|
80
|
+
* isolate is not immediately visible to another — so the verify path can and
|
|
81
|
+
* will see a miss for a secret that exists. If verification created-on-miss
|
|
82
|
+
* (as it did originally), that transient miss would mint a NEW secret and
|
|
83
|
+
* overwrite the one every outstanding link was signed with, permanently 403ing
|
|
84
|
+
* media that had been signed perfectly correctly. A self-inflicted denial of
|
|
85
|
+
* service, triggered by ordinary traffic, and irreversible once the old secret
|
|
86
|
+
* is gone.
|
|
87
|
+
*
|
|
88
|
+
* No secret stored ⇒ nothing was ever legitimately signed ⇒ refuse.
|
|
89
|
+
*/
|
|
90
|
+
export async function loadSigningKey(secret, store) {
|
|
91
|
+
const material = secret?.trim() || (await store.get(SIGNING_SECRET_KV_KEY));
|
|
92
|
+
return material ? importKey(material) : undefined;
|
|
93
|
+
}
|
|
94
|
+
/** The bytes covered by a signature: the object key and the expiry, nothing else. */
|
|
95
|
+
function payload(key, expiresAtMs) {
|
|
96
|
+
return new TextEncoder().encode(`${key}\n${expiresAtMs}`);
|
|
97
|
+
}
|
|
98
|
+
/** Sign one object key for use until `expiresAtMs`. */
|
|
99
|
+
export async function signMediaKey(signingKey, key, expiresAtMs) {
|
|
100
|
+
return base64UrlEncode(await crypto.subtle.sign('HMAC', signingKey, payload(key, expiresAtMs)));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Check a `?exp=&sig=` pair against an object key.
|
|
104
|
+
*
|
|
105
|
+
* Verification goes through `crypto.subtle.verify` rather than a string
|
|
106
|
+
* comparison, so it is constant-time — a `===` on the signature would leak the
|
|
107
|
+
* expected value one byte at a time to anyone willing to time it.
|
|
108
|
+
*/
|
|
109
|
+
export async function verifyMediaSignature(signingKey, key, exp, sig, nowMs) {
|
|
110
|
+
if (!exp || !sig)
|
|
111
|
+
return { ok: false, reason: 'missing' };
|
|
112
|
+
const expiresAtMs = Number(exp);
|
|
113
|
+
if (!Number.isFinite(expiresAtMs))
|
|
114
|
+
return { ok: false, reason: 'invalid' };
|
|
115
|
+
let valid;
|
|
116
|
+
try {
|
|
117
|
+
valid = await crypto.subtle.verify('HMAC', signingKey, base64UrlDecode(sig), payload(key, expiresAtMs));
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return { ok: false, reason: 'invalid' };
|
|
121
|
+
}
|
|
122
|
+
// Signature first, then expiry: an expired-but-authentic link deserves a
|
|
123
|
+
// different message from a forged one, and saying "expired" about a forgery
|
|
124
|
+
// would confirm the attacker got the signature right.
|
|
125
|
+
if (!valid)
|
|
126
|
+
return { ok: false, reason: 'invalid' };
|
|
127
|
+
if (nowMs > expiresAtMs)
|
|
128
|
+
return { ok: false, reason: 'expired' };
|
|
129
|
+
return { ok: true };
|
|
130
|
+
}
|
|
131
|
+
/** `<base>/<key>?exp=…&sig=…` — the URL handed back to the caller. */
|
|
132
|
+
export function buildMediaUrl(baseUrl, key, expiresAtMs, signature) {
|
|
133
|
+
const path = key.split('/').map(encodeURIComponent).join('/');
|
|
134
|
+
return `${baseUrl.replace(/\/+$/, '')}/${path}?exp=${expiresAtMs}&sig=${signature}`;
|
|
135
|
+
}
|
package/dist/storage/media.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* is a thin wrapper over the pre-existing `resolveOutputDir` + `writeMedia`
|
|
12
12
|
* behaviour and must stay byte-for-byte equivalent to it.
|
|
13
13
|
*/
|
|
14
|
+
import { base64ToBytes } from '../bytes.js';
|
|
14
15
|
/**
|
|
15
16
|
* The stdio sink: `resolveOutputDir(output_dir)` → `writeMedia` per item, in
|
|
16
17
|
* order (so `uniquePath`'s `name`/`name-2`/`name-3` sequencing is preserved).
|
|
@@ -29,13 +30,15 @@ export function createDiskSink() {
|
|
|
29
30
|
// Sequential on purpose: uniquePath checks the filesystem, so writing two
|
|
30
31
|
// colliding names concurrently would race onto the same path.
|
|
31
32
|
for (const it of items)
|
|
32
|
-
refs.push(await writeMedia(dir, it.base, it.base64, it.mimeType));
|
|
33
|
+
refs.push({ ref: await writeMedia(dir, it.base, it.base64, it.mimeType) });
|
|
33
34
|
return refs;
|
|
34
35
|
},
|
|
35
36
|
note: () => undefined,
|
|
36
37
|
};
|
|
37
38
|
}
|
|
38
|
-
|
|
39
|
+
// `gen`, not `media`: the objects are served at `/media/<key>`, and a `media`
|
|
40
|
+
// prefix would make every URL read `/media/media/…`.
|
|
41
|
+
const KEY_PREFIX_DEFAULT = 'gen';
|
|
39
42
|
/** Object-key-safe name: no slashes, no dot-segments, never empty. */
|
|
40
43
|
function safeKeySegment(base) {
|
|
41
44
|
const cleaned = base
|
|
@@ -56,7 +59,9 @@ export function createR2Sink(bucket, opts) {
|
|
|
56
59
|
const prefix = opts.prefix ?? KEY_PREFIX_DEFAULT;
|
|
57
60
|
const now = opts.now ?? (() => new Date());
|
|
58
61
|
const randomId = opts.randomId ?? (() => crypto.randomUUID().slice(0, 8));
|
|
59
|
-
const
|
|
62
|
+
const publicBase = opts.publicBaseUrl?.replace(/\/+$/, '');
|
|
63
|
+
const signedBase = opts.signedBaseUrl?.replace(/\/+$/, '');
|
|
64
|
+
const ttlMs = opts.urlTtlMs ?? 0;
|
|
60
65
|
return {
|
|
61
66
|
kind: 'r2',
|
|
62
67
|
persistsFiles: false,
|
|
@@ -66,32 +71,38 @@ export function createR2Sink(bucket, opts) {
|
|
|
66
71
|
// module graph.
|
|
67
72
|
const { mediaExt } = await import('../images.js');
|
|
68
73
|
const day = now().toISOString().slice(0, 10);
|
|
74
|
+
const expiresAtMs = now().getTime() + ttlMs;
|
|
69
75
|
const refs = [];
|
|
70
76
|
for (const it of items) {
|
|
71
77
|
const key = `${prefix}/${day}/${randomId()}-${safeKeySegment(it.base)}.${mediaExt(it.mimeType)}`;
|
|
72
|
-
|
|
73
|
-
await
|
|
74
|
-
refs.push(base ? `${base}/${key}` : `r2://${opts.bucketName ?? 'media'}/${key}`);
|
|
78
|
+
await bucket.put(key, base64ToBytes(it.base64), { httpMetadata: { contentType: it.mimeType } });
|
|
79
|
+
refs.push(await describe(key, expiresAtMs));
|
|
75
80
|
}
|
|
76
81
|
return refs;
|
|
77
82
|
},
|
|
78
|
-
note: () =>
|
|
79
|
-
? 'Generated media was uploaded to R2
|
|
83
|
+
note: () => publicBase
|
|
84
|
+
? 'Generated media was uploaded to R2 and the values above are public URLs (not local file paths) — open or download them directly. ' +
|
|
80
85
|
'This hosted connector has no filesystem, so `output_dir` is ignored and no <image>.json sidecar is written — capture interaction_id from this result to chain further turns.'
|
|
81
|
-
:
|
|
82
|
-
'
|
|
83
|
-
|
|
86
|
+
: signedBase
|
|
87
|
+
? 'Generated media is served by this connector at the signed URLs above — open them in a browser or fetch them with curl; no auth header is needed, the signature is in the link. ' +
|
|
88
|
+
'They expire (see media[].expires_at), and the objects behind them are cleaned up on a retention schedule. ' +
|
|
89
|
+
'This hosted connector has no filesystem, so `output_dir` is ignored and no <image>.json sidecar is written — capture interaction_id from this result to chain further turns.'
|
|
90
|
+
: 'Generated media was stored, but this connector has no public media URL configured and no signing route available, so the values above are bare object keys rather than fetchable links. ' +
|
|
91
|
+
'Set MEDIA_PUBLIC_BASE_URL on the Worker, or pass inline: true to receive the bytes directly.',
|
|
84
92
|
};
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
/** One stored object → the ref the caller sees, plus its key and expiry. */
|
|
94
|
+
async function describe(key, expiresAtMs) {
|
|
95
|
+
// A bucket served directly (r2.dev or a custom domain) needs no signature.
|
|
96
|
+
if (publicBase)
|
|
97
|
+
return { ref: `${publicBase}/${key}`, key };
|
|
98
|
+
if (signedBase && opts.sign) {
|
|
99
|
+
const { buildMediaUrl } = await import('../media-url.js');
|
|
100
|
+
const signature = await opts.sign(key, expiresAtMs);
|
|
101
|
+
return { ref: buildMediaUrl(signedBase, key, expiresAtMs, signature), key, expiresAt: new Date(expiresAtMs).toISOString() };
|
|
102
|
+
}
|
|
103
|
+
// Neither configured. Unreachable in the Worker (which always passes its
|
|
104
|
+
// own origin), so this is a misconfiguration rather than a mode — say so
|
|
105
|
+
// explicitly rather than returning something that looks like a filename.
|
|
106
|
+
return { ref: key, key, unavailable: true };
|
|
107
|
+
}
|
|
97
108
|
}
|
package/dist/tools/files.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { McpToolError, textResult } from '@chrischall/mcp-utils';
|
|
3
3
|
import { withProgressHeartbeat } from './shared.js';
|
|
4
|
+
import { base64ToBytes, wholeMb } from '../bytes.js';
|
|
5
|
+
import { fileNameFromUrl } from '../fetch-image.js';
|
|
4
6
|
import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
5
7
|
/**
|
|
6
8
|
* First-class Gemini Files API access.
|
|
@@ -38,7 +40,6 @@ import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfir
|
|
|
38
40
|
*/
|
|
39
41
|
const UPLOAD_URL_MAX_BYTES_DISK = 100 * 1024 * 1024;
|
|
40
42
|
const UPLOAD_URL_MAX_BYTES_HOSTED = 25 * 1024 * 1024;
|
|
41
|
-
const mb = (bytes) => Math.round(bytes / (1024 * 1024));
|
|
42
43
|
/** What a `url` upload will accept: the media the Files API is useful for. */
|
|
43
44
|
const UPLOAD_ACCEPT = ['image/', 'video/', 'audio/'];
|
|
44
45
|
const RETENTION_NOTE = 'Gemini retains uploaded files for ~48h. Until then this reference can be reused across any number of calls ' +
|
|
@@ -62,7 +63,10 @@ export function registerFileTools(server, client) {
|
|
|
62
63
|
const onDisk = client.mediaSink?.persistsFiles ?? true;
|
|
63
64
|
const urlMaxBytes = onDisk ? UPLOAD_URL_MAX_BYTES_DISK : UPLOAD_URL_MAX_BYTES_HOSTED;
|
|
64
65
|
server.registerTool('gemini_upload_file', {
|
|
65
|
-
description: 'Upload an image
|
|
66
|
+
description: 'Upload a file — an image, reference photo, picture, screenshot, video or audio clip — to the Gemini Files API ' +
|
|
67
|
+
'ONCE, and get back a reusable `file_uri` (`files/<id>`) to attach to later image, video or music generations. ' +
|
|
68
|
+
'Keywords: upload, upload file, upload image, upload photo, attach, reference image, reference photo, ' +
|
|
69
|
+
'file_uri, files api, image reference, reuse across calls. ' +
|
|
66
70
|
'Use this instead of pasting base64 into a tool call: the reference is a short string, so no image bytes ever ' +
|
|
67
71
|
'enter the conversation, and it can be reused across many generations until it expires (~48h). ' +
|
|
68
72
|
'Provide exactly one of `url` (the server downloads it), `data_base64`' +
|
|
@@ -77,7 +81,7 @@ export function registerFileTools(server, client) {
|
|
|
77
81
|
.string()
|
|
78
82
|
.url()
|
|
79
83
|
.optional()
|
|
80
|
-
.describe(`Public https URL the SERVER downloads and uploads (image/video/audio, up to ${
|
|
84
|
+
.describe(`Public https URL the SERVER downloads and uploads (image/video/audio, up to ${wholeMb(urlMaxBytes)}MB). ` +
|
|
81
85
|
'No bytes pass through the conversation.' +
|
|
82
86
|
(onDisk ? '' : ' Larger files: POST the raw bytes to /upload, which streams instead of buffering.')),
|
|
83
87
|
data_base64: z
|
|
@@ -122,24 +126,27 @@ export function registerFileTools(server, client) {
|
|
|
122
126
|
const uploaded = await withProgressHeartbeat(extra, 'Uploading to the Gemini Files API', async () => {
|
|
123
127
|
if (args.url) {
|
|
124
128
|
const fetched = await client.fetchRemoteImage(args.url, { maxBytes: urlMaxBytes, accept: UPLOAD_ACCEPT });
|
|
125
|
-
return client.uploadBytes(fetched.bytes, args.mime_type ?? fetched.mimeType,
|
|
129
|
+
return client.uploadBytes(fetched.bytes, args.mime_type ?? fetched.mimeType,
|
|
130
|
+
// 'upload', matching the data_base64 and path branches below —
|
|
131
|
+
// this tool takes video and audio too, so 'image' would be a lie.
|
|
132
|
+
args.display_name ?? fileNameFromUrl(args.url, 'upload'));
|
|
126
133
|
}
|
|
127
134
|
if (args.data_base64) {
|
|
128
135
|
const { decodeImageInput } = await import('../images.js');
|
|
129
136
|
const decoded = decodeImageInput(args.data_base64);
|
|
130
|
-
const bytes =
|
|
137
|
+
const bytes = base64ToBytes(decoded.base64);
|
|
131
138
|
return client.uploadBytes(bytes, args.mime_type ?? decoded.mimeType, args.display_name ?? 'upload');
|
|
132
139
|
}
|
|
133
140
|
const { readImageAsInline, resolveImagePath } = await import('../images.js');
|
|
134
141
|
const resolved = resolveImagePath(args.path);
|
|
135
142
|
const inline = await readImageAsInline(resolved);
|
|
136
|
-
const bytes =
|
|
143
|
+
const bytes = base64ToBytes(inline.base64);
|
|
137
144
|
return client.uploadBytes(bytes, args.mime_type ?? inline.mimeType, args.display_name ?? (resolved.split(/[\\/]/).pop() || 'upload'));
|
|
138
145
|
});
|
|
139
146
|
return uploadResult(uploaded);
|
|
140
147
|
});
|
|
141
148
|
server.registerTool('gemini_list_files', {
|
|
142
|
-
description: 'List
|
|
149
|
+
description: 'List files, images and photos currently uploaded to the Gemini Files API under this API key, with their reusable `file_uri` (`files/<id>`) references, ' +
|
|
143
150
|
'MIME types and expiry times. Retention is ~48h, so an entry that has vanished has expired rather than failed.',
|
|
144
151
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
145
152
|
inputSchema: {
|
|
@@ -165,7 +172,7 @@ export function registerFileTools(server, client) {
|
|
|
165
172
|
});
|
|
166
173
|
});
|
|
167
174
|
server.registerTool('gemini_delete_file', {
|
|
168
|
-
description: 'Delete an uploaded file from the Gemini Files API before its ~48h expiry. Any tool call still referencing it ' +
|
|
175
|
+
description: 'Delete an uploaded file, image or photo (by file_uri) from the Gemini Files API before its ~48h expiry. Any tool call still referencing it ' +
|
|
169
176
|
'will then fail with a generic 404, so delete only references you are finished with.',
|
|
170
177
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
171
178
|
inputSchema: {
|
|
@@ -182,13 +189,3 @@ export function registerFileTools(server, client) {
|
|
|
182
189
|
return textResult({ deleted: args.file_uri });
|
|
183
190
|
});
|
|
184
191
|
}
|
|
185
|
-
/** Last path segment of a URL, for the Files API display name. */
|
|
186
|
-
function nameFromUrl(url) {
|
|
187
|
-
try {
|
|
188
|
-
const name = new URL(url).pathname.split('/').filter(Boolean).pop();
|
|
189
|
-
return name ? decodeURIComponent(name).slice(0, 120) : 'upload';
|
|
190
|
-
}
|
|
191
|
-
catch {
|
|
192
|
-
return 'upload';
|
|
193
|
-
}
|
|
194
|
-
}
|