@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-mcp",
3
- "version": "0.1.3",
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.1.3",
29
- "@frockbot/connection-core": "0.1.3",
30
- "@frockbot/kernel-contracts": "0.1.3",
31
- "@frockbot/plugin-credentials": "0.1.3",
32
- "@frockbot/plugin-settings": "0.1.3",
33
- "@frockbot/plugin-tools": "0.1.3",
34
- "@frockbot/plugin-web": "0.1.3",
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
- mcpAssignmentResolutionV1,
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
- assignmentIndex?: number;
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
- assignment: {
104
+ capability: {
105
105
  packageId: "mcp",
106
106
  capabilityId: "mcp-tools",
107
107
  connectionId: "mcp-1",
108
- state: "enabled",
109
108
  },
110
- ...(config.assignmentIndex === undefined
109
+ ...(config.capabilityIndex === undefined
111
110
  ? {}
112
- : { assignmentIndex: config.assignmentIndex }),
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 Assignment", () => {
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
- assignmentIndex: MAX_MCP_SERVERS_PER_USER_V1,
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 Assignment's resolution key moves with the server epoch", () => {
389
- const before = mcpAssignmentResolutionV1({
390
- assignment: { connectionId: "mcp-1" },
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 = mcpAssignmentResolutionV1({
394
- assignment: { connectionId: "mcp-1" },
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 Assignment of `mcp-tools` becomes one
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
- mcpAssignmentResolutionKeyV1,
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
- * Assignments of this Package, so a Bot cannot be handed a seventeenth server
66
- * by adding one more Assignment.
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
- assignment: {
203
+ capability: {
204
204
  packageId: string;
205
205
  capabilityId: string;
206
206
  connectionId?: string;
207
- state: string;
208
207
  };
209
208
  /**
210
- * This Assignment's ordinal among the enabled Assignments of this Package,
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-Assignment factory.
211
+ * per-Capability factory.
213
212
  */
214
- assignmentIndex?: number;
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 Assignment into a mounted runtime Plugin. The handshake and
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.assignment.packageId !== MCP_PACKAGE_ID ||
252
- config.assignment.capabilityId !== MCP_CAPABILITY_ID ||
253
- config.assignment.state !== "enabled" ||
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.assignmentIndex ?? 0) >= MAX_MCP_SERVERS_PER_USER_V1) {
256
+ if ((config.capabilityIndex ?? 0) >= MAX_MCP_SERVERS_PER_USER_V1) {
259
257
  config.onFailure?.(
260
- `A User may assign at most ${MAX_MCP_SERVERS_PER_USER_V1} MCP servers`,
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.assignment.connectionId;
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 openAssignedCredential(config, connection)
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 Assignment of `mcp-tools` resolves to, including the server's
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 mcpAssignmentResolutionV1(config: {
381
- assignment: { connectionId?: string };
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 mcpAssignmentResolutionKeyV1({
386
- connectionId: config.assignment.connectionId ?? "",
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 openAssignedCredential(
394
+ async function openEnabledCredential(
397
395
  config: McpRuntimeContributionConfig,
398
396
  connection: ConnectionView,
399
397
  ): Promise<string> {
@@ -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 an Assignment.
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, and asking the User to authorize one, are chat-only; every
39
- * other lifecycle verb is not. Both are User-shaped decisions, and an
40
- * automation or subagent Turn has no User in front of it to make one.
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 is a durable pending decision for the User. It is a
119
- * subclass rather than a sibling so every existing `McpProtocolError` handler
120
- * keeps working, and typed rather than a status check so the runtime seam and
121
- * the durable record agree on what it means without re-deriving it from prose.
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. */
@@ -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
  });
@@ -7,7 +7,7 @@ import {
7
7
  decodeMcpRefusalRecordV1,
8
8
  decodeMcpServerRecordV1,
9
9
  decodeMcpServerStatusViewV1,
10
- mcpAssignmentResolutionKeyV1,
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 Assignment resolution key", () => {
266
+ describe("the Capability resolution key", () => {
267
267
  test("changes when the server epoch changes, and only then", () => {
268
- const before = mcpAssignmentResolutionKeyV1({
268
+ const before = mcpCapabilityResolutionKeyV1({
269
269
  connectionId: "mcp-1",
270
270
  connectionGeneration: "gen-1",
271
271
  serverEpoch: 1,
272
272
  });
273
- const restarted = mcpAssignmentResolutionKeyV1({
273
+ const restarted = mcpCapabilityResolutionKeyV1({
274
274
  connectionId: "mcp-1",
275
275
  connectionGeneration: "gen-1",
276
276
  serverEpoch: 2,
277
277
  });
278
- const again = mcpAssignmentResolutionKeyV1({
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
- mcpAssignmentResolutionKeyV1({
289
+ mcpCapabilityResolutionKeyV1({
290
290
  connectionId: "mcp-1",
291
291
  connectionGeneration: "gen-2",
292
292
  serverEpoch: 1,
293
293
  }),
294
294
  ).not.toBe(
295
- mcpAssignmentResolutionKeyV1({
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 Assignment's resolution
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
- | McpAddServerCommandV1
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 Assignment of `mcp-tools` resolves to. The `serverEpoch` is in it,
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 mcpAssignmentResolutionKeyV1(input: {
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 Assignment's
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
- // Assignment resolution key a Turn is pinned to.
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 may record a pending decision, never a redirect.
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. The Assignments that named it become unavailable
1522
- // tombstones through the ordinary Connection dependency path.
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
  }
@@ -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
- });