@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
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP lifecycle as tools the Bot may use.
|
|
3
|
+
*
|
|
4
|
+
* GrokBot carries `GetMcpServerStatus`, `SetMcpInstructions` and
|
|
5
|
+
* `RestartMcpServers` on every turn type, including automation, and strips
|
|
6
|
+
* `AddMcpServer` from anything that is not a chat turn
|
|
7
|
+
* (`AUTOMATION_PARENT_MEDIATED_MCP_TOOL_NAMES`). FrockBot says the same thing
|
|
8
|
+
* with the machinery it already has: the `mcp-lifecycle` Capability's
|
|
9
|
+
* manifest declares the ceiling, and `mcp_add_server` declares the narrower
|
|
10
|
+
* chat-only admission inside it — adding a server is a User-shaped decision,
|
|
11
|
+
* and an automation turn has no User in front of it.
|
|
12
|
+
*
|
|
13
|
+
* These tools need no Connection: they read and write the User's own MCP
|
|
14
|
+
* records through the host the Bot's Durable Object supplies, which is why
|
|
15
|
+
* this Contribution mounts for a Turn rather than for an Assignment.
|
|
16
|
+
*/
|
|
17
|
+
import type {
|
|
18
|
+
Session,
|
|
19
|
+
ToolDefinition,
|
|
20
|
+
ToolExecutionContext,
|
|
21
|
+
TurnTypeV1,
|
|
22
|
+
} from "@frockbot/kernel-contracts";
|
|
23
|
+
import type { Context, Plugin } from "cordis";
|
|
24
|
+
import type {
|
|
25
|
+
McpLifecycleReceiptV1,
|
|
26
|
+
McpServerStatusViewV1,
|
|
27
|
+
} from "./records.js";
|
|
28
|
+
import { MAX_MCP_INSTRUCTIONS_BYTES_V1 } from "./records.js";
|
|
29
|
+
|
|
30
|
+
/** The manifest's admission ceiling for `mcp-lifecycle`, restated here. */
|
|
31
|
+
export const MCP_LIFECYCLE_TURN_TYPES: readonly TurnTypeV1[] = [
|
|
32
|
+
"chat",
|
|
33
|
+
"automation",
|
|
34
|
+
"subagent",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Adding a server, and asking the User to authorize one, are chat-only; every
|
|
39
|
+
* other lifecycle verb is not. Both are User-shaped decisions, and an
|
|
40
|
+
* automation or subagent Turn has no User in front of it to make one.
|
|
41
|
+
*/
|
|
42
|
+
export const MCP_ADMISSION_TURN_TYPES: readonly TurnTypeV1[] = ["chat"];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The open step a send belongs to. The session log is the reconstruction
|
|
46
|
+
* surface, so a card recorded without its turn and step would not replay in
|
|
47
|
+
* place. The same rule `plugin-shell` applies to `send_to_user`, restated here
|
|
48
|
+
* because this Package records its own send rather than reaching into that one.
|
|
49
|
+
*/
|
|
50
|
+
function openStepPositionV1(
|
|
51
|
+
session: Session,
|
|
52
|
+
tool: string,
|
|
53
|
+
): { turn: number; step: number } {
|
|
54
|
+
const started = session.events.findLast(
|
|
55
|
+
(event) => event.type === "step/start",
|
|
56
|
+
);
|
|
57
|
+
const ended = session.events.findLast((event) => event.type === "step/end");
|
|
58
|
+
if (started?.type !== "step/start") {
|
|
59
|
+
throw new Error(`${tool} has no open step to record against`);
|
|
60
|
+
}
|
|
61
|
+
if (
|
|
62
|
+
ended?.type === "step/end" &&
|
|
63
|
+
ended.turn === started.turn &&
|
|
64
|
+
ended.step === started.step
|
|
65
|
+
) {
|
|
66
|
+
throw new Error(`${tool} has no open step to record against`);
|
|
67
|
+
}
|
|
68
|
+
return { turn: started.turn, step: started.step };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The User authority these tools run with. The Bot never reaches the records
|
|
73
|
+
* itself: its Durable Object carries each call to the User Durable Object
|
|
74
|
+
* that owns them, so a lifecycle tool has exactly the authority the User
|
|
75
|
+
* already granted and no more.
|
|
76
|
+
*/
|
|
77
|
+
export interface McpLifecycleToolHostV1 {
|
|
78
|
+
readStatus(): Promise<McpServerStatusViewV1>;
|
|
79
|
+
execute(command: unknown): Promise<McpLifecycleReceiptV1>;
|
|
80
|
+
/** Fresh command ids, so a retried tool call is one durable effect. */
|
|
81
|
+
randomId?(): string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
85
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function text(input: Record<string, unknown>, field: string): string {
|
|
89
|
+
const value = input[field];
|
|
90
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
91
|
+
throw new Error(`"${field}" is required`);
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function ok(value: unknown): { content: string; isError: boolean } {
|
|
97
|
+
return { content: JSON.stringify(value), isError: false };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function failed(error: unknown): { content: string; isError: boolean } {
|
|
101
|
+
return {
|
|
102
|
+
content:
|
|
103
|
+
error instanceof Error ? error.message : "MCP lifecycle call failed",
|
|
104
|
+
isError: true,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A receipt is a result, never an exception: a refusal — a stdio server, a
|
|
110
|
+
* quota breach — is exactly what the model needs to read back, and throwing
|
|
111
|
+
* it away as an error would hide the reason.
|
|
112
|
+
*/
|
|
113
|
+
function receipt(value: McpLifecycleReceiptV1): {
|
|
114
|
+
content: string;
|
|
115
|
+
isError: boolean;
|
|
116
|
+
} {
|
|
117
|
+
return {
|
|
118
|
+
content: JSON.stringify(value),
|
|
119
|
+
isError: value.status !== "applied",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function createMcpLifecycleRuntimePlugin(
|
|
124
|
+
host: McpLifecycleToolHostV1,
|
|
125
|
+
): Plugin.Function {
|
|
126
|
+
const nextId = () => host.randomId?.() ?? crypto.randomUUID();
|
|
127
|
+
const definitions: ToolDefinition[] = [
|
|
128
|
+
{
|
|
129
|
+
name: "mcp_server_status",
|
|
130
|
+
description:
|
|
131
|
+
"List the User's MCP servers with their state (connecting, ready, needs-auth, error), tool count, last handshake, instructions, and any failure — plus the durable refusal ledger and the MCP quotas.",
|
|
132
|
+
inputSchema: {
|
|
133
|
+
type: "object",
|
|
134
|
+
properties: {},
|
|
135
|
+
additionalProperties: false,
|
|
136
|
+
},
|
|
137
|
+
idempotent: true,
|
|
138
|
+
validate: (input) => input === undefined || isObject(input),
|
|
139
|
+
execute: async () => {
|
|
140
|
+
try {
|
|
141
|
+
return ok(await host.readStatus());
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return failed(error);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "mcp_set_instructions",
|
|
149
|
+
description:
|
|
150
|
+
"Set the instructions attached to one MCP server. They become the description every one of that server's tools carries in the next Turn's model request. An empty string clears them.",
|
|
151
|
+
inputSchema: {
|
|
152
|
+
type: "object",
|
|
153
|
+
properties: {
|
|
154
|
+
server_id: { type: "string" },
|
|
155
|
+
instructions: {
|
|
156
|
+
type: "string",
|
|
157
|
+
maxLength: MAX_MCP_INSTRUCTIONS_BYTES_V1,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
required: ["server_id", "instructions"],
|
|
161
|
+
additionalProperties: false,
|
|
162
|
+
},
|
|
163
|
+
validate: (input) => isObject(input),
|
|
164
|
+
execute: async (input) => {
|
|
165
|
+
try {
|
|
166
|
+
const value = isObject(input) ? input : {};
|
|
167
|
+
const instructions = value.instructions;
|
|
168
|
+
if (typeof instructions !== "string") {
|
|
169
|
+
throw new Error('"instructions" is required');
|
|
170
|
+
}
|
|
171
|
+
return receipt(
|
|
172
|
+
await host.execute({
|
|
173
|
+
schemaVersion: 1,
|
|
174
|
+
type: "mcp/set-instructions",
|
|
175
|
+
commandId: nextId(),
|
|
176
|
+
serverId: text(value, "server_id"),
|
|
177
|
+
instructions,
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return failed(error);
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: "mcp_restart_servers",
|
|
187
|
+
description:
|
|
188
|
+
"Restart one MCP server, or every one of them when no server is named. A restart bumps the server's epoch: the next admitted Turn re-handshakes and re-lists its tools, and the Turn in flight is unaffected.",
|
|
189
|
+
inputSchema: {
|
|
190
|
+
type: "object",
|
|
191
|
+
properties: { server_id: { type: "string" } },
|
|
192
|
+
additionalProperties: false,
|
|
193
|
+
},
|
|
194
|
+
validate: (input) => input === undefined || isObject(input),
|
|
195
|
+
execute: async (input) => {
|
|
196
|
+
try {
|
|
197
|
+
const value = isObject(input) ? input : {};
|
|
198
|
+
const named = value.server_id;
|
|
199
|
+
const serverIds =
|
|
200
|
+
typeof named === "string" && named.length > 0
|
|
201
|
+
? [named]
|
|
202
|
+
: (await host.readStatus()).servers.map(
|
|
203
|
+
(server) => server.serverId,
|
|
204
|
+
);
|
|
205
|
+
const receipts: McpLifecycleReceiptV1[] = [];
|
|
206
|
+
for (const serverId of serverIds) {
|
|
207
|
+
receipts.push(
|
|
208
|
+
await host.execute({
|
|
209
|
+
schemaVersion: 1,
|
|
210
|
+
type: "mcp/restart",
|
|
211
|
+
commandId: nextId(),
|
|
212
|
+
serverId,
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
content: JSON.stringify({ restarted: receipts }),
|
|
218
|
+
isError: receipts.some((value) => value.status !== "applied"),
|
|
219
|
+
};
|
|
220
|
+
} catch (error) {
|
|
221
|
+
return failed(error);
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
name: "mcp_add_server",
|
|
227
|
+
description:
|
|
228
|
+
"Add a remote MCP server by URL. Chat turns only: adding a server is the User's decision and an automation turn has no User in front of it. stdio servers are refused durably — they need a bidirectional pipe on the User's Computer that FrockBot does not offer yet.",
|
|
229
|
+
inputSchema: {
|
|
230
|
+
type: "object",
|
|
231
|
+
properties: {
|
|
232
|
+
label: { type: "string" },
|
|
233
|
+
url: { type: "string" },
|
|
234
|
+
transport: {
|
|
235
|
+
type: "string",
|
|
236
|
+
enum: ["streamable-http", "sse", "stdio"],
|
|
237
|
+
},
|
|
238
|
+
api_key: { type: "string" },
|
|
239
|
+
instructions: {
|
|
240
|
+
type: "string",
|
|
241
|
+
maxLength: MAX_MCP_INSTRUCTIONS_BYTES_V1,
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
required: ["label", "url", "transport"],
|
|
245
|
+
additionalProperties: false,
|
|
246
|
+
},
|
|
247
|
+
admission: { turnTypes: [...MCP_ADMISSION_TURN_TYPES] },
|
|
248
|
+
validate: (input) => isObject(input),
|
|
249
|
+
execute: async (input) => {
|
|
250
|
+
try {
|
|
251
|
+
const value = isObject(input) ? input : {};
|
|
252
|
+
const apiKey = value.api_key;
|
|
253
|
+
const instructions = value.instructions;
|
|
254
|
+
return receipt(
|
|
255
|
+
await host.execute({
|
|
256
|
+
schemaVersion: 1,
|
|
257
|
+
type: "mcp/add-server",
|
|
258
|
+
commandId: nextId(),
|
|
259
|
+
label: text(value, "label"),
|
|
260
|
+
url: text(value, "url"),
|
|
261
|
+
transport: text(value, "transport"),
|
|
262
|
+
...(typeof apiKey === "string" && apiKey.length > 0
|
|
263
|
+
? { apiKey }
|
|
264
|
+
: {}),
|
|
265
|
+
...(typeof instructions === "string" && instructions.length > 0
|
|
266
|
+
? { instructions }
|
|
267
|
+
: {}),
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
return failed(error);
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
];
|
|
276
|
+
definitions.push({
|
|
277
|
+
name: "mcp_authenticate_server",
|
|
278
|
+
description: [
|
|
279
|
+
"Ask the User to authorize one OAuth MCP server that is not connected.",
|
|
280
|
+
"This records a durable pending decision and shows the User a connect card.",
|
|
281
|
+
"It returns no link and no token, and it grants nothing: only the User,",
|
|
282
|
+
"pressing that card, can complete an authorization. Never write an",
|
|
283
|
+
"authorization URL yourself — you cannot have one, and one you composed",
|
|
284
|
+
"would not work. Chat turns only.",
|
|
285
|
+
].join(" "),
|
|
286
|
+
inputSchema: {
|
|
287
|
+
type: "object",
|
|
288
|
+
properties: {
|
|
289
|
+
server_id: { type: "string" },
|
|
290
|
+
reason: { type: "string", maxLength: 2_000 },
|
|
291
|
+
},
|
|
292
|
+
required: ["server_id"],
|
|
293
|
+
additionalProperties: false,
|
|
294
|
+
},
|
|
295
|
+
admission: { turnTypes: [...MCP_ADMISSION_TURN_TYPES] },
|
|
296
|
+
validate: (input) => isObject(input),
|
|
297
|
+
execute: async (input: unknown, context: ToolExecutionContext) => {
|
|
298
|
+
try {
|
|
299
|
+
const value = isObject(input) ? input : {};
|
|
300
|
+
const serverId = text(value, "server_id");
|
|
301
|
+
const status = await host.readStatus();
|
|
302
|
+
const server = status.servers.find(
|
|
303
|
+
(candidate) => candidate.serverId === serverId,
|
|
304
|
+
);
|
|
305
|
+
if (!server) {
|
|
306
|
+
return {
|
|
307
|
+
content: `No MCP server "${serverId}" is available.`,
|
|
308
|
+
isError: true,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
const applied = await host.execute({
|
|
312
|
+
schemaVersion: 1,
|
|
313
|
+
type: "mcp/request-authorization",
|
|
314
|
+
commandId: nextId(),
|
|
315
|
+
serverId,
|
|
316
|
+
});
|
|
317
|
+
if (applied.status !== "applied") return receipt(applied);
|
|
318
|
+
// The card is the User's, drawn by the host from the durable
|
|
319
|
+
// projection. What the Bot supplies is a reason, never a link.
|
|
320
|
+
const emitted = await emitConnectCard(context, {
|
|
321
|
+
connectionId: serverId,
|
|
322
|
+
title: `Connect ${server.label}`,
|
|
323
|
+
...(typeof value.reason === "string" && value.reason.length > 0
|
|
324
|
+
? { body: value.reason.slice(0, 2_000) }
|
|
325
|
+
: {}),
|
|
326
|
+
});
|
|
327
|
+
return {
|
|
328
|
+
content: JSON.stringify({
|
|
329
|
+
...applied,
|
|
330
|
+
pendingAuthorization: true,
|
|
331
|
+
cardShown: emitted,
|
|
332
|
+
}),
|
|
333
|
+
isError: false,
|
|
334
|
+
};
|
|
335
|
+
} catch (error) {
|
|
336
|
+
return failed(error);
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Record the connect card on the durable session log, as a `send/to-user`
|
|
343
|
+
* exactly like `send_to_user` produces — so the thread draws it, the
|
|
344
|
+
* transcript replays it, and no second delivery path exists.
|
|
345
|
+
*/
|
|
346
|
+
async function emitConnectCard(
|
|
347
|
+
context: ToolExecutionContext,
|
|
348
|
+
card: { connectionId: string; title: string; body?: string },
|
|
349
|
+
): Promise<boolean> {
|
|
350
|
+
const session = sessionStore()?.get(context.sessionId);
|
|
351
|
+
if (!session) return false;
|
|
352
|
+
let position: { turn: number; step: number };
|
|
353
|
+
try {
|
|
354
|
+
position = openStepPositionV1(session, "mcp_authenticate_server");
|
|
355
|
+
} catch {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
session.append({
|
|
359
|
+
type: "send/to-user",
|
|
360
|
+
...position,
|
|
361
|
+
occurrenceId: context.effectId,
|
|
362
|
+
payload: { type: "connect-card", ...card },
|
|
363
|
+
});
|
|
364
|
+
await session.flush();
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
let root: Context | undefined;
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The Session store, if this root has one.
|
|
372
|
+
*
|
|
373
|
+
* Not an `inject`: these tools are the User's own MCP records and they work
|
|
374
|
+
* with no Session at all — `mcp_server_status` is answered outside a Turn in
|
|
375
|
+
* several tests and in the lifecycle surface. Only the connect card needs a
|
|
376
|
+
* Session, and without one it degrades to "the decision was recorded, no card
|
|
377
|
+
* was drawn" rather than costing the whole lifecycle its mount.
|
|
378
|
+
*/
|
|
379
|
+
const sessionStore = (): Context["sessions"] | undefined => {
|
|
380
|
+
try {
|
|
381
|
+
return root?.sessions;
|
|
382
|
+
} catch {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const plugin: Plugin.Function = (ctx: Context) => {
|
|
388
|
+
root = ctx;
|
|
389
|
+
const disposers = definitions.map((definition) =>
|
|
390
|
+
ctx.tools.register(definition, {
|
|
391
|
+
admissionCeiling: MCP_LIFECYCLE_TURN_TYPES,
|
|
392
|
+
}),
|
|
393
|
+
);
|
|
394
|
+
return () => {
|
|
395
|
+
for (const dispose of disposers.toReversed()) dispose();
|
|
396
|
+
root = undefined;
|
|
397
|
+
};
|
|
398
|
+
};
|
|
399
|
+
plugin.inject = ["tools"];
|
|
400
|
+
return plugin;
|
|
401
|
+
}
|