@frockbot/plugin-mcp 0.1.3 → 0.2.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/package.json +8 -8
- package/src/agent.test.ts +12 -13
- package/src/agent.ts +22 -24
- package/src/lifecycle-tools.ts +5 -150
- package/src/mcp-client.ts +5 -4
- package/src/oauth-user.test.ts +0 -45
- package/src/records.test.ts +7 -7
- package/src/records.ts +4 -33
- package/src/user.ts +5 -42
- package/src/connect-card.test.ts +0 -226
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@frockbot/configuration-core": "0.
|
|
29
|
-
"@frockbot/connection-core": "0.
|
|
30
|
-
"@frockbot/kernel-contracts": "0.
|
|
31
|
-
"@frockbot/plugin-credentials": "0.
|
|
32
|
-
"@frockbot/plugin-settings": "0.
|
|
33
|
-
"@frockbot/plugin-tools": "0.
|
|
34
|
-
"@frockbot/plugin-web": "0.
|
|
28
|
+
"@frockbot/configuration-core": "0.2.0",
|
|
29
|
+
"@frockbot/connection-core": "0.2.0",
|
|
30
|
+
"@frockbot/kernel-contracts": "0.2.0",
|
|
31
|
+
"@frockbot/plugin-credentials": "0.2.0",
|
|
32
|
+
"@frockbot/plugin-settings": "0.2.0",
|
|
33
|
+
"@frockbot/plugin-tools": "0.2.0",
|
|
34
|
+
"@frockbot/plugin-web": "0.2.0",
|
|
35
35
|
"cordis": "4.0.0-rc.8"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
package/src/agent.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
createConfiguredMcpRuntimeContribution,
|
|
7
7
|
decodeMcpConnectionSettingsV1,
|
|
8
8
|
MAX_MCP_SERVERS_PER_USER_V1,
|
|
9
|
-
|
|
9
|
+
mcpCapabilityResolutionV1,
|
|
10
10
|
mcpConnectionLifecycleV1,
|
|
11
11
|
mcpServerSlugV1,
|
|
12
12
|
mcpToolNameV1,
|
|
@@ -96,20 +96,19 @@ function server(options: {
|
|
|
96
96
|
async function mount(config: {
|
|
97
97
|
fetch: typeof fetch;
|
|
98
98
|
connection?: ConnectionView;
|
|
99
|
-
|
|
99
|
+
capabilityIndex?: number;
|
|
100
100
|
failures?: string[];
|
|
101
101
|
outcomes?: unknown[];
|
|
102
102
|
}): Promise<{ root: Context; mounted: boolean }> {
|
|
103
103
|
const plugin = await createConfiguredMcpRuntimeContribution({
|
|
104
|
-
|
|
104
|
+
capability: {
|
|
105
105
|
packageId: "mcp",
|
|
106
106
|
capabilityId: "mcp-tools",
|
|
107
107
|
connectionId: "mcp-1",
|
|
108
|
-
state: "enabled",
|
|
109
108
|
},
|
|
110
|
-
...(config.
|
|
109
|
+
...(config.capabilityIndex === undefined
|
|
111
110
|
? {}
|
|
112
|
-
: {
|
|
111
|
+
: { capabilityIndex: config.capabilityIndex }),
|
|
113
112
|
userId: "user-1",
|
|
114
113
|
readSecret: () => undefined,
|
|
115
114
|
authorizeConnection: () =>
|
|
@@ -170,7 +169,7 @@ describe("Connection-scoped settings", () => {
|
|
|
170
169
|
});
|
|
171
170
|
});
|
|
172
171
|
|
|
173
|
-
describe("mounting one
|
|
172
|
+
describe("mounting one enabled Capability", () => {
|
|
174
173
|
test("registers every listed tool with the server's own schema", async () => {
|
|
175
174
|
const { root } = await mount({ fetch: server({}) });
|
|
176
175
|
|
|
@@ -284,7 +283,7 @@ describe("mounting one Assignment", () => {
|
|
|
284
283
|
const { mounted } = await mount({
|
|
285
284
|
failures,
|
|
286
285
|
fetch: server({}),
|
|
287
|
-
|
|
286
|
+
capabilityIndex: MAX_MCP_SERVERS_PER_USER_V1,
|
|
288
287
|
});
|
|
289
288
|
|
|
290
289
|
expect(mounted).toBe(false);
|
|
@@ -385,13 +384,13 @@ describe("the durable lifecycle a mount participates in", () => {
|
|
|
385
384
|
});
|
|
386
385
|
});
|
|
387
386
|
|
|
388
|
-
test("the
|
|
389
|
-
const before =
|
|
390
|
-
|
|
387
|
+
test("the Capability's resolution key moves with the server epoch", () => {
|
|
388
|
+
const before = mcpCapabilityResolutionV1({
|
|
389
|
+
capability: { connectionId: "mcp-1" },
|
|
391
390
|
connection: connection({ safeMetadata: { serverEpoch: 1 } }),
|
|
392
391
|
});
|
|
393
|
-
const restarted =
|
|
394
|
-
|
|
392
|
+
const restarted = mcpCapabilityResolutionV1({
|
|
393
|
+
capability: { connectionId: "mcp-1" },
|
|
395
394
|
connection: connection({ safeMetadata: { serverEpoch: 2 } }),
|
|
396
395
|
});
|
|
397
396
|
expect(restarted).not.toBe(before);
|
package/src/agent.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The runtime Contribution: one enabled
|
|
2
|
+
* The runtime Contribution: one enabled `mcp-tools` Capability becomes one
|
|
3
3
|
* remote MCP server's tools in the Bot's tool registry.
|
|
4
4
|
*
|
|
5
5
|
* Everything the Bot ever sees of a server passes through here, and every byte
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
import { mcpAuthorizationRequiredV1 } from "./oauth.js";
|
|
28
28
|
import { decodeOutboundMcpUrlV1 } from "./ssrf.js";
|
|
29
29
|
import {
|
|
30
|
-
|
|
30
|
+
mcpCapabilityResolutionKeyV1,
|
|
31
31
|
mcpFailureCodeV1,
|
|
32
32
|
MAX_MCP_INSTRUCTIONS_BYTES_V1,
|
|
33
33
|
type McpFailureCodeV1,
|
|
@@ -62,8 +62,8 @@ export const MCP_TOOL_SUBAGENT_ROLES: readonly string[] = ["executor"];
|
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
64
|
* The durable per-User ceiling on remote MCP servers. Counted over the enabled
|
|
65
|
-
*
|
|
66
|
-
* by
|
|
65
|
+
* Capabilities of this Package, so a Bot cannot be handed a seventeenth server
|
|
66
|
+
* by enabling one more Connection.
|
|
67
67
|
*/
|
|
68
68
|
export const MAX_MCP_SERVERS_PER_USER_V1 = 16;
|
|
69
69
|
|
|
@@ -200,18 +200,17 @@ function isObject(value: unknown): value is Record<string, unknown> {
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
export interface McpRuntimeContributionConfig {
|
|
203
|
-
|
|
203
|
+
capability: {
|
|
204
204
|
packageId: string;
|
|
205
205
|
capabilityId: string;
|
|
206
206
|
connectionId?: string;
|
|
207
|
-
state: string;
|
|
208
207
|
};
|
|
209
208
|
/**
|
|
210
|
-
* This
|
|
209
|
+
* This Capability's ordinal among the enabled ones of this Package,
|
|
211
210
|
* which is what makes the per-User server ceiling countable from inside a
|
|
212
|
-
* per-
|
|
211
|
+
* per-Capability factory.
|
|
213
212
|
*/
|
|
214
|
-
|
|
213
|
+
capabilityIndex?: number;
|
|
215
214
|
userId: string;
|
|
216
215
|
readSecret(name: string): string | undefined;
|
|
217
216
|
authorizeConnection(): Promise<ConnectionView>;
|
|
@@ -240,7 +239,7 @@ export interface McpRuntimeContributionConfig {
|
|
|
240
239
|
}
|
|
241
240
|
|
|
242
241
|
/**
|
|
243
|
-
* Resolve one
|
|
242
|
+
* Resolve one enabled Capability into a mounted runtime Plugin. The handshake and
|
|
244
243
|
* `tools/list` happen here, before the Plugin mounts, so a server that cannot
|
|
245
244
|
* be reached contributes nothing instead of half-registering.
|
|
246
245
|
*/
|
|
@@ -248,23 +247,22 @@ export async function createConfiguredMcpRuntimeContribution(
|
|
|
248
247
|
config: McpRuntimeContributionConfig,
|
|
249
248
|
): Promise<Plugin.Function | undefined> {
|
|
250
249
|
if (
|
|
251
|
-
config.
|
|
252
|
-
config.
|
|
253
|
-
config.
|
|
254
|
-
!config.assignment.connectionId
|
|
250
|
+
config.capability.packageId !== MCP_PACKAGE_ID ||
|
|
251
|
+
config.capability.capabilityId !== MCP_CAPABILITY_ID ||
|
|
252
|
+
!config.capability.connectionId
|
|
255
253
|
) {
|
|
256
254
|
return undefined;
|
|
257
255
|
}
|
|
258
|
-
if ((config.
|
|
256
|
+
if ((config.capabilityIndex ?? 0) >= MAX_MCP_SERVERS_PER_USER_V1) {
|
|
259
257
|
config.onFailure?.(
|
|
260
|
-
`A User may
|
|
258
|
+
`A User may enable at most ${MAX_MCP_SERVERS_PER_USER_V1} MCP servers`,
|
|
261
259
|
);
|
|
262
260
|
return undefined;
|
|
263
261
|
}
|
|
264
262
|
const fetchImpl = config.fetch ?? boundFetch;
|
|
265
263
|
let client: McpClient | undefined;
|
|
266
264
|
let lifecycle: { serverEpoch?: number; instructions?: string } = {};
|
|
267
|
-
const connectionId = config.
|
|
265
|
+
const connectionId = config.capability.connectionId;
|
|
268
266
|
try {
|
|
269
267
|
const connection = await config.authorizeConnection();
|
|
270
268
|
lifecycle = mcpConnectionLifecycleV1(connection);
|
|
@@ -275,7 +273,7 @@ export async function createConfiguredMcpRuntimeContribution(
|
|
|
275
273
|
const apiKey =
|
|
276
274
|
connection.connectionTypeId === MCP_KEYED_CONNECTION_TYPE_ID ||
|
|
277
275
|
connection.connectionTypeId === MCP_OAUTH_CONNECTION_TYPE_ID
|
|
278
|
-
? await
|
|
276
|
+
? await openEnabledCredential(config, connection)
|
|
279
277
|
: undefined;
|
|
280
278
|
client = new McpClient({
|
|
281
279
|
url: settings.url,
|
|
@@ -372,18 +370,18 @@ export async function createConfiguredMcpRuntimeContribution(
|
|
|
372
370
|
}
|
|
373
371
|
|
|
374
372
|
/**
|
|
375
|
-
* What one
|
|
373
|
+
* What one enabled `mcp-tools` Capability resolves to, including the server's
|
|
376
374
|
* `serverEpoch`. A restart bumps the epoch, so this key changes, so the next
|
|
377
375
|
* admitted Turn resolves a different mount and re-handshakes — while the
|
|
378
376
|
* in-flight Turn, holding the client it already mounted, is untouched.
|
|
379
377
|
*/
|
|
380
|
-
export function
|
|
381
|
-
|
|
378
|
+
export function mcpCapabilityResolutionV1(config: {
|
|
379
|
+
capability: { connectionId?: string };
|
|
382
380
|
connection: { generation?: string; safeMetadata?: Record<string, unknown> };
|
|
383
381
|
}): string {
|
|
384
382
|
const lifecycle = mcpConnectionLifecycleV1(config.connection);
|
|
385
|
-
return
|
|
386
|
-
connectionId: config.
|
|
383
|
+
return mcpCapabilityResolutionKeyV1({
|
|
384
|
+
connectionId: config.capability.connectionId ?? "",
|
|
387
385
|
...(config.connection.generation === undefined
|
|
388
386
|
? {}
|
|
389
387
|
: { connectionGeneration: config.connection.generation }),
|
|
@@ -393,7 +391,7 @@ export function mcpAssignmentResolutionV1(config: {
|
|
|
393
391
|
});
|
|
394
392
|
}
|
|
395
393
|
|
|
396
|
-
async function
|
|
394
|
+
async function openEnabledCredential(
|
|
397
395
|
config: McpRuntimeContributionConfig,
|
|
398
396
|
connection: ConnectionView,
|
|
399
397
|
): Promise<string> {
|
package/src/lifecycle-tools.ts
CHANGED
|
@@ -12,14 +12,9 @@
|
|
|
12
12
|
*
|
|
13
13
|
* These tools need no Connection: they read and write the User's own MCP
|
|
14
14
|
* records through the host the Bot's Durable Object supplies, which is why
|
|
15
|
-
* this Contribution mounts for a Turn rather than for
|
|
15
|
+
* this Contribution mounts for a Turn rather than for one Capability.
|
|
16
16
|
*/
|
|
17
|
-
import type {
|
|
18
|
-
Session,
|
|
19
|
-
ToolDefinition,
|
|
20
|
-
ToolExecutionContext,
|
|
21
|
-
TurnTypeV1,
|
|
22
|
-
} from "@frockbot/kernel-contracts";
|
|
17
|
+
import type { ToolDefinition, TurnTypeV1 } from "@frockbot/kernel-contracts";
|
|
23
18
|
import type { Context, Plugin } from "cordis";
|
|
24
19
|
import type {
|
|
25
20
|
McpLifecycleReceiptV1,
|
|
@@ -35,39 +30,12 @@ export const MCP_LIFECYCLE_TURN_TYPES: readonly TurnTypeV1[] = [
|
|
|
35
30
|
];
|
|
36
31
|
|
|
37
32
|
/**
|
|
38
|
-
* Adding a server
|
|
39
|
-
*
|
|
40
|
-
*
|
|
33
|
+
* Adding a server is chat-only; every other lifecycle verb is not. It is a
|
|
34
|
+
* User-shaped decision, and an automation or subagent Turn has no User in
|
|
35
|
+
* front of it to make one.
|
|
41
36
|
*/
|
|
42
37
|
export const MCP_ADMISSION_TURN_TYPES: readonly TurnTypeV1[] = ["chat"];
|
|
43
38
|
|
|
44
|
-
/**
|
|
45
|
-
* The open step a send belongs to. The session log is the reconstruction
|
|
46
|
-
* surface, so a card recorded without its turn and step would not replay in
|
|
47
|
-
* place. The same rule `plugin-shell` applies to `send_to_user`, restated here
|
|
48
|
-
* because this Package records its own send rather than reaching into that one.
|
|
49
|
-
*/
|
|
50
|
-
function openStepPositionV1(
|
|
51
|
-
session: Session,
|
|
52
|
-
tool: string,
|
|
53
|
-
): { turn: number; step: number } {
|
|
54
|
-
const started = session.events.findLast(
|
|
55
|
-
(event) => event.type === "step/start",
|
|
56
|
-
);
|
|
57
|
-
const ended = session.events.findLast((event) => event.type === "step/end");
|
|
58
|
-
if (started?.type !== "step/start") {
|
|
59
|
-
throw new Error(`${tool} has no open step to record against`);
|
|
60
|
-
}
|
|
61
|
-
if (
|
|
62
|
-
ended?.type === "step/end" &&
|
|
63
|
-
ended.turn === started.turn &&
|
|
64
|
-
ended.step === started.step
|
|
65
|
-
) {
|
|
66
|
-
throw new Error(`${tool} has no open step to record against`);
|
|
67
|
-
}
|
|
68
|
-
return { turn: started.turn, step: started.step };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
39
|
/**
|
|
72
40
|
* The User authority these tools run with. The Bot never reaches the records
|
|
73
41
|
* itself: its Durable Object carries each call to the User Durable Object
|
|
@@ -273,119 +241,7 @@ export function createMcpLifecycleRuntimePlugin(
|
|
|
273
241
|
},
|
|
274
242
|
},
|
|
275
243
|
];
|
|
276
|
-
definitions.push({
|
|
277
|
-
name: "mcp_authenticate_server",
|
|
278
|
-
description: [
|
|
279
|
-
"Ask the User to authorize one OAuth MCP server that is not connected.",
|
|
280
|
-
"This records a durable pending decision and shows the User a connect card.",
|
|
281
|
-
"It returns no link and no token, and it grants nothing: only the User,",
|
|
282
|
-
"pressing that card, can complete an authorization. Never write an",
|
|
283
|
-
"authorization URL yourself — you cannot have one, and one you composed",
|
|
284
|
-
"would not work. Chat turns only.",
|
|
285
|
-
].join(" "),
|
|
286
|
-
inputSchema: {
|
|
287
|
-
type: "object",
|
|
288
|
-
properties: {
|
|
289
|
-
server_id: { type: "string" },
|
|
290
|
-
reason: { type: "string", maxLength: 2_000 },
|
|
291
|
-
},
|
|
292
|
-
required: ["server_id"],
|
|
293
|
-
additionalProperties: false,
|
|
294
|
-
},
|
|
295
|
-
admission: { turnTypes: [...MCP_ADMISSION_TURN_TYPES] },
|
|
296
|
-
validate: (input) => isObject(input),
|
|
297
|
-
execute: async (input: unknown, context: ToolExecutionContext) => {
|
|
298
|
-
try {
|
|
299
|
-
const value = isObject(input) ? input : {};
|
|
300
|
-
const serverId = text(value, "server_id");
|
|
301
|
-
const status = await host.readStatus();
|
|
302
|
-
const server = status.servers.find(
|
|
303
|
-
(candidate) => candidate.serverId === serverId,
|
|
304
|
-
);
|
|
305
|
-
if (!server) {
|
|
306
|
-
return {
|
|
307
|
-
content: `No MCP server "${serverId}" is available.`,
|
|
308
|
-
isError: true,
|
|
309
|
-
};
|
|
310
|
-
}
|
|
311
|
-
const applied = await host.execute({
|
|
312
|
-
schemaVersion: 1,
|
|
313
|
-
type: "mcp/request-authorization",
|
|
314
|
-
commandId: nextId(),
|
|
315
|
-
serverId,
|
|
316
|
-
});
|
|
317
|
-
if (applied.status !== "applied") return receipt(applied);
|
|
318
|
-
// The card is the User's, drawn by the host from the durable
|
|
319
|
-
// projection. What the Bot supplies is a reason, never a link.
|
|
320
|
-
const emitted = await emitConnectCard(context, {
|
|
321
|
-
connectionId: serverId,
|
|
322
|
-
title: `Connect ${server.label}`,
|
|
323
|
-
...(typeof value.reason === "string" && value.reason.length > 0
|
|
324
|
-
? { body: value.reason.slice(0, 2_000) }
|
|
325
|
-
: {}),
|
|
326
|
-
});
|
|
327
|
-
return {
|
|
328
|
-
content: JSON.stringify({
|
|
329
|
-
...applied,
|
|
330
|
-
pendingAuthorization: true,
|
|
331
|
-
cardShown: emitted,
|
|
332
|
-
}),
|
|
333
|
-
isError: false,
|
|
334
|
-
};
|
|
335
|
-
} catch (error) {
|
|
336
|
-
return failed(error);
|
|
337
|
-
}
|
|
338
|
-
},
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* Record the connect card on the durable session log, as a `send/to-user`
|
|
343
|
-
* exactly like `send_to_user` produces — so the thread draws it, the
|
|
344
|
-
* transcript replays it, and no second delivery path exists.
|
|
345
|
-
*/
|
|
346
|
-
async function emitConnectCard(
|
|
347
|
-
context: ToolExecutionContext,
|
|
348
|
-
card: { connectionId: string; title: string; body?: string },
|
|
349
|
-
): Promise<boolean> {
|
|
350
|
-
const session = sessionStore()?.get(context.sessionId);
|
|
351
|
-
if (!session) return false;
|
|
352
|
-
let position: { turn: number; step: number };
|
|
353
|
-
try {
|
|
354
|
-
position = openStepPositionV1(session, "mcp_authenticate_server");
|
|
355
|
-
} catch {
|
|
356
|
-
return false;
|
|
357
|
-
}
|
|
358
|
-
session.append({
|
|
359
|
-
type: "send/to-user",
|
|
360
|
-
...position,
|
|
361
|
-
occurrenceId: context.effectId,
|
|
362
|
-
payload: { type: "connect-card", ...card },
|
|
363
|
-
});
|
|
364
|
-
await session.flush();
|
|
365
|
-
return true;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
let root: Context | undefined;
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* The Session store, if this root has one.
|
|
372
|
-
*
|
|
373
|
-
* Not an `inject`: these tools are the User's own MCP records and they work
|
|
374
|
-
* with no Session at all — `mcp_server_status` is answered outside a Turn in
|
|
375
|
-
* several tests and in the lifecycle surface. Only the connect card needs a
|
|
376
|
-
* Session, and without one it degrades to "the decision was recorded, no card
|
|
377
|
-
* was drawn" rather than costing the whole lifecycle its mount.
|
|
378
|
-
*/
|
|
379
|
-
const sessionStore = (): Context["sessions"] | undefined => {
|
|
380
|
-
try {
|
|
381
|
-
return root?.sessions;
|
|
382
|
-
} catch {
|
|
383
|
-
return undefined;
|
|
384
|
-
}
|
|
385
|
-
};
|
|
386
|
-
|
|
387
244
|
const plugin: Plugin.Function = (ctx: Context) => {
|
|
388
|
-
root = ctx;
|
|
389
245
|
const disposers = definitions.map((definition) =>
|
|
390
246
|
ctx.tools.register(definition, {
|
|
391
247
|
admissionCeiling: MCP_LIFECYCLE_TURN_TYPES,
|
|
@@ -393,7 +249,6 @@ export function createMcpLifecycleRuntimePlugin(
|
|
|
393
249
|
);
|
|
394
250
|
return () => {
|
|
395
251
|
for (const dispose of disposers.toReversed()) dispose();
|
|
396
|
-
root = undefined;
|
|
397
252
|
};
|
|
398
253
|
};
|
|
399
254
|
plugin.inject = ["tools"];
|
package/src/mcp-client.ts
CHANGED
|
@@ -115,10 +115,11 @@ export class McpProtocolError extends Error {
|
|
|
115
115
|
* The server said "authorize first".
|
|
116
116
|
*
|
|
117
117
|
* A 401 carrying `WWW-Authenticate: Bearer …` is the one MCP failure that is
|
|
118
|
-
* not a broken server: it
|
|
119
|
-
* subclass rather than a sibling so every existing
|
|
120
|
-
* keeps working, and typed rather than a status
|
|
121
|
-
* the durable record agree on what it means
|
|
118
|
+
* not a broken server: it puts the existing User Connection into a durable
|
|
119
|
+
* repair state. It is a subclass rather than a sibling so every existing
|
|
120
|
+
* `McpProtocolError` handler keeps working, and typed rather than a status
|
|
121
|
+
* check so the runtime seam and the durable record agree on what it means
|
|
122
|
+
* without re-deriving it from prose.
|
|
122
123
|
*/
|
|
123
124
|
export class McpAuthorizationRequiredError extends McpProtocolError {
|
|
124
125
|
/** RFC 9728 §5.1's `resource_metadata`, when the server named one. */
|
package/src/oauth-user.test.ts
CHANGED
|
@@ -728,49 +728,4 @@ describe("the pending-authorization projection", () => {
|
|
|
728
728
|
{ reason: "needs-auth", connectionId },
|
|
729
729
|
);
|
|
730
730
|
});
|
|
731
|
-
|
|
732
|
-
test("is what a Bot's request writes, and it writes nothing else", async () => {
|
|
733
|
-
const world = await fixture();
|
|
734
|
-
const { connectionId } = await connect(world);
|
|
735
|
-
|
|
736
|
-
const receipt = await world.mcp.executeLifecycle(ACCOUNT, {
|
|
737
|
-
schemaVersion: 1,
|
|
738
|
-
type: "mcp/request-authorization",
|
|
739
|
-
commandId: "bot-asked-1",
|
|
740
|
-
serverId: connectionId,
|
|
741
|
-
});
|
|
742
|
-
expect(receipt.status).toBe("applied");
|
|
743
|
-
|
|
744
|
-
// The decision is pending, and nothing about the server changed: a Bot
|
|
745
|
-
// does not get to declare its User's server broken.
|
|
746
|
-
expect((await world.read(connectionId)).pendingAuthorization).toMatchObject(
|
|
747
|
-
{ reason: "needs-auth", connectionId },
|
|
748
|
-
);
|
|
749
|
-
expect((await world.read(connectionId)).state).toBe("ready");
|
|
750
|
-
expect(
|
|
751
|
-
(await world.mcp.readServerStatus(ACCOUNT)).servers[0],
|
|
752
|
-
).toMatchObject({ state: "ready" });
|
|
753
|
-
});
|
|
754
|
-
|
|
755
|
-
test("refuses a Bot's request against a server that has nothing to authorize", async () => {
|
|
756
|
-
const world = await fixture();
|
|
757
|
-
const receipt = await world.mcp.executeLifecycle(ACCOUNT, {
|
|
758
|
-
schemaVersion: 1,
|
|
759
|
-
type: "mcp/add-server",
|
|
760
|
-
commandId: "add-public-1",
|
|
761
|
-
label: "Public",
|
|
762
|
-
url: SERVER,
|
|
763
|
-
transport: "streamable-http",
|
|
764
|
-
});
|
|
765
|
-
// The public server has no token, so there is no 401 path to it here; the
|
|
766
|
-
// request-authorization refusal is the assertion.
|
|
767
|
-
const refused = await world.mcp.executeLifecycle(ACCOUNT, {
|
|
768
|
-
schemaVersion: 1,
|
|
769
|
-
type: "mcp/request-authorization",
|
|
770
|
-
commandId: "bot-asked-2",
|
|
771
|
-
serverId: receipt.serverId!,
|
|
772
|
-
});
|
|
773
|
-
expect(refused.status).toBe("refused");
|
|
774
|
-
expect(refused.code).toBe("unauthorized");
|
|
775
|
-
});
|
|
776
731
|
});
|
package/src/records.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
decodeMcpRefusalRecordV1,
|
|
8
8
|
decodeMcpServerRecordV1,
|
|
9
9
|
decodeMcpServerStatusViewV1,
|
|
10
|
-
|
|
10
|
+
mcpCapabilityResolutionKeyV1,
|
|
11
11
|
mcpConnectionMetadataV1,
|
|
12
12
|
mcpFailureCodeV1,
|
|
13
13
|
type McpServerRecordV1,
|
|
@@ -263,19 +263,19 @@ describe("the mount outcome that crosses back from a Bot", () => {
|
|
|
263
263
|
});
|
|
264
264
|
});
|
|
265
265
|
|
|
266
|
-
describe("the
|
|
266
|
+
describe("the Capability resolution key", () => {
|
|
267
267
|
test("changes when the server epoch changes, and only then", () => {
|
|
268
|
-
const before =
|
|
268
|
+
const before = mcpCapabilityResolutionKeyV1({
|
|
269
269
|
connectionId: "mcp-1",
|
|
270
270
|
connectionGeneration: "gen-1",
|
|
271
271
|
serverEpoch: 1,
|
|
272
272
|
});
|
|
273
|
-
const restarted =
|
|
273
|
+
const restarted = mcpCapabilityResolutionKeyV1({
|
|
274
274
|
connectionId: "mcp-1",
|
|
275
275
|
connectionGeneration: "gen-1",
|
|
276
276
|
serverEpoch: 2,
|
|
277
277
|
});
|
|
278
|
-
const again =
|
|
278
|
+
const again = mcpCapabilityResolutionKeyV1({
|
|
279
279
|
connectionId: "mcp-1",
|
|
280
280
|
connectionGeneration: "gen-1",
|
|
281
281
|
serverEpoch: 1,
|
|
@@ -286,13 +286,13 @@ describe("the Assignment resolution key", () => {
|
|
|
286
286
|
|
|
287
287
|
test("changes when the Connection generation changes", () => {
|
|
288
288
|
expect(
|
|
289
|
-
|
|
289
|
+
mcpCapabilityResolutionKeyV1({
|
|
290
290
|
connectionId: "mcp-1",
|
|
291
291
|
connectionGeneration: "gen-2",
|
|
292
292
|
serverEpoch: 1,
|
|
293
293
|
}),
|
|
294
294
|
).not.toBe(
|
|
295
|
-
|
|
295
|
+
mcpCapabilityResolutionKeyV1({
|
|
296
296
|
connectionId: "mcp-1",
|
|
297
297
|
connectionGeneration: "gen-1",
|
|
298
298
|
serverEpoch: 1,
|
package/src/records.ts
CHANGED
|
@@ -96,7 +96,7 @@ export interface McpServerRecordV1 {
|
|
|
96
96
|
transport: "streamable-http" | "sse";
|
|
97
97
|
instructions?: string;
|
|
98
98
|
/**
|
|
99
|
-
* Bumped by `mcp/restart`. It participates in the
|
|
99
|
+
* Bumped by `mcp/restart`. It participates in the Capability's resolution
|
|
100
100
|
* key, so the next admitted Turn resolves a different mount and
|
|
101
101
|
* re-handshakes; the in-flight Turn keeps the client it already has.
|
|
102
102
|
*/
|
|
@@ -393,24 +393,8 @@ export interface McpRestartCommandV1 {
|
|
|
393
393
|
serverId: string;
|
|
394
394
|
}
|
|
395
395
|
|
|
396
|
-
/**
|
|
397
|
-
* GrokBot's `AuthenticateMcpServer`, as the constitution requires it to be: a
|
|
398
|
-
* Bot records a durable pending decision for its User and receives no link, no
|
|
399
|
-
* token and no grant. Chat-only, because an automation Turn has no User in
|
|
400
|
-
* front of it to decide.
|
|
401
|
-
*/
|
|
402
|
-
export interface McpRequestAuthorizationCommandV1 {
|
|
403
|
-
schemaVersion: 1;
|
|
404
|
-
type: "mcp/request-authorization";
|
|
405
|
-
commandId: string;
|
|
406
|
-
serverId: string;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
396
|
export type McpLifecycleCommandV1 =
|
|
410
|
-
|
|
|
411
|
-
| McpSetInstructionsCommandV1
|
|
412
|
-
| McpRestartCommandV1
|
|
413
|
-
| McpRequestAuthorizationCommandV1;
|
|
397
|
+
McpAddServerCommandV1 | McpSetInstructionsCommandV1 | McpRestartCommandV1;
|
|
414
398
|
|
|
415
399
|
export function decodeMcpLifecycleCommandV1(
|
|
416
400
|
input: unknown,
|
|
@@ -504,19 +488,6 @@ export function decodeMcpLifecycleCommandV1(
|
|
|
504
488
|
serverId: text(value.serverId, "serverId", 128),
|
|
505
489
|
};
|
|
506
490
|
}
|
|
507
|
-
case "mcp/request-authorization": {
|
|
508
|
-
exact(
|
|
509
|
-
value,
|
|
510
|
-
["schemaVersion", "type", "commandId", "serverId"],
|
|
511
|
-
"mcp/request-authorization",
|
|
512
|
-
);
|
|
513
|
-
return {
|
|
514
|
-
schemaVersion: 1,
|
|
515
|
-
type: "mcp/request-authorization",
|
|
516
|
-
commandId,
|
|
517
|
-
serverId: text(value.serverId, "serverId", 128),
|
|
518
|
-
};
|
|
519
|
-
}
|
|
520
491
|
default:
|
|
521
492
|
throw new Error(
|
|
522
493
|
`MCP lifecycle command "${String(value.type)}" is unknown`,
|
|
@@ -653,12 +624,12 @@ export function decodeMcpMountOutcomeV1(
|
|
|
653
624
|
}
|
|
654
625
|
|
|
655
626
|
/**
|
|
656
|
-
* What one
|
|
627
|
+
* What one enabled `mcp-tools` Capability resolves to. The `serverEpoch` is in it,
|
|
657
628
|
* which is the whole of restart semantics: a restart changes this key, the
|
|
658
629
|
* next admitted Turn resolves a different mount and re-handshakes, and the
|
|
659
630
|
* in-flight Turn — already holding its client — is untouched.
|
|
660
631
|
*/
|
|
661
|
-
export function
|
|
632
|
+
export function mcpCapabilityResolutionKeyV1(input: {
|
|
662
633
|
connectionId: string;
|
|
663
634
|
connectionGeneration?: string;
|
|
664
635
|
serverEpoch?: number;
|
package/src/user.ts
CHANGED
|
@@ -485,43 +485,6 @@ export class McpUserBackendContribution {
|
|
|
485
485
|
serverId: command.serverId,
|
|
486
486
|
};
|
|
487
487
|
}
|
|
488
|
-
case "mcp/request-authorization": {
|
|
489
|
-
// A durable pending decision, never a grant. The server record is left
|
|
490
|
-
// exactly as it is — a Bot does not get to declare its User's server
|
|
491
|
-
// broken — and what changes is the Connection projection the User's
|
|
492
|
-
// own surface draws a connect card from. No URL is minted here, and
|
|
493
|
-
// none is returned: only an authenticated `connection/start` does that.
|
|
494
|
-
const server = await this.requireServer(accountId, command.serverId);
|
|
495
|
-
const connection = await this.requireConnection(
|
|
496
|
-
accountId,
|
|
497
|
-
command.serverId,
|
|
498
|
-
);
|
|
499
|
-
if (connection.connectionTypeId !== MCP_OAUTH_CONNECTION_TYPE_ID) {
|
|
500
|
-
return this.refuse(command, {
|
|
501
|
-
code: "unauthorized",
|
|
502
|
-
message:
|
|
503
|
-
"This MCP server does not use OAuth, so there is nothing for the User to authorize. A keyed server needs a new key instead.",
|
|
504
|
-
});
|
|
505
|
-
}
|
|
506
|
-
await this.host.settings.replaceConnection(
|
|
507
|
-
accountId,
|
|
508
|
-
connection.connectionId,
|
|
509
|
-
connection.generation,
|
|
510
|
-
{
|
|
511
|
-
...connection,
|
|
512
|
-
pendingAuthorization: mcpPendingAuthorizationV1(
|
|
513
|
-
server,
|
|
514
|
-
new Date(this.now()).toISOString(),
|
|
515
|
-
),
|
|
516
|
-
},
|
|
517
|
-
);
|
|
518
|
-
return {
|
|
519
|
-
schemaVersion: 1,
|
|
520
|
-
commandId: command.commandId,
|
|
521
|
-
status: "applied",
|
|
522
|
-
serverId: command.serverId,
|
|
523
|
-
};
|
|
524
|
-
}
|
|
525
488
|
case "mcp/restart": {
|
|
526
489
|
const server = await this.requireServer(accountId, command.serverId);
|
|
527
490
|
const connection = await this.requireConnection(
|
|
@@ -531,7 +494,7 @@ export class McpUserBackendContribution {
|
|
|
531
494
|
if (connection.state === "revoked" || connection.state === "revoking") {
|
|
532
495
|
throw new Error("MCP Connection is revoked");
|
|
533
496
|
}
|
|
534
|
-
// The epoch bump is the whole of restart: it is in the
|
|
497
|
+
// The epoch bump is the whole of restart: it is in the Capability's
|
|
535
498
|
// resolution key, so the next admitted Turn resolves a different
|
|
536
499
|
// mount and re-handshakes, while the in-flight Turn keeps the client
|
|
537
500
|
// it already holds. The handshake here refreshes the status the User
|
|
@@ -704,7 +667,7 @@ export class McpUserBackendContribution {
|
|
|
704
667
|
// The Connection generation and the credential generation are the same
|
|
705
668
|
// thing for a keyed server, and deliberately are not for an OAuth one: a
|
|
706
669
|
// refresh rotates the sealed generation without disturbing the
|
|
707
|
-
//
|
|
670
|
+
// Capability resolution key a Turn is pinned to.
|
|
708
671
|
expectedGeneration: await this.currentCredentialGeneration(
|
|
709
672
|
input.accountId,
|
|
710
673
|
connection,
|
|
@@ -805,7 +768,7 @@ export class McpUserBackendContribution {
|
|
|
805
768
|
|
|
806
769
|
/**
|
|
807
770
|
* Mint one authorization. Only an authenticated User action reaches here —
|
|
808
|
-
* a Bot
|
|
771
|
+
* a Bot cannot request this path or receive a redirect.
|
|
809
772
|
*
|
|
810
773
|
* Order matters and is the durability argument: the quota is charged, the
|
|
811
774
|
* Connection exists in `authorizing`, and the pending record carrying the
|
|
@@ -1518,8 +1481,8 @@ export class McpUserBackendContribution {
|
|
|
1518
1481
|
{ ...connection, state, failure: undefined },
|
|
1519
1482
|
);
|
|
1520
1483
|
// GrokBot's `RemoveMcpAccount`: the server is gone, so its record is
|
|
1521
|
-
// gone with it.
|
|
1522
|
-
//
|
|
1484
|
+
// gone with it. Any Bot that was using the Connection fails closed
|
|
1485
|
+
// when its next Turn resolves the User's enabled capability set.
|
|
1523
1486
|
await this.removeServer(connection.connectionId);
|
|
1524
1487
|
return this.receipt(command.commandId, connection.connectionId);
|
|
1525
1488
|
}
|
package/src/connect-card.test.ts
DELETED
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The connect card and the durable pending decision behind it.
|
|
3
|
-
*
|
|
4
|
-
* One rule holds these together and every test here is a restatement of it: a
|
|
5
|
-
* Bot may record that its User needs to authorize something, and only the User
|
|
6
|
-
* may authorize it. Nothing a Bot writes — the projection, the card payload,
|
|
7
|
-
* the tool's own answer — may contain a link.
|
|
8
|
-
*/
|
|
9
|
-
import { describe, expect, test } from "bun:test";
|
|
10
|
-
import { Context } from "cordis";
|
|
11
|
-
import { ToolRegistry } from "@frockbot/plugin-tools";
|
|
12
|
-
import { decodeSendToUserPayloadV1 } from "@frockbot/kernel-contracts";
|
|
13
|
-
import { decodePendingAuthorizationV1 } from "@frockbot/configuration-core";
|
|
14
|
-
import { createMcpLifecycleRuntimePlugin } from "./lifecycle-tools.js";
|
|
15
|
-
import { mcpPendingAuthorizationV1 } from "./records.js";
|
|
16
|
-
import type {
|
|
17
|
-
McpLifecycleReceiptV1,
|
|
18
|
-
McpServerRecordV1,
|
|
19
|
-
McpServerStatusViewV1,
|
|
20
|
-
} from "./records.js";
|
|
21
|
-
|
|
22
|
-
const SERVER: McpServerRecordV1 = {
|
|
23
|
-
schemaVersion: 1,
|
|
24
|
-
serverId: "mcp-1",
|
|
25
|
-
label: "OAuth Example",
|
|
26
|
-
url: "https://mcp.example.test/mcp-oauth",
|
|
27
|
-
transport: "streamable-http",
|
|
28
|
-
serverEpoch: 1,
|
|
29
|
-
state: "needs-auth",
|
|
30
|
-
toolCount: 0,
|
|
31
|
-
toolsHash: "",
|
|
32
|
-
lastHandshakeAt: "2026-09-01T00:00:00.000Z",
|
|
33
|
-
failure: {
|
|
34
|
-
code: "unauthorized",
|
|
35
|
-
message: "MCP server answered 401",
|
|
36
|
-
at: "2026-09-01T00:00:00.000Z",
|
|
37
|
-
},
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const STATUS: McpServerStatusViewV1 = {
|
|
41
|
-
schemaVersion: 1,
|
|
42
|
-
servers: [SERVER],
|
|
43
|
-
refusals: [],
|
|
44
|
-
quotas: { maxServers: 16, maxToolsPerServer: 64, maxResponseBytes: 262_144 },
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const CALL_CONTEXT = {
|
|
48
|
-
botId: "bot-1",
|
|
49
|
-
agentId: "agent-1",
|
|
50
|
-
sessionId: "session-1",
|
|
51
|
-
compositionGenerationId: "generation-1",
|
|
52
|
-
effectId: "effect-1",
|
|
53
|
-
signal: new AbortController().signal,
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
describe("the pending-authorization projection", () => {
|
|
57
|
-
test("carries the reason, the moment, the Connection and its label — and no URL", () => {
|
|
58
|
-
const pending = mcpPendingAuthorizationV1(SERVER, SERVER.lastHandshakeAt);
|
|
59
|
-
expect(pending).toEqual({
|
|
60
|
-
reason: "needs-auth",
|
|
61
|
-
since: "2026-09-01T00:00:00.000Z",
|
|
62
|
-
connectionId: "mcp-1",
|
|
63
|
-
label: "OAuth Example",
|
|
64
|
-
});
|
|
65
|
-
// The server's own URL is in the record; it is not in the projection, and
|
|
66
|
-
// neither is anything else that could be followed.
|
|
67
|
-
expect(JSON.stringify(pending)).not.toContain("https://");
|
|
68
|
-
expect(decodePendingAuthorizationV1(pending)).toEqual(pending);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("is refused if anything tries to smuggle a redirect onto it", () => {
|
|
72
|
-
expect(() =>
|
|
73
|
-
decodePendingAuthorizationV1({
|
|
74
|
-
...mcpPendingAuthorizationV1(SERVER, SERVER.lastHandshakeAt),
|
|
75
|
-
redirectUrl: "https://auth.example.test/authorize?code_challenge=x",
|
|
76
|
-
}),
|
|
77
|
-
).toThrow();
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
describe("the connect-card send payload", () => {
|
|
82
|
-
test("decodes with a Connection, a title and a body, and nothing else", () => {
|
|
83
|
-
const payload = decodeSendToUserPayloadV1({
|
|
84
|
-
type: "connect-card",
|
|
85
|
-
connectionId: "mcp-1",
|
|
86
|
-
title: "Connect OAuth Example",
|
|
87
|
-
body: "I need it to read your calendar.",
|
|
88
|
-
});
|
|
89
|
-
expect(payload).toEqual({
|
|
90
|
-
type: "connect-card",
|
|
91
|
-
connectionId: "mcp-1",
|
|
92
|
-
title: "Connect OAuth Example",
|
|
93
|
-
body: "I need it to read your calendar.",
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("refuses a payload carrying a URL", () => {
|
|
98
|
-
for (const extra of [
|
|
99
|
-
{ url: "https://auth.example.test/authorize" },
|
|
100
|
-
{ redirectUrl: "https://auth.example.test/authorize" },
|
|
101
|
-
{ href: "https://auth.example.test/authorize" },
|
|
102
|
-
]) {
|
|
103
|
-
expect(() =>
|
|
104
|
-
decodeSendToUserPayloadV1({
|
|
105
|
-
type: "connect-card",
|
|
106
|
-
connectionId: "mcp-1",
|
|
107
|
-
title: "Connect",
|
|
108
|
-
...extra,
|
|
109
|
-
}),
|
|
110
|
-
).toThrow(/unexpected key/);
|
|
111
|
-
}
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
function fixture(options: { status?: McpServerStatusViewV1 } = {}) {
|
|
116
|
-
const commands: unknown[] = [];
|
|
117
|
-
const appended: unknown[] = [];
|
|
118
|
-
const session = {
|
|
119
|
-
events: [{ type: "step/start", turn: 1, step: 1 }],
|
|
120
|
-
append: (event: unknown) => {
|
|
121
|
-
appended.push(event);
|
|
122
|
-
},
|
|
123
|
-
flush: () => Promise.resolve(),
|
|
124
|
-
};
|
|
125
|
-
const root = new Context();
|
|
126
|
-
let id = 0;
|
|
127
|
-
const ready = (async () => {
|
|
128
|
-
await root.plugin(ToolRegistry);
|
|
129
|
-
// The Session store the Agent loop provides. Registered as a service so
|
|
130
|
-
// the lifecycle Plugin finds it exactly as it does in a real Turn.
|
|
131
|
-
root.provide("sessions");
|
|
132
|
-
root.sessions = { get: () => session } as never;
|
|
133
|
-
await root.plugin(
|
|
134
|
-
createMcpLifecycleRuntimePlugin({
|
|
135
|
-
readStatus: () => Promise.resolve(options.status ?? STATUS),
|
|
136
|
-
execute: (command) => {
|
|
137
|
-
commands.push(command);
|
|
138
|
-
return Promise.resolve({
|
|
139
|
-
schemaVersion: 1,
|
|
140
|
-
commandId: "applied",
|
|
141
|
-
status: "applied",
|
|
142
|
-
serverId: "mcp-1",
|
|
143
|
-
} satisfies McpLifecycleReceiptV1);
|
|
144
|
-
},
|
|
145
|
-
randomId: () => `command-${++id}`,
|
|
146
|
-
}),
|
|
147
|
-
);
|
|
148
|
-
})();
|
|
149
|
-
return { root, commands, appended, ready };
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
async function call(
|
|
153
|
-
root: Context,
|
|
154
|
-
name: string,
|
|
155
|
-
input: unknown,
|
|
156
|
-
): Promise<{ content: string; isError: boolean }> {
|
|
157
|
-
const toolCall = { id: "call-1", name, input };
|
|
158
|
-
const prepared = await root.tools.prepare(toolCall, {
|
|
159
|
-
...CALL_CONTEXT,
|
|
160
|
-
toolCall,
|
|
161
|
-
turnType: "chat" as const,
|
|
162
|
-
});
|
|
163
|
-
if (prepared.kind !== "ready") return prepared.result;
|
|
164
|
-
return root.tools.executePrepared(prepared, {
|
|
165
|
-
...CALL_CONTEXT,
|
|
166
|
-
toolCall,
|
|
167
|
-
turnType: "chat" as const,
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
describe("mcp_authenticate_server", () => {
|
|
172
|
-
test("is offered on a chat turn and nowhere else", async () => {
|
|
173
|
-
const { root, ready } = fixture();
|
|
174
|
-
await ready;
|
|
175
|
-
expect(
|
|
176
|
-
root.tools.schemas({ turnType: "chat" }).map((tool) => tool.name),
|
|
177
|
-
).toContain("mcp_authenticate_server");
|
|
178
|
-
for (const turnType of ["automation", "subagent"] as const) {
|
|
179
|
-
expect(
|
|
180
|
-
root.tools.schemas({ turnType }).map((tool) => tool.name),
|
|
181
|
-
).not.toContain("mcp_authenticate_server");
|
|
182
|
-
}
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
test("records a durable pending decision and emits a card with no URL", async () => {
|
|
186
|
-
const { root, commands, appended, ready } = fixture();
|
|
187
|
-
await ready;
|
|
188
|
-
|
|
189
|
-
const result = await call(root, "mcp_authenticate_server", {
|
|
190
|
-
server_id: "mcp-1",
|
|
191
|
-
reason: "I need it to read your calendar.",
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
expect(result.isError).toBe(false);
|
|
195
|
-
expect(commands).toEqual([
|
|
196
|
-
{
|
|
197
|
-
schemaVersion: 1,
|
|
198
|
-
type: "mcp/request-authorization",
|
|
199
|
-
commandId: "command-1",
|
|
200
|
-
serverId: "mcp-1",
|
|
201
|
-
},
|
|
202
|
-
]);
|
|
203
|
-
expect(appended).toHaveLength(1);
|
|
204
|
-
const event = appended[0] as { type: string; payload: unknown };
|
|
205
|
-
expect(event.type).toBe("send/to-user");
|
|
206
|
-
expect(decodeSendToUserPayloadV1(event.payload)).toEqual({
|
|
207
|
-
type: "connect-card",
|
|
208
|
-
connectionId: "mcp-1",
|
|
209
|
-
title: "Connect OAuth Example",
|
|
210
|
-
body: "I need it to read your calendar.",
|
|
211
|
-
});
|
|
212
|
-
// Not the tool's answer, not the card: no link anywhere the Bot can see.
|
|
213
|
-
expect(JSON.stringify(event.payload)).not.toContain("http");
|
|
214
|
-
expect(result.content).not.toContain("http");
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
test("says so rather than inventing a server it was not given", async () => {
|
|
218
|
-
const { root, commands, ready } = fixture();
|
|
219
|
-
await ready;
|
|
220
|
-
const result = await call(root, "mcp_authenticate_server", {
|
|
221
|
-
server_id: "mcp-missing",
|
|
222
|
-
});
|
|
223
|
-
expect(result.isError).toBe(true);
|
|
224
|
-
expect(commands).toEqual([]);
|
|
225
|
-
});
|
|
226
|
-
});
|