@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-mcp",
3
- "version": "0.1.4",
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.4",
29
- "@frockbot/connection-core": "0.1.4",
30
- "@frockbot/kernel-contracts": "0.1.4",
31
- "@frockbot/plugin-credentials": "0.1.4",
32
- "@frockbot/plugin-settings": "0.1.4",
33
- "@frockbot/plugin-tools": "0.1.4",
34
- "@frockbot/plugin-web": "0.1.4",
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": {
@@ -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, 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
  });
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
- | 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`,
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 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
@@ -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
- });