@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.
@@ -0,0 +1,504 @@
1
+ /**
2
+ * The MCP lifecycle against the User Contribution that owns it: the durable
3
+ * record's states, the epoch a restart bumps, the instructions a Turn will
4
+ * carry, and the two refusals this build records rather than performs.
5
+ */
6
+ import { describe, expect, test } from "bun:test";
7
+ import {
8
+ createCredentialUserBackendContribution,
9
+ type CredentialStorage,
10
+ type CredentialTransaction,
11
+ } from "@frockbot/plugin-credentials/user";
12
+ import {
13
+ createUserSettingsBackendContribution,
14
+ type UserSettingsStorage,
15
+ type UserSettingsTransaction,
16
+ } from "@frockbot/plugin-settings/user";
17
+ import { MAX_MCP_SERVERS_PER_USER_V1 } from "./agent.js";
18
+ import { createMcpUserBackendContribution } from "./user.js";
19
+
20
+ const ACCOUNT = "account-1";
21
+ const URL_TEXT = "https://mcp.example.test/mcp";
22
+ const GOOD_KEY = "good-key";
23
+
24
+ class MemoryStorage implements UserSettingsStorage, CredentialStorage {
25
+ readonly values = new Map<string, unknown>();
26
+
27
+ get<T>(key: string): Promise<T | undefined> {
28
+ return Promise.resolve(this.values.get(key) as T | undefined);
29
+ }
30
+
31
+ put<T>(key: string, value: T): Promise<void>;
32
+ put(entries: Record<string, unknown>): Promise<void>;
33
+ put<T>(
34
+ keyOrEntries: string | Record<string, unknown>,
35
+ value?: T,
36
+ ): Promise<void> {
37
+ if (typeof keyOrEntries === "string") this.values.set(keyOrEntries, value);
38
+ else {
39
+ for (const [key, entry] of Object.entries(keyOrEntries)) {
40
+ this.values.set(key, entry);
41
+ }
42
+ }
43
+ return Promise.resolve();
44
+ }
45
+
46
+ delete(key: string): Promise<boolean> {
47
+ return Promise.resolve(this.values.delete(key));
48
+ }
49
+
50
+ async transaction<T>(
51
+ callback: (
52
+ storage: UserSettingsTransaction & CredentialTransaction,
53
+ ) => Promise<T>,
54
+ ): Promise<T> {
55
+ const before = new Map(this.values);
56
+ try {
57
+ return await callback(this);
58
+ } catch (error) {
59
+ this.values.clear();
60
+ for (const [key, entry] of before) this.values.set(key, entry);
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ getAlarm(): Promise<number | null> {
66
+ return Promise.resolve(null);
67
+ }
68
+
69
+ setAlarm(): Promise<void> {
70
+ return Promise.resolve();
71
+ }
72
+ }
73
+
74
+ function keyring(): string {
75
+ const bytes = Uint8Array.from({ length: 32 }, (_, index) => index + 7);
76
+ let binary = "";
77
+ for (const byte of bytes) binary += String.fromCharCode(byte);
78
+ return JSON.stringify({
79
+ schemaVersion: 1,
80
+ currentKeyId: "primary",
81
+ keys: {
82
+ primary: btoa(binary)
83
+ .replaceAll("+", "-")
84
+ .replaceAll("/", "_")
85
+ .replace(/=+$/, ""),
86
+ },
87
+ });
88
+ }
89
+
90
+ interface ServerOptions {
91
+ goodKey?: string;
92
+ handshakes?: { count: number };
93
+ /** Flip to make a server that was reachable stop answering. */
94
+ down?: { value: boolean };
95
+ }
96
+
97
+ function mcpServer(options: ServerOptions = {}): typeof fetch {
98
+ return (async (input: string | URL | Request, init?: RequestInit) => {
99
+ if (options.down?.value) {
100
+ return new Response("gone", { status: 503 });
101
+ }
102
+ const headers = new Headers(init?.headers);
103
+ if (
104
+ options.goodKey !== undefined &&
105
+ headers.get("authorization") !== `Bearer ${options.goodKey}`
106
+ ) {
107
+ return new Response("Unauthorized", { status: 401 });
108
+ }
109
+ const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
110
+ if (body.id === undefined) return new Response("", { status: 202 });
111
+ if (body.method === "initialize" && options.handshakes) {
112
+ options.handshakes.count += 1;
113
+ }
114
+ return Response.json({
115
+ jsonrpc: "2.0",
116
+ id: body.id,
117
+ result:
118
+ body.method === "initialize"
119
+ ? {
120
+ protocolVersion: "2025-06-18",
121
+ capabilities: { tools: {} },
122
+ serverInfo: { name: "Example" },
123
+ }
124
+ : { tools: [{ name: "echo", inputSchema: { type: "object" } }] },
125
+ });
126
+ }) as typeof fetch;
127
+ }
128
+
129
+ async function fixture(fetchImpl: typeof fetch) {
130
+ const storage = new MemoryStorage();
131
+ const settings = createUserSettingsBackendContribution({
132
+ storage,
133
+ availablePackages: [{ packageId: "mcp", version: "0.0.1" }],
134
+ });
135
+ await settings.executeConfiguration({
136
+ schemaVersion: 1,
137
+ userId: ACCOUNT,
138
+ command: {
139
+ schemaVersion: 1,
140
+ type: "user/install-package",
141
+ commandId: "install-1",
142
+ expectedRevision: 0,
143
+ packageId: "mcp",
144
+ version: "0.0.1",
145
+ },
146
+ });
147
+ const credentials = createCredentialUserBackendContribution({
148
+ storage,
149
+ keyring: keyring(),
150
+ });
151
+ let id = 0;
152
+ const mcp = createMcpUserBackendContribution({
153
+ storage,
154
+ settings,
155
+ credentials,
156
+ fetch: fetchImpl,
157
+ randomId: () => `id-${++id}`,
158
+ });
159
+ const addServer = (overrides: Record<string, unknown> = {}) =>
160
+ mcp.executeLifecycle(ACCOUNT, {
161
+ schemaVersion: 1,
162
+ type: "mcp/add-server",
163
+ commandId: "add-1",
164
+ label: "Example",
165
+ url: URL_TEXT,
166
+ transport: "streamable-http",
167
+ ...overrides,
168
+ });
169
+ return { storage, settings, credentials, mcp, addServer };
170
+ }
171
+
172
+ describe("mcp/add-server", () => {
173
+ test("creates a ready server whose record carries what the handshake learned", async () => {
174
+ const { mcp, addServer } = await fixture(mcpServer());
175
+
176
+ const receipt = await addServer();
177
+ expect(receipt.status).toBe("applied");
178
+
179
+ const status = await mcp.readServerStatus(ACCOUNT);
180
+ expect(status.servers).toHaveLength(1);
181
+ expect(status.servers[0]).toMatchObject({
182
+ serverId: receipt.serverId,
183
+ label: "Example",
184
+ url: URL_TEXT,
185
+ transport: "streamable-http",
186
+ serverEpoch: 1,
187
+ state: "ready",
188
+ protocolVersion: "2025-06-18",
189
+ toolCount: 1,
190
+ });
191
+ expect(status.quotas).toEqual({
192
+ maxServers: MAX_MCP_SERVERS_PER_USER_V1,
193
+ maxToolsPerServer: 64,
194
+ maxResponseBytes: 262_144,
195
+ });
196
+ });
197
+
198
+ test("carries a key, and a key the server refuses leaves the record needs-auth", async () => {
199
+ const { mcp, addServer } = await fixture(mcpServer({ goodKey: GOOD_KEY }));
200
+
201
+ const good = await addServer({ apiKey: GOOD_KEY });
202
+ expect(good.status).toBe("applied");
203
+
204
+ const bad = await mcp.executeLifecycle(ACCOUNT, {
205
+ schemaVersion: 1,
206
+ type: "mcp/add-server",
207
+ commandId: "add-2",
208
+ label: "Wrong key",
209
+ url: URL_TEXT,
210
+ transport: "streamable-http",
211
+ apiKey: "not-the-key",
212
+ });
213
+ expect(bad.status).toBe("failed");
214
+
215
+ const status = await mcp.readServerStatus(ACCOUNT);
216
+ const failed = status.servers.find(
217
+ (server) => server.serverId === bad.serverId,
218
+ );
219
+ expect(failed).toMatchObject({ state: "needs-auth" });
220
+ expect(failed?.failure?.code).toBe("unauthorized");
221
+ });
222
+
223
+ test("a server that is not there leaves the record in error, durably", async () => {
224
+ const down = { value: true };
225
+ const { mcp, addServer } = await fixture(mcpServer({ down }));
226
+
227
+ const receipt = await addServer();
228
+ expect(receipt.status).toBe("failed");
229
+
230
+ const [server] = (await mcp.readServerStatus(ACCOUNT)).servers;
231
+ expect(server).toMatchObject({ state: "error" });
232
+ expect(server?.failure?.code).toBe("unreachable");
233
+ expect(server?.failure?.message).toContain("503");
234
+ });
235
+
236
+ test("refuses stdio durably rather than creating a Connection", async () => {
237
+ const { mcp, settings } = await fixture(mcpServer());
238
+
239
+ const receipt = await mcp.executeLifecycle(ACCOUNT, {
240
+ schemaVersion: 1,
241
+ type: "mcp/add-server",
242
+ commandId: "add-stdio",
243
+ label: "Beeper",
244
+ url: "stdio://beeper",
245
+ transport: "stdio",
246
+ });
247
+
248
+ expect(receipt).toMatchObject({
249
+ status: "refused",
250
+ code: "unsupported-transport",
251
+ });
252
+ const status = await mcp.readServerStatus(ACCOUNT);
253
+ expect(status.servers).toHaveLength(0);
254
+ // The refusal is the durable trace: the request left one, the Connection
255
+ // list did not gain a server that could never connect.
256
+ expect(status.refusals[0]).toMatchObject({
257
+ code: "unsupported-transport",
258
+ commandId: "add-stdio",
259
+ transport: "stdio",
260
+ label: "Beeper",
261
+ });
262
+ expect((await settings.read(ACCOUNT)).connections).toHaveLength(0);
263
+ });
264
+
265
+ test("refuses the server past the per-User quota, visibly", async () => {
266
+ const { mcp } = await fixture(mcpServer());
267
+ for (let index = 0; index < MAX_MCP_SERVERS_PER_USER_V1; index += 1) {
268
+ const receipt = await mcp.executeLifecycle(ACCOUNT, {
269
+ schemaVersion: 1,
270
+ type: "mcp/add-server",
271
+ commandId: `add-${index}`,
272
+ label: `Server ${index}`,
273
+ url: URL_TEXT,
274
+ transport: "streamable-http",
275
+ });
276
+ expect(receipt.status).toBe("applied");
277
+ }
278
+
279
+ const refused = await mcp.executeLifecycle(ACCOUNT, {
280
+ schemaVersion: 1,
281
+ type: "mcp/add-server",
282
+ commandId: "add-too-many",
283
+ label: "One too many",
284
+ url: URL_TEXT,
285
+ transport: "streamable-http",
286
+ });
287
+
288
+ expect(refused).toMatchObject({
289
+ status: "refused",
290
+ code: "server-quota",
291
+ });
292
+ const status = await mcp.readServerStatus(ACCOUNT);
293
+ expect(status.servers).toHaveLength(MAX_MCP_SERVERS_PER_USER_V1);
294
+ expect(status.refusals[0]?.code).toBe("server-quota");
295
+ });
296
+
297
+ test("is idempotent on its command id", async () => {
298
+ const { mcp, addServer } = await fixture(mcpServer());
299
+ const first = await addServer();
300
+ const second = await addServer();
301
+ expect(second).toEqual(first);
302
+ expect((await mcp.readServerStatus(ACCOUNT)).servers).toHaveLength(1);
303
+ });
304
+
305
+ test("refuses a reused command id carrying a different command", async () => {
306
+ const { mcp, addServer } = await fixture(mcpServer());
307
+ await addServer();
308
+ await expect(addServer({ label: "Different" })).rejects.toThrow(
309
+ "was reused for a different command",
310
+ );
311
+ });
312
+ });
313
+
314
+ describe("mcp/set-instructions", () => {
315
+ test("records the instructions and mirrors them onto the Connection", async () => {
316
+ const { mcp, settings, addServer } = await fixture(mcpServer());
317
+ const added = await addServer();
318
+
319
+ await mcp.executeLifecycle(ACCOUNT, {
320
+ schemaVersion: 1,
321
+ type: "mcp/set-instructions",
322
+ commandId: "set-1",
323
+ serverId: added.serverId!,
324
+ instructions: "Search before you answer.",
325
+ });
326
+
327
+ const [server] = (await mcp.readServerStatus(ACCOUNT)).servers;
328
+ expect(server?.instructions).toBe("Search before you answer.");
329
+ // The mirror is how a Turn reads them without a second cross-object call.
330
+ const connection = await settings.getConnection(ACCOUNT, added.serverId!);
331
+ expect(connection?.safeMetadata.instructions).toBe(
332
+ "Search before you answer.",
333
+ );
334
+ });
335
+
336
+ test("clears them with an empty string, and the mirror clears too", async () => {
337
+ const { mcp, settings, addServer } = await fixture(mcpServer());
338
+ const added = await addServer({ instructions: "Be brief." });
339
+ expect((await mcp.readServerStatus(ACCOUNT)).servers[0]?.instructions).toBe(
340
+ "Be brief.",
341
+ );
342
+
343
+ await mcp.executeLifecycle(ACCOUNT, {
344
+ schemaVersion: 1,
345
+ type: "mcp/set-instructions",
346
+ commandId: "clear-1",
347
+ serverId: added.serverId!,
348
+ instructions: "",
349
+ });
350
+
351
+ expect(
352
+ (await mcp.readServerStatus(ACCOUNT)).servers[0]?.instructions,
353
+ ).toBeUndefined();
354
+ const connection = await settings.getConnection(ACCOUNT, added.serverId!);
355
+ expect(connection?.safeMetadata.instructions).toBeUndefined();
356
+ });
357
+ });
358
+
359
+ describe("mcp/restart", () => {
360
+ test("bumps the epoch, re-handshakes, and keeps the instructions", async () => {
361
+ const handshakes = { count: 0 };
362
+ const { mcp, settings, addServer } = await fixture(
363
+ mcpServer({ handshakes }),
364
+ );
365
+ const added = await addServer({ instructions: "Be brief." });
366
+ expect(handshakes.count).toBe(1);
367
+
368
+ const receipt = await mcp.executeLifecycle(ACCOUNT, {
369
+ schemaVersion: 1,
370
+ type: "mcp/restart",
371
+ commandId: "restart-1",
372
+ serverId: added.serverId!,
373
+ });
374
+
375
+ expect(receipt.status).toBe("applied");
376
+ expect(handshakes.count).toBe(2);
377
+ const [server] = (await mcp.readServerStatus(ACCOUNT)).servers;
378
+ expect(server).toMatchObject({
379
+ serverEpoch: 2,
380
+ state: "ready",
381
+ instructions: "Be brief.",
382
+ });
383
+ // The epoch reaches the Bot through the Connection it already reads.
384
+ const connection = await settings.getConnection(ACCOUNT, added.serverId!);
385
+ expect(connection?.safeMetadata.serverEpoch).toBe(2);
386
+ });
387
+
388
+ test("restarts a keyed server without the key ever leaving the User object", async () => {
389
+ const handshakes = { count: 0 };
390
+ const { mcp, addServer } = await fixture(
391
+ mcpServer({ goodKey: GOOD_KEY, handshakes }),
392
+ );
393
+ const added = await addServer({ apiKey: GOOD_KEY });
394
+
395
+ const receipt = await mcp.executeLifecycle(ACCOUNT, {
396
+ schemaVersion: 1,
397
+ type: "mcp/restart",
398
+ commandId: "restart-keyed",
399
+ serverId: added.serverId!,
400
+ });
401
+
402
+ expect(receipt.status).toBe("applied");
403
+ expect(handshakes.count).toBe(2);
404
+ expect((await mcp.readServerStatus(ACCOUNT)).servers[0]).toMatchObject({
405
+ serverEpoch: 2,
406
+ state: "ready",
407
+ });
408
+ });
409
+
410
+ test("a restart that cannot reach the server leaves error, with the epoch bumped", async () => {
411
+ const down = { value: false };
412
+ const { mcp, addServer } = await fixture(mcpServer({ down }));
413
+ const added = await addServer();
414
+ down.value = true;
415
+
416
+ const receipt = await mcp.executeLifecycle(ACCOUNT, {
417
+ schemaVersion: 1,
418
+ type: "mcp/restart",
419
+ commandId: "restart-down",
420
+ serverId: added.serverId!,
421
+ });
422
+
423
+ expect(receipt).toMatchObject({ status: "failed", code: "unreachable" });
424
+ expect((await mcp.readServerStatus(ACCOUNT)).servers[0]).toMatchObject({
425
+ serverEpoch: 2,
426
+ state: "error",
427
+ });
428
+ });
429
+ });
430
+
431
+ describe("rename, remove and the mount outcome", () => {
432
+ test("connection/update-label renames the record too", async () => {
433
+ const { mcp, addServer } = await fixture(mcpServer());
434
+ const added = await addServer();
435
+
436
+ await mcp.executeConnection(ACCOUNT, {
437
+ schemaVersion: 1,
438
+ type: "connection/update-label",
439
+ commandId: "rename-1",
440
+ connectionId: added.serverId!,
441
+ label: "Renamed",
442
+ });
443
+
444
+ expect((await mcp.readServerStatus(ACCOUNT)).servers[0]?.label).toBe(
445
+ "Renamed",
446
+ );
447
+ });
448
+
449
+ test("connection/disconnect takes the record with it", async () => {
450
+ const { mcp, addServer } = await fixture(mcpServer());
451
+ const added = await addServer();
452
+
453
+ await mcp.executeConnection(ACCOUNT, {
454
+ schemaVersion: 1,
455
+ type: "connection/disconnect",
456
+ commandId: "remove-1",
457
+ connectionId: added.serverId!,
458
+ revokeUpstream: false,
459
+ });
460
+
461
+ expect((await mcp.readServerStatus(ACCOUNT)).servers).toHaveLength(0);
462
+ });
463
+
464
+ test("a Bot's failed mount writes error onto the record", async () => {
465
+ const { mcp, addServer } = await fixture(mcpServer());
466
+ const added = await addServer();
467
+
468
+ await mcp.recordMountOutcome({
469
+ accountId: ACCOUNT,
470
+ connectionId: added.serverId!,
471
+ serverEpoch: 1,
472
+ state: "error",
473
+ failure: { code: "unreachable", message: "MCP server answered 503" },
474
+ });
475
+
476
+ const [server] = (await mcp.readServerStatus(ACCOUNT)).servers;
477
+ expect(server).toMatchObject({ state: "error", toolCount: 0 });
478
+ expect(server?.failure?.message).toContain("503");
479
+ });
480
+
481
+ test("ignores an outcome for a server generation the User has restarted away from", async () => {
482
+ const { mcp, addServer } = await fixture(mcpServer());
483
+ const added = await addServer();
484
+ await mcp.executeLifecycle(ACCOUNT, {
485
+ schemaVersion: 1,
486
+ type: "mcp/restart",
487
+ commandId: "restart-1",
488
+ serverId: added.serverId!,
489
+ });
490
+
491
+ await mcp.recordMountOutcome({
492
+ accountId: ACCOUNT,
493
+ connectionId: added.serverId!,
494
+ serverEpoch: 1,
495
+ state: "error",
496
+ failure: { code: "unreachable", message: "stale" },
497
+ });
498
+
499
+ expect((await mcp.readServerStatus(ACCOUNT)).servers[0]).toMatchObject({
500
+ serverEpoch: 2,
501
+ state: "ready",
502
+ });
503
+ });
504
+ });
@@ -0,0 +1,3 @@
1
+ import manifest from "../frockbot.json" with { type: "json" };
2
+
3
+ export default manifest;