@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/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
|
+
}
|