@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
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { McpClient, McpProtocolError, parseSseEventsV1 } from "./mcp-client.js";
|
|
3
|
+
|
|
4
|
+
const ENDPOINT = new URL("https://mcp.example.test/mcp");
|
|
5
|
+
|
|
6
|
+
interface Exchange {
|
|
7
|
+
method: string;
|
|
8
|
+
body: Record<string, unknown>;
|
|
9
|
+
headers: Headers;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function jsonRpc(id: unknown, result: unknown): string {
|
|
13
|
+
return JSON.stringify({ jsonrpc: "2.0", id, result });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sseBody(id: unknown, result: unknown): string {
|
|
17
|
+
return `event: message\ndata: ${jsonRpc(id, result)}\n\n`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const INITIALIZE_RESULT = {
|
|
21
|
+
protocolVersion: "2025-06-18",
|
|
22
|
+
capabilities: { tools: {} },
|
|
23
|
+
serverInfo: { name: "Example", version: "1.2.3" },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const TOOLS_RESULT = {
|
|
27
|
+
tools: [
|
|
28
|
+
{
|
|
29
|
+
name: "echo",
|
|
30
|
+
description: "Echo a message back.",
|
|
31
|
+
inputSchema: {
|
|
32
|
+
type: "object",
|
|
33
|
+
properties: { message: { type: "string" } },
|
|
34
|
+
required: ["message"],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** A streamable-HTTP server that answers each POST inline. */
|
|
41
|
+
function streamableServer(options: {
|
|
42
|
+
contentType?: "application/json" | "text/event-stream";
|
|
43
|
+
onCall?: (args: unknown) => unknown;
|
|
44
|
+
exchanges: Exchange[];
|
|
45
|
+
}): typeof fetch {
|
|
46
|
+
const contentType = options.contentType ?? "application/json";
|
|
47
|
+
return (async (input: string | URL | Request, init?: RequestInit) => {
|
|
48
|
+
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
|
49
|
+
options.exchanges.push({
|
|
50
|
+
method: String(body.method),
|
|
51
|
+
body,
|
|
52
|
+
headers: new Headers(init?.headers),
|
|
53
|
+
});
|
|
54
|
+
if (body.id === undefined) return new Response("", { status: 202 });
|
|
55
|
+
const result =
|
|
56
|
+
body.method === "initialize"
|
|
57
|
+
? INITIALIZE_RESULT
|
|
58
|
+
: body.method === "tools/list"
|
|
59
|
+
? TOOLS_RESULT
|
|
60
|
+
: {
|
|
61
|
+
content: [
|
|
62
|
+
{
|
|
63
|
+
type: "text",
|
|
64
|
+
text: JSON.stringify(
|
|
65
|
+
options.onCall?.(
|
|
66
|
+
(body.params as Record<string, unknown>).arguments,
|
|
67
|
+
) ?? { ok: true },
|
|
68
|
+
),
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
const payload =
|
|
73
|
+
contentType === "application/json"
|
|
74
|
+
? jsonRpc(body.id, result)
|
|
75
|
+
: sseBody(body.id, result);
|
|
76
|
+
return new Response(payload, {
|
|
77
|
+
status: 200,
|
|
78
|
+
headers: {
|
|
79
|
+
"content-type": contentType,
|
|
80
|
+
...(body.method === "initialize"
|
|
81
|
+
? { "mcp-session-id": "session-9" }
|
|
82
|
+
: {}),
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}) as typeof fetch;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("parseSseEventsV1", () => {
|
|
89
|
+
test("reads named events, multi-line data and comments", () => {
|
|
90
|
+
expect(
|
|
91
|
+
parseSseEventsV1(
|
|
92
|
+
": keep-alive\nevent: endpoint\ndata: /messages?s=1\n\n" +
|
|
93
|
+
'data: {"a":\ndata: 1}\n\n',
|
|
94
|
+
),
|
|
95
|
+
).toEqual([
|
|
96
|
+
{ event: "endpoint", data: "/messages?s=1" },
|
|
97
|
+
{ event: "message", data: '{"a":\n1}' },
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("the streamable-HTTP transport", () => {
|
|
103
|
+
test("handshakes, lists tools, and passes the server's schema through", async () => {
|
|
104
|
+
const exchanges: Exchange[] = [];
|
|
105
|
+
const client = new McpClient({
|
|
106
|
+
url: ENDPOINT,
|
|
107
|
+
transport: "streamable-http",
|
|
108
|
+
fetch: streamableServer({ exchanges }),
|
|
109
|
+
apiKey: "secret-key",
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const handshake = await client.connect();
|
|
113
|
+
const tools = await client.listTools();
|
|
114
|
+
|
|
115
|
+
expect(handshake).toEqual({
|
|
116
|
+
protocolVersion: "2025-06-18",
|
|
117
|
+
serverName: "Example",
|
|
118
|
+
serverVersion: "1.2.3",
|
|
119
|
+
});
|
|
120
|
+
expect(tools).toEqual([
|
|
121
|
+
{
|
|
122
|
+
name: "echo",
|
|
123
|
+
description: "Echo a message back.",
|
|
124
|
+
inputSchema: {
|
|
125
|
+
type: "object",
|
|
126
|
+
properties: { message: { type: "string" } },
|
|
127
|
+
required: ["message"],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
]);
|
|
131
|
+
expect(exchanges.map((exchange) => exchange.method)).toEqual([
|
|
132
|
+
"initialize",
|
|
133
|
+
"notifications/initialized",
|
|
134
|
+
"tools/list",
|
|
135
|
+
]);
|
|
136
|
+
// The key travels as a bearer token, and the session and protocol the
|
|
137
|
+
// server named are echoed on every later request.
|
|
138
|
+
expect(exchanges[0]!.headers.get("authorization")).toBe(
|
|
139
|
+
"Bearer secret-key",
|
|
140
|
+
);
|
|
141
|
+
expect(exchanges[2]!.headers.get("mcp-session-id")).toBe("session-9");
|
|
142
|
+
expect(exchanges[2]!.headers.get("mcp-protocol-version")).toBe(
|
|
143
|
+
"2025-06-18",
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("reads a reply delivered as an SSE message event", async () => {
|
|
148
|
+
const exchanges: Exchange[] = [];
|
|
149
|
+
const client = new McpClient({
|
|
150
|
+
url: ENDPOINT,
|
|
151
|
+
transport: "streamable-http",
|
|
152
|
+
fetch: streamableServer({
|
|
153
|
+
exchanges,
|
|
154
|
+
contentType: "text/event-stream",
|
|
155
|
+
}),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await client.connect();
|
|
159
|
+
expect((await client.listTools()).map((tool) => tool.name)).toEqual([
|
|
160
|
+
"echo",
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("carries the call's arguments and renders text content", async () => {
|
|
165
|
+
const exchanges: Exchange[] = [];
|
|
166
|
+
const client = new McpClient({
|
|
167
|
+
url: ENDPOINT,
|
|
168
|
+
transport: "streamable-http",
|
|
169
|
+
fetch: streamableServer({
|
|
170
|
+
exchanges,
|
|
171
|
+
onCall: (args) => ({ echoed: args }),
|
|
172
|
+
}),
|
|
173
|
+
});
|
|
174
|
+
await client.connect();
|
|
175
|
+
|
|
176
|
+
const result = await client.callTool("echo", { message: "hi" });
|
|
177
|
+
|
|
178
|
+
expect(result).toEqual({
|
|
179
|
+
content: JSON.stringify({ echoed: { message: "hi" } }),
|
|
180
|
+
isError: false,
|
|
181
|
+
});
|
|
182
|
+
expect(exchanges.at(-1)!.body.params).toEqual({
|
|
183
|
+
name: "echo",
|
|
184
|
+
arguments: { message: "hi" },
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("sends a header key under its own name when one is named", async () => {
|
|
189
|
+
const exchanges: Exchange[] = [];
|
|
190
|
+
const client = new McpClient({
|
|
191
|
+
url: ENDPOINT,
|
|
192
|
+
transport: "streamable-http",
|
|
193
|
+
fetch: streamableServer({ exchanges }),
|
|
194
|
+
apiKey: "secret-key",
|
|
195
|
+
headerName: "X-Api-Key",
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await client.connect();
|
|
199
|
+
|
|
200
|
+
expect(exchanges[0]!.headers.get("x-api-key")).toBe("secret-key");
|
|
201
|
+
expect(exchanges[0]!.headers.get("authorization")).toBeNull();
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("the legacy SSE transport", () => {
|
|
206
|
+
function sseServer(): typeof fetch {
|
|
207
|
+
const encoder = new TextEncoder();
|
|
208
|
+
let push: ((chunk: string) => void) | undefined;
|
|
209
|
+
return (async (input: string | URL | Request, init?: RequestInit) => {
|
|
210
|
+
if ((init?.method ?? "GET") === "GET") {
|
|
211
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
212
|
+
start(controller) {
|
|
213
|
+
push = (chunk) => controller.enqueue(encoder.encode(chunk));
|
|
214
|
+
push("event: endpoint\ndata: /mcp/messages?session=7\n\n");
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
return new Response(stream, {
|
|
218
|
+
status: 200,
|
|
219
|
+
headers: { "content-type": "text/event-stream" },
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
|
223
|
+
expect(String(input)).toBe(
|
|
224
|
+
"https://mcp.example.test/mcp/messages?session=7",
|
|
225
|
+
);
|
|
226
|
+
if (body.id !== undefined) {
|
|
227
|
+
push!(
|
|
228
|
+
sseBody(
|
|
229
|
+
body.id,
|
|
230
|
+
body.method === "initialize" ? INITIALIZE_RESULT : TOOLS_RESULT,
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return new Response("", { status: 202 });
|
|
235
|
+
}) as typeof fetch;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
test("opens the stream, learns the message endpoint and reads replies", async () => {
|
|
239
|
+
const client = new McpClient({
|
|
240
|
+
url: ENDPOINT,
|
|
241
|
+
transport: "sse",
|
|
242
|
+
fetch: sseServer(),
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
expect((await client.connect()).protocolVersion).toBe("2025-06-18");
|
|
246
|
+
expect((await client.listTools()).map((tool) => tool.name)).toEqual([
|
|
247
|
+
"echo",
|
|
248
|
+
]);
|
|
249
|
+
await client.close();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("refuses a message endpoint on another origin", async () => {
|
|
253
|
+
const encoder = new TextEncoder();
|
|
254
|
+
const client = new McpClient({
|
|
255
|
+
url: ENDPOINT,
|
|
256
|
+
transport: "sse",
|
|
257
|
+
fetch: (() =>
|
|
258
|
+
Promise.resolve(
|
|
259
|
+
new Response(
|
|
260
|
+
new ReadableStream<Uint8Array>({
|
|
261
|
+
start(controller) {
|
|
262
|
+
controller.enqueue(
|
|
263
|
+
encoder.encode(
|
|
264
|
+
"event: endpoint\ndata: https://elsewhere.test/m\n\n",
|
|
265
|
+
),
|
|
266
|
+
);
|
|
267
|
+
controller.close();
|
|
268
|
+
},
|
|
269
|
+
}),
|
|
270
|
+
{
|
|
271
|
+
status: 200,
|
|
272
|
+
headers: { "content-type": "text/event-stream" },
|
|
273
|
+
},
|
|
274
|
+
),
|
|
275
|
+
)) as unknown as typeof fetch,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
await expect(client.connect()).rejects.toThrow(/changed origin/);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("bounds and failures", () => {
|
|
283
|
+
test("refuses a response past the byte ceiling", async () => {
|
|
284
|
+
const client = new McpClient({
|
|
285
|
+
url: ENDPOINT,
|
|
286
|
+
transport: "streamable-http",
|
|
287
|
+
maxResponseBytes: 64,
|
|
288
|
+
fetch: (() =>
|
|
289
|
+
Promise.resolve(
|
|
290
|
+
Response.json({
|
|
291
|
+
jsonrpc: "2.0",
|
|
292
|
+
id: 1,
|
|
293
|
+
result: { protocolVersion: "x".repeat(200) },
|
|
294
|
+
}),
|
|
295
|
+
)) as unknown as typeof fetch,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
await expect(client.connect()).rejects.toThrow(/too large/);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("refuses a server offering more tools than the ceiling", async () => {
|
|
302
|
+
const client = new McpClient({
|
|
303
|
+
url: ENDPOINT,
|
|
304
|
+
transport: "streamable-http",
|
|
305
|
+
maxTools: 2,
|
|
306
|
+
fetch: (async (_input: unknown, init?: RequestInit) => {
|
|
307
|
+
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
|
308
|
+
if (body.id === undefined) return new Response("", { status: 202 });
|
|
309
|
+
return Response.json({
|
|
310
|
+
jsonrpc: "2.0",
|
|
311
|
+
id: body.id,
|
|
312
|
+
result:
|
|
313
|
+
body.method === "initialize"
|
|
314
|
+
? INITIALIZE_RESULT
|
|
315
|
+
: {
|
|
316
|
+
tools: [1, 2, 3].map((index) => ({
|
|
317
|
+
name: `tool-${index}`,
|
|
318
|
+
inputSchema: { type: "object" },
|
|
319
|
+
})),
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
}) as typeof fetch,
|
|
323
|
+
});
|
|
324
|
+
await client.connect();
|
|
325
|
+
|
|
326
|
+
await expect(client.listTools()).rejects.toThrow(/more than 2 tools/);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("reports a JSON-RPC error as a protocol failure", async () => {
|
|
330
|
+
const client = new McpClient({
|
|
331
|
+
url: ENDPOINT,
|
|
332
|
+
transport: "streamable-http",
|
|
333
|
+
fetch: (async (_input: unknown, init?: RequestInit) => {
|
|
334
|
+
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
|
335
|
+
return Response.json({
|
|
336
|
+
jsonrpc: "2.0",
|
|
337
|
+
id: body.id,
|
|
338
|
+
error: { code: -32_600, message: "unsupported protocol version" },
|
|
339
|
+
});
|
|
340
|
+
}) as typeof fetch,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
await expect(client.connect()).rejects.toThrow(McpProtocolError);
|
|
344
|
+
await expect(client.connect()).rejects.toThrow(
|
|
345
|
+
/unsupported protocol version/,
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("reports an HTTP rejection with the server's status", async () => {
|
|
350
|
+
const client = new McpClient({
|
|
351
|
+
url: ENDPOINT,
|
|
352
|
+
transport: "streamable-http",
|
|
353
|
+
fetch: (() =>
|
|
354
|
+
Promise.resolve(
|
|
355
|
+
new Response("Unauthorized", { status: 401 }),
|
|
356
|
+
)) as unknown as typeof fetch,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
await expect(client.connect()).rejects.toThrow(/401: Unauthorized/);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("carries a tool that reports its own error as isError", async () => {
|
|
363
|
+
const client = new McpClient({
|
|
364
|
+
url: ENDPOINT,
|
|
365
|
+
transport: "streamable-http",
|
|
366
|
+
fetch: (async (_input: unknown, init?: RequestInit) => {
|
|
367
|
+
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
|
368
|
+
if (body.id === undefined) return new Response("", { status: 202 });
|
|
369
|
+
return Response.json({
|
|
370
|
+
jsonrpc: "2.0",
|
|
371
|
+
id: body.id,
|
|
372
|
+
result:
|
|
373
|
+
body.method === "initialize"
|
|
374
|
+
? INITIALIZE_RESULT
|
|
375
|
+
: {
|
|
376
|
+
isError: true,
|
|
377
|
+
content: [{ type: "text", text: "no such record" }],
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
}) as typeof fetch,
|
|
381
|
+
});
|
|
382
|
+
await client.connect();
|
|
383
|
+
|
|
384
|
+
expect(await client.callTool("lookup", {})).toEqual({
|
|
385
|
+
content: "no such record",
|
|
386
|
+
isError: true,
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
});
|