@frockbot/plugin-mcp 0.0.0 → 0.1.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/frockbot.json +183 -0
- package/package.json +41 -6
- package/src/agent.test.ts +409 -0
- package/src/agent.ts +516 -0
- package/src/backend.test.ts +333 -0
- package/src/backend.ts +490 -0
- package/src/connect-card.test.ts +226 -0
- package/src/index.ts +7 -0
- package/src/lifecycle-tools.test.ts +182 -0
- package/src/lifecycle-tools.ts +401 -0
- package/src/lifecycle.test.ts +504 -0
- package/src/manifest.ts +3 -0
- package/src/mcp-client.test.ts +389 -0
- package/src/mcp-client.ts +645 -0
- package/src/oauth-records.ts +330 -0
- package/src/oauth-user.test.ts +776 -0
- package/src/oauth.test.ts +433 -0
- package/src/oauth.ts +747 -0
- package/src/records.test.ts +331 -0
- package/src/records.ts +754 -0
- package/src/ssrf.test.ts +38 -0
- package/src/ssrf.ts +44 -0
- package/src/user.test.ts +390 -0
- package/src/user.ts +2068 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Context } from "cordis";
|
|
3
|
+
import { ToolRegistry } from "@frockbot/plugin-tools";
|
|
4
|
+
import { createMcpLifecycleRuntimePlugin } from "./lifecycle-tools.js";
|
|
5
|
+
import type {
|
|
6
|
+
McpLifecycleReceiptV1,
|
|
7
|
+
McpServerStatusViewV1,
|
|
8
|
+
} from "./records.js";
|
|
9
|
+
|
|
10
|
+
const STATUS: McpServerStatusViewV1 = {
|
|
11
|
+
schemaVersion: 1,
|
|
12
|
+
servers: [
|
|
13
|
+
{
|
|
14
|
+
schemaVersion: 1,
|
|
15
|
+
serverId: "mcp-1",
|
|
16
|
+
label: "Example",
|
|
17
|
+
url: "https://mcp.example.test/mcp",
|
|
18
|
+
transport: "streamable-http",
|
|
19
|
+
serverEpoch: 1,
|
|
20
|
+
state: "ready",
|
|
21
|
+
toolCount: 1,
|
|
22
|
+
toolsHash: "hash",
|
|
23
|
+
lastHandshakeAt: "2026-08-31T00:00:00.000Z",
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
refusals: [],
|
|
27
|
+
quotas: { maxServers: 16, maxToolsPerServer: 64, maxResponseBytes: 262_144 },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function context(options: {
|
|
31
|
+
execute?(command: unknown): Promise<McpLifecycleReceiptV1>;
|
|
32
|
+
status?: McpServerStatusViewV1;
|
|
33
|
+
}) {
|
|
34
|
+
const commands: unknown[] = [];
|
|
35
|
+
const root = new Context();
|
|
36
|
+
let id = 0;
|
|
37
|
+
const ready = (async () => {
|
|
38
|
+
await root.plugin(ToolRegistry);
|
|
39
|
+
await root.plugin(
|
|
40
|
+
createMcpLifecycleRuntimePlugin({
|
|
41
|
+
readStatus: () => Promise.resolve(options.status ?? STATUS),
|
|
42
|
+
execute: (command) => {
|
|
43
|
+
commands.push(command);
|
|
44
|
+
return (
|
|
45
|
+
options.execute?.(command) ??
|
|
46
|
+
Promise.resolve({
|
|
47
|
+
schemaVersion: 1,
|
|
48
|
+
commandId: "applied",
|
|
49
|
+
status: "applied",
|
|
50
|
+
} satisfies McpLifecycleReceiptV1)
|
|
51
|
+
);
|
|
52
|
+
},
|
|
53
|
+
randomId: () => `command-${++id}`,
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
})();
|
|
57
|
+
return { root, commands, ready };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const CALL_CONTEXT = {
|
|
61
|
+
botId: "bot-1",
|
|
62
|
+
agentId: "agent-1",
|
|
63
|
+
sessionId: "session-1",
|
|
64
|
+
compositionGenerationId: "generation-1",
|
|
65
|
+
effectId: "effect-1",
|
|
66
|
+
signal: new AbortController().signal,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
async function call(
|
|
70
|
+
root: Context,
|
|
71
|
+
name: string,
|
|
72
|
+
input: unknown,
|
|
73
|
+
): Promise<{ content: string; isError: boolean }> {
|
|
74
|
+
const toolCall = { id: "call-1", name, input };
|
|
75
|
+
const prepared = await root.tools.prepare(toolCall, {
|
|
76
|
+
...CALL_CONTEXT,
|
|
77
|
+
toolCall,
|
|
78
|
+
turnType: "chat" as const,
|
|
79
|
+
});
|
|
80
|
+
if (prepared.kind !== "ready") return prepared.result;
|
|
81
|
+
return root.tools.executePrepared(prepared, {
|
|
82
|
+
...CALL_CONTEXT,
|
|
83
|
+
toolCall,
|
|
84
|
+
turnType: "chat" as const,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("the MCP lifecycle tools", () => {
|
|
89
|
+
test("offers status, instructions and restart on every turn type", async () => {
|
|
90
|
+
const { root, ready } = context({});
|
|
91
|
+
await ready;
|
|
92
|
+
for (const turnType of ["chat", "automation", "subagent"] as const) {
|
|
93
|
+
const names = root.tools.schemas({ turnType }).map((tool) => tool.name);
|
|
94
|
+
expect(names).toContain("mcp_server_status");
|
|
95
|
+
expect(names).toContain("mcp_set_instructions");
|
|
96
|
+
expect(names).toContain("mcp_restart_servers");
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("offers mcp_add_server on a chat turn and nowhere else", async () => {
|
|
101
|
+
const { root, ready } = context({});
|
|
102
|
+
await ready;
|
|
103
|
+
expect(
|
|
104
|
+
root.tools.schemas({ turnType: "chat" }).map((tool) => tool.name),
|
|
105
|
+
).toContain("mcp_add_server");
|
|
106
|
+
for (const turnType of ["automation", "subagent"] as const) {
|
|
107
|
+
expect(
|
|
108
|
+
root.tools.schemas({ turnType }).map((tool) => tool.name),
|
|
109
|
+
).not.toContain("mcp_add_server");
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("mcp_server_status answers with the projection", async () => {
|
|
114
|
+
const { root, ready } = context({});
|
|
115
|
+
await ready;
|
|
116
|
+
const result = await call(root, "mcp_server_status", {});
|
|
117
|
+
expect(result.isError).toBe(false);
|
|
118
|
+
expect(JSON.parse(result.content)).toEqual(STATUS);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("mcp_set_instructions sends one command carrying the text", async () => {
|
|
122
|
+
const { root, commands, ready } = context({});
|
|
123
|
+
await ready;
|
|
124
|
+
const result = await call(root, "mcp_set_instructions", {
|
|
125
|
+
server_id: "mcp-1",
|
|
126
|
+
instructions: "Search first.",
|
|
127
|
+
});
|
|
128
|
+
expect(result.isError).toBe(false);
|
|
129
|
+
expect(commands[0]).toMatchObject({
|
|
130
|
+
type: "mcp/set-instructions",
|
|
131
|
+
serverId: "mcp-1",
|
|
132
|
+
instructions: "Search first.",
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("mcp_restart_servers with no server restarts every one of them", async () => {
|
|
137
|
+
const { root, commands, ready } = context({});
|
|
138
|
+
await ready;
|
|
139
|
+
await call(root, "mcp_restart_servers", {});
|
|
140
|
+
expect(commands).toHaveLength(1);
|
|
141
|
+
expect(commands[0]).toMatchObject({
|
|
142
|
+
type: "mcp/restart",
|
|
143
|
+
serverId: "mcp-1",
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("a refusal comes back as the receipt, not as a lost error", async () => {
|
|
148
|
+
const { root, ready } = context({
|
|
149
|
+
execute: () =>
|
|
150
|
+
Promise.resolve({
|
|
151
|
+
schemaVersion: 1,
|
|
152
|
+
commandId: "command-1",
|
|
153
|
+
status: "refused",
|
|
154
|
+
code: "unsupported-transport",
|
|
155
|
+
failure: "stdio is not supported",
|
|
156
|
+
}),
|
|
157
|
+
});
|
|
158
|
+
await ready;
|
|
159
|
+
const result = await call(root, "mcp_add_server", {
|
|
160
|
+
label: "Beeper",
|
|
161
|
+
url: "stdio://beeper",
|
|
162
|
+
transport: "stdio",
|
|
163
|
+
});
|
|
164
|
+
expect(result.isError).toBe(true);
|
|
165
|
+
expect(JSON.parse(result.content)).toMatchObject({
|
|
166
|
+
status: "refused",
|
|
167
|
+
code: "unsupported-transport",
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("mints a fresh command id per call, so a retry is one durable effect", async () => {
|
|
172
|
+
const { root, commands, ready } = context({});
|
|
173
|
+
await ready;
|
|
174
|
+
await call(root, "mcp_restart_servers", { server_id: "mcp-1" });
|
|
175
|
+
await call(root, "mcp_restart_servers", { server_id: "mcp-1" });
|
|
176
|
+
expect(
|
|
177
|
+
new Set(
|
|
178
|
+
commands.map((command) => (command as { commandId: string }).commandId),
|
|
179
|
+
).size,
|
|
180
|
+
).toBe(2);
|
|
181
|
+
});
|
|
182
|
+
});
|