gogcli-mcp 2.19.2 → 2.21.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 +134 -30
- package/dist/lib.js +95 -10
- package/manifest.json +1 -1
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/connector-runtime.ts +27 -2
- package/src/google-token.ts +225 -0
- package/src/remote-runner.ts +19 -1
- package/src/tools/auth.ts +6 -1
- package/src/tools/utils.ts +25 -3
- package/src/worker.ts +1 -1
- package/tests/connector-runtime.test.ts +51 -0
- package/tests/google-token.test.ts +304 -0
- package/tests/remote-runner.test.ts +41 -0
- package/tests/sdk-single-copy.test.ts +48 -0
- package/tests/tools/auth.test.ts +21 -0
- package/tests/tools/utils.test.ts +44 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
|
2
|
+
import { makeAccessTokenSource, clearAccessTokenCache } from '../src/google-token.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minting a short-lived access token from a long-lived refresh token is what
|
|
6
|
+
* lets a hosted gog's identity belong to the REGISTRATION instead of to the Fly
|
|
7
|
+
* box's volume (#241). The access token is what crosses the wire; the refresh
|
|
8
|
+
* token never leaves this process.
|
|
9
|
+
*
|
|
10
|
+
* Two properties carry the whole design, and both are about NOT quietly doing
|
|
11
|
+
* the wrong thing:
|
|
12
|
+
*
|
|
13
|
+
* 1. One caller's token must never be served to another. The cache is keyed
|
|
14
|
+
* by the credential, not held as a single "current token" — a Worker
|
|
15
|
+
* isolate serves many callers, and a global would hand the first caller's
|
|
16
|
+
* identity to everyone after them.
|
|
17
|
+
* 2. A failure must fail the CALL. Returning nothing would run the command as
|
|
18
|
+
* the box's identity, and the caller would read someone else's mailbox
|
|
19
|
+
* while everything looked like success.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const CLIENT = { GOG_CLIENT_ID: 'cid.apps.googleusercontent.com', GOG_CLIENT_SECRET: 'cs' };
|
|
23
|
+
|
|
24
|
+
function tokenResponse(accessToken: string, expiresIn = 3600) {
|
|
25
|
+
return new Response(JSON.stringify({ access_token: accessToken, expires_in: expiresIn }), {
|
|
26
|
+
status: 200,
|
|
27
|
+
headers: { 'content-type': 'application/json' },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
beforeEach(() => clearAccessTokenCache());
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.unstubAllGlobals();
|
|
34
|
+
vi.useRealTimers();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('makeAccessTokenSource', () => {
|
|
38
|
+
it('is absent when nothing is configured, so the box keeps acting as itself', () => {
|
|
39
|
+
expect(makeAccessTokenSource({})).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('passes through a directly-supplied access token without contacting Google', async () => {
|
|
43
|
+
// The #230 path. Still supported: a caller who already holds a token should
|
|
44
|
+
// not need an OAuth client to use it.
|
|
45
|
+
const fetchMock = vi.fn();
|
|
46
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
47
|
+
const source = makeAccessTokenSource({ GOG_ACCESS_TOKEN: 'ya29.direct' })!;
|
|
48
|
+
expect(await source()).toBe('ya29.direct');
|
|
49
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('mints an access token from the refresh token, then serves it from cache', async () => {
|
|
53
|
+
const fetchMock = vi.fn(async () => tokenResponse('ya29.minted'));
|
|
54
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
55
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
56
|
+
|
|
57
|
+
expect(await source()).toBe('ya29.minted');
|
|
58
|
+
expect(await source()).toBe('ya29.minted');
|
|
59
|
+
// A token exchange per tool call would be both slow and a good way to get
|
|
60
|
+
// rate-limited by Google.
|
|
61
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
62
|
+
|
|
63
|
+
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
|
64
|
+
expect(url).toBe('https://oauth2.googleapis.com/token');
|
|
65
|
+
const sent = new URLSearchParams(init.body as string);
|
|
66
|
+
expect(sent.get('grant_type')).toBe('refresh_token');
|
|
67
|
+
expect(sent.get('refresh_token')).toBe('rt-1');
|
|
68
|
+
expect(sent.get('client_id')).toBe(CLIENT.GOG_CLIENT_ID);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('never serves one credential holder the other one’s token', async () => {
|
|
72
|
+
// THE one that matters. A module-level "current access token" passes every
|
|
73
|
+
// other test in this file and fails this one — and on the Worker path,
|
|
74
|
+
// where a single isolate serves many callers, that is a cross-account leak
|
|
75
|
+
// rather than a bug.
|
|
76
|
+
const fetchMock = vi
|
|
77
|
+
.fn()
|
|
78
|
+
.mockResolvedValueOnce(tokenResponse('ya29.alice'))
|
|
79
|
+
.mockResolvedValueOnce(tokenResponse('ya29.bob'));
|
|
80
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
81
|
+
|
|
82
|
+
const alice = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-alice' })!;
|
|
83
|
+
const bob = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-bob' })!;
|
|
84
|
+
|
|
85
|
+
expect(await alice()).toBe('ya29.alice');
|
|
86
|
+
expect(await bob()).toBe('ya29.bob');
|
|
87
|
+
// And each keeps its own on a second read.
|
|
88
|
+
expect(await alice()).toBe('ya29.alice');
|
|
89
|
+
expect(await bob()).toBe('ya29.bob');
|
|
90
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('re-mints once the token is close to expiring', async () => {
|
|
94
|
+
vi.useFakeTimers();
|
|
95
|
+
const fetchMock = vi
|
|
96
|
+
.fn()
|
|
97
|
+
.mockResolvedValueOnce(tokenResponse('ya29.first', 3600))
|
|
98
|
+
.mockResolvedValueOnce(tokenResponse('ya29.second', 3600));
|
|
99
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
100
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
101
|
+
|
|
102
|
+
expect(await source()).toBe('ya29.first');
|
|
103
|
+
// Just inside the safety margin: a token that expires mid-flight is a
|
|
104
|
+
// failure the caller cannot do anything about, so it is replaced early.
|
|
105
|
+
vi.advanceTimersByTime((3600 - 60) * 1000);
|
|
106
|
+
expect(await source()).toBe('ya29.second');
|
|
107
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('THROWS when the exchange fails, rather than returning nothing', async () => {
|
|
111
|
+
// Returning undefined here would run the command as the box's identity.
|
|
112
|
+
vi.stubGlobal(
|
|
113
|
+
'fetch',
|
|
114
|
+
vi.fn(async () => new Response(JSON.stringify({ error: 'server_error' }), { status: 500 })),
|
|
115
|
+
);
|
|
116
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
117
|
+
await expect(source()).rejects.toThrow(/could not be refreshed|token exchange/i);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('explains an invalid_grant instead of surfacing a bare OAuth error', async () => {
|
|
121
|
+
vi.stubGlobal(
|
|
122
|
+
'fetch',
|
|
123
|
+
vi.fn(
|
|
124
|
+
async () =>
|
|
125
|
+
new Response(JSON.stringify({ error: 'invalid_grant', error_description: 'Token has been expired or revoked.' }), {
|
|
126
|
+
status: 400,
|
|
127
|
+
}),
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-dead' })!;
|
|
131
|
+
await expect(source()).rejects.toThrow(/expired or been revoked|re-?enrol|re-?authoriz/i);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('does not cache a failure, so a transient outage is not sticky', async () => {
|
|
135
|
+
const fetchMock = vi
|
|
136
|
+
.fn()
|
|
137
|
+
.mockResolvedValueOnce(new Response('{}', { status: 503 }))
|
|
138
|
+
.mockResolvedValueOnce(tokenResponse('ya29.recovered'));
|
|
139
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
140
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
141
|
+
|
|
142
|
+
await expect(source()).rejects.toThrow();
|
|
143
|
+
expect(await source()).toBe('ya29.recovered');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('fails loudly on a half-configured credential instead of acting as the box', async () => {
|
|
147
|
+
// A refresh token with no OAuth client cannot mint anything. Treating it as
|
|
148
|
+
// "unconfigured" would silently fall back to the box's identity, which is
|
|
149
|
+
// the exact confusion this feature exists to remove — so the source exists
|
|
150
|
+
// and throws when used, leaving tools/list working and the reason visible.
|
|
151
|
+
for (const partial of [
|
|
152
|
+
{ GOG_REFRESH_TOKEN: 'rt-1' },
|
|
153
|
+
{ GOG_REFRESH_TOKEN: 'rt-1', GOG_CLIENT_ID: 'cid' },
|
|
154
|
+
{ GOG_REFRESH_TOKEN: 'rt-1', GOG_CLIENT_SECRET: 'cs' },
|
|
155
|
+
]) {
|
|
156
|
+
const source = makeAccessTokenSource(partial);
|
|
157
|
+
expect(source).toBeTypeOf('function');
|
|
158
|
+
await expect(source!()).rejects.toThrow(/GOG_CLIENT_ID|GOG_CLIENT_SECRET/);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('says the exchange was unreachable when the network itself fails', async () => {
|
|
163
|
+
// Distinct from a rejection BY Google: nothing was evaluated, so the
|
|
164
|
+
// credential may be perfectly good and the right response is to retry, not
|
|
165
|
+
// to tell the owner to re-enrol.
|
|
166
|
+
vi.stubGlobal(
|
|
167
|
+
'fetch',
|
|
168
|
+
vi.fn(async () => {
|
|
169
|
+
throw new TypeError('fetch failed');
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
173
|
+
await expect(source()).rejects.toThrow(/could not be reached.*fetch failed/i);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('survives a thrown non-Error without masking it with a TypeError', async () => {
|
|
177
|
+
// `err.message` on a thrown string is undefined, and the template would
|
|
178
|
+
// then hide the real failure behind a crash inside the error path.
|
|
179
|
+
vi.stubGlobal(
|
|
180
|
+
'fetch',
|
|
181
|
+
vi.fn(async () => {
|
|
182
|
+
throw 'socket closed';
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
186
|
+
await expect(source()).rejects.toThrow(/could not be reached.*socket closed/i);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('refuses a 200 that carries no access_token', async () => {
|
|
190
|
+
// A success status with nothing usable in it would otherwise cache
|
|
191
|
+
// `undefined` and send no token at all — a silent downgrade to the
|
|
192
|
+
// backend's identity, wearing a 200.
|
|
193
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ expires_in: 3600 }), { status: 200 })));
|
|
194
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
195
|
+
await expect(source()).rejects.toThrow(/no access_token/i);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('assumes an hour when Google omits expires_in', async () => {
|
|
199
|
+
vi.useFakeTimers();
|
|
200
|
+
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ access_token: 'ya29.nolife' }), { status: 200 }));
|
|
201
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
202
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
203
|
+
|
|
204
|
+
expect(await source()).toBe('ya29.nolife');
|
|
205
|
+
// Still cached half an hour later — i.e. it did not treat "no expiry" as
|
|
206
|
+
// "already expired" and re-mint on every single call.
|
|
207
|
+
vi.advanceTimersByTime(1800 * 1000);
|
|
208
|
+
expect(await source()).toBe('ya29.nolife');
|
|
209
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('reports the status when the body is not JSON at all', async () => {
|
|
213
|
+
// An edge proxy answering a 502 with an HTML error page is the realistic
|
|
214
|
+
// case. Parsing that would throw inside the error path and bury the status,
|
|
215
|
+
// which is the only useful thing such a response carries.
|
|
216
|
+
vi.stubGlobal(
|
|
217
|
+
'fetch',
|
|
218
|
+
vi.fn(async () => new Response('<html>502 Bad Gateway</html>', { status: 502 })),
|
|
219
|
+
);
|
|
220
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
221
|
+
await expect(source()).rejects.toThrow(/could not be refreshed \(HTTP 502/);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('coalesces concurrent callers into ONE token exchange', async () => {
|
|
225
|
+
// The reported bug was several per-service servers flapping into needs-auth
|
|
226
|
+
// independently. This is the version of that failure that lives in OUR
|
|
227
|
+
// code: `get` → `await exchange` → `set` has an await between the miss and
|
|
228
|
+
// the fill, so N calls arriving together all miss and all exchange.
|
|
229
|
+
//
|
|
230
|
+
// One process per caller hides it; a Worker isolate serving many callers,
|
|
231
|
+
// or simply several tool calls in flight at once, turns one refresh into N
|
|
232
|
+
// simultaneous hits on Google's token endpoint — which is a good way to be
|
|
233
|
+
// rate-limited into exactly the intermittent auth errors being debugged.
|
|
234
|
+
let inFlight = 0;
|
|
235
|
+
let maxConcurrent = 0;
|
|
236
|
+
const fetchMock = vi.fn(async () => {
|
|
237
|
+
inFlight += 1;
|
|
238
|
+
maxConcurrent = Math.max(maxConcurrent, inFlight);
|
|
239
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
240
|
+
inFlight -= 1;
|
|
241
|
+
return tokenResponse('ya29.shared');
|
|
242
|
+
});
|
|
243
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
244
|
+
|
|
245
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
246
|
+
const results = await Promise.all(Array.from({ length: 8 }, () => source()));
|
|
247
|
+
|
|
248
|
+
expect(results).toEqual(Array(8).fill('ya29.shared'));
|
|
249
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
250
|
+
expect(maxConcurrent).toBe(1);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('lets a later caller retry after a concurrent exchange failed', async () => {
|
|
254
|
+
// Coalescing must not make one failure permanent for everyone: the shared
|
|
255
|
+
// attempt is dropped when it settles, so the next call starts a fresh one.
|
|
256
|
+
// The failure has to take a tick. An exchange that rejects instantly can
|
|
257
|
+
// finish and clear itself before the second caller even looks, so that
|
|
258
|
+
// caller correctly starts its own attempt — which would make this test pass
|
|
259
|
+
// without any sharing having happened. A real exchange is a network round
|
|
260
|
+
// trip, so the shared-failure case is the one worth pinning.
|
|
261
|
+
const fetchMock = vi
|
|
262
|
+
.fn()
|
|
263
|
+
.mockImplementationOnce(async () => {
|
|
264
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
265
|
+
throw new Error('boom');
|
|
266
|
+
})
|
|
267
|
+
.mockResolvedValueOnce(tokenResponse('ya29.after'));
|
|
268
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
269
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
270
|
+
|
|
271
|
+
const settled = await Promise.allSettled([source(), source()]);
|
|
272
|
+
expect(settled.every((s) => s.status === 'rejected')).toBe(true);
|
|
273
|
+
// Both shared ONE failed exchange rather than each making their own...
|
|
274
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
275
|
+
// ...and the failure was not cached, so the next caller recovers.
|
|
276
|
+
expect(await source()).toBe('ya29.after');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('does not let two different credentials share one in-flight exchange', async () => {
|
|
280
|
+
const fetchMock = vi
|
|
281
|
+
.fn()
|
|
282
|
+
.mockResolvedValueOnce(tokenResponse('ya29.alice'))
|
|
283
|
+
.mockResolvedValueOnce(tokenResponse('ya29.bob'));
|
|
284
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
285
|
+
const alice = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-alice' })!;
|
|
286
|
+
const bob = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-bob' })!;
|
|
287
|
+
|
|
288
|
+
const [a, b] = await Promise.all([alice(), bob()]);
|
|
289
|
+
expect(a).toBe('ya29.alice');
|
|
290
|
+
expect(b).toBe('ya29.bob');
|
|
291
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('never puts the refresh token in the error it throws', async () => {
|
|
295
|
+
vi.stubGlobal(
|
|
296
|
+
'fetch',
|
|
297
|
+
vi.fn(async () => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 })),
|
|
298
|
+
);
|
|
299
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-super-secret-value' })!;
|
|
300
|
+
await expect(source()).rejects.toThrow(
|
|
301
|
+
expect.not.stringContaining('rt-super-secret-value') as unknown as string,
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
@@ -101,6 +101,47 @@ describe('useRemoteGogRunner', () => {
|
|
|
101
101
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
it('forwards its own GOG_ACCESS_TOKEN so a hosted gog acts as its caller', async () => {
|
|
105
|
+
// Under mcp-host's perUserChild, THIS PROCESS belongs to one caller and
|
|
106
|
+
// holds their token (#230). The backend has one Google identity on its
|
|
107
|
+
// volume, so without forwarding, every caller of a hosted gog acts as
|
|
108
|
+
// whoever seeded it.
|
|
109
|
+
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ stdout: 'ok' }), { status: 200 }));
|
|
110
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
111
|
+
vi.stubEnv('GOG_PATH', '/nonexistent/gog');
|
|
112
|
+
|
|
113
|
+
expect(
|
|
114
|
+
useRemoteGogRunner({
|
|
115
|
+
GOG_RUNNER_URL: 'https://r.test',
|
|
116
|
+
GOG_RUNNER_KEY: 'secret',
|
|
117
|
+
GOG_ACCESS_TOKEN: 'ya29.the-caller',
|
|
118
|
+
}),
|
|
119
|
+
).toBe(true);
|
|
120
|
+
|
|
121
|
+
await runExecutor.exit(() => run(['auth', 'status']));
|
|
122
|
+
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
|
123
|
+
expect(JSON.parse(init.body as string).accessToken).toBe('ya29.the-caller');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('sends no token when the caller has not supplied one', async () => {
|
|
127
|
+
// Absent, not empty: the backend distinguishes "act as this caller" from
|
|
128
|
+
// "act as the box", and every registration that predates per-caller auth is
|
|
129
|
+
// the second case.
|
|
130
|
+
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ stdout: 'ok' }), { status: 200 }));
|
|
131
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
132
|
+
vi.stubEnv('GOG_PATH', '/nonexistent/gog');
|
|
133
|
+
|
|
134
|
+
expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test', GOG_RUNNER_KEY: 'secret' })).toBe(true);
|
|
135
|
+
|
|
136
|
+
await runExecutor.exit(() => run(['auth', 'status']));
|
|
137
|
+
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
|
138
|
+
// The KEY must be absent, not present-and-empty. Asserted on its own rather
|
|
139
|
+
// than by matching the whole body: `assembleArgs` folds in ambient settings
|
|
140
|
+
// like GOG_ACCOUNT, so a whole-body match passes or fails depending on the
|
|
141
|
+
// developer's shell.
|
|
142
|
+
expect(JSON.parse(init.body as string)).not.toHaveProperty('accessToken');
|
|
143
|
+
});
|
|
144
|
+
|
|
104
145
|
it('lets a per-request store beat the process-wide default', async () => {
|
|
105
146
|
// The Worker path scopes every request in `runExecutor.run({ executor })`
|
|
106
147
|
// because one isolate serves many callers (connector-runtime.ts). A
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
// Guards the invariant that broke dependabot #220: the whole monorepo must
|
|
7
|
+
// resolve ONE copy of @modelcontextprotocol/sdk.
|
|
8
|
+
//
|
|
9
|
+
// `McpServer` carries a `private _serverInfo`, so TypeScript compares it
|
|
10
|
+
// NOMINALLY, not structurally. Two installed copies therefore become two
|
|
11
|
+
// mutually-unassignable classes, and every `ToolRegistrar` in server.ts fails
|
|
12
|
+
// with TS2322 — with no API change and no source change anywhere. #220 split
|
|
13
|
+
// the tree exactly that way: `agents` (a root devDependency, the Worker
|
|
14
|
+
// connector's McpAgent) exact-pins the SDK to 1.29.0 and so takes the hoisted
|
|
15
|
+
// root slot that `@chrischall/mcp-utils` resolves its peer from, while the
|
|
16
|
+
// workspaces asking for ^1.30.0 each nested their own copy.
|
|
17
|
+
//
|
|
18
|
+
// This asserts resolution identity rather than a version string: the failure is
|
|
19
|
+
// "two copies", not "the wrong version", and pinning a version here would just
|
|
20
|
+
// have to be edited on every future bump.
|
|
21
|
+
describe('@modelcontextprotocol/sdk is installed exactly once', () => {
|
|
22
|
+
const here = createRequire(import.meta.url);
|
|
23
|
+
|
|
24
|
+
// An exported subpath — the SDK's `exports` map does not expose package.json.
|
|
25
|
+
const SDK_SUBPATH = '@modelcontextprotocol/sdk/server/mcp.js';
|
|
26
|
+
|
|
27
|
+
// `import.meta.resolve`, not `require.resolve`, to reach the dependency's own
|
|
28
|
+
// entry: these packages are ESM-only, so their `exports` maps carry no
|
|
29
|
+
// `require` condition and CJS resolution of the bare specifier throws.
|
|
30
|
+
const resolveFrom = (specifier: string): string =>
|
|
31
|
+
realpathSync(
|
|
32
|
+
createRequire(fileURLToPath(import.meta.resolve(specifier))).resolve(SDK_SUBPATH),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
it('resolves to the same file for this package and for @chrischall/mcp-utils', () => {
|
|
36
|
+
// mcp-utils declares the SDK as a peer and hands our registrars the
|
|
37
|
+
// McpServer it built, so its copy is the one they must be typed against.
|
|
38
|
+
expect(resolveFrom('@chrischall/mcp-utils')).toBe(
|
|
39
|
+
realpathSync(here.resolve(SDK_SUBPATH)),
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('resolves to the same file for `agents`, which exact-pins the SDK', () => {
|
|
44
|
+
// The Worker connector builds its McpServer via McpAgent from `agents`.
|
|
45
|
+
// An exact pin there is what captured the root hoist slot in #220.
|
|
46
|
+
expect(resolveFrom('agents')).toBe(realpathSync(here.resolve(SDK_SUBPATH)));
|
|
47
|
+
});
|
|
48
|
+
});
|
package/tests/tools/auth.test.ts
CHANGED
|
@@ -25,6 +25,27 @@ describe('gog_auth_list', () => {
|
|
|
25
25
|
expect(result.content[0].text).toBe('Error: No accounts configured');
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
it('does not advertise itself as proof the account still works', async () => {
|
|
29
|
+
// `gog auth list` reads the keyring. No network, no validation — it lists a
|
|
30
|
+
// full scope set for an account whose refresh token died days ago.
|
|
31
|
+
//
|
|
32
|
+
// This description used to say "check which accounts are configured and
|
|
33
|
+
// available", and "available" is exactly the wrong word: it was read as a
|
|
34
|
+
// liveness check while per-service servers were flapping into needs-auth,
|
|
35
|
+
// which pointed a whole debugging session away from an expired grant.
|
|
36
|
+
// gog_auth_health is the tool that actually probes Google.
|
|
37
|
+
const harness = await setupHandlers();
|
|
38
|
+
const { tools } = await harness.client.listTools();
|
|
39
|
+
const desc = tools.find((t) => t.name === 'gog_auth_list')!.description!;
|
|
40
|
+
|
|
41
|
+
expect(desc).not.toMatch(/\bavailable\b/i);
|
|
42
|
+
// It must say what it does NOT do, and where to go instead — a reader who
|
|
43
|
+
// wants liveness has to be sent somewhere, or they will use this anyway.
|
|
44
|
+
expect(desc).toMatch(/does not|without/i);
|
|
45
|
+
expect(desc).toContain('gog_auth_health');
|
|
46
|
+
await harness.close();
|
|
47
|
+
});
|
|
48
|
+
|
|
28
49
|
it('handles non-Error rejection', async () => {
|
|
29
50
|
vi.mocked(runner.run).mockRejectedValue('something went wrong');
|
|
30
51
|
const harness = await setupHandlers();
|
|
@@ -190,6 +190,50 @@ describe('runOrDiagnose', () => {
|
|
|
190
190
|
expect(result.content[0].text).toContain('gog_auth_add');
|
|
191
191
|
});
|
|
192
192
|
|
|
193
|
+
it('calls a rate-limited failure transient even though it says "token expired"', async () => {
|
|
194
|
+
// The reported symptom was servers flapping into a needs-auth state and
|
|
195
|
+
// then working seconds later. Telling someone to re-authorize an account
|
|
196
|
+
// whose credential is fine is the expensive kind of wrong: re-auth is
|
|
197
|
+
// manual, and it does not fix a 429.
|
|
198
|
+
//
|
|
199
|
+
// `invalid_grant` is exempt from this and stays auth (below) — it is the
|
|
200
|
+
// one signal that definitively means the refresh token is dead.
|
|
201
|
+
vi.mocked(runner.run)
|
|
202
|
+
.mockRejectedValueOnce(new Error('429 rateLimitExceeded: the access token expired mid-request, retry'))
|
|
203
|
+
.mockResolvedValueOnce('user@gmail.com');
|
|
204
|
+
const result = await runOrDiagnose(['sheets', 'get', 'A1'], {});
|
|
205
|
+
const text = result.content[0].text as string;
|
|
206
|
+
expect(text).toContain('often transient');
|
|
207
|
+
expect(text).not.toContain('gog_auth_add');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('still calls an explicit 401 an auth error even alongside a transient signal', async () => {
|
|
211
|
+
// The exemption above is for the LOOSE match only. A literal 401 is Google
|
|
212
|
+
// saying "not authenticated", and downgrading that to "retry" would loop a
|
|
213
|
+
// caller forever against a request that can never succeed.
|
|
214
|
+
vi.mocked(runner.run)
|
|
215
|
+
.mockRejectedValueOnce(new Error('401 unauthorized (quota project unset)'))
|
|
216
|
+
.mockResolvedValueOnce('user@gmail.com');
|
|
217
|
+
const result = await runOrDiagnose(['sheets', 'get', 'A1'], {});
|
|
218
|
+
expect(result.content[0].text).toContain('gog_auth_add');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('does not read "token" and "expired" as auth when they are unrelated sentences', async () => {
|
|
222
|
+
// The pattern was /token.*(expired|revoked)/ with a greedy `.*`, so any
|
|
223
|
+
// message mentioning a token anywhere and an expiry anywhere later — across
|
|
224
|
+
// whole paragraphs — was reported as an auth failure.
|
|
225
|
+
vi.mocked(runner.run)
|
|
226
|
+
// Deliberately ONE line: `.` does not cross newlines, so a multi-line
|
|
227
|
+
// message would pass this test without the greedy match ever being
|
|
228
|
+
// exercised — and gog's real errors are frequently one long line.
|
|
229
|
+
.mockRejectedValueOnce(
|
|
230
|
+
new Error('page token accepted; the requested export link has expired and must be regenerated'),
|
|
231
|
+
)
|
|
232
|
+
.mockResolvedValueOnce('user@gmail.com');
|
|
233
|
+
const result = await runOrDiagnose(['drive', 'export', 'abc'], {});
|
|
234
|
+
expect(result.content[0].text).not.toContain('gog_auth_add');
|
|
235
|
+
});
|
|
236
|
+
|
|
193
237
|
it('gives invalid_grant a richer hint than a plain 401: cause + durable fix + both re-auth paths', async () => {
|
|
194
238
|
vi.mocked(runner.run)
|
|
195
239
|
.mockRejectedValueOnce(new Error('oauth2: "invalid_grant" "Token has been expired or revoked."'))
|