gogcli-mcp 2.20.0 → 2.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +434 -66
- package/dist/lib.js +396 -47
- package/manifest.json +1 -1
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/auth-log.ts +147 -0
- package/src/connector-runtime.ts +585 -80
- package/src/google-token.ts +391 -0
- package/src/remote-runner.ts +8 -1
- package/src/runner.ts +67 -2
- package/src/tools/auth.ts +6 -1
- package/src/tools/utils.ts +94 -7
- package/src/worker.ts +12 -1
- package/tests/auth-log.test.ts +508 -0
- package/tests/connector-runtime.test.ts +675 -2
- package/tests/google-token.test.ts +425 -0
- package/tests/runner.test.ts +21 -1
- package/tests/sdk-single-copy.test.ts +48 -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 +21 -0
- package/tests/tools/utils.test.ts +99 -2
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { parseBoolEnv, readEnvVar } from '@chrischall/mcp-utils';
|
|
2
|
+
import { credentialTag, logAuthTransition } from './auth-log.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Mint short-lived Google access tokens from a long-lived refresh token, so a
|
|
6
|
+
* hosted gog's identity belongs to the REGISTRATION rather than to the machine
|
|
7
|
+
* the binary runs on (#241).
|
|
8
|
+
*
|
|
9
|
+
* The shape of the problem: `gog` reads credentials from a keyring at
|
|
10
|
+
* `GOG_HOME`, on the box where it executes — which is why one Fly volume ended
|
|
11
|
+
* up being every registration's identity. But `gog --access-token` bypasses the
|
|
12
|
+
* keyring entirely, and #235 already carries such a token to the box per
|
|
13
|
+
* request. The only missing piece was that an access token lives about an hour,
|
|
14
|
+
* so it cannot be the thing you STORE. A refresh token can.
|
|
15
|
+
*
|
|
16
|
+
* So the refresh token stays here, in the child's environment, and only a
|
|
17
|
+
* one-hour access token ever crosses the wire. That is strictly better than the
|
|
18
|
+
* arrangement it replaces, where a permanent credential sat on a shared volume.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Replace a token this long before it actually expires. A token that dies
|
|
25
|
+
* mid-flight is a failure the caller can do nothing about, and the exchange is
|
|
26
|
+
* cheap next to a failed tool call.
|
|
27
|
+
*/
|
|
28
|
+
const EXPIRY_MARGIN_MS = 120_000;
|
|
29
|
+
|
|
30
|
+
interface CachedToken {
|
|
31
|
+
accessToken: string;
|
|
32
|
+
expiresAt: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Keyed by the CREDENTIAL, never a single "current token".
|
|
37
|
+
*
|
|
38
|
+
* A module-level current-token would be correct for one stdio process and
|
|
39
|
+
* silently wrong everywhere else: a Worker isolate serves many callers, so the
|
|
40
|
+
* first caller's identity would be handed to everyone after them. That is the
|
|
41
|
+
* same failure as the captured executor in #235 and the ambient store in #233 —
|
|
42
|
+
* three bugs, one shape, which is why this one is keyed from the start.
|
|
43
|
+
*
|
|
44
|
+
* The key is a hash rather than the token itself so that nothing which dumps or
|
|
45
|
+
* iterates this map (a heap snapshot, a debugger, a future logging line) puts a
|
|
46
|
+
* live credential in front of someone.
|
|
47
|
+
*/
|
|
48
|
+
const cache = new Map<string, CachedToken>();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Exchanges currently in flight, so concurrent callers share ONE of them.
|
|
52
|
+
*
|
|
53
|
+
* Without this, `get` → `await exchange` → `set` has an await between the miss
|
|
54
|
+
* and the fill: every caller that arrives during that window also misses, and
|
|
55
|
+
* they all hit Google's token endpoint together. One process per caller hides
|
|
56
|
+
* it, but a Worker isolate serving many callers — or simply several tool calls
|
|
57
|
+
* in flight — turns a single refresh into a stampede, and being rate-limited
|
|
58
|
+
* for it produces exactly the intermittent auth failures this was meant to end.
|
|
59
|
+
*
|
|
60
|
+
* Keyed identically to `cache`, so two different credentials never wait on each
|
|
61
|
+
* other's exchange.
|
|
62
|
+
*/
|
|
63
|
+
const inFlight = new Map<string, Promise<CachedToken>>();
|
|
64
|
+
|
|
65
|
+
/** Test seam: both maps are process-wide, so they do not unwind between tests. */
|
|
66
|
+
export function clearAccessTokenCache(): void {
|
|
67
|
+
cache.clear();
|
|
68
|
+
inFlight.clear();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* WebCrypto rather than `node:crypto`: this module is reachable from the Worker
|
|
73
|
+
* build, which has no node builtins. Both runtimes expose `crypto.subtle`.
|
|
74
|
+
*/
|
|
75
|
+
async function cacheKey(refreshToken: string, clientId: string): Promise<string> {
|
|
76
|
+
// NUL-separated, spelled as an escape so this source file stays text: it
|
|
77
|
+
// keeps a (clientId, refreshToken) pair from colliding with a different
|
|
78
|
+
// pair whose concatenation happens to match. Neither value can contain a
|
|
79
|
+
// NUL, which is what makes the boundary unambiguous.
|
|
80
|
+
const data = new TextEncoder().encode(`${clientId}\u0000${refreshToken}`);
|
|
81
|
+
const digest = await crypto.subtle.digest('SHA-256', data);
|
|
82
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What a token source is: something that answers "who is this call acting as",
|
|
87
|
+
* or throws trying. It never answers `undefined` after being configured —
|
|
88
|
+
* see the failure note below.
|
|
89
|
+
*
|
|
90
|
+
* It also answers a second question, which is what makes a rejected token
|
|
91
|
+
* recoverable: `invalidate(rejected)` drops that token if it is still the one
|
|
92
|
+
* cached for this credential, and REPORTS whether it dropped anything.
|
|
93
|
+
*
|
|
94
|
+
* `true` means the next read mints something new, so a replay is worth making;
|
|
95
|
+
* `false` means a concurrent caller has already replaced the entry. Those are
|
|
96
|
+
* the only two answers, and a source with nothing to mint from gives neither —
|
|
97
|
+
* it omits the method entirely (see below).
|
|
98
|
+
*/
|
|
99
|
+
export interface AccessTokenSource {
|
|
100
|
+
(): Promise<string | undefined>;
|
|
101
|
+
/**
|
|
102
|
+
* Evict `rejected` if it is still this credential's cached token; answer
|
|
103
|
+
* whether anything was evicted.
|
|
104
|
+
*
|
|
105
|
+
* ABSENT exactly when this source holds nothing mintable — a directly-supplied
|
|
106
|
+
* GOG_ACCESS_TOKEN, or a refresh token with no OAuth client beside it. The
|
|
107
|
+
* absence has to be the signal, because an always-false return is not
|
|
108
|
+
* distinguishable from the false a real cache gives when a concurrent caller
|
|
109
|
+
* beat you to the refresh, and the connector records those as different
|
|
110
|
+
* causes: "nothing here can mint a replacement" is the one configuration
|
|
111
|
+
* where re-authorizing genuinely IS the repair, and it must not be logged as
|
|
112
|
+
* somebody else's concurrent write.
|
|
113
|
+
*
|
|
114
|
+
* Scoped to ONE credential on purpose. `clearAccessTokenCache()` drops every
|
|
115
|
+
* entry and exists only as a test seam — using it here would mean one
|
|
116
|
+
* caller's dead token forced a re-mint on every other caller sharing the
|
|
117
|
+
* isolate, which is the same shared-identity mistake this module is built
|
|
118
|
+
* around, wearing a different hat.
|
|
119
|
+
*
|
|
120
|
+
* Matched by VALUE, not just by key. A concurrent caller may already have
|
|
121
|
+
* replaced the entry while this call was in flight; evicting that fresh token
|
|
122
|
+
* would waste a mint and let two callers keep undoing each other's work.
|
|
123
|
+
*/
|
|
124
|
+
invalidate?: (rejected: string) => Promise<boolean>;
|
|
125
|
+
/**
|
|
126
|
+
* The log-safe name of the credential behind this source, for correlating
|
|
127
|
+
* this module's records with the connector's (auth-log.ts).
|
|
128
|
+
*
|
|
129
|
+
* Optional, and absent exactly when there is no mintable credential to name.
|
|
130
|
+
* A directly-supplied GOG_ACCESS_TOKEN emits no records here at all — nothing
|
|
131
|
+
* is minted, cached or evicted — so a tag for it would identify a story that
|
|
132
|
+
* is never told. Answering `undefined` says that honestly rather than
|
|
133
|
+
* inventing an identifier for a credential this module does not hold.
|
|
134
|
+
*/
|
|
135
|
+
credentialId?: () => Promise<string>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface TokenEnv {
|
|
139
|
+
GOG_ACCESS_TOKEN?: string;
|
|
140
|
+
GOG_REFRESH_TOKEN?: string;
|
|
141
|
+
GOG_CLIENT_ID?: string;
|
|
142
|
+
GOG_CLIENT_SECRET?: string;
|
|
143
|
+
[key: string]: string | undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Build the token source for this environment, or `undefined` when nothing is
|
|
148
|
+
* configured — which leaves the backend acting as itself, exactly as every
|
|
149
|
+
* registration did before this existed.
|
|
150
|
+
*
|
|
151
|
+
* Precedence puts a directly-supplied `GOG_ACCESS_TOKEN` first: someone who
|
|
152
|
+
* already holds a token should not need an OAuth client to use it, and it keeps
|
|
153
|
+
* the #230 path working untouched.
|
|
154
|
+
*/
|
|
155
|
+
export function makeAccessTokenSource(env: TokenEnv): AccessTokenSource | undefined {
|
|
156
|
+
// A token handed to us whole. Nothing minted it here, so nothing here can
|
|
157
|
+
// mint another — hence no `invalidate` at all, which is how the caller tells
|
|
158
|
+
// this apart from a cache that merely lost a race. When THIS is the token
|
|
159
|
+
// Google rejects, re-authorization really is the repair.
|
|
160
|
+
const direct = readEnvVar('GOG_ACCESS_TOKEN', { env });
|
|
161
|
+
if (direct) return async () => direct;
|
|
162
|
+
|
|
163
|
+
const refreshToken = readEnvVar('GOG_REFRESH_TOKEN', { env });
|
|
164
|
+
if (!refreshToken) return undefined;
|
|
165
|
+
|
|
166
|
+
const clientId = readEnvVar('GOG_CLIENT_ID', { env });
|
|
167
|
+
const clientSecret = readEnvVar('GOG_CLIENT_SECRET', { env });
|
|
168
|
+
|
|
169
|
+
// A refresh token with no OAuth client cannot mint anything, and the WRONG
|
|
170
|
+
// repair is to treat it as unconfigured: that falls back to the backend's own
|
|
171
|
+
// identity, which is the precise confusion this feature exists to remove. So
|
|
172
|
+
// the source exists and throws when used — `tools/list` still works, the
|
|
173
|
+
// server still starts, and the first tool call says what is missing.
|
|
174
|
+
if (!clientId || !clientSecret) {
|
|
175
|
+
const missing = [!clientId && 'GOG_CLIENT_ID', !clientSecret && 'GOG_CLIENT_SECRET']
|
|
176
|
+
.filter(Boolean)
|
|
177
|
+
.join(' and ');
|
|
178
|
+
return async () => {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`GOG_REFRESH_TOKEN is set but ${missing} is not, so no access token can be minted. ` +
|
|
181
|
+
'Set the OAuth client alongside the refresh token, or unset GOG_REFRESH_TOKEN to use the backend’s own identity.',
|
|
182
|
+
);
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Hashed ONCE per source rather than once per call. `read` and `invalidate`
|
|
187
|
+
// each used to recompute the SHA-256 on every invocation, which is work this
|
|
188
|
+
// module was already paying for on the request path; memoizing it also gives
|
|
189
|
+
// the log records a credential tag for free. Lazy rather than eager so a
|
|
190
|
+
// source that is never used never starts a promise nobody awaits.
|
|
191
|
+
let keyPromise: Promise<string> | undefined;
|
|
192
|
+
const key = (): Promise<string> => (keyPromise ??= cacheKey(refreshToken, clientId));
|
|
193
|
+
|
|
194
|
+
// Every other transition in the set is an EVENT; a cache hit is the absence
|
|
195
|
+
// of one. Narrating it writes a line per gog invocation — a Workers Logs line
|
|
196
|
+
// (and its cost) for every tool call in healthy operation on the Worker, and
|
|
197
|
+
// a stderr line per call in the MCP host's server log on stdio — which turns
|
|
198
|
+
// the `gog-auth` stream from a log of transitions into a request log.
|
|
199
|
+
//
|
|
200
|
+
// It stays available for the investigation that has to prove WHICH token a
|
|
201
|
+
// call was served (the shape the original incident took), behind a flag
|
|
202
|
+
// nobody sets in normal operation. Read once per source rather than per call:
|
|
203
|
+
// the env cannot change under a running process.
|
|
204
|
+
const logCacheHits = parseBoolEnv('GOG_AUTH_LOG_CACHE_HITS', { env });
|
|
205
|
+
|
|
206
|
+
const read = async (): Promise<string | undefined> => {
|
|
207
|
+
const k = await key();
|
|
208
|
+
const hit = cache.get(k);
|
|
209
|
+
if (hit && hit.expiresAt - EXPIRY_MARGIN_MS > Date.now()) {
|
|
210
|
+
if (logCacheHits) logAuthTransition('token.cache-hit', { credential: credentialTag(k) });
|
|
211
|
+
return hit.accessToken;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Join the exchange already running for this credential, or start the one
|
|
215
|
+
// everyone else will join.
|
|
216
|
+
let pending = inFlight.get(k);
|
|
217
|
+
if (!pending) {
|
|
218
|
+
pending = exchange(refreshToken, clientId, clientSecret)
|
|
219
|
+
.then((minted) => {
|
|
220
|
+
cache.set(k, minted);
|
|
221
|
+
logAuthTransition('token.minted', {
|
|
222
|
+
credential: credentialTag(k),
|
|
223
|
+
reason: `valid for ${Math.round((minted.expiresAt - Date.now()) / 1000)}s`,
|
|
224
|
+
});
|
|
225
|
+
return minted;
|
|
226
|
+
})
|
|
227
|
+
// Only the caller that STARTED the exchange records it, because only one
|
|
228
|
+
// exchange happened; the callers that joined it would otherwise turn one
|
|
229
|
+
// mint into a burst of identical lines.
|
|
230
|
+
//
|
|
231
|
+
// Typed as TokenExchangeError rather than `unknown`: `exchange` catches
|
|
232
|
+
// the fetch rejection and the JSON parse itself, so this is the only
|
|
233
|
+
// thing that can arrive here, and pretending otherwise would add an arm
|
|
234
|
+
// no test could ever reach.
|
|
235
|
+
.catch((err: TokenExchangeError): never => {
|
|
236
|
+
logAuthTransition(err.grantDead ? 'grant.dead' : 'token.mint-failed', {
|
|
237
|
+
credential: credentialTag(k),
|
|
238
|
+
reason: err.message,
|
|
239
|
+
});
|
|
240
|
+
throw err;
|
|
241
|
+
})
|
|
242
|
+
// Dropped whether it resolved OR threw. Keeping a rejected promise here
|
|
243
|
+
// would make one transient failure permanent for every later caller —
|
|
244
|
+
// the opposite of the "failures are not cached" rule above.
|
|
245
|
+
.finally(() => inFlight.delete(k));
|
|
246
|
+
inFlight.set(k, pending);
|
|
247
|
+
}
|
|
248
|
+
const minted = await pending;
|
|
249
|
+
return minted.accessToken;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Google rejected `rejected`; make sure the next read does not serve it again.
|
|
254
|
+
*
|
|
255
|
+
* The read guard above is purely about TIME, so without this a token Google
|
|
256
|
+
* has already answered 401 to is re-served until its nominal expiry — up to
|
|
257
|
+
* ~58 minutes of every call failing identically, which is exactly the incident
|
|
258
|
+
* this closes. `inFlight` is deliberately untouched: an exchange that is
|
|
259
|
+
* already running was started to produce a NEW token, and cancelling it would
|
|
260
|
+
* only make the callers waiting on it mint again.
|
|
261
|
+
*/
|
|
262
|
+
const invalidate = async (rejected: string): Promise<boolean> => {
|
|
263
|
+
const k = await key();
|
|
264
|
+
const hit = cache.get(k);
|
|
265
|
+
if (!hit || hit.accessToken !== rejected) {
|
|
266
|
+
// The two "nothing happened" cases are worth telling apart in a log: one
|
|
267
|
+
// says a concurrent caller has already repaired this credential, the
|
|
268
|
+
// other says the rejected token was never ours to begin with.
|
|
269
|
+
logAuthTransition('token.evict-noop', {
|
|
270
|
+
credential: credentialTag(k),
|
|
271
|
+
reason: hit
|
|
272
|
+
? 'a concurrent caller had already replaced this credential’s token'
|
|
273
|
+
: 'no token was cached for this credential',
|
|
274
|
+
});
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
cache.delete(k);
|
|
278
|
+
logAuthTransition('token.evicted', {
|
|
279
|
+
credential: credentialTag(k),
|
|
280
|
+
reason: 'Google rejected this access token; the next read will mint a new one',
|
|
281
|
+
});
|
|
282
|
+
return true;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
return Object.assign(read, {
|
|
286
|
+
invalidate,
|
|
287
|
+
credentialId: async () => credentialTag(await key()),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Exchange refresh -> access.
|
|
293
|
+
*
|
|
294
|
+
* THROWS on every failure, and never returns `undefined`. Returning nothing
|
|
295
|
+
* would let the call proceed as the backend's identity, and the caller would
|
|
296
|
+
* read someone else's mailbox while everything looked like success — the same
|
|
297
|
+
* reasoning that made a malformed token a 400 rather than an ignore in #235.
|
|
298
|
+
*
|
|
299
|
+
* Nothing here is cached on failure either, so a transient Google outage does
|
|
300
|
+
* not become a sticky one.
|
|
301
|
+
*/
|
|
302
|
+
class TokenExchangeError extends Error {
|
|
303
|
+
/**
|
|
304
|
+
* The REFRESH token is dead (Google's `invalid_grant`), not merely the access
|
|
305
|
+
* token. Carried as a flag rather than re-read from the message, because
|
|
306
|
+
* inferring the author of a failure from prose several authors can produce is
|
|
307
|
+
* precisely the mistake this branch exists to undo. `instanceof` is safe: the
|
|
308
|
+
* class is thrown and caught inside this one module.
|
|
309
|
+
*/
|
|
310
|
+
readonly grantDead: boolean;
|
|
311
|
+
|
|
312
|
+
constructor(message: string, grantDead: boolean) {
|
|
313
|
+
super(message);
|
|
314
|
+
this.grantDead = grantDead;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function exchange(refreshToken: string, clientId: string, clientSecret: string): Promise<CachedToken> {
|
|
319
|
+
let res: Response;
|
|
320
|
+
try {
|
|
321
|
+
res = await fetch(TOKEN_ENDPOINT, {
|
|
322
|
+
method: 'POST',
|
|
323
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
324
|
+
body: new URLSearchParams({
|
|
325
|
+
grant_type: 'refresh_token',
|
|
326
|
+
refresh_token: refreshToken,
|
|
327
|
+
client_id: clientId,
|
|
328
|
+
client_secret: clientSecret,
|
|
329
|
+
}).toString(),
|
|
330
|
+
});
|
|
331
|
+
} catch (err) {
|
|
332
|
+
throw new TokenExchangeError(
|
|
333
|
+
`the Google token exchange could not be reached: ${err instanceof Error ? err.message : String(err)}`,
|
|
334
|
+
false,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const body = (await res.json().catch(() => ({}))) as {
|
|
339
|
+
access_token?: string;
|
|
340
|
+
expires_in?: number;
|
|
341
|
+
error?: string;
|
|
342
|
+
error_description?: string;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
if (!res.ok) {
|
|
346
|
+
// invalid_grant is the one worth naming, because it is not a bug and not
|
|
347
|
+
// transient: the credential is gone and a human has to enrol again. Google
|
|
348
|
+
// expires refresh tokens after 7 days while a consent screen is still in
|
|
349
|
+
// "Testing" mode, which is how this fleet has usually met it.
|
|
350
|
+
//
|
|
351
|
+
// The literal `invalid_grant` is in the message ON PURPOSE, and is load
|
|
352
|
+
// bearing rather than decoration. This mint is a first-class error surface
|
|
353
|
+
// — it propagates in place of gog's 401 (connector-runtime.ts) — and
|
|
354
|
+
// tools/utils.ts picks INVALID_GRANT_HINT, the one that names the 7-day
|
|
355
|
+
// Testing-mode cause and the gog_auth_add_url/gog_auth_add_complete pair,
|
|
356
|
+
// by matching that exact token. Prose alone does not qualify: the previous
|
|
357
|
+
// wording ("token has expired or been revoked") missed
|
|
358
|
+
// INVALID_GRANT_PATTERN's second alternative ("token has been expired or
|
|
359
|
+
// revoked") by one word, so a dead refresh token surfaced here earned only
|
|
360
|
+
// the generic "authentication may have expired" advice while the identical
|
|
361
|
+
// failure reported BY gog earned the specific guidance.
|
|
362
|
+
if (body.error === 'invalid_grant') {
|
|
363
|
+
throw new TokenExchangeError(
|
|
364
|
+
'the stored refresh token was rejected (invalid_grant): it has expired or been revoked, so ' +
|
|
365
|
+
'this account must be re-authorized ' +
|
|
366
|
+
'(commonly the 7-day limit on OAuth consent screens still in "Testing" mode). ' +
|
|
367
|
+
'Re-enrol with gog_auth_add_url + gog_auth_add_complete and store the new refresh token.',
|
|
368
|
+
true,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
// The refresh token is deliberately absent from this message — it is a
|
|
372
|
+
// long-lived credential and an error string travels into logs and model
|
|
373
|
+
// context.
|
|
374
|
+
throw new TokenExchangeError(
|
|
375
|
+
`the access token could not be refreshed (HTTP ${res.status}${body.error ? `, ${body.error}` : ''})`,
|
|
376
|
+
false,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (!body.access_token) {
|
|
381
|
+
throw new TokenExchangeError(
|
|
382
|
+
'the access token could not be refreshed: Google returned no access_token',
|
|
383
|
+
false,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Default to an hour if Google omits expires_in; the margin above covers the
|
|
388
|
+
// difference between that guess and reality.
|
|
389
|
+
const expiresInMs = (body.expires_in ?? 3600) * 1000;
|
|
390
|
+
return { accessToken: body.access_token, expiresAt: Date.now() + expiresInMs };
|
|
391
|
+
}
|
package/src/remote-runner.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
2
2
|
import { setDefaultGogExecutor } from './runner.js';
|
|
3
|
+
import { makeAccessTokenSource } from './google-token.js';
|
|
3
4
|
import { makeFlyExecutor } from './connector-runtime.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -63,8 +64,14 @@ export function useRemoteGogRunner(env: NodeJS.ProcessEnv = process.env): boolea
|
|
|
63
64
|
// Read per call rather than captured here, because the executor outlives any
|
|
64
65
|
// one request and the claim being made is about a request. Absent when unset,
|
|
65
66
|
// which is every registration that predates per-caller auth.
|
|
67
|
+
//
|
|
68
|
+
// The source also covers the case where the registration stores a REFRESH
|
|
69
|
+
// token instead (#241) — the identity then belongs to the registration rather
|
|
70
|
+
// than to the backend's volume, and the short-lived token it mints is the
|
|
71
|
+
// only thing that crosses the wire. `undefined` when neither is configured,
|
|
72
|
+
// which leaves the backend acting as itself exactly as before.
|
|
66
73
|
setDefaultGogExecutor(
|
|
67
|
-
makeFlyExecutor(endpoint.replace(/\/+$/, ''), key, (
|
|
74
|
+
makeFlyExecutor(endpoint.replace(/\/+$/, ''), key, makeAccessTokenSource(env)),
|
|
68
75
|
);
|
|
69
76
|
return true;
|
|
70
77
|
}
|
package/src/runner.ts
CHANGED
|
@@ -45,6 +45,62 @@ export type GogExecutor = (
|
|
|
45
45
|
opts: { timeout?: number; interactive?: boolean },
|
|
46
46
|
) => Promise<string>;
|
|
47
47
|
|
|
48
|
+
// Which layer authored a failure, when the layer was OURS and not gog's.
|
|
49
|
+
//
|
|
50
|
+
// A remote executor (the Fly/Worker path) can fail in two categorically
|
|
51
|
+
// different ways, and every consumer downstream needs to tell them apart:
|
|
52
|
+
//
|
|
53
|
+
// - `gog` ran on the backend and failed. The message is gog's — or Google's,
|
|
54
|
+
// relayed by gog — so it is PROSE, and the only way to classify it is to
|
|
55
|
+
// read it. That failure is NOT a RunnerTransportError; it stays a plain
|
|
56
|
+
// Error so tools/utils.ts keeps applying its patterns to it.
|
|
57
|
+
// - The request never got that far: the runner rejected our bearer token,
|
|
58
|
+
// refused the request shape, was draining, or never answered. Nothing was
|
|
59
|
+
// ever shown to Google, so no amount of re-authorizing a Google account can
|
|
60
|
+
// help — and the runner's own words ("unauthorized") are indistinguishable
|
|
61
|
+
// from Google's when read as prose. That is what this type exists for.
|
|
62
|
+
//
|
|
63
|
+
// The kinds, and what each one asks of the caller:
|
|
64
|
+
// transport-auth the runner rejected OUR bearer (GOG_RUNNER_KEY on the
|
|
65
|
+
// Worker vs RUNNER_KEY on the Fly app). An operator has
|
|
66
|
+
// to fix a key; the end user's Google grant is fine.
|
|
67
|
+
// transport-request the runner refused the request shape (oversized arg,
|
|
68
|
+
// malformed JSON). Deterministic; retrying is pointless.
|
|
69
|
+
// transport-retryable the runner is draining, could not reach its disk, or
|
|
70
|
+
// never answered. The same call can succeed shortly.
|
|
71
|
+
export type RunnerFailureKind = 'transport-auth' | 'transport-request' | 'transport-retryable';
|
|
72
|
+
|
|
73
|
+
// `Symbol.for`, not a private symbol or a bare `instanceof`: the class can be
|
|
74
|
+
// evaluated more than once in one process (the stdio bundle and the Worker
|
|
75
|
+
// bundle are separate builds of the same source, and vitest can load a module
|
|
76
|
+
// twice across pools), and a second copy of the class would make `instanceof`
|
|
77
|
+
// answer false for an error that IS one. The registry symbol is the same value
|
|
78
|
+
// in every copy, so the brand survives.
|
|
79
|
+
const RUNNER_TRANSPORT_BRAND = Symbol.for('gogcli.RunnerTransportError');
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A failure authored by the gog-runner itself (or by the hop to it) rather than
|
|
83
|
+
* by `gog`/Google. Carries the runner's HTTP status when there was one.
|
|
84
|
+
*/
|
|
85
|
+
export class RunnerTransportError extends Error {
|
|
86
|
+
readonly kind: RunnerFailureKind;
|
|
87
|
+
readonly status: number | undefined;
|
|
88
|
+
|
|
89
|
+
constructor(message: string, kind: RunnerFailureKind, status?: number) {
|
|
90
|
+
super(message);
|
|
91
|
+
this.name = 'RunnerTransportError';
|
|
92
|
+
this.kind = kind;
|
|
93
|
+
this.status = status;
|
|
94
|
+
// Non-enumerable so the brand never shows up in a serialized error body.
|
|
95
|
+
Object.defineProperty(this, RUNNER_TRANSPORT_BRAND, { value: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Structural check for the above — see RUNNER_TRANSPORT_BRAND on why not `instanceof`. */
|
|
100
|
+
export function isRunnerTransportError(err: unknown): err is RunnerTransportError {
|
|
101
|
+
return err instanceof Error && (err as unknown as Record<symbol, unknown>)[RUNNER_TRANSPORT_BRAND] === true;
|
|
102
|
+
}
|
|
103
|
+
|
|
48
104
|
// Ambient override for the executor `run()` uses when no options.spawner is
|
|
49
105
|
// given. The Worker/Fly path wraps request handling in
|
|
50
106
|
// `runExecutor.run({ executor }, ...)`; unset, `run()` falls back to spawning.
|
|
@@ -111,7 +167,7 @@ const TIMEOUT_MS = 30_000;
|
|
|
111
167
|
// so the requirement change is surfaced in the release notes (see
|
|
112
168
|
// .github/release.yml). This is the single source of truth for the required
|
|
113
169
|
// version; keep the README/CLAUDE.md mention in sync.
|
|
114
|
-
export const MIN_GOG_VERSION = '0.
|
|
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/tools/auth.ts
CHANGED
|
@@ -14,7 +14,12 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
|
|
|
14
14
|
`Google API is not enabled on the OAuth client's project makes Google reject the WHOLE request ` +
|
|
15
15
|
`with invalid_scope.`;
|
|
16
16
|
server.registerTool('gog_auth_list', {
|
|
17
|
-
description:
|
|
17
|
+
description:
|
|
18
|
+
'List the Google accounts stored in gogcli, with their scopes. This reads local ' +
|
|
19
|
+
'configuration only — it does not contact Google and does NOT tell you whether an account ' +
|
|
20
|
+
'still works: a signed-out account whose refresh token expired or was revoked is listed here ' +
|
|
21
|
+
'exactly like a healthy one, scopes and all. Use gog_auth_health to check whether an account ' +
|
|
22
|
+
'can actually authenticate.',
|
|
18
23
|
annotations: { readOnlyHint: true },
|
|
19
24
|
inputSchema: {},
|
|
20
25
|
}, async () => {
|
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.
|
|
@@ -135,7 +135,39 @@ export function errorText(err: unknown): string {
|
|
|
135
135
|
return err instanceof Error ? `Error: ${err.message}` : String(err);
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
// Google saying "not authenticated" in its own words. Definitive: a retry
|
|
139
|
+
// cannot turn a 401 into a success, so this outranks the transient signal below.
|
|
140
|
+
// `unauthorized` and `invalid_grant` are unambiguous words, but 401 is also
|
|
141
|
+
// just an integer, and gog's output is full of integers that are row indices,
|
|
142
|
+
// ranges and counts. A bare `\b401\b` classified "row 401 is outside the sheet
|
|
143
|
+
// grid" — a pure Sheets range error — as a definite auth failure and sent the
|
|
144
|
+
// caller off to re-authorize a healthy account. So a 401 has to look like a
|
|
145
|
+
// STATUS: introduced by a status-ish word.
|
|
146
|
+
//
|
|
147
|
+
// THE SEPARATOR IS THE WHOLE DIFFICULTY (#246). The first attempt used
|
|
148
|
+
// `\s*[:=]?\s*`, which cannot cross an opening paren or a JSON quote — so it
|
|
149
|
+
// silently stopped matching the CANONICAL shape gog emits for a Google auth
|
|
150
|
+
// failure, `Google API error (401 authError)`, and JSON bodies like
|
|
151
|
+
// `{"code": 401}`. That regression is strictly worse than the false positive it
|
|
152
|
+
// was fixing: a false positive costs a pointless re-auth, but a real dead
|
|
153
|
+
// credential with no hint at all leaves the caller with nothing to act on.
|
|
154
|
+
//
|
|
155
|
+
// So the separator admits the punctuation those shapes actually use — quote,
|
|
156
|
+
// paren, colon, equals, comma, space — and is CAPPED at 4 characters so a status
|
|
157
|
+
// word cannot reach across prose to an unrelated integer ("error: could not
|
|
158
|
+
// write row 401" must stay silent). `A401:B401` never matched anyway: there is
|
|
159
|
+
// no word boundary after `A`.
|
|
160
|
+
const DEFINITE_AUTH_PATTERN =
|
|
161
|
+
/\b(?:unauthorized|invalid_grant)\b|\b(?:error|status|code|http|responded|response)["']?[\s:=(,]{0,4}401\b/i;
|
|
162
|
+
|
|
163
|
+
// A message that TALKS about an expired token. Suggestive, not definitive — and
|
|
164
|
+
// it used to be `/token.*(expired|revoked)/`, whose greedy `.*` matched a token
|
|
165
|
+
// mentioned anywhere and an expiry mentioned anywhere later in the same line
|
|
166
|
+
// ("page token accepted; the export link has expired"). Now the two words must
|
|
167
|
+
// actually be about each other.
|
|
168
|
+
const STALE_TOKEN_PATTERN = /\b(?:access[ _-]?)?token\b[^.;\n]{0,40}\b(?:has\s+)?(?:been\s+)?(?:expired|revoked)\b|\b(?:expired|revoked)\s+(?:access[ _-]?)?token\b/i;
|
|
169
|
+
|
|
170
|
+
const AUTH_ERROR_PATTERN = new RegExp(`${DEFINITE_AUTH_PATTERN.source}|${STALE_TOKEN_PATTERN.source}`, 'i');
|
|
139
171
|
|
|
140
172
|
// A DEAD refresh token — the whole account is signed out, not just a stale
|
|
141
173
|
// access token that would refresh silently. This is the recurring account-wide
|
|
@@ -177,6 +209,31 @@ const GRID_LIMIT_HINT =
|
|
|
177
209
|
'\n\nThe target range is outside the sheet\'s current grid. Add the missing rows or columns ' +
|
|
178
210
|
'first with gog_sheets_insert (dimension: rows or cols), then retry the write.';
|
|
179
211
|
|
|
212
|
+
// The hint for each RUNNER-authored failure kind (see RunnerTransportError in
|
|
213
|
+
// runner.ts). These are chosen by the error's TYPE, never by reading its text.
|
|
214
|
+
//
|
|
215
|
+
// transport-auth is the one that motivated all of this. The runner answers a
|
|
216
|
+
// bad bearer with the single word "unauthorized"; read as prose that is
|
|
217
|
+
// indistinguishable from Google rejecting a credential, and the caller was
|
|
218
|
+
// being told all session to re-authorize an account that had never been asked
|
|
219
|
+
// for anything. So this hint names the real cause and says outright that
|
|
220
|
+
// re-authorizing cannot help. It deliberately does NOT contain the literal
|
|
221
|
+
// `gog_auth_add`, which is the token the rest of the auth guidance keys on.
|
|
222
|
+
const RUNNER_TRANSPORT_AUTH_HINT =
|
|
223
|
+
'\n\nThis is the CONNECTOR\'s own transport auth failing, not your Google sign-in. The gog-runner ' +
|
|
224
|
+
'backend rejected the bearer token this server sent, so the request never reached gog and no Google ' +
|
|
225
|
+
'credential was checked — the Google account is not the problem and re-authorizing it cannot fix this. ' +
|
|
226
|
+
'An operator must make the Worker secret GOG_RUNNER_KEY equal RUNNER_KEY on the Fly app ' +
|
|
227
|
+
'(wrangler secret put GOG_RUNNER_KEY / fly secrets set RUNNER_KEY), then retry.';
|
|
228
|
+
|
|
229
|
+
const RUNNER_TRANSPORT_HINTS: Record<RunnerFailureKind, string> = {
|
|
230
|
+
'transport-auth': RUNNER_TRANSPORT_AUTH_HINT,
|
|
231
|
+
// The request itself was malformed, so the runner will refuse it identically
|
|
232
|
+
// every time. Nothing to advise beyond the message the runner already gave.
|
|
233
|
+
'transport-request': '',
|
|
234
|
+
'transport-retryable': TRANSIENT_HINT,
|
|
235
|
+
};
|
|
236
|
+
|
|
180
237
|
// Reduce `gog auth list --json` output to just the configured email addresses.
|
|
181
238
|
// The raw JSON also carries OAuth scopes, the Google subject id, and creation
|
|
182
239
|
// timestamps — none of which belong in an error surfaced to the model, and
|
|
@@ -206,11 +263,41 @@ export function formatAccountList(raw: string): string {
|
|
|
206
263
|
// keeps the same diagnostic quality as everywhere else.
|
|
207
264
|
export async function diagnose(err: unknown): Promise<CallToolResult> {
|
|
208
265
|
const errText = errorText(err);
|
|
266
|
+
|
|
267
|
+
// STRUCTURE BEFORE PROSE. A RunnerTransportError is this connector's own
|
|
268
|
+
// transport failing — its bearer, its request validation, its drain. Nothing
|
|
269
|
+
// was shown to Google, so none of the patterns below may be consulted for it:
|
|
270
|
+
// they exist to read gog's/Google's words, and the runner's words are not
|
|
271
|
+
// those. Read as prose, the runner's `unauthorized` matched
|
|
272
|
+
// DEFINITE_AUTH_PATTERN and produced AUTH_HINT — a human being told to
|
|
273
|
+
// re-authorize a healthy account over what was really a key mismatch.
|
|
274
|
+
//
|
|
275
|
+
// This short-circuits the ladder rather than joining it, so it is not a new
|
|
276
|
+
// rung in the precedence order documented below; that order still governs
|
|
277
|
+
// every error that genuinely came from gog.
|
|
278
|
+
const transportHint = isRunnerTransportError(err)
|
|
279
|
+
? RUNNER_TRANSPORT_HINTS[err.kind]
|
|
280
|
+
: undefined;
|
|
281
|
+
|
|
209
282
|
const isInvalidGrant = INVALID_GRANT_PATTERN.test(errText);
|
|
210
|
-
|
|
211
|
-
|
|
283
|
+
|
|
284
|
+
// Precedence, and the reason for it. Reporting needs-auth is EXPENSIVE to be
|
|
285
|
+
// wrong about: re-authorization is a manual, human step, and it does not fix
|
|
286
|
+
// a 429. So a transient signal beats a merely SUGGESTIVE auth signal (a
|
|
287
|
+
// message that mentions an expired token) — the reported bug was servers
|
|
288
|
+
// flapping into needs-auth and working again seconds later, which is what
|
|
289
|
+
// being told to re-auth over a rate-limit looks like.
|
|
290
|
+
//
|
|
291
|
+
// It does NOT beat a definitive one. A literal 401 or invalid_grant is Google
|
|
292
|
+
// saying the credential will not work, and calling that "retry" would loop a
|
|
293
|
+
// caller forever against a request that can never succeed.
|
|
294
|
+
const isTransientError = !DEFINITE_AUTH_PATTERN.test(errText) && TRANSIENT_ERROR_PATTERN.test(errText);
|
|
295
|
+
const isAuthError = !isTransientError && AUTH_ERROR_PATTERN.test(errText);
|
|
212
296
|
const isGridLimitError = GRID_LIMIT_ERROR_PATTERN.test(errText);
|
|
213
|
-
|
|
297
|
+
// `??`, not `||`: 'transport-request' maps to the empty string on purpose —
|
|
298
|
+
// "this failure is ours and there is nothing to advise" — and `||` would fall
|
|
299
|
+
// through to the prose ladder for exactly the errors that must never reach it.
|
|
300
|
+
const hint = transportHint ?? (isInvalidGrant
|
|
214
301
|
? INVALID_GRANT_HINT
|
|
215
302
|
: isAuthError
|
|
216
303
|
? AUTH_HINT
|
|
@@ -218,7 +305,7 @@ export async function diagnose(err: unknown): Promise<CallToolResult> {
|
|
|
218
305
|
? TRANSIENT_HINT
|
|
219
306
|
: isGridLimitError
|
|
220
307
|
? GRID_LIMIT_HINT
|
|
221
|
-
: '';
|
|
308
|
+
: '');
|
|
222
309
|
try {
|
|
223
310
|
const accounts = formatAccountList(await run(['auth', 'list']));
|
|
224
311
|
return errorResult(`${errText}\n\nConfigured accounts:\n${accounts || '(none)'}${hint}`);
|