@chrischall/gemini-mcp 1.0.3 → 1.1.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 +74 -1
- package/dist/bundle.js +773 -56
- package/dist/client.js +194 -17
- package/dist/fetch-image.js +339 -0
- package/dist/index.js +2 -1
- package/dist/inputs.js +186 -0
- package/dist/session.js +39 -0
- package/dist/tools/files.js +194 -0
- package/dist/tools/generate.js +21 -12
- package/dist/tools/interact.js +11 -4
- package/dist/tools/set.js +29 -7
- package/dist/tools/shared.js +43 -6
- package/dist/upload-endpoint.js +79 -0
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/server.json +2 -2
- package/skills/gemini-mcp/SKILL.md +60 -4
package/dist/inputs.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { McpToolError } from '@chrischall/mcp-utils';
|
|
2
|
+
import { IMAGE_INLINE_MAX_BYTES } from './fetch-image.js';
|
|
3
|
+
/** True when this call carries at least one image input of any kind. */
|
|
4
|
+
export function hasImageInput(args) {
|
|
5
|
+
return Boolean(args.images?.length ||
|
|
6
|
+
args.images_base64?.length ||
|
|
7
|
+
args.images_url?.length ||
|
|
8
|
+
args.images_file_uris?.length ||
|
|
9
|
+
args.from_clipboard);
|
|
10
|
+
}
|
|
11
|
+
/** Conservative TTL when the API doesn't tell us: documented retention is ~48h. */
|
|
12
|
+
const ASSUMED_TTL_MS = 47 * 60 * 60 * 1000;
|
|
13
|
+
function expiresAtMs(expirationTime, now) {
|
|
14
|
+
if (expirationTime) {
|
|
15
|
+
const t = Date.parse(expirationTime);
|
|
16
|
+
if (Number.isFinite(t))
|
|
17
|
+
return t;
|
|
18
|
+
}
|
|
19
|
+
return now + ASSUMED_TTL_MS;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolve every image input a tool was given into {@link ImageInput}s.
|
|
23
|
+
*
|
|
24
|
+
* Order is stable and matches the pre-`images_url` behaviour so existing calls
|
|
25
|
+
* produce byte-identical requests: clipboard, then paths, then base64, then
|
|
26
|
+
* fetched URLs, then Files API references.
|
|
27
|
+
*
|
|
28
|
+
* A URL repeated within one call is fetched ONCE. That is what lets
|
|
29
|
+
* `gemini_image_set` hand the same reference to the master and all N scene
|
|
30
|
+
* generations without paying for N downloads (or N uploads).
|
|
31
|
+
*/
|
|
32
|
+
export async function resolveImageInputs(args, client) {
|
|
33
|
+
const report = {};
|
|
34
|
+
const inputs = [];
|
|
35
|
+
const onDisk = client.mediaSink?.persistsFiles ?? true;
|
|
36
|
+
// 1. clipboard (macOS, stdio only). Imported dynamically so `child_process`
|
|
37
|
+
// stays off the default path — and off the Worker's module graph entirely.
|
|
38
|
+
if (args.from_clipboard) {
|
|
39
|
+
inputs.push(await (await import('./clipboard.js')).readClipboardImage());
|
|
40
|
+
}
|
|
41
|
+
// 2. local paths — inline, unless this session has seen the same file before.
|
|
42
|
+
for (const path of args.images ?? []) {
|
|
43
|
+
inputs.push(await resolveLocalPath(path, client, onDisk, report));
|
|
44
|
+
}
|
|
45
|
+
// 3. raw base64 / data URIs
|
|
46
|
+
if (args.images_base64?.length) {
|
|
47
|
+
const { decodeImageInput } = await import('./images.js');
|
|
48
|
+
for (const b64 of args.images_base64)
|
|
49
|
+
inputs.push(decodeImageInput(b64));
|
|
50
|
+
}
|
|
51
|
+
// 4. https URLs, fetched by the server. Deduped within the call.
|
|
52
|
+
const fetched = new Map();
|
|
53
|
+
for (const url of args.images_url ?? []) {
|
|
54
|
+
const hit = fetched.get(url);
|
|
55
|
+
if (hit) {
|
|
56
|
+
inputs.push(hit);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const resolved = await resolveUrl(url, client, report);
|
|
60
|
+
fetched.set(url, resolved);
|
|
61
|
+
inputs.push(resolved);
|
|
62
|
+
}
|
|
63
|
+
// 5. Files API references — no bytes move at all.
|
|
64
|
+
const seenUris = new Map();
|
|
65
|
+
for (const ref of args.images_file_uris ?? []) {
|
|
66
|
+
const hit = seenUris.get(ref);
|
|
67
|
+
if (hit) {
|
|
68
|
+
inputs.push(hit);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const file = await client.getFile(ref);
|
|
72
|
+
const input = { uri: file.uri, mimeType: file.mimeType };
|
|
73
|
+
seenUris.set(ref, input);
|
|
74
|
+
(report.file_uris ??= []).push(file.name);
|
|
75
|
+
inputs.push(input);
|
|
76
|
+
}
|
|
77
|
+
return { inputs, report: Object.keys(report).length > 0 ? report : undefined };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* One local path → an inline input, or a Files API reference once this session
|
|
81
|
+
* has referenced the same bytes more than once.
|
|
82
|
+
*
|
|
83
|
+
* Identity is path + mtime + size, so editing the file in place produces a new
|
|
84
|
+
* key rather than silently reusing an upload of the old content.
|
|
85
|
+
*/
|
|
86
|
+
async function resolveLocalPath(path, client, onDisk, report) {
|
|
87
|
+
const { readImageAsInline, resolveImagePath } = await import('./images.js');
|
|
88
|
+
// A runtime with no filesystem never gets here — `assertLocalInputsAvailable`
|
|
89
|
+
// rejects `images` before any of this — but if it somehow did, reading the
|
|
90
|
+
// file is what would fail, so keep the pre-existing error path.
|
|
91
|
+
if (!onDisk)
|
|
92
|
+
return readImageAsInline(path);
|
|
93
|
+
const { stat } = await import('node:fs/promises');
|
|
94
|
+
const resolved = resolveImagePath(path);
|
|
95
|
+
let key;
|
|
96
|
+
try {
|
|
97
|
+
const { mtimeMs, size } = await stat(resolved);
|
|
98
|
+
// NUL separator, written as the \u0000 ESCAPE and never a literal NUL
|
|
99
|
+
// byte: a literal one makes git classify this whole file as binary and
|
|
100
|
+
// stop diffing it (same trap as fingerprintRequest in jobs.ts). It cannot
|
|
101
|
+
// occur in a path, so no crafted name can straddle a field boundary.
|
|
102
|
+
key = `path\u0000${resolved}\u0000${mtimeMs}\u0000${size}`;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Unstattable but resolvable is odd; fall back to plain inline rather than
|
|
106
|
+
// failing a call that would otherwise have worked.
|
|
107
|
+
return readImageAsInline(path);
|
|
108
|
+
}
|
|
109
|
+
const cached = client.session.cachedUpload(key);
|
|
110
|
+
if (cached)
|
|
111
|
+
return { uri: cached.uri, mimeType: cached.mimeType };
|
|
112
|
+
const references = client.session.countReference(key);
|
|
113
|
+
const inline = await readImageAsInline(resolved);
|
|
114
|
+
if (references < 2)
|
|
115
|
+
return inline;
|
|
116
|
+
// Second sighting: pay one upload now so every later reference is free.
|
|
117
|
+
// Best-effort — an upload failure must not fail a call that inline handles
|
|
118
|
+
// perfectly well.
|
|
119
|
+
try {
|
|
120
|
+
const bytes = Uint8Array.from(atob(inline.base64), (c) => c.charCodeAt(0));
|
|
121
|
+
const displayName = resolved.split(/[\\/]/).pop() || 'image';
|
|
122
|
+
const uploaded = await client.uploadBytes(bytes, inline.mimeType, displayName);
|
|
123
|
+
client.session.uploadCache.set(key, {
|
|
124
|
+
name: uploaded.name,
|
|
125
|
+
uri: uploaded.uri,
|
|
126
|
+
mimeType: uploaded.mimeType,
|
|
127
|
+
expiresAtMs: expiresAtMs(uploaded.expirationTime, Date.now()),
|
|
128
|
+
});
|
|
129
|
+
(report.auto_uploaded ??= []).push({ path: resolved, file_uri: uploaded.name, expires: uploaded.expirationTime });
|
|
130
|
+
return { uri: uploaded.uri, mimeType: uploaded.mimeType };
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return inline;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** One https URL → inline bytes, or a Files API reference when it's large. */
|
|
137
|
+
async function resolveUrl(url, client, report) {
|
|
138
|
+
const fetched = await client.fetchRemoteImage(url);
|
|
139
|
+
const entry = {
|
|
140
|
+
url,
|
|
141
|
+
mime_type: fetched.mimeType,
|
|
142
|
+
bytes: fetched.size,
|
|
143
|
+
};
|
|
144
|
+
(report.fetched_urls ??= []).push(entry);
|
|
145
|
+
if (fetched.size <= IMAGE_INLINE_MAX_BYTES) {
|
|
146
|
+
return { base64: encodeBase64(fetched.bytes), mimeType: fetched.mimeType };
|
|
147
|
+
}
|
|
148
|
+
const displayName = fileNameFromUrl(url);
|
|
149
|
+
const uploaded = await client.uploadBytes(fetched.bytes, fetched.mimeType, displayName);
|
|
150
|
+
entry.file_uri = uploaded.name;
|
|
151
|
+
return { uri: uploaded.uri, mimeType: uploaded.mimeType };
|
|
152
|
+
}
|
|
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
|
+
/**
|
|
177
|
+
* Guard for tools that REQUIRE at least one reference image
|
|
178
|
+
* (`gemini_image_edit`). Names every accepted param, in the order a caller
|
|
179
|
+
* should prefer them — the context-free ones first.
|
|
180
|
+
*/
|
|
181
|
+
export function requireImageInput(args) {
|
|
182
|
+
if (hasImageInput(args))
|
|
183
|
+
return;
|
|
184
|
+
throw new McpToolError('Provide at least one input image via `images_url` (an https URL the server fetches), `images_file_uris` ' +
|
|
185
|
+
'(from gemini_upload_file), `images` (a local path), `images_base64`, or `from_clipboard`.');
|
|
186
|
+
}
|
package/dist/session.js
CHANGED
|
@@ -44,6 +44,43 @@ export class SessionState {
|
|
|
44
44
|
* model against the requested edit.
|
|
45
45
|
*/
|
|
46
46
|
writtenOutputs = new Set();
|
|
47
|
+
/**
|
|
48
|
+
* Files API uploads this session can reuse, keyed by an opaque identity
|
|
49
|
+
* string (for a local path: absolute path + mtime + size, so an edited file
|
|
50
|
+
* is a different key rather than a stale hit).
|
|
51
|
+
*
|
|
52
|
+
* Backs the stdio "referenced twice → upload once" optimization: the second
|
|
53
|
+
* time a session sends the same picture, the bytes are replaced by a
|
|
54
|
+
* `files/…` reference that costs nothing to repeat. Session-scoped, like
|
|
55
|
+
* everything else here — a uri is minted by ONE user's API key and is only
|
|
56
|
+
* readable with that key, so sharing this map across the connector's tenants
|
|
57
|
+
* would hand out references another user cannot use and this user did not
|
|
58
|
+
* create.
|
|
59
|
+
*/
|
|
60
|
+
uploadCache = new Map();
|
|
61
|
+
/**
|
|
62
|
+
* How many times each identity has been referenced this session. The
|
|
63
|
+
* threshold for auto-uploading is "more than once", so the count has to
|
|
64
|
+
* outlive the call that first saw it.
|
|
65
|
+
*/
|
|
66
|
+
referenceCounts = new Map();
|
|
67
|
+
/** Record a reference and return the running total (1 on first sight). */
|
|
68
|
+
countReference(key) {
|
|
69
|
+
const next = (this.referenceCounts.get(key) ?? 0) + 1;
|
|
70
|
+
this.referenceCounts.set(key, next);
|
|
71
|
+
return next;
|
|
72
|
+
}
|
|
73
|
+
/** A cached upload, or `undefined` if absent or past its Files API TTL. */
|
|
74
|
+
cachedUpload(key, now = Date.now()) {
|
|
75
|
+
const hit = this.uploadCache.get(key);
|
|
76
|
+
if (!hit)
|
|
77
|
+
return undefined;
|
|
78
|
+
if (hit.expiresAtMs <= now) {
|
|
79
|
+
this.uploadCache.delete(key);
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
return hit;
|
|
83
|
+
}
|
|
47
84
|
/** Forget everything. Tests use this; nothing in `src/` calls it. */
|
|
48
85
|
reset() {
|
|
49
86
|
this.jobs.reset();
|
|
@@ -51,5 +88,7 @@ export class SessionState {
|
|
|
51
88
|
this.lastMusicInteractionId = undefined;
|
|
52
89
|
this.lastVideoInteractionId = undefined;
|
|
53
90
|
this.writtenOutputs.clear();
|
|
91
|
+
this.uploadCache.clear();
|
|
92
|
+
this.referenceCounts.clear();
|
|
54
93
|
}
|
|
55
94
|
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { McpToolError, textResult } from '@chrischall/mcp-utils';
|
|
3
|
+
import { withProgressHeartbeat } from './shared.js';
|
|
4
|
+
import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
5
|
+
/**
|
|
6
|
+
* First-class Gemini Files API access.
|
|
7
|
+
*
|
|
8
|
+
* The problem these solve: on a hosted connector there is no filesystem, so the
|
|
9
|
+
* only way to hand over a picture used to be `images_base64` — and a base64
|
|
10
|
+
* JPEG is ~14k tokens of the model's context window per photo, corrupted
|
|
11
|
+
* outright whenever the file read that produced it was truncated. A Files API
|
|
12
|
+
* reference is a 20-character string that any number of later calls can reuse.
|
|
13
|
+
*
|
|
14
|
+
* Three ways in, none of which put bytes in the conversation:
|
|
15
|
+
*
|
|
16
|
+
* - `gemini_upload_file` with `url` — the SERVER downloads it.
|
|
17
|
+
* - `POST /upload` on the connector with the raw bytes as the request body —
|
|
18
|
+
* the intended path for an agent with a shell (see README).
|
|
19
|
+
* - `gemini_upload_file` with `path` — stdio only, reads the local file.
|
|
20
|
+
*
|
|
21
|
+
* `data_base64` exists as a deliberate last resort: it is the one form that
|
|
22
|
+
* *does* cost context, and it is documented as such.
|
|
23
|
+
*
|
|
24
|
+
* Uploads are retained ~48h. After that the uri stops resolving — which,
|
|
25
|
+
* unhelpfully, surfaces as the same generic 404 an unknown model id produces
|
|
26
|
+
* (see `ChainedRequest404Error` in client.ts), so the expiry is stated
|
|
27
|
+
* everywhere a uri is returned.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* A `url` upload is buffered whole before being forwarded (fetched bytes, then
|
|
31
|
+
* a Blob copy of them), so the cap has to be one the runtime can actually hold
|
|
32
|
+
* — an advertised limit that fails as an OOM is worse than a smaller honest
|
|
33
|
+
* one. A Cloudflare isolate gets 128MB total, hence the much lower hosted
|
|
34
|
+
* figure; Node has no comparable ceiling.
|
|
35
|
+
*
|
|
36
|
+
* `POST /upload` is unaffected: it streams, and is bounded only by the Files
|
|
37
|
+
* API's own 2 GB cap.
|
|
38
|
+
*/
|
|
39
|
+
const UPLOAD_URL_MAX_BYTES_DISK = 100 * 1024 * 1024;
|
|
40
|
+
const UPLOAD_URL_MAX_BYTES_HOSTED = 25 * 1024 * 1024;
|
|
41
|
+
const mb = (bytes) => Math.round(bytes / (1024 * 1024));
|
|
42
|
+
/** What a `url` upload will accept: the media the Files API is useful for. */
|
|
43
|
+
const UPLOAD_ACCEPT = ['image/', 'video/', 'audio/'];
|
|
44
|
+
const RETENTION_NOTE = 'Gemini retains uploaded files for ~48h. Until then this reference can be reused across any number of calls ' +
|
|
45
|
+
'(images_file_uris on gemini_image_generate / _edit / _set / gemini_interact, or video_url) without re-sending the bytes.';
|
|
46
|
+
/** Shape returned by every upload path. */
|
|
47
|
+
function uploadResult(file) {
|
|
48
|
+
return textResult({
|
|
49
|
+
// The `files/<id>` form is what every tool parameter documents, so it leads.
|
|
50
|
+
file_uri: file.name,
|
|
51
|
+
// The absolute uri, for callers that would rather pass it through verbatim.
|
|
52
|
+
uri: file.uri,
|
|
53
|
+
name: file.name,
|
|
54
|
+
mime_type: file.mimeType,
|
|
55
|
+
...(file.sizeBytes ? { size_bytes: Number(file.sizeBytes) } : {}),
|
|
56
|
+
...(file.displayName ? { display_name: file.displayName } : {}),
|
|
57
|
+
expires: file.expirationTime ?? '~48h from upload',
|
|
58
|
+
hint: `Pass "${file.name}" in images_file_uris (or video_url for video) on any image tool. ${RETENTION_NOTE}`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
export function registerFileTools(server, client) {
|
|
62
|
+
const onDisk = client.mediaSink?.persistsFiles ?? true;
|
|
63
|
+
const urlMaxBytes = onDisk ? UPLOAD_URL_MAX_BYTES_DISK : UPLOAD_URL_MAX_BYTES_HOSTED;
|
|
64
|
+
server.registerTool('gemini_upload_file', {
|
|
65
|
+
description: 'Upload an image (or video/audio) to the Gemini Files API ONCE and get back a reusable `files/<id>` reference. ' +
|
|
66
|
+
'Use this instead of pasting base64 into a tool call: the reference is a short string, so no image bytes ever ' +
|
|
67
|
+
'enter the conversation, and it can be reused across many generations until it expires (~48h). ' +
|
|
68
|
+
'Provide exactly one of `url` (the server downloads it), `data_base64`' +
|
|
69
|
+
(onDisk ? ', or `path` (a local file).' : '.') +
|
|
70
|
+
(onDisk
|
|
71
|
+
? ''
|
|
72
|
+
: ' This connector also accepts raw bytes over HTTP: POST them to /upload with the same Authorization header ' +
|
|
73
|
+
'and a Content-Type of image/*, which avoids base64 entirely.'),
|
|
74
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
75
|
+
inputSchema: {
|
|
76
|
+
url: z
|
|
77
|
+
.string()
|
|
78
|
+
.url()
|
|
79
|
+
.optional()
|
|
80
|
+
.describe(`Public https URL the SERVER downloads and uploads (image/video/audio, up to ${mb(urlMaxBytes)}MB). ` +
|
|
81
|
+
'No bytes pass through the conversation.' +
|
|
82
|
+
(onDisk ? '' : ' Larger files: POST the raw bytes to /upload, which streams instead of buffering.')),
|
|
83
|
+
data_base64: z
|
|
84
|
+
.string()
|
|
85
|
+
.min(1)
|
|
86
|
+
.optional()
|
|
87
|
+
.describe('Raw base64 or a data: URI. Last resort — this is the one form that costs model context (~14k tokens for a modest JPEG).'),
|
|
88
|
+
path: z
|
|
89
|
+
.string()
|
|
90
|
+
.min(1)
|
|
91
|
+
.optional()
|
|
92
|
+
.describe(onDisk
|
|
93
|
+
? 'Path to a local file (absolute, or resolved against $GEMINI_INPUT_DIR). Confirm-gated like every other local-file input.'
|
|
94
|
+
: 'Unavailable on this hosted connector (no filesystem) — use `url`, `data_base64`, or POST /upload.'),
|
|
95
|
+
mime_type: z
|
|
96
|
+
.string()
|
|
97
|
+
.regex(/^[\w.+-]+\/[\w.+-]+$/, 'must be a MIME type like image/png')
|
|
98
|
+
.optional()
|
|
99
|
+
.describe('Override the detected MIME type (sniffed from the bytes / taken from the server response otherwise)'),
|
|
100
|
+
display_name: z.string().min(1).optional().describe('Human-readable name recorded against the upload'),
|
|
101
|
+
confirm: schemaConfirm,
|
|
102
|
+
},
|
|
103
|
+
}, async (args, extra) => {
|
|
104
|
+
const sources = [args.url && 'url', args.data_base64 && 'data_base64', args.path && 'path'].filter(Boolean);
|
|
105
|
+
if (sources.length !== 1) {
|
|
106
|
+
throw new McpToolError(sources.length === 0
|
|
107
|
+
? 'Provide exactly one of `url`, `data_base64`' + (onDisk ? ' or `path`.' : '.')
|
|
108
|
+
: `Provide exactly one source, not ${sources.length} (${sources.join(', ')}).`);
|
|
109
|
+
}
|
|
110
|
+
if (args.path) {
|
|
111
|
+
if (!onDisk) {
|
|
112
|
+
throw new McpToolError('`path` is unavailable on the hosted connector: it runs on a Cloudflare Worker, which has no filesystem. ' +
|
|
113
|
+
'Use `url` (the server downloads it), `data_base64`, or POST the raw bytes to /upload with the same Authorization header.', { hint: 'Use `url`, `data_base64`, or POST /upload.' });
|
|
114
|
+
}
|
|
115
|
+
// Same gate as every other local-file input: a prompt-injected `path`
|
|
116
|
+
// would otherwise ship an arbitrary local file to Google, and an
|
|
117
|
+
// uploaded file is readable by anyone holding the api key for ~48h.
|
|
118
|
+
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, 'Upload a local file to the Gemini Files API', '/upload/v1beta/files', [args.path]);
|
|
119
|
+
if (gate)
|
|
120
|
+
return gate;
|
|
121
|
+
}
|
|
122
|
+
const uploaded = await withProgressHeartbeat(extra, 'Uploading to the Gemini Files API', async () => {
|
|
123
|
+
if (args.url) {
|
|
124
|
+
const fetched = await client.fetchRemoteImage(args.url, { maxBytes: urlMaxBytes, accept: UPLOAD_ACCEPT });
|
|
125
|
+
return client.uploadBytes(fetched.bytes, args.mime_type ?? fetched.mimeType, args.display_name ?? nameFromUrl(args.url));
|
|
126
|
+
}
|
|
127
|
+
if (args.data_base64) {
|
|
128
|
+
const { decodeImageInput } = await import('../images.js');
|
|
129
|
+
const decoded = decodeImageInput(args.data_base64);
|
|
130
|
+
const bytes = Uint8Array.from(atob(decoded.base64), (c) => c.charCodeAt(0));
|
|
131
|
+
return client.uploadBytes(bytes, args.mime_type ?? decoded.mimeType, args.display_name ?? 'upload');
|
|
132
|
+
}
|
|
133
|
+
const { readImageAsInline, resolveImagePath } = await import('../images.js');
|
|
134
|
+
const resolved = resolveImagePath(args.path);
|
|
135
|
+
const inline = await readImageAsInline(resolved);
|
|
136
|
+
const bytes = Uint8Array.from(atob(inline.base64), (c) => c.charCodeAt(0));
|
|
137
|
+
return client.uploadBytes(bytes, args.mime_type ?? inline.mimeType, args.display_name ?? (resolved.split(/[\\/]/).pop() || 'upload'));
|
|
138
|
+
});
|
|
139
|
+
return uploadResult(uploaded);
|
|
140
|
+
});
|
|
141
|
+
server.registerTool('gemini_list_files', {
|
|
142
|
+
description: 'List the files currently uploaded to the Gemini Files API under this API key, with their `files/<id>` references, ' +
|
|
143
|
+
'MIME types and expiry times. Retention is ~48h, so an entry that has vanished has expired rather than failed.',
|
|
144
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
145
|
+
inputSchema: {
|
|
146
|
+
page_size: z.number().int().positive().max(100).optional().describe('Maximum files to return (1-100, default 100)'),
|
|
147
|
+
},
|
|
148
|
+
}, async (args) => {
|
|
149
|
+
const files = await client.listFiles(args.page_size ?? 100);
|
|
150
|
+
return textResult({
|
|
151
|
+
count: files.length,
|
|
152
|
+
files: files.map((f) => ({
|
|
153
|
+
file_uri: f.name,
|
|
154
|
+
uri: f.uri,
|
|
155
|
+
mime_type: f.mimeType,
|
|
156
|
+
...(f.displayName ? { display_name: f.displayName } : {}),
|
|
157
|
+
...(f.sizeBytes ? { size_bytes: Number(f.sizeBytes) } : {}),
|
|
158
|
+
...(f.state ? { state: f.state } : {}),
|
|
159
|
+
...(f.createTime ? { created: f.createTime } : {}),
|
|
160
|
+
...(f.expirationTime ? { expires: f.expirationTime } : {}),
|
|
161
|
+
})),
|
|
162
|
+
hint: files.length
|
|
163
|
+
? `Pass any file_uri above in images_file_uris on an image tool. ${RETENTION_NOTE}`
|
|
164
|
+
: 'No files uploaded (or all have expired — retention is ~48h). Upload one with gemini_upload_file.',
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
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 ' +
|
|
169
|
+
'will then fail with a generic 404, so delete only references you are finished with.',
|
|
170
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
171
|
+
inputSchema: {
|
|
172
|
+
file_uri: z.string().min(1).describe('The `files/<id>` reference (or full uri) to delete'),
|
|
173
|
+
confirm: schemaConfirm,
|
|
174
|
+
},
|
|
175
|
+
}, async (args) => {
|
|
176
|
+
// Deleting is a remote mutation with no undo — confirm-gated per the
|
|
177
|
+
// fleet convention (unlike writing a generated image, which is local).
|
|
178
|
+
const gate = previewUnlessConfirmed(args.confirm, 'Delete an uploaded Gemini file', 'DELETE', `/v1beta/${args.file_uri}`);
|
|
179
|
+
if (gate)
|
|
180
|
+
return gate;
|
|
181
|
+
await client.deleteFile(args.file_uri);
|
|
182
|
+
return textResult({ deleted: args.file_uri });
|
|
183
|
+
});
|
|
184
|
+
}
|
|
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
|
+
}
|
package/dist/tools/generate.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
3
3
|
import { resolveModel } from '../models.js';
|
|
4
|
-
import { slugify, baseName
|
|
5
|
-
import {
|
|
4
|
+
import { slugify, baseName } from '../images.js';
|
|
5
|
+
import { resolveImageInputs, requireImageInput } from '../inputs.js';
|
|
6
|
+
import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
|
|
6
7
|
import { fingerprintRequest } from '../jobs.js';
|
|
7
8
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
8
9
|
const GENERATE_ENDPOINT = '/v1beta/models/{model}:generateContent';
|
|
@@ -21,7 +22,9 @@ export function registerGenerateTools(server, client) {
|
|
|
21
22
|
count: z.number().int().positive().max(8).optional().describe('Number of independent images (default 1)'),
|
|
22
23
|
filename: z.string().optional().describe('Base filename for the output image (extension stripped; default: slugified prompt)'),
|
|
23
24
|
images: z.array(z.string().min(1)).optional().describe('Paths to reference input images (image-conditioned generation)'),
|
|
24
|
-
|
|
25
|
+
images_url: imagesUrlSchema(),
|
|
26
|
+
images_file_uris: imagesFileUrisSchema(),
|
|
27
|
+
images_base64: z.array(z.string().min(1)).optional().describe('Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation'),
|
|
25
28
|
video_url: z.string().url().optional().describe('Public YouTube URL (or a previously uploaded Files API uri) as a video reference (video→image; use a Flash model e.g. gemini-3.1-flash-image)'),
|
|
26
29
|
video_path: videoPathSchema,
|
|
27
30
|
confirm: schemaConfirm,
|
|
@@ -46,12 +49,15 @@ export function registerGenerateTools(server, client) {
|
|
|
46
49
|
model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
|
|
47
50
|
thinking_level: args.thinking_level, google_search: args.google_search, count,
|
|
48
51
|
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
52
|
+
images_url: args.images_url, images_file_uris: args.images_file_uris,
|
|
49
53
|
video_url: args.video_url, video_path: args.video_path,
|
|
50
54
|
});
|
|
51
55
|
return client.session.jobs.dispatch({ toolName: 'gemini_image_generate', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
52
56
|
const seed = pickSeed(args.seed);
|
|
53
57
|
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
54
|
-
|
|
58
|
+
// Resolved ONCE, before the count loop: a URL is fetched (and a large
|
|
59
|
+
// one uploaded) a single time no matter how many images are generated.
|
|
60
|
+
const { inputs: refInputs, report } = await withProgressHeartbeat(extra, 'Resolving reference images', () => resolveImageInputs(args, client));
|
|
55
61
|
// Upload ONCE (before the count loop) — every generate call references
|
|
56
62
|
// the same files/… uri.
|
|
57
63
|
const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args, client));
|
|
@@ -90,6 +96,8 @@ export function registerGenerateTools(server, client) {
|
|
|
90
96
|
meta.text = capturedText;
|
|
91
97
|
if (capturedGrounding)
|
|
92
98
|
meta.grounding = capturedGrounding;
|
|
99
|
+
if (report)
|
|
100
|
+
meta.image_inputs = report;
|
|
93
101
|
if (video.videoFileMeta)
|
|
94
102
|
meta.video_file = video.videoFileMeta;
|
|
95
103
|
meta.hint = REFINE_HINT;
|
|
@@ -108,18 +116,16 @@ export function registerGenerateTools(server, client) {
|
|
|
108
116
|
inputSchema: {
|
|
109
117
|
prompt: z.string().min(1).describe('Instruction describing the edit or composition'),
|
|
110
118
|
images: z.array(z.string().min(1)).optional().describe('Paths to input image file(s) (1 = edit, 2+ = compose)'),
|
|
111
|
-
|
|
119
|
+
images_url: imagesUrlSchema('Input images'),
|
|
120
|
+
images_file_uris: imagesFileUrisSchema('Input images'),
|
|
121
|
+
images_base64: z.array(z.string().min(1)).optional().describe('Input images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation'),
|
|
112
122
|
filename: z.string().optional().describe('Base filename for the output image (extension stripped; default: slugified prompt)'),
|
|
113
123
|
confirm: schemaConfirm,
|
|
114
124
|
...sharedImageSchema,
|
|
115
125
|
},
|
|
116
126
|
}, async (args, extra) => {
|
|
117
127
|
assertLocalInputsAvailable(client.mediaSink, args);
|
|
118
|
-
|
|
119
|
-
const hasBase64 = (args.images_base64?.length ?? 0) > 0;
|
|
120
|
-
if (!hasPaths && !hasBase64 && !args.from_clipboard) {
|
|
121
|
-
throw new McpToolError('Provide at least one input image via `images`, `images_base64`, or `from_clipboard`.');
|
|
122
|
-
}
|
|
128
|
+
requireImageInput(args);
|
|
123
129
|
// Confirm-gate local file inputs (see gemini_image_generate). base64 /
|
|
124
130
|
// clipboard inputs are not local-path reads and pass through ungated.
|
|
125
131
|
const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
|
|
@@ -130,11 +136,12 @@ export function registerGenerateTools(server, client) {
|
|
|
130
136
|
model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
|
|
131
137
|
thinking_level: args.thinking_level, google_search: args.google_search,
|
|
132
138
|
images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
139
|
+
images_url: args.images_url, images_file_uris: args.images_file_uris,
|
|
133
140
|
});
|
|
134
141
|
return client.session.jobs.dispatch({ toolName: 'gemini_image_edit', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
135
142
|
const seed = pickSeed(args.seed);
|
|
136
143
|
const slug = args.filename ? baseName(args.filename) : slugify(args.prompt);
|
|
137
|
-
const inputs = await
|
|
144
|
+
const { inputs, report } = await withProgressHeartbeat(extra, 'Resolving input images', () => resolveImageInputs(args, client));
|
|
138
145
|
const result = await withProgressHeartbeat(extra, `Editing image (${model})`, () => client.generate({
|
|
139
146
|
prompt: args.prompt,
|
|
140
147
|
images: inputs,
|
|
@@ -152,6 +159,8 @@ export function registerGenerateTools(server, client) {
|
|
|
152
159
|
meta.text = text;
|
|
153
160
|
if (result.grounding)
|
|
154
161
|
meta.grounding = result.grounding;
|
|
162
|
+
if (report)
|
|
163
|
+
meta.image_inputs = report;
|
|
155
164
|
meta.hint = REFINE_HINT;
|
|
156
165
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: client.mediaSink?.persistsFiles });
|
|
157
166
|
if (risk)
|
package/dist/tools/interact.js
CHANGED
|
@@ -2,9 +2,10 @@ import { z } from 'zod';
|
|
|
2
2
|
import { McpToolError, ApiError, readEnvVar } from '@chrischall/mcp-utils';
|
|
3
3
|
import { resolveModel } from '../models.js';
|
|
4
4
|
import { ChainedRequest404Error } from '../client.js';
|
|
5
|
-
import { slugify, baseName,
|
|
5
|
+
import { slugify, baseName, resolveImagePath, writeSidecar, resolveOutputDir, readImageAsInline } from '../images.js';
|
|
6
|
+
import { resolveImageInputs } from '../inputs.js';
|
|
6
7
|
import { findInteractionImages, latestInteractionId } from '../sidecar.js';
|
|
7
|
-
import { emit, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable } from './shared.js';
|
|
8
|
+
import { emit, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
|
|
8
9
|
import { fingerprintRequest } from '../jobs.js';
|
|
9
10
|
import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
10
11
|
// The last-interaction id and the written-outputs set used to live here, at
|
|
@@ -73,10 +74,13 @@ export function registerInteractTools(server, client) {
|
|
|
73
74
|
.array(z.string().min(1))
|
|
74
75
|
.optional()
|
|
75
76
|
.describe(`Paths to reference input images. ${NEW_REFERENCES_ONLY}`),
|
|
77
|
+
images_url: imagesUrlSchema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
|
|
78
|
+
images_file_uris: imagesFileUrisSchema(`Reference images. ${NEW_REFERENCES_ONLY} Given`),
|
|
76
79
|
images_base64: z
|
|
77
80
|
.array(z.string().min(1))
|
|
78
81
|
.optional()
|
|
79
|
-
.describe(`Reference images as base64 strings or data URIs.
|
|
82
|
+
.describe(`Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, ` +
|
|
83
|
+
`which keep image bytes out of the conversation. ${NEW_REFERENCES_ONLY}`),
|
|
80
84
|
model: z
|
|
81
85
|
.string()
|
|
82
86
|
.optional()
|
|
@@ -171,10 +175,11 @@ export function registerInteractTools(server, client) {
|
|
|
171
175
|
thinking_level: args.thinking_level, previous_interaction_id: previousInteractionId,
|
|
172
176
|
google_search: args.google_search, search_types: args.search_types,
|
|
173
177
|
images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
|
|
178
|
+
images_url: args.images_url, images_file_uris: args.images_file_uris,
|
|
174
179
|
video_url: args.video_url, video_path: args.video_path,
|
|
175
180
|
});
|
|
176
181
|
return client.session.jobs.dispatch({ toolName: 'gemini_interact', fingerprint, idempotencyKey: args.idempotency_key, async: args.async }, async () => {
|
|
177
|
-
const inputs = await
|
|
182
|
+
const { inputs, report } = await resolveImageInputs({ ...args, images }, client);
|
|
178
183
|
const video = await withProgressHeartbeat(extra, 'Uploading video to the Gemini Files API', () => resolveVideoInput(args, client));
|
|
179
184
|
const callOpts = {
|
|
180
185
|
input: args.input,
|
|
@@ -254,6 +259,8 @@ export function registerInteractTools(server, client) {
|
|
|
254
259
|
meta.text = r.text;
|
|
255
260
|
if (r.grounding)
|
|
256
261
|
meta.grounding = r.grounding;
|
|
262
|
+
if (report)
|
|
263
|
+
meta.image_inputs = report;
|
|
257
264
|
if (video.videoFileMeta)
|
|
258
265
|
meta.video_file = video.videoFileMeta;
|
|
259
266
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, persistsFiles: onDisk });
|