@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/frockbot.json +183 -0
- package/package.json +41 -6
- package/src/agent.test.ts +409 -0
- package/src/agent.ts +516 -0
- package/src/backend.test.ts +333 -0
- package/src/backend.ts +490 -0
- package/src/connect-card.test.ts +226 -0
- package/src/index.ts +7 -0
- package/src/lifecycle-tools.test.ts +182 -0
- package/src/lifecycle-tools.ts +401 -0
- package/src/lifecycle.test.ts +504 -0
- package/src/manifest.ts +3 -0
- package/src/mcp-client.test.ts +389 -0
- package/src/mcp-client.ts +645 -0
- package/src/oauth-records.ts +330 -0
- package/src/oauth-user.test.ts +776 -0
- package/src/oauth.test.ts +433 -0
- package/src/oauth.ts +747 -0
- package/src/records.test.ts +331 -0
- package/src/records.ts +754 -0
- package/src/ssrf.test.ts +38 -0
- package/src/ssrf.ts +44 -0
- package/src/user.test.ts +390 -0
- package/src/user.ts +2068 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
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
|
+
}
|