gogcli-mcp 2.21.0 → 2.22.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.
@@ -0,0 +1,113 @@
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,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
 
package/src/timestamps.ts CHANGED
@@ -149,6 +149,13 @@ const TIMESTAMP_KEYS = new Set([
149
149
  'date', // gog gmail message/thread listings ("2026-07-28 03:36")
150
150
  'dateTime', // Calendar event start/end
151
151
  'internalDate', // Gmail, epoch milliseconds (authoritative)
152
+ // gog >= 0.35.0 Gmail message AND thread listings. Already offset-bearing
153
+ // (RFC3339 from internalDate), so it needs no offset repair — it is
154
+ // allowlisted purely to gain a Display sibling, and to be re-rendered in
155
+ // DISPLAY_TZ like every other instant. Separately sourced from the sibling
156
+ // `date`, which is a naive re-format of the sender's Date header; the two may
157
+ // legitimately disagree. See docs/timestamps.md.
158
+ 'internalDateIso',
152
159
  'modifiedTime', // Drive
153
160
  'createdTime', // Drive
154
161
  'createTime',
package/src/tools/auth.ts CHANGED
@@ -31,7 +31,10 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
31
31
  });
32
32
 
33
33
  server.registerTool('gog_auth_status', {
34
- description: 'Show gogcli auth configuration: keyring backend, credential files, and auth setup.',
34
+ description:
35
+ 'Show gogcli auth CONFIGURATION: keyring backend, credential files, and auth setup. Despite the ' +
36
+ 'name this is not a health check — it reads local setup and does not contact Google, so it says ' +
37
+ 'nothing about whether an account can still authenticate. Use gog_auth_health for that.',
35
38
  annotations: { readOnlyHint: true },
36
39
  inputSchema: {},
37
40
  }, async () => {
@@ -50,7 +53,10 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
50
53
  'service. Reports per account: whether the token is currently valid, the mapped cause when it is ' +
51
54
  'not, how long ago it was authorized, and a warning as it approaches the 7-day refresh-token limit ' +
52
55
  'that applies to OAuth apps whose consent screen is still in "Testing" mode. Run it proactively to ' +
53
- 're-authorize on your own schedule instead of mid-task.',
56
+ 're-authorize on your own schedule instead of mid-task. On the hosted connector this is the ONLY ' +
57
+ 'check that measures Google: a connector showing "connected" or "refreshed" has verified the ' +
58
+ 'connector key that reaches the gog machine, and nothing else — the Google credential lives on ' +
59
+ 'that machine and can be dead while the connection looks perfectly healthy.',
54
60
  annotations: { readOnlyHint: true },
55
61
  inputSchema: {},
56
62
  }, 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.
@@ -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}`);