@frockbot/plugin-mcp 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/agent.ts ADDED
@@ -0,0 +1,516 @@
1
+ /**
2
+ * The runtime Contribution: one enabled Assignment of `mcp-tools` becomes one
3
+ * remote MCP server's tools in the Bot's tool registry.
4
+ *
5
+ * Everything the Bot ever sees of a server passes through here, and every byte
6
+ * of it is bounded. The server is contacted with the Package's own `fetch`, so
7
+ * the outbound seam the deployment controls is the only way out; the API key,
8
+ * when there is one, arrives as an opaque credential lease and is opened
9
+ * against the keyring the Bot's own host holds.
10
+ */
11
+ import {
12
+ openCredentialV1,
13
+ parseCredentialKeyringV1,
14
+ type CredentialLeaseV1,
15
+ } from "@frockbot/connection-core";
16
+ import type { ConnectionView } from "@frockbot/configuration-core";
17
+ import type { ToolDefinition, TurnTypeV1 } from "@frockbot/kernel-contracts";
18
+ import type { Context, Plugin } from "cordis";
19
+ import {
20
+ McpClient,
21
+ MAX_MCP_RESPONSE_BYTES,
22
+ MAX_MCP_TOOLS_PER_SERVER,
23
+ type McpFetch,
24
+ type McpToolDeclarationV1,
25
+ type McpTransportV1,
26
+ } from "./mcp-client.js";
27
+ import { mcpAuthorizationRequiredV1 } from "./oauth.js";
28
+ import { decodeOutboundMcpUrlV1 } from "./ssrf.js";
29
+ import {
30
+ mcpAssignmentResolutionKeyV1,
31
+ mcpFailureCodeV1,
32
+ MAX_MCP_INSTRUCTIONS_BYTES_V1,
33
+ type McpFailureCodeV1,
34
+ type McpServerStatusViewV1,
35
+ } from "./records.js";
36
+
37
+ /**
38
+ * The global `fetch`, bound. A bare reference to it throws "Illegal
39
+ * invocation" inside a Durable Object, where the built-in checks its
40
+ * receiver.
41
+ */
42
+ const boundFetch: McpFetch = (input, init) => fetch(input, init);
43
+
44
+ export const MCP_PACKAGE_ID = "mcp";
45
+ export const MCP_CAPABILITY_ID = "mcp-tools";
46
+ export const MCP_CONNECTION_TYPE_ID = "mcp-remote";
47
+ export const MCP_KEYED_CONNECTION_TYPE_ID = "mcp-remote-key";
48
+ export const MCP_OAUTH_CONNECTION_TYPE_ID = "mcp-remote-oauth";
49
+
50
+ /** The manifest's admission ceiling, restated where registration happens. */
51
+ export const MCP_TOOL_TURN_TYPES: readonly TurnTypeV1[] = [
52
+ "chat",
53
+ "automation",
54
+ "subagent",
55
+ ];
56
+
57
+ /**
58
+ * A remote MCP server's tools are general work tools: an `executor` subagent
59
+ * gets them, and the narrow roles do not.
60
+ */
61
+ export const MCP_TOOL_SUBAGENT_ROLES: readonly string[] = ["executor"];
62
+
63
+ /**
64
+ * The durable per-User ceiling on remote MCP servers. Counted over the enabled
65
+ * Assignments of this Package, so a Bot cannot be handed a seventeenth server
66
+ * by adding one more Assignment.
67
+ */
68
+ export const MAX_MCP_SERVERS_PER_USER_V1 = 16;
69
+
70
+ /** What one mount of a server found, as the durable record records it. */
71
+ export interface McpMountOutcomeV1 {
72
+ connectionId: string;
73
+ serverEpoch?: number;
74
+ state: "ready" | "needs-auth" | "error";
75
+ failure?: { code: McpFailureCodeV1; message: string };
76
+ protocolVersion?: string;
77
+ toolCount?: number;
78
+ toolsHash?: string;
79
+ }
80
+
81
+ /**
82
+ * The server's durable lifecycle fields as they reach the Bot: mirrored onto
83
+ * the Connection's `safeMetadata` by the User Durable Object, which owns the
84
+ * record. The Bot never invents them, and an absent mirror is a server that
85
+ * has not been through the lifecycle yet, not a failure.
86
+ */
87
+ export function mcpConnectionLifecycleV1(connection: {
88
+ safeMetadata?: Record<string, unknown>;
89
+ }): { serverEpoch?: number; instructions?: string } {
90
+ const metadata = connection.safeMetadata ?? {};
91
+ const epoch = metadata.serverEpoch;
92
+ const instructions = metadata.instructions;
93
+ return {
94
+ ...(typeof epoch === "number" && Number.isInteger(epoch) && epoch >= 0
95
+ ? { serverEpoch: epoch }
96
+ : {}),
97
+ ...(typeof instructions === "string" &&
98
+ instructions.length > 0 &&
99
+ instructions.length <= MAX_MCP_INSTRUCTIONS_BYTES_V1
100
+ ? { instructions }
101
+ : {}),
102
+ };
103
+ }
104
+
105
+ export interface McpConnectionSettingsV1 {
106
+ url: URL;
107
+ transport: McpTransportV1;
108
+ headerName: string;
109
+ }
110
+
111
+ /**
112
+ * Decode the Connection-scoped settings the manifest declares. Anything the
113
+ * SSRF rules refuse, or a transport this build does not speak, is a refusal
114
+ * here rather than a request that leaves the Durable Object.
115
+ */
116
+ export function decodeMcpConnectionSettingsV1(
117
+ settings: Record<string, unknown> | undefined,
118
+ ): McpConnectionSettingsV1 {
119
+ const url = decodeOutboundMcpUrlV1(settings?.url);
120
+ const transport = settings?.transport ?? "streamable-http";
121
+ if (transport !== "streamable-http" && transport !== "sse") {
122
+ throw new Error("MCP transport is unsupported");
123
+ }
124
+ const headerName = settings?.["header-name"] ?? "Authorization";
125
+ if (
126
+ typeof headerName !== "string" ||
127
+ !/^[A-Za-z0-9-]{1,128}$/.test(headerName)
128
+ ) {
129
+ throw new Error("MCP key header name is invalid");
130
+ }
131
+ return { url, transport, headerName };
132
+ }
133
+
134
+ export interface McpOAuthConnectionSettingsV1 {
135
+ /** The scopes to request; absent takes the server's advertised default. */
136
+ scope?: string;
137
+ /** A pre-registered *public* client id, for a server without RFC 7591. */
138
+ clientId?: string;
139
+ }
140
+
141
+ /**
142
+ * The two settings only the `mcp-remote-oauth` Connection Type declares.
143
+ *
144
+ * There is deliberately no `client-secret`. A confidential client's secret
145
+ * would have to live in `ConnectionView.settings`, which is a projection every
146
+ * client of this User reads; the driver refuses such a server durably instead.
147
+ */
148
+ export function decodeMcpOAuthSettingsV1(
149
+ settings: Record<string, unknown> | undefined,
150
+ ): McpOAuthConnectionSettingsV1 {
151
+ const scope = settings?.scope;
152
+ if (
153
+ scope !== undefined &&
154
+ (typeof scope !== "string" || scope.length > 1_024)
155
+ ) {
156
+ throw new Error("MCP OAuth scope is invalid");
157
+ }
158
+ const clientId = settings?.["client-id"];
159
+ if (
160
+ clientId !== undefined &&
161
+ (typeof clientId !== "string" ||
162
+ clientId.length === 0 ||
163
+ clientId.length > 512)
164
+ ) {
165
+ throw new Error("MCP OAuth client-id is invalid");
166
+ }
167
+ return {
168
+ ...(typeof scope === "string" && scope.length > 0 ? { scope } : {}),
169
+ ...(typeof clientId === "string" ? { clientId } : {}),
170
+ };
171
+ }
172
+
173
+ /**
174
+ * The name one server's tool is offered to the model under:
175
+ * `mcp__<server>__<tool>`. The server segment comes from the Connection's own
176
+ * label, so a User who renames a server renames its tools; the tool segment is
177
+ * the server's name with everything a model tool name may not carry replaced.
178
+ */
179
+ export function mcpToolNameV1(serverSlug: string, toolName: string): string {
180
+ const tool = toolName.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);
181
+ return `mcp__${serverSlug}__${tool}`;
182
+ }
183
+
184
+ /** The server segment of a tool name, derived from the Connection. */
185
+ export function mcpServerSlugV1(connection: {
186
+ connectionId: string;
187
+ displayName?: string;
188
+ }): string {
189
+ const fromLabel = (connection.displayName ?? "")
190
+ .toLowerCase()
191
+ .replace(/[^a-z0-9]+/g, "-")
192
+ .replace(/^-+|-+$/g, "")
193
+ .slice(0, 32);
194
+ if (fromLabel) return fromLabel.replace(/-/g, "_");
195
+ return connection.connectionId.replace(/[^a-zA-Z0-9]/g, "_").slice(0, 32);
196
+ }
197
+
198
+ function isObject(value: unknown): value is Record<string, unknown> {
199
+ return typeof value === "object" && value !== null && !Array.isArray(value);
200
+ }
201
+
202
+ export interface McpRuntimeContributionConfig {
203
+ assignment: {
204
+ packageId: string;
205
+ capabilityId: string;
206
+ connectionId?: string;
207
+ state: string;
208
+ };
209
+ /**
210
+ * This Assignment's ordinal among the enabled Assignments of this Package,
211
+ * which is what makes the per-User server ceiling countable from inside a
212
+ * per-Assignment factory.
213
+ */
214
+ assignmentIndex?: number;
215
+ userId: string;
216
+ readSecret(name: string): string | undefined;
217
+ authorizeConnection(): Promise<ConnectionView>;
218
+ /** The Package's own outbound seam. */
219
+ fetch?: McpFetch;
220
+ /** The credential lease for a keyed server, from the User's authority. */
221
+ leaseCredential?(
222
+ effectId: string,
223
+ expectedGeneration?: string,
224
+ ): Promise<CredentialLeaseV1>;
225
+ settleCredential?(effectId: string): Promise<void>;
226
+ /**
227
+ * Where a mount failure goes in-process: a Bot whose server is unreachable
228
+ * is offered no tools rather than a broken one.
229
+ */
230
+ onFailure?(reason: string): void;
231
+ /**
232
+ * Where a mount outcome goes durably. The User Durable Object owns the
233
+ * server record, so this is how an unreachable server or a refused
234
+ * credential becomes a visible `error`/`needs-auth` on the User's own
235
+ * surface instead of a Bot that quietly lost its tools.
236
+ */
237
+ onOutcome?(outcome: McpMountOutcomeV1): Promise<void> | void;
238
+ now?: () => number;
239
+ randomId?: () => string;
240
+ }
241
+
242
+ /**
243
+ * Resolve one Assignment into a mounted runtime Plugin. The handshake and
244
+ * `tools/list` happen here, before the Plugin mounts, so a server that cannot
245
+ * be reached contributes nothing instead of half-registering.
246
+ */
247
+ export async function createConfiguredMcpRuntimeContribution(
248
+ config: McpRuntimeContributionConfig,
249
+ ): Promise<Plugin.Function | undefined> {
250
+ if (
251
+ config.assignment.packageId !== MCP_PACKAGE_ID ||
252
+ config.assignment.capabilityId !== MCP_CAPABILITY_ID ||
253
+ config.assignment.state !== "enabled" ||
254
+ !config.assignment.connectionId
255
+ ) {
256
+ return undefined;
257
+ }
258
+ if ((config.assignmentIndex ?? 0) >= MAX_MCP_SERVERS_PER_USER_V1) {
259
+ config.onFailure?.(
260
+ `A User may assign at most ${MAX_MCP_SERVERS_PER_USER_V1} MCP servers`,
261
+ );
262
+ return undefined;
263
+ }
264
+ const fetchImpl = config.fetch ?? boundFetch;
265
+ let client: McpClient | undefined;
266
+ let lifecycle: { serverEpoch?: number; instructions?: string } = {};
267
+ const connectionId = config.assignment.connectionId;
268
+ try {
269
+ const connection = await config.authorizeConnection();
270
+ lifecycle = mcpConnectionLifecycleV1(connection);
271
+ if (connection.state !== "ready") {
272
+ throw new Error("MCP Connection is not ready");
273
+ }
274
+ const settings = decodeMcpConnectionSettingsV1(connection.settings);
275
+ const apiKey =
276
+ connection.connectionTypeId === MCP_KEYED_CONNECTION_TYPE_ID ||
277
+ connection.connectionTypeId === MCP_OAUTH_CONNECTION_TYPE_ID
278
+ ? await openAssignedCredential(config, connection)
279
+ : undefined;
280
+ client = new McpClient({
281
+ url: settings.url,
282
+ transport: settings.transport,
283
+ fetch: fetchImpl,
284
+ ...(apiKey
285
+ ? {
286
+ apiKey,
287
+ // An OAuth access token is a bearer token by definition; only a
288
+ // keyed server gets to name the header its key travels in.
289
+ headerName:
290
+ connection.connectionTypeId === MCP_OAUTH_CONNECTION_TYPE_ID
291
+ ? "Authorization"
292
+ : settings.headerName,
293
+ }
294
+ : {}),
295
+ maxResponseBytes: MAX_MCP_RESPONSE_BYTES,
296
+ maxTools: MAX_MCP_TOOLS_PER_SERVER,
297
+ });
298
+ const handshake = await client.connect();
299
+ const tools = await client.listTools();
300
+ try {
301
+ await config.onOutcome?.({
302
+ connectionId,
303
+ ...(lifecycle.serverEpoch === undefined
304
+ ? {}
305
+ : { serverEpoch: lifecycle.serverEpoch }),
306
+ state: "ready",
307
+ protocolVersion: handshake.protocolVersion,
308
+ toolCount: tools.length,
309
+ });
310
+ } catch {
311
+ // The record is a projection, not the authority for this mount: a User
312
+ // Durable Object that cannot be reached must not cost the Bot the tools
313
+ // its server just listed.
314
+ }
315
+ return createMcpToolPlugin({
316
+ client,
317
+ serverSlug: mcpServerSlugV1(connection),
318
+ serverLabel: connection.displayName,
319
+ tools,
320
+ // A server that revokes a token mid-Turn answers the *call* with a 401,
321
+ // not the mount. Without this the Bot would see one failed tool result
322
+ // and the User would see a healthy server: the durable record has to
323
+ // learn about it from wherever it happens.
324
+ onCallFailure: async (error) => {
325
+ if (!mcpAuthorizationRequiredV1(error)) return;
326
+ try {
327
+ await config.onOutcome?.({
328
+ connectionId,
329
+ ...(lifecycle.serverEpoch === undefined
330
+ ? {}
331
+ : { serverEpoch: lifecycle.serverEpoch }),
332
+ state: "needs-auth",
333
+ failure: {
334
+ code: "unauthorized",
335
+ message:
336
+ error instanceof Error
337
+ ? error.message
338
+ : "MCP server requires authorization",
339
+ },
340
+ });
341
+ } catch {
342
+ // Best effort from inside a Turn: a User Durable Object that cannot
343
+ // be reached must not turn a failed tool call into a failed Turn.
344
+ }
345
+ },
346
+ ...(lifecycle.instructions
347
+ ? { instructions: lifecycle.instructions }
348
+ : {}),
349
+ });
350
+ } catch (error) {
351
+ await client?.close().catch(() => undefined);
352
+ const message =
353
+ error instanceof Error ? error.message : "MCP server is unavailable";
354
+ config.onFailure?.(message);
355
+ const code = mcpFailureCodeV1(error);
356
+ try {
357
+ await config.onOutcome?.({
358
+ connectionId,
359
+ ...(lifecycle.serverEpoch === undefined
360
+ ? {}
361
+ : { serverEpoch: lifecycle.serverEpoch }),
362
+ state: code === "unauthorized" ? "needs-auth" : "error",
363
+ failure: { code, message },
364
+ });
365
+ } catch {
366
+ // The record is best-effort from inside a mount: a User Durable Object
367
+ // that cannot be reached must not turn a missing tool into a failed
368
+ // Turn.
369
+ }
370
+ return undefined;
371
+ }
372
+ }
373
+
374
+ /**
375
+ * What one Assignment of `mcp-tools` resolves to, including the server's
376
+ * `serverEpoch`. A restart bumps the epoch, so this key changes, so the next
377
+ * admitted Turn resolves a different mount and re-handshakes — while the
378
+ * in-flight Turn, holding the client it already mounted, is untouched.
379
+ */
380
+ export function mcpAssignmentResolutionV1(config: {
381
+ assignment: { connectionId?: string };
382
+ connection: { generation?: string; safeMetadata?: Record<string, unknown> };
383
+ }): string {
384
+ const lifecycle = mcpConnectionLifecycleV1(config.connection);
385
+ return mcpAssignmentResolutionKeyV1({
386
+ connectionId: config.assignment.connectionId ?? "",
387
+ ...(config.connection.generation === undefined
388
+ ? {}
389
+ : { connectionGeneration: config.connection.generation }),
390
+ ...(lifecycle.serverEpoch === undefined
391
+ ? {}
392
+ : { serverEpoch: lifecycle.serverEpoch }),
393
+ });
394
+ }
395
+
396
+ async function openAssignedCredential(
397
+ config: McpRuntimeContributionConfig,
398
+ connection: ConnectionView,
399
+ ): Promise<string> {
400
+ if (!config.leaseCredential) {
401
+ throw new Error("MCP credential lease is unavailable");
402
+ }
403
+ const serialized = config.readSecret("CREDENTIAL_KEYRING");
404
+ if (!serialized) throw new Error("Credential keyring is unavailable");
405
+ const effectId = `mcp-mount:${connection.connectionId}:${
406
+ config.randomId?.() ?? crypto.randomUUID()
407
+ }`;
408
+ const lease = await config.leaseCredential(effectId, connection.generation);
409
+ // The lease the User Durable Object issued names the credential generation
410
+ // it actually opened — which for a refreshed OAuth token is not the
411
+ // Connection's generation. The mount takes the lease's word for it, because
412
+ // the User Durable Object is the authority for which generation is current.
413
+ try {
414
+ if (
415
+ lease.effectId !== effectId ||
416
+ lease.connectionId !== connection.connectionId ||
417
+ Date.parse(lease.expiresAt) <= (config.now ?? Date.now)()
418
+ ) {
419
+ throw new Error("MCP credential lease is invalid");
420
+ }
421
+ return await openCredentialV1({
422
+ keyring: parseCredentialKeyringV1(serialized),
423
+ context: {
424
+ accountId: config.userId,
425
+ connectionId: connection.connectionId,
426
+ packageId: MCP_PACKAGE_ID,
427
+ credentialGeneration: lease.credentialGeneration,
428
+ },
429
+ envelope: lease.envelope,
430
+ });
431
+ } finally {
432
+ // The lease is a one-shot authorization to open the credential, not a
433
+ // handle held for the life of the mount: it is settled the moment the key
434
+ // is in hand, so an evicted Durable Object leaves nothing open.
435
+ await (config.settleCredential?.(effectId) ?? Promise.resolve()).catch(
436
+ () => undefined,
437
+ );
438
+ }
439
+ }
440
+
441
+ /** One tool's description, with the server's instructions attached to it. */
442
+ export function mcpToolDescriptionV1(input: {
443
+ declaration: { name: string; description?: string };
444
+ serverLabel: string;
445
+ instructions?: string;
446
+ }): string {
447
+ const base =
448
+ input.declaration.description ??
449
+ `${input.declaration.name} on the MCP server "${input.serverLabel}".`;
450
+ return input.instructions
451
+ ? `${base}\n\nInstructions for "${input.serverLabel}": ${input.instructions}`
452
+ : base;
453
+ }
454
+
455
+ export function createMcpToolPlugin(config: {
456
+ client: McpClient;
457
+ serverSlug: string;
458
+ serverLabel: string;
459
+ tools: readonly McpToolDeclarationV1[];
460
+ /**
461
+ * GrokBot's `SetMcpInstructions`. There is no "tool set" in a model
462
+ * request — `model/request.tools` is a flat list — so the instructions
463
+ * become the description every one of this server's tools carries. That
464
+ * puts them in the exact normalized request the session log records, which
465
+ * is the only place a User can prove the model was told.
466
+ */
467
+ instructions?: string;
468
+ /**
469
+ * Where a mid-Turn failure goes durably. Called for every failed call; the
470
+ * caller decides which ones are worth recording.
471
+ */
472
+ onCallFailure?(error: unknown): Promise<void> | void;
473
+ }): Plugin.Function {
474
+ const plugin: Plugin.Function = (ctx: Context) => {
475
+ const disposers = config.tools.map((declaration) => {
476
+ const definition: ToolDefinition = {
477
+ name: mcpToolNameV1(config.serverSlug, declaration.name),
478
+ description: mcpToolDescriptionV1({
479
+ declaration,
480
+ serverLabel: config.serverLabel,
481
+ ...(config.instructions ? { instructions: config.instructions } : {}),
482
+ }),
483
+ inputSchema: declaration.inputSchema,
484
+ validate: (input: unknown) => input === undefined || isObject(input),
485
+ execute: async (input: unknown) => {
486
+ try {
487
+ const result = await config.client.callTool(
488
+ declaration.name,
489
+ isObject(input) ? input : {},
490
+ );
491
+ return { content: result.content, isError: result.isError };
492
+ } catch (error) {
493
+ await config.onCallFailure?.(error);
494
+ return {
495
+ content:
496
+ error instanceof Error ? error.message : "MCP tool call failed",
497
+ isError: true,
498
+ };
499
+ }
500
+ },
501
+ };
502
+ return ctx.tools.register(definition, {
503
+ admissionCeiling: MCP_TOOL_TURN_TYPES,
504
+ subagentRoleCeiling: MCP_TOOL_SUBAGENT_ROLES,
505
+ });
506
+ });
507
+ return () => {
508
+ for (const dispose of disposers.toReversed()) dispose();
509
+ void config.client.close();
510
+ };
511
+ };
512
+ plugin.inject = ["tools"];
513
+ return plugin;
514
+ }
515
+
516
+ export default createConfiguredMcpRuntimeContribution;