@chrischall/gemini-mcp 1.5.0 → 1.6.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 +28 -54
- package/dist/blob-store.js +176 -0
- package/dist/bundle.js +1181 -494
- package/dist/client.js +78 -5
- 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
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "MCP server for Google Gemini image generation and editing — generate and edit images via the Gemini API",
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.6.0"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"displayName": "Gemini Images",
|
|
16
16
|
"source": "./",
|
|
17
17
|
"description": "Generate and edit images with Google Gemini image models (Nano Banana / Nano Banana Pro) via natural language prompts",
|
|
18
|
-
"version": "1.
|
|
18
|
+
"version": "1.6.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
package/README.md
CHANGED
|
@@ -86,28 +86,28 @@ progress), two guards make re-issuing safe and unnecessary:
|
|
|
86
86
|
| `gemini_upload_file` | Upload an image (or video/audio) to the Gemini Files API once — from a `url`, `data_base64`, or a local `path` — and get a reusable `files/<id>` reference |
|
|
87
87
|
| `gemini_list_files` | List the files currently uploaded under this API key, with MIME types and expiry times |
|
|
88
88
|
| `gemini_delete_file` | Delete an uploaded file before its ~48h expiry (confirm-gated) |
|
|
89
|
-
| `gemini_sign_media` | *(hosted
|
|
90
|
-
| `gemini_get_upload_url` | *(hosted
|
|
91
|
-
| `gemini_save_character` / `gemini_list_characters` / `gemini_delete_character` | *(hosted
|
|
92
|
-
| `gemini_save_style` / `gemini_list_styles` / `gemini_delete_style` | *(hosted
|
|
89
|
+
| `gemini_sign_media` | *(hosted deployments only)* Mint a fresh signed URL for generated media from its `r2_key` — an expired link is not a dead end |
|
|
90
|
+
| `gemini_get_upload_url` | *(hosted deployments only)* Mint a short-lived signed PUT URL so a shell can upload a reference image with no auth header; the PUT returns an `r2_key` usable in `images_r2_keys`, `gemini_save_character`, or `gemini_upload_file` |
|
|
91
|
+
| `gemini_save_character` / `gemini_list_characters` / `gemini_delete_character` | *(hosted deployments only)* Persistent per-account character library: save a reference image + description under a name, then pass `characters: ["name"]` on generation tools. No expiry |
|
|
92
|
+
| `gemini_save_style` / `gemini_list_styles` / `gemini_delete_style` | *(hosted deployments only)* Persistent per-account style presets: a reusable prompt fragment (optionally with a reference image), applied by passing `style: "name"` on generation tools. No expiry |
|
|
93
93
|
|
|
94
94
|
Generation tools also share three throughput/latency controls: `async: true` (return a `job_id`
|
|
95
95
|
immediately), `max_wait_ms` (wait up to a budget, then hand back the `job_id` — fast results stay
|
|
96
96
|
in-band, slow batches never trip the host timeout), and `idempotency_key` (a retry returns the
|
|
97
|
-
recorded result instead of re-billing). On
|
|
97
|
+
recorded result instead of re-billing). On a hosted deployment, a `gemini_image_set` result with
|
|
98
98
|
more than one image also carries a **`bundle_url`** — one signed URL for a zip of every image in
|
|
99
99
|
the set — and set links are signed for ~7 days instead of the default ~48h. (A set too large to
|
|
100
|
-
zip safely in
|
|
100
|
+
zip safely in memory skips the bundle and says so via `bundle_skipped`; the per-image
|
|
101
101
|
links are unaffected.)
|
|
102
102
|
|
|
103
|
-
## Seeing your images (hosted
|
|
103
|
+
## Seeing your images (hosted)
|
|
104
104
|
|
|
105
|
-
On
|
|
105
|
+
On a hosted deployment there is no filesystem, so a generated image has to come back as
|
|
106
106
|
something you can *open*. It does: **every result includes a URL**, with no configuration.
|
|
107
107
|
|
|
108
108
|
```jsonc
|
|
109
109
|
{
|
|
110
|
-
"images": ["https://
|
|
110
|
+
"images": ["https://mcp.nullnet.app/b/<account>/gemini/gen/2026-07-29/ab12cd34-a-cat.png?exp=…&sig=…"],
|
|
111
111
|
"media": [{ "url": "https://…", "r2_key": "gen/2026-07-29/ab12cd34-a-cat.png",
|
|
112
112
|
"expires_at": "2026-07-31T12:00:00.000Z",
|
|
113
113
|
"curl_hint": "curl -sS -o a-cat.png \"https://…\"" }]
|
|
@@ -118,10 +118,10 @@ Those links need no auth header — the signature is in the URL — so they work
|
|
|
118
118
|
`curl`, and in a chat message. They expire (48h by default) and the objects behind them are
|
|
119
119
|
swept on a retention schedule. The `r2_key` is the durable handle for that window:
|
|
120
120
|
|
|
121
|
-
- **`gemini_sign_media`** (hosted
|
|
121
|
+
- **`gemini_sign_media`** (hosted deployments only) mints a fresh signed URL from an `r2_key`,
|
|
122
122
|
so an expired link never forces you to re-generate — and re-pay for — the image.
|
|
123
|
-
- **`gemini_upload_file` with `r2_key`** turns media
|
|
124
|
-
reference (the
|
|
123
|
+
- **`gemini_upload_file` with `r2_key`** turns media this server generated into a Files API
|
|
124
|
+
reference (the server reads it back with its own key — no signature for you to mint), so
|
|
125
125
|
a generated image can become the reference image for the next generation in one cheap call.
|
|
126
126
|
- Idempotent replays (`idempotency_key`) re-mint the URLs inside the recorded result before
|
|
127
127
|
returning it, so a reused result never carries a dead link.
|
|
@@ -132,20 +132,14 @@ extract bytes back out of its own context to save them elsewhere. A generation c
|
|
|
132
132
|
successfully and be invisible. A URL is the portable answer; `inline` remains available, but
|
|
133
133
|
it is no longer the only way to receive media.
|
|
134
134
|
|
|
135
|
-
###
|
|
135
|
+
### How the bytes are served
|
|
136
136
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
| **Custom domain** (best) | attach a domain to the bucket in Cloudflare, set `MEDIA_PUBLIC_BASE_URL` | `https://media.example.com/<key>` |
|
|
142
|
-
|
|
143
|
-
The built-in Worker route is the zero-config path: the connector streams the object out of R2
|
|
144
|
-
itself. The other two are worth setting up if you want plain, non-expiring URLs or want media
|
|
145
|
-
traffic served by R2 rather than by the Worker — r2.dev is easiest but rate-limited and fine
|
|
146
|
-
for personal use; a custom domain is the best of the three.
|
|
137
|
+
The host stores each generated object and serves it back at a signed, expiring
|
|
138
|
+
URL — `https://<host>/b/<account>/gemini/<key>?exp=&sig=`. No setup, and no auth
|
|
139
|
+
header: the signature in the link is the authorization, so `curl` and a browser
|
|
140
|
+
both work.
|
|
147
141
|
|
|
148
|
-
**Auth
|
|
142
|
+
**Auth is a signed, expiring URL rather than an unlisted key.** Random keys would
|
|
149
143
|
be simpler, but they never expire and never revoke: anything that ever logged or forwarded the
|
|
150
144
|
link keeps working forever. A signature scopes access to one object with a deadline, and
|
|
151
145
|
rotating `MEDIA_URL_SECRET` invalidates every outstanding link at once. The tradeoff is that
|
|
@@ -153,7 +147,7 @@ links are long and cannot be shortened by hand.
|
|
|
153
147
|
|
|
154
148
|
### For assistants relaying a result
|
|
155
149
|
|
|
156
|
-
Show the user the URL. If your sandbox has network egress to the
|
|
150
|
+
Show the user the URL. If your sandbox has network egress to the host, fetching it
|
|
157
151
|
and attaching the bytes as a file gives the nicest result; otherwise present the link itself.
|
|
158
152
|
Whether a given client renders `` markdown inline varies by client — a bare URL is the
|
|
159
153
|
safe form, and a markdown link is a reasonable enhancement where you know it renders.
|
|
@@ -164,7 +158,6 @@ safe form, and a markdown link is a reasonable enhancement where you know it ren
|
|
|
164
158
|
|---|---|---|
|
|
165
159
|
| `MEDIA_TTL_DAYS` | `7` | Objects older than this are deleted by a daily cron — generated media (`gen/`, legacy `media/`) and signed uploads (`up/`). The character/style library (`lib/`) is exempt: saved entries never expire |
|
|
166
160
|
| `MEDIA_URL_SECRET` | generated | HMAC key for `/media` links; rotate to revoke all outstanding URLs |
|
|
167
|
-
| `MEDIA_PUBLIC_BASE_URL` | unset | Serve from R2 directly instead of the Worker route |
|
|
168
161
|
|
|
169
162
|
Signed-URL lifetime is clamped to `MEDIA_TTL_DAYS`, so a link never outlives the object it
|
|
170
163
|
points at.
|
|
@@ -179,9 +172,9 @@ Every tool that takes a reference image — `gemini_image_generate`, `gemini_ima
|
|
|
179
172
|
|---|---|---|
|
|
180
173
|
| `images_url` (`master_images_url`) | the **server** downloads the https URL | none |
|
|
181
174
|
| `images_file_uris` (`master_images_file_uris`) | a `files/<id>` reference, already uploaded | none |
|
|
182
|
-
| `images_r2_keys` (`master_images_r2_keys`) | the
|
|
175
|
+
| `images_r2_keys` (`master_images_r2_keys`) | the server reads its **own store** (hosted deployments only) | none |
|
|
183
176
|
| `images` | read off local disk (stdio builds only) | none |
|
|
184
|
-
| `characters` / `style` | saved library entries, attached by name (hosted
|
|
177
|
+
| `characters` / `style` | saved library entries, attached by name (hosted deployments only) | none |
|
|
185
178
|
| `images_base64` | **through the tool-call JSON** | **~14k tokens per JPEG** |
|
|
186
179
|
|
|
187
180
|
`images_base64` is the fallback of last resort. It costs roughly 14k tokens per modest photo,
|
|
@@ -223,31 +216,12 @@ On stdio builds, a local `images` path that gets referenced **more than once in
|
|
|
223
216
|
uploaded to the Files API automatically (keyed on path + mtime + size), so repeated edits of
|
|
224
217
|
the same photo stop re-sending the bytes. Editing the file invalidates the cached upload.
|
|
225
218
|
|
|
226
|
-
###
|
|
227
|
-
|
|
228
|
-
The hosted connector exposes an HTTP upload endpoint behind the **same OAuth token as
|
|
229
|
-
`/mcp`**. This is the intended path for an agent with a shell: disk file → curl → `file_uri` →
|
|
230
|
-
tool call, with the image never entering the conversation.
|
|
231
|
-
|
|
232
|
-
```bash
|
|
233
|
-
curl -X POST https://connector.gemini.nullnet.app/upload \
|
|
234
|
-
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
|
235
|
-
-H "Content-Type: image/jpeg" \
|
|
236
|
-
--data-binary @photo.jpg
|
|
237
|
-
# → {"file_uri":"files/abc123","mime_type":"image/jpeg","expires":"...","...":"..."}
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
Then pass `"images_file_uris": ["files/abc123"]` to any image tool. The body is streamed
|
|
241
|
-
straight to the Files API (nothing is buffered), so `Content-Length` is required — curl sets it
|
|
242
|
-
automatically with `--data-binary @file`. Accepted content types are `image/*`, `video/*` and
|
|
243
|
-
`audio/*`; an optional `?name=` or `X-Filename` header sets the display name.
|
|
244
|
-
|
|
245
|
-
### Signed upload URLs — no token at all (hosted connector)
|
|
219
|
+
### Signed upload URLs — no token at all (hosted)
|
|
246
220
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
221
|
+
This is the intended path for an agent with a shell: disk file → curl → `r2_key` → tool call,
|
|
222
|
+
with the image never entering the conversation. `gemini_get_upload_url` mints a short-lived
|
|
223
|
+
(~10 min) signed **PUT** URL, and the shell uploads with zero auth headers — the signature in
|
|
224
|
+
the URL is the authorization, mirroring how the download links work.
|
|
251
225
|
|
|
252
226
|
```bash
|
|
253
227
|
# 1. tool call: gemini_get_upload_url { filename: "photo.jpg", content_type: "image/jpeg" }
|
|
@@ -261,13 +235,13 @@ curl -sS -X PUT -H "Content-Type: image/jpeg" --data-binary @photo.jpg "$UPLOAD_
|
|
|
261
235
|
The signature covers one tenant-scoped object key, the declared content type and the expiry;
|
|
262
236
|
uploads are capped at 15MB, enforced while reading the stream. Only **raster** image types are
|
|
263
237
|
accepted (jpeg/png/webp/gif/avif/heic/heif/bmp/tiff) — SVG is deliberately refused, because an
|
|
264
|
-
SVG is a scriptable document and `/media` serves from the
|
|
238
|
+
SVG is a scriptable document and `/media` serves from the server's own origin. The returned
|
|
265
239
|
`r2_key` is then usable three ways: directly as `images_r2_keys` on any generation tool (the
|
|
266
240
|
server reads its own bucket — no bytes in the conversation), permanently via
|
|
267
241
|
`gemini_save_character`, or as a ~48h Files API reference via `gemini_upload_file({ r2_key })`.
|
|
268
242
|
Uploads themselves follow the media retention schedule (`up/` prefix, default 7 days).
|
|
269
243
|
|
|
270
|
-
### Character & style library (hosted
|
|
244
|
+
### Character & style library (hosted)
|
|
271
245
|
|
|
272
246
|
Recurring subjects and styles can be saved once, per account, with **no expiry** (the retention
|
|
273
247
|
cron deliberately skips the library's `lib/` prefix):
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media, uploads and the character library, backed by mcp-host's shared blob
|
|
3
|
+
* store instead of a bucket binding of our own.
|
|
4
|
+
*
|
|
5
|
+
* This is what replaced the retired Cloudflare Worker connector. That Worker
|
|
6
|
+
* owned an R2 bucket and served `/media`, `/put` and `/upload` itself; hosted
|
|
7
|
+
* on mcp-host there is no Worker and no binding, but the platform offers the
|
|
8
|
+
* same shape over signed HTTP:
|
|
9
|
+
*
|
|
10
|
+
* PUT <base>/<key>?exp=&sig=
|
|
11
|
+
* GET <base>/<key>?exp=&sig=
|
|
12
|
+
* DELETE <base>/<key>?exp=&sig=
|
|
13
|
+
* GET <base>/?prefix=&cursor=&limit=&exp=&sig=
|
|
14
|
+
*
|
|
15
|
+
* where `<base>` is `MCP_BLOB_BASE_URL` (`https://host/b/<registrationId>`) and
|
|
16
|
+
* every signature is HMAC-SHA256 under `MCP_BLOB_SIGNING_KEY` — this
|
|
17
|
+
* registration's own derived key, which can only ever produce signatures valid
|
|
18
|
+
* under its own prefix.
|
|
19
|
+
*
|
|
20
|
+
* The existing seams do the rest: `createR2Sink` and `createR2Library` both
|
|
21
|
+
* take a STRUCTURAL bucket, so the adapter below is all that was needed —
|
|
22
|
+
* neither of them learns that storage moved.
|
|
23
|
+
*
|
|
24
|
+
* ## The signature covers the FULL key, not ours
|
|
25
|
+
*
|
|
26
|
+
* The gateway signs `<registrationId>/<key>`; we only ever think in `<key>`.
|
|
27
|
+
* The prefix is recovered from the base URL's own path (everything after
|
|
28
|
+
* `/b/`), so there is exactly one place that knows the difference and the rest
|
|
29
|
+
* of this repo keeps using relative keys.
|
|
30
|
+
*
|
|
31
|
+
* ## The four payload shapes must match the gateway byte for byte
|
|
32
|
+
*
|
|
33
|
+
* They also happen to be the shapes this MCP's own connector used, which is why
|
|
34
|
+
* media links minted before the migration still resolve. Do not "tidy" them.
|
|
35
|
+
*/
|
|
36
|
+
const READ = (key, exp) => `${key}\n${exp}`;
|
|
37
|
+
const WRITE = (key, ct, exp) => `put\0${key}\0${ct}\0${exp}`;
|
|
38
|
+
/**
|
|
39
|
+
* A write the host's retention prune must never reclaim. `lib/` only: a saved
|
|
40
|
+
* character or style outlives every signed URL and every upload, which is why
|
|
41
|
+
* saving COPIES the image bytes rather than pointing at `gen/`. The retired
|
|
42
|
+
* connector's own cleanup skipped this prefix for the same reason.
|
|
43
|
+
*/
|
|
44
|
+
const WRITE_PERMANENT = (key, ct, exp) => `putp\0${key}\0${ct}\0${exp}`;
|
|
45
|
+
const DELETE = (key, exp) => `del\0${key}\0${exp}`;
|
|
46
|
+
const LIST = (prefix, exp) => `list\0${prefix}\0${exp}`;
|
|
47
|
+
/** The library's key prefix — the one thing written to outlive retention. */
|
|
48
|
+
const PERMANENT_PREFIX = 'lib';
|
|
49
|
+
/** Default life of a signed link. The gateway refuses anything over 24h. */
|
|
50
|
+
const DEFAULT_TTL_MS = 60 * 60 * 1000;
|
|
51
|
+
function base64url(bytes) {
|
|
52
|
+
return Buffer.from(bytes).toString('base64url');
|
|
53
|
+
}
|
|
54
|
+
async function hmac(secret, payload) {
|
|
55
|
+
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
56
|
+
return base64url(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload)));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The key-space prefix out of `https://host/b/<registrationId>` — what the
|
|
60
|
+
* gateway folds into every signature. Whatever follows `/b/`, so this does not
|
|
61
|
+
* care how many segments the host uses.
|
|
62
|
+
*/
|
|
63
|
+
export function prefixFromBaseUrl(baseUrl) {
|
|
64
|
+
const path = new URL(baseUrl).pathname.replace(/^\/+|\/+$/g, '');
|
|
65
|
+
const marker = path.startsWith('b/') ? path.slice(2) : path;
|
|
66
|
+
return marker;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* One object store over the signed HTTP API, satisfying BOTH `MediaBucket`
|
|
70
|
+
* (put/get) and `LibraryBucket` (put/get/delete/list). They are structurally
|
|
71
|
+
* compatible, so one adapter serves both rather than two that can drift.
|
|
72
|
+
*/
|
|
73
|
+
export function createBlobBucket(opts) {
|
|
74
|
+
const base = opts.baseUrl.replace(/\/+$/, '');
|
|
75
|
+
const prefix = prefixFromBaseUrl(base);
|
|
76
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
77
|
+
const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
78
|
+
const now = opts.now ?? (() => Date.now());
|
|
79
|
+
const full = (key) => `${prefix}/${key}`;
|
|
80
|
+
const encode = (key) => key.split('/').map(encodeURIComponent).join('/');
|
|
81
|
+
async function signedUrl(key, payload, exp) {
|
|
82
|
+
const sig = await hmac(opts.signingKey, payload);
|
|
83
|
+
return `${base}/${encode(key)}?exp=${exp}&sig=${encodeURIComponent(sig)}`;
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
/** Exposed so the media sink can mint read links with the same key. */
|
|
87
|
+
async signRead(key, expiresAtMs) {
|
|
88
|
+
return hmac(opts.signingKey, READ(full(key), expiresAtMs));
|
|
89
|
+
},
|
|
90
|
+
async signWrite(key, contentType, expiresAtMs) {
|
|
91
|
+
return hmac(opts.signingKey, WRITE(full(key), contentType, expiresAtMs));
|
|
92
|
+
},
|
|
93
|
+
async put(key, value, options) {
|
|
94
|
+
const contentType = options?.httpMetadata?.contentType ?? 'application/octet-stream';
|
|
95
|
+
const exp = now() + ttl;
|
|
96
|
+
// The library is permanent; generated media and uploads are not. The
|
|
97
|
+
// prefix IS the distinction, and it is ours to know — the host has no
|
|
98
|
+
// idea what `lib/` means, it only enforces what was signed.
|
|
99
|
+
const permanent = key.startsWith(`${PERMANENT_PREFIX}/`);
|
|
100
|
+
const payload = permanent
|
|
101
|
+
? WRITE_PERMANENT(full(key), contentType, exp)
|
|
102
|
+
: WRITE(full(key), contentType, exp);
|
|
103
|
+
const url = (await signedUrl(key, payload, exp)) + (permanent ? '&retain=permanent' : '');
|
|
104
|
+
const body = typeof value === 'string' ? value : toArrayBuffer(value);
|
|
105
|
+
const res = await doFetch(url, { method: 'PUT', headers: { 'content-type': contentType }, body });
|
|
106
|
+
if (!res.ok)
|
|
107
|
+
throw new Error(`blob put failed (${res.status}) for ${key}`);
|
|
108
|
+
return undefined;
|
|
109
|
+
},
|
|
110
|
+
async get(key) {
|
|
111
|
+
const exp = now() + ttl;
|
|
112
|
+
const url = await signedUrl(key, READ(full(key), exp), exp);
|
|
113
|
+
const res = await doFetch(url);
|
|
114
|
+
// A missing object is `null`, not a throw: both callers treat absence as
|
|
115
|
+
// a normal outcome (a swept media object, an unknown character name).
|
|
116
|
+
if (res.status === 404)
|
|
117
|
+
return null;
|
|
118
|
+
if (!res.ok)
|
|
119
|
+
throw new Error(`blob get failed (${res.status}) for ${key}`);
|
|
120
|
+
const buf = await res.arrayBuffer();
|
|
121
|
+
return {
|
|
122
|
+
arrayBuffer: async () => buf,
|
|
123
|
+
httpMetadata: { contentType: res.headers.get('content-type') ?? undefined },
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
async delete(keys) {
|
|
127
|
+
for (const key of Array.isArray(keys) ? keys : [keys]) {
|
|
128
|
+
const exp = now() + ttl;
|
|
129
|
+
const url = await signedUrl(key, DELETE(full(key), exp), exp);
|
|
130
|
+
const res = await doFetch(url, { method: 'DELETE' });
|
|
131
|
+
// The gateway's delete is idempotent, so only a real failure throws.
|
|
132
|
+
if (!res.ok && res.status !== 404) {
|
|
133
|
+
throw new Error(`blob delete failed (${res.status}) for ${key}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
async list(options) {
|
|
138
|
+
const rel = options.prefix ?? '';
|
|
139
|
+
const exp = now() + ttl;
|
|
140
|
+
const sig = await hmac(opts.signingKey, LIST(rel, exp));
|
|
141
|
+
const params = new URLSearchParams({ exp: String(exp), sig });
|
|
142
|
+
if (rel)
|
|
143
|
+
params.set('prefix', rel);
|
|
144
|
+
if (options.cursor)
|
|
145
|
+
params.set('cursor', options.cursor);
|
|
146
|
+
if (options.limit)
|
|
147
|
+
params.set('limit', String(options.limit));
|
|
148
|
+
const res = await doFetch(`${base}/?${params}`);
|
|
149
|
+
if (!res.ok)
|
|
150
|
+
throw new Error(`blob list failed (${res.status})`);
|
|
151
|
+
// Keys come back relative to this registration's space, which is exactly
|
|
152
|
+
// the key space the library already thinks in.
|
|
153
|
+
return (await res.json());
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/** A standalone ArrayBuffer copy — `fetch` bodies will not take a view. */
|
|
158
|
+
function toArrayBuffer(value) {
|
|
159
|
+
if (value instanceof ArrayBuffer)
|
|
160
|
+
return value;
|
|
161
|
+
const view = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
162
|
+
return view.slice().buffer;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Synchronous on purpose: `client.ts` calls this while building its
|
|
166
|
+
* module-level singleton, and a top-level `await` there would put one in every
|
|
167
|
+
* consumer's module graph for something that is pure string work.
|
|
168
|
+
*/
|
|
169
|
+
export function blobStoreFromEnv(env = process.env, overrides = {}) {
|
|
170
|
+
const baseUrl = overrides.baseUrl ?? env.MCP_BLOB_BASE_URL;
|
|
171
|
+
const signingKey = overrides.signingKey ?? env.MCP_BLOB_SIGNING_KEY;
|
|
172
|
+
if (!baseUrl || !signingKey)
|
|
173
|
+
return undefined;
|
|
174
|
+
const bucket = createBlobBucket({ ...overrides, baseUrl, signingKey });
|
|
175
|
+
return { bucket, baseUrl, signRead: bucket.signRead, signWrite: bucket.signWrite };
|
|
176
|
+
}
|