@frockbot/plugin-mcp 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,776 @@
1
+ /**
2
+ * The `mcp-oauth` driver as the User Durable Object runs it: start, callback,
3
+ * refresh on the way out of a lease, and revoke.
4
+ *
5
+ * Everything here goes through the real Settings and Credential Contributions
6
+ * against an in-memory storage, so the assertions are about durable state —
7
+ * which credential generation is active, which is merely staged, what the
8
+ * Connection projection says — rather than about calls made.
9
+ */
10
+ import { describe, expect, test } from "bun:test";
11
+ import type { ConnectionView } from "@frockbot/configuration-core";
12
+ import {
13
+ createCredentialUserBackendContribution,
14
+ type CredentialStorage,
15
+ type CredentialTransaction,
16
+ } from "@frockbot/plugin-credentials/user";
17
+ import {
18
+ createUserSettingsBackendContribution,
19
+ type UserSettingsStorage,
20
+ type UserSettingsTransaction,
21
+ } from "@frockbot/plugin-settings/user";
22
+ import { createMcpUserBackendContribution } from "./user.js";
23
+ import { mcpCodeChallengeV1 } from "./oauth.js";
24
+ import {
25
+ mcpOAuthPendingKeyV1,
26
+ mcpOAuthRecordKeyV1,
27
+ mcpRefreshCredentialIdV1,
28
+ decodeMcpOAuthRecordV1,
29
+ } from "./oauth-records.js";
30
+
31
+ const ACCOUNT = "account-1";
32
+ const SERVER = "https://mcp.example.test/mcp";
33
+ const ISSUER = "https://auth.example.test";
34
+ const REDIRECT = "https://bot.example.test/api/plugins/mcp/callback";
35
+
36
+ class MemoryStorage implements UserSettingsStorage, CredentialStorage {
37
+ readonly values = new Map<string, unknown>();
38
+ alarm?: number;
39
+
40
+ get<T>(key: string): Promise<T | undefined> {
41
+ return Promise.resolve(this.values.get(key) as T | undefined);
42
+ }
43
+
44
+ put<T>(key: string, value: T): Promise<void>;
45
+ put(entries: Record<string, unknown>): Promise<void>;
46
+ put<T>(
47
+ keyOrEntries: string | Record<string, unknown>,
48
+ value?: T,
49
+ ): Promise<void> {
50
+ if (typeof keyOrEntries === "string") this.values.set(keyOrEntries, value);
51
+ else {
52
+ for (const [key, entry] of Object.entries(keyOrEntries)) {
53
+ this.values.set(key, entry);
54
+ }
55
+ }
56
+ return Promise.resolve();
57
+ }
58
+
59
+ delete(key: string): Promise<boolean> {
60
+ return Promise.resolve(this.values.delete(key));
61
+ }
62
+
63
+ async transaction<T>(
64
+ callback: (
65
+ storage: UserSettingsTransaction & CredentialTransaction,
66
+ ) => Promise<T>,
67
+ ): Promise<T> {
68
+ const before = new Map(this.values);
69
+ try {
70
+ return await callback(this);
71
+ } catch (error) {
72
+ this.values.clear();
73
+ for (const [key, entry] of before) this.values.set(key, entry);
74
+ throw error;
75
+ }
76
+ }
77
+
78
+ getAlarm(): Promise<number | null> {
79
+ return Promise.resolve(this.alarm ?? null);
80
+ }
81
+
82
+ setAlarm(scheduledTime: number | Date): Promise<void> {
83
+ this.alarm = Number(scheduledTime);
84
+ return Promise.resolve();
85
+ }
86
+ }
87
+
88
+ function keyring(): string {
89
+ const bytes = Uint8Array.from({ length: 32 }, (_, index) => index + 5);
90
+ let binary = "";
91
+ for (const byte of bytes) binary += String.fromCharCode(byte);
92
+ return JSON.stringify({
93
+ schemaVersion: 1,
94
+ currentKeyId: "primary",
95
+ keys: {
96
+ primary: btoa(binary)
97
+ .replaceAll("+", "-")
98
+ .replaceAll("/", "_")
99
+ .replace(/=+$/, ""),
100
+ },
101
+ });
102
+ }
103
+
104
+ interface WorldOptions {
105
+ /** Omit the registration endpoint, so the `client-id` setting must be used. */
106
+ withoutRegistration?: boolean;
107
+ withoutRevocation?: boolean;
108
+ revokeStatus?: number;
109
+ /** Refuse every token request, as a server with a spent grant does. */
110
+ tokenStatus?: number;
111
+ accessTokenLifetimeSeconds?: number;
112
+ }
113
+
114
+ /**
115
+ * One authorization server and one MCP server behind it. The MCP endpoint
116
+ * answers only the exact bearer token the token endpoint last issued, which is
117
+ * what makes "the Bot's tools came back after a refresh" a real assertion.
118
+ */
119
+ function world(options: WorldOptions = {}) {
120
+ const state = {
121
+ live: new Set<string>(),
122
+ refreshTokens: new Set<string>(),
123
+ codes: new Map<string, { challenge: string; resource: string }>(),
124
+ issued: 0,
125
+ exchanges: 0,
126
+ refreshes: 0,
127
+ revocations: 0,
128
+ lastTokenForm: {} as Record<string, string>,
129
+ mcpBearers: [] as string[],
130
+ };
131
+ const fetchImpl = (async (
132
+ input: string | URL | Request,
133
+ init?: RequestInit,
134
+ ) => {
135
+ const url = new URL(String(input));
136
+ const headers = new Headers(init?.headers);
137
+ const form = new URLSearchParams(
138
+ typeof init?.body === "string" ? init.body : "",
139
+ );
140
+ if (url.origin === ISSUER) {
141
+ if (url.pathname === "/.well-known/oauth-authorization-server") {
142
+ return Response.json({
143
+ issuer: ISSUER,
144
+ authorization_endpoint: `${ISSUER}/authorize`,
145
+ token_endpoint: `${ISSUER}/token`,
146
+ ...(options.withoutRegistration
147
+ ? {}
148
+ : { registration_endpoint: `${ISSUER}/register` }),
149
+ ...(options.withoutRevocation
150
+ ? {}
151
+ : { revocation_endpoint: `${ISSUER}/revoke` }),
152
+ code_challenge_methods_supported: ["S256"],
153
+ token_endpoint_auth_methods_supported: ["none"],
154
+ scopes_supported: ["mcp:tools"],
155
+ });
156
+ }
157
+ if (url.pathname === "/register") {
158
+ return Response.json(
159
+ { client_id: "registered-client" },
160
+ { status: 201 },
161
+ );
162
+ }
163
+ if (url.pathname === "/token") {
164
+ state.lastTokenForm = Object.fromEntries(form);
165
+ if (options.tokenStatus) {
166
+ return Response.json(
167
+ { error: "invalid_grant" },
168
+ { status: options.tokenStatus },
169
+ );
170
+ }
171
+ const token = `access-${++state.issued}`;
172
+ if (form.get("grant_type") === "authorization_code") {
173
+ state.exchanges += 1;
174
+ const issuedCode = state.codes.get(form.get("code") ?? "");
175
+ state.codes.delete(form.get("code") ?? "");
176
+ if (!issuedCode) {
177
+ return Response.json({ error: "invalid_grant" }, { status: 400 });
178
+ }
179
+ if (
180
+ (await mcpCodeChallengeV1(form.get("code_verifier") ?? "")) !==
181
+ issuedCode.challenge
182
+ ) {
183
+ return Response.json({ error: "invalid_grant" }, { status: 400 });
184
+ }
185
+ if (form.get("resource") !== issuedCode.resource) {
186
+ return Response.json({ error: "invalid_target" }, { status: 400 });
187
+ }
188
+ state.live.add(token);
189
+ state.refreshTokens.add("refresh-1");
190
+ return Response.json({
191
+ access_token: token,
192
+ token_type: "Bearer",
193
+ expires_in: options.accessTokenLifetimeSeconds ?? 3_600,
194
+ refresh_token: "refresh-1",
195
+ });
196
+ }
197
+ state.refreshes += 1;
198
+ if (!state.refreshTokens.has(form.get("refresh_token") ?? "")) {
199
+ return Response.json({ error: "invalid_grant" }, { status: 400 });
200
+ }
201
+ state.live.clear();
202
+ state.live.add(token);
203
+ return Response.json({
204
+ access_token: token,
205
+ token_type: "Bearer",
206
+ expires_in: options.accessTokenLifetimeSeconds ?? 3_600,
207
+ });
208
+ }
209
+ if (url.pathname === "/revoke") {
210
+ state.revocations += 1;
211
+ state.refreshTokens.delete(form.get("token") ?? "");
212
+ return new Response(null, { status: options.revokeStatus ?? 200 });
213
+ }
214
+ return new Response("not found", { status: 404 });
215
+ }
216
+ if (url.pathname === "/.well-known/oauth-protected-resource/mcp") {
217
+ return Response.json({
218
+ resource: SERVER,
219
+ authorization_servers: [ISSUER],
220
+ scopes_supported: ["mcp:tools"],
221
+ });
222
+ }
223
+ // The MCP endpoint itself.
224
+ const bearer = (headers.get("authorization") ?? "").replace(/^Bearer /, "");
225
+ state.mcpBearers.push(bearer);
226
+ if (!state.live.has(bearer)) {
227
+ return new Response(JSON.stringify({ error: "Unauthorized" }), {
228
+ status: 401,
229
+ headers: {
230
+ "content-type": "application/json",
231
+ "www-authenticate": `Bearer resource_metadata="https://mcp.example.test/.well-known/oauth-protected-resource/mcp"`,
232
+ },
233
+ });
234
+ }
235
+ const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
236
+ if (body.id === undefined) return new Response("", { status: 202 });
237
+ return Response.json({
238
+ jsonrpc: "2.0",
239
+ id: body.id,
240
+ result:
241
+ body.method === "initialize"
242
+ ? {
243
+ protocolVersion: "2025-06-18",
244
+ capabilities: { tools: {} },
245
+ serverInfo: { name: "Example" },
246
+ }
247
+ : { tools: [{ name: "echo", inputSchema: { type: "object" } }] },
248
+ });
249
+ }) as typeof fetch;
250
+ return { state, fetchImpl };
251
+ }
252
+
253
+ async function fixture(options: WorldOptions = {}) {
254
+ const { state, fetchImpl } = world(options);
255
+ const storage = new MemoryStorage();
256
+ const settings = createUserSettingsBackendContribution({
257
+ storage,
258
+ availablePackages: [{ packageId: "mcp", version: "0.0.1" }],
259
+ });
260
+ await settings.executeConfiguration({
261
+ schemaVersion: 1,
262
+ userId: ACCOUNT,
263
+ command: {
264
+ schemaVersion: 1,
265
+ type: "user/install-package",
266
+ commandId: "install-1",
267
+ expectedRevision: 0,
268
+ packageId: "mcp",
269
+ version: "0.0.1",
270
+ },
271
+ });
272
+ let id = 0;
273
+ let clock = Date.parse("2026-09-01T00:00:00.000Z");
274
+ const credentials = createCredentialUserBackendContribution({
275
+ storage,
276
+ keyring: keyring(),
277
+ now: () => clock,
278
+ });
279
+ const mcp = createMcpUserBackendContribution({
280
+ storage,
281
+ settings,
282
+ credentials,
283
+ fetch: fetchImpl,
284
+ randomId: () => `gen-${++id}`,
285
+ now: () => clock,
286
+ });
287
+ const read = async (connectionId: string): Promise<ConnectionView> => {
288
+ const connection = await settings.getConnection(ACCOUNT, connectionId);
289
+ expect(connection).toBeDefined();
290
+ return connection!;
291
+ };
292
+ /** Play the User's browser: follow the authorize URL, mint a code. */
293
+ const authorize = async (redirectUrl: string): Promise<string> => {
294
+ const url = new URL(redirectUrl);
295
+ const code = `code-${url.searchParams.get("state")!.slice(0, 8)}`;
296
+ state.codes.set(code, {
297
+ challenge: url.searchParams.get("code_challenge")!,
298
+ resource: url.searchParams.get("resource")!,
299
+ });
300
+ return code;
301
+ };
302
+ return {
303
+ storage,
304
+ settings,
305
+ credentials,
306
+ mcp,
307
+ read,
308
+ state,
309
+ authorize,
310
+ advance: (ms: number) => {
311
+ clock += ms;
312
+ },
313
+ now: () => clock,
314
+ };
315
+ }
316
+
317
+ const START = {
318
+ commandId: "connect-1",
319
+ label: "Example",
320
+ settings: { url: SERVER, transport: "streamable-http" as const },
321
+ redirectUri: REDIRECT,
322
+ callbackState: "signed-state-token",
323
+ authorizationStateId: "auth-state-1",
324
+ returnTarget: "browser" as const,
325
+ };
326
+
327
+ async function connect(world: Awaited<ReturnType<typeof fixture>>) {
328
+ const started = await world.mcp.startAuthorization(ACCOUNT, {
329
+ ...START,
330
+ authorizationStateExpiresAt: world.now() + 600_000,
331
+ });
332
+ expect(started.status).toBe("authorization-required");
333
+ const code = await world.authorize(
334
+ (started as { redirectUrl: string }).redirectUrl,
335
+ );
336
+ const completed = await world.mcp.completeAuthorization(ACCOUNT, {
337
+ authorizationStateId: START.authorizationStateId,
338
+ connectionId: started.connectionId,
339
+ returnTarget: "browser",
340
+ code,
341
+ });
342
+ return { started, completed, connectionId: started.connectionId };
343
+ }
344
+
345
+ describe("starting an authorization", () => {
346
+ test("mints a host-authored URL and records the pending authorization first", async () => {
347
+ const world = await fixture();
348
+ const started = await world.mcp.startAuthorization(ACCOUNT, {
349
+ ...START,
350
+ authorizationStateExpiresAt: world.now() + 600_000,
351
+ });
352
+
353
+ expect(started.status).toBe("authorization-required");
354
+ const url = new URL((started as { redirectUrl: string }).redirectUrl);
355
+ expect(url.origin + url.pathname).toBe(`${ISSUER}/authorize`);
356
+ expect(url.searchParams.get("state")).toBe(START.callbackState);
357
+ expect(url.searchParams.get("code_challenge_method")).toBe("S256");
358
+ expect(url.searchParams.get("resource")).toBe(SERVER);
359
+ expect(url.searchParams.get("client_id")).toBe("registered-client");
360
+ expect(url.searchParams.get("scope")).toBe("mcp:tools");
361
+
362
+ // The verifier is durable and never on the wire to the client.
363
+ const pending = world.storage.values.get(
364
+ mcpOAuthPendingKeyV1(START.authorizationStateId),
365
+ ) as { codeVerifier: string };
366
+ expect(pending.codeVerifier).toMatch(/^[A-Za-z0-9_-]{43,128}$/);
367
+ expect(JSON.stringify(started)).not.toContain(pending.codeVerifier);
368
+
369
+ // The Connection exists, in `authorizing`, before the browser leaves.
370
+ expect((await world.read(started.connectionId)).state).toBe("authorizing");
371
+ });
372
+
373
+ test("uses the client-id setting when the server offers no registration", async () => {
374
+ const world = await fixture({ withoutRegistration: true });
375
+ const started = await world.mcp.startAuthorization(ACCOUNT, {
376
+ ...START,
377
+ settings: { ...START.settings, "client-id": "preregistered" },
378
+ authorizationStateExpiresAt: world.now() + 600_000,
379
+ });
380
+ expect(
381
+ new URL(
382
+ (started as { redirectUrl: string }).redirectUrl,
383
+ ).searchParams.get("client_id"),
384
+ ).toBe("preregistered");
385
+ });
386
+
387
+ test("refuses a server that offers neither registration nor a client-id", async () => {
388
+ const world = await fixture({ withoutRegistration: true });
389
+ await expect(
390
+ world.mcp.startAuthorization(ACCOUNT, {
391
+ ...START,
392
+ authorizationStateExpiresAt: world.now() + 600_000,
393
+ }),
394
+ ).rejects.toThrow(/no dynamic client registration/);
395
+ // And the refusal is durable on the Connection, not only thrown.
396
+ const connections = (await world.settings.read(ACCOUNT)).connections;
397
+ expect(connections[0]).toMatchObject({ state: "failed" });
398
+ });
399
+
400
+ test("charges a per-User quota, and refuses past it", async () => {
401
+ const world = await fixture();
402
+ for (let attempt = 0; attempt < 24; attempt += 1) {
403
+ await world.mcp
404
+ .startAuthorization(ACCOUNT, {
405
+ ...START,
406
+ commandId: `connect-${attempt}`,
407
+ authorizationStateId: `auth-state-${attempt}`,
408
+ authorizationStateExpiresAt: world.now() + 600_000,
409
+ })
410
+ .catch(() => undefined);
411
+ }
412
+ await expect(
413
+ world.mcp.startAuthorization(ACCOUNT, {
414
+ ...START,
415
+ commandId: "connect-over",
416
+ authorizationStateId: "auth-state-over",
417
+ authorizationStateExpiresAt: world.now() + 600_000,
418
+ }),
419
+ ).rejects.toThrow(/at most 24 MCP authorizations an hour/);
420
+ });
421
+ });
422
+
423
+ describe("completing an authorization", () => {
424
+ test("exchanges the code and reaches ready only after the handshake", async () => {
425
+ const world = await fixture();
426
+ const { completed, connectionId } = await connect(world);
427
+
428
+ expect(completed).toMatchObject({
429
+ returnTarget: "browser",
430
+ status: "ready",
431
+ });
432
+ expect(world.state.exchanges).toBe(1);
433
+ expect(world.state.lastTokenForm.resource).toBe(SERVER);
434
+ expect(world.state.lastTokenForm.grant_type).toBe("authorization_code");
435
+ const connection = await world.read(connectionId);
436
+ expect(connection.state).toBe("ready");
437
+ expect(connection.safeMetadata).toMatchObject({ serverName: "Example" });
438
+ // The handshake carried the bearer the token endpoint issued.
439
+ expect(world.state.mcpBearers).toContain("access-1");
440
+ });
441
+
442
+ test("seals the access token leasably and the refresh token not at all", async () => {
443
+ const world = await fixture();
444
+ const { connectionId } = await connect(world);
445
+
446
+ const record = decodeMcpOAuthRecordV1(
447
+ world.storage.values.get(mcpOAuthRecordKeyV1(connectionId)),
448
+ );
449
+ expect(record.accessGeneration).toBeDefined();
450
+ expect(record.refreshGeneration).toBe(record.accessGeneration!);
451
+
452
+ // The access token leases.
453
+ const lease = await world.mcp.leaseToolCredential({
454
+ accountId: ACCOUNT,
455
+ connectionId,
456
+ effectId: "effect-1",
457
+ connectionGeneration: (await world.read(connectionId)).generation!,
458
+ });
459
+ expect(
460
+ await world.credentials.openLease({
461
+ accountId: ACCOUNT,
462
+ packageId: "mcp",
463
+ lease,
464
+ }),
465
+ ).toBe("access-1");
466
+
467
+ // The refresh token has no active generation at all, so there is nothing
468
+ // to lease: it is unleasable, not merely un-leased.
469
+ await expect(
470
+ world.credentials.lease({
471
+ accountId: ACCOUNT,
472
+ connectionId: mcpRefreshCredentialIdV1(connectionId),
473
+ packageId: "mcp",
474
+ effectId: "effect-refresh",
475
+ expiresAt: new Date(world.now() + 60_000).toISOString(),
476
+ expectedGeneration: record.refreshGeneration!,
477
+ }),
478
+ ).rejects.toThrow(/unavailable/);
479
+ });
480
+
481
+ test("is a no-op the second time the same state is presented", async () => {
482
+ const world = await fixture();
483
+ const { connectionId, started } = await connect(world);
484
+ expect(world.state.exchanges).toBe(1);
485
+
486
+ const replay = await world.mcp.completeAuthorization(ACCOUNT, {
487
+ authorizationStateId: START.authorizationStateId,
488
+ connectionId: started.connectionId,
489
+ returnTarget: "browser",
490
+ code: "code-replayed",
491
+ });
492
+ expect(replay).toMatchObject({ status: "ready" });
493
+ // No second token request, and the Connection is untouched.
494
+ expect(world.state.exchanges).toBe(1);
495
+ expect((await world.read(connectionId)).state).toBe("ready");
496
+ });
497
+
498
+ test("refuses a state whose Connection is not the one the record names", async () => {
499
+ const world = await fixture();
500
+ const started = await world.mcp.startAuthorization(ACCOUNT, {
501
+ ...START,
502
+ authorizationStateExpiresAt: world.now() + 600_000,
503
+ });
504
+ const completed = await world.mcp.completeAuthorization(ACCOUNT, {
505
+ authorizationStateId: START.authorizationStateId,
506
+ connectionId: "mcp-someone-elses",
507
+ returnTarget: "browser",
508
+ code: "code-1",
509
+ });
510
+ expect(completed.status).toBe("failed");
511
+ expect(world.state.exchanges).toBe(0);
512
+ expect((await world.read(started.connectionId)).state).toBe("authorizing");
513
+ });
514
+
515
+ test("leaves the Connection failed when the authorization server refuses", async () => {
516
+ const world = await fixture();
517
+ const started = await world.mcp.startAuthorization(ACCOUNT, {
518
+ ...START,
519
+ authorizationStateExpiresAt: world.now() + 600_000,
520
+ });
521
+ const completed = await world.mcp.completeAuthorization(ACCOUNT, {
522
+ authorizationStateId: START.authorizationStateId,
523
+ connectionId: started.connectionId,
524
+ returnTarget: "browser",
525
+ error: "access_denied",
526
+ });
527
+ expect(completed.status).toBe("failed");
528
+ const connection = await world.read(started.connectionId);
529
+ expect(connection.state).toBe("failed");
530
+ expect(connection.failure).toContain("access_denied");
531
+ });
532
+ });
533
+
534
+ describe("refresh on lease open", () => {
535
+ test("refreshes silently when the access token is about to expire", async () => {
536
+ const world = await fixture({ accessTokenLifetimeSeconds: 120 });
537
+ const { connectionId } = await connect(world);
538
+ const generation = (await world.read(connectionId)).generation!;
539
+
540
+ // Inside the skew window: the token would expire mid-lease.
541
+ world.advance(90_000);
542
+ const lease = await world.mcp.leaseToolCredential({
543
+ accountId: ACCOUNT,
544
+ connectionId,
545
+ effectId: "effect-after-refresh",
546
+ connectionGeneration: generation,
547
+ });
548
+ expect(world.state.refreshes).toBe(1);
549
+ expect(world.state.lastTokenForm).toMatchObject({
550
+ grant_type: "refresh_token",
551
+ refresh_token: "refresh-1",
552
+ resource: SERVER,
553
+ });
554
+ expect(
555
+ await world.credentials.openLease({
556
+ accountId: ACCOUNT,
557
+ packageId: "mcp",
558
+ lease,
559
+ }),
560
+ ).toBe("access-2");
561
+
562
+ // The Connection generation is untouched, so a Turn already pinned to it
563
+ // is not re-resolved by a refresh.
564
+ expect((await world.read(connectionId)).generation).toBe(generation);
565
+ });
566
+
567
+ test("does not refresh a token with time left on it", async () => {
568
+ const world = await fixture({ accessTokenLifetimeSeconds: 3_600 });
569
+ const { connectionId } = await connect(world);
570
+ await world.mcp.leaseToolCredential({
571
+ accountId: ACCOUNT,
572
+ connectionId,
573
+ effectId: "effect-fresh",
574
+ connectionGeneration: (await world.read(connectionId)).generation!,
575
+ });
576
+ expect(world.state.refreshes).toBe(0);
577
+ });
578
+
579
+ test("refuses the lease and records needs-auth when the refresh fails", async () => {
580
+ const world = await fixture({ accessTokenLifetimeSeconds: 120 });
581
+ const { connectionId } = await connect(world);
582
+ world.advance(90_000);
583
+ // The server forgets the refresh token, as one does after a User revokes.
584
+ world.state.refreshTokens.clear();
585
+
586
+ await expect(
587
+ world.mcp.leaseToolCredential({
588
+ accountId: ACCOUNT,
589
+ connectionId,
590
+ effectId: "effect-dead",
591
+ connectionGeneration: (await world.read(connectionId)).generation!,
592
+ }),
593
+ ).rejects.toThrow(/could not be refreshed/);
594
+
595
+ const status = await world.mcp.readServerStatus(ACCOUNT);
596
+ expect(status.servers[0]).toMatchObject({
597
+ state: "needs-auth",
598
+ failure: { code: "unauthorized" },
599
+ });
600
+ });
601
+ });
602
+
603
+ describe("revoking", () => {
604
+ test("revokes at the server, then forgets everything, and reports revoked", async () => {
605
+ const world = await fixture();
606
+ const { connectionId } = await connect(world);
607
+
608
+ expect(await world.mcp.revokeAuthorization(ACCOUNT, connectionId)).toEqual({
609
+ schemaVersion: 1,
610
+ status: "revoked",
611
+ });
612
+ expect(world.state.revocations).toBe(1);
613
+ expect((await world.read(connectionId)).state).toBe("revoked");
614
+ expect(
615
+ world.storage.values.get(mcpOAuthRecordKeyV1(connectionId)),
616
+ ).toBeUndefined();
617
+ // And the server record is gone with it, as `RemoveMcpAccount` requires.
618
+ expect((await world.mcp.readServerStatus(ACCOUNT)).servers).toHaveLength(0);
619
+ });
620
+
621
+ test("reports reconciliation-required when the server advertises no endpoint", async () => {
622
+ const world = await fixture({ withoutRevocation: true });
623
+ const { connectionId } = await connect(world);
624
+
625
+ expect(await world.mcp.revokeAuthorization(ACCOUNT, connectionId)).toEqual({
626
+ schemaVersion: 1,
627
+ status: "reconciliation-required",
628
+ });
629
+ expect((await world.read(connectionId)).state).toBe(
630
+ "reconciliation-required",
631
+ );
632
+ });
633
+
634
+ test("reports reconciliation-required when the server refuses the revocation", async () => {
635
+ const world = await fixture({ revokeStatus: 503 });
636
+ const { connectionId } = await connect(world);
637
+ expect(
638
+ (await world.mcp.revokeAuthorization(ACCOUNT, connectionId)).status,
639
+ ).toBe("reconciliation-required");
640
+ });
641
+
642
+ test("disconnecting with revokeUpstream revokes at the server too", async () => {
643
+ const world = await fixture();
644
+ const { connectionId } = await connect(world);
645
+
646
+ await world.mcp.executeConnection(ACCOUNT, {
647
+ schemaVersion: 1,
648
+ type: "connection/disconnect",
649
+ commandId: "disconnect-1",
650
+ connectionId,
651
+ revokeUpstream: true,
652
+ });
653
+ expect(world.state.revocations).toBe(1);
654
+ expect((await world.read(connectionId)).state).toBe("revoked");
655
+ });
656
+
657
+ test("disconnecting without it drops only FrockBot's copy", async () => {
658
+ const world = await fixture();
659
+ const { connectionId } = await connect(world);
660
+
661
+ await world.mcp.executeConnection(ACCOUNT, {
662
+ schemaVersion: 1,
663
+ type: "connection/disconnect",
664
+ commandId: "disconnect-2",
665
+ connectionId,
666
+ revokeUpstream: false,
667
+ });
668
+ expect(world.state.revocations).toBe(0);
669
+ expect((await world.read(connectionId)).state).toBe("revoked");
670
+ });
671
+ });
672
+
673
+ describe("the pending-authorization projection", () => {
674
+ test("is absent while the Connection is working", async () => {
675
+ const world = await fixture();
676
+ const { connectionId } = await connect(world);
677
+ expect(
678
+ (await world.read(connectionId)).pendingAuthorization,
679
+ ).toBeUndefined();
680
+ });
681
+
682
+ test("is set by a mount that met a 401, and cleared when the tools come back", async () => {
683
+ const world = await fixture();
684
+ const { connectionId } = await connect(world);
685
+
686
+ await world.mcp.recordMountOutcome({
687
+ accountId: ACCOUNT,
688
+ connectionId,
689
+ state: "needs-auth",
690
+ failure: { code: "unauthorized", message: "MCP server answered 401" },
691
+ });
692
+ const pending = (await world.read(connectionId)).pendingAuthorization;
693
+ expect(pending).toMatchObject({
694
+ reason: "needs-auth",
695
+ connectionId,
696
+ label: "Example",
697
+ });
698
+ // The card the User presses carries nothing that could be followed.
699
+ expect(JSON.stringify(pending)).not.toContain("http");
700
+
701
+ await world.mcp.recordMountOutcome({
702
+ accountId: ACCOUNT,
703
+ connectionId,
704
+ state: "ready",
705
+ toolCount: 1,
706
+ });
707
+ expect(
708
+ (await world.read(connectionId)).pendingAuthorization,
709
+ ).toBeUndefined();
710
+ });
711
+
712
+ test("is set when a refresh fails, beside the durable needs-auth record", async () => {
713
+ const world = await fixture({ accessTokenLifetimeSeconds: 120 });
714
+ const { connectionId } = await connect(world);
715
+ world.advance(90_000);
716
+ world.state.refreshTokens.clear();
717
+
718
+ await expect(
719
+ world.mcp.leaseToolCredential({
720
+ accountId: ACCOUNT,
721
+ connectionId,
722
+ effectId: "effect-dead-card",
723
+ connectionGeneration: (await world.read(connectionId)).generation!,
724
+ }),
725
+ ).rejects.toThrow();
726
+
727
+ expect((await world.read(connectionId)).pendingAuthorization).toMatchObject(
728
+ { reason: "needs-auth", connectionId },
729
+ );
730
+ });
731
+
732
+ test("is what a Bot's request writes, and it writes nothing else", async () => {
733
+ const world = await fixture();
734
+ const { connectionId } = await connect(world);
735
+
736
+ const receipt = await world.mcp.executeLifecycle(ACCOUNT, {
737
+ schemaVersion: 1,
738
+ type: "mcp/request-authorization",
739
+ commandId: "bot-asked-1",
740
+ serverId: connectionId,
741
+ });
742
+ expect(receipt.status).toBe("applied");
743
+
744
+ // The decision is pending, and nothing about the server changed: a Bot
745
+ // does not get to declare its User's server broken.
746
+ expect((await world.read(connectionId)).pendingAuthorization).toMatchObject(
747
+ { reason: "needs-auth", connectionId },
748
+ );
749
+ expect((await world.read(connectionId)).state).toBe("ready");
750
+ expect(
751
+ (await world.mcp.readServerStatus(ACCOUNT)).servers[0],
752
+ ).toMatchObject({ state: "ready" });
753
+ });
754
+
755
+ test("refuses a Bot's request against a server that has nothing to authorize", async () => {
756
+ const world = await fixture();
757
+ const receipt = await world.mcp.executeLifecycle(ACCOUNT, {
758
+ schemaVersion: 1,
759
+ type: "mcp/add-server",
760
+ commandId: "add-public-1",
761
+ label: "Public",
762
+ url: SERVER,
763
+ transport: "streamable-http",
764
+ });
765
+ // The public server has no token, so there is no 401 path to it here; the
766
+ // request-authorization refusal is the assertion.
767
+ const refused = await world.mcp.executeLifecycle(ACCOUNT, {
768
+ schemaVersion: 1,
769
+ type: "mcp/request-authorization",
770
+ commandId: "bot-asked-2",
771
+ serverId: receipt.serverId!,
772
+ });
773
+ expect(refused.status).toBe("refused");
774
+ expect(refused.code).toBe("unauthorized");
775
+ });
776
+ });