@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,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gateway Contribution's authorization routes.
|
|
3
|
+
*
|
|
4
|
+
* The one thing these tests exist to hold down: the callback's identity comes
|
|
5
|
+
* from the signed state and from nowhere else. It is a `publicRoute`, so it
|
|
6
|
+
* runs before the gateway has authenticated anyone, and a query parameter must
|
|
7
|
+
* never be able to name the User whose Durable Object is opened.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { encodeAuthorizationState } from "@frockbot/connection-core";
|
|
11
|
+
import {
|
|
12
|
+
createMcpBackendContribution,
|
|
13
|
+
MCP_CALLBACK_ROUTE,
|
|
14
|
+
MCP_CONNECTIONS_ROUTE,
|
|
15
|
+
type McpAuthorizationCompletionRequestV1,
|
|
16
|
+
type McpAuthorizationStartRequestV1,
|
|
17
|
+
} from "./backend.js";
|
|
18
|
+
|
|
19
|
+
const SECRET = "an-independent-random-secret-0123456789";
|
|
20
|
+
const ORIGIN = "https://bot.example.test";
|
|
21
|
+
|
|
22
|
+
function host(overrides: Record<string, unknown> = {}) {
|
|
23
|
+
const starts: McpAuthorizationStartRequestV1[] = [];
|
|
24
|
+
const completions: Array<{
|
|
25
|
+
userId: string;
|
|
26
|
+
completion: McpAuthorizationCompletionRequestV1;
|
|
27
|
+
}> = [];
|
|
28
|
+
const revocations: Array<{ userId: string; connectionId: string }> = [];
|
|
29
|
+
const contribution = createMcpBackendContribution({
|
|
30
|
+
readMcpServers: () => Promise.reject(new Error("not used")) as never,
|
|
31
|
+
executeMcpCommand: () => Promise.reject(new Error("not used")) as never,
|
|
32
|
+
readSecret: (name) =>
|
|
33
|
+
name === "FROCKBOT_AUTHORIZATION_STATE_SECRET" ? SECRET : undefined,
|
|
34
|
+
startMcpAuthorization: (userId, start) => {
|
|
35
|
+
starts.push(start);
|
|
36
|
+
return Promise.resolve({
|
|
37
|
+
schemaVersion: 1,
|
|
38
|
+
status: "authorization-required",
|
|
39
|
+
connectionId: start.connectionId ?? `mcp-${start.commandId}`,
|
|
40
|
+
redirectUrl: "https://auth.example.test/authorize?state=x",
|
|
41
|
+
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
completeMcpAuthorization: (userId, completion) => {
|
|
45
|
+
completions.push({ userId, completion });
|
|
46
|
+
return Promise.resolve({
|
|
47
|
+
returnTarget: completion.returnTarget,
|
|
48
|
+
status: "ready" as const,
|
|
49
|
+
...(completion.nativeReturnNonce
|
|
50
|
+
? { nativeReturnNonce: completion.nativeReturnNonce }
|
|
51
|
+
: {}),
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
revokeMcpAuthorization: (userId, connectionId) => {
|
|
55
|
+
revocations.push({ userId, connectionId });
|
|
56
|
+
return Promise.resolve({ schemaVersion: 1, status: "revoked" as const });
|
|
57
|
+
},
|
|
58
|
+
...overrides,
|
|
59
|
+
});
|
|
60
|
+
return { contribution, starts, completions, revocations };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function callbackRequest(query: string) {
|
|
64
|
+
const url = new URL(`${ORIGIN}${MCP_CALLBACK_ROUTE}?${query}`);
|
|
65
|
+
return { request: new Request(url), url };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function state(overrides: Record<string, unknown> = {}) {
|
|
69
|
+
return encodeAuthorizationState(
|
|
70
|
+
{
|
|
71
|
+
schemaVersion: 1,
|
|
72
|
+
authorizationStateId: "auth-state-1",
|
|
73
|
+
userId: "user-1",
|
|
74
|
+
connectionId: "mcp-1",
|
|
75
|
+
returnTarget: "browser",
|
|
76
|
+
expiresAt: Date.now() + 600_000,
|
|
77
|
+
...overrides,
|
|
78
|
+
} as Parameters<typeof encodeAuthorizationState>[0],
|
|
79
|
+
SECRET,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe("the OAuth callback", () => {
|
|
84
|
+
test("takes its User from the signed state, never from the request", async () => {
|
|
85
|
+
const { contribution, completions } = host();
|
|
86
|
+
const signed = await state();
|
|
87
|
+
const { request, url } = callbackRequest(
|
|
88
|
+
`code=code-1&state=${encodeURIComponent(signed)}&user=someone-else`,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const response = await contribution.publicRoute!(request, url, {
|
|
92
|
+
// A different User is presented on the context, and it is ignored.
|
|
93
|
+
userId: "attacker",
|
|
94
|
+
client: "browser",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(response!.status).toBe(303);
|
|
98
|
+
expect(response!.headers.get("location")).toBe(
|
|
99
|
+
`${ORIGIN}/?connection=mcp-ready`,
|
|
100
|
+
);
|
|
101
|
+
expect(completions).toHaveLength(1);
|
|
102
|
+
expect(completions[0]!.userId).toBe("user-1");
|
|
103
|
+
expect(completions[0]!.completion).toMatchObject({
|
|
104
|
+
authorizationStateId: "auth-state-1",
|
|
105
|
+
connectionId: "mcp-1",
|
|
106
|
+
code: "code-1",
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("refuses a forged state", async () => {
|
|
111
|
+
const { contribution, completions } = host();
|
|
112
|
+
const forged = await encodeAuthorizationState(
|
|
113
|
+
{
|
|
114
|
+
schemaVersion: 1,
|
|
115
|
+
authorizationStateId: "auth-state-1",
|
|
116
|
+
userId: "user-2",
|
|
117
|
+
connectionId: "mcp-1",
|
|
118
|
+
returnTarget: "browser",
|
|
119
|
+
expiresAt: Date.now() + 600_000,
|
|
120
|
+
},
|
|
121
|
+
"a-different-independent-random-secret-1",
|
|
122
|
+
);
|
|
123
|
+
const { request, url } = callbackRequest(
|
|
124
|
+
`code=code-1&state=${encodeURIComponent(forged)}`,
|
|
125
|
+
);
|
|
126
|
+
const response = await contribution.publicRoute!(request, url, {
|
|
127
|
+
client: "browser",
|
|
128
|
+
});
|
|
129
|
+
expect(response!.status).toBe(400);
|
|
130
|
+
expect(completions).toHaveLength(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("refuses an absent state", async () => {
|
|
134
|
+
const { contribution, completions } = host();
|
|
135
|
+
const { request, url } = callbackRequest("code=code-1");
|
|
136
|
+
const response = await contribution.publicRoute!(request, url, {
|
|
137
|
+
client: "browser",
|
|
138
|
+
});
|
|
139
|
+
expect(response!.status).toBe(400);
|
|
140
|
+
expect(completions).toHaveLength(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("refuses an expired state", async () => {
|
|
144
|
+
const { contribution, completions } = host();
|
|
145
|
+
const signed = await state({ expiresAt: Date.now() - 1_000 });
|
|
146
|
+
const { request, url } = callbackRequest(
|
|
147
|
+
`code=code-1&state=${encodeURIComponent(signed)}`,
|
|
148
|
+
);
|
|
149
|
+
const response = await contribution.publicRoute!(request, url, {
|
|
150
|
+
client: "browser",
|
|
151
|
+
});
|
|
152
|
+
expect(response!.status).toBe(400);
|
|
153
|
+
expect(completions).toHaveLength(0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("carries an authorization-server error through as a failure", async () => {
|
|
157
|
+
const { contribution, completions } = host();
|
|
158
|
+
const signed = await state();
|
|
159
|
+
const { request, url } = callbackRequest(
|
|
160
|
+
`error=access_denied&state=${encodeURIComponent(signed)}`,
|
|
161
|
+
);
|
|
162
|
+
await contribution.publicRoute!(request, url, { client: "browser" });
|
|
163
|
+
expect(completions[0]!.completion).toMatchObject({
|
|
164
|
+
error: "access_denied",
|
|
165
|
+
});
|
|
166
|
+
expect(completions[0]!.completion.code).toBeUndefined();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("returns a desktop deep link when the state says so", async () => {
|
|
170
|
+
const { contribution } = host();
|
|
171
|
+
const signed = await state({
|
|
172
|
+
returnTarget: "desktop",
|
|
173
|
+
nativeReturnNonce: "nonce-1",
|
|
174
|
+
});
|
|
175
|
+
const { request, url } = callbackRequest(
|
|
176
|
+
`code=code-1&state=${encodeURIComponent(signed)}`,
|
|
177
|
+
);
|
|
178
|
+
const response = await contribution.publicRoute!(request, url, {
|
|
179
|
+
client: "browser",
|
|
180
|
+
});
|
|
181
|
+
expect(response!.headers.get("location")).toBe(
|
|
182
|
+
"com.frockbot.desktop:/connections?status=ready&nonce=nonce-1",
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("is the only route exposed publicly", async () => {
|
|
187
|
+
const { contribution } = host();
|
|
188
|
+
const url = new URL(`${ORIGIN}${MCP_CONNECTIONS_ROUTE}`);
|
|
189
|
+
expect(
|
|
190
|
+
await contribution.publicRoute!(
|
|
191
|
+
new Request(url, { method: "POST" }),
|
|
192
|
+
url,
|
|
193
|
+
{ client: "browser" },
|
|
194
|
+
),
|
|
195
|
+
).toBeUndefined();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("starting an authorization over the route", () => {
|
|
200
|
+
test("requires a session and mints a state that names that User", async () => {
|
|
201
|
+
const { contribution, starts } = host();
|
|
202
|
+
const url = new URL(`${ORIGIN}${MCP_CONNECTIONS_ROUTE}`);
|
|
203
|
+
const body = {
|
|
204
|
+
schemaVersion: 1,
|
|
205
|
+
type: "connection/start",
|
|
206
|
+
commandId: "connect-1",
|
|
207
|
+
connectionTypeId: "mcp-remote-oauth",
|
|
208
|
+
label: "Example",
|
|
209
|
+
settings: { url: "https://mcp.example.test/mcp" },
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
expect(
|
|
213
|
+
(await contribution.route(
|
|
214
|
+
new Request(url, { method: "POST", body: JSON.stringify(body) }),
|
|
215
|
+
url,
|
|
216
|
+
{ client: "browser" },
|
|
217
|
+
))!.status,
|
|
218
|
+
).toBe(401);
|
|
219
|
+
|
|
220
|
+
const response = await contribution.route(
|
|
221
|
+
new Request(url, { method: "POST", body: JSON.stringify(body) }),
|
|
222
|
+
url,
|
|
223
|
+
{ userId: "user-1", client: "browser" },
|
|
224
|
+
);
|
|
225
|
+
expect(response!.status).toBe(201);
|
|
226
|
+
expect(starts).toHaveLength(1);
|
|
227
|
+
expect(starts[0]).toMatchObject({
|
|
228
|
+
commandId: "connect-1",
|
|
229
|
+
redirectUri: `${ORIGIN}${MCP_CALLBACK_ROUTE}`,
|
|
230
|
+
returnTarget: "browser",
|
|
231
|
+
});
|
|
232
|
+
// The signed state is opaque here and never returned to the client except
|
|
233
|
+
// inside the host-authored redirect URL.
|
|
234
|
+
expect(starts[0]!.callbackState.split(".")).toHaveLength(2);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("refuses a command with fields this build does not know", async () => {
|
|
238
|
+
const { contribution } = host();
|
|
239
|
+
const url = new URL(`${ORIGIN}${MCP_CONNECTIONS_ROUTE}`);
|
|
240
|
+
const response = await contribution.route(
|
|
241
|
+
new Request(url, {
|
|
242
|
+
method: "POST",
|
|
243
|
+
body: JSON.stringify({
|
|
244
|
+
schemaVersion: 1,
|
|
245
|
+
type: "connection/start",
|
|
246
|
+
commandId: "connect-1",
|
|
247
|
+
connectionTypeId: "mcp-remote-oauth",
|
|
248
|
+
redirectUrl: "https://attacker.example.test",
|
|
249
|
+
}),
|
|
250
|
+
}),
|
|
251
|
+
url,
|
|
252
|
+
{ userId: "user-1", client: "browser" },
|
|
253
|
+
);
|
|
254
|
+
expect(response!.status).toBe(400);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("requires a native nonce from a desktop client, and none from a browser", async () => {
|
|
258
|
+
const { contribution } = host();
|
|
259
|
+
const url = new URL(`${ORIGIN}${MCP_CONNECTIONS_ROUTE}`);
|
|
260
|
+
const command = {
|
|
261
|
+
schemaVersion: 1,
|
|
262
|
+
type: "connection/start",
|
|
263
|
+
commandId: "connect-1",
|
|
264
|
+
connectionTypeId: "mcp-remote-oauth",
|
|
265
|
+
};
|
|
266
|
+
expect(
|
|
267
|
+
(await contribution.route(
|
|
268
|
+
new Request(url, { method: "POST", body: JSON.stringify(command) }),
|
|
269
|
+
url,
|
|
270
|
+
{ userId: "user-1", client: "desktop" },
|
|
271
|
+
))!.status,
|
|
272
|
+
).toBe(400);
|
|
273
|
+
expect(
|
|
274
|
+
(await contribution.route(
|
|
275
|
+
new Request(url, {
|
|
276
|
+
method: "POST",
|
|
277
|
+
body: JSON.stringify({ ...command, nativeReturnNonce: "nonce-1" }),
|
|
278
|
+
}),
|
|
279
|
+
url,
|
|
280
|
+
{ userId: "user-1", client: "browser" },
|
|
281
|
+
))!.status,
|
|
282
|
+
).toBe(400);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("revoking over the route", () => {
|
|
287
|
+
test("requires a session, and names the Connection from the path", async () => {
|
|
288
|
+
const { contribution, revocations } = host();
|
|
289
|
+
const url = new URL(`${ORIGIN}/api/plugins/mcp/connections/mcp-1/revoke`);
|
|
290
|
+
const response = await contribution.route(
|
|
291
|
+
new Request(url, { method: "POST" }),
|
|
292
|
+
url,
|
|
293
|
+
{ userId: "user-1", client: "browser" },
|
|
294
|
+
);
|
|
295
|
+
expect(await response!.json()).toEqual({
|
|
296
|
+
schemaVersion: 1,
|
|
297
|
+
status: "revoked",
|
|
298
|
+
});
|
|
299
|
+
expect(revocations).toEqual([{ userId: "user-1", connectionId: "mcp-1" }]);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("a deployment with no signing secret", () => {
|
|
304
|
+
test("has no authorization door at all, rather than an unsigned one", async () => {
|
|
305
|
+
const { contribution } = host({ readSecret: () => undefined });
|
|
306
|
+
for (const path of [
|
|
307
|
+
MCP_CONNECTIONS_ROUTE,
|
|
308
|
+
MCP_CALLBACK_ROUTE,
|
|
309
|
+
"/api/plugins/mcp/connections/mcp-1/revoke",
|
|
310
|
+
]) {
|
|
311
|
+
const url = new URL(`${ORIGIN}${path}`);
|
|
312
|
+
const response = await contribution.route(
|
|
313
|
+
new Request(url, { method: "POST" }),
|
|
314
|
+
url,
|
|
315
|
+
{ userId: "user-1", client: "browser" },
|
|
316
|
+
);
|
|
317
|
+
expect(response!.status).toBe(503);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("refuses a secret that is the session secret", async () => {
|
|
322
|
+
const { contribution } = host({
|
|
323
|
+
readSecret: () => SECRET,
|
|
324
|
+
});
|
|
325
|
+
const url = new URL(`${ORIGIN}${MCP_CONNECTIONS_ROUTE}`);
|
|
326
|
+
const response = await contribution.route(
|
|
327
|
+
new Request(url, { method: "POST" }),
|
|
328
|
+
url,
|
|
329
|
+
{ userId: "user-1", client: "browser" },
|
|
330
|
+
);
|
|
331
|
+
expect(response!.status).toBe(503);
|
|
332
|
+
});
|
|
333
|
+
});
|