gogcli-mcp 2.21.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.
@@ -1,4 +1,5 @@
1
- import { readEnvVar } from '@chrischall/mcp-utils';
1
+ import { parseBoolEnv, readEnvVar } from '@chrischall/mcp-utils';
2
+ import { credentialTag, logAuthTransition } from './auth-log.js';
2
3
 
3
4
  /**
4
5
  * Mint short-lived Google access tokens from a long-lived refresh token, so a
@@ -85,8 +86,54 @@ async function cacheKey(refreshToken: string, clientId: string): Promise<string>
85
86
  * What a token source is: something that answers "who is this call acting as",
86
87
  * or throws trying. It never answers `undefined` after being configured —
87
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).
88
98
  */
89
- export type AccessTokenSource = () => Promise<string | undefined>;
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
+ }
90
137
 
91
138
  export interface TokenEnv {
92
139
  GOG_ACCESS_TOKEN?: string;
@@ -106,6 +153,10 @@ export interface TokenEnv {
106
153
  * the #230 path working untouched.
107
154
  */
108
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.
109
160
  const direct = readEnvVar('GOG_ACCESS_TOKEN', { env });
110
161
  if (direct) return async () => direct;
111
162
 
@@ -132,29 +183,109 @@ export function makeAccessTokenSource(env: TokenEnv): AccessTokenSource | undefi
132
183
  };
133
184
  }
134
185
 
135
- return async () => {
136
- const key = await cacheKey(refreshToken, clientId);
137
- const hit = cache.get(key);
138
- if (hit && hit.expiresAt - EXPIRY_MARGIN_MS > Date.now()) return hit.accessToken;
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
+ }
139
213
 
140
214
  // Join the exchange already running for this credential, or start the one
141
215
  // everyone else will join.
142
- let pending = inFlight.get(key);
216
+ let pending = inFlight.get(k);
143
217
  if (!pending) {
144
218
  pending = exchange(refreshToken, clientId, clientSecret)
145
219
  .then((minted) => {
146
- cache.set(key, 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
+ });
147
225
  return minted;
148
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
+ })
149
242
  // Dropped whether it resolved OR threw. Keeping a rejected promise here
150
243
  // would make one transient failure permanent for every later caller —
151
244
  // the opposite of the "failures are not cached" rule above.
152
- .finally(() => inFlight.delete(key));
153
- inFlight.set(key, pending);
245
+ .finally(() => inFlight.delete(k));
246
+ inFlight.set(k, pending);
154
247
  }
155
248
  const minted = await pending;
156
249
  return minted.accessToken;
157
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
+ });
158
289
  }
159
290
 
160
291
  /**
@@ -168,6 +299,22 @@ export function makeAccessTokenSource(env: TokenEnv): AccessTokenSource | undefi
168
299
  * Nothing here is cached on failure either, so a transient Google outage does
169
300
  * not become a sticky one.
170
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
+
171
318
  async function exchange(refreshToken: string, clientId: string, clientSecret: string): Promise<CachedToken> {
172
319
  let res: Response;
173
320
  try {
@@ -182,8 +329,9 @@ async function exchange(refreshToken: string, clientId: string, clientSecret: st
182
329
  }).toString(),
183
330
  });
184
331
  } catch (err) {
185
- throw new Error(
332
+ throw new TokenExchangeError(
186
333
  `the Google token exchange could not be reached: ${err instanceof Error ? err.message : String(err)}`,
334
+ false,
187
335
  );
188
336
  }
189
337
 
@@ -199,23 +347,41 @@ async function exchange(refreshToken: string, clientId: string, clientSecret: st
199
347
  // transient: the credential is gone and a human has to enrol again. Google
200
348
  // expires refresh tokens after 7 days while a consent screen is still in
201
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.
202
362
  if (body.error === 'invalid_grant') {
203
- throw new Error(
204
- 'the stored refresh token has expired or been revoked, so this account must be re-authorized ' +
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 ' +
205
366
  '(commonly the 7-day limit on OAuth consent screens still in "Testing" mode). ' +
206
367
  'Re-enrol with gog_auth_add_url + gog_auth_add_complete and store the new refresh token.',
368
+ true,
207
369
  );
208
370
  }
209
371
  // The refresh token is deliberately absent from this message — it is a
210
372
  // long-lived credential and an error string travels into logs and model
211
373
  // context.
212
- throw new Error(
374
+ throw new TokenExchangeError(
213
375
  `the access token could not be refreshed (HTTP ${res.status}${body.error ? `, ${body.error}` : ''})`,
376
+ false,
214
377
  );
215
378
  }
216
379
 
217
380
  if (!body.access_token) {
218
- throw new Error('the access token could not be refreshed: Google returned no access_token');
381
+ throw new TokenExchangeError(
382
+ 'the access token could not be refreshed: Google returned no access_token',
383
+ false,
384
+ );
219
385
  }
220
386
 
221
387
  // Default to an hour if Google omits expires_in; the margin above covers the
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
 
@@ -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.
@@ -137,7 +137,28 @@ export function errorText(err: unknown): string {
137
137
 
138
138
  // Google saying "not authenticated" in its own words. Definitive: a retry
139
139
  // cannot turn a 401 into a success, so this outranks the transient signal below.
140
- const DEFINITE_AUTH_PATTERN = /\b(401|unauthorized|invalid_grant)\b/i;
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;
141
162
 
142
163
  // A message that TALKS about an expired token. Suggestive, not definitive — and
143
164
  // it used to be `/token.*(expired|revoked)/`, whose greedy `.*` matched a token
@@ -188,6 +209,31 @@ const GRID_LIMIT_HINT =
188
209
  '\n\nThe target range is outside the sheet\'s current grid. Add the missing rows or columns ' +
189
210
  'first with gog_sheets_insert (dimension: rows or cols), then retry the write.';
190
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
+
191
237
  // Reduce `gog auth list --json` output to just the configured email addresses.
192
238
  // The raw JSON also carries OAuth scopes, the Google subject id, and creation
193
239
  // timestamps — none of which belong in an error surfaced to the model, and
@@ -217,6 +263,22 @@ export function formatAccountList(raw: string): string {
217
263
  // keeps the same diagnostic quality as everywhere else.
218
264
  export async function diagnose(err: unknown): Promise<CallToolResult> {
219
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
+
220
282
  const isInvalidGrant = INVALID_GRANT_PATTERN.test(errText);
221
283
 
222
284
  // Precedence, and the reason for it. Reporting needs-auth is EXPENSIVE to be
@@ -232,7 +294,10 @@ export async function diagnose(err: unknown): Promise<CallToolResult> {
232
294
  const isTransientError = !DEFINITE_AUTH_PATTERN.test(errText) && TRANSIENT_ERROR_PATTERN.test(errText);
233
295
  const isAuthError = !isTransientError && AUTH_ERROR_PATTERN.test(errText);
234
296
  const isGridLimitError = GRID_LIMIT_ERROR_PATTERN.test(errText);
235
- 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
236
301
  ? INVALID_GRANT_HINT
237
302
  : isAuthError
238
303
  ? AUTH_HINT
@@ -240,7 +305,7 @@ export async function diagnose(err: unknown): Promise<CallToolResult> {
240
305
  ? TRANSIENT_HINT
241
306
  : isGridLimitError
242
307
  ? GRID_LIMIT_HINT
243
- : '';
308
+ : '');
244
309
  try {
245
310
  const accounts = formatAccountList(await run(['auth', 'list']));
246
311
  return errorResult(`${errText}\n\nConfigured accounts:\n${accounts || '(none)'}${hint}`);
package/src/worker.ts CHANGED
@@ -38,7 +38,7 @@ import { gogAuth, type GogProps } from './connector-auth.js';
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.21.0'; // x-release-please-version
41
+ const VERSION = '2.21.1'; // 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.
@@ -48,6 +48,17 @@ function makeAgent(registrars: ToolRegistrar[]): typeof McpAgent {
48
48
  class GogAgent extends McpAgent<unknown, unknown, GogProps> {
49
49
  server = new McpServer({ name: 'gogcli-mcp', version: VERSION });
50
50
  async init() {
51
+ // NO third argument, deliberately: the hosted connector supplies no
52
+ // per-caller access token, so `gog` runs as the Fly volume's own identity
53
+ // and refreshes from its own keyring. That is what makes the eviction +
54
+ // replay machinery in connector-runtime.ts INERT here — with no token
55
+ // source there is no module-level cache that can go stale, so a Google
56
+ // 401 on this path stops at the `no access token was supplied` guard and
57
+ // logs `replay.declined`. That record is the expected outcome for a
58
+ // hosted connector, not a bug; the transport-failure classification and
59
+ // the auth log itself do apply here. (docs/DEPLOY-CONNECTOR.md,
60
+ // "Reading the auth log", says the same thing for whoever is reading logs
61
+ // rather than code.)
51
62
  const executor = makeFlyExecutor((this.env as { FLY_ENDPOINT: string }).FLY_ENDPOINT, this.props.key);
52
63
  const wrapped = wrapServer(this.server, executor);
53
64
  for (const register of registrars) register(wrapped);