@frockbot/plugin-mcp 0.0.0 → 0.1.1
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/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;
|