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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +436 -58
- package/dist/lib.js +437 -59
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/auth-log.ts +205 -0
- package/src/connector-auth.ts +265 -8
- package/src/connector-runtime.ts +764 -79
- package/src/google-probe.ts +113 -0
- package/src/google-token.ts +181 -15
- package/src/runner.ts +67 -2
- package/src/timestamps.ts +7 -0
- package/src/tools/auth.ts +8 -2
- package/src/tools/utils.ts +70 -5
- package/src/worker.ts +33 -3
- package/tests/auth-log.test.ts +530 -0
- package/tests/connector-auth.test.ts +539 -8
- package/tests/connector-runtime.test.ts +1121 -2
- package/tests/google-probe.test.ts +116 -0
- package/tests/google-token.test.ts +125 -4
- package/tests/runner.test.ts +21 -1
- package/tests/timestamps.test.ts +52 -0
- package/tests/tools/auth-401-context.test.ts +42 -0
- package/tests/tools/auth-401-shapes.test.ts +50 -0
- package/tests/tools/auth.test.ts +28 -0
- package/tests/tools/utils.test.ts +55 -2
- package/tests/worker.test.ts +33 -8
|
@@ -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
|
+
}
|
package/src/google-token.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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(
|
|
216
|
+
let pending = inFlight.get(k);
|
|
143
217
|
if (!pending) {
|
|
144
218
|
pending = exchange(refreshToken, clientId, clientSecret)
|
|
145
219
|
.then((minted) => {
|
|
146
|
-
cache.set(
|
|
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(
|
|
153
|
-
inFlight.set(
|
|
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
|
|
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
|
|
204
|
-
'the stored refresh token has expired or been revoked, so
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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 () => {
|
package/src/tools/utils.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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}`);
|