@plosson/agentio 0.7.2 → 0.7.3
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 +1 -1
- package/src/commands/config-import.test.ts +330 -0
- package/src/commands/config.ts +21 -2
- package/src/commands/mcp.ts +5 -0
- package/src/commands/server-tokens.test.ts +269 -0
- package/src/commands/server.ts +514 -0
- package/src/commands/teleport.test.ts +1216 -0
- package/src/commands/teleport.ts +733 -0
- package/src/index.ts +2 -0
- package/src/mcp/server.test.ts +89 -0
- package/src/mcp/server.ts +51 -30
- package/src/server/daemon.test.ts +637 -0
- package/src/server/daemon.ts +177 -0
- package/src/server/dockerfile-gen.test.ts +192 -0
- package/src/server/dockerfile-gen.ts +101 -0
- package/src/server/dockerfile-teleport.test.ts +180 -0
- package/src/server/http.test.ts +256 -0
- package/src/server/http.ts +54 -0
- package/src/server/mcp-adversarial.test.ts +643 -0
- package/src/server/mcp-e2e.test.ts +397 -0
- package/src/server/mcp-http.test.ts +364 -0
- package/src/server/mcp-http.ts +339 -0
- package/src/server/oauth-e2e.test.ts +466 -0
- package/src/server/oauth-store.test.ts +423 -0
- package/src/server/oauth-store.ts +216 -0
- package/src/server/oauth.test.ts +1502 -0
- package/src/server/oauth.ts +800 -0
- package/src/server/siteio-runner.test.ts +720 -0
- package/src/server/siteio-runner.ts +329 -0
- package/src/server/test-helpers.ts +201 -0
- package/src/types/config.ts +3 -0
- package/src/types/server.ts +61 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import { mkdtemp, rm } from 'fs/promises';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { tmpdir } from 'os';
|
|
6
|
+
import type { Subprocess } from 'bun';
|
|
7
|
+
|
|
8
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
9
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
10
|
+
|
|
11
|
+
import { findFreePort, startServerSubprocess } from './test-helpers';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* End-to-end MCP protocol test.
|
|
15
|
+
*
|
|
16
|
+
* This walks the full stack exactly as a real client (Claude Code,
|
|
17
|
+
* Cursor) would:
|
|
18
|
+
*
|
|
19
|
+
* 1. Start `agentio server start --foreground` in a subprocess with
|
|
20
|
+
* an isolated HOME.
|
|
21
|
+
* 2. Do the OAuth dance to get a bearer token (DCR → /authorize →
|
|
22
|
+
* /token).
|
|
23
|
+
* 3. Build the SDK's `StreamableHTTPClientTransport` with the bearer
|
|
24
|
+
* injected via `requestInit.headers`.
|
|
25
|
+
* 4. Connect an MCP `Client` instance, which triggers
|
|
26
|
+
* `initialize` + `notifications/initialized` automatically.
|
|
27
|
+
* 5. Call `listTools()` and verify the registered service tools are
|
|
28
|
+
* present.
|
|
29
|
+
* 6. Call `callTool()` against a fixture RSS feed served by a
|
|
30
|
+
* temporary local Bun server. Verify the response content.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
interface RunningServer {
|
|
34
|
+
proc: Subprocess<'ignore', 'pipe', 'pipe'>;
|
|
35
|
+
apiKey: string;
|
|
36
|
+
port: number;
|
|
37
|
+
baseUrl: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let tempHome = '';
|
|
41
|
+
let active: RunningServer | null = null;
|
|
42
|
+
let fixtureServer: ReturnType<typeof Bun.serve> | null = null;
|
|
43
|
+
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
tempHome = await mkdtemp(join(tmpdir(), 'agentio-mcp-e2e-'));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
if (active) {
|
|
50
|
+
try {
|
|
51
|
+
active.proc.kill('SIGTERM');
|
|
52
|
+
await Promise.race([
|
|
53
|
+
active.proc.exited,
|
|
54
|
+
new Promise<number>((resolve) => setTimeout(() => resolve(-1), 5000)),
|
|
55
|
+
]);
|
|
56
|
+
} catch {
|
|
57
|
+
try {
|
|
58
|
+
active.proc.kill('SIGKILL');
|
|
59
|
+
await active.proc.exited;
|
|
60
|
+
} catch {
|
|
61
|
+
/* ignore */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
active = null;
|
|
65
|
+
}
|
|
66
|
+
if (fixtureServer) {
|
|
67
|
+
fixtureServer.stop(true);
|
|
68
|
+
fixtureServer = null;
|
|
69
|
+
}
|
|
70
|
+
if (tempHome) {
|
|
71
|
+
await rm(tempHome, { recursive: true, force: true }).catch(() => {});
|
|
72
|
+
tempHome = '';
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/* ------------------------------------------------------------------ */
|
|
77
|
+
/* helpers */
|
|
78
|
+
/* ------------------------------------------------------------------ */
|
|
79
|
+
|
|
80
|
+
async function startAgentioServer(): Promise<RunningServer> {
|
|
81
|
+
const started = await startServerSubprocess({ home: tempHome });
|
|
82
|
+
const running: RunningServer = {
|
|
83
|
+
proc: started.proc,
|
|
84
|
+
apiKey: started.apiKey,
|
|
85
|
+
port: started.port,
|
|
86
|
+
baseUrl: `http://127.0.0.1:${started.port}`,
|
|
87
|
+
};
|
|
88
|
+
active = running;
|
|
89
|
+
return running;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Walk the OAuth flow with hand-rolled HTTP calls, matching what the
|
|
94
|
+
* oauth-e2e tests already validate. Returns the final bearer token.
|
|
95
|
+
*/
|
|
96
|
+
async function obtainBearerToken(server: RunningServer): Promise<string> {
|
|
97
|
+
const redirectUri = 'http://localhost:53682/callback';
|
|
98
|
+
const verifier = 'verifier_' + 'a'.repeat(54);
|
|
99
|
+
const challenge = createHash('sha256')
|
|
100
|
+
.update(verifier, 'ascii')
|
|
101
|
+
.digest('base64url');
|
|
102
|
+
|
|
103
|
+
// Dynamic Client Registration.
|
|
104
|
+
const regRes = await fetch(`${server.baseUrl}/register`, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: { 'content-type': 'application/json' },
|
|
107
|
+
body: JSON.stringify({
|
|
108
|
+
client_name: 'MCP E2E Test',
|
|
109
|
+
redirect_uris: [redirectUri],
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
if (regRes.status !== 201) {
|
|
113
|
+
throw new Error(`DCR failed: ${regRes.status}`);
|
|
114
|
+
}
|
|
115
|
+
const clientId = ((await regRes.json()) as Record<string, unknown>)
|
|
116
|
+
.client_id as string;
|
|
117
|
+
|
|
118
|
+
// Authorize.
|
|
119
|
+
const authRes = await fetch(`${server.baseUrl}/authorize`, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
122
|
+
body: new URLSearchParams({
|
|
123
|
+
client_id: clientId,
|
|
124
|
+
redirect_uri: redirectUri,
|
|
125
|
+
response_type: 'code',
|
|
126
|
+
code_challenge: challenge,
|
|
127
|
+
code_challenge_method: 'S256',
|
|
128
|
+
state: '',
|
|
129
|
+
scope: 'mcp',
|
|
130
|
+
api_key: server.apiKey,
|
|
131
|
+
}).toString(),
|
|
132
|
+
redirect: 'manual',
|
|
133
|
+
});
|
|
134
|
+
if (authRes.status !== 302) {
|
|
135
|
+
throw new Error(`authorize failed: ${authRes.status}`);
|
|
136
|
+
}
|
|
137
|
+
const code = new URL(authRes.headers.get('location')!).searchParams.get(
|
|
138
|
+
'code'
|
|
139
|
+
)!;
|
|
140
|
+
|
|
141
|
+
// Exchange.
|
|
142
|
+
const tokenRes = await fetch(`${server.baseUrl}/token`, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
145
|
+
body: new URLSearchParams({
|
|
146
|
+
grant_type: 'authorization_code',
|
|
147
|
+
code,
|
|
148
|
+
client_id: clientId,
|
|
149
|
+
redirect_uri: redirectUri,
|
|
150
|
+
code_verifier: verifier,
|
|
151
|
+
}).toString(),
|
|
152
|
+
});
|
|
153
|
+
if (tokenRes.status !== 200) {
|
|
154
|
+
throw new Error(`token failed: ${tokenRes.status}`);
|
|
155
|
+
}
|
|
156
|
+
return ((await tokenRes.json()) as Record<string, unknown>)
|
|
157
|
+
.access_token as string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build an MCP Client connected to the given base URL + services query,
|
|
162
|
+
* using the SDK's StreamableHTTPClientTransport with a bearer token
|
|
163
|
+
* injected via requestInit.headers. Returns the connected client — the
|
|
164
|
+
* caller is responsible for calling `client.close()`.
|
|
165
|
+
*/
|
|
166
|
+
async function connectMcpClient(
|
|
167
|
+
baseUrl: string,
|
|
168
|
+
services: string,
|
|
169
|
+
bearer: string
|
|
170
|
+
): Promise<Client> {
|
|
171
|
+
const url = new URL(
|
|
172
|
+
`${baseUrl}/mcp${services ? `?services=${services}` : ''}`
|
|
173
|
+
);
|
|
174
|
+
const transport = new StreamableHTTPClientTransport(url, {
|
|
175
|
+
requestInit: {
|
|
176
|
+
headers: { authorization: `Bearer ${bearer}` },
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
const client = new Client(
|
|
180
|
+
{ name: 'agentio-e2e-test', version: '0.0.1' },
|
|
181
|
+
{ capabilities: {} }
|
|
182
|
+
);
|
|
183
|
+
await client.connect(transport);
|
|
184
|
+
return client;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* ------------------------------------------------------------------ */
|
|
188
|
+
/* tests */
|
|
189
|
+
/* ------------------------------------------------------------------ */
|
|
190
|
+
|
|
191
|
+
describe('MCP end-to-end (real SDK client)', () => {
|
|
192
|
+
test('initialize → listTools exposes rss tools', async () => {
|
|
193
|
+
const server = await startAgentioServer();
|
|
194
|
+
const bearer = await obtainBearerToken(server);
|
|
195
|
+
const client = await connectMcpClient(server.baseUrl, 'rss', bearer);
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const result = await client.listTools();
|
|
199
|
+
expect(Array.isArray(result.tools)).toBe(true);
|
|
200
|
+
expect(result.tools.length).toBeGreaterThan(0);
|
|
201
|
+
const names = result.tools.map((t) => t.name);
|
|
202
|
+
expect(names.some((n) => n.startsWith('rss_'))).toBe(true);
|
|
203
|
+
} finally {
|
|
204
|
+
await client.close();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('listTools with multiple services exposes tools from each', async () => {
|
|
209
|
+
const server = await startAgentioServer();
|
|
210
|
+
const bearer = await obtainBearerToken(server);
|
|
211
|
+
const client = await connectMcpClient(
|
|
212
|
+
server.baseUrl,
|
|
213
|
+
'rss,sql',
|
|
214
|
+
bearer
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const result = await client.listTools();
|
|
219
|
+
const names = result.tools.map((t) => t.name);
|
|
220
|
+
expect(names.some((n) => n.startsWith('rss_'))).toBe(true);
|
|
221
|
+
expect(names.some((n) => n.startsWith('sql_'))).toBe(true);
|
|
222
|
+
} finally {
|
|
223
|
+
await client.close();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('listTools with empty services exposes no tools', async () => {
|
|
228
|
+
const server = await startAgentioServer();
|
|
229
|
+
const bearer = await obtainBearerToken(server);
|
|
230
|
+
const client = await connectMcpClient(server.baseUrl, '', bearer);
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const result = await client.listTools();
|
|
234
|
+
expect(result.tools).toEqual([]);
|
|
235
|
+
} finally {
|
|
236
|
+
await client.close();
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('callTool: rss_articles against a local fixture feed', async () => {
|
|
241
|
+
const server = await startAgentioServer();
|
|
242
|
+
const bearer = await obtainBearerToken(server);
|
|
243
|
+
|
|
244
|
+
// Serve a tiny fixture RSS feed on a free port.
|
|
245
|
+
const fixturePort = await findFreePort();
|
|
246
|
+
fixtureServer = Bun.serve({
|
|
247
|
+
port: fixturePort,
|
|
248
|
+
fetch: () =>
|
|
249
|
+
new Response(
|
|
250
|
+
`<?xml version="1.0" encoding="UTF-8"?>
|
|
251
|
+
<rss version="2.0">
|
|
252
|
+
<channel>
|
|
253
|
+
<title>E2E Fixture Feed</title>
|
|
254
|
+
<link>http://localhost:${fixturePort}/</link>
|
|
255
|
+
<description>A fixture feed for the mcp-e2e test</description>
|
|
256
|
+
<item>
|
|
257
|
+
<title>First fixture article</title>
|
|
258
|
+
<link>http://localhost:${fixturePort}/article/1</link>
|
|
259
|
+
<guid>http://localhost:${fixturePort}/article/1</guid>
|
|
260
|
+
<description>Content of the first article.</description>
|
|
261
|
+
<pubDate>Thu, 01 Jan 2026 12:00:00 GMT</pubDate>
|
|
262
|
+
</item>
|
|
263
|
+
<item>
|
|
264
|
+
<title>Second fixture article</title>
|
|
265
|
+
<link>http://localhost:${fixturePort}/article/2</link>
|
|
266
|
+
<guid>http://localhost:${fixturePort}/article/2</guid>
|
|
267
|
+
<description>Content of the second article.</description>
|
|
268
|
+
<pubDate>Fri, 02 Jan 2026 12:00:00 GMT</pubDate>
|
|
269
|
+
</item>
|
|
270
|
+
</channel>
|
|
271
|
+
</rss>`,
|
|
272
|
+
{ headers: { 'content-type': 'application/rss+xml' } }
|
|
273
|
+
),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const client = await connectMcpClient(server.baseUrl, 'rss', bearer);
|
|
277
|
+
try {
|
|
278
|
+
const result = await client.callTool({
|
|
279
|
+
name: 'rss_articles',
|
|
280
|
+
arguments: {
|
|
281
|
+
url: `http://127.0.0.1:${fixturePort}/`,
|
|
282
|
+
limit: '5',
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
expect(result.isError).toBeFalsy();
|
|
286
|
+
const content = result.content as Array<{ type: string; text: string }>;
|
|
287
|
+
expect(Array.isArray(content)).toBe(true);
|
|
288
|
+
expect(content.length).toBeGreaterThan(0);
|
|
289
|
+
const joined = content.map((c) => c.text).join('\n');
|
|
290
|
+
expect(joined).toContain('First fixture article');
|
|
291
|
+
expect(joined).toContain('Second fixture article');
|
|
292
|
+
} finally {
|
|
293
|
+
await client.close();
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('callTool with unknown tool name returns isError', async () => {
|
|
298
|
+
const server = await startAgentioServer();
|
|
299
|
+
const bearer = await obtainBearerToken(server);
|
|
300
|
+
const client = await connectMcpClient(server.baseUrl, 'rss', bearer);
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
const result = await client.callTool({
|
|
304
|
+
name: 'rss_nonexistent_tool_name',
|
|
305
|
+
arguments: {},
|
|
306
|
+
});
|
|
307
|
+
expect(result.isError).toBe(true);
|
|
308
|
+
const content = result.content as Array<{ type: string; text: string }>;
|
|
309
|
+
expect(content[0].text).toContain('Unknown tool');
|
|
310
|
+
} finally {
|
|
311
|
+
await client.close();
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('two concurrent callTool requests in the same session do not cross-contaminate', async () => {
|
|
316
|
+
const server = await startAgentioServer();
|
|
317
|
+
const bearer = await obtainBearerToken(server);
|
|
318
|
+
|
|
319
|
+
// Serve two distinct fixture feeds.
|
|
320
|
+
const fixturePort = await findFreePort();
|
|
321
|
+
fixtureServer = Bun.serve({
|
|
322
|
+
port: fixturePort,
|
|
323
|
+
fetch: (req) => {
|
|
324
|
+
const path = new URL(req.url).pathname;
|
|
325
|
+
if (path === '/feed-a') {
|
|
326
|
+
return new Response(
|
|
327
|
+
`<?xml version="1.0"?><rss version="2.0"><channel><title>FEED_A_TITLE</title><link>http://x/a</link><description>A</description><item><title>ARTICLE_A_ONE</title><link>http://x/a/1</link><description>a</description></item></channel></rss>`,
|
|
328
|
+
{ headers: { 'content-type': 'application/rss+xml' } }
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
if (path === '/feed-b') {
|
|
332
|
+
return new Response(
|
|
333
|
+
`<?xml version="1.0"?><rss version="2.0"><channel><title>FEED_B_TITLE</title><link>http://x/b</link><description>B</description><item><title>ARTICLE_B_ONE</title><link>http://x/b/1</link><description>b</description></item></channel></rss>`,
|
|
334
|
+
{ headers: { 'content-type': 'application/rss+xml' } }
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
return new Response('not found', { status: 404 });
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const client = await connectMcpClient(server.baseUrl, 'rss', bearer);
|
|
342
|
+
try {
|
|
343
|
+
const [a, b] = await Promise.all([
|
|
344
|
+
client.callTool({
|
|
345
|
+
name: 'rss_articles',
|
|
346
|
+
arguments: {
|
|
347
|
+
url: `http://127.0.0.1:${fixturePort}/feed-a`,
|
|
348
|
+
limit: '5',
|
|
349
|
+
},
|
|
350
|
+
}),
|
|
351
|
+
client.callTool({
|
|
352
|
+
name: 'rss_articles',
|
|
353
|
+
arguments: {
|
|
354
|
+
url: `http://127.0.0.1:${fixturePort}/feed-b`,
|
|
355
|
+
limit: '5',
|
|
356
|
+
},
|
|
357
|
+
}),
|
|
358
|
+
]);
|
|
359
|
+
|
|
360
|
+
const textA = (a.content as Array<{ text: string }>)
|
|
361
|
+
.map((c) => c.text)
|
|
362
|
+
.join('\n');
|
|
363
|
+
const textB = (b.content as Array<{ text: string }>)
|
|
364
|
+
.map((c) => c.text)
|
|
365
|
+
.join('\n');
|
|
366
|
+
|
|
367
|
+
expect(textA).toContain('ARTICLE_A_ONE');
|
|
368
|
+
expect(textA).not.toContain('ARTICLE_B_ONE');
|
|
369
|
+
expect(textB).toContain('ARTICLE_B_ONE');
|
|
370
|
+
expect(textB).not.toContain('ARTICLE_A_ONE');
|
|
371
|
+
} finally {
|
|
372
|
+
await client.close();
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('a second client on the same server gets an independent session', async () => {
|
|
377
|
+
const server = await startAgentioServer();
|
|
378
|
+
const bearer = await obtainBearerToken(server);
|
|
379
|
+
|
|
380
|
+
const clientA = await connectMcpClient(server.baseUrl, 'rss', bearer);
|
|
381
|
+
const clientB = await connectMcpClient(server.baseUrl, 'sql', bearer);
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
const [a, b] = await Promise.all([
|
|
385
|
+
clientA.listTools(),
|
|
386
|
+
clientB.listTools(),
|
|
387
|
+
]);
|
|
388
|
+
// clientA asked for rss → should see rss tools but NOT sql.
|
|
389
|
+
expect(a.tools.every((t) => t.name.startsWith('rss_'))).toBe(true);
|
|
390
|
+
// clientB asked for sql → sql tools only.
|
|
391
|
+
expect(b.tools.every((t) => t.name.startsWith('sql_'))).toBe(true);
|
|
392
|
+
} finally {
|
|
393
|
+
await clientA.close();
|
|
394
|
+
await clientB.close();
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
});
|