@chrischall/gemini-mcp 1.2.0 → 1.4.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 +70 -0
- package/dist/bundle.js +214 -33
- package/dist/client.js +18 -0
- package/dist/connector-origin.js +34 -0
- package/dist/jobs.js +77 -1
- package/dist/media-cleanup.js +75 -0
- package/dist/media-endpoint.js +160 -0
- package/dist/media-url.js +135 -0
- package/dist/storage/media.js +72 -12
- package/dist/tools/files.js +67 -6
- package/dist/tools/generate.js +42 -21
- package/dist/tools/interact.js +5 -3
- package/dist/tools/set.js +41 -5
- package/dist/tools/shared.js +33 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/tools/files.js
CHANGED
|
@@ -3,6 +3,7 @@ import { McpToolError, textResult } from '@chrischall/mcp-utils';
|
|
|
3
3
|
import { withProgressHeartbeat } from './shared.js';
|
|
4
4
|
import { base64ToBytes, wholeMb } from '../bytes.js';
|
|
5
5
|
import { fileNameFromUrl } from '../fetch-image.js';
|
|
6
|
+
import { downloadFilename } from '../media-endpoint.js';
|
|
6
7
|
import { previewUnlessConfirmed, previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
7
8
|
/**
|
|
8
9
|
* First-class Gemini Files API access.
|
|
@@ -63,11 +64,16 @@ export function registerFileTools(server, client) {
|
|
|
63
64
|
const onDisk = client.mediaSink?.persistsFiles ?? true;
|
|
64
65
|
const urlMaxBytes = onDisk ? UPLOAD_URL_MAX_BYTES_DISK : UPLOAD_URL_MAX_BYTES_HOSTED;
|
|
65
66
|
server.registerTool('gemini_upload_file', {
|
|
66
|
-
description: 'Upload an image
|
|
67
|
+
description: 'Upload a file — an image, reference photo, picture, screenshot, video or audio clip — to the Gemini Files API ' +
|
|
68
|
+
'ONCE, and get back a reusable `file_uri` (`files/<id>`) to attach to later image, video or music generations. ' +
|
|
69
|
+
'Keywords: upload, upload file, upload image, upload photo, attach, reference image, reference photo, ' +
|
|
70
|
+
'file_uri, files api, image reference, reuse across calls. ' +
|
|
67
71
|
'Use this instead of pasting base64 into a tool call: the reference is a short string, so no image bytes ever ' +
|
|
68
72
|
'enter the conversation, and it can be reused across many generations until it expires (~48h). ' +
|
|
69
73
|
'Provide exactly one of `url` (the server downloads it), `data_base64`' +
|
|
70
|
-
(onDisk
|
|
74
|
+
(onDisk
|
|
75
|
+
? ', or `path` (a local file).'
|
|
76
|
+
: ', or `r2_key` (re-upload media this connector generated, from media[].r2_key in an earlier result).') +
|
|
71
77
|
(onDisk
|
|
72
78
|
? ''
|
|
73
79
|
: ' This connector also accepts raw bytes over HTTP: POST them to /upload with the same Authorization header ' +
|
|
@@ -86,6 +92,16 @@ export function registerFileTools(server, client) {
|
|
|
86
92
|
.min(1)
|
|
87
93
|
.optional()
|
|
88
94
|
.describe('Raw base64 or a data: URI. Last resort — this is the one form that costs model context (~14k tokens for a modest JPEG).'),
|
|
95
|
+
r2_key: z
|
|
96
|
+
.string()
|
|
97
|
+
.min(1)
|
|
98
|
+
.optional()
|
|
99
|
+
.describe(onDisk
|
|
100
|
+
? 'Unavailable on this server (media is written to local disk) — pass the file path via `path` instead.'
|
|
101
|
+
: 'An `r2_key` from a previous generation result (media[].r2_key) — re-uploads media THIS connector generated, ' +
|
|
102
|
+
'so a generated image can become a reference image in one cheap call. The server reads its own bucket directly: ' +
|
|
103
|
+
'no HTTP request, no signed URL, and no expiry to worry about. Do NOT pass a /media URL as `url` for this — ' +
|
|
104
|
+
'that would be the connector fetching itself and needs a signature.'),
|
|
89
105
|
path: z
|
|
90
106
|
.string()
|
|
91
107
|
.min(1)
|
|
@@ -102,10 +118,10 @@ export function registerFileTools(server, client) {
|
|
|
102
118
|
confirm: schemaConfirm,
|
|
103
119
|
},
|
|
104
120
|
}, async (args, extra) => {
|
|
105
|
-
const sources = [args.url && 'url', args.data_base64 && 'data_base64', args.path && 'path'].filter(Boolean);
|
|
121
|
+
const sources = [args.url && 'url', args.data_base64 && 'data_base64', args.r2_key && 'r2_key', args.path && 'path'].filter(Boolean);
|
|
106
122
|
if (sources.length !== 1) {
|
|
107
123
|
throw new McpToolError(sources.length === 0
|
|
108
|
-
? 'Provide exactly one of `url`, `data_base64`' + (onDisk ? ' or `path`.' : '
|
|
124
|
+
? 'Provide exactly one of `url`, `data_base64`' + (onDisk ? ' or `path`.' : ' or `r2_key`.')
|
|
109
125
|
: `Provide exactly one source, not ${sources.length} (${sources.join(', ')}).`);
|
|
110
126
|
}
|
|
111
127
|
if (args.path) {
|
|
@@ -128,6 +144,17 @@ export function registerFileTools(server, client) {
|
|
|
128
144
|
// this tool takes video and audio too, so 'image' would be a lie.
|
|
129
145
|
args.display_name ?? fileNameFromUrl(args.url, 'upload'));
|
|
130
146
|
}
|
|
147
|
+
if (args.r2_key) {
|
|
148
|
+
if (onDisk) {
|
|
149
|
+
throw new McpToolError('`r2_key` is only available on the hosted connector — this server writes media to local disk, ' +
|
|
150
|
+
'so pass the generated file via `path` instead.');
|
|
151
|
+
}
|
|
152
|
+
// Reading our own bucket rather than fetching our own /media URL. A
|
|
153
|
+
// self-fetch would need a signature the caller cannot mint, which is
|
|
154
|
+
// exactly the 403 loop this parameter exists to remove.
|
|
155
|
+
const stored = await client.readStoredMedia(args.r2_key);
|
|
156
|
+
return client.uploadBytes(stored.bytes, args.mime_type ?? stored.mimeType, args.display_name ?? downloadFilename(args.r2_key));
|
|
157
|
+
}
|
|
131
158
|
if (args.data_base64) {
|
|
132
159
|
const { decodeImageInput } = await import('../images.js');
|
|
133
160
|
const decoded = decodeImageInput(args.data_base64);
|
|
@@ -143,7 +170,7 @@ export function registerFileTools(server, client) {
|
|
|
143
170
|
return uploadResult(uploaded);
|
|
144
171
|
});
|
|
145
172
|
server.registerTool('gemini_list_files', {
|
|
146
|
-
description: 'List
|
|
173
|
+
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, ' +
|
|
147
174
|
'MIME types and expiry times. Retention is ~48h, so an entry that has vanished has expired rather than failed.',
|
|
148
175
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
149
176
|
inputSchema: {
|
|
@@ -168,8 +195,42 @@ export function registerFileTools(server, client) {
|
|
|
168
195
|
: 'No files uploaded (or all have expired — retention is ~48h). Upload one with gemini_upload_file.',
|
|
169
196
|
});
|
|
170
197
|
});
|
|
198
|
+
// Hosted connector only. On a disk-backed server the result carries a path
|
|
199
|
+
// that never expires, so a "refresh my link" tool would only ever error —
|
|
200
|
+
// don't advertise it there (mirrors the Worker not registering video).
|
|
201
|
+
if (!onDisk) {
|
|
202
|
+
server.registerTool('gemini_sign_media', {
|
|
203
|
+
description: 'Mint a FRESH signed URL for media this connector generated, from its `r2_key`. ' +
|
|
204
|
+
'Signed media links expire well before the object behind them is cleaned up, so an expired link is not a dead ' +
|
|
205
|
+
'end — re-sign it instead of re-generating (and re-paying for) the image. Keywords: expired link, refresh url, ' +
|
|
206
|
+
're-sign, media url, download again.',
|
|
207
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
208
|
+
inputSchema: {
|
|
209
|
+
r2_key: z.string().min(1).describe('The `media[].r2_key` from an earlier generation result'),
|
|
210
|
+
},
|
|
211
|
+
}, async (args) => {
|
|
212
|
+
const sink = client.mediaSink;
|
|
213
|
+
if (!sink?.resign) {
|
|
214
|
+
throw new McpToolError('This server does not serve media URLs — generated files are written to disk and the path in the result is already usable.', {
|
|
215
|
+
hint: 'gemini_sign_media applies to the hosted connector only.',
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
const fresh = await sink.resign(args.r2_key.trim());
|
|
219
|
+
if (!fresh) {
|
|
220
|
+
// Remediation goes in the MESSAGE: MCP serialization drops hints.
|
|
221
|
+
throw new McpToolError(`No stored media for r2_key "${args.r2_key}" — it has probably passed its retention window. Re-run the generation to get a new one.`, { hint: 'Objects are swept on a retention schedule (MEDIA_TTL_DAYS, default 7 days).' });
|
|
222
|
+
}
|
|
223
|
+
return textResult({
|
|
224
|
+
url: fresh.ref,
|
|
225
|
+
r2_key: fresh.key,
|
|
226
|
+
...(fresh.expiresAt ? { expires_at: fresh.expiresAt } : {}),
|
|
227
|
+
curl_hint: `curl -sS -o ${downloadFilename(fresh.key ?? 'media')} "${fresh.ref}"`,
|
|
228
|
+
hint: 'In a Claude chat client, download this URL into your sandbox and present it as an output file — inline image blocks and markdown embeds do not render for the end user.',
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
}
|
|
171
232
|
server.registerTool('gemini_delete_file', {
|
|
172
|
-
description: 'Delete an uploaded file from the Gemini Files API before its ~48h expiry. Any tool call still referencing it ' +
|
|
233
|
+
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 ' +
|
|
173
234
|
'will then fail with a generic 404, so delete only references you are finished with.',
|
|
174
235
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
175
236
|
inputSchema: {
|
package/dist/tools/generate.js
CHANGED
|
@@ -66,31 +66,46 @@ export function registerGenerateTools(server, client) {
|
|
|
66
66
|
// For count>1, surface the FIRST call's grounding (each call grounds
|
|
67
67
|
// independently; one representative set of sources beats concatenating N).
|
|
68
68
|
let capturedGrounding;
|
|
69
|
+
// count>1 is N billed generations in one tools/call, and any one can
|
|
70
|
+
// fail (a safety filter, a transient 5xx). Failures are collected per
|
|
71
|
+
// image so one bad draw cannot lose the ones that succeeded — same
|
|
72
|
+
// contract as gemini_image_set's failed_scenes. Zero successes still
|
|
73
|
+
// throws: an empty success is worse than an error.
|
|
74
|
+
const failed = [];
|
|
75
|
+
let firstError;
|
|
69
76
|
await withProgressHeartbeat(extra, `Generating ${count > 1 ? `${count} images` : 'image'} (${model})`, async () => {
|
|
70
77
|
for (let i = 0; i < count; i++) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
78
|
+
try {
|
|
79
|
+
// Distinct seed per image (seed+0 for the single-image case == echoed seed)
|
|
80
|
+
// so count>1 yields N *different* images, not N duplicates.
|
|
81
|
+
const result = await client.generate({
|
|
82
|
+
prompt: args.prompt,
|
|
83
|
+
images: refInputs.length > 0 ? refInputs : undefined,
|
|
84
|
+
model: args.model,
|
|
85
|
+
aspectRatio: args.aspect_ratio,
|
|
86
|
+
imageSize: args.image_size,
|
|
87
|
+
seed: seed + i,
|
|
88
|
+
thinkingLevel: args.thinking_level,
|
|
89
|
+
googleSearch: args.google_search,
|
|
90
|
+
videoUrl: video.videoUrl,
|
|
91
|
+
videoMimeType: video.videoMimeType,
|
|
92
|
+
timeoutMs: args.timeout_ms,
|
|
93
|
+
});
|
|
94
|
+
const { images: [img], text } = result;
|
|
95
|
+
if (text && !capturedText)
|
|
96
|
+
capturedText = text;
|
|
97
|
+
if (result.grounding && !capturedGrounding)
|
|
98
|
+
capturedGrounding = result.grounding;
|
|
99
|
+
named.push({ image: img, base: count === 1 ? slug : `${slug}-${String(i + 1).padStart(2, '0')}` });
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
firstError ??= err;
|
|
103
|
+
failed.push({ image: i + 1, error: err instanceof Error ? err.message : String(err) });
|
|
104
|
+
}
|
|
92
105
|
}
|
|
93
106
|
});
|
|
107
|
+
if (named.length === 0)
|
|
108
|
+
throw firstError;
|
|
94
109
|
const meta = buildMeta(model, seed, args);
|
|
95
110
|
if (capturedText)
|
|
96
111
|
meta.text = capturedText;
|
|
@@ -98,6 +113,12 @@ export function registerGenerateTools(server, client) {
|
|
|
98
113
|
meta.grounding = capturedGrounding;
|
|
99
114
|
if (report)
|
|
100
115
|
meta.image_inputs = report;
|
|
116
|
+
if (failed.length) {
|
|
117
|
+
meta.failed_images = failed;
|
|
118
|
+
meta.partial =
|
|
119
|
+
`${failed.length} of ${count} image(s) failed; the ${named.length} that succeeded are returned above. ` +
|
|
120
|
+
'Re-run with count set to the shortfall rather than the whole batch — each failure message is in failed_images.';
|
|
121
|
+
}
|
|
101
122
|
if (video.videoFileMeta)
|
|
102
123
|
meta.video_file = video.videoFileMeta;
|
|
103
124
|
meta.hint = REFINE_HINT;
|
package/dist/tools/interact.js
CHANGED
|
@@ -48,9 +48,11 @@ export function registerInteractTools(server, client) {
|
|
|
48
48
|
'If a chained call 404s, this tool re-anchors itself on the prior output image and re-issues un-chained: ' +
|
|
49
49
|
'success is reported as `chain_recovered` (the chain was the problem, and you get your image anyway); ' +
|
|
50
50
|
'a second 404 is reported as the interaction id NOT being the cause (check the model id / files uri instead). '
|
|
51
|
-
: '
|
|
52
|
-
'
|
|
53
|
-
'
|
|
51
|
+
: 'On this connector each result returns an image URL you can open or share directly (see `media[].url`) — show it ' +
|
|
52
|
+
'to the user rather than assuming they can see the image. There is no filesystem, so no output dir and no ' +
|
|
53
|
+
'`<image>.json` sidecar: a lost response cannot be recovered from disk and a 404 on a chained call cannot be ' +
|
|
54
|
+
're-anchored automatically. Capture the returned `interaction_id` from every result, and use `async: true` + ' +
|
|
55
|
+
'gemini_get_result for long generations. ';
|
|
54
56
|
server.registerTool('gemini_interact', {
|
|
55
57
|
description: 'Preferred tool for iterative or multi-step refinement of a single image — ' +
|
|
56
58
|
"multi-turn generation/editing via Gemini's Interactions API. " +
|
package/dist/tools/set.js
CHANGED
|
@@ -61,6 +61,8 @@ export function registerSetTools(server, client) {
|
|
|
61
61
|
const carried = await resolveImageInputs({ images_url: args.master_images_url, images_file_uris: args.master_images_file_uris }, client);
|
|
62
62
|
const masterOnly = await resolveImageInputs({ images: args.master_images, images_base64: args.master_images_base64, from_clipboard: args.from_clipboard }, client);
|
|
63
63
|
const masterRefInputs = [...masterOnly.inputs, ...carried.inputs];
|
|
64
|
+
/** Scenes that failed, so one bad scene cannot lose the whole batch. */
|
|
65
|
+
const failed = [];
|
|
64
66
|
const report = carried.report || masterOnly.report
|
|
65
67
|
? { ...masterOnly.report, ...carried.report }
|
|
66
68
|
: undefined;
|
|
@@ -79,17 +81,44 @@ export function registerSetTools(server, client) {
|
|
|
79
81
|
// 2. scene prompts (explicit, or N repeats of master_prompt for variations)
|
|
80
82
|
const scenePrompts = args.scenes ?? Array.from({ length: args.count ?? 0 }, () => args.master_prompt);
|
|
81
83
|
const mode = args.reference_mode ?? 'master';
|
|
84
|
+
// A set is N+1 billed generations in one tools/call, and any one of
|
|
85
|
+
// them can fail — a safety filter on a single scene, a transient 5xx.
|
|
86
|
+
// `Promise.all` rejected the whole batch on the first failure, losing
|
|
87
|
+
// the master and every scene that HAD succeeded, and surfaced as an
|
|
88
|
+
// opaque "Error occurred during tool execution" with no indication of
|
|
89
|
+
// which scene or why. Failures are collected per scene instead.
|
|
90
|
+
const sceneName = (i) => `${slug}-${String(i + 1).padStart(2, '0')}`;
|
|
91
|
+
const record = (err, i) => {
|
|
92
|
+
failed.push({
|
|
93
|
+
scene: i + 1,
|
|
94
|
+
prompt: scenePrompts[i],
|
|
95
|
+
error: err instanceof Error ? err.message : String(err),
|
|
96
|
+
});
|
|
97
|
+
};
|
|
82
98
|
if (mode === 'chain') {
|
|
83
99
|
let ref = master;
|
|
84
100
|
for (let i = 0; i < scenePrompts.length; i++) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
101
|
+
try {
|
|
102
|
+
const { images: [img] } = await client.generate({ prompt: scenePrompts[i], images: [ref, ...carried.inputs], seed: seed + i + 1, ...cfg });
|
|
103
|
+
named.push({ image: img, base: sceneName(i) });
|
|
104
|
+
// Chain mode anchors each scene on the previous OUTPUT, so a
|
|
105
|
+
// failure must not advance the reference — the next scene
|
|
106
|
+
// continues from the last image that actually exists.
|
|
107
|
+
ref = img;
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
record(err, i);
|
|
111
|
+
}
|
|
88
112
|
}
|
|
89
113
|
}
|
|
90
114
|
else {
|
|
91
|
-
const
|
|
92
|
-
|
|
115
|
+
const settled = await Promise.allSettled(scenePrompts.map((p, i) => client.generate({ prompt: p, images: [master, ...carried.inputs], seed: seed + i + 1, ...cfg }).then((r) => r.images[0])));
|
|
116
|
+
settled.forEach((outcome, i) => {
|
|
117
|
+
if (outcome.status === 'fulfilled')
|
|
118
|
+
named.push({ image: outcome.value, base: sceneName(i) });
|
|
119
|
+
else
|
|
120
|
+
record(outcome.reason, i);
|
|
121
|
+
});
|
|
93
122
|
}
|
|
94
123
|
return result;
|
|
95
124
|
});
|
|
@@ -101,6 +130,13 @@ export function registerSetTools(server, client) {
|
|
|
101
130
|
meta.grounding = masterResult.grounding;
|
|
102
131
|
if (report)
|
|
103
132
|
meta.image_inputs = report;
|
|
133
|
+
if (failed.length) {
|
|
134
|
+
meta.failed_scenes = failed;
|
|
135
|
+
meta.partial =
|
|
136
|
+
`${failed.length} of ${failed.length + named.length - 1} scene(s) failed; the master and the ` +
|
|
137
|
+
`${named.length - 1} scene(s) that succeeded are returned above. Re-run just the failed prompts rather than the whole set — ` +
|
|
138
|
+
'each failure message is in failed_scenes.';
|
|
139
|
+
}
|
|
104
140
|
// A set is master + N scenes in one tools/call — the most timeout-prone
|
|
105
141
|
// tool. Effective image count drives the risk hint.
|
|
106
142
|
const risk = timeoutRiskHint({ model, imageSize: args.image_size, count: named.length, persistsFiles: client.mediaSink?.persistsFiles });
|
package/dist/tools/shared.js
CHANGED
|
@@ -4,6 +4,7 @@ import { resolveVideoPath, videoMimeType } from '../images.js';
|
|
|
4
4
|
import { IMAGE_URL_MAX_BYTES } from '../fetch-image.js';
|
|
5
5
|
import { wholeMb } from '../bytes.js';
|
|
6
6
|
import { createDiskSink } from '../storage/media.js';
|
|
7
|
+
import { downloadFilename } from '../media-endpoint.js';
|
|
7
8
|
/**
|
|
8
9
|
* Fallback sink for call sites that pass no explicit one (every pre-existing
|
|
9
10
|
* test, and any caller constructed before the sink seam existed). The stdio
|
|
@@ -265,9 +266,10 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
|
|
|
265
266
|
return { content: blocks };
|
|
266
267
|
}
|
|
267
268
|
const sink = opts.sink ?? DEFAULT_SINK;
|
|
268
|
-
const
|
|
269
|
+
const persisted = await sink.persist(named.map((n) => ({ base: n.base, base64: n.media.base64, mimeType: n.media.mimeType })), {
|
|
269
270
|
output_dir: opts.output_dir,
|
|
270
271
|
});
|
|
272
|
+
const refs = persisted.map((p) => p.ref);
|
|
271
273
|
// `onWritten` (sidecars, the written-outputs set) is meaningful only when the
|
|
272
274
|
// refs are real files. On a sink with no filesystem there is nothing to sit
|
|
273
275
|
// next to, so it is skipped rather than handed URLs it would misinterpret.
|
|
@@ -279,8 +281,37 @@ export async function emitMedia(named, kind, opts, meta, onWritten) {
|
|
|
279
281
|
// key is the same (`images`/`videos`/`audios`), so without this a caller would
|
|
280
282
|
// reasonably read a URL as a file path.
|
|
281
283
|
const storageNote = sink.note();
|
|
284
|
+
// Per-item detail (`url` / `r2_key` / `expires_at`) alongside the flat list,
|
|
285
|
+
// so a caller can reason about retention without parsing a URL. Only emitted
|
|
286
|
+
// where there IS extra detail — the disk sink's absolute paths say it all,
|
|
287
|
+
// and adding a parallel array there would change a stable result shape.
|
|
288
|
+
// A stored-but-unaddressable object is a configuration failure, not a mode.
|
|
289
|
+
// Saying so in the result beats handing back a bare key that reads like a
|
|
290
|
+
// filename — the silent-unopenable-ref problem this change exists to end.
|
|
291
|
+
const unavailable = persisted.some((p) => p.unavailable)
|
|
292
|
+
? {
|
|
293
|
+
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. ' +
|
|
294
|
+
'This is a connector misconfiguration: set MEDIA_PUBLIC_BASE_URL, or use inline: true to receive the bytes directly.',
|
|
295
|
+
}
|
|
296
|
+
: {};
|
|
297
|
+
const detailed = persisted.filter((p) => p.key !== undefined);
|
|
298
|
+
const media = detailed.length === persisted.length && detailed.length > 0
|
|
299
|
+
? {
|
|
300
|
+
media: persisted.map((p) => ({
|
|
301
|
+
url: p.ref,
|
|
302
|
+
...(p.key ? { r2_key: p.key } : {}),
|
|
303
|
+
...(p.expiresAt ? { expires_at: p.expiresAt } : {}),
|
|
304
|
+
// A ready-to-run command, not a description of one. Models copy an
|
|
305
|
+
// exact string far more reliably than they compose one — and in a
|
|
306
|
+
// chat client this download-then-attach flow is the ONLY way the end
|
|
307
|
+
// user actually sees the image (inline MCP blocks and markdown embeds
|
|
308
|
+
// both render for the model and not for them).
|
|
309
|
+
...(p.key ? { curl_hint: `curl -sS -o ${downloadFilename(p.key)} "${p.ref}"` } : {}),
|
|
310
|
+
})),
|
|
311
|
+
}
|
|
312
|
+
: {};
|
|
282
313
|
const storage = sink.persistsFiles ? {} : { storage: sink.kind, storage_note: storageNote };
|
|
283
|
-
return textResult({ [`${kind}s`]: refs, ...note, ...storage, ...meta });
|
|
314
|
+
return textResult({ [`${kind}s`]: refs, ...media, ...unavailable, ...note, ...storage, ...meta });
|
|
284
315
|
}
|
|
285
316
|
/**
|
|
286
317
|
* Image convenience wrapper over {@link emitMedia} — preserves the original
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Single source of the server version. release-please bumps the literal below. */
|
|
2
|
-
export const VERSION = '1.
|
|
2
|
+
export const VERSION = '1.4.0'; // x-release-please-version
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrischall/gemini-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/gemini-mcp",
|
|
5
5
|
"description": "Gemini image-generation MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/gemini-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.4.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/gemini-mcp",
|
|
14
|
-
"version": "1.
|
|
14
|
+
"version": "1.4.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|