@chrischall/gemini-mcp 1.6.1 → 1.6.2
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/dist/blob-store.js +18 -2
- package/dist/bundle.js +608 -598
- package/dist/client.js +6 -2
- package/dist/errors.js +120 -0
- package/dist/index.js +6 -25
- package/dist/media-url.js +10 -3
- package/dist/registrars.js +50 -0
- package/dist/signed-url.js +42 -0
- package/dist/storage/media.js +6 -3
- package/dist/tools/uploads.js +42 -19
- package/dist/upload-url.js +24 -4
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/client.js
CHANGED
|
@@ -776,7 +776,11 @@ function hostedStorage() {
|
|
|
776
776
|
const tenant = () => tenantIdFor(readEnvVar('GEMINI_API_KEY') ?? 'local');
|
|
777
777
|
return {
|
|
778
778
|
mediaSink: createR2Sink(blob.bucket, {
|
|
779
|
-
|
|
779
|
+
// The SAME `links` object the minter gets below. Media GETs and upload
|
|
780
|
+
// PUTs are one store reached two ways; handing each its own base URL is
|
|
781
|
+
// how the re-host moved the media links to /b/<registrationId>/ and left
|
|
782
|
+
// the upload links behind. One object, two shapes, no second edit.
|
|
783
|
+
links: blob.links,
|
|
780
784
|
sign: blob.signRead,
|
|
781
785
|
urlTtlMs: BLOB_URL_TTL_MS,
|
|
782
786
|
// Hard ceiling on any PER-PERSIST override. `gemini_image_set` asks for
|
|
@@ -792,7 +796,7 @@ function hostedStorage() {
|
|
|
792
796
|
tenant,
|
|
793
797
|
}),
|
|
794
798
|
library: createR2Library(blob.bucket, { tenant }),
|
|
795
|
-
uploadUrls: createUploadUrlMinter({
|
|
799
|
+
uploadUrls: createUploadUrlMinter({ links: blob.links, sign: blob.signWrite, tenant }),
|
|
796
800
|
};
|
|
797
801
|
}
|
|
798
802
|
/**
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Making a failure say what actually failed.
|
|
3
|
+
*
|
|
4
|
+
* A hosted `gemini_get_upload_url` call came back to the client as
|
|
5
|
+
* `{"error": "Error occurred during tool execution", "request_id": "req_…"}` —
|
|
6
|
+
* no message, no failing step, no stack. That outer wrapper is the client's and
|
|
7
|
+
* not ours to change, but it is only ever reached when what we hand back is
|
|
8
|
+
* itself uninformative, and two of this repo's habits make that likely:
|
|
9
|
+
*
|
|
10
|
+
* - a thrown non-`McpToolError` surfaces as bare `err.message`, which for a
|
|
11
|
+
* runtime fault (`Illegal invocation`, `fetch failed`) names neither the
|
|
12
|
+
* tool nor the operation that was in flight; and
|
|
13
|
+
* - `cause` is dropped by MCP serialization, so a wrapped error arrives with
|
|
14
|
+
* its actual root cause deleted — the trap `ChainedRequest404Error` in
|
|
15
|
+
* client.ts already documents for `hint`, which the host drops too.
|
|
16
|
+
*
|
|
17
|
+
* So: {@link describeError} flattens the whole chain into the message,
|
|
18
|
+
* {@link step} labels the operation, and {@link surfaceToolErrors} wraps a
|
|
19
|
+
* server so EVERY registered handler gets both without each registrar
|
|
20
|
+
* remembering to. `index.ts` applies it to every registrar in the list.
|
|
21
|
+
*/
|
|
22
|
+
import { McpToolError } from '@chrischall/mcp-utils';
|
|
23
|
+
/** How far down a `cause` chain to walk before assuming it is pathological. */
|
|
24
|
+
const MAX_CAUSE_DEPTH = 5;
|
|
25
|
+
/**
|
|
26
|
+
* One flat, self-describing line for anything throwable — including the
|
|
27
|
+
* `cause` chain, which MCP serialization deletes.
|
|
28
|
+
*
|
|
29
|
+
* The error's class name is prefixed only when it carries information: a bare
|
|
30
|
+
* `Error` says nothing a reader does not already know, while `TypeError:
|
|
31
|
+
* Illegal invocation` is the entire diagnosis of the workerd receiver bug (see
|
|
32
|
+
* the fetchImpl note in CLAUDE.md).
|
|
33
|
+
*/
|
|
34
|
+
export function describeError(err) {
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
const describe = (value, depth) => {
|
|
37
|
+
if (!(value instanceof Error)) {
|
|
38
|
+
if (typeof value === 'string')
|
|
39
|
+
return value;
|
|
40
|
+
try {
|
|
41
|
+
return typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return String(value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// A generic `Error` name is noise; anything else is the diagnosis.
|
|
48
|
+
const named = value.name && value.name !== 'Error' && value.name !== 'McpToolError'
|
|
49
|
+
? `${value.name}: ${value.message}`
|
|
50
|
+
: value.message;
|
|
51
|
+
const hint = value instanceof McpToolError && value.hint && !value.message.includes(value.hint)
|
|
52
|
+
? ` ${value.hint}`
|
|
53
|
+
: '';
|
|
54
|
+
const cause = value.cause;
|
|
55
|
+
// Self-referential and over-deep chains are real (a retry wrapper that
|
|
56
|
+
// re-wraps its own error); walking one forever is not an option.
|
|
57
|
+
if (cause === undefined || cause === null || depth >= MAX_CAUSE_DEPTH || seen.has(cause)) {
|
|
58
|
+
return `${named}${hint}`;
|
|
59
|
+
}
|
|
60
|
+
seen.add(cause);
|
|
61
|
+
return `${named}${hint} [caused by: ${describe(cause, depth + 1)}]`;
|
|
62
|
+
};
|
|
63
|
+
seen.add(err);
|
|
64
|
+
return describe(err, 0);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Run one named operation, and on failure say which one it was.
|
|
68
|
+
*
|
|
69
|
+
* Only the INNERMOST step labels: an already-labelled failure passes straight
|
|
70
|
+
* through, so a nested `step` reports `signing the upload URL: …` rather than
|
|
71
|
+
* `minting: signing the upload URL: …`. The label is what the caller needs;
|
|
72
|
+
* the call stack above it is not.
|
|
73
|
+
*/
|
|
74
|
+
export async function step(label, run) {
|
|
75
|
+
try {
|
|
76
|
+
return await run();
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (err instanceof StepError)
|
|
80
|
+
throw err;
|
|
81
|
+
throw new StepError(`${label}: ${describeError(err)}`, err);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** An `McpToolError` that has already been attributed to a step. */
|
|
85
|
+
class StepError extends McpToolError {
|
|
86
|
+
constructor(message, cause) {
|
|
87
|
+
super(message, { cause });
|
|
88
|
+
this.name = 'McpToolError';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Wrap a server so every tool registered through it reports failures fully.
|
|
93
|
+
*
|
|
94
|
+
* A `Proxy` rather than a spread: `McpServer` methods are bound to real private
|
|
95
|
+
* state, and registrars reach for more than `registerTool` (`server.server`,
|
|
96
|
+
* `registerResource`). Everything but `registerTool` passes through untouched,
|
|
97
|
+
* and a successful result is returned byte-identically — this only ever changes
|
|
98
|
+
* what an exception looks like.
|
|
99
|
+
*/
|
|
100
|
+
export function surfaceToolErrors(server) {
|
|
101
|
+
return new Proxy(server, {
|
|
102
|
+
get(target, prop, receiver) {
|
|
103
|
+
const value = Reflect.get(target, prop, receiver);
|
|
104
|
+
if (prop !== 'registerTool' || typeof value !== 'function') {
|
|
105
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
106
|
+
}
|
|
107
|
+
return (name, config, handler) => value.call(target, name, config, async (...args) => {
|
|
108
|
+
try {
|
|
109
|
+
return await handler(...args);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
// Named for the tool AND the underlying error. When the client
|
|
113
|
+
// still shows its own generic wrapper, this text is what a
|
|
114
|
+
// maintainer has to work from — so it carries everything.
|
|
115
|
+
throw new McpToolError(`${name} failed: ${describeError(err)}`, { cause: err });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -2,16 +2,7 @@
|
|
|
2
2
|
import { runMcp } from '@chrischall/mcp-utils';
|
|
3
3
|
import { loadStdioDotenv } from './dotenv.js';
|
|
4
4
|
import { VERSION } from './version.js';
|
|
5
|
-
import {
|
|
6
|
-
import { registerGenerateTools } from './tools/generate.js';
|
|
7
|
-
import { registerSetTools } from './tools/set.js';
|
|
8
|
-
import { registerInteractTools } from './tools/interact.js';
|
|
9
|
-
import { registerVideoTools } from './tools/video.js';
|
|
10
|
-
import { registerMusicTools } from './tools/music.js';
|
|
11
|
-
import { registerJobTools } from './tools/jobs.js';
|
|
12
|
-
import { registerFileTools } from './tools/files.js';
|
|
13
|
-
import { registerUploadUrlTools } from './tools/uploads.js';
|
|
14
|
-
import { registerLibraryTools } from './tools/library.js';
|
|
5
|
+
import { TOOL_REGISTRARS } from './registrars.js';
|
|
15
6
|
import { client } from './client.js';
|
|
16
7
|
// Load `.env` here rather than in client.ts: `import.meta.url` + async I/O at
|
|
17
8
|
// module scope is not safe in every runtime this module graph reaches (see
|
|
@@ -29,24 +20,14 @@ await loadStdioDotenv();
|
|
|
29
20
|
// boots and answers the host's install-time tools/list probe even without
|
|
30
21
|
// GEMINI_API_KEY. It also owns this process's session state (job registry,
|
|
31
22
|
// last-interaction memory); stdio is single-user, so one client is one session.
|
|
23
|
+
//
|
|
24
|
+
// The roster itself lives in registrars.ts: this file cannot be imported
|
|
25
|
+
// without booting a server, and a list nothing can import is a list nothing
|
|
26
|
+
// can test (see the note there).
|
|
32
27
|
await runMcp({
|
|
33
28
|
name: 'gemini-mcp',
|
|
34
29
|
version: VERSION,
|
|
35
30
|
banner: '[gemini-mcp] This project was developed and is maintained by AI (Claude). Use at your own discretion.',
|
|
36
|
-
tools:
|
|
37
|
-
registerModelTools,
|
|
38
|
-
registerGenerateTools,
|
|
39
|
-
registerSetTools,
|
|
40
|
-
registerInteractTools,
|
|
41
|
-
registerVideoTools,
|
|
42
|
-
registerMusicTools,
|
|
43
|
-
registerJobTools,
|
|
44
|
-
registerFileTools,
|
|
45
|
-
// Both gate on the client having somewhere to put bytes: hosted with a
|
|
46
|
-
// blob store they register, and on a local install they no-op rather than
|
|
47
|
-
// advertising a tool that cannot work.
|
|
48
|
-
registerUploadUrlTools,
|
|
49
|
-
registerLibraryTools,
|
|
50
|
-
],
|
|
31
|
+
tools: TOOL_REGISTRARS,
|
|
51
32
|
deps: client,
|
|
52
33
|
});
|
package/dist/media-url.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* Kept free of any Cloudflare import so the whole thing is testable under Node:
|
|
16
16
|
* WebCrypto is the only primitive used, and it exists on both runtimes.
|
|
17
17
|
*/
|
|
18
|
+
import { signedLinks } from './signed-url.js';
|
|
18
19
|
/** How long a freshly minted media URL stays valid. Matches Files API retention. */
|
|
19
20
|
export const MEDIA_URL_TTL_MS = 48 * 60 * 60 * 1000;
|
|
20
21
|
/** KV key holding the lazily-generated signing secret (zero-config path). */
|
|
@@ -128,8 +129,14 @@ export async function verifyMediaSignature(signingKey, key, exp, sig, nowMs) {
|
|
|
128
129
|
return { ok: false, reason: 'expired' };
|
|
129
130
|
return { ok: true };
|
|
130
131
|
}
|
|
131
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* `<base>/<key>?exp=…&sig=…` — the URL handed back to the caller.
|
|
134
|
+
*
|
|
135
|
+
* Delegates to {@link signedLinks} so this and `buildUploadUrl` cannot drift
|
|
136
|
+
* apart the way they did across the re-host. Prefer taking a {@link SignedLinks}
|
|
137
|
+
* over a base-URL string in new code; this wrapper exists for callers that
|
|
138
|
+
* already hold a base.
|
|
139
|
+
*/
|
|
132
140
|
export function buildMediaUrl(baseUrl, key, expiresAtMs, signature) {
|
|
133
|
-
|
|
134
|
-
return `${baseUrl.replace(/\/+$/, '')}/${path}?exp=${expiresAtMs}&sig=${signature}`;
|
|
141
|
+
return signedLinks(baseUrl).media(key, expiresAtMs, signature);
|
|
135
142
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tool roster, and the one place error reporting is attached to it.
|
|
3
|
+
*
|
|
4
|
+
* This lives apart from `index.ts` because that file is an ENTRY POINT: it has
|
|
5
|
+
* top-level `await loadStdioDotenv()` and `await runMcp(…)`, so importing it
|
|
6
|
+
* starts a server on stdio. Nothing can assert what it wires without booting
|
|
7
|
+
* it — which is precisely how `.map(reporting)` could be deleted with the
|
|
8
|
+
* suite still green, leaving hosted failures unattributed again (the exact
|
|
9
|
+
* regression this repo just spent a debugging round trip on). Here the list is
|
|
10
|
+
* an ordinary value, and `tests/registrars.test.ts` runs a registrar through it.
|
|
11
|
+
*/
|
|
12
|
+
import { surfaceToolErrors } from './errors.js';
|
|
13
|
+
import { registerModelTools } from './tools/models.js';
|
|
14
|
+
import { registerGenerateTools } from './tools/generate.js';
|
|
15
|
+
import { registerSetTools } from './tools/set.js';
|
|
16
|
+
import { registerInteractTools } from './tools/interact.js';
|
|
17
|
+
import { registerVideoTools } from './tools/video.js';
|
|
18
|
+
import { registerMusicTools } from './tools/music.js';
|
|
19
|
+
import { registerJobTools } from './tools/jobs.js';
|
|
20
|
+
import { registerFileTools } from './tools/files.js';
|
|
21
|
+
import { registerUploadUrlTools } from './tools/uploads.js';
|
|
22
|
+
import { registerLibraryTools } from './tools/library.js';
|
|
23
|
+
/**
|
|
24
|
+
* Give one registrar a server that reports failures in full.
|
|
25
|
+
*
|
|
26
|
+
* A hosted upload-URL failure reached the client as a bare "Error occurred
|
|
27
|
+
* during tool execution" — no message, no failing step. That wrapper is the
|
|
28
|
+
* client's, but it is only ever the whole story when our own text is empty, so
|
|
29
|
+
* every handler's throw is turned into a message naming the tool, the real
|
|
30
|
+
* error class, and the `cause` chain MCP serialization drops (src/errors.ts).
|
|
31
|
+
*/
|
|
32
|
+
export const reporting = (register) => (server, deps) => register(surfaceToolErrors(server), deps);
|
|
33
|
+
/**
|
|
34
|
+
* Every registrar, already wrapped. `index.ts` passes this straight to
|
|
35
|
+
* `runMcp`; the last two self-gate on the client having somewhere to put bytes
|
|
36
|
+
* (hosted with a blob store they register, on a local install they no-op
|
|
37
|
+
* rather than advertising a tool that cannot work).
|
|
38
|
+
*/
|
|
39
|
+
export const TOOL_REGISTRARS = [
|
|
40
|
+
registerModelTools,
|
|
41
|
+
registerGenerateTools,
|
|
42
|
+
registerSetTools,
|
|
43
|
+
registerInteractTools,
|
|
44
|
+
registerVideoTools,
|
|
45
|
+
registerMusicTools,
|
|
46
|
+
registerJobTools,
|
|
47
|
+
registerFileTools,
|
|
48
|
+
registerUploadUrlTools,
|
|
49
|
+
registerLibraryTools,
|
|
50
|
+
].map(reporting);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place that knows how a link into the object store is SHAPED.
|
|
3
|
+
*
|
|
4
|
+
* There are two kinds of signed link — a media GET (`?exp=&sig=`) and an
|
|
5
|
+
* upload PUT (`?ct=&exp=&sig=`) — and before this module they were two
|
|
6
|
+
* independent copies of the same string building, in two files, each holding
|
|
7
|
+
* its own base URL. That is exactly how the re-host from the retired Cloudflare
|
|
8
|
+
* connector to mcp-host split them: the media builder was moved onto the new
|
|
9
|
+
* `https://host/b/<registrationId>` base and the upload builder was a separate
|
|
10
|
+
* edit in a separate file, so nothing structural said the two had to agree.
|
|
11
|
+
*
|
|
12
|
+
* Both builders now delegate here, and {@link signedLinks} binds ONE base URL
|
|
13
|
+
* to both of them at once, so the next re-host either moves both or neither.
|
|
14
|
+
*
|
|
15
|
+
* Byte-compatible with what shipped: the path is percent-encoded per segment
|
|
16
|
+
* (separators survive, everything else is encoded — the gateway decodes per
|
|
17
|
+
* segment and verifies against the LOGICAL key), and the query parameters keep
|
|
18
|
+
* their historical order. Every link ever minted still parses.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* `<base>/<encoded key>?<params>`.
|
|
22
|
+
*
|
|
23
|
+
* Parameter order is insertion order and is part of the observable shape —
|
|
24
|
+
* nothing verifies against it, but the curl commands in `curl_hint` and every
|
|
25
|
+
* link in a user's history read the same way across a redeploy.
|
|
26
|
+
*/
|
|
27
|
+
export function signedObjectUrl(baseUrl, key, params) {
|
|
28
|
+
const path = key.split('/').map(encodeURIComponent).join('/');
|
|
29
|
+
const query = new URLSearchParams();
|
|
30
|
+
for (const [name, value] of Object.entries(params))
|
|
31
|
+
query.set(name, String(value));
|
|
32
|
+
return `${baseUrl.replace(/\/+$/, '')}/${path}?${query}`;
|
|
33
|
+
}
|
|
34
|
+
/** Bind one base URL to both link shapes. */
|
|
35
|
+
export function signedLinks(baseUrl) {
|
|
36
|
+
const base = baseUrl.replace(/\/+$/, '');
|
|
37
|
+
return {
|
|
38
|
+
base,
|
|
39
|
+
media: (key, expiresAtMs, signature) => signedObjectUrl(base, key, { exp: expiresAtMs, sig: signature }),
|
|
40
|
+
upload: (key, contentType, expiresAtMs, signature) => signedObjectUrl(base, key, { ct: contentType.toLowerCase(), exp: expiresAtMs, sig: signature }),
|
|
41
|
+
};
|
|
42
|
+
}
|
package/dist/storage/media.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* behaviour and must stay byte-for-byte equivalent to it.
|
|
13
13
|
*/
|
|
14
14
|
import { base64ToBytes } from '../bytes.js';
|
|
15
|
+
import { signedLinks } from '../signed-url.js';
|
|
15
16
|
/**
|
|
16
17
|
* The stdio sink: `resolveOutputDir(output_dir)` → `writeMedia` per item, in
|
|
17
18
|
* order (so `uniquePath`'s `name`/`name-2`/`name-3` sequencing is preserved).
|
|
@@ -65,7 +66,7 @@ export function createR2Sink(bucket, opts) {
|
|
|
65
66
|
const now = opts.now ?? (() => new Date());
|
|
66
67
|
const randomId = opts.randomId ?? (() => crypto.randomUUID().slice(0, 8));
|
|
67
68
|
const publicBase = opts.publicBaseUrl?.replace(/\/+$/, '');
|
|
68
|
-
const signedBase = opts.signedBaseUrl?.replace(/\/+$/, '');
|
|
69
|
+
const signedBase = opts.links?.base ?? opts.signedBaseUrl?.replace(/\/+$/, '');
|
|
69
70
|
const ttlMs = opts.urlTtlMs ?? 0;
|
|
70
71
|
const readPrefixes = [prefix, ...(opts.readPrefixes ?? [])];
|
|
71
72
|
let tenantCache;
|
|
@@ -145,9 +146,11 @@ export function createR2Sink(bucket, opts) {
|
|
|
145
146
|
if (publicBase)
|
|
146
147
|
return { ref: `${publicBase}/${key}`, key };
|
|
147
148
|
if (signedBase && opts.sign) {
|
|
148
|
-
const { buildMediaUrl } = await import('../media-url.js');
|
|
149
149
|
const signature = await opts.sign(key, expiresAtMs);
|
|
150
|
-
|
|
150
|
+
// `signedLinks` is the ONE shape-owner shared with the upload-URL minter
|
|
151
|
+
// (src/signed-url.ts) — never hand-build the query here.
|
|
152
|
+
const link = (opts.links ?? signedLinks(signedBase)).media(key, expiresAtMs, signature);
|
|
153
|
+
return { ref: link, key, expiresAt: new Date(expiresAtMs).toISOString() };
|
|
151
154
|
}
|
|
152
155
|
// Neither configured. Unreachable in the Worker (which always passes its
|
|
153
156
|
// own origin), so this is a misconfiguration rather than a mode — say so
|
package/dist/tools/uploads.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { textResult } from '@chrischall/mcp-utils';
|
|
3
3
|
import { UPLOAD_MAX_BYTES, RASTER_IMAGE_TYPE_PATTERN } from '../upload-url.js';
|
|
4
4
|
import { wholeMb } from '../bytes.js';
|
|
5
|
+
import { step } from '../errors.js';
|
|
5
6
|
/**
|
|
6
7
|
* `gemini_get_upload_url` — mint a short-lived signed PUT URL so a shell can
|
|
7
8
|
* upload a reference image with ZERO auth headers:
|
|
@@ -15,9 +16,27 @@ import { wholeMb } from '../bytes.js';
|
|
|
15
16
|
* shell doesn't have. A signed URL is mintable by the session and usable by
|
|
16
17
|
* the shell.
|
|
17
18
|
*
|
|
18
|
-
* Hosted
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* Hosted only — stdio has no HTTP surface to PUT against (and no need: it reads
|
|
20
|
+
* local paths directly). Gated on `client.uploadUrls`, the same pattern as
|
|
21
|
+
* `gemini_sign_media` / the library tools.
|
|
22
|
+
*
|
|
23
|
+
* ## The PUT response body is NOT the contract any more
|
|
24
|
+
*
|
|
25
|
+
* The retired Cloudflare connector served `/put` itself and answered
|
|
26
|
+
* `201 { r2_key, size_bytes, content_type, hint }`. mcp-host's blob gateway is
|
|
27
|
+
* a generic object store: it answers a bare 2xx and the body is whatever the
|
|
28
|
+
* gateway feels like. So the `r2_key` a caller must keep is the one in THIS
|
|
29
|
+
* result, minted here, known before a byte is sent — and success is "the PUT
|
|
30
|
+
* returned 2xx", nothing finer. Every hint below says so; don't reintroduce
|
|
31
|
+
* guidance to read the key out of the PUT response, it isn't there.
|
|
32
|
+
*
|
|
33
|
+
* ## Minting writes nothing
|
|
34
|
+
*
|
|
35
|
+
* There is no reservation, no pending-upload record, no placeholder object —
|
|
36
|
+
* a mint is a tenant id, a key string and one HMAC. That is what makes a failed
|
|
37
|
+
* mint atomic: it cannot leave orphaned state behind because it never had any
|
|
38
|
+
* to leave. `tests/upload-flow.test.ts` holds that property to a fake gateway
|
|
39
|
+
* that fails the call if it is touched at all.
|
|
21
40
|
*/
|
|
22
41
|
export function registerUploadUrlTools(server, client) {
|
|
23
42
|
const minter = client.uploadUrls;
|
|
@@ -26,10 +45,12 @@ export function registerUploadUrlTools(server, client) {
|
|
|
26
45
|
server.registerTool('gemini_get_upload_url', {
|
|
27
46
|
description: 'Mint a short-lived (~10 min) signed PUT URL for uploading one reference image to this connector with NO auth ' +
|
|
28
47
|
'headers — usable from any shell: curl -sS -X PUT -H "Content-Type: image/jpeg" --data-binary @photo.jpg "<upload_url>". ' +
|
|
29
|
-
'
|
|
30
|
-
'
|
|
31
|
-
'
|
|
32
|
-
|
|
48
|
+
'A 2xx from that PUT means the bytes are stored; the key to reference them is the `r2_key` in THIS result (the PUT ' +
|
|
49
|
+
'itself returns no useful body). Pass that r2_key in `images_r2_keys` on generation tools, save it permanently with ' +
|
|
50
|
+
'gemini_save_character, or convert it to a ~48h Files API reference with gemini_upload_file({ r2_key }). Prefer this ' +
|
|
51
|
+
'over images_base64 (which costs ~14k tokens per photo) whenever the caller has a shell. Images only, up to ' +
|
|
52
|
+
`${wholeMb(UPLOAD_MAX_BYTES)}MB. The PUT must send exactly the Content-Type this URL was minted for — the signature ` +
|
|
53
|
+
'covers it, so a mismatched header is rejected.',
|
|
33
54
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
34
55
|
inputSchema: {
|
|
35
56
|
filename: z
|
|
@@ -45,13 +66,11 @@ export function registerUploadUrlTools(server, client) {
|
|
|
45
66
|
.describe('MIME type of the bytes that will be PUT — raster images only (jpeg/png/webp/gif/avif/heic/heif/bmp/tiff; not SVG). The PUT must send exactly this Content-Type.'),
|
|
46
67
|
},
|
|
47
68
|
}, async (args) => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
throw new McpToolError(err instanceof Error ? err.message : String(err));
|
|
54
|
-
}
|
|
69
|
+
// `step` names the operation in the message, and `surfaceToolErrors`
|
|
70
|
+
// (wired in index.ts) prefixes the tool and flattens the cause chain —
|
|
71
|
+
// between them a mint failure can no longer reach the client as a bare
|
|
72
|
+
// "Error occurred during tool execution".
|
|
73
|
+
const minted = await step(`minting a signed upload URL for ${args.filename} (${args.content_type})`, () => minter.mint(args.filename, args.content_type));
|
|
55
74
|
return textResult({
|
|
56
75
|
upload_url: minted.url,
|
|
57
76
|
method: 'PUT',
|
|
@@ -60,10 +79,14 @@ export function registerUploadUrlTools(server, client) {
|
|
|
60
79
|
expires_at: new Date(minted.expiresAtMs).toISOString(),
|
|
61
80
|
max_bytes: UPLOAD_MAX_BYTES,
|
|
62
81
|
curl_hint: `curl -sS -X PUT -H "Content-Type: ${minted.contentType}" --data-binary @${args.filename} "${minted.url}"`,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
82
|
+
// Named for what it is: the PUT's own body is the object store's, not
|
|
83
|
+
// this connector's, so 2xx is the whole success signal.
|
|
84
|
+
success_is: 'any 2xx from the PUT; the response body is not part of the contract and carries no r2_key',
|
|
85
|
+
hint: 'PUT the raw bytes within ~10 minutes with exactly the Content-Type above; no Authorization header is needed ' +
|
|
86
|
+
'(the signature in the URL is the auth, and it covers the content type). On a 2xx, reference the `r2_key` from ' +
|
|
87
|
+
'THIS result — not the PUT response: images_r2_keys on generation tools (master_images_r2_keys on ' +
|
|
88
|
+
'gemini_image_set), gemini_save_character to keep it with no expiry, or gemini_upload_file({ r2_key }) for a ' +
|
|
89
|
+
'~48h files/… reference. Uploads themselves follow the media retention schedule (default 7 days).',
|
|
67
90
|
});
|
|
68
91
|
});
|
|
69
92
|
}
|
package/dist/upload-url.js
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
* import so it tests under Node.
|
|
33
33
|
*/
|
|
34
34
|
import { base64UrlEncode, base64UrlDecode } from './media-url.js';
|
|
35
|
+
import { signedLinks } from './signed-url.js';
|
|
35
36
|
import { safeKeySegment } from './storage/media.js';
|
|
36
37
|
/** How long a minted upload URL stays usable. Deliberately short — it exists to
|
|
37
38
|
* be curled within the same conversation turn that minted it. */
|
|
@@ -86,10 +87,17 @@ export async function verifyUploadSignature(signingKey, key, contentType, exp, s
|
|
|
86
87
|
return { ok: false, reason: 'expired' };
|
|
87
88
|
return { ok: true };
|
|
88
89
|
}
|
|
89
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* `<base>/<key>?ct=…&exp=…&sig=…` — the URL handed back by the mint tool.
|
|
92
|
+
*
|
|
93
|
+
* Delegates to {@link signedLinks}, the single shape-owner it shares with
|
|
94
|
+
* `buildMediaUrl`. Prefer passing a {@link SignedLinks} into
|
|
95
|
+
* {@link createUploadUrlMinter} (`links`) over a bare `baseUrl`: a store handed
|
|
96
|
+
* to the sink and the minter as one object cannot be re-pointed for one and
|
|
97
|
+
* not the other, which is precisely how the re-host broke this flow.
|
|
98
|
+
*/
|
|
90
99
|
export function buildUploadUrl(baseUrl, key, contentType, expiresAtMs, signature) {
|
|
91
|
-
|
|
92
|
-
return `${baseUrl.replace(/\/+$/, '')}/${path}?ct=${encodeURIComponent(contentType.toLowerCase())}&exp=${expiresAtMs}&sig=${signature}`;
|
|
100
|
+
return signedLinks(baseUrl).upload(key, contentType, expiresAtMs, signature);
|
|
93
101
|
}
|
|
94
102
|
/**
|
|
95
103
|
* RASTER image types only — an explicit allowlist, not `image/*`.
|
|
@@ -117,6 +125,18 @@ export function acceptableUploadType(contentType) {
|
|
|
117
125
|
* session's own namespace, so a signed PUT cannot write into anyone else's.
|
|
118
126
|
*/
|
|
119
127
|
export function createUploadUrlMinter(opts) {
|
|
128
|
+
// Fail at construction, not at mint. A minter built without somewhere to
|
|
129
|
+
// point is not a degraded minter, it is a broken one — and the symptom it
|
|
130
|
+
// used to produce (a URL reading `undefined/up/…`, or one pointing at a host
|
|
131
|
+
// that no longer exists) is unreadable from the client side. This is the
|
|
132
|
+
// "mint-with-missing-env fails loudly and atomically" guarantee: it throws
|
|
133
|
+
// before any key is derived and long before anything could be written, and
|
|
134
|
+
// the tool simply does not register.
|
|
135
|
+
const links = opts.links ?? (opts.baseUrl ? signedLinks(opts.baseUrl) : undefined);
|
|
136
|
+
if (!links) {
|
|
137
|
+
throw new Error('createUploadUrlMinter requires `links` (preferred) or `baseUrl`: there is nowhere to point a signed upload URL. ' +
|
|
138
|
+
'Hosted, both come from the blob store — check MCP_BLOB_BASE_URL is set on the deployment.');
|
|
139
|
+
}
|
|
120
140
|
const now = opts.now ?? (() => new Date());
|
|
121
141
|
const randomId = opts.randomId ?? (() => crypto.randomUUID().slice(0, 8));
|
|
122
142
|
const ttlMs = opts.ttlMs ?? UPLOAD_URL_TTL_MS;
|
|
@@ -132,7 +152,7 @@ export function createUploadUrlMinter(opts) {
|
|
|
132
152
|
const key = `${UPLOAD_KEY_PREFIX}/${tenant}/${day}/${randomId()}-${safeKeySegment(filename)}`;
|
|
133
153
|
const expiresAtMs = now().getTime() + ttlMs;
|
|
134
154
|
const signature = await opts.sign(key, ct, expiresAtMs);
|
|
135
|
-
return { url:
|
|
155
|
+
return { url: links.upload(key, ct, expiresAtMs, signature), key, contentType: ct, expiresAtMs };
|
|
136
156
|
},
|
|
137
157
|
};
|
|
138
158
|
}
|
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.6.
|
|
2
|
+
export const VERSION = '1.6.2'; // x-release-please-version
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrischall/gemini-mcp",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.2",
|
|
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.6.
|
|
9
|
+
"version": "1.6.2",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/gemini-mcp",
|
|
14
|
-
"version": "1.6.
|
|
14
|
+
"version": "1.6.2",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|