@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/records.ts ADDED
@@ -0,0 +1,754 @@
1
+ /**
2
+ * The durable MCP server record, its refusal ledger, and the wire shapes the
3
+ * lifecycle speaks.
4
+ *
5
+ * L2 made a remote MCP server a Connection: the handshake decided whether it
6
+ * was `ready` or `failed`, and a mount that could not reach it simply offered
7
+ * no tools. That is invisible. This module is the durable half GrokBot's
8
+ * `GetMcpServerStatus` projects: one record per server, owned by the User
9
+ * Durable Object beside the Connection, carrying the state a User can act on
10
+ * — `connecting`, `ready`, `needs-auth`, `error` — the instructions that
11
+ * become the server's tool-set description, and the `serverEpoch` a restart
12
+ * bumps.
13
+ *
14
+ * Every shape here decodes strictly. A record read back from storage that
15
+ * carries a key this build does not know is a corrupt record, not a record
16
+ * with an extra field: an MCP server's durable state is small enough that
17
+ * tolerance would only hide a bug.
18
+ */
19
+
20
+ import { McpProtocolError } from "./mcp-client.js";
21
+
22
+ export const MCP_SERVER_RECORD_PREFIX = "mcp-server:";
23
+ export const MCP_SERVER_INDEX_KEY = "mcp-server-index";
24
+ export const MCP_REFUSAL_PREFIX = "mcp-refusal:";
25
+ export const MCP_REFUSAL_INDEX_KEY = "mcp-refusal-index";
26
+
27
+ /** How many refusals the ledger keeps before the oldest is evicted. */
28
+ export const MAX_MCP_REFUSALS_V1 = 32;
29
+ /** The instruction text a User may attach to one server. */
30
+ export const MAX_MCP_INSTRUCTIONS_BYTES_V1 = 4_096;
31
+
32
+ export function mcpServerRecordKeyV1(serverId: string): string {
33
+ return `${MCP_SERVER_RECORD_PREFIX}${serverId}`;
34
+ }
35
+
36
+ export function mcpRefusalKeyV1(refusalId: string): string {
37
+ return `${MCP_REFUSAL_PREFIX}${refusalId}`;
38
+ }
39
+
40
+ /**
41
+ * A server's lifecycle state, which is exactly GrokBot's: `needs-auth` is a
42
+ * server that answered but refused the credential, `error` is one that could
43
+ * not be reached or did not speak the protocol. The two are distinguished
44
+ * because only one of them a User can fix by re-authorizing.
45
+ */
46
+ export type McpServerStateV1 = "connecting" | "ready" | "needs-auth" | "error";
47
+
48
+ export type McpTransportRequestV1 = "streamable-http" | "sse" | "stdio";
49
+
50
+ /**
51
+ * Why a server is not `ready`, or why an operation was refused. Codes, not
52
+ * prose: the message is the server's own words and changes; the code is what
53
+ * a surface and a test may branch on.
54
+ */
55
+ export type McpFailureCodeV1 =
56
+ | "unreachable"
57
+ | "unauthorized"
58
+ | "protocol"
59
+ | "unsupported-transport"
60
+ | "server-quota"
61
+ | "tool-quota"
62
+ | "response-quota"
63
+ // The three the `mcp-oauth` grant driver adds. They are distinct from
64
+ // `unauthorized` on purpose: `unauthorized` is a credential the User can
65
+ // replace, and each of these is a different repair — re-run the connect
66
+ // card, use a different server, or wait.
67
+ | "authorization-discovery"
68
+ | "unsupported-client-authentication"
69
+ | "authorization-quota";
70
+
71
+ const FAILURE_CODES = new Set<string>([
72
+ "unreachable",
73
+ "unauthorized",
74
+ "protocol",
75
+ "unsupported-transport",
76
+ "server-quota",
77
+ "tool-quota",
78
+ "response-quota",
79
+ "authorization-discovery",
80
+ "unsupported-client-authentication",
81
+ "authorization-quota",
82
+ ]);
83
+
84
+ export interface McpServerFailureV1 {
85
+ code: McpFailureCodeV1;
86
+ message: string;
87
+ at: string;
88
+ }
89
+
90
+ export interface McpServerRecordV1 {
91
+ schemaVersion: 1;
92
+ /** The Connection's own id. One Connection is one server; there is no second identity. */
93
+ serverId: string;
94
+ label: string;
95
+ url: string;
96
+ transport: "streamable-http" | "sse";
97
+ instructions?: string;
98
+ /**
99
+ * Bumped by `mcp/restart`. It participates in the Assignment's resolution
100
+ * key, so the next admitted Turn resolves a different mount and
101
+ * re-handshakes; the in-flight Turn keeps the client it already has.
102
+ */
103
+ serverEpoch: number;
104
+ state: McpServerStateV1;
105
+ protocolVersion?: string;
106
+ toolCount: number;
107
+ toolsHash: string;
108
+ lastHandshakeAt: string;
109
+ failure?: McpServerFailureV1;
110
+ }
111
+
112
+ /**
113
+ * A durable refusal: an operation the User asked for that this build will not
114
+ * perform. A stdio server and a seventeenth server both land here, because
115
+ * "the request left no trace" is the one outcome a durable system may not
116
+ * produce.
117
+ */
118
+ export interface McpRefusalRecordV1 {
119
+ schemaVersion: 1;
120
+ refusalId: string;
121
+ commandId: string;
122
+ code: McpFailureCodeV1;
123
+ message: string;
124
+ at: string;
125
+ label?: string;
126
+ url?: string;
127
+ transport?: McpTransportRequestV1;
128
+ }
129
+
130
+ function record(value: unknown, label: string): Record<string, unknown> {
131
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
132
+ throw new Error(`${label} is invalid`);
133
+ }
134
+ return value as Record<string, unknown>;
135
+ }
136
+
137
+ function exact(
138
+ value: Record<string, unknown>,
139
+ allowed: readonly string[],
140
+ label: string,
141
+ ): void {
142
+ const permitted = new Set(allowed);
143
+ for (const key of Object.keys(value)) {
144
+ if (!permitted.has(key)) {
145
+ throw new Error(`${label} carries unknown field "${key}"`);
146
+ }
147
+ }
148
+ }
149
+
150
+ function text(value: unknown, label: string, maximum: number): string {
151
+ if (
152
+ typeof value !== "string" ||
153
+ value.length === 0 ||
154
+ value.length > maximum
155
+ ) {
156
+ throw new Error(`${label} is invalid`);
157
+ }
158
+ return value;
159
+ }
160
+
161
+ function timestamp(value: unknown, label: string): string {
162
+ const parsed = typeof value === "string" ? Date.parse(value) : Number.NaN;
163
+ if (!Number.isFinite(parsed)) throw new Error(`${label} is invalid`);
164
+ return value as string;
165
+ }
166
+
167
+ function count(value: unknown, label: string, maximum: number): number {
168
+ if (
169
+ typeof value !== "number" ||
170
+ !Number.isInteger(value) ||
171
+ value < 0 ||
172
+ value > maximum
173
+ ) {
174
+ throw new Error(`${label} is invalid`);
175
+ }
176
+ return value;
177
+ }
178
+
179
+ function failure(value: unknown): McpServerFailureV1 {
180
+ const item = record(value, "MCP server failure");
181
+ exact(item, ["code", "message", "at"], "MCP server failure");
182
+ if (typeof item.code !== "string" || !FAILURE_CODES.has(item.code)) {
183
+ throw new Error("MCP server failure code is invalid");
184
+ }
185
+ return {
186
+ code: item.code as McpFailureCodeV1,
187
+ message: text(item.message, "MCP server failure message", 2_000),
188
+ at: timestamp(item.at, "MCP server failure timestamp"),
189
+ };
190
+ }
191
+
192
+ export function decodeMcpServerRecordV1(input: unknown): McpServerRecordV1 {
193
+ const value = record(input, "MCP server record");
194
+ exact(
195
+ value,
196
+ [
197
+ "schemaVersion",
198
+ "serverId",
199
+ "label",
200
+ "url",
201
+ "transport",
202
+ "instructions",
203
+ "serverEpoch",
204
+ "state",
205
+ "protocolVersion",
206
+ "toolCount",
207
+ "toolsHash",
208
+ "lastHandshakeAt",
209
+ "failure",
210
+ ],
211
+ "MCP server record",
212
+ );
213
+ if (value.schemaVersion !== 1) {
214
+ throw new Error("MCP server record schemaVersion is unsupported");
215
+ }
216
+ if (value.transport !== "streamable-http" && value.transport !== "sse") {
217
+ throw new Error("MCP server record transport is invalid");
218
+ }
219
+ if (
220
+ value.state !== "connecting" &&
221
+ value.state !== "ready" &&
222
+ value.state !== "needs-auth" &&
223
+ value.state !== "error"
224
+ ) {
225
+ throw new Error("MCP server record state is invalid");
226
+ }
227
+ return {
228
+ schemaVersion: 1,
229
+ serverId: text(value.serverId, "MCP server id", 128),
230
+ label: text(value.label, "MCP server label", 120),
231
+ url: text(value.url, "MCP server url", 2_048),
232
+ transport: value.transport,
233
+ ...(value.instructions === undefined
234
+ ? {}
235
+ : {
236
+ instructions: text(
237
+ value.instructions,
238
+ "MCP server instructions",
239
+ MAX_MCP_INSTRUCTIONS_BYTES_V1,
240
+ ),
241
+ }),
242
+ serverEpoch: count(value.serverEpoch, "MCP server epoch", 1_000_000),
243
+ state: value.state,
244
+ ...(value.protocolVersion === undefined
245
+ ? {}
246
+ : {
247
+ protocolVersion: text(
248
+ value.protocolVersion,
249
+ "MCP protocol version",
250
+ 64,
251
+ ),
252
+ }),
253
+ toolCount: count(value.toolCount, "MCP server tool count", 4_096),
254
+ toolsHash:
255
+ value.toolsHash === "" ? "" : text(value.toolsHash, "MCP tools hash", 64),
256
+ lastHandshakeAt: timestamp(value.lastHandshakeAt, "MCP handshake time"),
257
+ ...(value.failure === undefined ? {} : { failure: failure(value.failure) }),
258
+ };
259
+ }
260
+
261
+ export function decodeMcpRefusalRecordV1(input: unknown): McpRefusalRecordV1 {
262
+ const value = record(input, "MCP refusal record");
263
+ exact(
264
+ value,
265
+ [
266
+ "schemaVersion",
267
+ "refusalId",
268
+ "commandId",
269
+ "code",
270
+ "message",
271
+ "at",
272
+ "label",
273
+ "url",
274
+ "transport",
275
+ ],
276
+ "MCP refusal record",
277
+ );
278
+ if (value.schemaVersion !== 1) {
279
+ throw new Error("MCP refusal record schemaVersion is unsupported");
280
+ }
281
+ if (typeof value.code !== "string" || !FAILURE_CODES.has(value.code)) {
282
+ throw new Error("MCP refusal code is invalid");
283
+ }
284
+ if (
285
+ value.transport !== undefined &&
286
+ value.transport !== "streamable-http" &&
287
+ value.transport !== "sse" &&
288
+ value.transport !== "stdio"
289
+ ) {
290
+ throw new Error("MCP refusal transport is invalid");
291
+ }
292
+ return {
293
+ schemaVersion: 1,
294
+ refusalId: text(value.refusalId, "MCP refusal id", 128),
295
+ commandId: text(value.commandId, "MCP refusal commandId", 128),
296
+ code: value.code as McpFailureCodeV1,
297
+ message: text(value.message, "MCP refusal message", 2_000),
298
+ at: timestamp(value.at, "MCP refusal timestamp"),
299
+ ...(value.label === undefined
300
+ ? {}
301
+ : { label: text(value.label, "MCP refusal label", 120) }),
302
+ ...(value.url === undefined
303
+ ? {}
304
+ : { url: text(value.url, "MCP refusal url", 2_048) }),
305
+ ...(value.transport === undefined
306
+ ? {}
307
+ : { transport: value.transport as McpTransportRequestV1 }),
308
+ };
309
+ }
310
+
311
+ /**
312
+ * The status projection: GrokBot's `GetMcpServerStatus`, plus the refusal
313
+ * ledger and the quotas every one of them is held to, so a surface can show a
314
+ * User both what their servers are doing and what the ceiling is.
315
+ */
316
+ export interface McpServerStatusViewV1 {
317
+ schemaVersion: 1;
318
+ servers: McpServerRecordV1[];
319
+ refusals: McpRefusalRecordV1[];
320
+ quotas: {
321
+ maxServers: number;
322
+ maxToolsPerServer: number;
323
+ maxResponseBytes: number;
324
+ };
325
+ }
326
+
327
+ export function decodeMcpServerStatusViewV1(
328
+ input: unknown,
329
+ ): McpServerStatusViewV1 {
330
+ const value = record(input, "MCP status view");
331
+ exact(
332
+ value,
333
+ ["schemaVersion", "servers", "refusals", "quotas"],
334
+ "MCP status view",
335
+ );
336
+ if (value.schemaVersion !== 1) {
337
+ throw new Error("MCP status view schemaVersion is unsupported");
338
+ }
339
+ if (!Array.isArray(value.servers) || !Array.isArray(value.refusals)) {
340
+ throw new Error("MCP status view is invalid");
341
+ }
342
+ const quotas = record(value.quotas, "MCP status quotas");
343
+ exact(
344
+ quotas,
345
+ ["maxServers", "maxToolsPerServer", "maxResponseBytes"],
346
+ "MCP status quotas",
347
+ );
348
+ return {
349
+ schemaVersion: 1,
350
+ servers: value.servers.map(decodeMcpServerRecordV1),
351
+ refusals: value.refusals.map(decodeMcpRefusalRecordV1),
352
+ quotas: {
353
+ maxServers: count(quotas.maxServers, "maxServers", 4_096),
354
+ maxToolsPerServer: count(
355
+ quotas.maxToolsPerServer,
356
+ "maxToolsPerServer",
357
+ 4_096,
358
+ ),
359
+ maxResponseBytes: count(
360
+ quotas.maxResponseBytes,
361
+ "maxResponseBytes",
362
+ 1_073_741_824,
363
+ ),
364
+ },
365
+ };
366
+ }
367
+
368
+ export interface McpAddServerCommandV1 {
369
+ schemaVersion: 1;
370
+ type: "mcp/add-server";
371
+ commandId: string;
372
+ label: string;
373
+ url: string;
374
+ transport: McpTransportRequestV1;
375
+ apiKey?: string;
376
+ headerName?: string;
377
+ instructions?: string;
378
+ }
379
+
380
+ export interface McpSetInstructionsCommandV1 {
381
+ schemaVersion: 1;
382
+ type: "mcp/set-instructions";
383
+ commandId: string;
384
+ serverId: string;
385
+ /** An empty string clears the instructions; absence is not how you unset. */
386
+ instructions: string;
387
+ }
388
+
389
+ export interface McpRestartCommandV1 {
390
+ schemaVersion: 1;
391
+ type: "mcp/restart";
392
+ commandId: string;
393
+ serverId: string;
394
+ }
395
+
396
+ /**
397
+ * GrokBot's `AuthenticateMcpServer`, as the constitution requires it to be: a
398
+ * Bot records a durable pending decision for its User and receives no link, no
399
+ * token and no grant. Chat-only, because an automation Turn has no User in
400
+ * front of it to decide.
401
+ */
402
+ export interface McpRequestAuthorizationCommandV1 {
403
+ schemaVersion: 1;
404
+ type: "mcp/request-authorization";
405
+ commandId: string;
406
+ serverId: string;
407
+ }
408
+
409
+ export type McpLifecycleCommandV1 =
410
+ | McpAddServerCommandV1
411
+ | McpSetInstructionsCommandV1
412
+ | McpRestartCommandV1
413
+ | McpRequestAuthorizationCommandV1;
414
+
415
+ export function decodeMcpLifecycleCommandV1(
416
+ input: unknown,
417
+ ): McpLifecycleCommandV1 {
418
+ const value = record(input, "MCP lifecycle command");
419
+ if (value.schemaVersion !== 1) {
420
+ throw new Error("MCP lifecycle command schemaVersion is unsupported");
421
+ }
422
+ const commandId = text(value.commandId, "commandId", 128);
423
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(commandId)) {
424
+ throw new Error("MCP lifecycle commandId is invalid");
425
+ }
426
+ switch (value.type) {
427
+ case "mcp/add-server": {
428
+ exact(
429
+ value,
430
+ [
431
+ "schemaVersion",
432
+ "type",
433
+ "commandId",
434
+ "label",
435
+ "url",
436
+ "transport",
437
+ ...(Object.hasOwn(value, "apiKey") ? ["apiKey"] : []),
438
+ ...(Object.hasOwn(value, "headerName") ? ["headerName"] : []),
439
+ ...(Object.hasOwn(value, "instructions") ? ["instructions"] : []),
440
+ ],
441
+ "mcp/add-server",
442
+ );
443
+ if (
444
+ value.transport !== "streamable-http" &&
445
+ value.transport !== "sse" &&
446
+ value.transport !== "stdio"
447
+ ) {
448
+ throw new Error("MCP transport is invalid");
449
+ }
450
+ return {
451
+ schemaVersion: 1,
452
+ type: "mcp/add-server",
453
+ commandId,
454
+ label: text(value.label, "label", 120),
455
+ url: text(value.url, "url", 2_048),
456
+ transport: value.transport,
457
+ ...(value.apiKey === undefined
458
+ ? {}
459
+ : { apiKey: text(value.apiKey, "apiKey", 16_384) }),
460
+ ...(value.headerName === undefined
461
+ ? {}
462
+ : { headerName: text(value.headerName, "headerName", 128) }),
463
+ ...(value.instructions === undefined
464
+ ? {}
465
+ : {
466
+ instructions: text(
467
+ value.instructions,
468
+ "instructions",
469
+ MAX_MCP_INSTRUCTIONS_BYTES_V1,
470
+ ),
471
+ }),
472
+ };
473
+ }
474
+ case "mcp/set-instructions": {
475
+ exact(
476
+ value,
477
+ ["schemaVersion", "type", "commandId", "serverId", "instructions"],
478
+ "mcp/set-instructions",
479
+ );
480
+ if (
481
+ typeof value.instructions !== "string" ||
482
+ value.instructions.length > MAX_MCP_INSTRUCTIONS_BYTES_V1
483
+ ) {
484
+ throw new Error("MCP instructions are invalid");
485
+ }
486
+ return {
487
+ schemaVersion: 1,
488
+ type: "mcp/set-instructions",
489
+ commandId,
490
+ serverId: text(value.serverId, "serverId", 128),
491
+ instructions: value.instructions,
492
+ };
493
+ }
494
+ case "mcp/restart": {
495
+ exact(
496
+ value,
497
+ ["schemaVersion", "type", "commandId", "serverId"],
498
+ "mcp/restart",
499
+ );
500
+ return {
501
+ schemaVersion: 1,
502
+ type: "mcp/restart",
503
+ commandId,
504
+ serverId: text(value.serverId, "serverId", 128),
505
+ };
506
+ }
507
+ case "mcp/request-authorization": {
508
+ exact(
509
+ value,
510
+ ["schemaVersion", "type", "commandId", "serverId"],
511
+ "mcp/request-authorization",
512
+ );
513
+ return {
514
+ schemaVersion: 1,
515
+ type: "mcp/request-authorization",
516
+ commandId,
517
+ serverId: text(value.serverId, "serverId", 128),
518
+ };
519
+ }
520
+ default:
521
+ throw new Error(
522
+ `MCP lifecycle command "${String(value.type)}" is unknown`,
523
+ );
524
+ }
525
+ }
526
+
527
+ export interface McpLifecycleReceiptV1 {
528
+ schemaVersion: 1;
529
+ commandId: string;
530
+ status: "applied" | "refused" | "failed";
531
+ serverId?: string;
532
+ code?: McpFailureCodeV1;
533
+ failure?: string;
534
+ }
535
+
536
+ export function decodeMcpLifecycleReceiptV1(
537
+ input: unknown,
538
+ ): McpLifecycleReceiptV1 {
539
+ const value = record(input, "MCP lifecycle receipt");
540
+ exact(
541
+ value,
542
+ ["schemaVersion", "commandId", "status", "serverId", "code", "failure"],
543
+ "MCP lifecycle receipt",
544
+ );
545
+ if (value.schemaVersion !== 1) {
546
+ throw new Error("MCP lifecycle receipt schemaVersion is unsupported");
547
+ }
548
+ if (
549
+ value.status !== "applied" &&
550
+ value.status !== "refused" &&
551
+ value.status !== "failed"
552
+ ) {
553
+ throw new Error("MCP lifecycle receipt status is invalid");
554
+ }
555
+ if (
556
+ value.code !== undefined &&
557
+ (typeof value.code !== "string" || !FAILURE_CODES.has(value.code))
558
+ ) {
559
+ throw new Error("MCP lifecycle receipt code is invalid");
560
+ }
561
+ return {
562
+ schemaVersion: 1,
563
+ commandId: text(value.commandId, "commandId", 128),
564
+ status: value.status,
565
+ ...(value.serverId === undefined
566
+ ? {}
567
+ : { serverId: text(value.serverId, "serverId", 128) }),
568
+ ...(value.code === undefined
569
+ ? {}
570
+ : { code: value.code as McpFailureCodeV1 }),
571
+ ...(value.failure === undefined
572
+ ? {}
573
+ : { failure: text(value.failure, "failure", 2_000) }),
574
+ };
575
+ }
576
+
577
+ /**
578
+ * What one mount of a server found, as it crosses from the Bot Durable Object
579
+ * to the User Durable Object that owns the record. Decoded at the seam like
580
+ * every other inbound value: a Bot may report an outcome, never a record.
581
+ */
582
+ export interface McpMountOutcomeReportV1 {
583
+ connectionId: string;
584
+ serverEpoch?: number;
585
+ state: "ready" | "needs-auth" | "error";
586
+ failure?: { code: McpFailureCodeV1; message: string };
587
+ protocolVersion?: string;
588
+ toolCount?: number;
589
+ toolsHash?: string;
590
+ }
591
+
592
+ export function decodeMcpMountOutcomeV1(
593
+ input: unknown,
594
+ ): McpMountOutcomeReportV1 {
595
+ const value = record(input, "MCP mount outcome");
596
+ exact(
597
+ value,
598
+ [
599
+ "connectionId",
600
+ "serverEpoch",
601
+ "state",
602
+ "failure",
603
+ "protocolVersion",
604
+ "toolCount",
605
+ "toolsHash",
606
+ ],
607
+ "MCP mount outcome",
608
+ );
609
+ if (
610
+ value.state !== "ready" &&
611
+ value.state !== "needs-auth" &&
612
+ value.state !== "error"
613
+ ) {
614
+ throw new Error("MCP mount outcome state is invalid");
615
+ }
616
+ let reported: { code: McpFailureCodeV1; message: string } | undefined;
617
+ if (value.failure !== undefined) {
618
+ const item = record(value.failure, "MCP mount failure");
619
+ exact(item, ["code", "message"], "MCP mount failure");
620
+ if (typeof item.code !== "string" || !FAILURE_CODES.has(item.code)) {
621
+ throw new Error("MCP mount failure code is invalid");
622
+ }
623
+ reported = {
624
+ code: item.code as McpFailureCodeV1,
625
+ message: text(item.message, "MCP mount failure message", 2_000),
626
+ };
627
+ }
628
+ return {
629
+ connectionId: text(value.connectionId, "MCP mount connectionId", 128),
630
+ ...(value.serverEpoch === undefined
631
+ ? {}
632
+ : {
633
+ serverEpoch: count(value.serverEpoch, "MCP server epoch", 1_000_000),
634
+ }),
635
+ state: value.state,
636
+ ...(reported ? { failure: reported } : {}),
637
+ ...(value.protocolVersion === undefined
638
+ ? {}
639
+ : {
640
+ protocolVersion: text(
641
+ value.protocolVersion,
642
+ "MCP protocol version",
643
+ 64,
644
+ ),
645
+ }),
646
+ ...(value.toolCount === undefined
647
+ ? {}
648
+ : { toolCount: count(value.toolCount, "MCP tool count", 4_096) }),
649
+ ...(value.toolsHash === undefined
650
+ ? {}
651
+ : { toolsHash: text(value.toolsHash, "MCP tools hash", 64) }),
652
+ };
653
+ }
654
+
655
+ /**
656
+ * What one Assignment of `mcp-tools` resolves to. The `serverEpoch` is in it,
657
+ * which is the whole of restart semantics: a restart changes this key, the
658
+ * next admitted Turn resolves a different mount and re-handshakes, and the
659
+ * in-flight Turn — already holding its client — is untouched.
660
+ */
661
+ export function mcpAssignmentResolutionKeyV1(input: {
662
+ connectionId: string;
663
+ connectionGeneration?: string;
664
+ serverEpoch?: number;
665
+ }): string {
666
+ return [
667
+ "mcp",
668
+ input.connectionId,
669
+ input.connectionGeneration ?? "no-generation",
670
+ String(input.serverEpoch ?? 0),
671
+ ].join(":");
672
+ }
673
+
674
+ /**
675
+ * The record fields mirrored onto the Connection's `safeMetadata`, so the Bot
676
+ * Durable Object reads the epoch and the instructions over the seam it
677
+ * already has. The record in the User Durable Object stays the authority;
678
+ * this is a projection of it.
679
+ */
680
+ export function mcpConnectionMetadataV1(
681
+ server: McpServerRecordV1,
682
+ ): Record<string, string | number | boolean> {
683
+ return {
684
+ serverEpoch: server.serverEpoch,
685
+ serverState: server.state,
686
+ toolCount: server.toolCount,
687
+ toolsHash: server.toolsHash,
688
+ ...(server.protocolVersion
689
+ ? { protocolVersion: server.protocolVersion }
690
+ : {}),
691
+ ...(server.instructions ? { instructions: server.instructions } : {}),
692
+ };
693
+ }
694
+
695
+ /**
696
+ * The Connection-level projection of a server that needs authorizing.
697
+ *
698
+ * The record in the User Durable Object is the authority; this is the small,
699
+ * URL-free shape a client draws a connect card from. There is no redirect in
700
+ * it and there never will be: a Bot may write this, and only an authenticated
701
+ * User action mints a link.
702
+ */
703
+ export function mcpPendingAuthorizationV1(
704
+ server: McpServerRecordV1,
705
+ at: string,
706
+ ): {
707
+ reason: string;
708
+ since: string;
709
+ connectionId: string;
710
+ label: string;
711
+ } {
712
+ return {
713
+ reason: "needs-auth",
714
+ since: at,
715
+ connectionId: server.serverId,
716
+ label: server.label,
717
+ };
718
+ }
719
+
720
+ /**
721
+ * Which failure a thrown handshake was. `needs-auth` and `error` are
722
+ * different repairs — one is a credential the User can replace, the other is
723
+ * a server that is not answering — so the classification is durable rather
724
+ * than left to whoever reads the message.
725
+ */
726
+ export function mcpFailureCodeV1(error: unknown): McpFailureCodeV1 {
727
+ // An authorization failure classifies itself: the driver already knows
728
+ // whether the server published no metadata, demanded a client secret, or
729
+ // simply refused the grant, and re-deriving that from a message would be a
730
+ // second, worse classifier.
731
+ if (
732
+ error instanceof Error &&
733
+ "code" in error &&
734
+ typeof (error as { code?: unknown }).code === "string" &&
735
+ FAILURE_CODES.has((error as { code: string }).code)
736
+ ) {
737
+ return (error as { code: McpFailureCodeV1 }).code;
738
+ }
739
+ if (
740
+ error instanceof Error &&
741
+ (error as { code?: unknown }).code === "authorization-failed"
742
+ ) {
743
+ return "unauthorized";
744
+ }
745
+ const status = error instanceof McpProtocolError ? error.status : undefined;
746
+ if (status === 401 || status === 403) return "unauthorized";
747
+ const message = error instanceof Error ? error.message : "";
748
+ if (/more than \d+ tools/.test(message)) return "tool-quota";
749
+ if (message.includes("response is too large")) return "response-quota";
750
+ if (error instanceof McpProtocolError && status === undefined) {
751
+ return "protocol";
752
+ }
753
+ return "unreachable";
754
+ }