@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
|
@@ -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.3.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.3.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
package/README.md
CHANGED
|
@@ -87,10 +87,71 @@ progress), two guards make re-issuing safe and unnecessary:
|
|
|
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
89
|
|
|
90
|
+
## Seeing your images (hosted connector)
|
|
91
|
+
|
|
92
|
+
On the hosted connector there is no filesystem, so a generated image has to come back as
|
|
93
|
+
something you can *open*. It does: **every result includes a URL**, with no configuration.
|
|
94
|
+
|
|
95
|
+
```jsonc
|
|
96
|
+
{
|
|
97
|
+
"images": ["https://connector.example.com/media/gen/2026-07-29/ab12cd34-a-cat.png?exp=…&sig=…"],
|
|
98
|
+
"media": [{ "url": "https://…", "r2_key": "gen/2026-07-29/ab12cd34-a-cat.png",
|
|
99
|
+
"expires_at": "2026-07-31T12:00:00.000Z" }]
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Those links need no auth header — the signature is in the URL — so they work in a browser, in
|
|
104
|
+
`curl`, and in a chat message. They expire (48h by default) and the objects behind them are
|
|
105
|
+
swept on a retention schedule.
|
|
106
|
+
|
|
107
|
+
**Why this matters:** MCP's inline image content blocks (`inline: true`) are visible to the
|
|
108
|
+
*assistant* but many chat clients never render them to the user, and the assistant cannot
|
|
109
|
+
extract bytes back out of its own context to save them elsewhere. A generation could bill
|
|
110
|
+
successfully and be invisible. A URL is the portable answer; `inline` remains available, but
|
|
111
|
+
it is no longer the only way to receive media.
|
|
112
|
+
|
|
113
|
+
### Three ways to serve the bytes
|
|
114
|
+
|
|
115
|
+
| | setup | URL shape |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| **Worker route** (default) | none — works out of the box | `https://<worker-host>/media/<key>?exp=&sig=` |
|
|
118
|
+
| **r2.dev public bucket** | enable public access on the bucket, set `MEDIA_PUBLIC_BASE_URL` | `https://pub-….r2.dev/<key>` |
|
|
119
|
+
| **Custom domain** (best) | attach a domain to the bucket in Cloudflare, set `MEDIA_PUBLIC_BASE_URL` | `https://media.example.com/<key>` |
|
|
120
|
+
|
|
121
|
+
The built-in Worker route is the zero-config path: the connector streams the object out of R2
|
|
122
|
+
itself. The other two are worth setting up if you want plain, non-expiring URLs or want media
|
|
123
|
+
traffic served by R2 rather than by the Worker — r2.dev is easiest but rate-limited and fine
|
|
124
|
+
for personal use; a custom domain is the best of the three.
|
|
125
|
+
|
|
126
|
+
**Auth on `/media` is a signed, expiring URL rather than an unlisted key.** Random keys would
|
|
127
|
+
be simpler, but they never expire and never revoke: anything that ever logged or forwarded the
|
|
128
|
+
link keeps working forever. A signature scopes access to one object with a deadline, and
|
|
129
|
+
rotating `MEDIA_URL_SECRET` invalidates every outstanding link at once. The tradeoff is that
|
|
130
|
+
links are long and cannot be shortened by hand.
|
|
131
|
+
|
|
132
|
+
### For assistants relaying a result
|
|
133
|
+
|
|
134
|
+
Show the user the URL. If your sandbox has network egress to the connector host, fetching it
|
|
135
|
+
and attaching the bytes as a file gives the nicest result; otherwise present the link itself.
|
|
136
|
+
Whether a given client renders `` markdown inline varies by client — a bare URL is the
|
|
137
|
+
safe form, and a markdown link is a reasonable enhancement where you know it renders.
|
|
138
|
+
|
|
139
|
+
### Retention
|
|
140
|
+
|
|
141
|
+
| Variable | Default | Effect |
|
|
142
|
+
|---|---|---|
|
|
143
|
+
| `MEDIA_TTL_DAYS` | `7` | Objects older than this are deleted by a daily cron (both the current `gen/` prefix and the legacy `media/` one) |
|
|
144
|
+
| `MEDIA_URL_SECRET` | generated | HMAC key for `/media` links; rotate to revoke all outstanding URLs |
|
|
145
|
+
| `MEDIA_PUBLIC_BASE_URL` | unset | Serve from R2 directly instead of the Worker route |
|
|
146
|
+
|
|
147
|
+
Signed-URL lifetime is clamped to `MEDIA_TTL_DAYS`, so a link never outlives the object it
|
|
148
|
+
points at.
|
|
149
|
+
|
|
90
150
|
## Sending reference images without burning context
|
|
91
151
|
|
|
92
|
-
Every image
|
|
93
|
-
`gemini_interact`
|
|
152
|
+
Every tool that takes a reference image — `gemini_image_generate`, `gemini_image_edit`,
|
|
153
|
+
`gemini_image_set`, `gemini_interact`, plus `gemini_video_generate` (reference stills) and
|
|
154
|
+
`gemini_music_generate` — accepts them four ways. Only one of them costs model context:
|
|
94
155
|
|
|
95
156
|
| Parameter | Where the bytes travel | Context cost |
|
|
96
157
|
|---|---|---|
|
package/dist/bundle.js
CHANGED
|
@@ -32548,73 +32548,11 @@ var init_dist = __esm({
|
|
|
32548
32548
|
}
|
|
32549
32549
|
});
|
|
32550
32550
|
|
|
32551
|
-
// src/clipboard.ts
|
|
32552
|
-
var clipboard_exports = {};
|
|
32553
|
-
__export(clipboard_exports, {
|
|
32554
|
-
readClipboardImage: () => readClipboardImage
|
|
32555
|
-
});
|
|
32556
|
-
import { execFile } from "node:child_process";
|
|
32557
|
-
import { promisify } from "node:util";
|
|
32558
|
-
import { readFile, unlink } from "node:fs/promises";
|
|
32559
|
-
import { tmpdir } from "node:os";
|
|
32560
|
-
import { join as join2 } from "node:path";
|
|
32561
|
-
async function readClipboardImage(run = defaultRun) {
|
|
32562
|
-
if (process.platform !== "darwin") {
|
|
32563
|
-
throw new McpToolError("from_clipboard is only supported on macOS", {
|
|
32564
|
-
hint: "Save the image to a file and pass it via `images` instead."
|
|
32565
|
-
});
|
|
32566
|
-
}
|
|
32567
|
-
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
32568
|
-
const png = join2(tmpdir(), `gemini-clip-${id}.png`);
|
|
32569
|
-
const jpg = join2(tmpdir(), `gemini-clip-${id}.jpg`);
|
|
32570
|
-
try {
|
|
32571
|
-
try {
|
|
32572
|
-
await run("osascript", [
|
|
32573
|
-
"-e",
|
|
32574
|
-
`set f to (open for access POSIX file "${png}" with write permission)`,
|
|
32575
|
-
"-e",
|
|
32576
|
-
"write (the clipboard as \xABclass PNGf\xBB) to f",
|
|
32577
|
-
"-e",
|
|
32578
|
-
"close access f"
|
|
32579
|
-
]);
|
|
32580
|
-
} catch {
|
|
32581
|
-
throw new McpToolError("No image found on the clipboard", {
|
|
32582
|
-
hint: "Copy an image (\u2318C) first, or pass `images`/`images_base64`."
|
|
32583
|
-
});
|
|
32584
|
-
}
|
|
32585
|
-
try {
|
|
32586
|
-
await run("sips", ["-Z", "2048", "-s", "format", "jpeg", png, "--out", jpg]);
|
|
32587
|
-
} catch {
|
|
32588
|
-
throw new McpToolError("Failed to convert the clipboard image", {
|
|
32589
|
-
hint: "The clipboard may contain a non-standard image format."
|
|
32590
|
-
});
|
|
32591
|
-
}
|
|
32592
|
-
const buf = await readFile(jpg);
|
|
32593
|
-
return { base64: buf.toString("base64"), mimeType: "image/jpeg" };
|
|
32594
|
-
} finally {
|
|
32595
|
-
await unlink(png).catch(() => void 0);
|
|
32596
|
-
await unlink(jpg).catch(() => void 0);
|
|
32597
|
-
}
|
|
32598
|
-
}
|
|
32599
|
-
var execFileAsync, defaultRun;
|
|
32600
|
-
var init_clipboard = __esm({
|
|
32601
|
-
"src/clipboard.ts"() {
|
|
32602
|
-
"use strict";
|
|
32603
|
-
init_dist();
|
|
32604
|
-
execFileAsync = promisify(execFile);
|
|
32605
|
-
defaultRun = async (cmd, args) => {
|
|
32606
|
-
await execFileAsync(cmd, args);
|
|
32607
|
-
};
|
|
32608
|
-
}
|
|
32609
|
-
});
|
|
32610
|
-
|
|
32611
32551
|
// src/images.ts
|
|
32612
32552
|
var images_exports = {};
|
|
32613
32553
|
__export(images_exports, {
|
|
32614
32554
|
baseName: () => baseName,
|
|
32615
32555
|
decodeImageInput: () => decodeImageInput,
|
|
32616
|
-
gatherImageInputs: () => gatherImageInputs,
|
|
32617
|
-
loadImageInputs: () => loadImageInputs,
|
|
32618
32556
|
mediaExt: () => mediaExt,
|
|
32619
32557
|
previewImageInput: () => previewImageInput,
|
|
32620
32558
|
previewVideoInput: () => previewVideoInput,
|
|
@@ -32629,9 +32567,9 @@ __export(images_exports, {
|
|
|
32629
32567
|
writeMedia: () => writeMedia,
|
|
32630
32568
|
writeSidecar: () => writeSidecar
|
|
32631
32569
|
});
|
|
32632
|
-
import { mkdir, readFile
|
|
32570
|
+
import { mkdir, readFile, writeFile, access, stat, open } from "node:fs/promises";
|
|
32633
32571
|
import { existsSync } from "node:fs";
|
|
32634
|
-
import { join as
|
|
32572
|
+
import { join as join2, resolve, isAbsolute } from "node:path";
|
|
32635
32573
|
function slugify2(text, max = 40) {
|
|
32636
32574
|
const s = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/g, "");
|
|
32637
32575
|
return s || "image";
|
|
@@ -32645,10 +32583,10 @@ async function exists(p) {
|
|
|
32645
32583
|
}
|
|
32646
32584
|
}
|
|
32647
32585
|
async function uniquePath(dir, base, ext) {
|
|
32648
|
-
let candidate =
|
|
32586
|
+
let candidate = join2(dir, `${base}.${ext}`);
|
|
32649
32587
|
let n = 2;
|
|
32650
32588
|
while (await exists(candidate)) {
|
|
32651
|
-
candidate =
|
|
32589
|
+
candidate = join2(dir, `${base}-${n}.${ext}`);
|
|
32652
32590
|
n++;
|
|
32653
32591
|
}
|
|
32654
32592
|
return candidate;
|
|
@@ -32694,7 +32632,7 @@ function resolveInputPath(p, kind) {
|
|
|
32694
32632
|
if (isAbsolute(p) && existsSync(p)) return p;
|
|
32695
32633
|
const inputDir = readEnvVar("GEMINI_INPUT_DIR");
|
|
32696
32634
|
if (inputDir) {
|
|
32697
|
-
const candidate =
|
|
32635
|
+
const candidate = join2(inputDir, p);
|
|
32698
32636
|
if (existsSync(candidate)) return resolve(candidate);
|
|
32699
32637
|
}
|
|
32700
32638
|
const cwd = resolve(p);
|
|
@@ -32740,7 +32678,7 @@ async function previewVideoInput(path) {
|
|
|
32740
32678
|
}
|
|
32741
32679
|
async function readImageAsInline(path) {
|
|
32742
32680
|
const resolved = resolveImagePath(path);
|
|
32743
|
-
const buf = await
|
|
32681
|
+
const buf = await readFile(resolved);
|
|
32744
32682
|
const mimeType = sniffMimeBytes(buf);
|
|
32745
32683
|
return { base64: buf.toString("base64"), mimeType };
|
|
32746
32684
|
}
|
|
@@ -32757,16 +32695,6 @@ function decodeImageInput(input) {
|
|
|
32757
32695
|
const buf = Buffer.from(trimmed, "base64");
|
|
32758
32696
|
return { base64: trimmed, mimeType: sniffMimeBytes(buf) };
|
|
32759
32697
|
}
|
|
32760
|
-
async function loadImageInputs(paths = [], base64s = []) {
|
|
32761
|
-
const fromPaths = await Promise.all(paths.map((p) => readImageAsInline(p)));
|
|
32762
|
-
const fromBase64s = base64s.map((b) => decodeImageInput(b));
|
|
32763
|
-
return [...fromPaths, ...fromBase64s];
|
|
32764
|
-
}
|
|
32765
|
-
async function gatherImageInputs(opts) {
|
|
32766
|
-
const clipboardImages = opts.from_clipboard ? [await (await Promise.resolve().then(() => (init_clipboard(), clipboard_exports))).readClipboardImage()] : [];
|
|
32767
|
-
const fileImages = await loadImageInputs(opts.images, opts.images_base64);
|
|
32768
|
-
return [...clipboardImages, ...fileImages];
|
|
32769
|
-
}
|
|
32770
32698
|
function baseName(name) {
|
|
32771
32699
|
const stripped = name.replace(/\.(png|jpe?g|webp|mp4|mov|webm|mp3|wav|ogg)$/i, "");
|
|
32772
32700
|
const safe = stripped.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
@@ -32795,6 +32723,66 @@ var init_images = __esm({
|
|
|
32795
32723
|
}
|
|
32796
32724
|
});
|
|
32797
32725
|
|
|
32726
|
+
// src/clipboard.ts
|
|
32727
|
+
var clipboard_exports = {};
|
|
32728
|
+
__export(clipboard_exports, {
|
|
32729
|
+
readClipboardImage: () => readClipboardImage
|
|
32730
|
+
});
|
|
32731
|
+
import { execFile } from "node:child_process";
|
|
32732
|
+
import { promisify } from "node:util";
|
|
32733
|
+
import { readFile as readFile2, unlink } from "node:fs/promises";
|
|
32734
|
+
import { tmpdir } from "node:os";
|
|
32735
|
+
import { join as join3 } from "node:path";
|
|
32736
|
+
async function readClipboardImage(run = defaultRun) {
|
|
32737
|
+
if (process.platform !== "darwin") {
|
|
32738
|
+
throw new McpToolError("from_clipboard is only supported on macOS", {
|
|
32739
|
+
hint: "Save the image to a file and pass it via `images` instead."
|
|
32740
|
+
});
|
|
32741
|
+
}
|
|
32742
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
32743
|
+
const png = join3(tmpdir(), `gemini-clip-${id}.png`);
|
|
32744
|
+
const jpg = join3(tmpdir(), `gemini-clip-${id}.jpg`);
|
|
32745
|
+
try {
|
|
32746
|
+
try {
|
|
32747
|
+
await run("osascript", [
|
|
32748
|
+
"-e",
|
|
32749
|
+
`set f to (open for access POSIX file "${png}" with write permission)`,
|
|
32750
|
+
"-e",
|
|
32751
|
+
"write (the clipboard as \xABclass PNGf\xBB) to f",
|
|
32752
|
+
"-e",
|
|
32753
|
+
"close access f"
|
|
32754
|
+
]);
|
|
32755
|
+
} catch {
|
|
32756
|
+
throw new McpToolError("No image found on the clipboard", {
|
|
32757
|
+
hint: "Copy an image (\u2318C) first, or pass `images`/`images_base64`."
|
|
32758
|
+
});
|
|
32759
|
+
}
|
|
32760
|
+
try {
|
|
32761
|
+
await run("sips", ["-Z", "2048", "-s", "format", "jpeg", png, "--out", jpg]);
|
|
32762
|
+
} catch {
|
|
32763
|
+
throw new McpToolError("Failed to convert the clipboard image", {
|
|
32764
|
+
hint: "The clipboard may contain a non-standard image format."
|
|
32765
|
+
});
|
|
32766
|
+
}
|
|
32767
|
+
const buf = await readFile2(jpg);
|
|
32768
|
+
return { base64: buf.toString("base64"), mimeType: "image/jpeg" };
|
|
32769
|
+
} finally {
|
|
32770
|
+
await unlink(png).catch(() => void 0);
|
|
32771
|
+
await unlink(jpg).catch(() => void 0);
|
|
32772
|
+
}
|
|
32773
|
+
}
|
|
32774
|
+
var execFileAsync, defaultRun;
|
|
32775
|
+
var init_clipboard = __esm({
|
|
32776
|
+
"src/clipboard.ts"() {
|
|
32777
|
+
"use strict";
|
|
32778
|
+
init_dist();
|
|
32779
|
+
execFileAsync = promisify(execFile);
|
|
32780
|
+
defaultRun = async (cmd, args) => {
|
|
32781
|
+
await execFileAsync(cmd, args);
|
|
32782
|
+
};
|
|
32783
|
+
}
|
|
32784
|
+
});
|
|
32785
|
+
|
|
32798
32786
|
// src/index.ts
|
|
32799
32787
|
init_dist();
|
|
32800
32788
|
|
|
@@ -32808,7 +32796,7 @@ async function loadStdioDotenv() {
|
|
|
32808
32796
|
}
|
|
32809
32797
|
|
|
32810
32798
|
// src/version.ts
|
|
32811
|
-
var VERSION = "1.
|
|
32799
|
+
var VERSION = "1.3.0";
|
|
32812
32800
|
|
|
32813
32801
|
// src/tools/models.ts
|
|
32814
32802
|
init_dist();
|
|
@@ -32853,6 +32841,30 @@ init_dist();
|
|
|
32853
32841
|
|
|
32854
32842
|
// src/fetch-image.ts
|
|
32855
32843
|
init_dist();
|
|
32844
|
+
|
|
32845
|
+
// src/bytes.ts
|
|
32846
|
+
function base64ToBytes(base643) {
|
|
32847
|
+
const binary = atob(base643);
|
|
32848
|
+
const bytes = new Uint8Array(binary.length);
|
|
32849
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
32850
|
+
return bytes;
|
|
32851
|
+
}
|
|
32852
|
+
function bytesToBase64(bytes) {
|
|
32853
|
+
const CHUNK = 32768;
|
|
32854
|
+
let binary = "";
|
|
32855
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
32856
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
32857
|
+
}
|
|
32858
|
+
return btoa(binary);
|
|
32859
|
+
}
|
|
32860
|
+
function wholeMb(bytes) {
|
|
32861
|
+
return Math.round(bytes / (1024 * 1024));
|
|
32862
|
+
}
|
|
32863
|
+
function formatMb(bytes) {
|
|
32864
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
32865
|
+
}
|
|
32866
|
+
|
|
32867
|
+
// src/fetch-image.ts
|
|
32856
32868
|
var IMAGE_URL_MAX_BYTES = 15 * 1024 * 1024;
|
|
32857
32869
|
var IMAGE_INLINE_MAX_BYTES = 6 * 1024 * 1024;
|
|
32858
32870
|
var MAX_REDIRECTS = 5;
|
|
@@ -32955,6 +32967,14 @@ function assertPublicHttpsUrl(raw, requested) {
|
|
|
32955
32967
|
}
|
|
32956
32968
|
return url2;
|
|
32957
32969
|
}
|
|
32970
|
+
function fileNameFromUrl(url2, fallback) {
|
|
32971
|
+
try {
|
|
32972
|
+
const name = new URL(url2).pathname.split("/").filter(Boolean).pop();
|
|
32973
|
+
return name ? decodeURIComponent(name).slice(0, 120) : fallback;
|
|
32974
|
+
} catch {
|
|
32975
|
+
return fallback;
|
|
32976
|
+
}
|
|
32977
|
+
}
|
|
32958
32978
|
function describeUrl(current, requested) {
|
|
32959
32979
|
return current === requested ? current : `${current} (redirected from ${requested})`;
|
|
32960
32980
|
}
|
|
@@ -33008,9 +33028,8 @@ function fetchFailure(err, current, requested, timeoutMs) {
|
|
|
33008
33028
|
);
|
|
33009
33029
|
}
|
|
33010
33030
|
function tooBig(url2, requested, size, max, partial2 = false) {
|
|
33011
|
-
const mb2 = (n) => `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
33012
33031
|
return new McpToolError(
|
|
33013
|
-
`Image at ${describeUrl(url2, requested)} is ${partial2 ? "over" : ""} ${
|
|
33032
|
+
`Image at ${describeUrl(url2, requested)} is ${partial2 ? "over" : ""} ${formatMb(size)}, above the ${formatMb(max)} limit for images_url.`,
|
|
33014
33033
|
{ hint: "Resize or re-encode the image, or upload it yourself with gemini_upload_file and pass the returned file_uri." }
|
|
33015
33034
|
);
|
|
33016
33035
|
}
|
|
@@ -33143,7 +33162,7 @@ async function resolveLocalPath(path, client2, onDisk, report) {
|
|
|
33143
33162
|
const inline = await readImageAsInline2(resolved);
|
|
33144
33163
|
if (references < 2) return inline;
|
|
33145
33164
|
try {
|
|
33146
|
-
const bytes =
|
|
33165
|
+
const bytes = base64ToBytes(inline.base64);
|
|
33147
33166
|
const displayName = resolved.split(/[\\/]/).pop() || "image";
|
|
33148
33167
|
const uploaded = await client2.uploadBytes(bytes, inline.mimeType, displayName);
|
|
33149
33168
|
client2.session.uploadCache.set(key, {
|
|
@@ -33167,29 +33186,13 @@ async function resolveUrl(url2, client2, report) {
|
|
|
33167
33186
|
};
|
|
33168
33187
|
(report.fetched_urls ??= []).push(entry);
|
|
33169
33188
|
if (fetched.size <= IMAGE_INLINE_MAX_BYTES) {
|
|
33170
|
-
return { base64:
|
|
33189
|
+
return { base64: bytesToBase64(fetched.bytes), mimeType: fetched.mimeType };
|
|
33171
33190
|
}
|
|
33172
|
-
const displayName = fileNameFromUrl(url2);
|
|
33191
|
+
const displayName = fileNameFromUrl(url2, "image");
|
|
33173
33192
|
const uploaded = await client2.uploadBytes(fetched.bytes, fetched.mimeType, displayName);
|
|
33174
33193
|
entry.file_uri = uploaded.name;
|
|
33175
33194
|
return { uri: uploaded.uri, mimeType: uploaded.mimeType };
|
|
33176
33195
|
}
|
|
33177
|
-
function fileNameFromUrl(url2) {
|
|
33178
|
-
try {
|
|
33179
|
-
const name = new URL(url2).pathname.split("/").filter(Boolean).pop();
|
|
33180
|
-
return name ? decodeURIComponent(name).slice(0, 120) : "image";
|
|
33181
|
-
} catch {
|
|
33182
|
-
return "image";
|
|
33183
|
-
}
|
|
33184
|
-
}
|
|
33185
|
-
function encodeBase64(bytes) {
|
|
33186
|
-
let binary = "";
|
|
33187
|
-
const CHUNK = 32768;
|
|
33188
|
-
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
33189
|
-
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
33190
|
-
}
|
|
33191
|
-
return btoa(binary);
|
|
33192
|
-
}
|
|
33193
33196
|
function requireImageInput(args) {
|
|
33194
33197
|
if (hasImageInput(args)) return;
|
|
33195
33198
|
throw new McpToolError(
|
|
@@ -33211,7 +33214,7 @@ function createDiskSink() {
|
|
|
33211
33214
|
const { writeMedia: writeMedia2, resolveOutputDir: resolveOutputDir2 } = await Promise.resolve().then(() => (init_images(), images_exports));
|
|
33212
33215
|
const dir = resolveOutputDir2(opts.output_dir);
|
|
33213
33216
|
const refs = [];
|
|
33214
|
-
for (const it of items) refs.push(await writeMedia2(dir, it.base, it.base64, it.mimeType));
|
|
33217
|
+
for (const it of items) refs.push({ ref: await writeMedia2(dir, it.base, it.base64, it.mimeType) });
|
|
33215
33218
|
return refs;
|
|
33216
33219
|
},
|
|
33217
33220
|
note: () => void 0
|
|
@@ -33234,7 +33237,7 @@ var asyncSchema = external_exports.boolean().optional().describe(
|
|
|
33234
33237
|
);
|
|
33235
33238
|
function imagesUrlSchema(label = "Reference images") {
|
|
33236
33239
|
return external_exports.array(external_exports.string().url()).optional().describe(
|
|
33237
|
-
`${label} as public https URLs \u2014 the SERVER downloads them, so no image bytes travel through the conversation. Preferred over images_base64, which costs ~14k tokens per photo and breaks if a file read was truncated. Max ${
|
|
33240
|
+
`${label} as public https URLs \u2014 the SERVER downloads them, so no image bytes travel through the conversation. Preferred over images_base64, which costs ~14k tokens per photo and breaks if a file read was truncated. Max ${wholeMb(IMAGE_URL_MAX_BYTES)}MB each; must be a directly-linked image (Content-Type image/*).`
|
|
33238
33241
|
);
|
|
33239
33242
|
}
|
|
33240
33243
|
function imagesFileUrisSchema(label = "Reference images") {
|
|
@@ -33343,14 +33346,26 @@ async function emitMedia(named, kind, opts, meta3, onWritten) {
|
|
|
33343
33346
|
return { content: blocks };
|
|
33344
33347
|
}
|
|
33345
33348
|
const sink = opts.sink ?? DEFAULT_SINK;
|
|
33346
|
-
const
|
|
33349
|
+
const persisted = await sink.persist(named.map((n) => ({ base: n.base, base64: n.media.base64, mimeType: n.media.mimeType })), {
|
|
33347
33350
|
output_dir: opts.output_dir
|
|
33348
33351
|
});
|
|
33352
|
+
const refs = persisted.map((p) => p.ref);
|
|
33349
33353
|
if (sink.persistsFiles) await onWritten?.(refs);
|
|
33350
33354
|
const note = opts.inline && kind === "video" ? { inline_unsupported: "video has no MCP inline content type \u2014 written to disk instead" } : {};
|
|
33351
33355
|
const storageNote = sink.note();
|
|
33356
|
+
const unavailable = persisted.some((p) => p.unavailable) ? {
|
|
33357
|
+
media_url_unavailable: "The media was stored but no public URL could be generated for it, so the values above are bare object keys, not links. This is a connector misconfiguration: set MEDIA_PUBLIC_BASE_URL, or use inline: true to receive the bytes directly."
|
|
33358
|
+
} : {};
|
|
33359
|
+
const detailed = persisted.filter((p) => p.key !== void 0);
|
|
33360
|
+
const media = detailed.length === persisted.length && detailed.length > 0 ? {
|
|
33361
|
+
media: persisted.map((p) => ({
|
|
33362
|
+
url: p.ref,
|
|
33363
|
+
...p.key ? { r2_key: p.key } : {},
|
|
33364
|
+
...p.expiresAt ? { expires_at: p.expiresAt } : {}
|
|
33365
|
+
}))
|
|
33366
|
+
} : {};
|
|
33352
33367
|
const storage = sink.persistsFiles ? {} : { storage: sink.kind, storage_note: storageNote };
|
|
33353
|
-
return textResult({ [`${kind}s`]: refs, ...note, ...storage, ...meta3 });
|
|
33368
|
+
return textResult({ [`${kind}s`]: refs, ...media, ...unavailable, ...note, ...storage, ...meta3 });
|
|
33354
33369
|
}
|
|
33355
33370
|
async function emit(named, opts, meta3, onWritten) {
|
|
33356
33371
|
return emitMedia(named.map((n) => ({ media: n.image, base: n.base })), "image", opts, meta3, onWritten);
|
|
@@ -34505,7 +34520,7 @@ function splitReattachedOutputs(images, writtenOutputs) {
|
|
|
34505
34520
|
}
|
|
34506
34521
|
function registerInteractTools(server, client2) {
|
|
34507
34522
|
const onDiskRuntime = client2.mediaSink?.persistsFiles ?? true;
|
|
34508
|
-
const recoveryDescription = onDiskRuntime ? "If a call times out on the client side, the generation usually still completes: the image plus a `<image>.json` sidecar recording its interaction id land in the output dir, and `continue_last: true` still resumes that interaction \u2014 check the output dir before re-issuing (a re-issue is a second billable generation). If a chained call 404s, this tool re-anchors itself on the prior output image and re-issues un-chained: success is reported as `chain_recovered` (the chain was the problem, and you get your image anyway); a second 404 is reported as the interaction id NOT being the cause (check the model id / files uri instead). " : "
|
|
34523
|
+
const recoveryDescription = onDiskRuntime ? "If a call times out on the client side, the generation usually still completes: the image plus a `<image>.json` sidecar recording its interaction id land in the output dir, and `continue_last: true` still resumes that interaction \u2014 check the output dir before re-issuing (a re-issue is a second billable generation). If a chained call 404s, this tool re-anchors itself on the prior output image and re-issues un-chained: success is reported as `chain_recovered` (the chain was the problem, and you get your image anyway); a second 404 is reported as the interaction id NOT being the cause (check the model id / files uri instead). " : "On this connector each result returns an image URL you can open or share directly (see `media[].url`) \u2014 show it to the user rather than assuming they can see the image. There is no filesystem, so no output dir and no `<image>.json` sidecar: a lost response cannot be recovered from disk and a 404 on a chained call cannot be re-anchored automatically. Capture the returned `interaction_id` from every result, and use `async: true` + gemini_get_result for long generations. ";
|
|
34509
34524
|
server.registerTool(
|
|
34510
34525
|
"gemini_interact",
|
|
34511
34526
|
{
|
|
@@ -34675,7 +34690,9 @@ function registerVideoTools(server, client2) {
|
|
|
34675
34690
|
aspect_ratio: external_exports.enum(VIDEO_ASPECT_RATIOS).optional().describe("Output aspect ratio (omni: 16:9 or 9:16)"),
|
|
34676
34691
|
task: external_exports.enum(VIDEO_TASKS).optional().describe("text_to_video (default), image_to_video / reference_to_video (need image input), or edit (needs previous_interaction_id)"),
|
|
34677
34692
|
images: external_exports.array(external_exports.string().min(1)).optional().describe("Reference image path(s) for image_to_video / reference_to_video"),
|
|
34678
|
-
|
|
34693
|
+
images_url: imagesUrlSchema("Reference stills"),
|
|
34694
|
+
images_file_uris: imagesFileUrisSchema("Reference stills"),
|
|
34695
|
+
images_base64: external_exports.array(external_exports.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"),
|
|
34679
34696
|
from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS clipboard as a reference"),
|
|
34680
34697
|
filename: external_exports.string().optional().describe("Base filename for the output video (extension stripped; default: slugified prompt)"),
|
|
34681
34698
|
output_dir: external_exports.string().optional().describe("Directory to write the video to (default: $GEMINI_OUTPUT_DIR or cwd)"),
|
|
@@ -34710,10 +34727,12 @@ function registerVideoTools(server, client2) {
|
|
|
34710
34727
|
images: args.images,
|
|
34711
34728
|
images_base64: args.images_base64,
|
|
34712
34729
|
from_clipboard: args.from_clipboard,
|
|
34730
|
+
images_url: args.images_url,
|
|
34731
|
+
images_file_uris: args.images_file_uris,
|
|
34713
34732
|
previous_interaction_id: previousInteractionId
|
|
34714
34733
|
});
|
|
34715
34734
|
return client2.session.jobs.dispatch({ toolName: "gemini_video_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
34716
|
-
const inputs = await
|
|
34735
|
+
const { inputs, report } = await resolveImageInputs(args, client2);
|
|
34717
34736
|
const r = await withProgressHeartbeat(extra, `Generating video (${model})`, () => client2.generateVideo({
|
|
34718
34737
|
input: args.prompt,
|
|
34719
34738
|
images: inputs.length ? inputs : void 0,
|
|
@@ -34727,6 +34746,7 @@ function registerVideoTools(server, client2) {
|
|
|
34727
34746
|
const meta3 = { model, interaction_id: r.id };
|
|
34728
34747
|
if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
|
|
34729
34748
|
if (r.text) meta3.text = r.text;
|
|
34749
|
+
if (report) meta3.image_inputs = report;
|
|
34730
34750
|
meta3.hint = `To edit this video, call gemini_video_generate again with task: "edit" and previous_interaction_id: "${r.id}" (or continue_last: true).`;
|
|
34731
34751
|
const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
|
|
34732
34752
|
const named = r.videos.map((media, i) => ({
|
|
@@ -34759,7 +34779,9 @@ function registerMusicTools(server, client2) {
|
|
|
34759
34779
|
model: external_exports.enum(MUSIC_MODELS).optional().describe(`Lyria model (default: ${DEFAULT_MUSIC_MODEL}). Pro is longer-form and supports WAV.`),
|
|
34760
34780
|
audio_format: external_exports.enum(AUDIO_FORMATS).optional().describe("Output format (default mp3). wav is lyria-3-pro-preview-only."),
|
|
34761
34781
|
images: external_exports.array(external_exports.string().min(1)).optional().describe("Optional reference image path(s) to condition the music"),
|
|
34762
|
-
|
|
34782
|
+
images_url: imagesUrlSchema("Reference images"),
|
|
34783
|
+
images_file_uris: imagesFileUrisSchema("Reference images"),
|
|
34784
|
+
images_base64: external_exports.array(external_exports.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"),
|
|
34763
34785
|
from_clipboard: external_exports.boolean().optional().describe("Use the image currently on the macOS clipboard as a reference"),
|
|
34764
34786
|
filename: external_exports.string().optional().describe("Base filename for the output audio (extension stripped; default: slugified prompt)"),
|
|
34765
34787
|
output_dir: external_exports.string().optional().describe("Directory to write audio to (default: $GEMINI_OUTPUT_DIR or cwd)"),
|
|
@@ -34798,10 +34820,12 @@ function registerMusicTools(server, client2) {
|
|
|
34798
34820
|
images: args.images,
|
|
34799
34821
|
images_base64: args.images_base64,
|
|
34800
34822
|
from_clipboard: args.from_clipboard,
|
|
34823
|
+
images_url: args.images_url,
|
|
34824
|
+
images_file_uris: args.images_file_uris,
|
|
34801
34825
|
previous_interaction_id: previousInteractionId
|
|
34802
34826
|
});
|
|
34803
34827
|
return client2.session.jobs.dispatch({ toolName: "gemini_music_generate", fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
34804
|
-
const inputs = await
|
|
34828
|
+
const { inputs, report } = await resolveImageInputs(args, client2);
|
|
34805
34829
|
const r = await withProgressHeartbeat(extra, `Generating music (${model})`, () => client2.generateMusic({
|
|
34806
34830
|
input: args.prompt,
|
|
34807
34831
|
images: inputs.length ? inputs : void 0,
|
|
@@ -34814,6 +34838,7 @@ function registerMusicTools(server, client2) {
|
|
|
34814
34838
|
const meta3 = { model, interaction_id: r.id };
|
|
34815
34839
|
if (previousInteractionId) meta3.previous_interaction_id = previousInteractionId;
|
|
34816
34840
|
if (r.text) meta3.text = r.text;
|
|
34841
|
+
if (report) meta3.image_inputs = report;
|
|
34817
34842
|
meta3.hint = `To continue this track, call gemini_music_generate again with previous_interaction_id: "${r.id}" (or continue_last: true).`;
|
|
34818
34843
|
const slug = args.filename ? baseName(args.filename) : slugify2(args.prompt);
|
|
34819
34844
|
const named = r.audios.map((media, i) => ({
|
|
@@ -34847,7 +34872,6 @@ init_zod();
|
|
|
34847
34872
|
init_dist();
|
|
34848
34873
|
var UPLOAD_URL_MAX_BYTES_DISK = 100 * 1024 * 1024;
|
|
34849
34874
|
var UPLOAD_URL_MAX_BYTES_HOSTED = 25 * 1024 * 1024;
|
|
34850
|
-
var mb = (bytes) => Math.round(bytes / (1024 * 1024));
|
|
34851
34875
|
var UPLOAD_ACCEPT = ["image/", "video/", "audio/"];
|
|
34852
34876
|
var RETENTION_NOTE = "Gemini retains uploaded files for ~48h. Until then this reference can be reused across any number of calls (images_file_uris on gemini_image_generate / _edit / _set / gemini_interact, or video_url) without re-sending the bytes.";
|
|
34853
34877
|
function uploadResult(file2) {
|
|
@@ -34870,11 +34894,11 @@ function registerFileTools(server, client2) {
|
|
|
34870
34894
|
server.registerTool(
|
|
34871
34895
|
"gemini_upload_file",
|
|
34872
34896
|
{
|
|
34873
|
-
description: "Upload an image
|
|
34897
|
+
description: "Upload a file \u2014 an image, reference photo, picture, screenshot, video or audio clip \u2014 to the Gemini Files API ONCE, and get back a reusable `file_uri` (`files/<id>`) to attach to later image, video or music generations. Keywords: upload, upload file, upload image, upload photo, attach, reference image, reference photo, file_uri, files api, image reference, reuse across calls. Use this instead of pasting base64 into a tool call: the reference is a short string, so no image bytes ever enter the conversation, and it can be reused across many generations until it expires (~48h). Provide exactly one of `url` (the server downloads it), `data_base64`" + (onDisk ? ", or `path` (a local file)." : ".") + (onDisk ? "" : " This connector also accepts raw bytes over HTTP: POST them to /upload with the same Authorization header and a Content-Type of image/*, which avoids base64 entirely."),
|
|
34874
34898
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
34875
34899
|
inputSchema: {
|
|
34876
34900
|
url: external_exports.string().url().optional().describe(
|
|
34877
|
-
`Public https URL the SERVER downloads and uploads (image/video/audio, up to ${
|
|
34901
|
+
`Public https URL the SERVER downloads and uploads (image/video/audio, up to ${wholeMb(urlMaxBytes)}MB). No bytes pass through the conversation.` + (onDisk ? "" : " Larger files: POST the raw bytes to /upload, which streams instead of buffering.")
|
|
34878
34902
|
),
|
|
34879
34903
|
data_base64: external_exports.string().min(1).optional().describe("Raw base64 or a data: URI. Last resort \u2014 this is the one form that costs model context (~14k tokens for a modest JPEG)."),
|
|
34880
34904
|
path: external_exports.string().min(1).optional().describe(
|
|
@@ -34913,19 +34937,21 @@ function registerFileTools(server, client2) {
|
|
|
34913
34937
|
return client2.uploadBytes(
|
|
34914
34938
|
fetched.bytes,
|
|
34915
34939
|
args.mime_type ?? fetched.mimeType,
|
|
34916
|
-
|
|
34940
|
+
// 'upload', matching the data_base64 and path branches below —
|
|
34941
|
+
// this tool takes video and audio too, so 'image' would be a lie.
|
|
34942
|
+
args.display_name ?? fileNameFromUrl(args.url, "upload")
|
|
34917
34943
|
);
|
|
34918
34944
|
}
|
|
34919
34945
|
if (args.data_base64) {
|
|
34920
34946
|
const { decodeImageInput: decodeImageInput2 } = await Promise.resolve().then(() => (init_images(), images_exports));
|
|
34921
34947
|
const decoded = decodeImageInput2(args.data_base64);
|
|
34922
|
-
const bytes2 =
|
|
34948
|
+
const bytes2 = base64ToBytes(decoded.base64);
|
|
34923
34949
|
return client2.uploadBytes(bytes2, args.mime_type ?? decoded.mimeType, args.display_name ?? "upload");
|
|
34924
34950
|
}
|
|
34925
34951
|
const { readImageAsInline: readImageAsInline2, resolveImagePath: resolveImagePath2 } = await Promise.resolve().then(() => (init_images(), images_exports));
|
|
34926
34952
|
const resolved = resolveImagePath2(args.path);
|
|
34927
34953
|
const inline = await readImageAsInline2(resolved);
|
|
34928
|
-
const bytes =
|
|
34954
|
+
const bytes = base64ToBytes(inline.base64);
|
|
34929
34955
|
return client2.uploadBytes(
|
|
34930
34956
|
bytes,
|
|
34931
34957
|
args.mime_type ?? inline.mimeType,
|
|
@@ -34938,7 +34964,7 @@ function registerFileTools(server, client2) {
|
|
|
34938
34964
|
server.registerTool(
|
|
34939
34965
|
"gemini_list_files",
|
|
34940
34966
|
{
|
|
34941
|
-
description: "List
|
|
34967
|
+
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, MIME types and expiry times. Retention is ~48h, so an entry that has vanished has expired rather than failed.",
|
|
34942
34968
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
34943
34969
|
inputSchema: {
|
|
34944
34970
|
page_size: external_exports.number().int().positive().max(100).optional().describe("Maximum files to return (1-100, default 100)")
|
|
@@ -34965,7 +34991,7 @@ function registerFileTools(server, client2) {
|
|
|
34965
34991
|
server.registerTool(
|
|
34966
34992
|
"gemini_delete_file",
|
|
34967
34993
|
{
|
|
34968
|
-
description: "Delete an uploaded file from the Gemini Files API before its ~48h expiry. Any tool call still referencing it will then fail with a generic 404, so delete only references you are finished with.",
|
|
34994
|
+
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 will then fail with a generic 404, so delete only references you are finished with.",
|
|
34969
34995
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
34970
34996
|
inputSchema: {
|
|
34971
34997
|
file_uri: external_exports.string().min(1).describe("The `files/<id>` reference (or full uri) to delete"),
|
|
@@ -34980,14 +35006,6 @@ function registerFileTools(server, client2) {
|
|
|
34980
35006
|
}
|
|
34981
35007
|
);
|
|
34982
35008
|
}
|
|
34983
|
-
function nameFromUrl(url2) {
|
|
34984
|
-
try {
|
|
34985
|
-
const name = new URL(url2).pathname.split("/").filter(Boolean).pop();
|
|
34986
|
-
return name ? decodeURIComponent(name).slice(0, 120) : "upload";
|
|
34987
|
-
} catch {
|
|
34988
|
-
return "upload";
|
|
34989
|
-
}
|
|
34990
|
-
}
|
|
34991
35009
|
|
|
34992
35010
|
// src/index.ts
|
|
34993
35011
|
await loadStdioDotenv();
|
package/dist/bytes.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* base64 ↔ bytes, and byte-size formatting.
|
|
3
|
+
*
|
|
4
|
+
* These conversions had grown four near-identical copies — an inline
|
|
5
|
+
* `Uint8Array.from(atob(x), c => c.charCodeAt(0))` in `inputs.ts` and twice in
|
|
6
|
+
* `tools/files.ts`, plus a private `decodeBase64` in `storage/media.ts` — and
|
|
7
|
+
* a fifth (`encodeBase64`) that was subtly different because it is the only one
|
|
8
|
+
* that has to chunk. One place, so the chunking rule can't be forgotten in copy
|
|
9
|
+
* number six.
|
|
10
|
+
*
|
|
11
|
+
* `atob`/`btoa` rather than `Buffer`: this module is on the Worker's import
|
|
12
|
+
* graph, and they are the primitive both runtimes share. Module scope stays
|
|
13
|
+
* side-effect-free for the same reason (see the note in client.ts).
|
|
14
|
+
*/
|
|
15
|
+
/** Decode base64 into raw bytes. */
|
|
16
|
+
export function base64ToBytes(base64) {
|
|
17
|
+
const binary = atob(base64);
|
|
18
|
+
const bytes = new Uint8Array(binary.length);
|
|
19
|
+
for (let i = 0; i < binary.length; i++)
|
|
20
|
+
bytes[i] = binary.charCodeAt(i);
|
|
21
|
+
return bytes;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Encode raw bytes as base64.
|
|
25
|
+
*
|
|
26
|
+
* Chunked because `String.fromCharCode(...bytes)` spreads one argument per
|
|
27
|
+
* byte and blows the engine's argument limit somewhere around 100–125k — i.e.
|
|
28
|
+
* it works fine on every small test fixture and throws on the first real
|
|
29
|
+
* photo, which is the worst possible place to learn about it.
|
|
30
|
+
*/
|
|
31
|
+
export function bytesToBase64(bytes) {
|
|
32
|
+
const CHUNK = 0x8000;
|
|
33
|
+
let binary = '';
|
|
34
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
35
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
36
|
+
}
|
|
37
|
+
return btoa(binary);
|
|
38
|
+
}
|
|
39
|
+
/** Whole megabytes, for limits stated in tool descriptions (`25MB`). */
|
|
40
|
+
export function wholeMb(bytes) {
|
|
41
|
+
return Math.round(bytes / (1024 * 1024));
|
|
42
|
+
}
|
|
43
|
+
/** One-decimal megabytes, for error messages about an actual size (`15.4 MB`). */
|
|
44
|
+
export function formatMb(bytes) {
|
|
45
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
46
|
+
}
|