@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.
package/src/user.ts ADDED
@@ -0,0 +1,2068 @@
1
+ /**
2
+ * The User Contribution: MCP servers as Connections the User owns.
3
+ *
4
+ * A Connection is created, validated, relabelled, disabled and disconnected
5
+ * through the ordinary Connection command path — `connection/create` for a
6
+ * public server, `connection/create-api-key` for one behind a key. Validation
7
+ * is the handshake itself: `initialize` followed by `tools/list` is the only
8
+ * thing that proves both that the endpoint speaks MCP and that the key opens
9
+ * it, so a Connection that cannot complete it reaches `failed` with the reason
10
+ * rather than `ready` with a promise.
11
+ *
12
+ * The credential never leaves this object except as an opaque, expiring lease
13
+ * the Bot's own host opens against the keyring.
14
+ */
15
+ import type { ConnectionView } from "@frockbot/configuration-core";
16
+ import {
17
+ decodeConnectionCommandV1,
18
+ type ConnectionCommandReceiptV1,
19
+ type ConnectionCommandV1,
20
+ type ConnectionCompletionResult,
21
+ type ConnectionSettingsV1,
22
+ type CredentialLeaseV1,
23
+ type RevokeConnectionResult,
24
+ type StartConnectionResult,
25
+ } from "@frockbot/connection-core";
26
+ import type {
27
+ CredentialStorage,
28
+ CredentialUserBackendContribution,
29
+ } from "@frockbot/plugin-credentials/user";
30
+ import type {
31
+ UserSettingsBackendContribution,
32
+ UserSettingsStorage,
33
+ } from "@frockbot/plugin-settings/user";
34
+ import type { Plugin } from "cordis";
35
+ import {
36
+ MAX_MCP_SERVERS_PER_USER_V1,
37
+ MCP_CONNECTION_TYPE_ID,
38
+ MCP_KEYED_CONNECTION_TYPE_ID,
39
+ MCP_OAUTH_CONNECTION_TYPE_ID,
40
+ MCP_PACKAGE_ID,
41
+ decodeMcpConnectionSettingsV1,
42
+ decodeMcpOAuthSettingsV1,
43
+ } from "./agent.js";
44
+ import {
45
+ McpAuthorizationError,
46
+ McpOAuthClient,
47
+ MCP_ACCESS_REFRESH_SKEW_MS_V1,
48
+ MCP_AUTHORIZATION_START_WINDOW_MS_V1,
49
+ MCP_AUTHORIZATION_TTL_MS_V1,
50
+ MAX_MCP_AUTHORIZATION_STARTS_V1,
51
+ mcpAuthorizeUrlV1,
52
+ mcpCanonicalResourceV1,
53
+ createPkcePairV1,
54
+ type McpOAuthTokenSetV1,
55
+ } from "./oauth.js";
56
+ import {
57
+ decodeMcpAuthorizationStartsV1,
58
+ decodeMcpOAuthPendingV1,
59
+ decodeMcpOAuthRecordV1,
60
+ mcpAuthorizationConnectionIdV1,
61
+ mcpOAuthPendingKeyV1,
62
+ mcpOAuthRecordKeyV1,
63
+ mcpRefreshCredentialIdV1,
64
+ MCP_OAUTH_STARTS_KEY,
65
+ type McpOAuthPendingV1,
66
+ type McpOAuthRecordV1,
67
+ } from "./oauth-records.js";
68
+ import {
69
+ McpClient,
70
+ MAX_MCP_RESPONSE_BYTES,
71
+ MAX_MCP_TOOLS_PER_SERVER,
72
+ type McpFetch,
73
+ } from "./mcp-client.js";
74
+ import {
75
+ decodeMcpLifecycleCommandV1,
76
+ decodeMcpLifecycleReceiptV1,
77
+ mcpFailureCodeV1,
78
+ decodeMcpRefusalRecordV1,
79
+ decodeMcpServerRecordV1,
80
+ mcpConnectionMetadataV1,
81
+ mcpPendingAuthorizationV1,
82
+ mcpRefusalKeyV1,
83
+ mcpServerRecordKeyV1,
84
+ MAX_MCP_REFUSALS_V1,
85
+ MCP_REFUSAL_INDEX_KEY,
86
+ MCP_SERVER_INDEX_KEY,
87
+ type McpFailureCodeV1,
88
+ type McpLifecycleCommandV1,
89
+ type McpLifecycleReceiptV1,
90
+ type McpRefusalRecordV1,
91
+ type McpServerRecordV1,
92
+ type McpServerStateV1,
93
+ type McpServerStatusViewV1,
94
+ } from "./records.js";
95
+
96
+ /**
97
+ * The global `fetch`, bound. A bare reference to it throws "Illegal
98
+ * invocation" inside a Durable Object, where the built-in checks its
99
+ * receiver.
100
+ */
101
+ const boundFetch: McpFetch = (input, init) => fetch(input, init);
102
+
103
+ const COMMAND_PREFIX = "mcp-connection-command:";
104
+ const LIFECYCLE_COMMAND_PREFIX = "mcp-lifecycle-command:";
105
+ const LIFECYCLE_COMMAND_INDEX_KEY = "mcp-lifecycle-command-index";
106
+ const MAX_STORED_COMMANDS = 256;
107
+ const COMMAND_INDEX_KEY = "mcp-connection-command-index";
108
+ /** Long enough for one mount's handshake, short enough to be worth nothing. */
109
+ const TOOL_LEASE_MS = 5 * 60 * 1_000;
110
+
111
+ interface StoredLifecycleCommand {
112
+ schemaVersion: 1;
113
+ commandId: string;
114
+ fingerprint: string;
115
+ receipt: McpLifecycleReceiptV1;
116
+ }
117
+
118
+ interface StoredCommand {
119
+ schemaVersion: 1;
120
+ commandId: string;
121
+ fingerprint: string;
122
+ connectionId: string;
123
+ receipt: ConnectionCommandReceiptV1;
124
+ }
125
+
126
+ export interface McpUserBackendHost {
127
+ storage: UserSettingsStorage & CredentialStorage;
128
+ settings: UserSettingsBackendContribution;
129
+ credentials: CredentialUserBackendContribution;
130
+ /** The Package's own outbound seam; the deployment's `fetch` by default. */
131
+ fetch?: McpFetch;
132
+ now?: () => number;
133
+ randomId?: () => string;
134
+ }
135
+
136
+ async function fingerprint(value: unknown): Promise<string> {
137
+ const digest = await crypto.subtle.digest(
138
+ "SHA-256",
139
+ new TextEncoder().encode(JSON.stringify(value)),
140
+ );
141
+ return Array.from(new Uint8Array(digest), (byte) =>
142
+ byte.toString(16).padStart(2, "0"),
143
+ ).join("");
144
+ }
145
+
146
+ /** The identity of a server's tool set, so a change in it is observable. */
147
+ export async function mcpToolsHashV1(
148
+ tools: readonly { name: string; inputSchema: unknown }[],
149
+ ): Promise<string> {
150
+ const canonical = [...tools]
151
+ .map((tool) => ({ name: tool.name, inputSchema: tool.inputSchema }))
152
+ .sort((left, right) => left.name.localeCompare(right.name));
153
+ return (await fingerprint(canonical)).slice(0, 32);
154
+ }
155
+
156
+ function decodeStoredLifecycleCommand(input: unknown): StoredLifecycleCommand {
157
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
158
+ throw new Error("Stored MCP lifecycle command is invalid");
159
+ }
160
+ const value = input as Record<string, unknown>;
161
+ const allowed = new Set([
162
+ "schemaVersion",
163
+ "commandId",
164
+ "fingerprint",
165
+ "receipt",
166
+ ]);
167
+ if (
168
+ value.schemaVersion !== 1 ||
169
+ typeof value.commandId !== "string" ||
170
+ typeof value.fingerprint !== "string" ||
171
+ Object.keys(value).some((key) => !allowed.has(key))
172
+ ) {
173
+ throw new Error("Stored MCP lifecycle command is invalid");
174
+ }
175
+ return {
176
+ schemaVersion: 1,
177
+ commandId: value.commandId,
178
+ fingerprint: value.fingerprint,
179
+ receipt: decodeMcpLifecycleReceiptV1(value.receipt),
180
+ };
181
+ }
182
+
183
+ function decodeStoredCommand(input: unknown): StoredCommand {
184
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
185
+ throw new Error("Stored MCP Connection command is invalid");
186
+ }
187
+ const value = input as Record<string, unknown>;
188
+ const allowed = new Set([
189
+ "schemaVersion",
190
+ "commandId",
191
+ "fingerprint",
192
+ "connectionId",
193
+ "receipt",
194
+ ]);
195
+ if (
196
+ value.schemaVersion !== 1 ||
197
+ typeof value.commandId !== "string" ||
198
+ typeof value.fingerprint !== "string" ||
199
+ typeof value.connectionId !== "string" ||
200
+ !value.receipt ||
201
+ typeof value.receipt !== "object" ||
202
+ Object.keys(value).some((key) => !allowed.has(key))
203
+ ) {
204
+ throw new Error("Stored MCP Connection command is invalid");
205
+ }
206
+ return value as unknown as StoredCommand;
207
+ }
208
+
209
+ /**
210
+ * How a handshake opens this Connection's credential. `none` is a public
211
+ * server; `sealed` names the exact credential generation to open, which for an
212
+ * OAuth Connection is the access token's generation and not the Connection's.
213
+ */
214
+ export type McpHandshakeCredentialV1 =
215
+ { kind: "none" } | { kind: "sealed"; generation: string };
216
+
217
+ /** Whether a Connection Type of this Package carries a credential at all. */
218
+ export function mcpConnectionCarriesCredentialV1(
219
+ connectionTypeId: string,
220
+ ): boolean {
221
+ return (
222
+ connectionTypeId === MCP_KEYED_CONNECTION_TYPE_ID ||
223
+ connectionTypeId === MCP_OAUTH_CONNECTION_TYPE_ID
224
+ );
225
+ }
226
+
227
+ /** What one authorization start asks of this object. */
228
+ export interface McpAuthorizationStartInputV1 {
229
+ commandId: string;
230
+ /** Reconnecting an existing Connection; absent creates one. */
231
+ connectionId?: string;
232
+ label?: string;
233
+ settings?: Record<string, unknown>;
234
+ /** The absolute callback URL the gateway will be redirected back to. */
235
+ redirectUri: string;
236
+ /** The signed state the gateway minted. Opaque here. */
237
+ callbackState: string;
238
+ authorizationStateId: string;
239
+ authorizationStateExpiresAt: number;
240
+ returnTarget: "browser" | "desktop";
241
+ nativeReturnNonce?: string;
242
+ }
243
+
244
+ /** What the callback carries back, once its state has been verified. */
245
+ export interface McpAuthorizationCompletionInputV1 {
246
+ authorizationStateId: string;
247
+ connectionId: string;
248
+ returnTarget: "browser" | "desktop";
249
+ nativeReturnNonce?: string;
250
+ code?: string;
251
+ error?: string;
252
+ }
253
+
254
+ export class McpUserBackendContribution {
255
+ readonly packageId = MCP_PACKAGE_ID;
256
+
257
+ private readonly now: () => number;
258
+ private readonly randomId: () => string;
259
+
260
+ constructor(private readonly host: McpUserBackendHost) {
261
+ this.now = host.now ?? (() => Date.now());
262
+ this.randomId = host.randomId ?? (() => crypto.randomUUID());
263
+ }
264
+
265
+ async executeConnection(
266
+ accountId: string,
267
+ input: unknown,
268
+ ): Promise<ConnectionCommandReceiptV1> {
269
+ const command = decodeConnectionCommandV1(input);
270
+ const commandFingerprint = await fingerprint(command);
271
+ const stored = await this.readCommand(command.commandId);
272
+ if (stored) {
273
+ if (stored.fingerprint !== commandFingerprint) {
274
+ throw new Error(
275
+ `MCP Connection command "${command.commandId}" was reused for a different command`,
276
+ );
277
+ }
278
+ return stored.receipt;
279
+ }
280
+ const receipt = await this.apply(accountId, command);
281
+ await this.recordCommand({
282
+ schemaVersion: 1,
283
+ commandId: command.commandId,
284
+ fingerprint: commandFingerprint,
285
+ connectionId: receipt.connectionId,
286
+ receipt,
287
+ });
288
+ return receipt;
289
+ }
290
+
291
+ async lookupConnectionCommand(
292
+ _accountId: string,
293
+ commandId: string,
294
+ ): Promise<ConnectionCommandReceiptV1 | undefined> {
295
+ return (await this.readCommand(commandId))?.receipt;
296
+ }
297
+
298
+ /**
299
+ * The MCP lifecycle: add a server, set its instructions, restart it.
300
+ *
301
+ * These are the three GrokBot verbs the ordinary Connection commands do not
302
+ * cover (`RenameMcpAccount` and `RemoveMcpAccount` are
303
+ * `connection/update-label` and `connection/disconnect`, which need nothing
304
+ * new). Each is idempotent on its `commandId`, and each refusal is durable:
305
+ * a stdio server and a seventeenth server both leave a record saying why.
306
+ */
307
+ async executeLifecycle(
308
+ accountId: string,
309
+ input: unknown,
310
+ ): Promise<McpLifecycleReceiptV1> {
311
+ const command = decodeMcpLifecycleCommandV1(input);
312
+ const commandFingerprint = await fingerprint(command);
313
+ const stored = await this.readLifecycleCommand(command.commandId);
314
+ if (stored) {
315
+ if (stored.fingerprint !== commandFingerprint) {
316
+ throw new Error(
317
+ `MCP lifecycle command "${command.commandId}" was reused for a different command`,
318
+ );
319
+ }
320
+ return stored.receipt;
321
+ }
322
+ const receipt = await this.applyLifecycle(accountId, command);
323
+ await this.recordLifecycleCommand({
324
+ schemaVersion: 1,
325
+ commandId: command.commandId,
326
+ fingerprint: commandFingerprint,
327
+ receipt,
328
+ });
329
+ return receipt;
330
+ }
331
+
332
+ /** GrokBot's `GetMcpServerStatus`, as a projection of the durable records. */
333
+ async readServerStatus(accountId: string): Promise<McpServerStatusViewV1> {
334
+ await this.host.settings.read(accountId);
335
+ const servers: McpServerRecordV1[] = [];
336
+ for (const serverId of await this.readServerIndex()) {
337
+ const server = await this.readServer(serverId);
338
+ if (server) servers.push(server);
339
+ }
340
+ const refusals: McpRefusalRecordV1[] = [];
341
+ for (const refusalId of await this.readRefusalIndex()) {
342
+ const value = await this.host.storage.get<unknown>(
343
+ mcpRefusalKeyV1(refusalId),
344
+ );
345
+ if (value !== undefined) refusals.push(decodeMcpRefusalRecordV1(value));
346
+ }
347
+ return {
348
+ schemaVersion: 1,
349
+ servers: servers.sort((left, right) =>
350
+ left.label.localeCompare(right.label),
351
+ ),
352
+ refusals: refusals.toReversed(),
353
+ quotas: {
354
+ maxServers: MAX_MCP_SERVERS_PER_USER_V1,
355
+ maxToolsPerServer: MAX_MCP_TOOLS_PER_SERVER,
356
+ maxResponseBytes: MAX_MCP_RESPONSE_BYTES,
357
+ },
358
+ };
359
+ }
360
+
361
+ /**
362
+ * What a Bot's mount of this server found. L2 dropped it on the floor: a
363
+ * server that could not be reached simply contributed no tool and left no
364
+ * trace. It is durable here, so an unreachable server is a visible `error`
365
+ * on the User's own surface rather than a Bot that quietly lost its tools.
366
+ *
367
+ * A mount reporting an epoch the record has moved past is ignored: the
368
+ * outcome describes a server generation the User has already restarted away
369
+ * from.
370
+ */
371
+ async recordMountOutcome(input: {
372
+ accountId: string;
373
+ connectionId: string;
374
+ serverEpoch?: number;
375
+ state: "ready" | "needs-auth" | "error";
376
+ failure?: { code: McpFailureCodeV1; message: string };
377
+ protocolVersion?: string;
378
+ toolCount?: number;
379
+ toolsHash?: string;
380
+ }): Promise<void> {
381
+ const current = await this.readServer(input.connectionId);
382
+ if (!current) return;
383
+ if (
384
+ input.serverEpoch !== undefined &&
385
+ input.serverEpoch !== current.serverEpoch
386
+ ) {
387
+ return;
388
+ }
389
+ const at = new Date(this.now()).toISOString();
390
+ const server = await this.writeServer({
391
+ ...current,
392
+ state: input.state,
393
+ ...(input.protocolVersion === undefined
394
+ ? {}
395
+ : { protocolVersion: input.protocolVersion }),
396
+ toolCount:
397
+ input.toolCount ?? (input.state === "ready" ? current.toolCount : 0),
398
+ toolsHash: input.toolsHash ?? current.toolsHash,
399
+ lastHandshakeAt: at,
400
+ ...(input.failure
401
+ ? {
402
+ failure: {
403
+ code: input.failure.code,
404
+ message: input.failure.message.slice(0, 2_000),
405
+ at,
406
+ },
407
+ }
408
+ : {}),
409
+ });
410
+ // The card the User presses is drawn from this projection, so a mount that
411
+ // met a 401 has to reach it. `needs-auth` sets it; anything else clears
412
+ // it, which is what makes "the tools came back" and "the card went away"
413
+ // the same event rather than two.
414
+ await this.projectPendingAuthorization(input.accountId, server, at).catch(
415
+ () => undefined,
416
+ );
417
+ }
418
+
419
+ /**
420
+ * Set or clear the Connection's `pendingAuthorization`.
421
+ *
422
+ * The projection is idempotent and carries no URL. It is written
423
+ * best-effort from a mount outcome — the Bot must not lose a Turn because
424
+ * the projection could not be updated — but the durable server record it
425
+ * mirrors is written first, so the reason is never lost.
426
+ */
427
+ private async projectPendingAuthorization(
428
+ accountId: string,
429
+ server: McpServerRecordV1,
430
+ at: string,
431
+ ): Promise<void> {
432
+ const connection = await this.host.settings.getConnection(
433
+ accountId,
434
+ server.serverId,
435
+ );
436
+ if (!connection) return;
437
+ const pending =
438
+ server.state === "needs-auth"
439
+ ? mcpPendingAuthorizationV1(server, at)
440
+ : undefined;
441
+ const current = connection.pendingAuthorization;
442
+ if (
443
+ (pending === undefined && current === undefined) ||
444
+ (pending !== undefined &&
445
+ current !== undefined &&
446
+ current.connectionId === pending.connectionId &&
447
+ current.label === pending.label)
448
+ ) {
449
+ return;
450
+ }
451
+ await this.host.settings.replaceConnection(
452
+ accountId,
453
+ connection.connectionId,
454
+ connection.generation,
455
+ // Rebuilt rather than spread, because clearing it must remove the key
456
+ // and a spread of `undefined` would leave it present.
457
+ {
458
+ ...connection,
459
+ pendingAuthorization: undefined,
460
+ ...(pending ? { pendingAuthorization: pending } : {}),
461
+ },
462
+ );
463
+ }
464
+
465
+ private async applyLifecycle(
466
+ accountId: string,
467
+ command: McpLifecycleCommandV1,
468
+ ): Promise<McpLifecycleReceiptV1> {
469
+ switch (command.type) {
470
+ case "mcp/add-server":
471
+ return this.addServer(accountId, command);
472
+ case "mcp/set-instructions": {
473
+ const server = await this.requireServer(accountId, command.serverId);
474
+ await this.writeServer({
475
+ ...server,
476
+ ...(command.instructions
477
+ ? { instructions: command.instructions }
478
+ : { instructions: undefined }),
479
+ });
480
+ await this.mirrorConnectionMetadata(accountId, command.serverId);
481
+ return {
482
+ schemaVersion: 1,
483
+ commandId: command.commandId,
484
+ status: "applied",
485
+ serverId: command.serverId,
486
+ };
487
+ }
488
+ case "mcp/request-authorization": {
489
+ // A durable pending decision, never a grant. The server record is left
490
+ // exactly as it is — a Bot does not get to declare its User's server
491
+ // broken — and what changes is the Connection projection the User's
492
+ // own surface draws a connect card from. No URL is minted here, and
493
+ // none is returned: only an authenticated `connection/start` does that.
494
+ const server = await this.requireServer(accountId, command.serverId);
495
+ const connection = await this.requireConnection(
496
+ accountId,
497
+ command.serverId,
498
+ );
499
+ if (connection.connectionTypeId !== MCP_OAUTH_CONNECTION_TYPE_ID) {
500
+ return this.refuse(command, {
501
+ code: "unauthorized",
502
+ message:
503
+ "This MCP server does not use OAuth, so there is nothing for the User to authorize. A keyed server needs a new key instead.",
504
+ });
505
+ }
506
+ await this.host.settings.replaceConnection(
507
+ accountId,
508
+ connection.connectionId,
509
+ connection.generation,
510
+ {
511
+ ...connection,
512
+ pendingAuthorization: mcpPendingAuthorizationV1(
513
+ server,
514
+ new Date(this.now()).toISOString(),
515
+ ),
516
+ },
517
+ );
518
+ return {
519
+ schemaVersion: 1,
520
+ commandId: command.commandId,
521
+ status: "applied",
522
+ serverId: command.serverId,
523
+ };
524
+ }
525
+ case "mcp/restart": {
526
+ const server = await this.requireServer(accountId, command.serverId);
527
+ const connection = await this.requireConnection(
528
+ accountId,
529
+ command.serverId,
530
+ );
531
+ if (connection.state === "revoked" || connection.state === "revoking") {
532
+ throw new Error("MCP Connection is revoked");
533
+ }
534
+ // The epoch bump is the whole of restart: it is in the Assignment's
535
+ // resolution key, so the next admitted Turn resolves a different
536
+ // mount and re-handshakes, while the in-flight Turn keeps the client
537
+ // it already holds. The handshake here refreshes the status the User
538
+ // is looking at; it kills no process, because there is none.
539
+ const receipt = await this.validateAndActivate(
540
+ accountId,
541
+ command.commandId,
542
+ connection,
543
+ await this.handshakeCredential(connection),
544
+ {
545
+ serverEpoch: server.serverEpoch + 1,
546
+ ...(server.instructions
547
+ ? { instructions: server.instructions }
548
+ : {}),
549
+ },
550
+ );
551
+ const next = await this.readServer(command.serverId);
552
+ return {
553
+ schemaVersion: 1,
554
+ commandId: command.commandId,
555
+ status: receipt.status === "applied" ? "applied" : "failed",
556
+ serverId: command.serverId,
557
+ ...(next?.failure
558
+ ? { code: next.failure.code, failure: next.failure.message }
559
+ : {}),
560
+ };
561
+ }
562
+ }
563
+ }
564
+
565
+ /**
566
+ * A custom remote MCP server by URL. stdio is refused durably: it needs a
567
+ * bidirectional pipe on the User's Computer that the Computer interface
568
+ * does not offer yet, and a refusal a User can read is a smaller lie than a
569
+ * Connection that never connects.
570
+ */
571
+ private async addServer(
572
+ accountId: string,
573
+ command: Extract<McpLifecycleCommandV1, { type: "mcp/add-server" }>,
574
+ ): Promise<McpLifecycleReceiptV1> {
575
+ if (command.transport === "stdio") {
576
+ return this.refuse(command, {
577
+ code: "unsupported-transport",
578
+ message:
579
+ "A stdio MCP server runs on the User's Computer, which needs a bidirectional service pipe FrockBot's Computer interface does not offer yet. Add the server over streamable-http or sse instead.",
580
+ });
581
+ }
582
+ const servers = await this.readServerIndex();
583
+ if (servers.length >= MAX_MCP_SERVERS_PER_USER_V1) {
584
+ return this.refuse(command, {
585
+ code: "server-quota",
586
+ message: `A User may hold at most ${MAX_MCP_SERVERS_PER_USER_V1} MCP servers; this one holds ${servers.length}.`,
587
+ });
588
+ }
589
+ const settings = {
590
+ url: command.url,
591
+ transport: command.transport,
592
+ ...(command.headerName ? { "header-name": command.headerName } : {}),
593
+ };
594
+ const receipt = await this.create(
595
+ accountId,
596
+ command.apiKey === undefined
597
+ ? {
598
+ schemaVersion: 1,
599
+ type: "connection/create",
600
+ commandId: command.commandId,
601
+ packageId: MCP_PACKAGE_ID,
602
+ connectionTypeId: MCP_CONNECTION_TYPE_ID,
603
+ label: command.label,
604
+ settings,
605
+ }
606
+ : {
607
+ schemaVersion: 1,
608
+ type: "connection/create-api-key",
609
+ commandId: command.commandId,
610
+ packageId: MCP_PACKAGE_ID,
611
+ connectionTypeId: MCP_KEYED_CONNECTION_TYPE_ID,
612
+ label: command.label,
613
+ apiKey: command.apiKey,
614
+ settings,
615
+ },
616
+ command.instructions === undefined
617
+ ? {}
618
+ : { instructions: command.instructions },
619
+ );
620
+ const server = await this.readServer(receipt.connectionId);
621
+ return {
622
+ schemaVersion: 1,
623
+ commandId: command.commandId,
624
+ status: receipt.status === "applied" ? "applied" : "failed",
625
+ serverId: receipt.connectionId,
626
+ ...(server?.failure
627
+ ? { code: server.failure.code, failure: server.failure.message }
628
+ : {}),
629
+ };
630
+ }
631
+
632
+ private async refuse(
633
+ command: McpLifecycleCommandV1,
634
+ failure: { code: McpFailureCodeV1; message: string },
635
+ ): Promise<McpLifecycleReceiptV1> {
636
+ const refusal: McpRefusalRecordV1 = {
637
+ schemaVersion: 1,
638
+ refusalId: this.randomId(),
639
+ commandId: command.commandId,
640
+ code: failure.code,
641
+ message: failure.message,
642
+ at: new Date(this.now()).toISOString(),
643
+ ...(command.type === "mcp/add-server"
644
+ ? {
645
+ label: command.label,
646
+ url: command.url,
647
+ transport: command.transport,
648
+ }
649
+ : {}),
650
+ };
651
+ await this.recordRefusal(refusal);
652
+ return {
653
+ schemaVersion: 1,
654
+ commandId: command.commandId,
655
+ status: "refused",
656
+ code: failure.code,
657
+ failure: failure.message,
658
+ };
659
+ }
660
+
661
+ /**
662
+ * MCP Connections carry tools, never a model. The seam exists because every
663
+ * Connection Package answers the same RPC; answering it honestly is a
664
+ * refusal, not a silent success.
665
+ */
666
+ leaseModelCredential(): Promise<never> {
667
+ return Promise.reject(new Error("MCP Connections offer no model"));
668
+ }
669
+
670
+ settleModelCredential(): Promise<void> {
671
+ return Promise.resolve();
672
+ }
673
+
674
+ /**
675
+ * One expiring lease over a keyed server's credential, for one mount. The
676
+ * Bot's own host opens it against the keyring; the plaintext never crosses
677
+ * this seam.
678
+ */
679
+ async leaseToolCredential(input: {
680
+ accountId: string;
681
+ connectionId: string;
682
+ effectId: string;
683
+ connectionGeneration: string;
684
+ }): Promise<CredentialLeaseV1> {
685
+ const connection = await this.requireConnection(
686
+ input.accountId,
687
+ input.connectionId,
688
+ );
689
+ if (
690
+ !mcpConnectionCarriesCredentialV1(connection.connectionTypeId) ||
691
+ connection.state !== "ready"
692
+ ) {
693
+ throw new Error("MCP Connection carries no credential");
694
+ }
695
+ if (connection.generation !== input.connectionGeneration) {
696
+ throw new Error("MCP Connection generation changed");
697
+ }
698
+ return this.host.credentials.lease({
699
+ accountId: input.accountId,
700
+ connectionId: input.connectionId,
701
+ packageId: MCP_PACKAGE_ID,
702
+ effectId: input.effectId,
703
+ expiresAt: new Date(this.now() + TOOL_LEASE_MS).toISOString(),
704
+ // The Connection generation and the credential generation are the same
705
+ // thing for a keyed server, and deliberately are not for an OAuth one: a
706
+ // refresh rotates the sealed generation without disturbing the
707
+ // Assignment resolution key a Turn is pinned to.
708
+ expectedGeneration: await this.currentCredentialGeneration(
709
+ input.accountId,
710
+ connection,
711
+ ),
712
+ });
713
+ }
714
+
715
+ /**
716
+ * The credential generation a lease should open, refreshing it first when the
717
+ * access token is about to expire.
718
+ *
719
+ * This is where "refresh on lease open" lives, and it is here rather than in
720
+ * the mount because only this object can see the refresh token. A lease is
721
+ * five minutes long, so a token expiring inside the next minute would expire
722
+ * mid-mount; it is replaced before it is handed over. A failed refresh flips
723
+ * the durable record to `needs-auth` and refuses the lease — the Bot loses
724
+ * the tools, which is visible, rather than calling the server with a dead
725
+ * token, which is not.
726
+ */
727
+ private async currentCredentialGeneration(
728
+ accountId: string,
729
+ connection: ConnectionView,
730
+ ): Promise<string> {
731
+ const generation = connection.generation!;
732
+ if (connection.connectionTypeId !== MCP_OAUTH_CONNECTION_TYPE_ID) {
733
+ return generation;
734
+ }
735
+ const record = await this.readOAuthRecord(connection.connectionId);
736
+ if (!record) return generation;
737
+ const current = record.accessGeneration ?? generation;
738
+ if (
739
+ record.accessExpiresAt === undefined ||
740
+ record.accessExpiresAt - MCP_ACCESS_REFRESH_SKEW_MS_V1 > this.now()
741
+ ) {
742
+ return current;
743
+ }
744
+ try {
745
+ return await this.refreshAccessToken(
746
+ accountId,
747
+ connection.connectionId,
748
+ record,
749
+ );
750
+ } catch (error) {
751
+ const message =
752
+ error instanceof Error ? error.message : "MCP token refresh failed";
753
+ const at = new Date(this.now()).toISOString();
754
+ const server = await this.readServer(connection.connectionId);
755
+ if (server) {
756
+ const next = await this.writeServer({
757
+ ...server,
758
+ state: "needs-auth",
759
+ toolCount: 0,
760
+ lastHandshakeAt: at,
761
+ failure: {
762
+ code: "unauthorized",
763
+ message: message.slice(0, 2_000),
764
+ at,
765
+ },
766
+ }).catch(() => undefined);
767
+ if (next) {
768
+ await this.projectPendingAuthorization(accountId, next, at).catch(
769
+ () => undefined,
770
+ );
771
+ }
772
+ }
773
+ // Classified, not narrated: a refresh that failed is a token the User
774
+ // must replace, and the mount that meets this has to record `needs-auth`
775
+ // rather than "the server is unreachable".
776
+ throw new McpAuthorizationError(
777
+ `MCP access token could not be refreshed: ${message}`,
778
+ );
779
+ }
780
+ }
781
+
782
+ async settleToolCredential(input: {
783
+ accountId: string;
784
+ connectionId: string;
785
+ effectId: string;
786
+ }): Promise<void> {
787
+ await this.host.credentials.settle({
788
+ accountId: input.accountId,
789
+ connectionId: input.connectionId,
790
+ packageId: MCP_PACKAGE_ID,
791
+ effectId: input.effectId,
792
+ });
793
+ }
794
+
795
+ // ------------------------------------------------------------------
796
+ // The `mcp-oauth` grant driver.
797
+ //
798
+ // Every outbound OAuth request in FrockBot happens inside these methods,
799
+ // which is the point of putting them here rather than in the gateway
800
+ // Contribution: this object is the User's authority, it holds the keyring,
801
+ // and it is the only place a token, a refresh token, or a PKCE verifier ever
802
+ // exists. The gateway signs a state and forwards; it performs no OAuth call
803
+ // and stores nothing.
804
+ // ------------------------------------------------------------------
805
+
806
+ /**
807
+ * Mint one authorization. Only an authenticated User action reaches here —
808
+ * a Bot may record a pending decision, never a redirect.
809
+ *
810
+ * Order matters and is the durability argument: the quota is charged, the
811
+ * Connection exists in `authorizing`, and the pending record carrying the
812
+ * PKCE verifier is written, all *before* the URL is returned. A callback that
813
+ * arrives for an authorization this object never recorded finds nothing.
814
+ */
815
+ async startAuthorization(
816
+ accountId: string,
817
+ input: McpAuthorizationStartInputV1,
818
+ ): Promise<StartConnectionResult> {
819
+ await this.chargeAuthorizationStart();
820
+ const redirectUri = this.decodeRedirectUri(input.redirectUri);
821
+ const { connection, settings, oauthSettings } =
822
+ await this.prepareOAuthConnection(accountId, input);
823
+ const generation = connection.generation!;
824
+ try {
825
+ const client = this.oauthClient();
826
+ const resourceMetadata = await client.discoverProtectedResource({
827
+ serverUrl: settings.url,
828
+ });
829
+ const metadata = await client.discoverAuthorizationServer(
830
+ resourceMetadata.authorizationServers[0]!,
831
+ );
832
+ // DCR is the default the specification expects of an MCP client; the
833
+ // `client-id` setting is the fallback for a server that registers its
834
+ // clients out of band. A confidential client is refused inside
835
+ // `register`, with its own durable code.
836
+ const clientId = metadata.registrationEndpoint
837
+ ? (
838
+ await client.register({
839
+ registrationEndpoint: metadata.registrationEndpoint,
840
+ redirectUri,
841
+ ...(oauthSettings.scope ? { scope: oauthSettings.scope } : {}),
842
+ })
843
+ ).clientId
844
+ : oauthSettings.clientId;
845
+ if (!clientId) {
846
+ throw new McpAuthorizationError(
847
+ "The authorization server offers no dynamic client registration, and this Connection names no client-id.",
848
+ "authorization-discovery",
849
+ );
850
+ }
851
+ const scope =
852
+ oauthSettings.scope ?? resourceMetadata.scopesSupported?.join(" ");
853
+ const pkce = await createPkcePairV1();
854
+ const resource = mcpCanonicalResourceV1(settings.url);
855
+ const expiresAt = Math.min(
856
+ input.authorizationStateExpiresAt,
857
+ this.now() + MCP_AUTHORIZATION_TTL_MS_V1,
858
+ );
859
+ const pending: McpOAuthPendingV1 = {
860
+ schemaVersion: 1,
861
+ authorizationStateId: input.authorizationStateId,
862
+ connectionId: connection.connectionId,
863
+ codeVerifier: pkce.codeVerifier,
864
+ clientId,
865
+ tokenEndpoint: metadata.tokenEndpoint,
866
+ ...(metadata.revocationEndpoint
867
+ ? { revocationEndpoint: metadata.revocationEndpoint }
868
+ : {}),
869
+ authorizationEndpoint: metadata.authorizationEndpoint,
870
+ ...(metadata.registrationEndpoint
871
+ ? { registrationEndpoint: metadata.registrationEndpoint }
872
+ : {}),
873
+ issuer: metadata.issuer,
874
+ resource,
875
+ ...(scope ? { scope } : {}),
876
+ redirectUri,
877
+ generation,
878
+ returnTarget: input.returnTarget,
879
+ ...(input.nativeReturnNonce
880
+ ? { nativeReturnNonce: input.nativeReturnNonce }
881
+ : {}),
882
+ expiresAt,
883
+ createdAt: new Date(this.now()).toISOString(),
884
+ };
885
+ await this.host.storage.put(
886
+ mcpOAuthPendingKeyV1(pending.authorizationStateId),
887
+ decodeMcpOAuthPendingV1(pending),
888
+ );
889
+ return {
890
+ schemaVersion: 1,
891
+ status: "authorization-required",
892
+ connectionId: connection.connectionId,
893
+ redirectUrl: mcpAuthorizeUrlV1({
894
+ authorizationEndpoint: metadata.authorizationEndpoint,
895
+ clientId,
896
+ redirectUri,
897
+ state: input.callbackState,
898
+ codeChallenge: pkce.codeChallenge,
899
+ resource,
900
+ ...(scope ? { scope } : {}),
901
+ }),
902
+ expiresAt: new Date(expiresAt).toISOString(),
903
+ ...(input.nativeReturnNonce
904
+ ? { nativeReturnNonce: input.nativeReturnNonce }
905
+ : {}),
906
+ };
907
+ } catch (error) {
908
+ await this.failAuthorization(accountId, connection, error);
909
+ throw error;
910
+ }
911
+ }
912
+
913
+ /**
914
+ * The callback, once the gateway has verified its signed state.
915
+ *
916
+ * The `authorizationStateId` is consumed transactionally: the pending record
917
+ * is read and deleted in one transaction, so a replayed callback — the
918
+ * browser's back button, a retried redirect, an attacker with a copied URL —
919
+ * finds nothing and changes nothing. It is a no-op reporting the Connection's
920
+ * current state, never a second token exchange.
921
+ */
922
+ async completeAuthorization(
923
+ accountId: string,
924
+ input: McpAuthorizationCompletionInputV1,
925
+ ): Promise<ConnectionCompletionResult> {
926
+ const completion = (
927
+ status: "ready" | "pending" | "failed",
928
+ ): ConnectionCompletionResult => ({
929
+ returnTarget: input.returnTarget,
930
+ status,
931
+ ...(input.nativeReturnNonce
932
+ ? { nativeReturnNonce: input.nativeReturnNonce }
933
+ : {}),
934
+ });
935
+ const pending = await this.consumePendingAuthorization(
936
+ input.authorizationStateId,
937
+ input.connectionId,
938
+ );
939
+ if (!pending) {
940
+ const settled = await this.host.settings.getConnection(
941
+ accountId,
942
+ input.connectionId,
943
+ );
944
+ return completion(settled?.state === "ready" ? "ready" : "failed");
945
+ }
946
+ const connection = await this.requireConnection(
947
+ accountId,
948
+ pending.connectionId,
949
+ );
950
+ if (input.error || !input.code) {
951
+ await this.failAuthorization(
952
+ accountId,
953
+ connection,
954
+ new McpAuthorizationError(
955
+ `The authorization server refused: ${(input.error ?? "no authorization code was returned").slice(0, 200)}`,
956
+ ),
957
+ );
958
+ return completion("failed");
959
+ }
960
+ let tokens: McpOAuthTokenSetV1;
961
+ try {
962
+ tokens = await this.oauthClient().exchangeCode({
963
+ tokenEndpoint: pending.tokenEndpoint,
964
+ clientId: pending.clientId,
965
+ code: input.code,
966
+ codeVerifier: pending.codeVerifier,
967
+ redirectUri: pending.redirectUri,
968
+ resource: pending.resource,
969
+ });
970
+ } catch (error) {
971
+ await this.failAuthorization(accountId, connection, error);
972
+ return completion("failed");
973
+ }
974
+ await this.sealTokenSet(accountId, pending.connectionId, {
975
+ generation: pending.generation,
976
+ tokens,
977
+ });
978
+ await this.writeOAuthRecord({
979
+ schemaVersion: 1,
980
+ connectionId: pending.connectionId,
981
+ issuer: pending.issuer,
982
+ authorizationEndpoint: pending.authorizationEndpoint,
983
+ tokenEndpoint: pending.tokenEndpoint,
984
+ ...(pending.registrationEndpoint
985
+ ? { registrationEndpoint: pending.registrationEndpoint }
986
+ : {}),
987
+ ...(pending.revocationEndpoint
988
+ ? { revocationEndpoint: pending.revocationEndpoint }
989
+ : {}),
990
+ clientId: pending.clientId,
991
+ resource: pending.resource,
992
+ ...(pending.scope ? { scope: pending.scope } : {}),
993
+ redirectUri: pending.redirectUri,
994
+ accessGeneration: pending.generation,
995
+ ...(tokens.refreshToken ? { refreshGeneration: pending.generation } : {}),
996
+ ...(tokens.expiresAt === undefined
997
+ ? {}
998
+ : { accessExpiresAt: tokens.expiresAt }),
999
+ updatedAt: new Date(this.now()).toISOString(),
1000
+ });
1001
+ // The handshake is still the validation: a token the server will not accept
1002
+ // leaves the Connection `failed` with the reason, exactly as a bad API key
1003
+ // does. `ready` means `initialize` and `tools/list` both answered.
1004
+ const receipt = await this.validateAndActivate(
1005
+ accountId,
1006
+ `mcp-oauth-complete:${pending.authorizationStateId}`,
1007
+ connection,
1008
+ { kind: "sealed", generation: pending.generation },
1009
+ );
1010
+ return completion(receipt.status === "applied" ? "ready" : "failed");
1011
+ }
1012
+
1013
+ /**
1014
+ * RFC 7009 revocation, then the local teardown.
1015
+ *
1016
+ * A server that advertises no revocation endpoint leaves the Connection
1017
+ * `reconciliation-required`: FrockBot has forgotten the token and the server
1018
+ * has not, and saying `revoked` would be a claim about someone else's state.
1019
+ */
1020
+ async revokeAuthorization(
1021
+ accountId: string,
1022
+ connectionId: string,
1023
+ ): Promise<RevokeConnectionResult> {
1024
+ const connection = await this.requireConnection(accountId, connectionId);
1025
+ if (connection.connectionTypeId !== MCP_OAUTH_CONNECTION_TYPE_ID) {
1026
+ throw new Error("MCP Connection carries no authorization");
1027
+ }
1028
+ const status = await this.revokeOAuthTokens(accountId, connectionId);
1029
+ await this.host.settings.replaceConnection(
1030
+ accountId,
1031
+ connectionId,
1032
+ connection.generation,
1033
+ { ...connection, state: status, failure: undefined },
1034
+ );
1035
+ await this.removeServer(connectionId);
1036
+ return {
1037
+ schemaVersion: 1,
1038
+ status: status === "revoked" ? "revoked" : "reconciliation-required",
1039
+ };
1040
+ }
1041
+
1042
+ /**
1043
+ * Revoke what this object holds, and forget it either way.
1044
+ *
1045
+ * The order is deliberate: the tokens are revoked at the server *first*,
1046
+ * because once the sealed generations are gone there is nothing left to
1047
+ * revoke with. A refused revocation still tears down locally — a token
1048
+ * FrockBot cannot use is not a token FrockBot should keep.
1049
+ */
1050
+ private async revokeOAuthTokens(
1051
+ accountId: string,
1052
+ connectionId: string,
1053
+ ): Promise<"revoked" | "reconciliation-required"> {
1054
+ const record = await this.readOAuthRecord(connectionId);
1055
+ let revoked = false;
1056
+ if (record?.revocationEndpoint) {
1057
+ const client = this.oauthClient();
1058
+ revoked = true;
1059
+ const refreshToken = await this.readRefreshToken(accountId, record);
1060
+ if (refreshToken) {
1061
+ revoked =
1062
+ (await client.revoke({
1063
+ revocationEndpoint: record.revocationEndpoint,
1064
+ token: refreshToken,
1065
+ tokenTypeHint: "refresh_token",
1066
+ clientId: record.clientId,
1067
+ })) && revoked;
1068
+ }
1069
+ }
1070
+ await this.host.credentials.disconnect(connectionId);
1071
+ if (record?.refreshGeneration) {
1072
+ await this.host.credentials
1073
+ .discardPending(
1074
+ mcpRefreshCredentialIdV1(connectionId),
1075
+ record.refreshGeneration,
1076
+ )
1077
+ .catch(() => undefined);
1078
+ }
1079
+ await this.host.storage.delete(mcpOAuthRecordKeyV1(connectionId));
1080
+ return revoked ? "revoked" : "reconciliation-required";
1081
+ }
1082
+
1083
+ /**
1084
+ * The refresh grant, run on the way out of the lease seam.
1085
+ *
1086
+ * Bounded — one attempt, no retry loop — and idempotent in effect: the DO
1087
+ * serializes calls, so two mounts asking at once produce one refresh and two
1088
+ * leases of the same generation. A failure is durable: the record flips to
1089
+ * `needs-auth` and the lease is refused, so the Bot loses the tools rather
1090
+ * than calling a server with a dead token.
1091
+ */
1092
+ private async refreshAccessToken(
1093
+ accountId: string,
1094
+ connectionId: string,
1095
+ record: McpOAuthRecordV1,
1096
+ ): Promise<string> {
1097
+ const refreshToken = await this.readRefreshToken(accountId, record);
1098
+ if (!refreshToken) {
1099
+ throw new McpAuthorizationError(
1100
+ "This MCP Connection holds no refresh token; it must be authorized again.",
1101
+ );
1102
+ }
1103
+ const generation = this.randomId();
1104
+ const tokens = await this.oauthClient().refresh({
1105
+ tokenEndpoint: record.tokenEndpoint,
1106
+ clientId: record.clientId,
1107
+ refreshToken,
1108
+ resource: record.resource,
1109
+ ...(record.scope ? { scope: record.scope } : {}),
1110
+ });
1111
+ await this.sealTokenSet(accountId, connectionId, {
1112
+ generation,
1113
+ tokens,
1114
+ // A server that rotates its refresh token returns a new one; one that
1115
+ // does not expects the old one to keep working, so it is carried
1116
+ // forward rather than dropped.
1117
+ fallbackRefreshToken: refreshToken,
1118
+ });
1119
+ await this.host.credentials.activate({
1120
+ accountId,
1121
+ connectionId,
1122
+ packageId: MCP_PACKAGE_ID,
1123
+ generation,
1124
+ });
1125
+ if (record.refreshGeneration && record.refreshGeneration !== generation) {
1126
+ await this.host.credentials
1127
+ .discardPending(
1128
+ mcpRefreshCredentialIdV1(connectionId),
1129
+ record.refreshGeneration,
1130
+ )
1131
+ .catch(() => undefined);
1132
+ }
1133
+ await this.writeOAuthRecord({
1134
+ ...record,
1135
+ accessGeneration: generation,
1136
+ refreshGeneration: generation,
1137
+ ...(tokens.expiresAt === undefined
1138
+ ? { accessExpiresAt: undefined }
1139
+ : { accessExpiresAt: tokens.expiresAt }),
1140
+ updatedAt: new Date(this.now()).toISOString(),
1141
+ });
1142
+ return generation;
1143
+ }
1144
+
1145
+ /** Seal one token set: the access token leasable, the refresh token not. */
1146
+ private async sealTokenSet(
1147
+ accountId: string,
1148
+ connectionId: string,
1149
+ input: {
1150
+ generation: string;
1151
+ tokens: McpOAuthTokenSetV1;
1152
+ fallbackRefreshToken?: string;
1153
+ },
1154
+ ): Promise<void> {
1155
+ await this.host.credentials.stageApiKey({
1156
+ accountId,
1157
+ connectionId,
1158
+ packageId: MCP_PACKAGE_ID,
1159
+ generation: input.generation,
1160
+ apiKey: input.tokens.accessToken,
1161
+ });
1162
+ const refreshToken =
1163
+ input.tokens.refreshToken ?? input.fallbackRefreshToken;
1164
+ if (!refreshToken) return;
1165
+ // Staged and never activated. `plugin-credentials` leases the *active*
1166
+ // generation of a Connection id and nothing else, so a generation that is
1167
+ // never activated under an id that has no active generation cannot be
1168
+ // leased at all — not merely "is not leased today".
1169
+ await this.host.credentials.stageApiKey({
1170
+ accountId,
1171
+ connectionId: mcpRefreshCredentialIdV1(connectionId),
1172
+ packageId: MCP_PACKAGE_ID,
1173
+ generation: input.generation,
1174
+ apiKey: refreshToken,
1175
+ });
1176
+ }
1177
+
1178
+ private async readRefreshToken(
1179
+ accountId: string,
1180
+ record: McpOAuthRecordV1,
1181
+ ): Promise<string | undefined> {
1182
+ if (!record.refreshGeneration) return undefined;
1183
+ return this.host.credentials
1184
+ .readStagedApiKey({
1185
+ accountId,
1186
+ connectionId: mcpRefreshCredentialIdV1(record.connectionId),
1187
+ packageId: MCP_PACKAGE_ID,
1188
+ generation: record.refreshGeneration,
1189
+ })
1190
+ .catch(() => undefined);
1191
+ }
1192
+
1193
+ private oauthClient(): McpOAuthClient {
1194
+ return new McpOAuthClient({
1195
+ fetch: this.host.fetch ?? boundFetch,
1196
+ now: this.now,
1197
+ });
1198
+ }
1199
+
1200
+ /**
1201
+ * The redirect URI, checked for what it is rather than where it points. It
1202
+ * is never fetched by this object — the authorization server sends the
1203
+ * User's browser to it — so the outbound SSRF classifier is the wrong test;
1204
+ * what matters is that it is an absolute URL with no fragment and no
1205
+ * credentials, so it cannot be turned into an open redirect.
1206
+ */
1207
+ private decodeRedirectUri(value: string): string {
1208
+ const url = URL.parse(value);
1209
+ if (
1210
+ !url ||
1211
+ (url.protocol !== "https:" && url.protocol !== "http:") ||
1212
+ url.username ||
1213
+ url.password ||
1214
+ url.hash ||
1215
+ value.length > 2_048
1216
+ ) {
1217
+ throw new McpAuthorizationError(
1218
+ "MCP authorization callback URL is invalid",
1219
+ "authorization-discovery",
1220
+ );
1221
+ }
1222
+ return url.toString();
1223
+ }
1224
+
1225
+ /** The per-User start quota, in a fixed window, charged before any work. */
1226
+ private async chargeAuthorizationStart(): Promise<void> {
1227
+ const now = this.now();
1228
+ const refused = await this.host.storage.transaction(async (storage) => {
1229
+ const stored = await storage.get<unknown>(MCP_OAUTH_STARTS_KEY);
1230
+ const ledger =
1231
+ stored === undefined
1232
+ ? { schemaVersion: 1 as const, windowStartedAt: now, count: 0 }
1233
+ : decodeMcpAuthorizationStartsV1(stored);
1234
+ const fresh =
1235
+ now - ledger.windowStartedAt >= MCP_AUTHORIZATION_START_WINDOW_MS_V1
1236
+ ? { schemaVersion: 1 as const, windowStartedAt: now, count: 0 }
1237
+ : ledger;
1238
+ if (fresh.count >= MAX_MCP_AUTHORIZATION_STARTS_V1) return true;
1239
+ await storage.put(MCP_OAUTH_STARTS_KEY, {
1240
+ ...fresh,
1241
+ count: fresh.count + 1,
1242
+ });
1243
+ return false;
1244
+ });
1245
+ if (refused) {
1246
+ throw new McpAuthorizationError(
1247
+ `A User may start at most ${MAX_MCP_AUTHORIZATION_STARTS_V1} MCP authorizations an hour.`,
1248
+ // Reused deliberately: a quota refusal is a refusal, and the code is
1249
+ // what a surface branches on.
1250
+ "authorization-discovery",
1251
+ );
1252
+ }
1253
+ }
1254
+
1255
+ /**
1256
+ * The Connection an authorization will land on: a new one in `authorizing`,
1257
+ * or the existing one moved back to `authorizing` on a fresh generation.
1258
+ * Either way the Connection exists durably before the User's browser leaves.
1259
+ */
1260
+ private async prepareOAuthConnection(
1261
+ accountId: string,
1262
+ input: McpAuthorizationStartInputV1,
1263
+ ): Promise<{
1264
+ connection: ConnectionView;
1265
+ settings: ReturnType<typeof decodeMcpConnectionSettingsV1>;
1266
+ oauthSettings: { scope?: string; clientId?: string };
1267
+ }> {
1268
+ const generation = this.randomId();
1269
+ if (input.connectionId) {
1270
+ const current = await this.requireConnection(
1271
+ accountId,
1272
+ input.connectionId,
1273
+ );
1274
+ if (current.connectionTypeId !== MCP_OAUTH_CONNECTION_TYPE_ID) {
1275
+ throw new Error("MCP Connection does not use OAuth");
1276
+ }
1277
+ if (current.state === "revoked" || current.state === "revoking") {
1278
+ throw new Error("MCP Connection is revoked");
1279
+ }
1280
+ const connection = await this.host.settings.replaceConnection(
1281
+ accountId,
1282
+ current.connectionId,
1283
+ current.generation,
1284
+ {
1285
+ ...current,
1286
+ generation,
1287
+ state: "authorizing",
1288
+ failure: undefined,
1289
+ ...(input.label ? { displayName: input.label } : {}),
1290
+ },
1291
+ );
1292
+ return {
1293
+ connection,
1294
+ settings: decodeMcpConnectionSettingsV1(connection.settings),
1295
+ oauthSettings: decodeMcpOAuthSettingsV1(connection.settings),
1296
+ };
1297
+ }
1298
+ const servers = await this.readServerIndex();
1299
+ if (servers.length >= MAX_MCP_SERVERS_PER_USER_V1) {
1300
+ throw new Error(
1301
+ `A User may hold at most ${MAX_MCP_SERVERS_PER_USER_V1} MCP servers`,
1302
+ );
1303
+ }
1304
+ const settingsInput = input.settings ?? {};
1305
+ const settings = decodeMcpConnectionSettingsV1(settingsInput);
1306
+ const oauthSettings = decodeMcpOAuthSettingsV1(settingsInput);
1307
+ const connection: ConnectionView = {
1308
+ // The same derivation the gateway used when it signed the state, so the
1309
+ // callback's Connection and this one are one Connection.
1310
+ connectionId: mcpAuthorizationConnectionIdV1(input.commandId),
1311
+ packageId: MCP_PACKAGE_ID,
1312
+ connectionTypeId: MCP_OAUTH_CONNECTION_TYPE_ID,
1313
+ displayName: input.label ?? "MCP server",
1314
+ state: "authorizing",
1315
+ generation,
1316
+ providerType: "mcp",
1317
+ settings: settingsInput as ConnectionSettingsV1,
1318
+ safeMetadata: {},
1319
+ };
1320
+ await this.host.settings.createConnection(accountId, connection);
1321
+ await this.writeServer({
1322
+ schemaVersion: 1,
1323
+ serverId: connection.connectionId,
1324
+ label: connection.displayName,
1325
+ url: settings.url.toString(),
1326
+ transport: settings.transport,
1327
+ serverEpoch: 1,
1328
+ state: "connecting",
1329
+ toolCount: 0,
1330
+ toolsHash: "",
1331
+ lastHandshakeAt: new Date(this.now()).toISOString(),
1332
+ });
1333
+ return { connection, settings, oauthSettings };
1334
+ }
1335
+
1336
+ /** A failed authorization is durable on both records, never only in a log. */
1337
+ private async failAuthorization(
1338
+ accountId: string,
1339
+ connection: ConnectionView,
1340
+ error: unknown,
1341
+ ): Promise<void> {
1342
+ const message =
1343
+ error instanceof Error ? error.message : "MCP authorization failed";
1344
+ const code = mcpFailureCodeV1(error);
1345
+ const at = new Date(this.now()).toISOString();
1346
+ const existing = await this.readServer(connection.connectionId);
1347
+ if (existing) {
1348
+ await this.writeServer({
1349
+ ...existing,
1350
+ state: code === "unauthorized" ? "needs-auth" : "error",
1351
+ toolCount: 0,
1352
+ lastHandshakeAt: at,
1353
+ failure: { code, message: message.slice(0, 2_000), at },
1354
+ }).catch(() => undefined);
1355
+ }
1356
+ const server = existing
1357
+ ? await this.readServer(connection.connectionId)
1358
+ : undefined;
1359
+ await this.host.settings
1360
+ .replaceConnection(
1361
+ accountId,
1362
+ connection.connectionId,
1363
+ connection.generation,
1364
+ {
1365
+ ...connection,
1366
+ state: "failed",
1367
+ failure: message.slice(0, 2_000),
1368
+ pendingAuthorization: undefined,
1369
+ ...(server?.state === "needs-auth"
1370
+ ? { pendingAuthorization: mcpPendingAuthorizationV1(server, at) }
1371
+ : {}),
1372
+ },
1373
+ )
1374
+ .catch(() => undefined);
1375
+ }
1376
+
1377
+ /**
1378
+ * Take one pending authorization, exactly once.
1379
+ *
1380
+ * The delete is the claim, not the read: `delete` answers whether the key
1381
+ * was there, so of two callbacks racing the same `authorizationStateId`
1382
+ * precisely one is told `true` and proceeds. A read-then-delete pair would
1383
+ * let both through, because every `await` in a Durable Object is a yield.
1384
+ */
1385
+ private async consumePendingAuthorization(
1386
+ authorizationStateId: string,
1387
+ connectionId: string,
1388
+ ): Promise<McpOAuthPendingV1 | undefined> {
1389
+ const key = mcpOAuthPendingKeyV1(authorizationStateId);
1390
+ const stored = await this.host.storage.get<unknown>(key);
1391
+ if (stored === undefined) return undefined;
1392
+ if (!(await this.host.storage.delete(key))) return undefined;
1393
+ const pending = decodeMcpOAuthPendingV1(stored);
1394
+ // The signed state named a Connection; the record names one too. A
1395
+ // callback whose state disagrees with the record it unlocks is refused
1396
+ // rather than applied to whichever of the two is more convenient.
1397
+ if (
1398
+ pending.connectionId !== connectionId ||
1399
+ pending.expiresAt <= this.now()
1400
+ ) {
1401
+ return undefined;
1402
+ }
1403
+ return pending;
1404
+ }
1405
+
1406
+ private async readOAuthRecord(
1407
+ connectionId: string,
1408
+ ): Promise<McpOAuthRecordV1 | undefined> {
1409
+ const stored = await this.host.storage.get<unknown>(
1410
+ mcpOAuthRecordKeyV1(connectionId),
1411
+ );
1412
+ return stored === undefined ? undefined : decodeMcpOAuthRecordV1(stored);
1413
+ }
1414
+
1415
+ private async writeOAuthRecord(record: McpOAuthRecordV1): Promise<void> {
1416
+ await this.host.storage.put(
1417
+ mcpOAuthRecordKeyV1(record.connectionId),
1418
+ decodeMcpOAuthRecordV1(record),
1419
+ );
1420
+ }
1421
+
1422
+ /** Which credential generation a handshake for this Connection opens. */
1423
+ private async handshakeCredential(
1424
+ connection: ConnectionView,
1425
+ ): Promise<McpHandshakeCredentialV1> {
1426
+ if (!mcpConnectionCarriesCredentialV1(connection.connectionTypeId)) {
1427
+ return { kind: "none" };
1428
+ }
1429
+ if (connection.connectionTypeId !== MCP_OAUTH_CONNECTION_TYPE_ID) {
1430
+ return { kind: "sealed", generation: connection.generation! };
1431
+ }
1432
+ const record = await this.readOAuthRecord(connection.connectionId);
1433
+ return {
1434
+ kind: "sealed",
1435
+ generation: record?.accessGeneration ?? connection.generation!,
1436
+ };
1437
+ }
1438
+
1439
+ private async apply(
1440
+ accountId: string,
1441
+ command: ConnectionCommandV1,
1442
+ ): Promise<ConnectionCommandReceiptV1> {
1443
+ switch (command.type) {
1444
+ case "connection/create":
1445
+ case "connection/create-api-key":
1446
+ return this.create(accountId, command);
1447
+ case "connection/rotate-api-key":
1448
+ return this.rotate(accountId, command.commandId, command.connectionId, {
1449
+ apiKey: command.apiKey,
1450
+ });
1451
+ case "connection/update-label": {
1452
+ const connection = await this.requireConnection(
1453
+ accountId,
1454
+ command.connectionId,
1455
+ );
1456
+ await this.host.settings.replaceConnection(
1457
+ accountId,
1458
+ connection.connectionId,
1459
+ connection.generation,
1460
+ { ...connection, displayName: command.label },
1461
+ );
1462
+ // A rename is GrokBot's `RenameMcpAccount`, and it renames the tools
1463
+ // too: the server slug is derived from the label, so the record must
1464
+ // carry the new one or the status would disagree with the tool names.
1465
+ const renamed = await this.readServer(connection.connectionId);
1466
+ if (renamed) {
1467
+ await this.writeServer({ ...renamed, label: command.label });
1468
+ }
1469
+ return this.receipt(command.commandId, connection.connectionId);
1470
+ }
1471
+ case "connection/set-enabled": {
1472
+ const connection = await this.requireConnection(
1473
+ accountId,
1474
+ command.connectionId,
1475
+ );
1476
+ if (connection.state === "revoked" || connection.state === "revoking") {
1477
+ throw new Error("MCP Connection is revoked");
1478
+ }
1479
+ await this.host.settings.replaceConnection(
1480
+ accountId,
1481
+ connection.connectionId,
1482
+ connection.generation,
1483
+ {
1484
+ ...connection,
1485
+ state: command.enabled ? "ready" : "disabled",
1486
+ },
1487
+ );
1488
+ return this.receipt(command.commandId, connection.connectionId);
1489
+ }
1490
+ case "connection/disconnect": {
1491
+ const connection = await this.requireConnection(
1492
+ accountId,
1493
+ command.connectionId,
1494
+ );
1495
+ // An OAuth Connection the User asked to revoke upstream is revoked at
1496
+ // its authorization server before it is forgotten here (RFC 7009). A
1497
+ // server that advertises no revocation endpoint, or refuses, leaves
1498
+ // `reconciliation-required`: FrockBot has forgotten the token and the
1499
+ // server has not, and `revoked` would be a claim about someone else's
1500
+ // state. `revokeUpstream: false` is the User keeping the grant and
1501
+ // dropping only FrockBot's copy, so it takes the ordinary path.
1502
+ let state: "revoked" | "reconciliation-required" = "revoked";
1503
+ if (
1504
+ connection.connectionTypeId === MCP_OAUTH_CONNECTION_TYPE_ID &&
1505
+ command.revokeUpstream
1506
+ ) {
1507
+ state = await this.revokeOAuthTokens(
1508
+ accountId,
1509
+ connection.connectionId,
1510
+ );
1511
+ } else {
1512
+ await this.host.credentials.disconnect(connection.connectionId);
1513
+ }
1514
+ await this.host.settings.replaceConnection(
1515
+ accountId,
1516
+ connection.connectionId,
1517
+ connection.generation,
1518
+ { ...connection, state, failure: undefined },
1519
+ );
1520
+ // GrokBot's `RemoveMcpAccount`: the server is gone, so its record is
1521
+ // gone with it. The Assignments that named it become unavailable
1522
+ // tombstones through the ordinary Connection dependency path.
1523
+ await this.removeServer(connection.connectionId);
1524
+ return this.receipt(command.commandId, connection.connectionId);
1525
+ }
1526
+ case "connection/refresh-models":
1527
+ throw new Error("MCP Connections offer no model catalog");
1528
+ }
1529
+ }
1530
+
1531
+ private async create(
1532
+ accountId: string,
1533
+ command: Extract<
1534
+ ConnectionCommandV1,
1535
+ { type: "connection/create" | "connection/create-api-key" }
1536
+ >,
1537
+ options: { instructions?: string } = {},
1538
+ ): Promise<ConnectionCommandReceiptV1> {
1539
+ const keyed = command.type === "connection/create-api-key";
1540
+ const expectedType = keyed
1541
+ ? MCP_KEYED_CONNECTION_TYPE_ID
1542
+ : MCP_CONNECTION_TYPE_ID;
1543
+ if (command.connectionTypeId !== expectedType) {
1544
+ throw new Error(
1545
+ `MCP Connection Type "${command.connectionTypeId}" does not accept this command`,
1546
+ );
1547
+ }
1548
+ // Decoded before anything durable happens: a URL the SSRF rules refuse is
1549
+ // never recorded as a Connection at all.
1550
+ decodeMcpConnectionSettingsV1(command.settings);
1551
+ const connectionId = `mcp-${this.randomId()}`;
1552
+ const generation = this.randomId();
1553
+ if (keyed) {
1554
+ await this.host.credentials.stageApiKey({
1555
+ accountId,
1556
+ connectionId,
1557
+ packageId: MCP_PACKAGE_ID,
1558
+ generation,
1559
+ apiKey: command.apiKey,
1560
+ });
1561
+ }
1562
+ const connection: ConnectionView = {
1563
+ connectionId,
1564
+ packageId: MCP_PACKAGE_ID,
1565
+ connectionTypeId: command.connectionTypeId,
1566
+ displayName: command.label,
1567
+ state: "authorizing",
1568
+ generation,
1569
+ providerType: "mcp",
1570
+ ...(command.settings === undefined
1571
+ ? {}
1572
+ : { settings: command.settings as ConnectionSettingsV1 }),
1573
+ safeMetadata: {},
1574
+ };
1575
+ await this.host.settings.createConnection(accountId, connection);
1576
+ return this.validateAndActivate(
1577
+ accountId,
1578
+ command.commandId,
1579
+ connection,
1580
+ keyed ? { kind: "sealed", generation } : { kind: "none" },
1581
+ options,
1582
+ );
1583
+ }
1584
+
1585
+ private async rotate(
1586
+ accountId: string,
1587
+ commandId: string,
1588
+ connectionId: string,
1589
+ input: { apiKey: string },
1590
+ ): Promise<ConnectionCommandReceiptV1> {
1591
+ const current = await this.requireConnection(accountId, connectionId);
1592
+ if (current.connectionTypeId !== MCP_KEYED_CONNECTION_TYPE_ID) {
1593
+ throw new Error("MCP Connection carries no credential");
1594
+ }
1595
+ const generation = this.randomId();
1596
+ await this.host.credentials.stageApiKey({
1597
+ accountId,
1598
+ connectionId,
1599
+ packageId: MCP_PACKAGE_ID,
1600
+ generation,
1601
+ apiKey: input.apiKey,
1602
+ });
1603
+ const next = await this.host.settings.replaceConnection(
1604
+ accountId,
1605
+ connectionId,
1606
+ current.generation,
1607
+ { ...current, generation, state: "authorizing", failure: undefined },
1608
+ );
1609
+ return this.validateAndActivate(accountId, commandId, next, {
1610
+ kind: "sealed",
1611
+ generation,
1612
+ });
1613
+ }
1614
+
1615
+ /**
1616
+ * The handshake that decides a Connection's state, and with it the durable
1617
+ * server record.
1618
+ *
1619
+ * Success activates the credential generation and records what the server
1620
+ * said about itself; failure leaves a `failed` Connection and a server
1621
+ * record in `needs-auth` or `error` carrying the reason. Both are durable:
1622
+ * the record is the only place a User can read why a server they added is
1623
+ * offering nothing.
1624
+ */
1625
+ private async validateAndActivate(
1626
+ accountId: string,
1627
+ commandId: string,
1628
+ connection: ConnectionView,
1629
+ credential: McpHandshakeCredentialV1,
1630
+ options: { serverEpoch?: number; instructions?: string } = {},
1631
+ ): Promise<ConnectionCommandReceiptV1> {
1632
+ const generation = connection.generation!;
1633
+ const existing = await this.readServer(connection.connectionId);
1634
+ const serverEpoch = options.serverEpoch ?? existing?.serverEpoch ?? 1;
1635
+ const instructions = options.instructions ?? existing?.instructions;
1636
+ let settings: ReturnType<typeof decodeMcpConnectionSettingsV1> | undefined;
1637
+ let client: McpClient | undefined;
1638
+ try {
1639
+ settings = decodeMcpConnectionSettingsV1(connection.settings);
1640
+ // Durable intent before the request leaves: a handshake that never
1641
+ // returns leaves a record saying so, not silence.
1642
+ await this.writeServer({
1643
+ schemaVersion: 1,
1644
+ serverId: connection.connectionId,
1645
+ label: connection.displayName,
1646
+ url: settings.url.toString(),
1647
+ transport: settings.transport,
1648
+ ...(instructions ? { instructions } : {}),
1649
+ serverEpoch,
1650
+ state: "connecting",
1651
+ toolCount: existing?.toolCount ?? 0,
1652
+ toolsHash: existing?.toolsHash ?? "",
1653
+ lastHandshakeAt: new Date(this.now()).toISOString(),
1654
+ });
1655
+ const apiKey =
1656
+ credential.kind === "sealed"
1657
+ ? await this.openConnectionCredential(
1658
+ accountId,
1659
+ connection,
1660
+ credential.generation,
1661
+ )
1662
+ : undefined;
1663
+ client = new McpClient({
1664
+ url: settings.url,
1665
+ transport: settings.transport,
1666
+ fetch: this.host.fetch ?? boundFetch,
1667
+ ...(apiKey ? { apiKey, headerName: settings.headerName } : {}),
1668
+ maxResponseBytes: MAX_MCP_RESPONSE_BYTES,
1669
+ maxTools: MAX_MCP_TOOLS_PER_SERVER,
1670
+ });
1671
+ const handshake = await client.connect();
1672
+ const tools = await client.listTools();
1673
+ if (credential.kind === "sealed" && connection.state === "authorizing") {
1674
+ await this.host.credentials.activate({
1675
+ accountId,
1676
+ connectionId: connection.connectionId,
1677
+ packageId: MCP_PACKAGE_ID,
1678
+ generation: credential.generation,
1679
+ });
1680
+ }
1681
+ const server = await this.writeServer({
1682
+ schemaVersion: 1,
1683
+ serverId: connection.connectionId,
1684
+ label: connection.displayName,
1685
+ url: settings.url.toString(),
1686
+ transport: settings.transport,
1687
+ ...(instructions ? { instructions } : {}),
1688
+ serverEpoch,
1689
+ state: "ready",
1690
+ protocolVersion: handshake.protocolVersion,
1691
+ toolCount: tools.length,
1692
+ toolsHash: await mcpToolsHashV1(tools),
1693
+ lastHandshakeAt: new Date(this.now()).toISOString(),
1694
+ });
1695
+ await this.host.settings.replaceConnection(
1696
+ accountId,
1697
+ connection.connectionId,
1698
+ generation,
1699
+ {
1700
+ ...connection,
1701
+ state: "ready",
1702
+ failure: undefined,
1703
+ // A handshake that succeeded is the decision being made: the card
1704
+ // goes away in the same write that brings the tools back.
1705
+ pendingAuthorization: undefined,
1706
+ safeMetadata: {
1707
+ ...mcpConnectionMetadataV1(server),
1708
+ ...(handshake.serverName
1709
+ ? { serverName: handshake.serverName }
1710
+ : {}),
1711
+ },
1712
+ },
1713
+ );
1714
+ return this.receipt(commandId, connection.connectionId);
1715
+ } catch (error) {
1716
+ const failure =
1717
+ error instanceof Error ? error.message : "MCP server handshake failed";
1718
+ const code = mcpFailureCodeV1(error);
1719
+ const server = await this.writeServer({
1720
+ schemaVersion: 1,
1721
+ serverId: connection.connectionId,
1722
+ label: connection.displayName,
1723
+ url: settings
1724
+ ? settings.url.toString()
1725
+ : String(connection.settings?.url ?? "about:invalid"),
1726
+ transport: settings?.transport ?? "streamable-http",
1727
+ ...(instructions ? { instructions } : {}),
1728
+ serverEpoch,
1729
+ state: code === "unauthorized" ? "needs-auth" : "error",
1730
+ toolCount: 0,
1731
+ toolsHash: existing?.toolsHash ?? "",
1732
+ lastHandshakeAt: new Date(this.now()).toISOString(),
1733
+ failure: {
1734
+ code,
1735
+ message: failure.slice(0, 2_000),
1736
+ at: new Date(this.now()).toISOString(),
1737
+ },
1738
+ }).catch(() => undefined);
1739
+ await this.host.settings.replaceConnection(
1740
+ accountId,
1741
+ connection.connectionId,
1742
+ generation,
1743
+ {
1744
+ ...connection,
1745
+ state: "failed",
1746
+ failure: failure.slice(0, 2_000),
1747
+ pendingAuthorization: undefined,
1748
+ ...(server?.state === "needs-auth"
1749
+ ? {
1750
+ pendingAuthorization: mcpPendingAuthorizationV1(
1751
+ server,
1752
+ server.lastHandshakeAt,
1753
+ ),
1754
+ }
1755
+ : {}),
1756
+ ...(server ? { safeMetadata: mcpConnectionMetadataV1(server) } : {}),
1757
+ },
1758
+ );
1759
+ return {
1760
+ schemaVersion: 1,
1761
+ commandId,
1762
+ connectionId: connection.connectionId,
1763
+ status: "failed",
1764
+ };
1765
+ } finally {
1766
+ await client?.close().catch(() => undefined);
1767
+ }
1768
+ }
1769
+
1770
+ /**
1771
+ * The plaintext of a keyed server's credential, for a handshake this object
1772
+ * performs itself. It travels the same lease the Bot's host would open: an
1773
+ * effect id, an expiry, and a settle the moment the key is in hand, so a
1774
+ * restart leaves nothing open behind it.
1775
+ */
1776
+ private async openConnectionCredential(
1777
+ accountId: string,
1778
+ connection: ConnectionView,
1779
+ /**
1780
+ * The *credential* generation, which is not always the Connection's own.
1781
+ * A refreshed OAuth access token rotates the sealed generation while the
1782
+ * Connection generation stays put, so a Bot's pinned resolution key does
1783
+ * not change under it mid-Turn.
1784
+ */
1785
+ generation: string,
1786
+ ): Promise<string> {
1787
+ if (connection.state === "authorizing") {
1788
+ return this.host.credentials.readStagedApiKey({
1789
+ accountId,
1790
+ connectionId: connection.connectionId,
1791
+ packageId: MCP_PACKAGE_ID,
1792
+ generation,
1793
+ });
1794
+ }
1795
+ const effectId = `mcp-handshake:${connection.connectionId}:${this.randomId()}`;
1796
+ const lease = await this.host.credentials.lease({
1797
+ accountId,
1798
+ connectionId: connection.connectionId,
1799
+ packageId: MCP_PACKAGE_ID,
1800
+ effectId,
1801
+ expiresAt: new Date(this.now() + TOOL_LEASE_MS).toISOString(),
1802
+ expectedGeneration: generation,
1803
+ });
1804
+ try {
1805
+ return await this.host.credentials.openLease({
1806
+ accountId,
1807
+ packageId: MCP_PACKAGE_ID,
1808
+ lease,
1809
+ });
1810
+ } finally {
1811
+ await this.host.credentials
1812
+ .settle({
1813
+ accountId,
1814
+ connectionId: connection.connectionId,
1815
+ packageId: MCP_PACKAGE_ID,
1816
+ effectId,
1817
+ })
1818
+ .catch(() => undefined);
1819
+ }
1820
+ }
1821
+
1822
+ private receipt(
1823
+ commandId: string,
1824
+ connectionId: string,
1825
+ ): ConnectionCommandReceiptV1 {
1826
+ return {
1827
+ schemaVersion: 1,
1828
+ commandId,
1829
+ connectionId,
1830
+ status: "applied",
1831
+ };
1832
+ }
1833
+
1834
+ private async requireConnection(
1835
+ accountId: string,
1836
+ connectionId: string,
1837
+ ): Promise<ConnectionView> {
1838
+ const connection = await this.host.settings.getConnection(
1839
+ accountId,
1840
+ connectionId,
1841
+ );
1842
+ if (!connection || connection.packageId !== MCP_PACKAGE_ID) {
1843
+ throw new Error("MCP Connection is unavailable");
1844
+ }
1845
+ return connection;
1846
+ }
1847
+
1848
+ private async requireServer(
1849
+ accountId: string,
1850
+ serverId: string,
1851
+ ): Promise<McpServerRecordV1> {
1852
+ await this.requireConnection(accountId, serverId);
1853
+ const server = await this.readServer(serverId);
1854
+ if (!server) throw new Error("MCP server record is unavailable");
1855
+ return server;
1856
+ }
1857
+
1858
+ private async readServer(
1859
+ serverId: string,
1860
+ ): Promise<McpServerRecordV1 | undefined> {
1861
+ const stored = await this.host.storage.get<unknown>(
1862
+ mcpServerRecordKeyV1(serverId),
1863
+ );
1864
+ return stored === undefined ? undefined : decodeMcpServerRecordV1(stored);
1865
+ }
1866
+
1867
+ /**
1868
+ * One record write, index included. The record decodes on the way out as
1869
+ * well as on the way in, so a field this build would refuse to read is
1870
+ * never written in the first place.
1871
+ */
1872
+ private async writeServer(
1873
+ server: McpServerRecordV1,
1874
+ ): Promise<McpServerRecordV1> {
1875
+ const decoded = decodeMcpServerRecordV1(server);
1876
+ await this.host.storage.transaction(async (storage) => {
1877
+ const index = await this.readServerIndex(storage);
1878
+ await storage.put({
1879
+ [mcpServerRecordKeyV1(decoded.serverId)]: decoded,
1880
+ [MCP_SERVER_INDEX_KEY]: index.includes(decoded.serverId)
1881
+ ? index
1882
+ : [...index, decoded.serverId],
1883
+ });
1884
+ });
1885
+ return decoded;
1886
+ }
1887
+
1888
+ private async removeServer(serverId: string): Promise<void> {
1889
+ await this.host.storage.transaction(async (storage) => {
1890
+ const index = await this.readServerIndex(storage);
1891
+ await storage.put(
1892
+ MCP_SERVER_INDEX_KEY,
1893
+ index.filter((value) => value !== serverId),
1894
+ );
1895
+ });
1896
+ await this.host.storage.delete(mcpServerRecordKeyV1(serverId));
1897
+ }
1898
+
1899
+ private async readServerIndex(
1900
+ storage: { get<T>(key: string): Promise<T | undefined> } = this.host
1901
+ .storage,
1902
+ ): Promise<string[]> {
1903
+ const stored = await storage.get<unknown>(MCP_SERVER_INDEX_KEY);
1904
+ return Array.isArray(stored)
1905
+ ? stored.filter((value): value is string => typeof value === "string")
1906
+ : [];
1907
+ }
1908
+
1909
+ private async readRefusalIndex(
1910
+ storage: { get<T>(key: string): Promise<T | undefined> } = this.host
1911
+ .storage,
1912
+ ): Promise<string[]> {
1913
+ const stored = await storage.get<unknown>(MCP_REFUSAL_INDEX_KEY);
1914
+ return Array.isArray(stored)
1915
+ ? stored.filter((value): value is string => typeof value === "string")
1916
+ : [];
1917
+ }
1918
+
1919
+ /** The refusal ledger, bounded: the oldest refusal is the first to go. */
1920
+ private async recordRefusal(refusal: McpRefusalRecordV1): Promise<void> {
1921
+ const evicted = await this.host.storage.transaction(async (storage) => {
1922
+ const index = await this.readRefusalIndex(storage);
1923
+ const next = [...index, refusal.refusalId];
1924
+ const removed = next.splice(
1925
+ 0,
1926
+ Math.max(0, next.length - MAX_MCP_REFUSALS_V1),
1927
+ );
1928
+ await storage.put({
1929
+ [mcpRefusalKeyV1(refusal.refusalId)]: decodeMcpRefusalRecordV1(refusal),
1930
+ [MCP_REFUSAL_INDEX_KEY]: next,
1931
+ });
1932
+ return removed;
1933
+ });
1934
+ for (const refusalId of evicted) {
1935
+ await this.host.storage.delete(mcpRefusalKeyV1(refusalId));
1936
+ }
1937
+ }
1938
+
1939
+ /**
1940
+ * Re-project the record onto the Connection's `safeMetadata`, which is the
1941
+ * seam the Bot Durable Object already reads. The record stays the
1942
+ * authority; this is how the epoch and the instructions reach a mount
1943
+ * without a second cross-object call at Turn time.
1944
+ */
1945
+ private async mirrorConnectionMetadata(
1946
+ accountId: string,
1947
+ serverId: string,
1948
+ ): Promise<void> {
1949
+ const server = await this.readServer(serverId);
1950
+ const connection = await this.requireConnection(accountId, serverId);
1951
+ if (!server) return;
1952
+ await this.host.settings.replaceConnection(
1953
+ accountId,
1954
+ connection.connectionId,
1955
+ connection.generation,
1956
+ {
1957
+ ...connection,
1958
+ // Rebuilt, not merged: clearing the instructions must remove the
1959
+ // key, and a spread of `undefined` would leave it present.
1960
+ safeMetadata: {
1961
+ ...(typeof connection.safeMetadata.serverName === "string"
1962
+ ? { serverName: connection.safeMetadata.serverName }
1963
+ : {}),
1964
+ ...mcpConnectionMetadataV1(server),
1965
+ },
1966
+ },
1967
+ );
1968
+ }
1969
+
1970
+ private async readLifecycleCommand(
1971
+ commandId: string,
1972
+ ): Promise<StoredLifecycleCommand | undefined> {
1973
+ const stored = await this.host.storage.get<unknown>(
1974
+ `${LIFECYCLE_COMMAND_PREFIX}${commandId}`,
1975
+ );
1976
+ return stored === undefined
1977
+ ? undefined
1978
+ : decodeStoredLifecycleCommand(stored);
1979
+ }
1980
+
1981
+ private async recordLifecycleCommand(
1982
+ command: StoredLifecycleCommand,
1983
+ ): Promise<void> {
1984
+ const evicted = await this.host.storage.transaction(async (storage) => {
1985
+ const indexValue = await storage.get<unknown>(
1986
+ LIFECYCLE_COMMAND_INDEX_KEY,
1987
+ );
1988
+ const index = Array.isArray(indexValue)
1989
+ ? indexValue.filter(
1990
+ (value): value is string => typeof value === "string",
1991
+ )
1992
+ : [];
1993
+ const next = [
1994
+ ...index.filter((value) => value !== command.commandId),
1995
+ command.commandId,
1996
+ ];
1997
+ const removed = next.splice(
1998
+ 0,
1999
+ Math.max(0, next.length - MAX_STORED_COMMANDS),
2000
+ );
2001
+ await storage.put({
2002
+ [`${LIFECYCLE_COMMAND_PREFIX}${command.commandId}`]: command,
2003
+ [LIFECYCLE_COMMAND_INDEX_KEY]: next,
2004
+ });
2005
+ return removed;
2006
+ });
2007
+ for (const commandId of evicted) {
2008
+ await this.host.storage.delete(`${LIFECYCLE_COMMAND_PREFIX}${commandId}`);
2009
+ }
2010
+ }
2011
+
2012
+ private async readCommand(
2013
+ commandId: string,
2014
+ ): Promise<StoredCommand | undefined> {
2015
+ const stored = await this.host.storage.get<unknown>(
2016
+ `${COMMAND_PREFIX}${commandId}`,
2017
+ );
2018
+ return stored === undefined ? undefined : decodeStoredCommand(stored);
2019
+ }
2020
+
2021
+ /**
2022
+ * Receipts are retained under a bounded index: a User Durable Object cannot
2023
+ * grow an unbounded key space, and the oldest receipt is the one a client is
2024
+ * least likely to still be asking about.
2025
+ */
2026
+ private async recordCommand(command: StoredCommand): Promise<void> {
2027
+ const evicted = await this.host.storage.transaction(async (storage) => {
2028
+ const indexValue = await storage.get<unknown>(COMMAND_INDEX_KEY);
2029
+ const index = Array.isArray(indexValue)
2030
+ ? indexValue.filter(
2031
+ (value): value is string => typeof value === "string",
2032
+ )
2033
+ : [];
2034
+ const next = [
2035
+ ...index.filter((value) => value !== command.commandId),
2036
+ command.commandId,
2037
+ ];
2038
+ const removed = next.splice(
2039
+ 0,
2040
+ Math.max(0, next.length - MAX_STORED_COMMANDS),
2041
+ );
2042
+ await storage.put({
2043
+ [`${COMMAND_PREFIX}${command.commandId}`]: command,
2044
+ [COMMAND_INDEX_KEY]: next,
2045
+ });
2046
+ return removed;
2047
+ });
2048
+ // Eviction is a separate, idempotent step: the index no longer names these
2049
+ // receipts, so a failure here leaves unreachable keys, never a receipt the
2050
+ // index still promises.
2051
+ for (const commandId of evicted) {
2052
+ await this.host.storage.delete(`${COMMAND_PREFIX}${commandId}`);
2053
+ }
2054
+ }
2055
+ }
2056
+
2057
+ export function createMcpUserBackendContribution(
2058
+ host: McpUserBackendHost,
2059
+ ): McpUserBackendContribution {
2060
+ return new McpUserBackendContribution(host);
2061
+ }
2062
+
2063
+ export function createMcpUserBackendPlugin(
2064
+ host: McpUserBackendHost,
2065
+ lifecycle: { mount(value: McpUserBackendContribution): () => void },
2066
+ ): Plugin {
2067
+ return () => lifecycle.mount(createMcpUserBackendContribution(host));
2068
+ }