gogcli-mcp 2.20.0 → 2.21.1

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.
@@ -0,0 +1,391 @@
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,5 +1,6 @@
1
1
  import { readEnvVar } from '@chrischall/mcp-utils';
2
2
  import { setDefaultGogExecutor } from './runner.js';
3
+ import { makeAccessTokenSource } from './google-token.js';
3
4
  import { makeFlyExecutor } from './connector-runtime.js';
4
5
 
5
6
  /**
@@ -63,8 +64,14 @@ export function useRemoteGogRunner(env: NodeJS.ProcessEnv = process.env): boolea
63
64
  // Read per call rather than captured here, because the executor outlives any
64
65
  // one request and the claim being made is about a request. Absent when unset,
65
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.
66
73
  setDefaultGogExecutor(
67
- makeFlyExecutor(endpoint.replace(/\/+$/, ''), key, () => readEnvVar('GOG_ACCESS_TOKEN', { env })),
74
+ makeFlyExecutor(endpoint.replace(/\/+$/, ''), key, makeAccessTokenSource(env)),
68
75
  );
69
76
  return true;
70
77
  }
package/src/runner.ts CHANGED
@@ -45,6 +45,62 @@ export type GogExecutor = (
45
45
  opts: { timeout?: number; interactive?: boolean },
46
46
  ) => Promise<string>;
47
47
 
48
+ // Which layer authored a failure, when the layer was OURS and not gog's.
49
+ //
50
+ // A remote executor (the Fly/Worker path) can fail in two categorically
51
+ // different ways, and every consumer downstream needs to tell them apart:
52
+ //
53
+ // - `gog` ran on the backend and failed. The message is gog's — or Google's,
54
+ // relayed by gog — so it is PROSE, and the only way to classify it is to
55
+ // read it. That failure is NOT a RunnerTransportError; it stays a plain
56
+ // Error so tools/utils.ts keeps applying its patterns to it.
57
+ // - The request never got that far: the runner rejected our bearer token,
58
+ // refused the request shape, was draining, or never answered. Nothing was
59
+ // ever shown to Google, so no amount of re-authorizing a Google account can
60
+ // help — and the runner's own words ("unauthorized") are indistinguishable
61
+ // from Google's when read as prose. That is what this type exists for.
62
+ //
63
+ // The kinds, and what each one asks of the caller:
64
+ // transport-auth the runner rejected OUR bearer (GOG_RUNNER_KEY on the
65
+ // Worker vs RUNNER_KEY on the Fly app). An operator has
66
+ // to fix a key; the end user's Google grant is fine.
67
+ // transport-request the runner refused the request shape (oversized arg,
68
+ // malformed JSON). Deterministic; retrying is pointless.
69
+ // transport-retryable the runner is draining, could not reach its disk, or
70
+ // never answered. The same call can succeed shortly.
71
+ export type RunnerFailureKind = 'transport-auth' | 'transport-request' | 'transport-retryable';
72
+
73
+ // `Symbol.for`, not a private symbol or a bare `instanceof`: the class can be
74
+ // evaluated more than once in one process (the stdio bundle and the Worker
75
+ // bundle are separate builds of the same source, and vitest can load a module
76
+ // twice across pools), and a second copy of the class would make `instanceof`
77
+ // answer false for an error that IS one. The registry symbol is the same value
78
+ // in every copy, so the brand survives.
79
+ const RUNNER_TRANSPORT_BRAND = Symbol.for('gogcli.RunnerTransportError');
80
+
81
+ /**
82
+ * A failure authored by the gog-runner itself (or by the hop to it) rather than
83
+ * by `gog`/Google. Carries the runner's HTTP status when there was one.
84
+ */
85
+ export class RunnerTransportError extends Error {
86
+ readonly kind: RunnerFailureKind;
87
+ readonly status: number | undefined;
88
+
89
+ constructor(message: string, kind: RunnerFailureKind, status?: number) {
90
+ super(message);
91
+ this.name = 'RunnerTransportError';
92
+ this.kind = kind;
93
+ this.status = status;
94
+ // Non-enumerable so the brand never shows up in a serialized error body.
95
+ Object.defineProperty(this, RUNNER_TRANSPORT_BRAND, { value: true });
96
+ }
97
+ }
98
+
99
+ /** Structural check for the above — see RUNNER_TRANSPORT_BRAND on why not `instanceof`. */
100
+ export function isRunnerTransportError(err: unknown): err is RunnerTransportError {
101
+ return err instanceof Error && (err as unknown as Record<symbol, unknown>)[RUNNER_TRANSPORT_BRAND] === true;
102
+ }
103
+
48
104
  // Ambient override for the executor `run()` uses when no options.spawner is
49
105
  // given. The Worker/Fly path wraps request handling in
50
106
  // `runExecutor.run({ executor }, ...)`; unset, `run()` falls back to spawning.
@@ -111,7 +167,7 @@ const TIMEOUT_MS = 30_000;
111
167
  // so the requirement change is surfaced in the release notes (see
112
168
  // .github/release.yml). This is the single source of truth for the required
113
169
  // version; keep the README/CLAUDE.md mention in sync.
114
- export const MIN_GOG_VERSION = '0.34.1';
170
+ export const MIN_GOG_VERSION = '0.35.0';
115
171
 
116
172
  // Interpret the GOG_READONLY kill-switch. `readEnvVar` already treats blank
117
173
  // values, 'undefined'/'null' sentinels, and unresolved .mcpb placeholders
@@ -377,7 +433,16 @@ export async function run(args: GogArg[], options: RunOptions = {}): Promise<str
377
433
  // A thrown non-Error would make `.message` undefined and redact() blow up
378
434
  // with a TypeError, masking the real failure. Same instanceof guard the
379
435
  // codebase already uses in errorText() (tools/utils.ts).
380
- throw new Error(redact(err instanceof Error ? err.message : String(err)));
436
+ const message = redact(err instanceof Error ? err.message : String(err));
437
+ // Redaction must not cost the error its TYPE. `RunnerTransportError` is the
438
+ // structural claim "this failure was ours, not Google's"; flattening it to a
439
+ // bare Error here would put diagnose() straight back to guessing from prose,
440
+ // which is the bug this type exists to close. Rebuilt rather than mutated so
441
+ // the un-redacted message never survives anywhere.
442
+ if (isRunnerTransportError(err)) {
443
+ throw new RunnerTransportError(message, err.kind, err.status);
444
+ }
445
+ throw new Error(message);
381
446
  }
382
447
  }
383
448
 
package/src/tools/auth.ts CHANGED
@@ -14,7 +14,12 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
14
14
  `Google API is not enabled on the OAuth client's project makes Google reject the WHOLE request ` +
15
15
  `with invalid_scope.`;
16
16
  server.registerTool('gog_auth_list', {
17
- description: 'List all Google accounts stored in gogcli. Use this to check which accounts are configured and available.',
17
+ description:
18
+ 'List the Google accounts stored in gogcli, with their scopes. This reads local ' +
19
+ 'configuration only — it does not contact Google and does NOT tell you whether an account ' +
20
+ 'still works: a signed-out account whose refresh token expired or was revoked is listed here ' +
21
+ 'exactly like a healthy one, scopes and all. Use gog_auth_health to check whether an account ' +
22
+ 'can actually authenticate.',
18
23
  annotations: { readOnlyHint: true },
19
24
  inputSchema: {},
20
25
  }, async () => {
@@ -2,8 +2,8 @@ import { z } from 'zod';
2
2
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
4
4
  import { errorResult, rawTextResult } from '@chrischall/mcp-utils';
5
- import { run } from '../runner.js';
6
- import type { GogArg } from '../runner.js';
5
+ import { run, isRunnerTransportError } from '../runner.js';
6
+ import type { GogArg, RunnerFailureKind } from '../runner.js';
7
7
  import { normalizeTimestamps } from '../timestamps.js';
8
8
 
9
9
  // Byte size at or below which a payload stays on the plain inline flag.
@@ -135,7 +135,39 @@ export function errorText(err: unknown): string {
135
135
  return err instanceof Error ? `Error: ${err.message}` : String(err);
136
136
  }
137
137
 
138
- const AUTH_ERROR_PATTERN = /\b(401|unauthorized|token.*(expired|revoked)|invalid_grant)\b/i;
138
+ // Google saying "not authenticated" in its own words. Definitive: a retry
139
+ // cannot turn a 401 into a success, so this outranks the transient signal below.
140
+ // `unauthorized` and `invalid_grant` are unambiguous words, but 401 is also
141
+ // just an integer, and gog's output is full of integers that are row indices,
142
+ // ranges and counts. A bare `\b401\b` classified "row 401 is outside the sheet
143
+ // grid" — a pure Sheets range error — as a definite auth failure and sent the
144
+ // caller off to re-authorize a healthy account. So a 401 has to look like a
145
+ // STATUS: introduced by a status-ish word.
146
+ //
147
+ // THE SEPARATOR IS THE WHOLE DIFFICULTY (#246). The first attempt used
148
+ // `\s*[:=]?\s*`, which cannot cross an opening paren or a JSON quote — so it
149
+ // silently stopped matching the CANONICAL shape gog emits for a Google auth
150
+ // failure, `Google API error (401 authError)`, and JSON bodies like
151
+ // `{"code": 401}`. That regression is strictly worse than the false positive it
152
+ // was fixing: a false positive costs a pointless re-auth, but a real dead
153
+ // credential with no hint at all leaves the caller with nothing to act on.
154
+ //
155
+ // So the separator admits the punctuation those shapes actually use — quote,
156
+ // paren, colon, equals, comma, space — and is CAPPED at 4 characters so a status
157
+ // word cannot reach across prose to an unrelated integer ("error: could not
158
+ // write row 401" must stay silent). `A401:B401` never matched anyway: there is
159
+ // no word boundary after `A`.
160
+ const DEFINITE_AUTH_PATTERN =
161
+ /\b(?:unauthorized|invalid_grant)\b|\b(?:error|status|code|http|responded|response)["']?[\s:=(,]{0,4}401\b/i;
162
+
163
+ // A message that TALKS about an expired token. Suggestive, not definitive — and
164
+ // it used to be `/token.*(expired|revoked)/`, whose greedy `.*` matched a token
165
+ // mentioned anywhere and an expiry mentioned anywhere later in the same line
166
+ // ("page token accepted; the export link has expired"). Now the two words must
167
+ // actually be about each other.
168
+ const STALE_TOKEN_PATTERN = /\b(?:access[ _-]?)?token\b[^.;\n]{0,40}\b(?:has\s+)?(?:been\s+)?(?:expired|revoked)\b|\b(?:expired|revoked)\s+(?:access[ _-]?)?token\b/i;
169
+
170
+ const AUTH_ERROR_PATTERN = new RegExp(`${DEFINITE_AUTH_PATTERN.source}|${STALE_TOKEN_PATTERN.source}`, 'i');
139
171
 
140
172
  // A DEAD refresh token — the whole account is signed out, not just a stale
141
173
  // access token that would refresh silently. This is the recurring account-wide
@@ -177,6 +209,31 @@ const GRID_LIMIT_HINT =
177
209
  '\n\nThe target range is outside the sheet\'s current grid. Add the missing rows or columns ' +
178
210
  'first with gog_sheets_insert (dimension: rows or cols), then retry the write.';
179
211
 
212
+ // The hint for each RUNNER-authored failure kind (see RunnerTransportError in
213
+ // runner.ts). These are chosen by the error's TYPE, never by reading its text.
214
+ //
215
+ // transport-auth is the one that motivated all of this. The runner answers a
216
+ // bad bearer with the single word "unauthorized"; read as prose that is
217
+ // indistinguishable from Google rejecting a credential, and the caller was
218
+ // being told all session to re-authorize an account that had never been asked
219
+ // for anything. So this hint names the real cause and says outright that
220
+ // re-authorizing cannot help. It deliberately does NOT contain the literal
221
+ // `gog_auth_add`, which is the token the rest of the auth guidance keys on.
222
+ const RUNNER_TRANSPORT_AUTH_HINT =
223
+ '\n\nThis is the CONNECTOR\'s own transport auth failing, not your Google sign-in. The gog-runner ' +
224
+ 'backend rejected the bearer token this server sent, so the request never reached gog and no Google ' +
225
+ 'credential was checked — the Google account is not the problem and re-authorizing it cannot fix this. ' +
226
+ 'An operator must make the Worker secret GOG_RUNNER_KEY equal RUNNER_KEY on the Fly app ' +
227
+ '(wrangler secret put GOG_RUNNER_KEY / fly secrets set RUNNER_KEY), then retry.';
228
+
229
+ const RUNNER_TRANSPORT_HINTS: Record<RunnerFailureKind, string> = {
230
+ 'transport-auth': RUNNER_TRANSPORT_AUTH_HINT,
231
+ // The request itself was malformed, so the runner will refuse it identically
232
+ // every time. Nothing to advise beyond the message the runner already gave.
233
+ 'transport-request': '',
234
+ 'transport-retryable': TRANSIENT_HINT,
235
+ };
236
+
180
237
  // Reduce `gog auth list --json` output to just the configured email addresses.
181
238
  // The raw JSON also carries OAuth scopes, the Google subject id, and creation
182
239
  // timestamps — none of which belong in an error surfaced to the model, and
@@ -206,11 +263,41 @@ export function formatAccountList(raw: string): string {
206
263
  // keeps the same diagnostic quality as everywhere else.
207
264
  export async function diagnose(err: unknown): Promise<CallToolResult> {
208
265
  const errText = errorText(err);
266
+
267
+ // STRUCTURE BEFORE PROSE. A RunnerTransportError is this connector's own
268
+ // transport failing — its bearer, its request validation, its drain. Nothing
269
+ // was shown to Google, so none of the patterns below may be consulted for it:
270
+ // they exist to read gog's/Google's words, and the runner's words are not
271
+ // those. Read as prose, the runner's `unauthorized` matched
272
+ // DEFINITE_AUTH_PATTERN and produced AUTH_HINT — a human being told to
273
+ // re-authorize a healthy account over what was really a key mismatch.
274
+ //
275
+ // This short-circuits the ladder rather than joining it, so it is not a new
276
+ // rung in the precedence order documented below; that order still governs
277
+ // every error that genuinely came from gog.
278
+ const transportHint = isRunnerTransportError(err)
279
+ ? RUNNER_TRANSPORT_HINTS[err.kind]
280
+ : undefined;
281
+
209
282
  const isInvalidGrant = INVALID_GRANT_PATTERN.test(errText);
210
- const isAuthError = AUTH_ERROR_PATTERN.test(errText);
211
- const isTransientError = !isAuthError && TRANSIENT_ERROR_PATTERN.test(errText);
283
+
284
+ // Precedence, and the reason for it. Reporting needs-auth is EXPENSIVE to be
285
+ // wrong about: re-authorization is a manual, human step, and it does not fix
286
+ // a 429. So a transient signal beats a merely SUGGESTIVE auth signal (a
287
+ // message that mentions an expired token) — the reported bug was servers
288
+ // flapping into needs-auth and working again seconds later, which is what
289
+ // being told to re-auth over a rate-limit looks like.
290
+ //
291
+ // It does NOT beat a definitive one. A literal 401 or invalid_grant is Google
292
+ // saying the credential will not work, and calling that "retry" would loop a
293
+ // caller forever against a request that can never succeed.
294
+ const isTransientError = !DEFINITE_AUTH_PATTERN.test(errText) && TRANSIENT_ERROR_PATTERN.test(errText);
295
+ const isAuthError = !isTransientError && AUTH_ERROR_PATTERN.test(errText);
212
296
  const isGridLimitError = GRID_LIMIT_ERROR_PATTERN.test(errText);
213
- const hint = isInvalidGrant
297
+ // `??`, not `||`: 'transport-request' maps to the empty string on purpose —
298
+ // "this failure is ours and there is nothing to advise" — and `||` would fall
299
+ // through to the prose ladder for exactly the errors that must never reach it.
300
+ const hint = transportHint ?? (isInvalidGrant
214
301
  ? INVALID_GRANT_HINT
215
302
  : isAuthError
216
303
  ? AUTH_HINT
@@ -218,7 +305,7 @@ export async function diagnose(err: unknown): Promise<CallToolResult> {
218
305
  ? TRANSIENT_HINT
219
306
  : isGridLimitError
220
307
  ? GRID_LIMIT_HINT
221
- : '';
308
+ : '');
222
309
  try {
223
310
  const accounts = formatAccountList(await run(['auth', 'list']));
224
311
  return errorResult(`${errText}\n\nConfigured accounts:\n${accounts || '(none)'}${hint}`);