@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/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
- signedBaseUrl: blob.baseUrl,
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({ baseUrl: blob.baseUrl, sign: blob.signWrite, tenant }),
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 { registerModelTools } from './tools/models.js';
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
- /** `<base>/<key>?exp=…&sig=…` — the URL handed back to the caller. */
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
- const path = key.split('/').map(encodeURIComponent).join('/');
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
+ }
@@ -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
- return { ref: buildMediaUrl(signedBase, key, expiresAtMs, signature), key, expiresAt: new Date(expiresAtMs).toISOString() };
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
@@ -1,7 +1,8 @@
1
1
  import { z } from 'zod';
2
- import { McpToolError, textResult } from '@chrischall/mcp-utils';
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 connector only — stdio has no HTTP surface to PUT against (and no
19
- * need: it reads local paths directly). Gated on `client.uploadUrls`, the same
20
- * pattern as `gemini_sign_media` / the library tools.
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
- 'The response to the PUT returns an `r2_key` to reference the upload: pass it in `images_r2_keys` on generation ' +
30
- 'tools, save it permanently with gemini_save_character, or convert it to a ~48h Files API reference with ' +
31
- 'gemini_upload_file({ r2_key }). Prefer this over images_base64 (which costs ~14k tokens per photo) whenever the ' +
32
- `caller has a shell. Images only, up to ${wholeMb(UPLOAD_MAX_BYTES)}MB.`,
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
- let minted;
49
- try {
50
- minted = await minter.mint(args.filename, args.content_type);
51
- }
52
- catch (err) {
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
- hint: 'PUT the raw bytes within ~10 minutes; no Authorization header is needed (the signature in the URL is the auth). ' +
64
- 'Then reference the returned r2_key: images_r2_keys on generation tools (master_images_r2_keys on gemini_image_set), ' +
65
- 'gemini_save_character to keep it with no expiry, or gemini_upload_file({ r2_key }) for a ~48h files/… reference. ' +
66
- 'Uploads themselves follow the media retention schedule (default 7 days).',
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
  }
@@ -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
- /** `<base>/<key>?ct=…&exp=…&sig=…` — the URL handed back by the mint tool. */
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
- const path = key.split('/').map(encodeURIComponent).join('/');
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: buildUploadUrl(opts.baseUrl, key, ct, expiresAtMs, signature), key, contentType: ct, expiresAtMs };
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.1'; // x-release-please-version
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.1",
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.1",
9
+ "version": "1.6.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "1.6.1",
14
+ "version": "1.6.2",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },