@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
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
|
+
}
|