@chrischall/gemini-mcp 1.5.1 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +28 -54
- package/dist/blob-store.js +176 -0
- package/dist/bundle.js +1177 -491
- package/dist/client.js +74 -2
- package/dist/index.js +20 -4
- package/dist/jobs.js +1 -1
- package/dist/media-name.js +20 -0
- package/dist/tools/files.js +1 -1
- package/dist/tools/shared.js +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -15
- package/server.json +2 -2
- package/skills/gemini-mcp/SKILL.md +4 -4
- package/dist/connector-origin.js +0 -34
- package/dist/media-cleanup.js +0 -78
- package/dist/media-endpoint.js +0 -166
- package/dist/put-endpoint.js +0 -122
- package/dist/upload-endpoint.js +0 -79
package/dist/put-endpoint.js
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
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/upload-endpoint.js
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
/** Media the Files API is useful for. Anything else is a caller mistake. */
|
|
2
|
-
const ACCEPTED_PREFIXES = ['image/', 'video/', 'audio/'];
|
|
3
|
-
function json(body, status) {
|
|
4
|
-
return new Response(JSON.stringify(body, null, 2), {
|
|
5
|
-
status,
|
|
6
|
-
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
7
|
-
});
|
|
8
|
-
}
|
|
9
|
-
/** Every error names the fix, because the caller here is a shell, not a model. */
|
|
10
|
-
function fail(status, error, hint) {
|
|
11
|
-
return json({ error, hint }, status);
|
|
12
|
-
}
|
|
13
|
-
/**
|
|
14
|
-
* Build the `/upload` route handler. `buildClient` is injected rather than
|
|
15
|
-
* imported so the Worker can construct a client from *this request's* OAuth
|
|
16
|
-
* props (never a shared singleton — see the cross-tenant note in session.ts),
|
|
17
|
-
* and so tests can drive the whole handler against a stub client.
|
|
18
|
-
*/
|
|
19
|
-
export function createUploadHandler(buildClient) {
|
|
20
|
-
return async function handleUpload(request, ctx) {
|
|
21
|
-
if (request.method === 'OPTIONS') {
|
|
22
|
-
return new Response(null, { status: 204, headers: { allow: 'POST, OPTIONS' } });
|
|
23
|
-
}
|
|
24
|
-
if (request.method !== 'POST') {
|
|
25
|
-
return fail(405, `Method ${request.method} not allowed on /upload.`, 'POST the raw file bytes as the request body.');
|
|
26
|
-
}
|
|
27
|
-
// The OAuth provider populates props from the validated bearer token. Its
|
|
28
|
-
// absence means the route was reached some other way — refuse rather than
|
|
29
|
-
// fall back to an ambient key, which on a multi-tenant Worker would bill an
|
|
30
|
-
// unauthenticated upload to whoever configured the environment.
|
|
31
|
-
const apiKey = ctx.props?.apiKey;
|
|
32
|
-
if (typeof apiKey !== 'string' || !apiKey) {
|
|
33
|
-
return fail(401, 'Unauthenticated.', 'Send the same Authorization: Bearer <access-token> you use for the /mcp endpoint.');
|
|
34
|
-
}
|
|
35
|
-
const contentType = (request.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
|
|
36
|
-
if (!ACCEPTED_PREFIXES.some((p) => contentType.startsWith(p))) {
|
|
37
|
-
return fail(415, `Unsupported Content-Type "${contentType || '(none)'}".`, 'Set Content-Type to the file\'s media type, e.g. -H "Content-Type: image/jpeg".');
|
|
38
|
-
}
|
|
39
|
-
const declared = request.headers.get('content-length');
|
|
40
|
-
const contentLength = Number(declared);
|
|
41
|
-
if (!declared || !Number.isFinite(contentLength) || contentLength <= 0) {
|
|
42
|
-
return fail(411, 'Content-Length is required (and must be > 0).', 'The Files API resumable upload declares the size up front, so the body cannot be sent chunked. ' +
|
|
43
|
-
'curl sets this automatically with --data-binary @file.');
|
|
44
|
-
}
|
|
45
|
-
const body = request.body;
|
|
46
|
-
if (!body) {
|
|
47
|
-
return fail(400, 'Request had no body.', 'Send the file bytes as the request body, e.g. --data-binary @photo.jpg.');
|
|
48
|
-
}
|
|
49
|
-
try {
|
|
50
|
-
const file = await buildClient(apiKey).uploadStream(body, contentType, displayNameFor(request, contentType), contentLength);
|
|
51
|
-
return json({
|
|
52
|
-
file_uri: file.name,
|
|
53
|
-
uri: file.uri,
|
|
54
|
-
name: file.name,
|
|
55
|
-
mime_type: file.mimeType,
|
|
56
|
-
...(file.sizeBytes ? { size_bytes: Number(file.sizeBytes) } : {}),
|
|
57
|
-
expires: file.expirationTime ?? '~48h from upload',
|
|
58
|
-
hint: `Pass "${file.name}" as images_file_uris (or video_url) on any image tool. Gemini retains it ~48h.`,
|
|
59
|
-
}, 201);
|
|
60
|
-
}
|
|
61
|
-
catch (err) {
|
|
62
|
-
// Upstream failures are the interesting case here (bad key, quota, a file
|
|
63
|
-
// over the 2 GB cap), so the message is passed through rather than
|
|
64
|
-
// flattened into "upload failed".
|
|
65
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
66
|
-
return fail(502, `Upload to the Gemini Files API failed: ${message}`, 'Check the API key on this connection is still valid and funded.');
|
|
67
|
-
}
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
/**
|
|
71
|
-
* A display name for the upload: an explicit `?name=` wins, else the
|
|
72
|
-
* `X-Filename` header (what a wrapper script would send), else the media type.
|
|
73
|
-
* Cosmetic — the Files API keys on the returned resource name, not this.
|
|
74
|
-
*/
|
|
75
|
-
function displayNameFor(request, contentType) {
|
|
76
|
-
const fromQuery = new URL(request.url).searchParams.get('name');
|
|
77
|
-
const raw = fromQuery ?? request.headers.get('x-filename') ?? `upload.${contentType.split('/')[1] || 'bin'}`;
|
|
78
|
-
return raw.replace(/[^\w.\-]+/g, '-').slice(0, 120) || 'upload';
|
|
79
|
-
}
|