gogcli-mcp 2.29.1 → 2.30.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/manifest.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "manifest_version": "0.3",
4
4
  "name": "gogcli-mcp",
5
5
  "display_name": "gogcli",
6
- "version": "2.29.1",
6
+ "version": "2.30.0",
7
7
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
8
8
  "author": {
9
9
  "name": "Chris Hall",
package/mint.yaml CHANGED
@@ -13,7 +13,7 @@ env:
13
13
  help: >-
14
14
  Google OAuth client id. NOTE: on the local-spawn path the child `gog`
15
15
  receives a sanitized env — runner.ts drops GOG_ACCESS_TOKEN and every
16
- *_TOKEN / *_SECRET / *_API_KEY / *_PRIVATE_KEY variable — so the CLI
16
+ *_TOKEN / *_SECRET / *_KEY / *_CREDENTIALS variable — so the CLI
17
17
  authenticates from its own stored credentials under $HOME (see
18
18
  state.dataDir), not from these variables being passed through.
19
19
  - name: GOG_CLIENT_SECRET
@@ -86,7 +86,7 @@ dependencies:
86
86
  # nine pins too.
87
87
  - kind: github-release
88
88
  repo: openclaw/gogcli
89
- tag: v0.39.1
89
+ tag: v0.40.0
90
90
  asset: "gogcli_*_linux_amd64.tar.gz"
91
91
  bin: [gog]
92
92
  state:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp",
3
- "version": "2.29.1",
3
+ "version": "2.30.0",
4
4
  "mcpName": "io.github.chrischall/gogcli-mcp",
5
5
  "description": "MCP server wrapping gogcli for Google service access",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -41,12 +41,12 @@
41
41
  "test:coverage": "vitest run --coverage"
42
42
  },
43
43
  "dependencies": {
44
- "@chrischall/mcp-utils": "^0.23.0",
44
+ "@chrischall/mcp-utils": "^0.23.3",
45
45
  "@modelcontextprotocol/sdk": "^1.30.0",
46
- "zod": "^4.5.4"
46
+ "zod": "^4.6.1"
47
47
  },
48
48
  "devDependencies": {
49
- "@types/node": "^26.4.1",
49
+ "@types/node": "^26.5.1",
50
50
  "@vitest/coverage-v8": "^4.1.8",
51
51
  "esbuild": "^0.28.2",
52
52
  "typescript": "^7.0.2",
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "packages/gogcli-mcp"
9
9
  },
10
- "version": "2.29.1",
10
+ "version": "2.30.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "2.29.1",
15
+ "version": "2.30.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -0,0 +1,179 @@
1
+ import { readEnvVar } from '@chrischall/mcp-utils';
2
+
3
+ /**
4
+ * Ask the gog runner to stream a file off ITS disk to a signed blob-store URL.
5
+ *
6
+ * ## Why the runner and not this process
7
+ *
8
+ * Under the hosted connector this child is a FORWARDER: `run()` POSTs an
9
+ * arg-array to fly-gog-runner, the Google refresh token lives in that app's
10
+ * keyring, and `gog gmail attachment --out` writes the file to THAT box's disk.
11
+ * The child never sees a byte, so the runner is the only party that can send
12
+ * one anywhere. That is the same seam `deliverViaDrive` already uses — `gog
13
+ * drive upload <path>` pushes bytes from the runner's disk to a remote
14
+ * destination with the child out of the way — with a different destination.
15
+ *
16
+ * Routing them through the child instead would cap out near 24 MB against
17
+ * `/run`'s 32 MB body envelope and cost a ~31 MB base64 string plus a decoded
18
+ * copy, for bytes nobody here reads.
19
+ *
20
+ * ## What the runner does with it
21
+ *
22
+ * `POST /upload` (fly-gog-runner/server.mjs) confines `path` to the attachment
23
+ * directory, refuses anything past the blob store's own 100 MiB ceiling before
24
+ * dialling, and PUTs the file with `Content-Length` from its own size and the
25
+ * `Content-Type` header byte for byte as given — the PUT signature commits to
26
+ * it. Its statuses carry the classification: `422` is deterministic (re-mint,
27
+ * do not retry), `502` is the far side or the transfer, `400`/`404` are this
28
+ * request. All of them are failures here; none of them is retried from this
29
+ * side, because a fresh URL is the only repair for the common one.
30
+ */
31
+
32
+ /**
33
+ * The deadline this side puts on the exchange.
34
+ *
35
+ * The runner has one of its own (`UPLOAD_TIMEOUT_MS`, 120 s) but it is an
36
+ * INACTIVITY timer spent through `req.setTimeout` — it bounds a socket that has
37
+ * gone quiet, never a transfer that keeps dribbling — so nothing else stands
38
+ * between a wedged upload and an MCP request that never answers.
39
+ *
40
+ * Longer than `/run`'s 30 s because this is not a `gog` invocation: it is up to
41
+ * 100 MiB leaving a Fly machine, and firing before the runner can report a real
42
+ * failure would turn "the blob store refused the signature" into an opaque
43
+ * timeout — the same reason `makeFlyExecutor` sits its deadline above the
44
+ * backend's.
45
+ *
46
+ * It is `120 s + 5 s`, and it was 90 s: the doc block above cited that rule
47
+ * while the number inverted it, so a socket that went quiet was aborted HERE
48
+ * 30 s before the runner's own timer could say "the upload timed out after
49
+ * 120000ms". Exactly the opaque client abort the rule exists to prevent, and
50
+ * the one shape where the runner has something useful to say. The grace is
51
+ * `DEADLINE_GRACE_MS`'s 5 s for `DEADLINE_GRACE_MS`'s reason: enough for the
52
+ * answer to travel, and not a second more, because every extra second is a
53
+ * second of opaque timeout replacing a real error.
54
+ *
55
+ * Restated rather than imported — `fly-gog-runner/server.mjs` must not be
56
+ * pulled into the Worker bundle, the same trade `attachments.ts` makes for the
57
+ * runner's size ceilings — so the two move by hand, and a test asserts the
58
+ * ordering rather than leaving it to this comment.
59
+ */
60
+ export const RUNNER_UPLOAD_TIMEOUT_MS = 125_000;
61
+
62
+ export interface BlobUploadRequest {
63
+ /** The file to send, resolved on the RUNNER's disk. */
64
+ path: string;
65
+ /** The signed PUT URL. A credential — never log it, never echo it. */
66
+ url: string;
67
+ /** Sent as `Content-Type` byte for byte; the PUT signature commits to it. */
68
+ contentType: string;
69
+ }
70
+
71
+ export interface BlobUploadOutcome {
72
+ /** Bytes the runner actually streamed — the file's own size, not a claim. */
73
+ bytes?: number;
74
+ /** The blob store's status, as the runner observed it. */
75
+ status?: number;
76
+ }
77
+
78
+ export interface BlobUploadOptions {
79
+ /** Where the runner's endpoint and key are read from. Injected by tests. */
80
+ env?: NodeJS.ProcessEnv;
81
+ }
82
+
83
+ /**
84
+ * Remove a signed URL, and the bare signature inside it, from a message.
85
+ *
86
+ * Everything this module throws is expected to be logged, and a failure's text
87
+ * is routinely a third party's: the runner scrubs its own words, but the blob
88
+ * store's error is quoted through it and an HTTP client's rejection may name
89
+ * the URL it was dialling. Both shapes are removed, because either left
90
+ * standing is the whole credential — the signature is what `sig=` carries, and
91
+ * the rest of the URL is public.
92
+ *
93
+ * The empty needle is guarded rather than assumed away: `split('')` cuts a
94
+ * message into single characters and interleaves the replacement between every
95
+ * one of them.
96
+ */
97
+ function withoutUrlSecrets(message: string, url: string): string {
98
+ let out = url ? message.split(url).join('<signed url>') : message;
99
+ const signature = /[?&]sig=([^&#]+)/.exec(url)?.[1];
100
+ if (signature) out = out.split(signature).join('<signature>');
101
+ return out;
102
+ }
103
+
104
+ /**
105
+ * Stream the file at `request.path` (on the runner) to `request.url`.
106
+ *
107
+ * Resolves with what the runner reported; THROWS on every failure, with a
108
+ * message safe to log. Both variables are required — the same both-or-neither
109
+ * rule `useRemoteGogRunner` applies, for the same reason: a URL with no key
110
+ * sends unauthenticated requests the runner rejects, and a key with no URL is a
111
+ * credential configured for nothing.
112
+ */
113
+ export async function uploadToBlobStore(
114
+ request: BlobUploadRequest,
115
+ options: BlobUploadOptions = {},
116
+ ): Promise<BlobUploadOutcome> {
117
+ const env = options.env ?? process.env;
118
+ // The shared reader, as everywhere else: blanks, unexpanded `${...}`
119
+ // placeholders and the literal "undefined"/"null" are all unset.
120
+ const endpoint = readEnvVar('GOG_RUNNER_URL', { env });
121
+ const key = readEnvVar('GOG_RUNNER_KEY', { env });
122
+ if (!endpoint || !key) {
123
+ throw new Error(
124
+ 'no gog runner is configured (GOG_RUNNER_URL + GOG_RUNNER_KEY, both or neither), and the ' +
125
+ 'attachment bytes are on the runner\'s disk rather than here — so nothing on this side can send them',
126
+ );
127
+ }
128
+
129
+ let response: { ok: boolean; status: number; text(): Promise<string> };
130
+ try {
131
+ response = await fetch(`${endpoint.replace(/\/+$/, '')}/upload`, {
132
+ method: 'POST',
133
+ headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
134
+ body: JSON.stringify({ path: request.path, url: request.url, contentType: request.contentType }),
135
+ signal: AbortSignal.timeout(RUNNER_UPLOAD_TIMEOUT_MS),
136
+ });
137
+ } catch (err) {
138
+ throw new Error(withoutUrlSecrets(
139
+ `the upload to the blob store did not complete: ${err instanceof Error ? err.message : String(err)}`,
140
+ request.url,
141
+ ));
142
+ }
143
+
144
+ // Inside a try of its own: the response ARRIVING is not the body arriving,
145
+ // and a severed or truncated one rejects here. That rejection is a transport
146
+ // failure like the dial above and is scrubbed like one — this module's
147
+ // contract is that nothing it throws carries the URL, and a guarantee with a
148
+ // hole in it is not one.
149
+ let raw: string;
150
+ try {
151
+ raw = await response.text();
152
+ } catch (err) {
153
+ throw new Error(withoutUrlSecrets(
154
+ `the upload to the blob store did not complete: the runner's answer could not be read: ` +
155
+ `${err instanceof Error ? err.message : String(err)}`,
156
+ request.url,
157
+ ));
158
+ }
159
+ let body: { error?: string; bytes?: number; status?: number } = {};
160
+ try {
161
+ body = JSON.parse(raw) as typeof body;
162
+ } catch {
163
+ // A proxy's HTML error page, or a truncated answer. Quoted, never parsed
164
+ // into a decision — the status is the part that classifies.
165
+ body = { error: raw };
166
+ }
167
+ if (!response.ok) {
168
+ // The runner's own status says which layer failed (422 refused / 502 far
169
+ // side / 400 this request); `body.status` is the blob store's verdict when
170
+ // there was one. Both are reported, because "403 inside a 422" is what
171
+ // tells a stale signature from a broken runner.
172
+ throw new Error(withoutUrlSecrets(
173
+ `the runner could not store the attachment (HTTP ${response.status}` +
174
+ `${body.status ? `, blob store ${body.status}` : ''}): ${body.error ?? 'no reason given'}`,
175
+ request.url,
176
+ ));
177
+ }
178
+ return { bytes: body.bytes, status: body.status };
179
+ }
@@ -0,0 +1,280 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { readEnvVar } from '@chrischall/mcp-utils';
3
+
4
+ /**
5
+ * Signed URLs for mcp-host's per-registration blob store.
6
+ *
7
+ * ## Why this exists
8
+ *
9
+ * A hosted MCP has no HTTP surface of its own — mcp-host proxies MCP protocol
10
+ * to a stdio child and nothing else — so there is no way for a tool to hand an
11
+ * agent BYTES. `gog_gmail_attachment` works around that by uploading to the
12
+ * user's Drive and returning a `webViewLink`, which needs a Google session to
13
+ * open (so `curl` cannot), writes a file into the user's Drive as a side effect
14
+ * of reading mail, and is refused outright under `GOG_READONLY`.
15
+ *
16
+ * mcp-host answers this with a blob store at `/b/<registrationId>/<rest>` that
17
+ * sits OUTSIDE OAuth: a signed URL is the entire access control. A registration
18
+ * receives two variables at spawn —
19
+ *
20
+ * MCP_BLOB_BASE_URL https://<host>/b/<registrationId>
21
+ * MCP_BLOB_SIGNING_KEY that registration's derived key
22
+ *
23
+ * — and mints its own links. Neither exists on a local stdio install, which is
24
+ * why {@link blobStoreFromEnv} answers `undefined` rather than throwing: the
25
+ * absence is a fact about the host, and the CALLER is the one that knows
26
+ * whether the delivery mode the user asked for needs it.
27
+ *
28
+ * ## The payload shapes
29
+ *
30
+ * Transcribed from mcp-host's `gateway/src/blob-key.ts`, which is what verifies
31
+ * them:
32
+ *
33
+ * GET signs `<key>\n<exp>`
34
+ * PUT signs `put\0<key>\0<ct>\0<exp>`
35
+ * PUT† signs `putp\0<key>\0<ct>\0<exp>` (retention-exempt — not minted here)
36
+ * DELETE signs `del\0<key>\0<exp>`
37
+ * LIST signs `list\0<relPrefix>\0<exp>`
38
+ *
39
+ * The shapes MUST NOT converge, which is why a read payload carries a newline
40
+ * and never a NUL while every other verb is NUL-separated behind a distinct
41
+ * leading word. A signature that lets someone READ an object can then never be
42
+ * replayed to overwrite or destroy it. The write payload commits to the content
43
+ * type as well, so a signature for a PDF cannot be spent storing a script at the
44
+ * same key — which in turn means the `Content-Type` header on the PUT must be
45
+ * byte-identical to the one signed, or the signature simply does not verify.
46
+ *
47
+ * Only the two verbs this repo needs are minted: a read and an ordinary write.
48
+ * Adding a third means adding its shape, not generalising these two.
49
+ *
50
+ * ## Where this belongs
51
+ *
52
+ * In `@chrischall/mcp-utils`, the moment a SECOND MCP needs it. It lives here
53
+ * now only to avoid a cross-repo release chain for one feature. mcp-host's own
54
+ * blob-store doc makes the argument for moving it: N MCPs re-implementing the
55
+ * signing is N chances to get the security-critical part wrong, and the part
56
+ * that is easy to get wrong is exactly the part below — which bytes are signed,
57
+ * and which of them are percent-encoded on the way into the URL.
58
+ *
59
+ * `node:crypto` rather than WebCrypto (which `google-token.ts` uses, for the
60
+ * Worker build): HMAC through `crypto.subtle` is async, and a URL minter that
61
+ * returns a promise infects every call site for no gain here. The Worker build
62
+ * sets `nodejs_compat` (wrangler.jsonc), so `createHmac` resolves there too if
63
+ * this module is ever pulled into that graph.
64
+ */
65
+
66
+ /**
67
+ * The gateway refuses an `exp` more than 24 hours out (`MAX_TTL_MS` in
68
+ * `gateway/src/blob.ts`) — "a far-future exp is a signature that never stops
69
+ * working". Clamped rather than validated: the caller asking for a week should
70
+ * get a working day-long link, not a rejection they cannot act on.
71
+ */
72
+ export const BLOB_URL_MAX_TTL_MS = 24 * 60 * 60 * 1000;
73
+
74
+ /**
75
+ * How far UNDER that ceiling the longest link we will mint sits.
76
+ *
77
+ * The gateway's refusal is strict (`exp > Date.now() + MAX_TTL_MS`) and it is
78
+ * judged on the GATEWAY's clock against an `exp` computed on this child's, so
79
+ * landing exactly on the ceiling leaves no skew budget at all: one millisecond
80
+ * of this process running ahead is a hard 403 whose message names no cause the
81
+ * caller can act on. `BLOB_URL_MAX_TTL_MS` is exported, so `ttlMs:
82
+ * BLOB_URL_MAX_TTL_MS` is the natural way to ask for the longest legal link —
83
+ * the API would otherwise invite exactly the request that breaks.
84
+ *
85
+ * A minute: far more than two well-behaved clocks drift, and irrelevant
86
+ * against a 24-hour link.
87
+ */
88
+ export const BLOB_URL_CEILING_MARGIN_MS = 60 * 1000;
89
+
90
+ /**
91
+ * One hour.
92
+ *
93
+ * A signed URL IS a credential — anyone holding it reads the object, with no
94
+ * session and no bearer — so its lifetime is the window in which a leak is
95
+ * spendable, and the only reason to lengthen it is a consumer that comes back
96
+ * late. Nothing here does: an agent handed a download link fetches it within
97
+ * the same turn. An hour is far more than that needs while still surviving a
98
+ * slow client, a retry, and a person who copies the link into a terminal.
99
+ */
100
+ export const BLOB_URL_DEFAULT_TTL_MS = 60 * 60 * 1000;
101
+
102
+ /** The smallest link worth minting. A `ttlMs` of 0 would be dead on arrival. */
103
+ const MIN_TTL_MS = 1000;
104
+
105
+ /** The two variables mcp-host hands a child at spawn. */
106
+ export interface BlobStoreConfig {
107
+ /** `https://<host>/b/<registrationId>` — MCP_BLOB_BASE_URL. */
108
+ baseUrl: string;
109
+ /** That registration's derived key — MCP_BLOB_SIGNING_KEY. */
110
+ signingKey: string;
111
+ }
112
+
113
+ export interface MintOptions {
114
+ /** Link lifetime, clamped into [1s, 24h]. Defaults to {@link BLOB_URL_DEFAULT_TTL_MS}. */
115
+ ttlMs?: number;
116
+ /** Epoch ms to measure the expiry from. Defaults to the clock; injected by tests. */
117
+ now?: number;
118
+ }
119
+
120
+ /**
121
+ * A signed write URL and the content type that URL's signature commits to.
122
+ *
123
+ * They travel together because they are only correct together — see
124
+ * `BlobUrlMinter.putUrl`.
125
+ */
126
+ export interface BlobPutTarget {
127
+ readonly url: string;
128
+ readonly contentType: string;
129
+ }
130
+
131
+ export interface BlobUrlMinter {
132
+ /** The last path segment of the base URL — the prefix every object key sits under. */
133
+ readonly registrationId: string;
134
+ /**
135
+ * A URL that STORES bytes at `rest`, WITH the content type it was signed
136
+ * under.
137
+ *
138
+ * The pair rather than the URL alone, because the gateway rebuilds the write
139
+ * payload from the PUT's own `content-type` header: a caller that drops the
140
+ * header, lets an HTTP library default it, or re-cases it produces a
141
+ * signature that does not verify, and the refusal looks like a missing
142
+ * object rather than a wrong header. Returning the URL by itself makes that
143
+ * a thing the uploader has to remember; returning both makes it one it
144
+ * cannot drop.
145
+ *
146
+ * The PUT must also send `Content-Length` — the gateway answers 411 without
147
+ * one, and `Number(null)` is 0, so an absent length is not read as empty.
148
+ */
149
+ putUrl(rest: string, contentType: string, options?: MintOptions): BlobPutTarget;
150
+ /** A URL that READS the object at `rest`. */
151
+ getUrl(rest: string, options?: MintOptions): string;
152
+ }
153
+
154
+ /** `<key>\n<exp>` — the read shape. Newline, never a NUL. */
155
+ export function readPayload(objectKey: string, exp: number): string {
156
+ return `${objectKey}\n${exp}`;
157
+ }
158
+
159
+ /** `put\0<key>\0<ct>\0<exp>` — the ordinary write shape. */
160
+ export function writePayload(objectKey: string, contentType: string, exp: number): string {
161
+ return `put\0${objectKey}\0${contentType}\0${exp}`;
162
+ }
163
+
164
+ function sign(signingKey: string, payload: string): string {
165
+ return createHmac('sha256', signingKey).update(payload, 'utf8').digest('base64url');
166
+ }
167
+
168
+ /**
169
+ * Split `rest` into the segments the gateway will see, refusing the ones it
170
+ * refuses.
171
+ *
172
+ * `parsePath` in `gateway/src/blob.ts` decodes each segment and then rejects
173
+ * any that is empty, `.` or `..` — deliberately rejecting rather than
174
+ * normalising, "because normalising means the bytes signed and the bytes used
175
+ * are different strings". So a URL minted for one of these can never be spent;
176
+ * refusing here turns a silent 404 at the door into a message at the call site.
177
+ */
178
+ function objectPathSegments(rest: string): string[] {
179
+ const segments = rest.split('/');
180
+ if (segments.some((seg) => seg === '' || seg === '.' || seg === '..')) {
181
+ // Names the offending path, never the key or the URL.
182
+ throw new Error(
183
+ `invalid blob object path ${JSON.stringify(rest)}: every segment must be non-empty and neither "." nor ".."`,
184
+ );
185
+ }
186
+ return segments;
187
+ }
188
+
189
+ function expiryFor(options: MintOptions | undefined): number {
190
+ const now = options?.now ?? Date.now();
191
+ const requested = options?.ttlMs ?? BLOB_URL_DEFAULT_TTL_MS;
192
+ const ceiling = BLOB_URL_MAX_TTL_MS - BLOB_URL_CEILING_MARGIN_MS;
193
+ return now + Math.min(Math.max(requested, MIN_TTL_MS), ceiling);
194
+ }
195
+
196
+ /**
197
+ * A minter bound to one registration's base URL and key.
198
+ *
199
+ * Throws on a base URL it cannot read a registration id out of, or an empty
200
+ * key — both are configuration faults, and half-working here would mint links
201
+ * that 404 later with nothing to point at.
202
+ */
203
+ export function createBlobUrlMinter(config: BlobStoreConfig): BlobUrlMinter {
204
+ if (!config.signingKey) {
205
+ throw new Error('MCP_BLOB_SIGNING_KEY is empty — cannot sign blob-store URLs');
206
+ }
207
+
208
+ let parsed: URL;
209
+ try {
210
+ parsed = new URL(config.baseUrl);
211
+ } catch {
212
+ // The value itself is not a secret, but it is not worth echoing either.
213
+ throw new Error('MCP_BLOB_BASE_URL is not a valid URL');
214
+ }
215
+
216
+ // `https://<host>/b/<registrationId>`, with or without a trailing slash. The
217
+ // registration id is the LAST path segment, and it is also the first segment
218
+ // of every object key — the gateway derives the signing key from the id in
219
+ // the PATH, so the two must be the same string.
220
+ //
221
+ // Two segments are required, not one. On "last segment" alone a base URL of
222
+ // `…/b/` — the store's mount with the id missing — reads as the id `b`, and
223
+ // is indistinguishable from a tolerated trailing slash. Since the store is
224
+ // always mounted under a prefix (`/b/<id>`; `handleBlob` matches nothing
225
+ // else), a single-segment path is a misconfiguration, and saying so here
226
+ // beats minting links that 404 at the door with nothing to point at.
227
+ const pathSegments = parsed.pathname.split('/').filter((seg) => seg !== '');
228
+ const registrationId = pathSegments.length >= 2 ? pathSegments[pathSegments.length - 1] : undefined;
229
+ if (!registrationId) {
230
+ throw new Error('MCP_BLOB_BASE_URL has no registration id in its path');
231
+ }
232
+
233
+ // Rebuilt rather than reused so a trailing slash on the configured value
234
+ // cannot become a double slash — an empty first segment the gateway refuses.
235
+ const basePrefix = `${parsed.origin}/${pathSegments.join('/')}`;
236
+
237
+ function mint(rest: string, exp: number, payload: (objectKey: string) => string): string {
238
+ const segments = objectPathSegments(rest);
239
+ // Signed DECODED, sent ENCODED. The gateway percent-decodes each segment
240
+ // before it builds the key it verifies against, so a key containing a space
241
+ // or a '+' signs one string and is checked as another unless the encoding
242
+ // happens ONLY on the wire. Per segment, never over the joined string: an
243
+ // encoded '/' would invent a separator the signed key does not have.
244
+ const url = `${basePrefix}/${segments.map(encodeURIComponent).join('/')}`;
245
+ const signature = sign(config.signingKey, payload(`${registrationId}/${segments.join('/')}`));
246
+ return `${url}?exp=${exp}&sig=${signature}`;
247
+ }
248
+
249
+ return {
250
+ registrationId,
251
+ putUrl(rest, contentType, options) {
252
+ const exp = expiryFor(options);
253
+ return {
254
+ url: mint(rest, exp, (key) => writePayload(key, contentType, exp)),
255
+ contentType,
256
+ };
257
+ },
258
+ getUrl(rest, options) {
259
+ const exp = expiryFor(options);
260
+ return mint(rest, exp, (key) => readPayload(key, exp));
261
+ },
262
+ };
263
+ }
264
+
265
+ /**
266
+ * The blob store as this process's environment describes it, or `undefined`
267
+ * when it is absent — a local stdio install, where mcp-host is not the host.
268
+ *
269
+ * `readEnvVar` rather than `process.env` directly: it already treats a blank
270
+ * value and an unresolved `.mcpb` placeholder (`${user_config.x}`) as unset,
271
+ * which is the same rule the rest of this package's configuration follows.
272
+ * BOTH or neither — a base URL with no key cannot sign anything, and a key with
273
+ * no base URL has nowhere to point.
274
+ */
275
+ export function blobStoreFromEnv(): BlobStoreConfig | undefined {
276
+ const baseUrl = readEnvVar('MCP_BLOB_BASE_URL');
277
+ const signingKey = readEnvVar('MCP_BLOB_SIGNING_KEY');
278
+ if (!baseUrl || !signingKey) return undefined;
279
+ return { baseUrl, signingKey };
280
+ }
package/src/lib.ts CHANGED
@@ -64,3 +64,22 @@ export {
64
64
  registerRunTool,
65
65
  assertNotBoth,
66
66
  } from './tools/utils.js';
67
+ // Signed URLs for mcp-host's per-registration blob store — the only way a
68
+ // hosted child can hand an agent BYTES it can fetch with `curl`. Exported from
69
+ // the base package so the gmail sub-package (and any later one) shares ONE
70
+ // implementation of the signing; see src/blob-urls.ts for why that matters.
71
+ // Deliberately NOT re-exporting the payload builders: a caller outside this
72
+ // module has no business assembling a payload and signing it by hand, which is
73
+ // the mistake the shared minter exists to prevent.
74
+ export {
75
+ blobStoreFromEnv,
76
+ createBlobUrlMinter,
77
+ BLOB_URL_MAX_TTL_MS,
78
+ BLOB_URL_DEFAULT_TTL_MS,
79
+ } from './blob-urls.js';
80
+ export type { BlobStoreConfig, BlobUrlMinter, MintOptions } from './blob-urls.js';
81
+ // The other half of that hop: under the hosted connector the bytes are on the
82
+ // RUNNER's disk and this child never sees them, so the runner is asked to
83
+ // stream them to the URL this process minted. See src/blob-upload.ts.
84
+ export { uploadToBlobStore, RUNNER_UPLOAD_TIMEOUT_MS } from './blob-upload.js';
85
+ export type { BlobUploadRequest, BlobUploadOutcome, BlobUploadOptions } from './blob-upload.js';
package/src/runner.ts CHANGED
@@ -217,7 +217,7 @@ const TIMEOUT_MS = 30_000;
217
217
  // so the requirement change is surfaced in the release notes (see
218
218
  // .github/release.yml). This is the single source of truth for the required
219
219
  // version; keep the README/CLAUDE.md mention in sync.
220
- export const MIN_GOG_VERSION = '0.39.1';
220
+ export const MIN_GOG_VERSION = '0.40.0';
221
221
 
222
222
  // Interpret the GOG_READONLY kill-switch. `readEnvVar` already treats blank
223
223
  // values, 'undefined'/'null' sentinels, and unresolved .mcpb placeholders
@@ -236,12 +236,29 @@ function readonlyEnvEnabled(): boolean {
236
236
  // instead of the stored refresh token. The broader patterns are
237
237
  // defense-in-depth — the parent process's shell may have other Google /
238
238
  // cloud / API secrets in scope that the child has no business seeing.
239
+ //
240
+ // `_KEY`, not `_API_KEY|_PRIVATE_KEY`: those were four spellings of "a key"
241
+ // with the bare one missing, and TWO credentials this repo hands its own
242
+ // process fell in that gap. `MCP_BLOB_SIGNING_KEY` mints the signed blob URLs
243
+ // a `deliver="url"` download is uploaded to — a signature IS the whole access
244
+ // control on that store — and `GOG_RUNNER_KEY` is the bearer for the Fly
245
+ // backend, where `POST /run` is arbitrary `gog` argv. Neither is read by the
246
+ // child: both are spent HERE, and when `GOG_RUNNER_URL` is set nothing is
247
+ // spawned at all. `_CREDENTIALS` generalises the named
248
+ // GOOGLE_APPLICATION_CREDENTIALS above, which stays named because it is the
249
+ // one gog itself would act on.
250
+ //
251
+ // The list is bounded by what the child LEGITIMATELY READS, which is why
252
+ // `_PASSWORD` is deliberately NOT on it: `GOG_KEYRING_PASSWORD` decrypts gog's
253
+ // own file keyring (`GOG_KEYRING_BACKEND=file`), so that rule would strip the
254
+ // one credential the child needs and turn every call into an auth failure.
255
+ // Both directions are tested — a widening with no control case is a guess.
239
256
  function sanitizedEnv(): NodeJS.ProcessEnv {
240
257
  const result: NodeJS.ProcessEnv = {};
241
258
  for (const [key, value] of Object.entries(process.env)) {
242
259
  if (key === 'GOG_ACCESS_TOKEN') continue;
243
260
  if (key === 'GOOGLE_APPLICATION_CREDENTIALS') continue;
244
- if (/(_TOKEN|_SECRET|_API_KEY|_PRIVATE_KEY)$/.test(key)) continue;
261
+ if (/(_TOKEN|_SECRET|_KEY|_CREDENTIALS)$/.test(key)) continue;
245
262
  result[key] = value;
246
263
  }
247
264
  return result;
package/src/worker.ts CHANGED
@@ -38,7 +38,7 @@ import { gogAuth, CONNECTOR_INSTRUCTIONS, type GogProps } from './connector-auth
38
38
  // connector with all ~360 tools at once. Add whichever paths you want as separate
39
39
  // connectors in claude.ai (each authorizes with the same connector key).
40
40
 
41
- const VERSION = '2.29.1'; // x-release-please-version
41
+ const VERSION = '2.30.0'; // x-release-please-version
42
42
 
43
43
  // Build an McpAgent subclass whose init() registers `registrars` onto its server,
44
44
  // each handler wrapped in the ALS scope carrying the per-session Fly executor.