@poncho-ai/harness 0.2.0 → 0.3.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/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +16 -0
- package/dist/index.d.ts +58 -4
- package/dist/index.js +752 -118
- package/package.json +1 -3
- package/src/agent-parser.ts +25 -0
- package/src/config.ts +2 -0
- package/src/harness.ts +245 -9
- package/src/mcp.ts +398 -123
- package/src/skill-context.ts +37 -9
- package/src/skill-tools.ts +72 -2
- package/src/tool-dispatcher.ts +10 -0
- package/src/tool-policy.ts +104 -0
- package/test/harness.test.ts +437 -10
- package/test/mcp.test.ts +350 -55
package/test/mcp.test.ts
CHANGED
|
@@ -1,69 +1,112 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { LocalMcpBridge } from "../src/mcp.js";
|
|
4
4
|
|
|
5
5
|
describe("mcp bridge protocol transports", () => {
|
|
6
|
-
it("discovers and calls tools over
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
6
|
+
it("discovers and calls tools over streamable HTTP", async () => {
|
|
7
|
+
process.env.LINEAR_TOKEN = "token-123";
|
|
8
|
+
const requests: string[] = [];
|
|
9
|
+
let authHeader = "";
|
|
10
|
+
let deleteSeen = false;
|
|
11
|
+
const session = "session_test";
|
|
12
|
+
const server = createServer(async (req, res) => {
|
|
13
|
+
if (req.method === "DELETE") {
|
|
14
|
+
deleteSeen = true;
|
|
15
|
+
res.statusCode = 200;
|
|
16
|
+
res.end();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const chunks: Buffer[] = [];
|
|
20
|
+
for await (const chunk of req) {
|
|
21
|
+
chunks.push(Buffer.from(chunk));
|
|
22
|
+
}
|
|
23
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
24
|
+
const payload = body.trim().length > 0 ? (JSON.parse(body) as any) : {};
|
|
25
|
+
authHeader = req.headers.authorization ?? "";
|
|
26
|
+
if (payload.method) {
|
|
27
|
+
requests.push(payload.method);
|
|
28
|
+
}
|
|
29
|
+
if (payload.method === "initialize") {
|
|
30
|
+
res.setHeader("Content-Type", "application/json");
|
|
31
|
+
res.setHeader("Mcp-Session-Id", session);
|
|
32
|
+
res.end(
|
|
33
|
+
JSON.stringify({
|
|
34
|
+
jsonrpc: "2.0",
|
|
35
|
+
id: payload.id,
|
|
36
|
+
result: {
|
|
37
|
+
protocolVersion: "2025-03-26",
|
|
38
|
+
capabilities: { tools: { listChanged: true } },
|
|
39
|
+
serverInfo: { name: "remote", version: "1.0.0" },
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (payload.method === "notifications/initialized") {
|
|
46
|
+
res.statusCode = 202;
|
|
47
|
+
res.end();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (payload.method === "tools/list") {
|
|
51
|
+
expect(req.headers["mcp-session-id"]).toBe(session);
|
|
52
|
+
res.setHeader("Content-Type", "application/json");
|
|
53
|
+
res.end(
|
|
54
|
+
JSON.stringify({
|
|
55
|
+
jsonrpc: "2.0",
|
|
56
|
+
id: payload.id,
|
|
57
|
+
result: {
|
|
58
|
+
tools: [
|
|
59
|
+
{
|
|
60
|
+
name: "remotePing",
|
|
61
|
+
description: "Remote ping",
|
|
62
|
+
inputSchema: {
|
|
63
|
+
type: "object",
|
|
64
|
+
properties: { value: { type: "string" } },
|
|
33
65
|
},
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (payload.method === "tools/call") {
|
|
74
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
75
|
+
res.end(
|
|
76
|
+
`data: ${JSON.stringify({
|
|
77
|
+
jsonrpc: "2.0",
|
|
78
|
+
id: payload.id,
|
|
79
|
+
result: { result: { echoed: payload.params?.arguments?.value ?? "" } },
|
|
80
|
+
})}\n\n`,
|
|
81
|
+
);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
res.statusCode = 404;
|
|
85
|
+
res.end();
|
|
50
86
|
});
|
|
51
|
-
|
|
52
|
-
const address =
|
|
87
|
+
await new Promise<void>((resolveOpen) => server.listen(0, () => resolveOpen()));
|
|
88
|
+
const address = server.address();
|
|
53
89
|
if (!address || typeof address === "string") {
|
|
54
|
-
throw new Error("Unexpected
|
|
90
|
+
throw new Error("Unexpected server address");
|
|
55
91
|
}
|
|
56
92
|
|
|
57
93
|
const bridge = new LocalMcpBridge({
|
|
58
|
-
mcp: [
|
|
94
|
+
mcp: [
|
|
95
|
+
{
|
|
96
|
+
name: "remote",
|
|
97
|
+
url: `http://127.0.0.1:${address.port}/mcp`,
|
|
98
|
+
auth: { type: "bearer", tokenEnv: "LINEAR_TOKEN" },
|
|
99
|
+
},
|
|
100
|
+
],
|
|
59
101
|
});
|
|
60
102
|
|
|
61
103
|
await bridge.startLocalServers();
|
|
62
|
-
|
|
63
|
-
const
|
|
104
|
+
await bridge.discoverTools();
|
|
105
|
+
const tools = await bridge.loadTools(["remote/remotePing"]);
|
|
106
|
+
const tool = tools.find((entry) => entry.name === "remote/remotePing");
|
|
64
107
|
expect(tool).toBeDefined();
|
|
65
108
|
const output = await tool?.handler(
|
|
66
|
-
{ value: "
|
|
109
|
+
{ value: "http" },
|
|
67
110
|
{
|
|
68
111
|
agentId: "agent",
|
|
69
112
|
runId: "run",
|
|
@@ -72,11 +115,263 @@ describe("mcp bridge protocol transports", () => {
|
|
|
72
115
|
parameters: {},
|
|
73
116
|
},
|
|
74
117
|
);
|
|
75
|
-
expect(output).toEqual({ echoed: "
|
|
118
|
+
expect(output).toEqual({ echoed: "http" });
|
|
119
|
+
expect(authHeader).toBe("Bearer token-123");
|
|
120
|
+
expect(requests).toContain("initialize");
|
|
121
|
+
expect(requests).toContain("tools/list");
|
|
122
|
+
expect(requests).toContain("tools/call");
|
|
76
123
|
|
|
77
124
|
await bridge.stopLocalServers();
|
|
78
|
-
await new Promise<void>((resolveClose) =>
|
|
79
|
-
|
|
125
|
+
await new Promise<void>((resolveClose) => server.close(() => resolveClose()));
|
|
126
|
+
expect(deleteSeen).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("fails fast on duplicate server names", () => {
|
|
130
|
+
expect(
|
|
131
|
+
() =>
|
|
132
|
+
new LocalMcpBridge({
|
|
133
|
+
mcp: [
|
|
134
|
+
{ name: "dup", url: "https://example.com/a" },
|
|
135
|
+
{ name: "dup", url: "https://example.com/b" },
|
|
136
|
+
],
|
|
137
|
+
}),
|
|
138
|
+
).toThrow(/Duplicate MCP server name/);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("applies allowlist and denylist policy filters", async () => {
|
|
142
|
+
process.env.LINEAR_TOKEN = "token-123";
|
|
143
|
+
const server = createServer(async (req, res) => {
|
|
144
|
+
if (req.method === "DELETE") {
|
|
145
|
+
res.statusCode = 200;
|
|
146
|
+
res.end();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const chunks: Buffer[] = [];
|
|
150
|
+
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
|
151
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
152
|
+
const payload = body.trim().length > 0 ? (JSON.parse(body) as any) : {};
|
|
153
|
+
if (payload.method === "initialize") {
|
|
154
|
+
res.setHeader("Content-Type", "application/json");
|
|
155
|
+
res.setHeader("Mcp-Session-Id", "s");
|
|
156
|
+
res.end(
|
|
157
|
+
JSON.stringify({
|
|
158
|
+
jsonrpc: "2.0",
|
|
159
|
+
id: payload.id,
|
|
160
|
+
result: {
|
|
161
|
+
protocolVersion: "2025-03-26",
|
|
162
|
+
capabilities: { tools: { listChanged: true } },
|
|
163
|
+
serverInfo: { name: "remote", version: "1.0.0" },
|
|
164
|
+
},
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (payload.method === "notifications/initialized") {
|
|
170
|
+
res.statusCode = 202;
|
|
171
|
+
res.end();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (payload.method === "tools/list") {
|
|
175
|
+
res.setHeader("Content-Type", "application/json");
|
|
176
|
+
res.end(
|
|
177
|
+
JSON.stringify({
|
|
178
|
+
jsonrpc: "2.0",
|
|
179
|
+
id: payload.id,
|
|
180
|
+
result: {
|
|
181
|
+
tools: [
|
|
182
|
+
{ name: "a", inputSchema: { type: "object", properties: {} } },
|
|
183
|
+
{ name: "b", inputSchema: { type: "object", properties: {} } },
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
res.setHeader("Content-Type", "application/json");
|
|
191
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", id: payload.id, result: {} }));
|
|
192
|
+
});
|
|
193
|
+
await new Promise<void>((resolveOpen) => server.listen(0, () => resolveOpen()));
|
|
194
|
+
const address = server.address();
|
|
195
|
+
if (!address || typeof address === "string") {
|
|
196
|
+
throw new Error("Unexpected server address");
|
|
197
|
+
}
|
|
198
|
+
const allowBridge = new LocalMcpBridge({
|
|
199
|
+
mcp: [
|
|
200
|
+
{
|
|
201
|
+
name: "remote",
|
|
202
|
+
url: `http://127.0.0.1:${address.port}/mcp`,
|
|
203
|
+
auth: { type: "bearer", tokenEnv: "LINEAR_TOKEN" },
|
|
204
|
+
tools: { mode: "allowlist", include: ["remote/a"] },
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
});
|
|
208
|
+
await allowBridge.startLocalServers();
|
|
209
|
+
await allowBridge.discoverTools();
|
|
210
|
+
const allowTools = await allowBridge.loadTools(["remote/*"]);
|
|
211
|
+
expect(allowTools.map((tool) => tool.name)).toEqual(["remote/a"]);
|
|
212
|
+
await allowBridge.stopLocalServers();
|
|
213
|
+
|
|
214
|
+
const denyBridge = new LocalMcpBridge({
|
|
215
|
+
mcp: [
|
|
216
|
+
{
|
|
217
|
+
name: "remote",
|
|
218
|
+
url: `http://127.0.0.1:${address.port}/mcp`,
|
|
219
|
+
auth: { type: "bearer", tokenEnv: "LINEAR_TOKEN" },
|
|
220
|
+
tools: { mode: "denylist", exclude: ["remote/b"] },
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
});
|
|
224
|
+
await denyBridge.startLocalServers();
|
|
225
|
+
await denyBridge.discoverTools();
|
|
226
|
+
const denyTools = await denyBridge.loadTools(["remote/*"]);
|
|
227
|
+
expect(denyTools.map((tool) => tool.name).sort()).toEqual(["remote/a"]);
|
|
228
|
+
await denyBridge.stopLocalServers();
|
|
229
|
+
await new Promise<void>((resolveClose) => server.close(() => resolveClose()));
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("skips discovery when bearer token env value is missing", async () => {
|
|
233
|
+
delete process.env.MISSING_TOKEN_ENV;
|
|
234
|
+
const bridge = new LocalMcpBridge({
|
|
235
|
+
mcp: [
|
|
236
|
+
{
|
|
237
|
+
name: "remote",
|
|
238
|
+
url: "https://example.com/mcp",
|
|
239
|
+
auth: { type: "bearer", tokenEnv: "MISSING_TOKEN_ENV" },
|
|
240
|
+
},
|
|
241
|
+
],
|
|
80
242
|
});
|
|
243
|
+
await bridge.startLocalServers();
|
|
244
|
+
await bridge.discoverTools();
|
|
245
|
+
const tools = await bridge.loadTools(["remote/*"]);
|
|
246
|
+
expect(tools).toEqual([]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("reports auth failures during discovery and call execution", async () => {
|
|
250
|
+
process.env.LINEAR_TOKEN = "token-123";
|
|
251
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
252
|
+
const server = createServer(async (req, res) => {
|
|
253
|
+
if (req.method === "DELETE") {
|
|
254
|
+
res.statusCode = 200;
|
|
255
|
+
res.end();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const chunks: Buffer[] = [];
|
|
259
|
+
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
|
260
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
261
|
+
const payload = body.trim().length > 0 ? (JSON.parse(body) as any) : {};
|
|
262
|
+
if (payload.method === "initialize") {
|
|
263
|
+
res.statusCode = 401;
|
|
264
|
+
res.end("unauthorized");
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
res.statusCode = 404;
|
|
268
|
+
res.end();
|
|
269
|
+
});
|
|
270
|
+
await new Promise<void>((resolveOpen) => server.listen(0, () => resolveOpen()));
|
|
271
|
+
const address = server.address();
|
|
272
|
+
if (!address || typeof address === "string") throw new Error("Unexpected address");
|
|
273
|
+
const bridge = new LocalMcpBridge({
|
|
274
|
+
mcp: [
|
|
275
|
+
{
|
|
276
|
+
name: "remote",
|
|
277
|
+
url: `http://127.0.0.1:${address.port}/mcp`,
|
|
278
|
+
auth: { type: "bearer", tokenEnv: "LINEAR_TOKEN" },
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
});
|
|
282
|
+
await bridge.startLocalServers();
|
|
283
|
+
await bridge.discoverTools();
|
|
284
|
+
expect(bridge.listDiscoveredTools()).toEqual([]);
|
|
285
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"event":"auth.failed"'));
|
|
286
|
+
await bridge.stopLocalServers();
|
|
287
|
+
await new Promise<void>((resolveClose) => server.close(() => resolveClose()));
|
|
288
|
+
warnSpy.mockRestore();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("returns actionable errors for 403 permission failures", async () => {
|
|
292
|
+
process.env.LINEAR_TOKEN = "token-123";
|
|
293
|
+
const server = createServer(async (req, res) => {
|
|
294
|
+
if (req.method === "DELETE") {
|
|
295
|
+
res.statusCode = 200;
|
|
296
|
+
res.end();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const chunks: Buffer[] = [];
|
|
300
|
+
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
|
301
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
302
|
+
const payload = body.trim().length > 0 ? (JSON.parse(body) as any) : {};
|
|
303
|
+
if (payload.method === "initialize") {
|
|
304
|
+
res.setHeader("Content-Type", "application/json");
|
|
305
|
+
res.setHeader("Mcp-Session-Id", "sess");
|
|
306
|
+
res.end(
|
|
307
|
+
JSON.stringify({
|
|
308
|
+
jsonrpc: "2.0",
|
|
309
|
+
id: payload.id,
|
|
310
|
+
result: {
|
|
311
|
+
protocolVersion: "2025-03-26",
|
|
312
|
+
capabilities: { tools: { listChanged: true } },
|
|
313
|
+
serverInfo: { name: "remote", version: "1.0.0" },
|
|
314
|
+
},
|
|
315
|
+
}),
|
|
316
|
+
);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (payload.method === "notifications/initialized") {
|
|
320
|
+
res.statusCode = 202;
|
|
321
|
+
res.end();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (payload.method === "tools/list") {
|
|
325
|
+
res.setHeader("Content-Type", "application/json");
|
|
326
|
+
res.end(
|
|
327
|
+
JSON.stringify({
|
|
328
|
+
jsonrpc: "2.0",
|
|
329
|
+
id: payload.id,
|
|
330
|
+
result: {
|
|
331
|
+
tools: [{ name: "restricted", inputSchema: { type: "object", properties: {} } }],
|
|
332
|
+
},
|
|
333
|
+
}),
|
|
334
|
+
);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (payload.method === "tools/call") {
|
|
338
|
+
res.statusCode = 403;
|
|
339
|
+
res.end("forbidden");
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
res.statusCode = 404;
|
|
343
|
+
res.end();
|
|
344
|
+
});
|
|
345
|
+
await new Promise<void>((resolveOpen) => server.listen(0, () => resolveOpen()));
|
|
346
|
+
const address = server.address();
|
|
347
|
+
if (!address || typeof address === "string") throw new Error("Unexpected address");
|
|
348
|
+
const bridge = new LocalMcpBridge({
|
|
349
|
+
mcp: [
|
|
350
|
+
{
|
|
351
|
+
name: "remote",
|
|
352
|
+
url: `http://127.0.0.1:${address.port}/mcp`,
|
|
353
|
+
auth: { type: "bearer", tokenEnv: "LINEAR_TOKEN" },
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
});
|
|
357
|
+
await bridge.startLocalServers();
|
|
358
|
+
await bridge.discoverTools();
|
|
359
|
+
const tools = await bridge.loadTools(["remote/*"]);
|
|
360
|
+
const restricted = tools.find((tool) => tool.name === "remote/restricted");
|
|
361
|
+
expect(restricted).toBeDefined();
|
|
362
|
+
await expect(
|
|
363
|
+
restricted!.handler(
|
|
364
|
+
{},
|
|
365
|
+
{
|
|
366
|
+
agentId: "agent",
|
|
367
|
+
runId: "run",
|
|
368
|
+
step: 1,
|
|
369
|
+
workingDir: process.cwd(),
|
|
370
|
+
parameters: {},
|
|
371
|
+
},
|
|
372
|
+
),
|
|
373
|
+
).rejects.toThrow(/permission denied/i);
|
|
374
|
+
await bridge.stopLocalServers();
|
|
375
|
+
await new Promise<void>((resolveClose) => server.close(() => resolveClose()));
|
|
81
376
|
});
|
|
82
377
|
});
|