@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,38 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { decodeOutboundMcpUrlV1 } from "./ssrf.js";
3
+
4
+ describe("decodeOutboundMcpUrlV1", () => {
5
+ test("accepts an absolute https URL", () => {
6
+ expect(
7
+ decodeOutboundMcpUrlV1("https://mcp.example.test/mcp?v=1").toString(),
8
+ ).toBe("https://mcp.example.test/mcp?v=1");
9
+ });
10
+
11
+ test.each([
12
+ ["http://mcp.example.test/mcp", /must use https/],
13
+ ["ws://mcp.example.test/mcp", /must use https/],
14
+ ["/mcp", /absolute https URL/],
15
+ ["", /absolute https URL/],
16
+ ["https://user:pass@mcp.example.test/mcp", /must not carry credentials/],
17
+ ["https://localhost/mcp", /private address/],
18
+ ["https://server.local/mcp", /private address/],
19
+ ["https://127.0.0.1/mcp", /private address/],
20
+ ["https://10.0.0.5/mcp", /private address/],
21
+ ["https://172.16.4.4/mcp", /private address/],
22
+ ["https://192.168.0.1/mcp", /private address/],
23
+ ["https://169.254.169.254/latest", /private address/],
24
+ ["https://100.100.0.1/mcp", /private address/],
25
+ ["https://metadata.google.internal/mcp", /private address/],
26
+ ["https://[::1]/mcp", /private address/],
27
+ ["https://[fd00::1]/mcp", /private address/],
28
+ ["https://[fe80::1]/mcp", /private address/],
29
+ ])("refuses %s", (url, reason) => {
30
+ expect(() => decodeOutboundMcpUrlV1(url)).toThrow(reason);
31
+ });
32
+
33
+ test("still accepts a public IP literal", () => {
34
+ expect(decodeOutboundMcpUrlV1("https://93.184.216.34/mcp").hostname).toBe(
35
+ "93.184.216.34",
36
+ );
37
+ });
38
+ });
package/src/ssrf.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The outbound-URL rules every MCP request is held to.
3
+ *
4
+ * These rules are not MCP's. They are the Bot's outbound trust boundary, and
5
+ * they live in `@frockbot/plugin-web/ssrf` — one classifier, one place, used
6
+ * by `web_fetch` and by this Package. This module is the adapter that gives
7
+ * the shared verdict MCP's own vocabulary.
8
+ *
9
+ * The earlier copy of these rules lived here and said so: "when it lands, one
10
+ * of the two becomes the other's import; they are intentionally identical in
11
+ * behaviour so the merge is a deletion." This is that deletion. The shared
12
+ * classifier is a strict superset of what stood here — it additionally
13
+ * normalizes every IPv4 encoding (`0177.0.0.1`, `2130706433`, `0x7f000001`),
14
+ * parses IPv6 properly rather than by prefix regex, and refuses a bare label
15
+ * with no dot — so no URL this module used to accept is refused now except
16
+ * ones that were always private.
17
+ *
18
+ * One rule is deliberately relaxed for MCP: `web_fetch` refuses a non-default
19
+ * port, because a Bot reading the public web has no business on one. A User
20
+ * naming their own MCP endpoint may well run it on its own port, so this
21
+ * caller opts into that and into nothing else.
22
+ */
23
+ import { classifyOutboundUrlV1 } from "@frockbot/plugin-web/ssrf";
24
+
25
+ /**
26
+ * Decode one outbound MCP URL, or explain why it is refused. Absolute https
27
+ * only: an MCP endpoint carries a bearer credential, and http would put it on
28
+ * the wire in the clear.
29
+ */
30
+ export function decodeOutboundMcpUrlV1(value: unknown): URL {
31
+ const verdict = classifyOutboundUrlV1(value, { allowNonDefaultPort: true });
32
+ if (verdict.allowed) return new URL(verdict.url);
33
+ switch (verdict.reason) {
34
+ case "ssrf-blocked-scheme":
35
+ throw new Error("MCP server URL must use https");
36
+ case "ssrf-blocked-credentials":
37
+ throw new Error("MCP server URL must not carry credentials");
38
+ case "ssrf-blocked-host":
39
+ case "ssrf-blocked-private-address":
40
+ throw new Error("MCP server URL must not name a private address");
41
+ default:
42
+ throw new Error("MCP server URL must be an absolute https URL");
43
+ }
44
+ }
@@ -0,0 +1,390 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ConnectionView } from "@frockbot/configuration-core";
3
+ import {
4
+ createCredentialUserBackendContribution,
5
+ type CredentialStorage,
6
+ type CredentialTransaction,
7
+ } from "@frockbot/plugin-credentials/user";
8
+ import {
9
+ createUserSettingsBackendContribution,
10
+ type UserSettingsStorage,
11
+ type UserSettingsTransaction,
12
+ } from "@frockbot/plugin-settings/user";
13
+ import { createMcpUserBackendContribution } from "./user.js";
14
+
15
+ const ACCOUNT = "account-1";
16
+ const URL_TEXT = "https://mcp.example.test/mcp";
17
+
18
+ class MemoryStorage implements UserSettingsStorage, CredentialStorage {
19
+ readonly values = new Map<string, unknown>();
20
+ alarm?: number;
21
+
22
+ get<T>(key: string): Promise<T | undefined> {
23
+ return Promise.resolve(this.values.get(key) as T | undefined);
24
+ }
25
+
26
+ put<T>(key: string, value: T): Promise<void>;
27
+ put(entries: Record<string, unknown>): Promise<void>;
28
+ put<T>(
29
+ keyOrEntries: string | Record<string, unknown>,
30
+ value?: T,
31
+ ): Promise<void> {
32
+ if (typeof keyOrEntries === "string") this.values.set(keyOrEntries, value);
33
+ else {
34
+ for (const [key, entry] of Object.entries(keyOrEntries)) {
35
+ this.values.set(key, entry);
36
+ }
37
+ }
38
+ return Promise.resolve();
39
+ }
40
+
41
+ delete(key: string): Promise<boolean> {
42
+ return Promise.resolve(this.values.delete(key));
43
+ }
44
+
45
+ async transaction<T>(
46
+ callback: (
47
+ storage: UserSettingsTransaction & CredentialTransaction,
48
+ ) => Promise<T>,
49
+ ): Promise<T> {
50
+ const before = new Map(this.values);
51
+ try {
52
+ return await callback(this);
53
+ } catch (error) {
54
+ this.values.clear();
55
+ for (const [key, entry] of before) this.values.set(key, entry);
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ getAlarm(): Promise<number | null> {
61
+ return Promise.resolve(this.alarm ?? null);
62
+ }
63
+
64
+ setAlarm(scheduledTime: number | Date): Promise<void> {
65
+ this.alarm = Number(scheduledTime);
66
+ return Promise.resolve();
67
+ }
68
+ }
69
+
70
+ function keyring(): string {
71
+ const bytes = Uint8Array.from({ length: 32 }, (_, index) => index + 3);
72
+ let binary = "";
73
+ for (const byte of bytes) binary += String.fromCharCode(byte);
74
+ return JSON.stringify({
75
+ schemaVersion: 1,
76
+ currentKeyId: "primary",
77
+ keys: {
78
+ primary: btoa(binary)
79
+ .replaceAll("+", "-")
80
+ .replaceAll("/", "_")
81
+ .replace(/=+$/, ""),
82
+ },
83
+ });
84
+ }
85
+
86
+ const INITIALIZE_RESULT = {
87
+ protocolVersion: "2025-06-18",
88
+ capabilities: { tools: {} },
89
+ serverInfo: { name: "Example" },
90
+ };
91
+
92
+ /** A server that accepts exactly one key, when a key is required at all. */
93
+ function mcpServer(options: {
94
+ goodKey?: string;
95
+ seen?: string[];
96
+ tools?: { name: string; inputSchema: Record<string, unknown> }[];
97
+ }): typeof fetch {
98
+ const tools = options.tools ?? [
99
+ { name: "echo", inputSchema: { type: "object" } },
100
+ { name: "search", inputSchema: { type: "object" } },
101
+ ];
102
+ return (async (input: string | URL | Request, init?: RequestInit) => {
103
+ options.seen?.push(String(input));
104
+ const headers = new Headers(init?.headers);
105
+ if (
106
+ options.goodKey !== undefined &&
107
+ headers.get("authorization") !== `Bearer ${options.goodKey}`
108
+ ) {
109
+ return new Response("Unauthorized", { status: 401 });
110
+ }
111
+ const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
112
+ if (body.id === undefined) return new Response("", { status: 202 });
113
+ return Response.json({
114
+ jsonrpc: "2.0",
115
+ id: body.id,
116
+ result: body.method === "initialize" ? INITIALIZE_RESULT : { tools },
117
+ });
118
+ }) as typeof fetch;
119
+ }
120
+
121
+ async function fixture(fetchImpl: typeof fetch) {
122
+ const storage = new MemoryStorage();
123
+ const settings = createUserSettingsBackendContribution({
124
+ storage,
125
+ availablePackages: [{ packageId: "mcp", version: "0.0.1" }],
126
+ });
127
+ await settings.executeConfiguration({
128
+ schemaVersion: 1,
129
+ userId: ACCOUNT,
130
+ command: {
131
+ schemaVersion: 1,
132
+ type: "user/install-package",
133
+ commandId: "install-1",
134
+ expectedRevision: 0,
135
+ packageId: "mcp",
136
+ version: "0.0.1",
137
+ },
138
+ });
139
+ const credentials = createCredentialUserBackendContribution({
140
+ storage,
141
+ keyring: keyring(),
142
+ });
143
+ let id = 0;
144
+ const mcp = createMcpUserBackendContribution({
145
+ storage,
146
+ settings,
147
+ credentials,
148
+ fetch: fetchImpl,
149
+ randomId: () => `id-${++id}`,
150
+ });
151
+ const read = async (connectionId: string): Promise<ConnectionView> => {
152
+ const connection = await settings.getConnection(ACCOUNT, connectionId);
153
+ expect(connection).toBeDefined();
154
+ return connection!;
155
+ };
156
+ return { storage, settings, credentials, mcp, read };
157
+ }
158
+
159
+ describe("creating a public MCP server Connection", () => {
160
+ test("reaches ready and records what the handshake learned", async () => {
161
+ const { mcp, read } = await fixture(mcpServer({}));
162
+
163
+ const receipt = await mcp.executeConnection(ACCOUNT, {
164
+ schemaVersion: 1,
165
+ type: "connection/create",
166
+ commandId: "add-1",
167
+ packageId: "mcp",
168
+ connectionTypeId: "mcp-remote",
169
+ label: "Example",
170
+ settings: { url: URL_TEXT, transport: "streamable-http" },
171
+ });
172
+
173
+ expect(receipt.status).toBe("applied");
174
+ const connection = await read(receipt.connectionId);
175
+ expect(connection.state).toBe("ready");
176
+ expect(connection.settings).toEqual({
177
+ url: URL_TEXT,
178
+ transport: "streamable-http",
179
+ });
180
+ expect(connection.safeMetadata).toMatchObject({
181
+ protocolVersion: "2025-06-18",
182
+ toolCount: 2,
183
+ serverName: "Example",
184
+ });
185
+ expect(typeof connection.safeMetadata.toolsHash).toBe("string");
186
+ });
187
+
188
+ test("refuses a URL the outbound rules reject before recording anything", async () => {
189
+ const { mcp, settings } = await fixture(mcpServer({}));
190
+
191
+ await expect(
192
+ mcp.executeConnection(ACCOUNT, {
193
+ schemaVersion: 1,
194
+ type: "connection/create",
195
+ commandId: "add-private",
196
+ packageId: "mcp",
197
+ connectionTypeId: "mcp-remote",
198
+ label: "Internal",
199
+ settings: { url: "https://10.0.0.1/mcp" },
200
+ }),
201
+ ).rejects.toThrow(/private address/);
202
+ expect((await settings.read(ACCOUNT)).connections).toEqual([]);
203
+ });
204
+
205
+ test("refuses the keyed Connection Type on the keyless command", async () => {
206
+ const { mcp } = await fixture(mcpServer({}));
207
+
208
+ await expect(
209
+ mcp.executeConnection(ACCOUNT, {
210
+ schemaVersion: 1,
211
+ type: "connection/create",
212
+ commandId: "add-wrong-type",
213
+ packageId: "mcp",
214
+ connectionTypeId: "mcp-remote-key",
215
+ label: "Example",
216
+ settings: { url: URL_TEXT },
217
+ }),
218
+ ).rejects.toThrow(/does not accept this command/);
219
+ });
220
+ });
221
+
222
+ describe("creating a keyed MCP server Connection", () => {
223
+ const create = (commandId: string, apiKey: string) => ({
224
+ schemaVersion: 1 as const,
225
+ type: "connection/create-api-key" as const,
226
+ commandId,
227
+ packageId: "mcp",
228
+ connectionTypeId: "mcp-remote-key",
229
+ label: "Example",
230
+ apiKey,
231
+ settings: { url: URL_TEXT },
232
+ });
233
+
234
+ test("proves the key with the handshake and reaches ready", async () => {
235
+ const { mcp, read } = await fixture(mcpServer({ goodKey: "good-key" }));
236
+
237
+ const receipt = await mcp.executeConnection(
238
+ ACCOUNT,
239
+ create("add-key", "good-key"),
240
+ );
241
+
242
+ expect(receipt.status).toBe("applied");
243
+ expect((await read(receipt.connectionId)).state).toBe("ready");
244
+ });
245
+
246
+ test("leaves a rejected key failed, with the server's reason", async () => {
247
+ const { mcp, read } = await fixture(mcpServer({ goodKey: "good-key" }));
248
+
249
+ const receipt = await mcp.executeConnection(
250
+ ACCOUNT,
251
+ create("add-bad-key", "wrong-key"),
252
+ );
253
+
254
+ expect(receipt.status).toBe("failed");
255
+ const connection = await read(receipt.connectionId);
256
+ expect(connection.state).toBe("failed");
257
+ expect(connection.failure).toContain("401");
258
+ });
259
+
260
+ test("leases the credential for a mount only while the Connection is ready", async () => {
261
+ const { mcp, read } = await fixture(mcpServer({ goodKey: "good-key" }));
262
+ const receipt = await mcp.executeConnection(
263
+ ACCOUNT,
264
+ create("add-lease", "good-key"),
265
+ );
266
+ const connection = await read(receipt.connectionId);
267
+
268
+ const lease = await mcp.leaseToolCredential({
269
+ accountId: ACCOUNT,
270
+ connectionId: connection.connectionId,
271
+ effectId: "mount-1",
272
+ connectionGeneration: connection.generation!,
273
+ });
274
+
275
+ expect(lease.connectionId).toBe(connection.connectionId);
276
+ expect(lease.effectId).toBe("mount-1");
277
+ await mcp.settleToolCredential({
278
+ accountId: ACCOUNT,
279
+ connectionId: connection.connectionId,
280
+ effectId: "mount-1",
281
+ });
282
+
283
+ // A generation the caller no longer holds is refused, so a rotated key
284
+ // cannot be opened by a Composition pinned to the old one.
285
+ await expect(
286
+ mcp.leaseToolCredential({
287
+ accountId: ACCOUNT,
288
+ connectionId: connection.connectionId,
289
+ effectId: "mount-2",
290
+ connectionGeneration: "stale-generation",
291
+ }),
292
+ ).rejects.toThrow(/generation changed/);
293
+ });
294
+
295
+ test("refuses a lease on a keyless Connection", async () => {
296
+ const { mcp } = await fixture(mcpServer({}));
297
+ const receipt = await mcp.executeConnection(ACCOUNT, {
298
+ schemaVersion: 1,
299
+ type: "connection/create",
300
+ commandId: "add-keyless",
301
+ packageId: "mcp",
302
+ connectionTypeId: "mcp-remote",
303
+ label: "Example",
304
+ settings: { url: URL_TEXT },
305
+ });
306
+
307
+ await expect(
308
+ mcp.leaseToolCredential({
309
+ accountId: ACCOUNT,
310
+ connectionId: receipt.connectionId,
311
+ effectId: "mount-1",
312
+ connectionGeneration: "id-2",
313
+ }),
314
+ ).rejects.toThrow(/carries no credential/);
315
+ });
316
+ });
317
+
318
+ describe("the Connection command path", () => {
319
+ const add = {
320
+ schemaVersion: 1 as const,
321
+ type: "connection/create" as const,
322
+ commandId: "add-1",
323
+ packageId: "mcp",
324
+ connectionTypeId: "mcp-remote",
325
+ label: "Example",
326
+ settings: { url: URL_TEXT },
327
+ };
328
+
329
+ test("replays one receipt for a repeated command and refuses a reused id", async () => {
330
+ const seen: string[] = [];
331
+ const { mcp, settings } = await fixture(mcpServer({ seen }));
332
+
333
+ const first = await mcp.executeConnection(ACCOUNT, add);
334
+ const replay = await mcp.executeConnection(ACCOUNT, add);
335
+
336
+ expect(replay).toEqual(first);
337
+ expect((await settings.read(ACCOUNT)).connections).toHaveLength(1);
338
+ expect(await mcp.lookupConnectionCommand(ACCOUNT, "add-1")).toEqual(first);
339
+ await expect(
340
+ mcp.executeConnection(ACCOUNT, { ...add, label: "Different" }),
341
+ ).rejects.toThrow(/reused for a different command/);
342
+ });
343
+
344
+ test("renames, disables and disconnects through the shared commands", async () => {
345
+ const { mcp, read } = await fixture(mcpServer({}));
346
+ const { connectionId } = await mcp.executeConnection(ACCOUNT, add);
347
+
348
+ await mcp.executeConnection(ACCOUNT, {
349
+ schemaVersion: 1,
350
+ type: "connection/update-label",
351
+ commandId: "rename-1",
352
+ connectionId,
353
+ label: "Renamed",
354
+ });
355
+ expect((await read(connectionId)).displayName).toBe("Renamed");
356
+
357
+ await mcp.executeConnection(ACCOUNT, {
358
+ schemaVersion: 1,
359
+ type: "connection/set-enabled",
360
+ commandId: "disable-1",
361
+ connectionId,
362
+ enabled: false,
363
+ });
364
+ expect((await read(connectionId)).state).toBe("disabled");
365
+
366
+ await mcp.executeConnection(ACCOUNT, {
367
+ schemaVersion: 1,
368
+ type: "connection/disconnect",
369
+ commandId: "remove-1",
370
+ connectionId,
371
+ revokeUpstream: false,
372
+ });
373
+ expect((await read(connectionId)).state).toBe("revoked");
374
+ });
375
+
376
+ test("offers no model catalog", async () => {
377
+ const { mcp } = await fixture(mcpServer({}));
378
+ const { connectionId } = await mcp.executeConnection(ACCOUNT, add);
379
+
380
+ await expect(
381
+ mcp.executeConnection(ACCOUNT, {
382
+ schemaVersion: 1,
383
+ type: "connection/refresh-models",
384
+ commandId: "refresh-1",
385
+ connectionId,
386
+ }),
387
+ ).rejects.toThrow(/no model catalog/);
388
+ await expect(mcp.leaseModelCredential()).rejects.toThrow(/no model/);
389
+ });
390
+ });