@plosson/agentio 0.7.2 → 0.7.4
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 +1462 -0
- package/src/commands/teleport.ts +882 -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 +218 -0
- package/src/server/dockerfile-gen.ts +108 -0
- package/src/server/dockerfile-teleport.test.ts +184 -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 +766 -0
- package/src/server/siteio-runner.ts +352 -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,643 @@
|
|
|
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 { findFreePort, startServerSubprocess } from './test-helpers';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Adversarial HTTP-level tests for /mcp. These bypass the SDK client and
|
|
12
|
+
* hit the endpoint directly with malformed / hostile / edge-case inputs
|
|
13
|
+
* to verify the hand-rolled routing + bearer gate + session manager
|
|
14
|
+
* don't crash, leak state, or return wrong status codes.
|
|
15
|
+
*
|
|
16
|
+
* What the SDK client e2e test (mcp-e2e.test.ts) covers:
|
|
17
|
+
* - Happy path protocol flow, tool discovery, tool execution.
|
|
18
|
+
*
|
|
19
|
+
* What THIS test covers:
|
|
20
|
+
* - Malformed JSON bodies
|
|
21
|
+
* - Wrong HTTP methods on /mcp
|
|
22
|
+
* - Missing / wrong Accept headers
|
|
23
|
+
* - Missing / wrong Content-Type headers
|
|
24
|
+
* - Oversized bodies
|
|
25
|
+
* - Invalid / malformed session ids
|
|
26
|
+
* - Missing required MCP-Protocol-Version on non-init requests
|
|
27
|
+
* - Session id confusion attacks (try to use one client's session id
|
|
28
|
+
* from a different bearer token)
|
|
29
|
+
* - Bearer auth bypass attempts
|
|
30
|
+
* - ?services= injection (quotes, semicolons, super-long values)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
interface RunningServer {
|
|
34
|
+
proc: Subprocess<'ignore', 'pipe', 'pipe'>;
|
|
35
|
+
apiKey: string;
|
|
36
|
+
port: number;
|
|
37
|
+
baseUrl: string;
|
|
38
|
+
bearer: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let tempHome = '';
|
|
42
|
+
let active: RunningServer | null = null;
|
|
43
|
+
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
tempHome = await mkdtemp(join(tmpdir(), 'agentio-mcp-adv-'));
|
|
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 (tempHome) {
|
|
67
|
+
await rm(tempHome, { recursive: true, force: true }).catch(() => {});
|
|
68
|
+
tempHome = '';
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
async function startAndAuth(): Promise<RunningServer> {
|
|
73
|
+
const started = await startServerSubprocess({ home: tempHome });
|
|
74
|
+
const { proc, port, apiKey } = started;
|
|
75
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
76
|
+
|
|
77
|
+
// Run the OAuth flow inline (the oauth-e2e tests validate the shape).
|
|
78
|
+
const redirectUri = 'http://localhost:53682/callback';
|
|
79
|
+
const verifier = 'verifier_' + 'a'.repeat(54);
|
|
80
|
+
const challenge = createHash('sha256')
|
|
81
|
+
.update(verifier, 'ascii')
|
|
82
|
+
.digest('base64url');
|
|
83
|
+
|
|
84
|
+
const regRes = await fetch(`${baseUrl}/register`, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: { 'content-type': 'application/json' },
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
client_name: 'adv',
|
|
89
|
+
redirect_uris: [redirectUri],
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
const clientId = ((await regRes.json()) as Record<string, unknown>)
|
|
93
|
+
.client_id as string;
|
|
94
|
+
|
|
95
|
+
const authRes = await fetch(`${baseUrl}/authorize`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
98
|
+
body: new URLSearchParams({
|
|
99
|
+
client_id: clientId,
|
|
100
|
+
redirect_uri: redirectUri,
|
|
101
|
+
response_type: 'code',
|
|
102
|
+
code_challenge: challenge,
|
|
103
|
+
code_challenge_method: 'S256',
|
|
104
|
+
state: '',
|
|
105
|
+
scope: 'mcp',
|
|
106
|
+
api_key: apiKey,
|
|
107
|
+
}).toString(),
|
|
108
|
+
redirect: 'manual',
|
|
109
|
+
});
|
|
110
|
+
const code = new URL(authRes.headers.get('location')!).searchParams.get(
|
|
111
|
+
'code'
|
|
112
|
+
)!;
|
|
113
|
+
|
|
114
|
+
const tokenRes = await fetch(`${baseUrl}/token`, {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
117
|
+
body: new URLSearchParams({
|
|
118
|
+
grant_type: 'authorization_code',
|
|
119
|
+
code,
|
|
120
|
+
client_id: clientId,
|
|
121
|
+
redirect_uri: redirectUri,
|
|
122
|
+
code_verifier: verifier,
|
|
123
|
+
}).toString(),
|
|
124
|
+
});
|
|
125
|
+
const bearer = ((await tokenRes.json()) as Record<string, unknown>)
|
|
126
|
+
.access_token as string;
|
|
127
|
+
|
|
128
|
+
const running: RunningServer = {
|
|
129
|
+
proc: proc as Subprocess<'ignore', 'pipe', 'pipe'>,
|
|
130
|
+
apiKey,
|
|
131
|
+
port,
|
|
132
|
+
baseUrl,
|
|
133
|
+
bearer,
|
|
134
|
+
};
|
|
135
|
+
active = running;
|
|
136
|
+
return running;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Initialize an MCP session via a raw POST and return the session id
|
|
141
|
+
* assigned by the server. Used by tests that need a valid session id as
|
|
142
|
+
* a starting point.
|
|
143
|
+
*/
|
|
144
|
+
async function rawInitialize(
|
|
145
|
+
server: RunningServer,
|
|
146
|
+
services = 'rss'
|
|
147
|
+
): Promise<string> {
|
|
148
|
+
const res = await fetch(`${server.baseUrl}/mcp?services=${services}`, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: {
|
|
151
|
+
authorization: `Bearer ${server.bearer}`,
|
|
152
|
+
'content-type': 'application/json',
|
|
153
|
+
accept: 'application/json, text/event-stream',
|
|
154
|
+
},
|
|
155
|
+
body: JSON.stringify({
|
|
156
|
+
jsonrpc: '2.0',
|
|
157
|
+
id: 1,
|
|
158
|
+
method: 'initialize',
|
|
159
|
+
params: {
|
|
160
|
+
protocolVersion: '2024-11-05',
|
|
161
|
+
capabilities: {},
|
|
162
|
+
clientInfo: { name: 'adv', version: '0.0.0' },
|
|
163
|
+
},
|
|
164
|
+
}),
|
|
165
|
+
});
|
|
166
|
+
const sid = res.headers.get('mcp-session-id');
|
|
167
|
+
if (!sid) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`initialize failed, status ${res.status}, body: ${await res.text()}`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
await res.text();
|
|
173
|
+
return sid;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* ------------------------------------------------------------------ */
|
|
177
|
+
/* bearer gate */
|
|
178
|
+
/* ------------------------------------------------------------------ */
|
|
179
|
+
|
|
180
|
+
describe('adversarial /mcp — bearer gate', () => {
|
|
181
|
+
test('no Authorization → 401 with WWW-Authenticate', async () => {
|
|
182
|
+
const server = await startAndAuth();
|
|
183
|
+
const res = await fetch(`${server.baseUrl}/mcp`, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: {
|
|
186
|
+
'content-type': 'application/json',
|
|
187
|
+
accept: 'application/json, text/event-stream',
|
|
188
|
+
},
|
|
189
|
+
body: JSON.stringify({
|
|
190
|
+
jsonrpc: '2.0',
|
|
191
|
+
id: 1,
|
|
192
|
+
method: 'initialize',
|
|
193
|
+
params: {},
|
|
194
|
+
}),
|
|
195
|
+
});
|
|
196
|
+
expect(res.status).toBe(401);
|
|
197
|
+
expect(res.headers.get('www-authenticate')).toContain('Bearer');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('Authorization: Bearer <wrong> → 401', async () => {
|
|
201
|
+
const server = await startAndAuth();
|
|
202
|
+
const res = await fetch(`${server.baseUrl}/mcp`, {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: {
|
|
205
|
+
authorization: 'Bearer not_a_real_token',
|
|
206
|
+
'content-type': 'application/json',
|
|
207
|
+
},
|
|
208
|
+
body: '{}',
|
|
209
|
+
});
|
|
210
|
+
expect(res.status).toBe(401);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('Authorization: Basic … → 401 (only Bearer accepted)', async () => {
|
|
214
|
+
const server = await startAndAuth();
|
|
215
|
+
const res = await fetch(`${server.baseUrl}/mcp`, {
|
|
216
|
+
method: 'POST',
|
|
217
|
+
headers: {
|
|
218
|
+
authorization: 'Basic dXNlcjpwYXNz',
|
|
219
|
+
'content-type': 'application/json',
|
|
220
|
+
},
|
|
221
|
+
body: '{}',
|
|
222
|
+
});
|
|
223
|
+
expect(res.status).toBe(401);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('bearer with trailing whitespace is rejected as unknown', async () => {
|
|
227
|
+
const server = await startAndAuth();
|
|
228
|
+
const res = await fetch(`${server.baseUrl}/mcp`, {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
headers: {
|
|
231
|
+
authorization: `Bearer ${server.bearer} `,
|
|
232
|
+
'content-type': 'application/json',
|
|
233
|
+
accept: 'application/json, text/event-stream',
|
|
234
|
+
},
|
|
235
|
+
body: '{}',
|
|
236
|
+
});
|
|
237
|
+
// Trim happens in requireBearer — this actually should still work.
|
|
238
|
+
// Test locks in behavior: should NOT be 401.
|
|
239
|
+
expect(res.status).not.toBe(401);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
/* ------------------------------------------------------------------ */
|
|
244
|
+
/* session routing */
|
|
245
|
+
/* ------------------------------------------------------------------ */
|
|
246
|
+
|
|
247
|
+
describe('adversarial /mcp — session routing', () => {
|
|
248
|
+
test('unknown mcp-session-id → 404 JSON-RPC error', async () => {
|
|
249
|
+
const server = await startAndAuth();
|
|
250
|
+
const res = await fetch(`${server.baseUrl}/mcp`, {
|
|
251
|
+
method: 'POST',
|
|
252
|
+
headers: {
|
|
253
|
+
authorization: `Bearer ${server.bearer}`,
|
|
254
|
+
'mcp-session-id': 'totally-made-up-session-id',
|
|
255
|
+
'content-type': 'application/json',
|
|
256
|
+
accept: 'application/json, text/event-stream',
|
|
257
|
+
},
|
|
258
|
+
body: JSON.stringify({
|
|
259
|
+
jsonrpc: '2.0',
|
|
260
|
+
id: 1,
|
|
261
|
+
method: 'tools/list',
|
|
262
|
+
params: {},
|
|
263
|
+
}),
|
|
264
|
+
});
|
|
265
|
+
expect(res.status).toBe(404);
|
|
266
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
267
|
+
expect(body.jsonrpc).toBe('2.0');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('empty mcp-session-id header → treated as "no session" → new session', async () => {
|
|
271
|
+
const server = await startAndAuth();
|
|
272
|
+
const res = await fetch(`${server.baseUrl}/mcp?services=rss`, {
|
|
273
|
+
method: 'POST',
|
|
274
|
+
headers: {
|
|
275
|
+
authorization: `Bearer ${server.bearer}`,
|
|
276
|
+
'mcp-session-id': '',
|
|
277
|
+
'content-type': 'application/json',
|
|
278
|
+
accept: 'application/json, text/event-stream',
|
|
279
|
+
},
|
|
280
|
+
body: JSON.stringify({
|
|
281
|
+
jsonrpc: '2.0',
|
|
282
|
+
id: 1,
|
|
283
|
+
method: 'initialize',
|
|
284
|
+
params: {
|
|
285
|
+
protocolVersion: '2024-11-05',
|
|
286
|
+
capabilities: {},
|
|
287
|
+
clientInfo: { name: 'adv', version: '0.0.0' },
|
|
288
|
+
},
|
|
289
|
+
}),
|
|
290
|
+
});
|
|
291
|
+
// Either creates a new session OR returns a protocol error; must
|
|
292
|
+
// NOT be 404 (which would imply it was treated as an unknown session).
|
|
293
|
+
expect(res.status).not.toBe(404);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('DELETE with unknown session id → 404', async () => {
|
|
297
|
+
const server = await startAndAuth();
|
|
298
|
+
const res = await fetch(`${server.baseUrl}/mcp`, {
|
|
299
|
+
method: 'DELETE',
|
|
300
|
+
headers: {
|
|
301
|
+
authorization: `Bearer ${server.bearer}`,
|
|
302
|
+
'mcp-session-id': 'nope',
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
expect(res.status).toBe(404);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('a second bearer can reuse the first session id (current design)', async () => {
|
|
309
|
+
// Both bearers are for the same API key, so this is expected to work.
|
|
310
|
+
// This documents the current (non-isolated) behavior. If we ever want
|
|
311
|
+
// strict per-bearer sessions, this test should be flipped.
|
|
312
|
+
const server = await startAndAuth();
|
|
313
|
+
const sid = await rawInitialize(server);
|
|
314
|
+
|
|
315
|
+
// Get a second bearer via a second OAuth dance.
|
|
316
|
+
const redirectUri = 'http://localhost:53682/callback';
|
|
317
|
+
const verifier = 'verifier_' + 'b'.repeat(54);
|
|
318
|
+
const challenge = createHash('sha256')
|
|
319
|
+
.update(verifier, 'ascii')
|
|
320
|
+
.digest('base64url');
|
|
321
|
+
const regRes = await fetch(`${server.baseUrl}/register`, {
|
|
322
|
+
method: 'POST',
|
|
323
|
+
headers: { 'content-type': 'application/json' },
|
|
324
|
+
body: JSON.stringify({
|
|
325
|
+
client_name: 'adv2',
|
|
326
|
+
redirect_uris: [redirectUri],
|
|
327
|
+
}),
|
|
328
|
+
});
|
|
329
|
+
const cid2 = ((await regRes.json()) as Record<string, unknown>)
|
|
330
|
+
.client_id as string;
|
|
331
|
+
const authRes = await fetch(`${server.baseUrl}/authorize`, {
|
|
332
|
+
method: 'POST',
|
|
333
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
334
|
+
body: new URLSearchParams({
|
|
335
|
+
client_id: cid2,
|
|
336
|
+
redirect_uri: redirectUri,
|
|
337
|
+
response_type: 'code',
|
|
338
|
+
code_challenge: challenge,
|
|
339
|
+
code_challenge_method: 'S256',
|
|
340
|
+
state: '',
|
|
341
|
+
scope: 'mcp',
|
|
342
|
+
api_key: server.apiKey,
|
|
343
|
+
}).toString(),
|
|
344
|
+
redirect: 'manual',
|
|
345
|
+
});
|
|
346
|
+
const code = new URL(authRes.headers.get('location')!).searchParams.get(
|
|
347
|
+
'code'
|
|
348
|
+
)!;
|
|
349
|
+
const tokenRes = await fetch(`${server.baseUrl}/token`, {
|
|
350
|
+
method: 'POST',
|
|
351
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
352
|
+
body: new URLSearchParams({
|
|
353
|
+
grant_type: 'authorization_code',
|
|
354
|
+
code,
|
|
355
|
+
client_id: cid2,
|
|
356
|
+
redirect_uri: redirectUri,
|
|
357
|
+
code_verifier: verifier,
|
|
358
|
+
}).toString(),
|
|
359
|
+
});
|
|
360
|
+
const bearer2 = ((await tokenRes.json()) as Record<string, unknown>)
|
|
361
|
+
.access_token as string;
|
|
362
|
+
|
|
363
|
+
// Send initialized notification so the session is usable.
|
|
364
|
+
await fetch(`${server.baseUrl}/mcp`, {
|
|
365
|
+
method: 'POST',
|
|
366
|
+
headers: {
|
|
367
|
+
authorization: `Bearer ${server.bearer}`,
|
|
368
|
+
'mcp-session-id': sid,
|
|
369
|
+
'content-type': 'application/json',
|
|
370
|
+
accept: 'application/json, text/event-stream',
|
|
371
|
+
'mcp-protocol-version': '2024-11-05',
|
|
372
|
+
},
|
|
373
|
+
body: JSON.stringify({
|
|
374
|
+
jsonrpc: '2.0',
|
|
375
|
+
method: 'notifications/initialized',
|
|
376
|
+
}),
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// Use bearer2 with the session id from bearer1's initialize.
|
|
380
|
+
const res = await fetch(`${server.baseUrl}/mcp`, {
|
|
381
|
+
method: 'POST',
|
|
382
|
+
headers: {
|
|
383
|
+
authorization: `Bearer ${bearer2}`,
|
|
384
|
+
'mcp-session-id': sid,
|
|
385
|
+
'content-type': 'application/json',
|
|
386
|
+
accept: 'application/json, text/event-stream',
|
|
387
|
+
'mcp-protocol-version': '2024-11-05',
|
|
388
|
+
},
|
|
389
|
+
body: JSON.stringify({
|
|
390
|
+
jsonrpc: '2.0',
|
|
391
|
+
id: 2,
|
|
392
|
+
method: 'tools/list',
|
|
393
|
+
params: {},
|
|
394
|
+
}),
|
|
395
|
+
});
|
|
396
|
+
// Current design: shared sessions across bearers (single-user server).
|
|
397
|
+
expect(res.status).toBe(200);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
/* ------------------------------------------------------------------ */
|
|
402
|
+
/* invalid JSON / protocol */
|
|
403
|
+
/* ------------------------------------------------------------------ */
|
|
404
|
+
|
|
405
|
+
describe('adversarial /mcp — malformed bodies', () => {
|
|
406
|
+
test('body is not JSON → transport returns an error response', async () => {
|
|
407
|
+
const server = await startAndAuth();
|
|
408
|
+
const res = await fetch(`${server.baseUrl}/mcp`, {
|
|
409
|
+
method: 'POST',
|
|
410
|
+
headers: {
|
|
411
|
+
authorization: `Bearer ${server.bearer}`,
|
|
412
|
+
'content-type': 'application/json',
|
|
413
|
+
accept: 'application/json, text/event-stream',
|
|
414
|
+
},
|
|
415
|
+
body: 'not valid json',
|
|
416
|
+
});
|
|
417
|
+
// The SDK transport handles this and returns a JSON-RPC / HTTP error.
|
|
418
|
+
// Anything in the 4xx family is acceptable — the key property is "not
|
|
419
|
+
// 500, not 200".
|
|
420
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
421
|
+
expect(res.status).toBeLessThan(500);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test('body is an empty JSON object → transport returns an error', async () => {
|
|
425
|
+
const server = await startAndAuth();
|
|
426
|
+
const res = await fetch(`${server.baseUrl}/mcp`, {
|
|
427
|
+
method: 'POST',
|
|
428
|
+
headers: {
|
|
429
|
+
authorization: `Bearer ${server.bearer}`,
|
|
430
|
+
'content-type': 'application/json',
|
|
431
|
+
accept: 'application/json, text/event-stream',
|
|
432
|
+
},
|
|
433
|
+
body: '{}',
|
|
434
|
+
});
|
|
435
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
436
|
+
expect(res.status).toBeLessThan(500);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test('body is JSON-RPC with bogus method → error result, not 500', async () => {
|
|
440
|
+
const server = await startAndAuth();
|
|
441
|
+
const sid = await rawInitialize(server);
|
|
442
|
+
// Send notifications/initialized so subsequent requests are allowed.
|
|
443
|
+
await fetch(`${server.baseUrl}/mcp`, {
|
|
444
|
+
method: 'POST',
|
|
445
|
+
headers: {
|
|
446
|
+
authorization: `Bearer ${server.bearer}`,
|
|
447
|
+
'mcp-session-id': sid,
|
|
448
|
+
'content-type': 'application/json',
|
|
449
|
+
accept: 'application/json, text/event-stream',
|
|
450
|
+
'mcp-protocol-version': '2024-11-05',
|
|
451
|
+
},
|
|
452
|
+
body: JSON.stringify({
|
|
453
|
+
jsonrpc: '2.0',
|
|
454
|
+
method: 'notifications/initialized',
|
|
455
|
+
}),
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
const res = await fetch(`${server.baseUrl}/mcp`, {
|
|
459
|
+
method: 'POST',
|
|
460
|
+
headers: {
|
|
461
|
+
authorization: `Bearer ${server.bearer}`,
|
|
462
|
+
'mcp-session-id': sid,
|
|
463
|
+
'content-type': 'application/json',
|
|
464
|
+
accept: 'application/json, text/event-stream',
|
|
465
|
+
'mcp-protocol-version': '2024-11-05',
|
|
466
|
+
},
|
|
467
|
+
body: JSON.stringify({
|
|
468
|
+
jsonrpc: '2.0',
|
|
469
|
+
id: 99,
|
|
470
|
+
method: 'this/is/not/a/real/method',
|
|
471
|
+
params: {},
|
|
472
|
+
}),
|
|
473
|
+
});
|
|
474
|
+
expect(res.status).toBeLessThan(500);
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
/* ------------------------------------------------------------------ */
|
|
479
|
+
/* ?services= edge cases */
|
|
480
|
+
/* ------------------------------------------------------------------ */
|
|
481
|
+
|
|
482
|
+
describe('adversarial /mcp — services query', () => {
|
|
483
|
+
test('?services=nope → 400 BEFORE any MCP protocol runs', async () => {
|
|
484
|
+
const server = await startAndAuth();
|
|
485
|
+
const res = await fetch(`${server.baseUrl}/mcp?services=nope`, {
|
|
486
|
+
method: 'POST',
|
|
487
|
+
headers: {
|
|
488
|
+
authorization: `Bearer ${server.bearer}`,
|
|
489
|
+
'content-type': 'application/json',
|
|
490
|
+
accept: 'application/json, text/event-stream',
|
|
491
|
+
},
|
|
492
|
+
body: JSON.stringify({
|
|
493
|
+
jsonrpc: '2.0',
|
|
494
|
+
id: 1,
|
|
495
|
+
method: 'initialize',
|
|
496
|
+
params: {
|
|
497
|
+
protocolVersion: '2024-11-05',
|
|
498
|
+
capabilities: {},
|
|
499
|
+
clientInfo: { name: 'x', version: '0' },
|
|
500
|
+
},
|
|
501
|
+
}),
|
|
502
|
+
});
|
|
503
|
+
expect(res.status).toBe(400);
|
|
504
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
505
|
+
expect(body.error).toBe('invalid_request');
|
|
506
|
+
expect(body.error_description).toContain('nope');
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test('?services=<very long garbage> → 400 (still validated)', async () => {
|
|
510
|
+
const server = await startAndAuth();
|
|
511
|
+
const garbage = 'x'.repeat(5000);
|
|
512
|
+
const res = await fetch(
|
|
513
|
+
`${server.baseUrl}/mcp?services=${encodeURIComponent(garbage)}`,
|
|
514
|
+
{
|
|
515
|
+
method: 'POST',
|
|
516
|
+
headers: {
|
|
517
|
+
authorization: `Bearer ${server.bearer}`,
|
|
518
|
+
'content-type': 'application/json',
|
|
519
|
+
accept: 'application/json, text/event-stream',
|
|
520
|
+
},
|
|
521
|
+
body: JSON.stringify({
|
|
522
|
+
jsonrpc: '2.0',
|
|
523
|
+
id: 1,
|
|
524
|
+
method: 'initialize',
|
|
525
|
+
params: {
|
|
526
|
+
protocolVersion: '2024-11-05',
|
|
527
|
+
capabilities: {},
|
|
528
|
+
clientInfo: { name: 'x', version: '0' },
|
|
529
|
+
},
|
|
530
|
+
}),
|
|
531
|
+
}
|
|
532
|
+
);
|
|
533
|
+
expect(res.status).toBe(400);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test('?services=gmail: (empty profile after colon) → 400', async () => {
|
|
537
|
+
const server = await startAndAuth();
|
|
538
|
+
const res = await fetch(
|
|
539
|
+
`${server.baseUrl}/mcp?services=${encodeURIComponent('gmail:')}`,
|
|
540
|
+
{
|
|
541
|
+
method: 'POST',
|
|
542
|
+
headers: {
|
|
543
|
+
authorization: `Bearer ${server.bearer}`,
|
|
544
|
+
'content-type': 'application/json',
|
|
545
|
+
accept: 'application/json, text/event-stream',
|
|
546
|
+
},
|
|
547
|
+
body: JSON.stringify({
|
|
548
|
+
jsonrpc: '2.0',
|
|
549
|
+
id: 1,
|
|
550
|
+
method: 'initialize',
|
|
551
|
+
params: {
|
|
552
|
+
protocolVersion: '2024-11-05',
|
|
553
|
+
capabilities: {},
|
|
554
|
+
clientInfo: { name: 'x', version: '0' },
|
|
555
|
+
},
|
|
556
|
+
}),
|
|
557
|
+
}
|
|
558
|
+
);
|
|
559
|
+
expect(res.status).toBe(400);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test('?services= changes between requests in the same session are ignored', async () => {
|
|
563
|
+
const server = await startAndAuth();
|
|
564
|
+
const sid = await rawInitialize(server, 'rss');
|
|
565
|
+
|
|
566
|
+
// Notifications/initialized.
|
|
567
|
+
await fetch(`${server.baseUrl}/mcp?services=rss`, {
|
|
568
|
+
method: 'POST',
|
|
569
|
+
headers: {
|
|
570
|
+
authorization: `Bearer ${server.bearer}`,
|
|
571
|
+
'mcp-session-id': sid,
|
|
572
|
+
'content-type': 'application/json',
|
|
573
|
+
accept: 'application/json, text/event-stream',
|
|
574
|
+
'mcp-protocol-version': '2024-11-05',
|
|
575
|
+
},
|
|
576
|
+
body: JSON.stringify({
|
|
577
|
+
jsonrpc: '2.0',
|
|
578
|
+
method: 'notifications/initialized',
|
|
579
|
+
}),
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// Now request tools/list but pretend to also ask for gmail. The
|
|
583
|
+
// session was frozen at rss; gmail tools should NOT appear.
|
|
584
|
+
const res = await fetch(
|
|
585
|
+
`${server.baseUrl}/mcp?services=rss,gmail:nope`,
|
|
586
|
+
{
|
|
587
|
+
method: 'POST',
|
|
588
|
+
headers: {
|
|
589
|
+
authorization: `Bearer ${server.bearer}`,
|
|
590
|
+
'mcp-session-id': sid,
|
|
591
|
+
'content-type': 'application/json',
|
|
592
|
+
accept: 'application/json, text/event-stream',
|
|
593
|
+
'mcp-protocol-version': '2024-11-05',
|
|
594
|
+
},
|
|
595
|
+
body: JSON.stringify({
|
|
596
|
+
jsonrpc: '2.0',
|
|
597
|
+
id: 2,
|
|
598
|
+
method: 'tools/list',
|
|
599
|
+
params: {},
|
|
600
|
+
}),
|
|
601
|
+
}
|
|
602
|
+
);
|
|
603
|
+
expect(res.status).toBe(200);
|
|
604
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
605
|
+
const result = body.result as Record<string, unknown>;
|
|
606
|
+
const tools = result.tools as Array<{ name: string }>;
|
|
607
|
+
expect(tools.every((t) => t.name.startsWith('rss_'))).toBe(true);
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
/* ------------------------------------------------------------------ */
|
|
612
|
+
/* unsupported methods */
|
|
613
|
+
/* ------------------------------------------------------------------ */
|
|
614
|
+
|
|
615
|
+
describe('adversarial /mcp — HTTP methods', () => {
|
|
616
|
+
test('PUT /mcp with bearer → 4xx (no payload match)', async () => {
|
|
617
|
+
const server = await startAndAuth();
|
|
618
|
+
const res = await fetch(`${server.baseUrl}/mcp`, {
|
|
619
|
+
method: 'PUT',
|
|
620
|
+
headers: {
|
|
621
|
+
authorization: `Bearer ${server.bearer}`,
|
|
622
|
+
'content-type': 'application/json',
|
|
623
|
+
},
|
|
624
|
+
body: '{}',
|
|
625
|
+
});
|
|
626
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
627
|
+
expect(res.status).toBeLessThan(500);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test('PATCH /mcp with bearer → 4xx', async () => {
|
|
631
|
+
const server = await startAndAuth();
|
|
632
|
+
const res = await fetch(`${server.baseUrl}/mcp`, {
|
|
633
|
+
method: 'PATCH',
|
|
634
|
+
headers: {
|
|
635
|
+
authorization: `Bearer ${server.bearer}`,
|
|
636
|
+
'content-type': 'application/json',
|
|
637
|
+
},
|
|
638
|
+
body: '{}',
|
|
639
|
+
});
|
|
640
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
641
|
+
expect(res.status).toBeLessThan(500);
|
|
642
|
+
});
|
|
643
|
+
});
|