@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,331 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { McpProtocolError } from "./mcp-client.js";
|
|
3
|
+
import {
|
|
4
|
+
decodeMcpLifecycleCommandV1,
|
|
5
|
+
decodeMcpLifecycleReceiptV1,
|
|
6
|
+
decodeMcpMountOutcomeV1,
|
|
7
|
+
decodeMcpRefusalRecordV1,
|
|
8
|
+
decodeMcpServerRecordV1,
|
|
9
|
+
decodeMcpServerStatusViewV1,
|
|
10
|
+
mcpAssignmentResolutionKeyV1,
|
|
11
|
+
mcpConnectionMetadataV1,
|
|
12
|
+
mcpFailureCodeV1,
|
|
13
|
+
type McpServerRecordV1,
|
|
14
|
+
} from "./records.js";
|
|
15
|
+
|
|
16
|
+
function server(
|
|
17
|
+
overrides: Partial<McpServerRecordV1> = {},
|
|
18
|
+
): Record<string, unknown> {
|
|
19
|
+
return {
|
|
20
|
+
schemaVersion: 1,
|
|
21
|
+
serverId: "mcp-1",
|
|
22
|
+
label: "Example",
|
|
23
|
+
url: "https://mcp.example.test/mcp",
|
|
24
|
+
transport: "streamable-http",
|
|
25
|
+
serverEpoch: 1,
|
|
26
|
+
state: "ready",
|
|
27
|
+
toolCount: 2,
|
|
28
|
+
toolsHash: "abc123",
|
|
29
|
+
lastHandshakeAt: "2026-08-31T00:00:00.000Z",
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("the durable MCP server record", () => {
|
|
35
|
+
test("decodes a complete record and drops nothing", () => {
|
|
36
|
+
const decoded = decodeMcpServerRecordV1(
|
|
37
|
+
server({
|
|
38
|
+
instructions: "Prefer the search tool.",
|
|
39
|
+
protocolVersion: "2025-06-18",
|
|
40
|
+
state: "needs-auth",
|
|
41
|
+
failure: {
|
|
42
|
+
code: "unauthorized",
|
|
43
|
+
message: "MCP server answered 401",
|
|
44
|
+
at: "2026-08-31T00:00:01.000Z",
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
expect(decoded.state).toBe("needs-auth");
|
|
49
|
+
expect(decoded.instructions).toBe("Prefer the search tool.");
|
|
50
|
+
expect(decoded.failure?.code).toBe("unauthorized");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("refuses an unknown field rather than ignoring it", () => {
|
|
54
|
+
expect(() => decodeMcpServerRecordV1({ ...server(), pid: 1234 })).toThrow(
|
|
55
|
+
'MCP server record carries unknown field "pid"',
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("refuses a state, transport, failure code or version it does not know", () => {
|
|
60
|
+
expect(() =>
|
|
61
|
+
decodeMcpServerRecordV1(server({ state: "stopped" as never })),
|
|
62
|
+
).toThrow("MCP server record state is invalid");
|
|
63
|
+
expect(() =>
|
|
64
|
+
decodeMcpServerRecordV1(server({ transport: "stdio" as never })),
|
|
65
|
+
).toThrow("MCP server record transport is invalid");
|
|
66
|
+
expect(() =>
|
|
67
|
+
decodeMcpServerRecordV1(
|
|
68
|
+
server({
|
|
69
|
+
failure: { code: "boom", message: "x", at: "2026-08-31" } as never,
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
72
|
+
).toThrow("MCP server failure code is invalid");
|
|
73
|
+
expect(() =>
|
|
74
|
+
decodeMcpServerRecordV1({ ...server(), schemaVersion: 2 }),
|
|
75
|
+
).toThrow("MCP server record schemaVersion is unsupported");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("accepts an empty tools hash, which is a server that has not listed yet", () => {
|
|
79
|
+
expect(
|
|
80
|
+
decodeMcpServerRecordV1(server({ state: "connecting", toolsHash: "" }))
|
|
81
|
+
.toolsHash,
|
|
82
|
+
).toBe("");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("projects only the fields the Bot needs onto the Connection", () => {
|
|
86
|
+
const metadata = mcpConnectionMetadataV1(
|
|
87
|
+
decodeMcpServerRecordV1(
|
|
88
|
+
server({ instructions: "Be brief.", protocolVersion: "2025-06-18" }),
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
expect(metadata).toEqual({
|
|
92
|
+
serverEpoch: 1,
|
|
93
|
+
serverState: "ready",
|
|
94
|
+
toolCount: 2,
|
|
95
|
+
toolsHash: "abc123",
|
|
96
|
+
protocolVersion: "2025-06-18",
|
|
97
|
+
instructions: "Be brief.",
|
|
98
|
+
});
|
|
99
|
+
// Nothing that could be a credential, and nothing the Bot cannot use.
|
|
100
|
+
expect(Object.keys(metadata)).not.toContain("url");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("the refusal ledger and the status projection", () => {
|
|
105
|
+
test("decodes a refusal with its origin", () => {
|
|
106
|
+
const refusal = decodeMcpRefusalRecordV1({
|
|
107
|
+
schemaVersion: 1,
|
|
108
|
+
refusalId: "r-1",
|
|
109
|
+
commandId: "add-1",
|
|
110
|
+
code: "unsupported-transport",
|
|
111
|
+
message: "stdio is not supported",
|
|
112
|
+
at: "2026-08-31T00:00:00.000Z",
|
|
113
|
+
label: "Beeper",
|
|
114
|
+
url: "stdio://beeper",
|
|
115
|
+
transport: "stdio",
|
|
116
|
+
});
|
|
117
|
+
expect(refusal.transport).toBe("stdio");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("refuses a transport the refusal ledger does not know", () => {
|
|
121
|
+
expect(() =>
|
|
122
|
+
decodeMcpRefusalRecordV1({
|
|
123
|
+
schemaVersion: 1,
|
|
124
|
+
refusalId: "r-1",
|
|
125
|
+
commandId: "add-1",
|
|
126
|
+
code: "server-quota",
|
|
127
|
+
message: "too many",
|
|
128
|
+
at: "2026-08-31T00:00:00.000Z",
|
|
129
|
+
transport: "pipe",
|
|
130
|
+
}),
|
|
131
|
+
).toThrow("MCP refusal transport is invalid");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("round-trips a status view", () => {
|
|
135
|
+
const view = decodeMcpServerStatusViewV1({
|
|
136
|
+
schemaVersion: 1,
|
|
137
|
+
servers: [server()],
|
|
138
|
+
refusals: [],
|
|
139
|
+
quotas: {
|
|
140
|
+
maxServers: 16,
|
|
141
|
+
maxToolsPerServer: 64,
|
|
142
|
+
maxResponseBytes: 262_144,
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
expect(view.servers).toHaveLength(1);
|
|
146
|
+
expect(view.quotas.maxServers).toBe(16);
|
|
147
|
+
expect(() =>
|
|
148
|
+
decodeMcpServerStatusViewV1({
|
|
149
|
+
schemaVersion: 1,
|
|
150
|
+
servers: [],
|
|
151
|
+
refusals: [],
|
|
152
|
+
quotas: { maxServers: 16, maxToolsPerServer: 64 },
|
|
153
|
+
}),
|
|
154
|
+
).toThrow("maxResponseBytes is invalid");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("the lifecycle command codec", () => {
|
|
159
|
+
test("decodes each command exactly", () => {
|
|
160
|
+
expect(
|
|
161
|
+
decodeMcpLifecycleCommandV1({
|
|
162
|
+
schemaVersion: 1,
|
|
163
|
+
type: "mcp/add-server",
|
|
164
|
+
commandId: "add-1",
|
|
165
|
+
label: "Example",
|
|
166
|
+
url: "https://mcp.example.test/mcp",
|
|
167
|
+
transport: "streamable-http",
|
|
168
|
+
}).type,
|
|
169
|
+
).toBe("mcp/add-server");
|
|
170
|
+
expect(
|
|
171
|
+
decodeMcpLifecycleCommandV1({
|
|
172
|
+
schemaVersion: 1,
|
|
173
|
+
type: "mcp/set-instructions",
|
|
174
|
+
commandId: "set-1",
|
|
175
|
+
serverId: "mcp-1",
|
|
176
|
+
instructions: "",
|
|
177
|
+
}),
|
|
178
|
+
).toMatchObject({ instructions: "" });
|
|
179
|
+
expect(
|
|
180
|
+
decodeMcpLifecycleCommandV1({
|
|
181
|
+
schemaVersion: 1,
|
|
182
|
+
type: "mcp/restart",
|
|
183
|
+
commandId: "restart-1",
|
|
184
|
+
serverId: "mcp-1",
|
|
185
|
+
}).type,
|
|
186
|
+
).toBe("mcp/restart");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("carries stdio through the codec so the refusal can be durable", () => {
|
|
190
|
+
const command = decodeMcpLifecycleCommandV1({
|
|
191
|
+
schemaVersion: 1,
|
|
192
|
+
type: "mcp/add-server",
|
|
193
|
+
commandId: "add-1",
|
|
194
|
+
label: "Beeper",
|
|
195
|
+
url: "stdio://beeper",
|
|
196
|
+
transport: "stdio",
|
|
197
|
+
});
|
|
198
|
+
expect(command).toMatchObject({ transport: "stdio" });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("refuses unknown fields, unknown types and an unknown transport", () => {
|
|
202
|
+
expect(() =>
|
|
203
|
+
decodeMcpLifecycleCommandV1({
|
|
204
|
+
schemaVersion: 1,
|
|
205
|
+
type: "mcp/restart",
|
|
206
|
+
commandId: "restart-1",
|
|
207
|
+
serverId: "mcp-1",
|
|
208
|
+
force: true,
|
|
209
|
+
}),
|
|
210
|
+
).toThrow('mcp/restart carries unknown field "force"');
|
|
211
|
+
expect(() =>
|
|
212
|
+
decodeMcpLifecycleCommandV1({
|
|
213
|
+
schemaVersion: 1,
|
|
214
|
+
type: "mcp/kill",
|
|
215
|
+
commandId: "kill-1",
|
|
216
|
+
}),
|
|
217
|
+
).toThrow('MCP lifecycle command "mcp/kill" is unknown');
|
|
218
|
+
expect(() =>
|
|
219
|
+
decodeMcpLifecycleCommandV1({
|
|
220
|
+
schemaVersion: 1,
|
|
221
|
+
type: "mcp/add-server",
|
|
222
|
+
commandId: "add-1",
|
|
223
|
+
label: "Example",
|
|
224
|
+
url: "https://mcp.example.test/mcp",
|
|
225
|
+
transport: "carrier-pigeon",
|
|
226
|
+
}),
|
|
227
|
+
).toThrow("MCP transport is invalid");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("decodes a receipt and its refusal code", () => {
|
|
231
|
+
expect(
|
|
232
|
+
decodeMcpLifecycleReceiptV1({
|
|
233
|
+
schemaVersion: 1,
|
|
234
|
+
commandId: "add-1",
|
|
235
|
+
status: "refused",
|
|
236
|
+
code: "unsupported-transport",
|
|
237
|
+
failure: "stdio is not supported",
|
|
238
|
+
}).code,
|
|
239
|
+
).toBe("unsupported-transport");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("the mount outcome that crosses back from a Bot", () => {
|
|
244
|
+
test("decodes a reported outcome", () => {
|
|
245
|
+
expect(
|
|
246
|
+
decodeMcpMountOutcomeV1({
|
|
247
|
+
connectionId: "mcp-1",
|
|
248
|
+
serverEpoch: 3,
|
|
249
|
+
state: "error",
|
|
250
|
+
failure: { code: "unreachable", message: "MCP server answered 404" },
|
|
251
|
+
}),
|
|
252
|
+
).toMatchObject({ serverEpoch: 3, state: "error" });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("refuses a Bot that tries to report a whole record", () => {
|
|
256
|
+
expect(() =>
|
|
257
|
+
decodeMcpMountOutcomeV1({
|
|
258
|
+
connectionId: "mcp-1",
|
|
259
|
+
state: "ready",
|
|
260
|
+
label: "Renamed by the Bot",
|
|
261
|
+
}),
|
|
262
|
+
).toThrow('MCP mount outcome carries unknown field "label"');
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("the Assignment resolution key", () => {
|
|
267
|
+
test("changes when the server epoch changes, and only then", () => {
|
|
268
|
+
const before = mcpAssignmentResolutionKeyV1({
|
|
269
|
+
connectionId: "mcp-1",
|
|
270
|
+
connectionGeneration: "gen-1",
|
|
271
|
+
serverEpoch: 1,
|
|
272
|
+
});
|
|
273
|
+
const restarted = mcpAssignmentResolutionKeyV1({
|
|
274
|
+
connectionId: "mcp-1",
|
|
275
|
+
connectionGeneration: "gen-1",
|
|
276
|
+
serverEpoch: 2,
|
|
277
|
+
});
|
|
278
|
+
const again = mcpAssignmentResolutionKeyV1({
|
|
279
|
+
connectionId: "mcp-1",
|
|
280
|
+
connectionGeneration: "gen-1",
|
|
281
|
+
serverEpoch: 1,
|
|
282
|
+
});
|
|
283
|
+
expect(restarted).not.toBe(before);
|
|
284
|
+
expect(again).toBe(before);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("changes when the Connection generation changes", () => {
|
|
288
|
+
expect(
|
|
289
|
+
mcpAssignmentResolutionKeyV1({
|
|
290
|
+
connectionId: "mcp-1",
|
|
291
|
+
connectionGeneration: "gen-2",
|
|
292
|
+
serverEpoch: 1,
|
|
293
|
+
}),
|
|
294
|
+
).not.toBe(
|
|
295
|
+
mcpAssignmentResolutionKeyV1({
|
|
296
|
+
connectionId: "mcp-1",
|
|
297
|
+
connectionGeneration: "gen-1",
|
|
298
|
+
serverEpoch: 1,
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe("classifying a handshake failure", () => {
|
|
305
|
+
test("tells a credential a User can replace from a server that is not there", () => {
|
|
306
|
+
expect(
|
|
307
|
+
mcpFailureCodeV1(new McpProtocolError("MCP server answered 401", 401)),
|
|
308
|
+
).toBe("unauthorized");
|
|
309
|
+
expect(
|
|
310
|
+
mcpFailureCodeV1(new McpProtocolError("MCP server answered 403", 403)),
|
|
311
|
+
).toBe("unauthorized");
|
|
312
|
+
expect(
|
|
313
|
+
mcpFailureCodeV1(new McpProtocolError("MCP server answered 404", 404)),
|
|
314
|
+
).toBe("unreachable");
|
|
315
|
+
expect(mcpFailureCodeV1(new TypeError("fetch failed"))).toBe("unreachable");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("names a quota breach as a quota breach", () => {
|
|
319
|
+
expect(
|
|
320
|
+
mcpFailureCodeV1(
|
|
321
|
+
new McpProtocolError("MCP server offers more than 64 tools"),
|
|
322
|
+
),
|
|
323
|
+
).toBe("tool-quota");
|
|
324
|
+
expect(
|
|
325
|
+
mcpFailureCodeV1(new McpProtocolError("MCP response is too large")),
|
|
326
|
+
).toBe("response-quota");
|
|
327
|
+
expect(
|
|
328
|
+
mcpFailureCodeV1(new McpProtocolError("MCP reply does not answer")),
|
|
329
|
+
).toBe("protocol");
|
|
330
|
+
});
|
|
331
|
+
});
|