gogcli-mcp 3.0.0 → 4.0.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.
Files changed (50) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +273 -748
  4. package/dist/lib.js +375 -785
  5. package/manifest.json +2 -2
  6. package/mint.yaml +37 -33
  7. package/package.json +3 -3
  8. package/server.json +2 -2
  9. package/src/attachments.ts +28 -34
  10. package/src/blob-upload.ts +165 -134
  11. package/src/blob-urls.ts +3 -5
  12. package/src/bootstrap-auth.ts +97 -0
  13. package/src/index.ts +3 -4
  14. package/src/lib.ts +14 -9
  15. package/src/runner.ts +23 -175
  16. package/src/tools/appscript.ts +1 -1
  17. package/src/tools/auth.ts +5 -5
  18. package/src/tools/drive.ts +1 -3
  19. package/src/tools/gmail.ts +9 -9
  20. package/src/tools/utils.ts +12 -58
  21. package/tests/attachments.test.ts +11 -14
  22. package/tests/blob-upload.test.ts +235 -160
  23. package/tests/bootstrap-auth.test.ts +245 -0
  24. package/tests/runner-file-args.test.ts +1 -13
  25. package/tests/runner.test.ts +8 -95
  26. package/tests/sdk-single-copy.test.ts +4 -12
  27. package/tests/tools/auth-401-shapes.test.ts +2 -3
  28. package/tests/tools/auth.test.ts +5 -4
  29. package/tests/tools/drive.test.ts +11 -3
  30. package/tests/tools/gmail.test.ts +2 -2
  31. package/tests/tools/utils.test.ts +1 -50
  32. package/tests/zod-single-copy.test.ts +6 -14
  33. package/tsconfig.json +1 -2
  34. package/vitest.config.ts +2 -12
  35. package/src/auth-log.ts +0 -205
  36. package/src/connector-auth.ts +0 -319
  37. package/src/connector-login.ts +0 -87
  38. package/src/connector-runtime.ts +0 -910
  39. package/src/google-probe.ts +0 -113
  40. package/src/google-token.ts +0 -391
  41. package/src/remote-runner.ts +0 -77
  42. package/src/worker.ts +0 -117
  43. package/tests/auth-log.test.ts +0 -530
  44. package/tests/connector-auth.test.ts +0 -559
  45. package/tests/connector-login.test.ts +0 -151
  46. package/tests/connector-runtime.test.ts +0 -1664
  47. package/tests/google-probe.test.ts +0 -116
  48. package/tests/google-token.test.ts +0 -425
  49. package/tests/remote-runner.test.ts +0 -202
  50. package/tests/worker.test.ts +0 -167
@@ -1,113 +0,0 @@
1
- /**
2
- * How this side reads the runner's `GET /health/google` answer.
3
- *
4
- * ## Why this is a module and not two `if` statements
5
- *
6
- * There are two callers — the connect-time probe in `connector-auth.ts` and the
7
- * post-refusal probe in `connector-runtime.ts` — and they used to make this
8
- * judgement separately. That is exactly how the defect below survived review of
9
- * both: each site branched on `body.ok === true` alone, so both inherited the
10
- * same wrong reading, and fixing one would have left the other. The judgement
11
- * now exists once; the call sites only translate a verdict into their own event
12
- * names.
13
- *
14
- * ## The defect this deletes
15
- *
16
- * `ok` answers "is the Google layer healthy". It does NOT answer "did anything
17
- * find out" — and `server.mjs` reports `ok:false` for causes that are facts
18
- * about the PROBE rather than about Google: it timed out, it could not be run at
19
- * all (no `gog` on PATH, no `credentials.json` on the volume), its output could
20
- * not be parsed, or gog declined to state validity. Reading `ok !== true` as
21
- * "Google refused" filed every one of those at error level under an event whose
22
- * documented meaning is "Google was asked and **refused**". An operator grepping
23
- * event names would conclude the refresh token was dead and close the incident
24
- * on evidence that was never gathered — the original defect (status claiming
25
- * health nothing measured) with the alarm merely inverted.
26
- *
27
- * So the runner now states `measured` explicitly, and this module reads it
28
- * FIRST. `ok` is only consulted once a measurement is established.
29
- *
30
- * ## Which way "unknown" resolves
31
- *
32
- * Toward `unmeasured`, always. The two errors are not symmetrical: filing a real
33
- * refusal as unmeasured under-claims, and the runner's own cause string still
34
- * rides along on the same log line, so nothing is lost. Filing a non-measurement
35
- * as a refusal invents evidence. A runner that does not say whether it measured
36
- * therefore gets no verdict about Google extracted from it — including the
37
- * incoherent `ok:true, measured:false` and the merely silent `ok:true`, neither
38
- * of which can license a health claim.
39
- *
40
- * `ok:true` is worth spelling out, because it is where this rule is easiest to
41
- * talk yourself out of: an affirmative-sounding field feels self-licensing, and
42
- * the harm of believing it looks small. It is not. The GOOD verdict is what the
43
- * refusal path compares Google's live 401 against, so a `kind:'ok'` built on
44
- * silence becomes `refusal.google-ok` — the record documented as "the one
45
- * record that means we cannot explain this", logged at error level, and the
46
- * only evidence that could ever justify building automatic recovery on the
47
- * hosted path. Raised from a measurement nobody took, it is precisely the
48
- * defect this branch exists to delete: a health claim with nothing behind it.
49
- */
50
-
51
- /** The verdict, in the three states the log's event names already distinguish. */
52
- export type GoogleProbeVerdict =
53
- /** Measured, and the credential works. */
54
- | { kind: 'ok'; reason?: undefined }
55
- /** Measured, and it does not. `reason` is the runner's classification. */
56
- | { kind: 'unhealthy'; reason: string }
57
- /** Nothing was learned about Google. Not evidence of anything. */
58
- | { kind: 'unmeasured'; reason: string };
59
-
60
- /** Read a field only if it really is a boolean — a proxy may put anything here. */
61
- const bool = (value: unknown): boolean | undefined =>
62
- typeof value === 'boolean' ? value : undefined;
63
-
64
- /**
65
- * The runner's cause, if it sent a usable one.
66
- *
67
- * Non-strings are dropped rather than stringified: this value reaches a log
68
- * aggregator, and `[object Object]` is worse than the honest fallback sentence.
69
- * Every legitimate value is a literal from `PROBE_CAUSES` in `server.mjs`.
70
- */
71
- const cause = (value: unknown): string | undefined =>
72
- typeof value === 'string' && value.length > 0 ? value : undefined;
73
-
74
- /**
75
- * Turn a `/health/google` body into a verdict. Total: every input, including a
76
- * proxy's HTML page parsed into a string and a body of `null`, yields one of the
77
- * three kinds and never throws.
78
- */
79
- export function readGoogleProbe(body: unknown): GoogleProbeVerdict {
80
- const record = (typeof body === 'object' && body !== null ? body : {}) as Record<string, unknown>;
81
- const measured = bool(record.measured);
82
- const reported = cause(record.error);
83
-
84
- // FIRST, and before `ok` is consulted at all: a runner that says it could not
85
- // measure has told us nothing about the credential, however alarming its cause
86
- // string reads.
87
- if (measured === false) {
88
- return {
89
- kind: 'unmeasured',
90
- reason: reported ?? 'the runner reported it could not measure the Google layer',
91
- };
92
- }
93
- // `ok` is read ONLY here, inside the established measurement. Health is a
94
- // claim, not a fact that states itself: `ok:true` on its own is a runner
95
- // asserting a verdict about a credential without saying anything asked.
96
- if (measured === true) {
97
- if (bool(record.ok) === true) return { kind: 'ok' };
98
- return {
99
- kind: 'unhealthy',
100
- reason: reported ?? 'the runner reported the Google layer unhealthy with no cause',
101
- };
102
- }
103
- // No `measured` field at all — whatever `ok` says. Silence is not a
104
- // measurement, so no claim about the credential may be built on it, in either
105
- // direction; but whatever the runner did say is carried through, because the
106
- // operator reading this line needs it.
107
- return {
108
- kind: 'unmeasured',
109
- reason: reported
110
- ? `the runner did not report whether it measured the Google layer; it said: ${reported}`
111
- : 'the runner did not report whether it measured the Google layer',
112
- };
113
- }
@@ -1,391 +0,0 @@
1
- import { parseBoolEnv, readEnvVar } from '@chrischall/mcp-utils';
2
- import { credentialTag, logAuthTransition } from './auth-log.js';
3
-
4
- /**
5
- * Mint short-lived Google access tokens from a long-lived refresh token, so a
6
- * hosted gog's identity belongs to the REGISTRATION rather than to the machine
7
- * the binary runs on (#241).
8
- *
9
- * The shape of the problem: `gog` reads credentials from a keyring at
10
- * `GOG_HOME`, on the box where it executes — which is why one Fly volume ended
11
- * up being every registration's identity. But `gog --access-token` bypasses the
12
- * keyring entirely, and #235 already carries such a token to the box per
13
- * request. The only missing piece was that an access token lives about an hour,
14
- * so it cannot be the thing you STORE. A refresh token can.
15
- *
16
- * So the refresh token stays here, in the child's environment, and only a
17
- * one-hour access token ever crosses the wire. That is strictly better than the
18
- * arrangement it replaces, where a permanent credential sat on a shared volume.
19
- */
20
-
21
- const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
22
-
23
- /**
24
- * Replace a token this long before it actually expires. A token that dies
25
- * mid-flight is a failure the caller can do nothing about, and the exchange is
26
- * cheap next to a failed tool call.
27
- */
28
- const EXPIRY_MARGIN_MS = 120_000;
29
-
30
- interface CachedToken {
31
- accessToken: string;
32
- expiresAt: number;
33
- }
34
-
35
- /**
36
- * Keyed by the CREDENTIAL, never a single "current token".
37
- *
38
- * A module-level current-token would be correct for one stdio process and
39
- * silently wrong everywhere else: a Worker isolate serves many callers, so the
40
- * first caller's identity would be handed to everyone after them. That is the
41
- * same failure as the captured executor in #235 and the ambient store in #233 —
42
- * three bugs, one shape, which is why this one is keyed from the start.
43
- *
44
- * The key is a hash rather than the token itself so that nothing which dumps or
45
- * iterates this map (a heap snapshot, a debugger, a future logging line) puts a
46
- * live credential in front of someone.
47
- */
48
- const cache = new Map<string, CachedToken>();
49
-
50
- /**
51
- * Exchanges currently in flight, so concurrent callers share ONE of them.
52
- *
53
- * Without this, `get` → `await exchange` → `set` has an await between the miss
54
- * and the fill: every caller that arrives during that window also misses, and
55
- * they all hit Google's token endpoint together. One process per caller hides
56
- * it, but a Worker isolate serving many callers — or simply several tool calls
57
- * in flight — turns a single refresh into a stampede, and being rate-limited
58
- * for it produces exactly the intermittent auth failures this was meant to end.
59
- *
60
- * Keyed identically to `cache`, so two different credentials never wait on each
61
- * other's exchange.
62
- */
63
- const inFlight = new Map<string, Promise<CachedToken>>();
64
-
65
- /** Test seam: both maps are process-wide, so they do not unwind between tests. */
66
- export function clearAccessTokenCache(): void {
67
- cache.clear();
68
- inFlight.clear();
69
- }
70
-
71
- /**
72
- * WebCrypto rather than `node:crypto`: this module is reachable from the Worker
73
- * build, which has no node builtins. Both runtimes expose `crypto.subtle`.
74
- */
75
- async function cacheKey(refreshToken: string, clientId: string): Promise<string> {
76
- // NUL-separated, spelled as an escape so this source file stays text: it
77
- // keeps a (clientId, refreshToken) pair from colliding with a different
78
- // pair whose concatenation happens to match. Neither value can contain a
79
- // NUL, which is what makes the boundary unambiguous.
80
- const data = new TextEncoder().encode(`${clientId}\u0000${refreshToken}`);
81
- const digest = await crypto.subtle.digest('SHA-256', data);
82
- return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
83
- }
84
-
85
- /**
86
- * What a token source is: something that answers "who is this call acting as",
87
- * or throws trying. It never answers `undefined` after being configured —
88
- * see the failure note below.
89
- *
90
- * It also answers a second question, which is what makes a rejected token
91
- * recoverable: `invalidate(rejected)` drops that token if it is still the one
92
- * cached for this credential, and REPORTS whether it dropped anything.
93
- *
94
- * `true` means the next read mints something new, so a replay is worth making;
95
- * `false` means a concurrent caller has already replaced the entry. Those are
96
- * the only two answers, and a source with nothing to mint from gives neither —
97
- * it omits the method entirely (see below).
98
- */
99
- export interface AccessTokenSource {
100
- (): Promise<string | undefined>;
101
- /**
102
- * Evict `rejected` if it is still this credential's cached token; answer
103
- * whether anything was evicted.
104
- *
105
- * ABSENT exactly when this source holds nothing mintable — a directly-supplied
106
- * GOG_ACCESS_TOKEN, or a refresh token with no OAuth client beside it. The
107
- * absence has to be the signal, because an always-false return is not
108
- * distinguishable from the false a real cache gives when a concurrent caller
109
- * beat you to the refresh, and the connector records those as different
110
- * causes: "nothing here can mint a replacement" is the one configuration
111
- * where re-authorizing genuinely IS the repair, and it must not be logged as
112
- * somebody else's concurrent write.
113
- *
114
- * Scoped to ONE credential on purpose. `clearAccessTokenCache()` drops every
115
- * entry and exists only as a test seam — using it here would mean one
116
- * caller's dead token forced a re-mint on every other caller sharing the
117
- * isolate, which is the same shared-identity mistake this module is built
118
- * around, wearing a different hat.
119
- *
120
- * Matched by VALUE, not just by key. A concurrent caller may already have
121
- * replaced the entry while this call was in flight; evicting that fresh token
122
- * would waste a mint and let two callers keep undoing each other's work.
123
- */
124
- invalidate?: (rejected: string) => Promise<boolean>;
125
- /**
126
- * The log-safe name of the credential behind this source, for correlating
127
- * this module's records with the connector's (auth-log.ts).
128
- *
129
- * Optional, and absent exactly when there is no mintable credential to name.
130
- * A directly-supplied GOG_ACCESS_TOKEN emits no records here at all — nothing
131
- * is minted, cached or evicted — so a tag for it would identify a story that
132
- * is never told. Answering `undefined` says that honestly rather than
133
- * inventing an identifier for a credential this module does not hold.
134
- */
135
- credentialId?: () => Promise<string>;
136
- }
137
-
138
- export interface TokenEnv {
139
- GOG_ACCESS_TOKEN?: string;
140
- GOG_REFRESH_TOKEN?: string;
141
- GOG_CLIENT_ID?: string;
142
- GOG_CLIENT_SECRET?: string;
143
- [key: string]: string | undefined;
144
- }
145
-
146
- /**
147
- * Build the token source for this environment, or `undefined` when nothing is
148
- * configured — which leaves the backend acting as itself, exactly as every
149
- * registration did before this existed.
150
- *
151
- * Precedence puts a directly-supplied `GOG_ACCESS_TOKEN` first: someone who
152
- * already holds a token should not need an OAuth client to use it, and it keeps
153
- * the #230 path working untouched.
154
- */
155
- export function makeAccessTokenSource(env: TokenEnv): AccessTokenSource | undefined {
156
- // A token handed to us whole. Nothing minted it here, so nothing here can
157
- // mint another — hence no `invalidate` at all, which is how the caller tells
158
- // this apart from a cache that merely lost a race. When THIS is the token
159
- // Google rejects, re-authorization really is the repair.
160
- const direct = readEnvVar('GOG_ACCESS_TOKEN', { env });
161
- if (direct) return async () => direct;
162
-
163
- const refreshToken = readEnvVar('GOG_REFRESH_TOKEN', { env });
164
- if (!refreshToken) return undefined;
165
-
166
- const clientId = readEnvVar('GOG_CLIENT_ID', { env });
167
- const clientSecret = readEnvVar('GOG_CLIENT_SECRET', { env });
168
-
169
- // A refresh token with no OAuth client cannot mint anything, and the WRONG
170
- // repair is to treat it as unconfigured: that falls back to the backend's own
171
- // identity, which is the precise confusion this feature exists to remove. So
172
- // the source exists and throws when used — `tools/list` still works, the
173
- // server still starts, and the first tool call says what is missing.
174
- if (!clientId || !clientSecret) {
175
- const missing = [!clientId && 'GOG_CLIENT_ID', !clientSecret && 'GOG_CLIENT_SECRET']
176
- .filter(Boolean)
177
- .join(' and ');
178
- return async () => {
179
- throw new Error(
180
- `GOG_REFRESH_TOKEN is set but ${missing} is not, so no access token can be minted. ` +
181
- 'Set the OAuth client alongside the refresh token, or unset GOG_REFRESH_TOKEN to use the backend’s own identity.',
182
- );
183
- };
184
- }
185
-
186
- // Hashed ONCE per source rather than once per call. `read` and `invalidate`
187
- // each used to recompute the SHA-256 on every invocation, which is work this
188
- // module was already paying for on the request path; memoizing it also gives
189
- // the log records a credential tag for free. Lazy rather than eager so a
190
- // source that is never used never starts a promise nobody awaits.
191
- let keyPromise: Promise<string> | undefined;
192
- const key = (): Promise<string> => (keyPromise ??= cacheKey(refreshToken, clientId));
193
-
194
- // Every other transition in the set is an EVENT; a cache hit is the absence
195
- // of one. Narrating it writes a line per gog invocation — a Workers Logs line
196
- // (and its cost) for every tool call in healthy operation on the Worker, and
197
- // a stderr line per call in the MCP host's server log on stdio — which turns
198
- // the `gog-auth` stream from a log of transitions into a request log.
199
- //
200
- // It stays available for the investigation that has to prove WHICH token a
201
- // call was served (the shape the original incident took), behind a flag
202
- // nobody sets in normal operation. Read once per source rather than per call:
203
- // the env cannot change under a running process.
204
- const logCacheHits = parseBoolEnv('GOG_AUTH_LOG_CACHE_HITS', { env });
205
-
206
- const read = async (): Promise<string | undefined> => {
207
- const k = await key();
208
- const hit = cache.get(k);
209
- if (hit && hit.expiresAt - EXPIRY_MARGIN_MS > Date.now()) {
210
- if (logCacheHits) logAuthTransition('token.cache-hit', { credential: credentialTag(k) });
211
- return hit.accessToken;
212
- }
213
-
214
- // Join the exchange already running for this credential, or start the one
215
- // everyone else will join.
216
- let pending = inFlight.get(k);
217
- if (!pending) {
218
- pending = exchange(refreshToken, clientId, clientSecret)
219
- .then((minted) => {
220
- cache.set(k, minted);
221
- logAuthTransition('token.minted', {
222
- credential: credentialTag(k),
223
- reason: `valid for ${Math.round((minted.expiresAt - Date.now()) / 1000)}s`,
224
- });
225
- return minted;
226
- })
227
- // Only the caller that STARTED the exchange records it, because only one
228
- // exchange happened; the callers that joined it would otherwise turn one
229
- // mint into a burst of identical lines.
230
- //
231
- // Typed as TokenExchangeError rather than `unknown`: `exchange` catches
232
- // the fetch rejection and the JSON parse itself, so this is the only
233
- // thing that can arrive here, and pretending otherwise would add an arm
234
- // no test could ever reach.
235
- .catch((err: TokenExchangeError): never => {
236
- logAuthTransition(err.grantDead ? 'grant.dead' : 'token.mint-failed', {
237
- credential: credentialTag(k),
238
- reason: err.message,
239
- });
240
- throw err;
241
- })
242
- // Dropped whether it resolved OR threw. Keeping a rejected promise here
243
- // would make one transient failure permanent for every later caller —
244
- // the opposite of the "failures are not cached" rule above.
245
- .finally(() => inFlight.delete(k));
246
- inFlight.set(k, pending);
247
- }
248
- const minted = await pending;
249
- return minted.accessToken;
250
- };
251
-
252
- /**
253
- * Google rejected `rejected`; make sure the next read does not serve it again.
254
- *
255
- * The read guard above is purely about TIME, so without this a token Google
256
- * has already answered 401 to is re-served until its nominal expiry — up to
257
- * ~58 minutes of every call failing identically, which is exactly the incident
258
- * this closes. `inFlight` is deliberately untouched: an exchange that is
259
- * already running was started to produce a NEW token, and cancelling it would
260
- * only make the callers waiting on it mint again.
261
- */
262
- const invalidate = async (rejected: string): Promise<boolean> => {
263
- const k = await key();
264
- const hit = cache.get(k);
265
- if (!hit || hit.accessToken !== rejected) {
266
- // The two "nothing happened" cases are worth telling apart in a log: one
267
- // says a concurrent caller has already repaired this credential, the
268
- // other says the rejected token was never ours to begin with.
269
- logAuthTransition('token.evict-noop', {
270
- credential: credentialTag(k),
271
- reason: hit
272
- ? 'a concurrent caller had already replaced this credential’s token'
273
- : 'no token was cached for this credential',
274
- });
275
- return false;
276
- }
277
- cache.delete(k);
278
- logAuthTransition('token.evicted', {
279
- credential: credentialTag(k),
280
- reason: 'Google rejected this access token; the next read will mint a new one',
281
- });
282
- return true;
283
- };
284
-
285
- return Object.assign(read, {
286
- invalidate,
287
- credentialId: async () => credentialTag(await key()),
288
- });
289
- }
290
-
291
- /**
292
- * Exchange refresh -> access.
293
- *
294
- * THROWS on every failure, and never returns `undefined`. Returning nothing
295
- * would let the call proceed as the backend's identity, and the caller would
296
- * read someone else's mailbox while everything looked like success — the same
297
- * reasoning that made a malformed token a 400 rather than an ignore in #235.
298
- *
299
- * Nothing here is cached on failure either, so a transient Google outage does
300
- * not become a sticky one.
301
- */
302
- class TokenExchangeError extends Error {
303
- /**
304
- * The REFRESH token is dead (Google's `invalid_grant`), not merely the access
305
- * token. Carried as a flag rather than re-read from the message, because
306
- * inferring the author of a failure from prose several authors can produce is
307
- * precisely the mistake this branch exists to undo. `instanceof` is safe: the
308
- * class is thrown and caught inside this one module.
309
- */
310
- readonly grantDead: boolean;
311
-
312
- constructor(message: string, grantDead: boolean) {
313
- super(message);
314
- this.grantDead = grantDead;
315
- }
316
- }
317
-
318
- async function exchange(refreshToken: string, clientId: string, clientSecret: string): Promise<CachedToken> {
319
- let res: Response;
320
- try {
321
- res = await fetch(TOKEN_ENDPOINT, {
322
- method: 'POST',
323
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
324
- body: new URLSearchParams({
325
- grant_type: 'refresh_token',
326
- refresh_token: refreshToken,
327
- client_id: clientId,
328
- client_secret: clientSecret,
329
- }).toString(),
330
- });
331
- } catch (err) {
332
- throw new TokenExchangeError(
333
- `the Google token exchange could not be reached: ${err instanceof Error ? err.message : String(err)}`,
334
- false,
335
- );
336
- }
337
-
338
- const body = (await res.json().catch(() => ({}))) as {
339
- access_token?: string;
340
- expires_in?: number;
341
- error?: string;
342
- error_description?: string;
343
- };
344
-
345
- if (!res.ok) {
346
- // invalid_grant is the one worth naming, because it is not a bug and not
347
- // transient: the credential is gone and a human has to enrol again. Google
348
- // expires refresh tokens after 7 days while a consent screen is still in
349
- // "Testing" mode, which is how this fleet has usually met it.
350
- //
351
- // The literal `invalid_grant` is in the message ON PURPOSE, and is load
352
- // bearing rather than decoration. This mint is a first-class error surface
353
- // — it propagates in place of gog's 401 (connector-runtime.ts) — and
354
- // tools/utils.ts picks INVALID_GRANT_HINT, the one that names the 7-day
355
- // Testing-mode cause and the gog_auth_add_url/gog_auth_add_complete pair,
356
- // by matching that exact token. Prose alone does not qualify: the previous
357
- // wording ("token has expired or been revoked") missed
358
- // INVALID_GRANT_PATTERN's second alternative ("token has been expired or
359
- // revoked") by one word, so a dead refresh token surfaced here earned only
360
- // the generic "authentication may have expired" advice while the identical
361
- // failure reported BY gog earned the specific guidance.
362
- if (body.error === 'invalid_grant') {
363
- throw new TokenExchangeError(
364
- 'the stored refresh token was rejected (invalid_grant): it has expired or been revoked, so ' +
365
- 'this account must be re-authorized ' +
366
- '(commonly the 7-day limit on OAuth consent screens still in "Testing" mode). ' +
367
- 'Re-enrol with gog_auth_add_url + gog_auth_add_complete and store the new refresh token.',
368
- true,
369
- );
370
- }
371
- // The refresh token is deliberately absent from this message — it is a
372
- // long-lived credential and an error string travels into logs and model
373
- // context.
374
- throw new TokenExchangeError(
375
- `the access token could not be refreshed (HTTP ${res.status}${body.error ? `, ${body.error}` : ''})`,
376
- false,
377
- );
378
- }
379
-
380
- if (!body.access_token) {
381
- throw new TokenExchangeError(
382
- 'the access token could not be refreshed: Google returned no access_token',
383
- false,
384
- );
385
- }
386
-
387
- // Default to an hour if Google omits expires_in; the margin above covers the
388
- // difference between that guess and reality.
389
- const expiresInMs = (body.expires_in ?? 3600) * 1000;
390
- return { accessToken: body.access_token, expiresAt: Date.now() + expiresInMs };
391
- }
@@ -1,77 +0,0 @@
1
- import { readEnvVar } from '@chrischall/mcp-utils';
2
- import { setDefaultGogExecutor } from './runner.js';
3
- import { makeAccessTokenSource } from './google-token.js';
4
- import { makeFlyExecutor } from './connector-runtime.js';
5
-
6
- /**
7
- * Let a stdio server run `gog` on the Fly backend instead of spawning it.
8
- *
9
- * The default stdio path shells out to the `gog` binary (`runner.ts`,
10
- * `spawn(GOG_PATH ?? 'gog')`), which is right on a laptop and impossible
11
- * anywhere the binary is not installed — notably mcp-host, whose runner image
12
- * is deliberately Node + git + tar and nothing else. Baking a Go binary into a
13
- * generic runner for one MCP's sake, or curling an unpinned release tarball
14
- * inside an install, are both worse than using the seam that already exists:
15
- * `makeFlyExecutor` has forwarded arg-arrays to `<runner>/run` for the
16
- * Cloudflare connector since that connector shipped, and it touches nothing
17
- * Worker-only.
18
- *
19
- * So this is wiring, not new machinery. Set both variables and the process
20
- * executes remotely; leave either unset and nothing changes, which is what
21
- * keeps every existing local install on the binary it already has.
22
- *
23
- * ## Why a process-wide default and not the AsyncLocalStorage
24
- *
25
- * `runExecutor` is an AsyncLocalStorage, and the Worker wraps each REQUEST in
26
- * `runExecutor.run(...)` because one isolate serves many callers whose backend
27
- * credentials differ. A stdio process is the opposite: one backend for its
28
- * whole life.
29
- *
30
- * This used to reach for `enterWith` on the theory that it "sets the store for
31
- * the whole process". It does not. `enterWith` sets the store on the async
32
- * resource that is CURRENT when it runs — here, module evaluation — and the
33
- * tool calls arrive later as I/O events on the transport's own resources, which
34
- * do not descend from that. So `getStore()` was undefined at exactly the moment
35
- * `run()` asked, and the seam reverted to spawning a binary the host does not
36
- * have. Every hosted gog MCP answered "gog executable not found" while pointed
37
- * at a healthy backend, and the unit test missed it because a test that calls
38
- * this function itself awaits inside the resource it just mutated.
39
- *
40
- * A process-lifetime value is not a scoped value, so it does not live in a
41
- * scope: `setDefaultGogExecutor` holds it, and a per-request store still beats
42
- * it (runner.ts `activeExecutor`) so the Worker path is unchanged.
43
- *
44
- * Call before the server starts, so no tool can be serviced ahead of it.
45
- */
46
- export function useRemoteGogRunner(env: NodeJS.ProcessEnv = process.env): boolean {
47
- // The shared reader, not a local trim: it already treats blanks, unexpanded
48
- // `${...}` placeholders AND the literal strings "undefined"/"null" as unset.
49
- // Those last two are what a hand-rolled check misses, and they arrive whenever
50
- // a host stringifies a missing value into an env block.
51
- const endpoint = readEnvVar('GOG_RUNNER_URL', { env });
52
- const key = readEnvVar('GOG_RUNNER_KEY', { env });
53
- // Both or neither. A URL with no key would send unauthenticated requests the
54
- // runner rejects, and a key with no URL is a credential configured for
55
- // nothing — either alone is a misconfiguration, and silently spawning
56
- // instead would hide it until someone wondered why the binary was needed.
57
- if (!endpoint || !key) return false;
58
- // Whose Google identity this process acts as (#230). The backend holds ONE
59
- // identity on its volume, so without this every caller of a hosted gog acts
60
- // as whoever seeded it. Under mcp-host's `perUserChild` this process belongs
61
- // to a single caller and its environment carries that caller's token, so
62
- // forwarding it is the whole of "act as the person calling you".
63
- //
64
- // Read per call rather than captured here, because the executor outlives any
65
- // one request and the claim being made is about a request. Absent when unset,
66
- // which is every registration that predates per-caller auth.
67
- //
68
- // The source also covers the case where the registration stores a REFRESH
69
- // token instead (#241) — the identity then belongs to the registration rather
70
- // than to the backend's volume, and the short-lived token it mints is the
71
- // only thing that crosses the wire. `undefined` when neither is configured,
72
- // which leaves the backend acting as itself exactly as before.
73
- setDefaultGogExecutor(
74
- makeFlyExecutor(endpoint.replace(/\/+$/, ''), key, makeAccessTokenSource(env)),
75
- );
76
- return true;
77
- }