@frockbot/plugin-mcp 0.1.4 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -8
- package/src/lifecycle-tools.ts +4 -149
- package/src/mcp-client.ts +5 -4
- package/src/oauth-user.test.ts +0 -45
- package/src/records.ts +1 -30
- package/src/user.ts +1 -38
- 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.1
|
|
3
|
+
"version": "0.2.1",
|
|
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.1
|
|
29
|
-
"@frockbot/connection-core": "0.1
|
|
30
|
-
"@frockbot/kernel-contracts": "0.1
|
|
31
|
-
"@frockbot/plugin-credentials": "0.1
|
|
32
|
-
"@frockbot/plugin-settings": "0.1
|
|
33
|
-
"@frockbot/plugin-tools": "0.1
|
|
34
|
-
"@frockbot/plugin-web": "0.1
|
|
28
|
+
"@frockbot/configuration-core": "0.2.1",
|
|
29
|
+
"@frockbot/connection-core": "0.2.1",
|
|
30
|
+
"@frockbot/kernel-contracts": "0.2.1",
|
|
31
|
+
"@frockbot/plugin-credentials": "0.2.1",
|
|
32
|
+
"@frockbot/plugin-settings": "0.2.1",
|
|
33
|
+
"@frockbot/plugin-tools": "0.2.1",
|
|
34
|
+
"@frockbot/plugin-web": "0.2.1",
|
|
35
35
|
"cordis": "4.0.0-rc.8"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
package/src/lifecycle-tools.ts
CHANGED
|
@@ -14,12 +14,7 @@
|
|
|
14
14
|
* records through the host the Bot's Durable Object supplies, which is why
|
|
15
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.ts
CHANGED
|
@@ -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`,
|
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(
|
|
@@ -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
|
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
|
-
});
|