@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/src/backend.ts ADDED
@@ -0,0 +1,490 @@
1
+ /**
2
+ * The MCP gateway Contribution: the status projection and the lifecycle
3
+ * commands, on routes this Package owns.
4
+ *
5
+ * The Settings Package already carries `/api/connections`, and an MCP server
6
+ * is a Connection, so rename and remove need nothing here. What does need a
7
+ * route is everything the provider-neutral Connection command union has no
8
+ * word for — a server's durable state, its instructions, and its restart —
9
+ * and the honest place for it is `plugin-mcp`, not a Settings Package that
10
+ * would then have to know what MCP is.
11
+ *
12
+ * The gateway dispatches every mounted Contribution in turn and takes the
13
+ * first non-`undefined` Response, so this is purely additive: no route, host,
14
+ * or decoder in `plugin-settings` changes.
15
+ */
16
+ import {
17
+ decodeAuthorizationState,
18
+ encodeAuthorizationState,
19
+ isStrongAuthorizationStateSecretV1,
20
+ type AuthorizationState,
21
+ type ConnectionCompletionResult,
22
+ type RevokeConnectionResult,
23
+ type StartConnectionResult,
24
+ } from "@frockbot/connection-core";
25
+ import type { Plugin } from "cordis";
26
+ import {
27
+ decodeMcpLifecycleReceiptV1,
28
+ decodeMcpServerStatusViewV1,
29
+ type McpLifecycleReceiptV1,
30
+ type McpServerStatusViewV1,
31
+ } from "./records.js";
32
+ import { MCP_PACKAGE_ID } from "./agent.js";
33
+ import { mcpAuthorizationConnectionIdV1 } from "./oauth-records.js";
34
+
35
+ export const MCP_SERVERS_ROUTE = "/api/mcp/servers";
36
+ export const MCP_CONNECTIONS_ROUTE = "/api/plugins/mcp/connections";
37
+ export const MCP_CALLBACK_ROUTE = "/api/plugins/mcp/callback";
38
+ const MCP_REVOKE_PATTERN =
39
+ /^\/api\/plugins\/mcp\/connections\/([^/]+)\/revoke$/;
40
+
41
+ /** How long a minted authorization state is good for. */
42
+ const AUTHORIZATION_STATE_TTL_MS = 10 * 60_000;
43
+
44
+ /**
45
+ * The body `POST /api/plugins/mcp/connections` accepts.
46
+ *
47
+ * Deliberately not `StartConnectionCommandV1`: that command is the
48
+ * provider-neutral one, and it has no word for the server URL a User is
49
+ * naming or the Connection they are reconnecting. Decoding MCP's own shape
50
+ * here keeps `configuration-core` from growing an MCP-shaped field.
51
+ */
52
+ export interface McpStartAuthorizationCommandV1 {
53
+ schemaVersion: 1;
54
+ type: "connection/start";
55
+ commandId: string;
56
+ connectionTypeId: string;
57
+ /** Reconnecting an existing Connection; absent creates one. */
58
+ connectionId?: string;
59
+ label?: string;
60
+ settings?: Record<string, unknown>;
61
+ nativeReturnNonce?: string;
62
+ }
63
+
64
+ function identifier(value: unknown, label: string): string {
65
+ if (
66
+ typeof value !== "string" ||
67
+ !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value)
68
+ ) {
69
+ throw new Error(`MCP authorization ${label} is invalid`);
70
+ }
71
+ return value;
72
+ }
73
+
74
+ export function decodeMcpStartAuthorizationCommandV1(
75
+ input: unknown,
76
+ ): McpStartAuthorizationCommandV1 {
77
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
78
+ throw new Error("MCP authorization command is invalid");
79
+ }
80
+ const value = input as Record<string, unknown>;
81
+ const allowed = new Set([
82
+ "schemaVersion",
83
+ "type",
84
+ "commandId",
85
+ "connectionTypeId",
86
+ "connectionId",
87
+ "label",
88
+ "settings",
89
+ "nativeReturnNonce",
90
+ ]);
91
+ for (const key of Object.keys(value)) {
92
+ if (!allowed.has(key)) {
93
+ throw new Error(`MCP authorization command carries unknown "${key}"`);
94
+ }
95
+ }
96
+ if (value.schemaVersion !== 1 || value.type !== "connection/start") {
97
+ throw new Error("MCP authorization command is unsupported");
98
+ }
99
+ if (
100
+ value.settings !== undefined &&
101
+ (!value.settings ||
102
+ typeof value.settings !== "object" ||
103
+ Array.isArray(value.settings))
104
+ ) {
105
+ throw new Error("MCP authorization settings are invalid");
106
+ }
107
+ if (
108
+ value.label !== undefined &&
109
+ (typeof value.label !== "string" ||
110
+ value.label.length === 0 ||
111
+ value.label.length > 120)
112
+ ) {
113
+ throw new Error("MCP authorization label is invalid");
114
+ }
115
+ return {
116
+ schemaVersion: 1,
117
+ type: "connection/start",
118
+ commandId: identifier(value.commandId, "commandId"),
119
+ connectionTypeId: identifier(value.connectionTypeId, "connectionTypeId"),
120
+ ...(value.connectionId === undefined
121
+ ? {}
122
+ : { connectionId: identifier(value.connectionId, "connectionId") }),
123
+ ...(value.label === undefined ? {} : { label: value.label as string }),
124
+ ...(value.settings === undefined
125
+ ? {}
126
+ : { settings: value.settings as Record<string, unknown> }),
127
+ ...(value.nativeReturnNonce === undefined
128
+ ? {}
129
+ : {
130
+ nativeReturnNonce: identifier(
131
+ value.nativeReturnNonce,
132
+ "nativeReturnNonce",
133
+ ),
134
+ }),
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Where the User's browser is sent once the callback has been handled.
140
+ *
141
+ * Byte-for-byte the shape `plugin-composio` uses, because it is the shape the
142
+ * clients already read: a browser lands back on `/?connection=mcp-<status>`,
143
+ * and a desktop shell on its own deep link carrying the nonce it minted.
144
+ */
145
+ export function mcpConnectionCompletionResponse(
146
+ url: URL,
147
+ target: "browser" | "desktop",
148
+ status: "ready" | "pending" | "failed",
149
+ nativeReturnNonce?: string,
150
+ ): Response {
151
+ const destination =
152
+ target === "desktop"
153
+ ? `com.frockbot.desktop:/connections?status=${status}${
154
+ nativeReturnNonce
155
+ ? `&nonce=${encodeURIComponent(nativeReturnNonce)}`
156
+ : ""
157
+ }`
158
+ : new URL(`/?connection=mcp-${status}`, url.origin).toString();
159
+ return new Response(null, {
160
+ status: 303,
161
+ headers: { location: destination },
162
+ });
163
+ }
164
+
165
+ export interface McpGatewayHost {
166
+ readMcpServers(userId: string): Promise<McpServerStatusViewV1>;
167
+ executeMcpCommand(
168
+ userId: string,
169
+ command: unknown,
170
+ ): Promise<McpLifecycleReceiptV1>;
171
+ /**
172
+ * The `mcp-oauth` seams, absent on a deployment that configures no
173
+ * authorization-state secret. All three land in the User Durable Object: this
174
+ * Contribution signs a state and forwards, and performs no OAuth call.
175
+ */
176
+ startMcpAuthorization?(
177
+ userId: string,
178
+ input: McpAuthorizationStartRequestV1,
179
+ ): Promise<StartConnectionResult>;
180
+ completeMcpAuthorization?(
181
+ userId: string,
182
+ input: McpAuthorizationCompletionRequestV1,
183
+ ): Promise<ConnectionCompletionResult>;
184
+ revokeMcpAuthorization?(
185
+ userId: string,
186
+ connectionId: string,
187
+ ): Promise<RevokeConnectionResult>;
188
+ /** Reads a deployment secret. Only `FROCKBOT_AUTHORIZATION_STATE_SECRET` is read. */
189
+ readSecret?(name: string): string | undefined;
190
+ /**
191
+ * The absolute origin the callback is reachable at, when the deployment
192
+ * pins one. Absent takes the origin the request arrived on, which is what a
193
+ * development host and a preview deployment both need.
194
+ */
195
+ callbackBaseUrl?: string;
196
+ }
197
+
198
+ export interface McpAuthorizationStartRequestV1 {
199
+ commandId: string;
200
+ connectionId?: string;
201
+ label?: string;
202
+ settings?: Record<string, unknown>;
203
+ redirectUri: string;
204
+ callbackState: string;
205
+ authorizationStateId: string;
206
+ authorizationStateExpiresAt: number;
207
+ returnTarget: "browser" | "desktop";
208
+ nativeReturnNonce?: string;
209
+ }
210
+
211
+ export interface McpAuthorizationCompletionRequestV1 {
212
+ authorizationStateId: string;
213
+ connectionId: string;
214
+ returnTarget: "browser" | "desktop";
215
+ nativeReturnNonce?: string;
216
+ code?: string;
217
+ error?: string;
218
+ }
219
+
220
+ export interface McpBackendRouteContribution {
221
+ packageId: string;
222
+ /**
223
+ * The OAuth callback, which runs *before* the gateway has authenticated
224
+ * anyone. It has to: an authorization server redirects the User's browser
225
+ * here with no FrockBot session attached. The identity it acts as comes from
226
+ * the HMAC-signed `state` and from nowhere else — `context.userId` is not
227
+ * consulted on this path, and no query parameter is trusted for identity.
228
+ */
229
+ publicRoute?(
230
+ request: Request,
231
+ url: URL,
232
+ context: { userId?: string; client: "browser" | "desktop" },
233
+ ): Promise<Response | undefined>;
234
+ route(
235
+ request: Request,
236
+ url: URL,
237
+ context: { userId?: string; client?: "browser" | "desktop" },
238
+ ): Promise<Response | undefined>;
239
+ }
240
+
241
+ function jsonError(status: number, message: string): Response {
242
+ return Response.json({ error: message }, { status });
243
+ }
244
+
245
+ /**
246
+ * The signing secret, or `undefined` when this deployment has none.
247
+ *
248
+ * The same two checks `plugin-composio` makes, for the same reason: this
249
+ * secret is the only thing standing between a forged query string and acting
250
+ * as another User, so a weak one is refused at construction rather than
251
+ * trusted at callback time, and it may not be the session secret — one
252
+ * compromise must not become two.
253
+ */
254
+ function authorizationStateSecret(host: McpGatewayHost): string | undefined {
255
+ const secret = host.readSecret?.("FROCKBOT_AUTHORIZATION_STATE_SECRET");
256
+ if (
257
+ !secret ||
258
+ !isStrongAuthorizationStateSecretV1(secret) ||
259
+ secret === host.readSecret?.("BETTER_AUTH_SECRET")
260
+ ) {
261
+ return undefined;
262
+ }
263
+ return secret;
264
+ }
265
+
266
+ export function createMcpBackendContribution(
267
+ host: McpGatewayHost,
268
+ ): McpBackendRouteContribution {
269
+ const secret = authorizationStateSecret(host);
270
+ const authorizationConfigured =
271
+ secret !== undefined &&
272
+ host.startMcpAuthorization !== undefined &&
273
+ host.completeMcpAuthorization !== undefined &&
274
+ host.revokeMcpAuthorization !== undefined;
275
+
276
+ const callbackUrl = (url: URL): string =>
277
+ new URL(MCP_CALLBACK_ROUTE, host.callbackBaseUrl ?? url.origin).toString();
278
+
279
+ const routeAuthorization = async (
280
+ request: Request,
281
+ url: URL,
282
+ context: { userId?: string; client?: "browser" | "desktop" },
283
+ ): Promise<Response | undefined> => {
284
+ const isStart = url.pathname === MCP_CONNECTIONS_ROUTE;
285
+ const revokeMatch = url.pathname.match(MCP_REVOKE_PATTERN);
286
+ const isCallback = url.pathname === MCP_CALLBACK_ROUTE;
287
+ if (!isStart && !revokeMatch && !isCallback) return undefined;
288
+ if (!authorizationConfigured || !secret) {
289
+ return jsonError(503, "MCP authorization is not configured");
290
+ }
291
+ const client = context.client === "desktop" ? "desktop" : "browser";
292
+
293
+ if (isCallback) {
294
+ if (request.method !== "GET") {
295
+ return jsonError(405, "method not allowed");
296
+ }
297
+ // Identity, and only identity, comes from here. The `code`, the
298
+ // `connectionId`, the `state` — everything else on this URL was chosen
299
+ // by whoever sent the browser, and the signature is what says which
300
+ // User's Durable Object may be opened at all.
301
+ const encodedState = url.searchParams.get("state");
302
+ if (!encodedState) {
303
+ return jsonError(400, "MCP callback state is required");
304
+ }
305
+ let state: AuthorizationState;
306
+ try {
307
+ state = await decodeAuthorizationState(encodedState, secret);
308
+ } catch (error) {
309
+ return jsonError(
310
+ 400,
311
+ error instanceof Error ? error.message : "MCP callback is invalid",
312
+ );
313
+ }
314
+ const code = url.searchParams.get("code") ?? undefined;
315
+ const failure = url.searchParams.get("error") ?? undefined;
316
+ try {
317
+ const result = await host.completeMcpAuthorization!(state.userId, {
318
+ authorizationStateId: state.authorizationStateId,
319
+ connectionId: state.connectionId,
320
+ returnTarget: state.returnTarget,
321
+ ...(state.nativeReturnNonce
322
+ ? { nativeReturnNonce: state.nativeReturnNonce }
323
+ : {}),
324
+ ...(code ? { code } : {}),
325
+ ...(failure ? { error: failure } : {}),
326
+ });
327
+ return mcpConnectionCompletionResponse(
328
+ url,
329
+ result.returnTarget,
330
+ result.status,
331
+ result.nativeReturnNonce,
332
+ );
333
+ } catch (error) {
334
+ return jsonError(
335
+ 400,
336
+ error instanceof Error ? error.message : "MCP authorization failed",
337
+ );
338
+ }
339
+ }
340
+
341
+ if (!context.userId) return jsonError(401, "authentication required");
342
+ if (request.method !== "POST") {
343
+ return jsonError(405, "method not allowed");
344
+ }
345
+
346
+ if (revokeMatch) {
347
+ let connectionId: string;
348
+ try {
349
+ connectionId = identifier(
350
+ decodeURIComponent(revokeMatch[1]!),
351
+ "connectionId",
352
+ );
353
+ } catch {
354
+ return jsonError(400, "connectionId is invalid");
355
+ }
356
+ try {
357
+ return Response.json(
358
+ await host.revokeMcpAuthorization!(context.userId, connectionId),
359
+ );
360
+ } catch (error) {
361
+ return jsonError(
362
+ 400,
363
+ error instanceof Error ? error.message : "MCP revocation failed",
364
+ );
365
+ }
366
+ }
367
+
368
+ let command: McpStartAuthorizationCommandV1;
369
+ try {
370
+ command = decodeMcpStartAuthorizationCommandV1(await request.json());
371
+ } catch (error) {
372
+ return jsonError(
373
+ 400,
374
+ error instanceof Error
375
+ ? error.message
376
+ : "MCP authorization command is invalid",
377
+ );
378
+ }
379
+ if (
380
+ (client === "desktop" && !command.nativeReturnNonce) ||
381
+ (client === "browser" && command.nativeReturnNonce !== undefined)
382
+ ) {
383
+ return jsonError(400, "nativeReturnNonce is invalid");
384
+ }
385
+ const authorizationStateId = crypto.randomUUID();
386
+ const authorizationStateExpiresAt = Date.now() + AUTHORIZATION_STATE_TTL_MS;
387
+ try {
388
+ // The state is signed here, before the User Durable Object is asked for
389
+ // anything: it binds this callback to this User, this Connection and one
390
+ // single-use id, and the gateway keeps no copy of it.
391
+ const callbackState = await encodeAuthorizationState(
392
+ {
393
+ schemaVersion: 1,
394
+ authorizationStateId,
395
+ userId: context.userId,
396
+ connectionId:
397
+ command.connectionId ??
398
+ mcpAuthorizationConnectionIdV1(command.commandId),
399
+ returnTarget: client,
400
+ expiresAt: authorizationStateExpiresAt,
401
+ ...(command.nativeReturnNonce
402
+ ? { nativeReturnNonce: command.nativeReturnNonce }
403
+ : {}),
404
+ },
405
+ secret,
406
+ );
407
+ const started = await host.startMcpAuthorization!(context.userId, {
408
+ commandId: command.commandId,
409
+ ...(command.connectionId ? { connectionId: command.connectionId } : {}),
410
+ ...(command.label ? { label: command.label } : {}),
411
+ ...(command.settings ? { settings: command.settings } : {}),
412
+ redirectUri: callbackUrl(url),
413
+ callbackState,
414
+ authorizationStateId,
415
+ authorizationStateExpiresAt,
416
+ returnTarget: client,
417
+ ...(command.nativeReturnNonce
418
+ ? { nativeReturnNonce: command.nativeReturnNonce }
419
+ : {}),
420
+ });
421
+ return Response.json(started, { status: 201 });
422
+ } catch (error) {
423
+ return jsonError(
424
+ 400,
425
+ error instanceof Error ? error.message : "MCP authorization failed",
426
+ );
427
+ }
428
+ };
429
+
430
+ const contribution: McpBackendRouteContribution = {
431
+ packageId: MCP_PACKAGE_ID,
432
+ async route(request, url, context) {
433
+ const authorization = await routeAuthorization(request, url, context);
434
+ if (authorization) return authorization;
435
+ if (!context.userId) return undefined;
436
+ if (url.pathname !== MCP_SERVERS_ROUTE) return undefined;
437
+ if (request.method === "GET") {
438
+ try {
439
+ return Response.json(
440
+ decodeMcpServerStatusViewV1(
441
+ await host.readMcpServers(context.userId),
442
+ ),
443
+ );
444
+ } catch (error) {
445
+ return jsonError(
446
+ 400,
447
+ error instanceof Error ? error.message : "MCP status read failed",
448
+ );
449
+ }
450
+ }
451
+ if (request.method !== "POST") {
452
+ return jsonError(405, "method not allowed");
453
+ }
454
+ try {
455
+ // Decoded on the far side of the seam, in the Durable Object that
456
+ // owns the records; the receipt is decoded again on the way back so
457
+ // the client never sees a shape this build did not produce.
458
+ return Response.json(
459
+ decodeMcpLifecycleReceiptV1(
460
+ await host.executeMcpCommand(context.userId, await request.json()),
461
+ ),
462
+ );
463
+ } catch (error) {
464
+ return jsonError(
465
+ 400,
466
+ error instanceof Error
467
+ ? error.message
468
+ : "MCP lifecycle command failed",
469
+ );
470
+ }
471
+ },
472
+ };
473
+ // The callback, and nothing else, is public. It runs before authentication
474
+ // because it has to; every other route on this Contribution still requires a
475
+ // session.
476
+ contribution.publicRoute = (request, url, context) =>
477
+ url.pathname === MCP_CALLBACK_ROUTE
478
+ ? contribution.route(request, url, context)
479
+ : Promise.resolve(undefined);
480
+ return contribution;
481
+ }
482
+
483
+ export namespace createMcpBackendContribution {
484
+ export function plugin(
485
+ host: McpGatewayHost,
486
+ lifecycle: { mount(value: McpBackendRouteContribution): () => void },
487
+ ): Plugin {
488
+ return () => lifecycle.mount(createMcpBackendContribution(host));
489
+ }
490
+ }