@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
package/frockbot.json
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 4,
|
|
3
|
+
"id": "mcp",
|
|
4
|
+
"displayName": "MCP servers",
|
|
5
|
+
"version": "0.0.1",
|
|
6
|
+
"compatibility": {
|
|
7
|
+
"frockbot": ">=0.0.1"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"credentials": ">=0.0.1",
|
|
11
|
+
"settings": ">=0.0.1"
|
|
12
|
+
},
|
|
13
|
+
"contributions": {
|
|
14
|
+
"backend": [
|
|
15
|
+
{
|
|
16
|
+
"entry": "./user",
|
|
17
|
+
"host": "user"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"entry": "./backend",
|
|
21
|
+
"host": "gateway"
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"runtime": {
|
|
25
|
+
"entry": "./agent"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"permissions": ["connections:manage", "tools:execute", "network:mcp"],
|
|
29
|
+
"configuration": {
|
|
30
|
+
"settings": [],
|
|
31
|
+
"connectionTypes": [
|
|
32
|
+
{
|
|
33
|
+
"id": "mcp-remote",
|
|
34
|
+
"displayName": "MCP server",
|
|
35
|
+
"allowMultiple": true,
|
|
36
|
+
"authorization": {
|
|
37
|
+
"kind": "none",
|
|
38
|
+
"driverId": "mcp"
|
|
39
|
+
},
|
|
40
|
+
"capabilities": ["mcp-tools"],
|
|
41
|
+
"settings": [
|
|
42
|
+
{
|
|
43
|
+
"id": "url",
|
|
44
|
+
"schemaVersion": 1,
|
|
45
|
+
"schema": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"title": "Server URL",
|
|
48
|
+
"description": "The absolute https URL of the remote MCP endpoint.",
|
|
49
|
+
"minLength": 8,
|
|
50
|
+
"maxLength": 2048
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"id": "transport",
|
|
55
|
+
"schemaVersion": 1,
|
|
56
|
+
"schema": {
|
|
57
|
+
"type": "string",
|
|
58
|
+
"title": "Transport",
|
|
59
|
+
"description": "The MCP HTTP transport the server speaks. Absent means streamable-http.",
|
|
60
|
+
"enum": ["streamable-http", "sse"]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"id": "mcp-remote-key",
|
|
67
|
+
"displayName": "MCP server (API key)",
|
|
68
|
+
"allowMultiple": true,
|
|
69
|
+
"authorization": {
|
|
70
|
+
"kind": "api-key",
|
|
71
|
+
"driverId": "mcp"
|
|
72
|
+
},
|
|
73
|
+
"capabilities": ["mcp-tools"],
|
|
74
|
+
"settings": [
|
|
75
|
+
{
|
|
76
|
+
"id": "url",
|
|
77
|
+
"schemaVersion": 1,
|
|
78
|
+
"schema": {
|
|
79
|
+
"type": "string",
|
|
80
|
+
"title": "Server URL",
|
|
81
|
+
"description": "The absolute https URL of the remote MCP endpoint.",
|
|
82
|
+
"minLength": 8,
|
|
83
|
+
"maxLength": 2048
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"id": "transport",
|
|
88
|
+
"schemaVersion": 1,
|
|
89
|
+
"schema": {
|
|
90
|
+
"type": "string",
|
|
91
|
+
"title": "Transport",
|
|
92
|
+
"description": "The MCP HTTP transport the server speaks. Absent means streamable-http.",
|
|
93
|
+
"enum": ["streamable-http", "sse"]
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"id": "header-name",
|
|
98
|
+
"schemaVersion": 1,
|
|
99
|
+
"schema": {
|
|
100
|
+
"type": "string",
|
|
101
|
+
"title": "Key header",
|
|
102
|
+
"description": "The header the API key is sent in. Defaults to Authorization, which carries the key as a bearer token.",
|
|
103
|
+
"minLength": 1,
|
|
104
|
+
"maxLength": 128
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"id": "mcp-remote-oauth",
|
|
111
|
+
"displayName": "MCP server (OAuth)",
|
|
112
|
+
"allowMultiple": true,
|
|
113
|
+
"authorization": {
|
|
114
|
+
"kind": "grant",
|
|
115
|
+
"driverId": "mcp-oauth"
|
|
116
|
+
},
|
|
117
|
+
"capabilities": ["mcp-tools"],
|
|
118
|
+
"settings": [
|
|
119
|
+
{
|
|
120
|
+
"id": "url",
|
|
121
|
+
"schemaVersion": 1,
|
|
122
|
+
"schema": {
|
|
123
|
+
"type": "string",
|
|
124
|
+
"title": "Server URL",
|
|
125
|
+
"description": "The absolute https URL of the remote MCP endpoint.",
|
|
126
|
+
"minLength": 8,
|
|
127
|
+
"maxLength": 2048
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"id": "transport",
|
|
132
|
+
"schemaVersion": 1,
|
|
133
|
+
"schema": {
|
|
134
|
+
"type": "string",
|
|
135
|
+
"title": "Transport",
|
|
136
|
+
"description": "The MCP HTTP transport the server speaks. Absent means streamable-http.",
|
|
137
|
+
"enum": ["streamable-http", "sse"]
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"id": "scope",
|
|
142
|
+
"schemaVersion": 1,
|
|
143
|
+
"schema": {
|
|
144
|
+
"type": "string",
|
|
145
|
+
"title": "Scope",
|
|
146
|
+
"description": "The OAuth scopes to request. Absent asks the server's protected-resource metadata for its own default.",
|
|
147
|
+
"maxLength": 1024
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"id": "client-id",
|
|
152
|
+
"schemaVersion": 1,
|
|
153
|
+
"schema": {
|
|
154
|
+
"type": "string",
|
|
155
|
+
"title": "Client ID",
|
|
156
|
+
"description": "A pre-registered public OAuth client id. Absent registers one dynamically (RFC 7591), which is what most MCP servers expect.",
|
|
157
|
+
"maxLength": 512
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
],
|
|
163
|
+
"capabilities": [
|
|
164
|
+
{
|
|
165
|
+
"id": "mcp-tools",
|
|
166
|
+
"kind": "tool",
|
|
167
|
+
"connectionTypes": ["mcp-remote", "mcp-remote-key", "mcp-remote-oauth"],
|
|
168
|
+
"admission": {
|
|
169
|
+
"turnTypes": ["chat", "automation", "subagent"],
|
|
170
|
+
"subagentRoles": ["executor"]
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
"id": "mcp-lifecycle",
|
|
175
|
+
"kind": "tool",
|
|
176
|
+
"connectionTypes": [],
|
|
177
|
+
"admission": {
|
|
178
|
+
"turnTypes": ["chat", "automation", "subagent"]
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
]
|
|
182
|
+
}
|
|
183
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./client": "./src/mcp-client.ts",
|
|
9
|
+
"./user": "./src/user.ts",
|
|
10
|
+
"./agent": "./src/agent.ts",
|
|
11
|
+
"./manifest": "./src/manifest.ts",
|
|
12
|
+
"./frockbot.json": "./frockbot.json",
|
|
13
|
+
"./package.json": "./package.json",
|
|
14
|
+
"./backend": "./src/backend.ts",
|
|
15
|
+
"./records": "./src/records.ts",
|
|
16
|
+
"./lifecycle-tools": "./src/lifecycle-tools.ts",
|
|
17
|
+
"./oauth": "./src/oauth.ts",
|
|
18
|
+
"./oauth-records": "./src/oauth-records.ts"
|
|
19
|
+
},
|
|
20
|
+
"frockbot": {
|
|
21
|
+
"manifest": "./frockbot.json"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "bun test src",
|
|
25
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@frockbot/configuration-core": "0.1.1",
|
|
29
|
+
"@frockbot/connection-core": "0.1.1",
|
|
30
|
+
"@frockbot/kernel-contracts": "0.1.1",
|
|
31
|
+
"@frockbot/plugin-credentials": "0.1.1",
|
|
32
|
+
"@frockbot/plugin-settings": "0.1.1",
|
|
33
|
+
"@frockbot/plugin-tools": "0.1.1",
|
|
34
|
+
"@frockbot/plugin-web": "0.1.1",
|
|
35
|
+
"cordis": "4.0.0-rc.8"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/bun": "1.3.6",
|
|
39
|
+
"typescript": "^7.0.2"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
6
44
|
"repository": {
|
|
7
45
|
"type": "git",
|
|
8
46
|
"url": "git+https://github.com/timoconnellaus/frockbot.git",
|
|
9
47
|
"directory": "packages/plugin-mcp"
|
|
10
|
-
},
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
13
48
|
}
|
|
14
49
|
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ConnectionView } from "@frockbot/configuration-core";
|
|
3
|
+
import { ToolRegistry } from "@frockbot/plugin-tools";
|
|
4
|
+
import { Context } from "cordis";
|
|
5
|
+
import {
|
|
6
|
+
createConfiguredMcpRuntimeContribution,
|
|
7
|
+
decodeMcpConnectionSettingsV1,
|
|
8
|
+
MAX_MCP_SERVERS_PER_USER_V1,
|
|
9
|
+
mcpAssignmentResolutionV1,
|
|
10
|
+
mcpConnectionLifecycleV1,
|
|
11
|
+
mcpServerSlugV1,
|
|
12
|
+
mcpToolNameV1,
|
|
13
|
+
} from "./agent.js";
|
|
14
|
+
|
|
15
|
+
const roots: Context[] = [];
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await Promise.all(roots.splice(0).map((root) => root.fiber.dispose()));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const INITIALIZE_RESULT = {
|
|
22
|
+
protocolVersion: "2025-06-18",
|
|
23
|
+
capabilities: { tools: {} },
|
|
24
|
+
serverInfo: { name: "Example" },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function connection(overrides: Partial<ConnectionView> = {}): ConnectionView {
|
|
28
|
+
return {
|
|
29
|
+
connectionId: "mcp-1",
|
|
30
|
+
packageId: "mcp",
|
|
31
|
+
connectionTypeId: "mcp-remote",
|
|
32
|
+
displayName: "Example",
|
|
33
|
+
state: "ready",
|
|
34
|
+
generation: "gen-1",
|
|
35
|
+
settings: { url: "https://mcp.example.test/mcp" },
|
|
36
|
+
safeMetadata: {},
|
|
37
|
+
...overrides,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function server(options: {
|
|
42
|
+
tools?: { name: string; inputSchema?: Record<string, unknown> }[];
|
|
43
|
+
onCall?: (name: string, args: unknown) => unknown;
|
|
44
|
+
calls?: { name: string; args: unknown }[];
|
|
45
|
+
}): typeof fetch {
|
|
46
|
+
const tools = options.tools ?? [
|
|
47
|
+
{
|
|
48
|
+
name: "echo",
|
|
49
|
+
inputSchema: {
|
|
50
|
+
type: "object",
|
|
51
|
+
properties: { message: { type: "string" } },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
return (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
56
|
+
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
|
57
|
+
if (body.id === undefined) return new Response("", { status: 202 });
|
|
58
|
+
if (body.method === "initialize") {
|
|
59
|
+
return Response.json({
|
|
60
|
+
jsonrpc: "2.0",
|
|
61
|
+
id: body.id,
|
|
62
|
+
result: INITIALIZE_RESULT,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (body.method === "tools/list") {
|
|
66
|
+
return Response.json({
|
|
67
|
+
jsonrpc: "2.0",
|
|
68
|
+
id: body.id,
|
|
69
|
+
result: {
|
|
70
|
+
tools: tools.map((tool) => ({
|
|
71
|
+
name: tool.name,
|
|
72
|
+
inputSchema: tool.inputSchema ?? { type: "object" },
|
|
73
|
+
})),
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const params = body.params as { name: string; arguments: unknown };
|
|
78
|
+
options.calls?.push({ name: params.name, args: params.arguments });
|
|
79
|
+
return Response.json({
|
|
80
|
+
jsonrpc: "2.0",
|
|
81
|
+
id: body.id,
|
|
82
|
+
result: {
|
|
83
|
+
content: [
|
|
84
|
+
{
|
|
85
|
+
type: "text",
|
|
86
|
+
text: JSON.stringify(
|
|
87
|
+
options.onCall?.(params.name, params.arguments) ?? { ok: true },
|
|
88
|
+
),
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}) as typeof fetch;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function mount(config: {
|
|
97
|
+
fetch: typeof fetch;
|
|
98
|
+
connection?: ConnectionView;
|
|
99
|
+
assignmentIndex?: number;
|
|
100
|
+
failures?: string[];
|
|
101
|
+
outcomes?: unknown[];
|
|
102
|
+
}): Promise<{ root: Context; mounted: boolean }> {
|
|
103
|
+
const plugin = await createConfiguredMcpRuntimeContribution({
|
|
104
|
+
assignment: {
|
|
105
|
+
packageId: "mcp",
|
|
106
|
+
capabilityId: "mcp-tools",
|
|
107
|
+
connectionId: "mcp-1",
|
|
108
|
+
state: "enabled",
|
|
109
|
+
},
|
|
110
|
+
...(config.assignmentIndex === undefined
|
|
111
|
+
? {}
|
|
112
|
+
: { assignmentIndex: config.assignmentIndex }),
|
|
113
|
+
userId: "user-1",
|
|
114
|
+
readSecret: () => undefined,
|
|
115
|
+
authorizeConnection: () =>
|
|
116
|
+
Promise.resolve(config.connection ?? connection()),
|
|
117
|
+
fetch: config.fetch,
|
|
118
|
+
onFailure: (reason) => config.failures?.push(reason),
|
|
119
|
+
...(config.outcomes
|
|
120
|
+
? {
|
|
121
|
+
onOutcome: (outcome) => {
|
|
122
|
+
config.outcomes!.push(outcome);
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
: {}),
|
|
126
|
+
});
|
|
127
|
+
const root = new Context();
|
|
128
|
+
roots.push(root);
|
|
129
|
+
await root.plugin(ToolRegistry);
|
|
130
|
+
if (plugin) await root.plugin(plugin);
|
|
131
|
+
return { root, mounted: plugin !== undefined };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
describe("tool naming", () => {
|
|
135
|
+
test("mangles the server label and the server's own tool name", () => {
|
|
136
|
+
expect(
|
|
137
|
+
mcpServerSlugV1({ connectionId: "mcp-1", displayName: "Example" }),
|
|
138
|
+
).toBe("example");
|
|
139
|
+
expect(
|
|
140
|
+
mcpServerSlugV1({ connectionId: "mcp-1", displayName: "My Docs (v2)!" }),
|
|
141
|
+
).toBe("my_docs_v2");
|
|
142
|
+
// An unlabelled Connection still yields a stable, legal segment.
|
|
143
|
+
expect(mcpServerSlugV1({ connectionId: "mcp-a.b", displayName: "" })).toBe(
|
|
144
|
+
"mcp_a_b",
|
|
145
|
+
);
|
|
146
|
+
expect(mcpToolNameV1("example", "echo")).toBe("mcp__example__echo");
|
|
147
|
+
expect(mcpToolNameV1("example", "search/files")).toBe(
|
|
148
|
+
"mcp__example__search_files",
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("Connection-scoped settings", () => {
|
|
154
|
+
test("defaults the transport and the key header", () => {
|
|
155
|
+
const settings = decodeMcpConnectionSettingsV1({
|
|
156
|
+
url: "https://mcp.example.test/mcp",
|
|
157
|
+
});
|
|
158
|
+
expect(settings.transport).toBe("streamable-http");
|
|
159
|
+
expect(settings.headerName).toBe("Authorization");
|
|
160
|
+
expect(settings.url.toString()).toBe("https://mcp.example.test/mcp");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("refuses an unsupported transport and a stdio server", () => {
|
|
164
|
+
expect(() =>
|
|
165
|
+
decodeMcpConnectionSettingsV1({
|
|
166
|
+
url: "https://mcp.example.test/mcp",
|
|
167
|
+
transport: "stdio",
|
|
168
|
+
}),
|
|
169
|
+
).toThrow(/transport is unsupported/);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("mounting one Assignment", () => {
|
|
174
|
+
test("registers every listed tool with the server's own schema", async () => {
|
|
175
|
+
const { root } = await mount({ fetch: server({}) });
|
|
176
|
+
|
|
177
|
+
const schemas = root.tools.schemas({ turnType: "chat" });
|
|
178
|
+
|
|
179
|
+
expect(schemas.map((schema) => schema.name)).toEqual([
|
|
180
|
+
"mcp__example__echo",
|
|
181
|
+
]);
|
|
182
|
+
expect(schemas[0]!.inputSchema).toEqual({
|
|
183
|
+
type: "object",
|
|
184
|
+
properties: { message: { type: "string" } },
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("offers its tools on every turn type the manifest admits", async () => {
|
|
189
|
+
const { root } = await mount({ fetch: server({}) });
|
|
190
|
+
|
|
191
|
+
for (const turnType of ["chat", "automation", "subagent"] as const) {
|
|
192
|
+
expect(root.tools.schemas({ turnType }).map((tool) => tool.name)).toEqual(
|
|
193
|
+
["mcp__example__echo"],
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("calls the server's tool by its unmangled name", async () => {
|
|
199
|
+
const calls: { name: string; args: unknown }[] = [];
|
|
200
|
+
const { root } = await mount({
|
|
201
|
+
fetch: server({ calls, onCall: (_name, args) => ({ echoed: args }) }),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const prepared = await root.tools.prepare(
|
|
205
|
+
{ id: "call-1", name: "mcp__example__echo", input: { message: "hi" } },
|
|
206
|
+
context(),
|
|
207
|
+
);
|
|
208
|
+
expect(prepared.kind).toBe("ready");
|
|
209
|
+
const result = await root.tools.executePrepared(
|
|
210
|
+
prepared as Extract<typeof prepared, { kind: "ready" }>,
|
|
211
|
+
context(),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
expect(calls).toEqual([{ name: "echo", args: { message: "hi" } }]);
|
|
215
|
+
expect(result).toEqual({
|
|
216
|
+
content: JSON.stringify({ echoed: { message: "hi" } }),
|
|
217
|
+
isError: false,
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("reports a failing call as an error the Bot can read", async () => {
|
|
222
|
+
const { root } = await mount({
|
|
223
|
+
fetch: (async (_input: unknown, init?: RequestInit) => {
|
|
224
|
+
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
|
225
|
+
if (body.id === undefined) return new Response("", { status: 202 });
|
|
226
|
+
if (body.method === "tools/call") {
|
|
227
|
+
return new Response("boom", { status: 503 });
|
|
228
|
+
}
|
|
229
|
+
return Response.json({
|
|
230
|
+
jsonrpc: "2.0",
|
|
231
|
+
id: body.id,
|
|
232
|
+
result:
|
|
233
|
+
body.method === "initialize"
|
|
234
|
+
? INITIALIZE_RESULT
|
|
235
|
+
: { tools: [{ name: "echo", inputSchema: { type: "object" } }] },
|
|
236
|
+
});
|
|
237
|
+
}) as typeof fetch,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const prepared = await root.tools.prepare(
|
|
241
|
+
{ id: "call-1", name: "mcp__example__echo", input: {} },
|
|
242
|
+
context(),
|
|
243
|
+
);
|
|
244
|
+
const result = await root.tools.executePrepared(
|
|
245
|
+
prepared as Extract<typeof prepared, { kind: "ready" }>,
|
|
246
|
+
context(),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
expect(result.isError).toBe(true);
|
|
250
|
+
expect(result.content).toContain("503");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("contributes nothing when the handshake fails, with a reason", async () => {
|
|
254
|
+
const failures: string[] = [];
|
|
255
|
+
const { root, mounted } = await mount({
|
|
256
|
+
failures,
|
|
257
|
+
fetch: (() =>
|
|
258
|
+
Promise.resolve(
|
|
259
|
+
new Response("Unauthorized", { status: 401 }),
|
|
260
|
+
)) as unknown as typeof fetch,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(mounted).toBe(false);
|
|
264
|
+
expect(root.tools.schemas({ turnType: "chat" })).toEqual([]);
|
|
265
|
+
expect(failures[0]).toContain("401");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("refuses a Connection whose URL names a private address", async () => {
|
|
269
|
+
const failures: string[] = [];
|
|
270
|
+
const { mounted } = await mount({
|
|
271
|
+
failures,
|
|
272
|
+
fetch: server({}),
|
|
273
|
+
connection: connection({
|
|
274
|
+
settings: { url: "https://192.168.1.10/mcp" },
|
|
275
|
+
}),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
expect(mounted).toBe(false);
|
|
279
|
+
expect(failures[0]).toContain("private address");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("refuses past the per-User server ceiling", async () => {
|
|
283
|
+
const failures: string[] = [];
|
|
284
|
+
const { mounted } = await mount({
|
|
285
|
+
failures,
|
|
286
|
+
fetch: server({}),
|
|
287
|
+
assignmentIndex: MAX_MCP_SERVERS_PER_USER_V1,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
expect(mounted).toBe(false);
|
|
291
|
+
expect(failures[0]).toContain(String(MAX_MCP_SERVERS_PER_USER_V1));
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("refuses a Connection that is not ready", async () => {
|
|
295
|
+
const failures: string[] = [];
|
|
296
|
+
const { mounted } = await mount({
|
|
297
|
+
failures,
|
|
298
|
+
fetch: server({}),
|
|
299
|
+
connection: connection({ state: "failed" }),
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
expect(mounted).toBe(false);
|
|
303
|
+
expect(failures[0]).toContain("not ready");
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
function context() {
|
|
308
|
+
return {
|
|
309
|
+
botId: "bot-1",
|
|
310
|
+
agentId: "agent-1",
|
|
311
|
+
sessionId: "session-1",
|
|
312
|
+
compositionGenerationId: "generation-1",
|
|
313
|
+
effectId: "effect-1",
|
|
314
|
+
turnType: "chat" as const,
|
|
315
|
+
signal: new AbortController().signal,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
describe("the durable lifecycle a mount participates in", () => {
|
|
320
|
+
test("attaches the server's instructions to every one of its tool descriptions", async () => {
|
|
321
|
+
const { root } = await mount({
|
|
322
|
+
fetch: server({ tools: [{ name: "echo" }] }),
|
|
323
|
+
connection: connection({
|
|
324
|
+
safeMetadata: {
|
|
325
|
+
serverEpoch: 4,
|
|
326
|
+
instructions: "Search before answering.",
|
|
327
|
+
},
|
|
328
|
+
}),
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const [schema] = root.tools.schemas({ turnType: "chat" });
|
|
332
|
+
expect(schema?.name).toBe("mcp__example__echo");
|
|
333
|
+
// The instructions reach the model in the exact normalized request the
|
|
334
|
+
// session log records, which is the only place a User can prove it.
|
|
335
|
+
expect(schema?.description).toContain("Search before answering.");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("reports a ready mount and an unreachable one, with the epoch it ran at", async () => {
|
|
339
|
+
const ready: unknown[] = [];
|
|
340
|
+
await mount({
|
|
341
|
+
fetch: server({}),
|
|
342
|
+
connection: connection({ safeMetadata: { serverEpoch: 4 } }),
|
|
343
|
+
outcomes: ready,
|
|
344
|
+
});
|
|
345
|
+
expect(ready[0]).toMatchObject({
|
|
346
|
+
connectionId: "mcp-1",
|
|
347
|
+
serverEpoch: 4,
|
|
348
|
+
state: "ready",
|
|
349
|
+
protocolVersion: "2025-06-18",
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const broken: unknown[] = [];
|
|
353
|
+
const failures: string[] = [];
|
|
354
|
+
const { mounted } = await mount({
|
|
355
|
+
fetch: (() =>
|
|
356
|
+
Promise.resolve(
|
|
357
|
+
new Response("gone", { status: 503 }),
|
|
358
|
+
)) as unknown as typeof fetch,
|
|
359
|
+
connection: connection({ safeMetadata: { serverEpoch: 4 } }),
|
|
360
|
+
outcomes: broken,
|
|
361
|
+
failures,
|
|
362
|
+
});
|
|
363
|
+
expect(mounted).toBe(false);
|
|
364
|
+
expect(broken[0]).toMatchObject({
|
|
365
|
+
connectionId: "mcp-1",
|
|
366
|
+
serverEpoch: 4,
|
|
367
|
+
state: "error",
|
|
368
|
+
failure: { code: "unreachable" },
|
|
369
|
+
});
|
|
370
|
+
expect(failures[0]).toContain("503");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("reports needs-auth when the server refuses the credential", async () => {
|
|
374
|
+
const outcomes: unknown[] = [];
|
|
375
|
+
await mount({
|
|
376
|
+
fetch: (() =>
|
|
377
|
+
Promise.resolve(
|
|
378
|
+
new Response("Unauthorized", { status: 401 }),
|
|
379
|
+
)) as unknown as typeof fetch,
|
|
380
|
+
outcomes,
|
|
381
|
+
});
|
|
382
|
+
expect(outcomes[0]).toMatchObject({
|
|
383
|
+
state: "needs-auth",
|
|
384
|
+
failure: { code: "unauthorized" },
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("the Assignment's resolution key moves with the server epoch", () => {
|
|
389
|
+
const before = mcpAssignmentResolutionV1({
|
|
390
|
+
assignment: { connectionId: "mcp-1" },
|
|
391
|
+
connection: connection({ safeMetadata: { serverEpoch: 1 } }),
|
|
392
|
+
});
|
|
393
|
+
const restarted = mcpAssignmentResolutionV1({
|
|
394
|
+
assignment: { connectionId: "mcp-1" },
|
|
395
|
+
connection: connection({ safeMetadata: { serverEpoch: 2 } }),
|
|
396
|
+
});
|
|
397
|
+
expect(restarted).not.toBe(before);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("reads no epoch or instructions from a Connection that carries none", () => {
|
|
401
|
+
expect(mcpConnectionLifecycleV1({ safeMetadata: {} })).toEqual({});
|
|
402
|
+
// A Bot cannot smuggle a lifecycle field in as the wrong type.
|
|
403
|
+
expect(
|
|
404
|
+
mcpConnectionLifecycleV1({
|
|
405
|
+
safeMetadata: { serverEpoch: "4", instructions: 7 },
|
|
406
|
+
}),
|
|
407
|
+
).toEqual({});
|
|
408
|
+
});
|
|
409
|
+
});
|