@plosson/agentio 0.7.1 → 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,364 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
_getSessionCount,
|
|
5
|
+
_resetSessionsForTests,
|
|
6
|
+
handleMcpRequest,
|
|
7
|
+
parseServicesQuery,
|
|
8
|
+
} from './mcp-http';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Pure-ish unit tests for the session manager. `parseServicesQuery` is
|
|
12
|
+
* a pure function — tested exhaustively. `handleMcpRequest` touches the
|
|
13
|
+
* module-level `sessions` map, so tests wipe it in afterEach.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
_resetSessionsForTests();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/* ------------------------------------------------------------------ */
|
|
21
|
+
/* parseServicesQuery */
|
|
22
|
+
/* ------------------------------------------------------------------ */
|
|
23
|
+
|
|
24
|
+
describe('parseServicesQuery — happy path', () => {
|
|
25
|
+
test('null → empty services (valid)', () => {
|
|
26
|
+
const r = parseServicesQuery(null);
|
|
27
|
+
expect(r.ok).toBe(true);
|
|
28
|
+
if (r.ok) expect(r.services).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('empty string → empty services', () => {
|
|
32
|
+
const r = parseServicesQuery('');
|
|
33
|
+
expect(r.ok).toBe(true);
|
|
34
|
+
if (r.ok) expect(r.services).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('whitespace-only → empty services', () => {
|
|
38
|
+
const r = parseServicesQuery(' , , ');
|
|
39
|
+
expect(r.ok).toBe(true);
|
|
40
|
+
if (r.ok) expect(r.services).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('single service without profile', () => {
|
|
44
|
+
const r = parseServicesQuery('rss');
|
|
45
|
+
expect(r.ok).toBe(true);
|
|
46
|
+
if (r.ok)
|
|
47
|
+
expect(r.services).toEqual([{ service: 'rss', profile: undefined }]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('single service:profile pair', () => {
|
|
51
|
+
const r = parseServicesQuery('gchat:default');
|
|
52
|
+
expect(r.ok).toBe(true);
|
|
53
|
+
if (r.ok)
|
|
54
|
+
expect(r.services).toEqual([{ service: 'gchat', profile: 'default' }]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('multiple services with mixed profile/no-profile', () => {
|
|
58
|
+
const r = parseServicesQuery('rss,gchat:default,gmail:work');
|
|
59
|
+
expect(r.ok).toBe(true);
|
|
60
|
+
if (r.ok)
|
|
61
|
+
expect(r.services).toEqual([
|
|
62
|
+
{ service: 'rss', profile: undefined },
|
|
63
|
+
{ service: 'gchat', profile: 'default' },
|
|
64
|
+
{ service: 'gmail', profile: 'work' },
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('trims whitespace around each entry', () => {
|
|
69
|
+
const r = parseServicesQuery(' rss , gchat:default ');
|
|
70
|
+
expect(r.ok).toBe(true);
|
|
71
|
+
if (r.ok)
|
|
72
|
+
expect(r.services).toEqual([
|
|
73
|
+
{ service: 'rss', profile: undefined },
|
|
74
|
+
{ service: 'gchat', profile: 'default' },
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('accepts all valid registered services', () => {
|
|
79
|
+
const all =
|
|
80
|
+
'discourse,gcal,gchat,gdocs,gdrive,github,gmail,gsheets,gtasks,jira,rss,slack,sql,telegram,whatsapp';
|
|
81
|
+
const r = parseServicesQuery(all);
|
|
82
|
+
expect(r.ok).toBe(true);
|
|
83
|
+
if (r.ok) expect(r.services.length).toBe(15);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('parseServicesQuery — adversarial', () => {
|
|
88
|
+
test('unknown service name → 400 with known-services hint', () => {
|
|
89
|
+
const r = parseServicesQuery('nope');
|
|
90
|
+
expect(r.ok).toBe(false);
|
|
91
|
+
if (!r.ok) {
|
|
92
|
+
expect(r.status).toBe(400);
|
|
93
|
+
expect(r.message).toContain('unknown service');
|
|
94
|
+
expect(r.message).toContain('"nope"');
|
|
95
|
+
expect(r.message).toContain('rss'); // sample of known services
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('one unknown among valid → still rejected', () => {
|
|
100
|
+
const r = parseServicesQuery('rss,nope,gmail:x');
|
|
101
|
+
expect(r.ok).toBe(false);
|
|
102
|
+
if (!r.ok) expect(r.message).toContain('nope');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('service name with empty profile (trailing colon) → 400', () => {
|
|
106
|
+
const r = parseServicesQuery('gmail:');
|
|
107
|
+
expect(r.ok).toBe(false);
|
|
108
|
+
if (!r.ok) {
|
|
109
|
+
expect(r.status).toBe(400);
|
|
110
|
+
expect(r.message).toContain('profile name is empty');
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('leading colon (empty service name) → 400', () => {
|
|
115
|
+
const r = parseServicesQuery(':foo');
|
|
116
|
+
expect(r.ok).toBe(false);
|
|
117
|
+
if (!r.ok) expect(r.message).toContain('service name is empty');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('bare colon → 400', () => {
|
|
121
|
+
const r = parseServicesQuery(':');
|
|
122
|
+
expect(r.ok).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('case-sensitive: RSS (uppercase) → unknown', () => {
|
|
126
|
+
const r = parseServicesQuery('RSS');
|
|
127
|
+
expect(r.ok).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('service name with unexpected characters → unknown', () => {
|
|
131
|
+
const r = parseServicesQuery('rss-feed');
|
|
132
|
+
expect(r.ok).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/* ------------------------------------------------------------------ */
|
|
137
|
+
/* handleMcpRequest — routing (no real MCP protocol yet) */
|
|
138
|
+
/* ------------------------------------------------------------------ */
|
|
139
|
+
|
|
140
|
+
describe('handleMcpRequest — routing', () => {
|
|
141
|
+
test('unknown mcp-session-id → 404 JSON-RPC error', async () => {
|
|
142
|
+
const req = new Request('http://localhost:9999/mcp', {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: {
|
|
145
|
+
'mcp-session-id': 'does-not-exist',
|
|
146
|
+
'content-type': 'application/json',
|
|
147
|
+
accept: 'application/json, text/event-stream',
|
|
148
|
+
},
|
|
149
|
+
body: JSON.stringify({
|
|
150
|
+
jsonrpc: '2.0',
|
|
151
|
+
id: 1,
|
|
152
|
+
method: 'tools/list',
|
|
153
|
+
params: {},
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
const res = await handleMcpRequest(req);
|
|
157
|
+
expect(res.status).toBe(404);
|
|
158
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
159
|
+
expect(body.jsonrpc).toBe('2.0');
|
|
160
|
+
const error = body.error as Record<string, unknown>;
|
|
161
|
+
expect(error.message).toContain('does-not-exist');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('no session id + invalid services → 400 BEFORE transport runs', async () => {
|
|
165
|
+
const req = new Request(
|
|
166
|
+
'http://localhost:9999/mcp?services=nope',
|
|
167
|
+
{
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: {
|
|
170
|
+
'content-type': 'application/json',
|
|
171
|
+
accept: 'application/json, text/event-stream',
|
|
172
|
+
},
|
|
173
|
+
body: JSON.stringify({
|
|
174
|
+
jsonrpc: '2.0',
|
|
175
|
+
id: 1,
|
|
176
|
+
method: 'initialize',
|
|
177
|
+
params: {
|
|
178
|
+
protocolVersion: '2024-11-05',
|
|
179
|
+
capabilities: {},
|
|
180
|
+
clientInfo: { name: 'test', version: '0.0.0' },
|
|
181
|
+
},
|
|
182
|
+
}),
|
|
183
|
+
}
|
|
184
|
+
);
|
|
185
|
+
const res = await handleMcpRequest(req);
|
|
186
|
+
expect(res.status).toBe(400);
|
|
187
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
188
|
+
expect(body.error).toBe('invalid_request');
|
|
189
|
+
expect(body.error_description).toContain('unknown service');
|
|
190
|
+
// And no session was leaked into the map.
|
|
191
|
+
expect(_getSessionCount()).toBe(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('no session id + empty services → transport runs, session created on initialize', async () => {
|
|
195
|
+
const req = new Request('http://localhost:9999/mcp', {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers: {
|
|
198
|
+
'content-type': 'application/json',
|
|
199
|
+
accept: 'application/json, text/event-stream',
|
|
200
|
+
},
|
|
201
|
+
body: JSON.stringify({
|
|
202
|
+
jsonrpc: '2.0',
|
|
203
|
+
id: 1,
|
|
204
|
+
method: 'initialize',
|
|
205
|
+
params: {
|
|
206
|
+
protocolVersion: '2024-11-05',
|
|
207
|
+
capabilities: {},
|
|
208
|
+
clientInfo: { name: 'test', version: '0.0.0' },
|
|
209
|
+
},
|
|
210
|
+
}),
|
|
211
|
+
});
|
|
212
|
+
const res = await handleMcpRequest(req);
|
|
213
|
+
// The SDK accepts the initialize and assigns a session id.
|
|
214
|
+
expect(res.status).toBe(200);
|
|
215
|
+
expect(res.headers.get('mcp-session-id')).toBeDefined();
|
|
216
|
+
expect(_getSessionCount()).toBe(1);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('session survives across multiple requests routed by mcp-session-id', async () => {
|
|
220
|
+
// First: initialize (creates session).
|
|
221
|
+
const initReq = new Request('http://localhost:9999/mcp?services=rss', {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: {
|
|
224
|
+
'content-type': 'application/json',
|
|
225
|
+
accept: 'application/json, text/event-stream',
|
|
226
|
+
},
|
|
227
|
+
body: JSON.stringify({
|
|
228
|
+
jsonrpc: '2.0',
|
|
229
|
+
id: 1,
|
|
230
|
+
method: 'initialize',
|
|
231
|
+
params: {
|
|
232
|
+
protocolVersion: '2024-11-05',
|
|
233
|
+
capabilities: {},
|
|
234
|
+
clientInfo: { name: 'test', version: '0.0.0' },
|
|
235
|
+
},
|
|
236
|
+
}),
|
|
237
|
+
});
|
|
238
|
+
const initRes = await handleMcpRequest(initReq);
|
|
239
|
+
const sid = initRes.headers.get('mcp-session-id');
|
|
240
|
+
expect(sid).toBeTruthy();
|
|
241
|
+
// Drain the body so the stream doesn't leak.
|
|
242
|
+
await initRes.text();
|
|
243
|
+
|
|
244
|
+
// Per MCP spec: client sends notifications/initialized after the
|
|
245
|
+
// initialize response before using other methods.
|
|
246
|
+
const notifReq = new Request('http://localhost:9999/mcp?services=rss', {
|
|
247
|
+
method: 'POST',
|
|
248
|
+
headers: {
|
|
249
|
+
'mcp-session-id': sid!,
|
|
250
|
+
'content-type': 'application/json',
|
|
251
|
+
accept: 'application/json, text/event-stream',
|
|
252
|
+
'mcp-protocol-version': '2024-11-05',
|
|
253
|
+
},
|
|
254
|
+
body: JSON.stringify({
|
|
255
|
+
jsonrpc: '2.0',
|
|
256
|
+
method: 'notifications/initialized',
|
|
257
|
+
}),
|
|
258
|
+
});
|
|
259
|
+
const notifRes = await handleMcpRequest(notifReq);
|
|
260
|
+
expect(notifRes.status).toBeLessThan(500);
|
|
261
|
+
await notifRes.text();
|
|
262
|
+
|
|
263
|
+
// Second request: tools/list against the same session.
|
|
264
|
+
const listReq = new Request('http://localhost:9999/mcp?services=rss', {
|
|
265
|
+
method: 'POST',
|
|
266
|
+
headers: {
|
|
267
|
+
'mcp-session-id': sid!,
|
|
268
|
+
'content-type': 'application/json',
|
|
269
|
+
accept: 'application/json, text/event-stream',
|
|
270
|
+
'mcp-protocol-version': '2024-11-05',
|
|
271
|
+
},
|
|
272
|
+
body: JSON.stringify({
|
|
273
|
+
jsonrpc: '2.0',
|
|
274
|
+
id: 2,
|
|
275
|
+
method: 'tools/list',
|
|
276
|
+
params: {},
|
|
277
|
+
}),
|
|
278
|
+
});
|
|
279
|
+
const listRes = await handleMcpRequest(listReq);
|
|
280
|
+
expect(listRes.status).toBe(200);
|
|
281
|
+
const listBody = (await listRes.json()) as Record<string, unknown>;
|
|
282
|
+
expect(listBody.jsonrpc).toBe('2.0');
|
|
283
|
+
const result = listBody.result as Record<string, unknown>;
|
|
284
|
+
expect(Array.isArray(result.tools)).toBe(true);
|
|
285
|
+
// rss has at least one tool (rss_articles).
|
|
286
|
+
const tools = result.tools as Array<{ name: string }>;
|
|
287
|
+
expect(tools.some((t) => t.name.startsWith('rss_'))).toBe(true);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('DELETE on a live session removes it from the map', async () => {
|
|
291
|
+
// Create a session first.
|
|
292
|
+
const init = await handleMcpRequest(
|
|
293
|
+
new Request('http://localhost:9999/mcp?services=rss', {
|
|
294
|
+
method: 'POST',
|
|
295
|
+
headers: {
|
|
296
|
+
'content-type': 'application/json',
|
|
297
|
+
accept: 'application/json, text/event-stream',
|
|
298
|
+
},
|
|
299
|
+
body: JSON.stringify({
|
|
300
|
+
jsonrpc: '2.0',
|
|
301
|
+
id: 1,
|
|
302
|
+
method: 'initialize',
|
|
303
|
+
params: {
|
|
304
|
+
protocolVersion: '2024-11-05',
|
|
305
|
+
capabilities: {},
|
|
306
|
+
clientInfo: { name: 'test', version: '0.0.0' },
|
|
307
|
+
},
|
|
308
|
+
}),
|
|
309
|
+
})
|
|
310
|
+
);
|
|
311
|
+
const sid = init.headers.get('mcp-session-id')!;
|
|
312
|
+
await init.text();
|
|
313
|
+
expect(_getSessionCount()).toBe(1);
|
|
314
|
+
|
|
315
|
+
const del = await handleMcpRequest(
|
|
316
|
+
new Request('http://localhost:9999/mcp', {
|
|
317
|
+
method: 'DELETE',
|
|
318
|
+
headers: {
|
|
319
|
+
'mcp-session-id': sid,
|
|
320
|
+
'mcp-protocol-version': '2024-11-05',
|
|
321
|
+
},
|
|
322
|
+
})
|
|
323
|
+
);
|
|
324
|
+
// DELETE is handled by the transport; either 200 or 204 is fine.
|
|
325
|
+
expect([200, 204]).toContain(del.status);
|
|
326
|
+
expect(_getSessionCount()).toBe(0);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('two concurrent initialize requests create two independent sessions', async () => {
|
|
330
|
+
const mkInitReq = () =>
|
|
331
|
+
new Request('http://localhost:9999/mcp?services=rss', {
|
|
332
|
+
method: 'POST',
|
|
333
|
+
headers: {
|
|
334
|
+
'content-type': 'application/json',
|
|
335
|
+
accept: 'application/json, text/event-stream',
|
|
336
|
+
},
|
|
337
|
+
body: JSON.stringify({
|
|
338
|
+
jsonrpc: '2.0',
|
|
339
|
+
id: 1,
|
|
340
|
+
method: 'initialize',
|
|
341
|
+
params: {
|
|
342
|
+
protocolVersion: '2024-11-05',
|
|
343
|
+
capabilities: {},
|
|
344
|
+
clientInfo: { name: 'test', version: '0.0.0' },
|
|
345
|
+
},
|
|
346
|
+
}),
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const [a, b] = await Promise.all([
|
|
350
|
+
handleMcpRequest(mkInitReq()),
|
|
351
|
+
handleMcpRequest(mkInitReq()),
|
|
352
|
+
]);
|
|
353
|
+
expect(a.status).toBe(200);
|
|
354
|
+
expect(b.status).toBe(200);
|
|
355
|
+
const sidA = a.headers.get('mcp-session-id');
|
|
356
|
+
const sidB = b.headers.get('mcp-session-id');
|
|
357
|
+
expect(sidA).toBeTruthy();
|
|
358
|
+
expect(sidB).toBeTruthy();
|
|
359
|
+
expect(sidA).not.toBe(sidB);
|
|
360
|
+
expect(_getSessionCount()).toBe(2);
|
|
361
|
+
await a.text();
|
|
362
|
+
await b.text();
|
|
363
|
+
});
|
|
364
|
+
});
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
|
|
3
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
|
+
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
|
5
|
+
import {
|
|
6
|
+
CallToolRequestSchema,
|
|
7
|
+
ListToolsRequestSchema,
|
|
8
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
buildProgram,
|
|
12
|
+
executeCommand,
|
|
13
|
+
parseServiceProfiles,
|
|
14
|
+
SERVICE_REGISTRATIONS,
|
|
15
|
+
type ServiceProfilePair,
|
|
16
|
+
} from '../mcp/server';
|
|
17
|
+
import { collectMcpTools, type McpToolDefinition } from '../mcp/tools';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Per-session bookkeeping for the Streamable HTTP MCP transport.
|
|
21
|
+
*
|
|
22
|
+
* The SDK transport is session-oriented: each `WebStandardStreamableHTTPServerTransport`
|
|
23
|
+
* instance has exactly one `sessionId` set during the `initialize` request.
|
|
24
|
+
* That means we need one Server + one Transport pair per active MCP
|
|
25
|
+
* session, not one shared instance for the whole daemon.
|
|
26
|
+
*
|
|
27
|
+
* The session is keyed by the SDK-generated `mcp-session-id` header value.
|
|
28
|
+
* On a new connection (no session id header), we mint a fresh
|
|
29
|
+
* Server+Transport pair, parse the URL's `?services=` to determine which
|
|
30
|
+
* tools to expose, and let the SDK assign a session id during initialize
|
|
31
|
+
* via our `onsessioninitialized` callback.
|
|
32
|
+
*
|
|
33
|
+
* The service set is FROZEN for the session's lifetime — once initialize
|
|
34
|
+
* has run, we ignore any new `?services=` on subsequent requests because
|
|
35
|
+
* Claude Code (and any sane MCP client) won't send them anyway, and
|
|
36
|
+
* letting them mutate the tool surface mid-session would break the
|
|
37
|
+
* client's cached `tools/list`.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
interface McpSession {
|
|
41
|
+
server: Server;
|
|
42
|
+
transport: WebStandardStreamableHTTPServerTransport;
|
|
43
|
+
services: ServiceProfilePair[];
|
|
44
|
+
toolNames: Set<string>;
|
|
45
|
+
/**
|
|
46
|
+
* Per-session "previously checked" tracking for gchat_list. The plan
|
|
47
|
+
* keys this by `sessionId:service:space`, but since we already have one
|
|
48
|
+
* Map *per session*, the key inside the Map only needs `service:space`.
|
|
49
|
+
*/
|
|
50
|
+
lastChecked: Map<string, Date>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const sessions = new Map<string, McpSession>();
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* For tests only — drop all in-memory session state. Tests that exercise
|
|
57
|
+
* the session manager should call this in afterEach so leaked state from
|
|
58
|
+
* one test never bleeds into another.
|
|
59
|
+
*/
|
|
60
|
+
export function _resetSessionsForTests(): void {
|
|
61
|
+
for (const session of sessions.values()) {
|
|
62
|
+
try {
|
|
63
|
+
session.transport.close();
|
|
64
|
+
} catch {
|
|
65
|
+
/* ignore */
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
sessions.clear();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* ------------------------------------------------------------------ */
|
|
72
|
+
/* services parsing + validation */
|
|
73
|
+
/* ------------------------------------------------------------------ */
|
|
74
|
+
|
|
75
|
+
export interface ParseServicesResult {
|
|
76
|
+
ok: true;
|
|
77
|
+
services: ServiceProfilePair[];
|
|
78
|
+
}
|
|
79
|
+
export interface ParseServicesError {
|
|
80
|
+
ok: false;
|
|
81
|
+
status: number;
|
|
82
|
+
message: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Parse the `?services=gmail:work,slack:team` query string into
|
|
87
|
+
* ServiceProfilePair[]. Returns a structured result so the caller can
|
|
88
|
+
* decide how to surface failures (HTTP 400, JSON-RPC error, etc.).
|
|
89
|
+
*
|
|
90
|
+
* Empty input → empty array (valid; the session just exposes no tools).
|
|
91
|
+
* Unknown service name → error with the offending service.
|
|
92
|
+
* Empty profile after `:` → error.
|
|
93
|
+
*/
|
|
94
|
+
export function parseServicesQuery(
|
|
95
|
+
servicesParam: string | null
|
|
96
|
+
): ParseServicesResult | ParseServicesError {
|
|
97
|
+
if (!servicesParam) {
|
|
98
|
+
return { ok: true, services: [] };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const parts = servicesParam
|
|
102
|
+
.split(',')
|
|
103
|
+
.map((p) => p.trim())
|
|
104
|
+
.filter((p) => p.length > 0);
|
|
105
|
+
|
|
106
|
+
if (parts.length === 0) {
|
|
107
|
+
return { ok: true, services: [] };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Reject anything with an empty service name (e.g. ":foo" or "foo::bar").
|
|
111
|
+
for (const part of parts) {
|
|
112
|
+
if (part.startsWith(':') || part === ':') {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
status: 400,
|
|
116
|
+
message: `invalid services entry "${part}": service name is empty`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const colonIdx = part.indexOf(':');
|
|
120
|
+
if (colonIdx !== -1 && colonIdx === part.length - 1) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
status: 400,
|
|
124
|
+
message: `invalid services entry "${part}": profile name is empty after ":"`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const pairs = parseServiceProfiles(parts);
|
|
130
|
+
|
|
131
|
+
// Validate every service exists in the registry.
|
|
132
|
+
for (const pair of pairs) {
|
|
133
|
+
if (!(pair.service in SERVICE_REGISTRATIONS)) {
|
|
134
|
+
const known = Object.keys(SERVICE_REGISTRATIONS).sort().join(', ');
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
status: 400,
|
|
138
|
+
message: `unknown service "${pair.service}". known services: ${known}`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { ok: true, services: pairs };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* ------------------------------------------------------------------ */
|
|
147
|
+
/* session creation */
|
|
148
|
+
/* ------------------------------------------------------------------ */
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Build the Server + Transport pair for a new MCP session, register
|
|
152
|
+
* tool handlers, and connect them. The session is added to the global
|
|
153
|
+
* `sessions` map by the `onsessioninitialized` callback (fired during
|
|
154
|
+
* the first `transport.handleRequest()` call from the initialize
|
|
155
|
+
* request).
|
|
156
|
+
*/
|
|
157
|
+
async function createSession(
|
|
158
|
+
pairs: ServiceProfilePair[]
|
|
159
|
+
): Promise<McpSession> {
|
|
160
|
+
const serviceNames = [...new Set(pairs.map((p) => p.service))];
|
|
161
|
+
const profileMap = new Map<string, string | undefined>();
|
|
162
|
+
for (const pair of pairs) {
|
|
163
|
+
profileMap.set(pair.service, pair.profile);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Build the program once to collect the tool list. The same program is
|
|
167
|
+
// NOT reused for executeCommand — we build a fresh one per call to
|
|
168
|
+
// avoid Commander state leaks across invocations.
|
|
169
|
+
const toolProgram = buildProgram(serviceNames);
|
|
170
|
+
const allTools: McpToolDefinition[] = [];
|
|
171
|
+
for (const service of serviceNames) {
|
|
172
|
+
allTools.push(...collectMcpTools(toolProgram, service));
|
|
173
|
+
}
|
|
174
|
+
const toolNames = new Set(allTools.map((t) => t.name));
|
|
175
|
+
|
|
176
|
+
const server = new Server(
|
|
177
|
+
{ name: 'agentio', version: '1.0.0' },
|
|
178
|
+
{ capabilities: { tools: {} } }
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// Allocated up front so the closures below + onsessioninitialized can
|
|
182
|
+
// close over the same object.
|
|
183
|
+
const session: McpSession = {
|
|
184
|
+
server,
|
|
185
|
+
// transport is filled in below; the field is non-null after the
|
|
186
|
+
// assignment but TypeScript doesn't know that mid-construction.
|
|
187
|
+
transport: undefined as unknown as WebStandardStreamableHTTPServerTransport,
|
|
188
|
+
services: pairs,
|
|
189
|
+
toolNames,
|
|
190
|
+
lastChecked: new Map(),
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
194
|
+
return {
|
|
195
|
+
tools: allTools.map((tool) => ({
|
|
196
|
+
name: tool.name,
|
|
197
|
+
description: tool.description,
|
|
198
|
+
inputSchema: tool.inputSchema,
|
|
199
|
+
})),
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
204
|
+
const { name, arguments: args } = request.params;
|
|
205
|
+
const tool = allTools.find((t) => t.name === name);
|
|
206
|
+
|
|
207
|
+
if (!tool) {
|
|
208
|
+
return {
|
|
209
|
+
content: [{ type: 'text' as const, text: `Unknown tool: ${name}` }],
|
|
210
|
+
isError: true,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const service = tool.commandPath[0];
|
|
215
|
+
const profile = profileMap.get(service);
|
|
216
|
+
const input = (args as Record<string, unknown>) || {};
|
|
217
|
+
|
|
218
|
+
// Fresh program per call — Commander mutates parser state, and two
|
|
219
|
+
// overlapping calls in the same session would otherwise stomp on
|
|
220
|
+
// each other.
|
|
221
|
+
const execProgram = buildProgram(serviceNames);
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const output = await executeCommand(execProgram, tool, input, profile);
|
|
225
|
+
|
|
226
|
+
let result = output || '(no output)';
|
|
227
|
+
if (name === 'gchat_list' && typeof input.space === 'string') {
|
|
228
|
+
const key = `gchat:${input.space}`;
|
|
229
|
+
const last = session.lastChecked.get(key);
|
|
230
|
+
if (last) {
|
|
231
|
+
result += `\n\nPreviously checked: ${last.toISOString()}`;
|
|
232
|
+
}
|
|
233
|
+
session.lastChecked.set(key, new Date());
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
content: [{ type: 'text' as const, text: result }],
|
|
238
|
+
};
|
|
239
|
+
} catch (error: unknown) {
|
|
240
|
+
const message =
|
|
241
|
+
error instanceof Error ? error.message : String(error);
|
|
242
|
+
return {
|
|
243
|
+
content: [{ type: 'text' as const, text: `Error: ${message}` }],
|
|
244
|
+
isError: true,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
250
|
+
sessionIdGenerator: () => randomUUID(),
|
|
251
|
+
enableJsonResponse: true, // simpler for hand-rolled HTTP testing
|
|
252
|
+
onsessioninitialized: (sid) => {
|
|
253
|
+
sessions.set(sid, session);
|
|
254
|
+
},
|
|
255
|
+
onsessionclosed: (sid) => {
|
|
256
|
+
sessions.delete(sid);
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
session.transport = transport;
|
|
261
|
+
await server.connect(transport);
|
|
262
|
+
|
|
263
|
+
return session;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* ------------------------------------------------------------------ */
|
|
267
|
+
/* request entry point */
|
|
268
|
+
/* ------------------------------------------------------------------ */
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Top-level handler for /mcp requests, called from `http.ts` after the
|
|
272
|
+
* bearer middleware has run.
|
|
273
|
+
*
|
|
274
|
+
* - Existing session id in `mcp-session-id` header → look up the session
|
|
275
|
+
* and forward to its transport. Unknown id → 404.
|
|
276
|
+
* - No session id → create a new session, validate `?services=`, then
|
|
277
|
+
* forward to the freshly-built transport. The SDK will assign a
|
|
278
|
+
* session id during initialize and our `onsessioninitialized` callback
|
|
279
|
+
* will register it in the map.
|
|
280
|
+
*/
|
|
281
|
+
export async function handleMcpRequest(req: Request): Promise<Response> {
|
|
282
|
+
const sessionId = req.headers.get('mcp-session-id');
|
|
283
|
+
|
|
284
|
+
if (sessionId) {
|
|
285
|
+
const session = sessions.get(sessionId);
|
|
286
|
+
if (!session) {
|
|
287
|
+
return new Response(
|
|
288
|
+
JSON.stringify({
|
|
289
|
+
jsonrpc: '2.0',
|
|
290
|
+
error: {
|
|
291
|
+
code: -32001,
|
|
292
|
+
message: `unknown session id "${sessionId}"`,
|
|
293
|
+
},
|
|
294
|
+
id: null,
|
|
295
|
+
}),
|
|
296
|
+
{
|
|
297
|
+
status: 404,
|
|
298
|
+
headers: { 'content-type': 'application/json' },
|
|
299
|
+
}
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
return session.transport.handleRequest(req);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// No session id — this should be an initialize request. Validate
|
|
306
|
+
// services BEFORE building the session so a typo in ?services= gives
|
|
307
|
+
// a clean 400 instead of an opaque MCP error.
|
|
308
|
+
const url = new URL(req.url);
|
|
309
|
+
const parsed = parseServicesQuery(url.searchParams.get('services'));
|
|
310
|
+
if (!parsed.ok) {
|
|
311
|
+
return new Response(
|
|
312
|
+
JSON.stringify({
|
|
313
|
+
error: 'invalid_request',
|
|
314
|
+
error_description: parsed.message,
|
|
315
|
+
}),
|
|
316
|
+
{
|
|
317
|
+
status: parsed.status,
|
|
318
|
+
headers: { 'content-type': 'application/json' },
|
|
319
|
+
}
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const session = await createSession(parsed.services);
|
|
324
|
+
return session.transport.handleRequest(req);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Test/diagnostic helper: how many sessions are currently active?
|
|
329
|
+
*/
|
|
330
|
+
export function _getSessionCount(): number {
|
|
331
|
+
return sessions.size;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Test helper: peek at the live sessions map. Do not mutate.
|
|
336
|
+
*/
|
|
337
|
+
export function _peekSessionIds(): string[] {
|
|
338
|
+
return [...sessions.keys()];
|
|
339
|
+
}
|