mcpose 2.1.1 → 3.1.0
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/README.md +461 -62
- package/dist/backendClient.d.ts +1 -1
- package/dist/backendClient.d.ts.map +1 -1
- package/dist/backendClient.js +6 -1
- package/dist/backendClient.js.map +1 -1
- package/dist/core.d.ts +309 -35
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +1023 -186
- package/dist/core.js.map +1 -1
- package/dist/delegation.d.ts +89 -0
- package/dist/delegation.d.ts.map +1 -0
- package/dist/delegation.js +175 -0
- package/dist/delegation.js.map +1 -0
- package/dist/eventStore.d.ts +12 -0
- package/dist/eventStore.d.ts.map +1 -1
- package/dist/eventStore.js +43 -5
- package/dist/eventStore.js.map +1 -1
- package/dist/hiddenTools.d.ts +42 -0
- package/dist/hiddenTools.d.ts.map +1 -0
- package/dist/hiddenTools.js +53 -0
- package/dist/hiddenTools.js.map +1 -0
- package/dist/index.d.ts +15 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/mesh.d.ts +82 -0
- package/dist/mesh.d.ts.map +1 -0
- package/dist/mesh.js +141 -0
- package/dist/mesh.js.map +1 -0
- package/dist/middleware.d.ts +13 -0
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +27 -3
- package/dist/middleware.js.map +1 -1
- package/dist/proxyContext.d.ts +65 -5
- package/dist/proxyContext.d.ts.map +1 -1
- package/dist/proxyContext.js +39 -4
- package/dist/proxyContext.js.map +1 -1
- package/dist/rejection.d.ts +7 -1
- package/dist/rejection.d.ts.map +1 -1
- package/dist/rejection.js +24 -1
- package/dist/rejection.js.map +1 -1
- package/dist/sanitizeCatalog.d.ts +37 -0
- package/dist/sanitizeCatalog.d.ts.map +1 -0
- package/dist/sanitizeCatalog.js +92 -0
- package/dist/sanitizeCatalog.js.map +1 -0
- package/dist/sessionRegistry.d.ts +79 -0
- package/dist/sessionRegistry.d.ts.map +1 -0
- package/dist/sessionRegistry.js +83 -0
- package/dist/sessionRegistry.js.map +1 -0
- package/dist/telemetry.d.ts +29 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/testing.d.ts +18 -2
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +30 -8
- package/dist/testing.js.map +1 -1
- package/dist/version.d.ts +7 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +7 -0
- package/dist/version.js.map +1 -0
- package/package.json +22 -8
- package/src/__tests__/_helpers.ts +64 -0
- package/src/__tests__/backendClient.test.ts +198 -0
- package/src/__tests__/core.delegation.test.ts +964 -0
- package/src/__tests__/core.edge.test.ts +314 -0
- package/src/__tests__/core.hiddenTools.predicate.test.ts +236 -0
- package/src/__tests__/core.localTools.test.ts +314 -0
- package/src/__tests__/core.mesh.test.ts +1182 -0
- package/src/__tests__/core.stripRequestMeta.test.ts +226 -0
- package/src/__tests__/core.stripResultMeta.test.ts +217 -0
- package/src/__tests__/core.test.ts +1105 -0
- package/src/__tests__/core.toolCallTelemetry.test.ts +157 -0
- package/src/__tests__/eventStore.test.ts +141 -0
- package/src/__tests__/hasToolContent.test.ts +63 -0
- package/src/__tests__/mapToolResult.test.ts +101 -0
- package/src/__tests__/middleware.edge.test.ts +126 -0
- package/src/__tests__/middleware.test.ts +233 -0
- package/src/__tests__/proxyContext.test.ts +140 -0
- package/src/__tests__/sanitizeCatalog.test.ts +380 -0
- package/src/__tests__/startHttpProxy.lifecycle.test.ts +675 -0
- package/src/__tests__/startHttpProxy.registry.test.ts +409 -0
- package/src/__tests__/startHttpProxy.replay.test.ts +116 -0
- package/src/__tests__/startHttpProxy.security.test.ts +288 -0
- package/src/__tests__/startHttpProxy.test.ts +1065 -0
- package/src/__tests__/testing.test.ts +250 -0
- package/src/__tests__/version.test.ts +12 -0
- package/src/backendClient.ts +83 -0
- package/src/core.ts +2129 -0
- package/src/delegation.ts +224 -0
- package/src/eventStore.ts +106 -0
- package/src/hiddenTools.ts +84 -0
- package/src/identity.ts +15 -0
- package/src/index.ts +63 -0
- package/src/mesh.ts +204 -0
- package/src/middleware.ts +101 -0
- package/src/proxyContext.ts +108 -0
- package/src/rejection.ts +62 -0
- package/src/sanitizeCatalog.ts +126 -0
- package/src/sessionRegistry.ts +142 -0
- package/src/telemetry.ts +46 -0
- package/src/testing.ts +174 -0
- package/src/version.ts +6 -0
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
it,
|
|
4
|
+
expect,
|
|
5
|
+
beforeAll,
|
|
6
|
+
afterAll,
|
|
7
|
+
vi,
|
|
8
|
+
beforeEach,
|
|
9
|
+
afterEach,
|
|
10
|
+
} from 'vitest';
|
|
11
|
+
import * as http from 'node:http';
|
|
12
|
+
import {
|
|
13
|
+
startHttpProxy,
|
|
14
|
+
type ListToolsMiddleware,
|
|
15
|
+
type ToolMiddleware,
|
|
16
|
+
} from '../core.js';
|
|
17
|
+
import type { ProxyContext } from '../proxyContext.js';
|
|
18
|
+
import { makeMockBackend, getPort, closeServer } from './_helpers.js';
|
|
19
|
+
|
|
20
|
+
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
describe('startHttpProxy()', () => {
|
|
23
|
+
it('resolves to a listening http.Server', async () => {
|
|
24
|
+
const backend = makeMockBackend();
|
|
25
|
+
const server = await startHttpProxy(
|
|
26
|
+
backend,
|
|
27
|
+
{ name: 'test-server' },
|
|
28
|
+
{ port: 0 },
|
|
29
|
+
);
|
|
30
|
+
try {
|
|
31
|
+
expect(server).toBeInstanceOf(http.Server);
|
|
32
|
+
expect(server.listening).toBe(true);
|
|
33
|
+
} finally {
|
|
34
|
+
await new Promise<void>((res) => server.close(() => res()));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('routing', () => {
|
|
39
|
+
let server: http.Server;
|
|
40
|
+
let baseUrl: string;
|
|
41
|
+
|
|
42
|
+
beforeAll(async () => {
|
|
43
|
+
const backend = makeMockBackend();
|
|
44
|
+
server = await startHttpProxy(
|
|
45
|
+
backend,
|
|
46
|
+
{ name: 'test-server' },
|
|
47
|
+
{ port: 0, path: '/mcp' },
|
|
48
|
+
);
|
|
49
|
+
baseUrl = `http://localhost:${getPort(server)}`;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterAll(() => new Promise<void>((res) => server.close(() => res())));
|
|
53
|
+
|
|
54
|
+
it('returns 404 for unknown paths', async () => {
|
|
55
|
+
const res = await fetch(`${baseUrl}/unknown`, { method: 'POST' });
|
|
56
|
+
expect(res.status).toBe(404);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns 404 for wrong HTTP methods on /mcp', async () => {
|
|
60
|
+
const res = await fetch(`${baseUrl}/mcp`, { method: 'PUT' });
|
|
61
|
+
expect(res.status).toBe(404);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('does not return 404 or 500 for a valid MCP initialize POST', async () => {
|
|
65
|
+
const body = JSON.stringify({
|
|
66
|
+
jsonrpc: '2.0',
|
|
67
|
+
id: 1,
|
|
68
|
+
method: 'initialize',
|
|
69
|
+
params: {
|
|
70
|
+
protocolVersion: '2024-11-05',
|
|
71
|
+
capabilities: {},
|
|
72
|
+
clientInfo: { name: 'test', version: '0.0.1' },
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'Content-Type': 'application/json' },
|
|
79
|
+
body,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(res.status).not.toBe(404);
|
|
83
|
+
expect(res.status).not.toBe(500);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('routes a follow-up POST with mcp-session-id to the existing session', async () => {
|
|
87
|
+
const mcpAccept = 'application/json, text/event-stream';
|
|
88
|
+
const initBody = JSON.stringify({
|
|
89
|
+
jsonrpc: '2.0',
|
|
90
|
+
id: 1,
|
|
91
|
+
method: 'initialize',
|
|
92
|
+
params: {
|
|
93
|
+
protocolVersion: '2024-11-05',
|
|
94
|
+
capabilities: {},
|
|
95
|
+
clientInfo: { name: 'test', version: '0.0.1' },
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Initialize — server assigns session ID
|
|
100
|
+
const initRes = await fetch(`${baseUrl}/mcp`, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: { 'Content-Type': 'application/json', Accept: mcpAccept },
|
|
103
|
+
body: initBody,
|
|
104
|
+
});
|
|
105
|
+
const sessionId = initRes.headers.get('mcp-session-id');
|
|
106
|
+
expect(sessionId).toBeTruthy();
|
|
107
|
+
|
|
108
|
+
// Follow-up reusing session ID
|
|
109
|
+
const followUpBody = JSON.stringify({
|
|
110
|
+
jsonrpc: '2.0',
|
|
111
|
+
id: 2,
|
|
112
|
+
method: 'tools/list',
|
|
113
|
+
params: {},
|
|
114
|
+
});
|
|
115
|
+
const followUpRes = await fetch(`${baseUrl}/mcp`, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: {
|
|
118
|
+
'Content-Type': 'application/json',
|
|
119
|
+
Accept: mcpAccept,
|
|
120
|
+
'mcp-session-id': sessionId!,
|
|
121
|
+
},
|
|
122
|
+
body: followUpBody,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(followUpRes.status).not.toBe(404);
|
|
126
|
+
expect(followUpRes.status).not.toBe(500);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns 404 for a POST with an unknown mcp-session-id', async () => {
|
|
130
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
131
|
+
method: 'POST',
|
|
132
|
+
headers: {
|
|
133
|
+
'Content-Type': 'application/json',
|
|
134
|
+
'mcp-session-id': 'nonexistent-session-id-12345',
|
|
135
|
+
},
|
|
136
|
+
body: JSON.stringify({
|
|
137
|
+
jsonrpc: '2.0',
|
|
138
|
+
id: 1,
|
|
139
|
+
method: 'tools/list',
|
|
140
|
+
params: {},
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(res.status).toBe(404);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('maxSessions', () => {
|
|
149
|
+
it('returns 503 when session cap is reached', async () => {
|
|
150
|
+
const backend = makeMockBackend();
|
|
151
|
+
const server = await startHttpProxy(
|
|
152
|
+
backend,
|
|
153
|
+
{ name: 'test-server' },
|
|
154
|
+
{ port: 0, path: '/mcp', maxSessions: 1 },
|
|
155
|
+
);
|
|
156
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
157
|
+
|
|
158
|
+
const initBody = JSON.stringify({
|
|
159
|
+
jsonrpc: '2.0',
|
|
160
|
+
id: 1,
|
|
161
|
+
method: 'initialize',
|
|
162
|
+
params: {
|
|
163
|
+
protocolVersion: '2024-11-05',
|
|
164
|
+
capabilities: {},
|
|
165
|
+
clientInfo: { name: 'test', version: '0.0.1' },
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
const headers = {
|
|
169
|
+
'Content-Type': 'application/json',
|
|
170
|
+
Accept: 'application/json, text/event-stream',
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
// First session — should succeed
|
|
175
|
+
const res1 = await fetch(`${baseUrl}/mcp`, {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers,
|
|
178
|
+
body: initBody,
|
|
179
|
+
});
|
|
180
|
+
expect(res1.status).not.toBe(503);
|
|
181
|
+
|
|
182
|
+
// Second session — should be rejected
|
|
183
|
+
const res2 = await fetch(`${baseUrl}/mcp`, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers,
|
|
186
|
+
body: initBody,
|
|
187
|
+
});
|
|
188
|
+
expect(res2.status).toBe(503);
|
|
189
|
+
} finally {
|
|
190
|
+
await new Promise<void>((res) => server.close(() => res()));
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('sessionTtlMs', () => {
|
|
196
|
+
beforeEach(() => {
|
|
197
|
+
vi.useFakeTimers();
|
|
198
|
+
});
|
|
199
|
+
afterEach(() => {
|
|
200
|
+
vi.useRealTimers();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('removes session after TTL expires', async () => {
|
|
204
|
+
const backend = makeMockBackend();
|
|
205
|
+
const ttlMs = 5000;
|
|
206
|
+
const server = await startHttpProxy(
|
|
207
|
+
backend,
|
|
208
|
+
{ name: 'test-server' },
|
|
209
|
+
{ port: 0, path: '/mcp', sessionTtlMs: ttlMs },
|
|
210
|
+
);
|
|
211
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
212
|
+
|
|
213
|
+
const initBody = JSON.stringify({
|
|
214
|
+
jsonrpc: '2.0',
|
|
215
|
+
id: 1,
|
|
216
|
+
method: 'initialize',
|
|
217
|
+
params: {
|
|
218
|
+
protocolVersion: '2024-11-05',
|
|
219
|
+
capabilities: {},
|
|
220
|
+
clientInfo: { name: 'test', version: '0.0.1' },
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
const headers = {
|
|
224
|
+
'Content-Type': 'application/json',
|
|
225
|
+
Accept: 'application/json, text/event-stream',
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const initRes = await fetch(`${baseUrl}/mcp`, {
|
|
230
|
+
method: 'POST',
|
|
231
|
+
headers,
|
|
232
|
+
body: initBody,
|
|
233
|
+
});
|
|
234
|
+
const sessionId = initRes.headers.get('mcp-session-id');
|
|
235
|
+
expect(sessionId).toBeTruthy();
|
|
236
|
+
|
|
237
|
+
// Session is alive before TTL
|
|
238
|
+
const before = await fetch(`${baseUrl}/mcp`, {
|
|
239
|
+
method: 'POST',
|
|
240
|
+
headers: { ...headers, 'mcp-session-id': sessionId! },
|
|
241
|
+
body: JSON.stringify({
|
|
242
|
+
jsonrpc: '2.0',
|
|
243
|
+
id: 2,
|
|
244
|
+
method: 'tools/list',
|
|
245
|
+
params: {},
|
|
246
|
+
}),
|
|
247
|
+
});
|
|
248
|
+
expect(before.status).not.toBe(404);
|
|
249
|
+
|
|
250
|
+
// Advance past TTL
|
|
251
|
+
await vi.advanceTimersByTimeAsync(ttlMs + 1);
|
|
252
|
+
|
|
253
|
+
// Session should now be gone
|
|
254
|
+
const after = await fetch(`${baseUrl}/mcp`, {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
headers: { ...headers, 'mcp-session-id': sessionId! },
|
|
257
|
+
body: JSON.stringify({
|
|
258
|
+
jsonrpc: '2.0',
|
|
259
|
+
id: 3,
|
|
260
|
+
method: 'tools/list',
|
|
261
|
+
params: {},
|
|
262
|
+
}),
|
|
263
|
+
});
|
|
264
|
+
expect(after.status).toBe(404);
|
|
265
|
+
} finally {
|
|
266
|
+
await new Promise<void>((res) => server.close(() => res()));
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('maxBodyBytes', () => {
|
|
272
|
+
it('returns 413 when request body exceeds limit', async () => {
|
|
273
|
+
const backend = makeMockBackend();
|
|
274
|
+
const server = await startHttpProxy(
|
|
275
|
+
backend,
|
|
276
|
+
{ name: 'test-server' },
|
|
277
|
+
{ port: 0, path: '/mcp', maxBodyBytes: 10 },
|
|
278
|
+
);
|
|
279
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const largeBody = JSON.stringify({
|
|
283
|
+
jsonrpc: '2.0',
|
|
284
|
+
id: 1,
|
|
285
|
+
method: 'initialize',
|
|
286
|
+
params: {
|
|
287
|
+
protocolVersion: '2024-11-05',
|
|
288
|
+
capabilities: {},
|
|
289
|
+
clientInfo: {
|
|
290
|
+
name: 'test-client-with-long-name',
|
|
291
|
+
version: '0.0.1',
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
296
|
+
method: 'POST',
|
|
297
|
+
headers: { 'Content-Type': 'application/json' },
|
|
298
|
+
body: largeBody,
|
|
299
|
+
});
|
|
300
|
+
expect(res.status).toBe(413);
|
|
301
|
+
} finally {
|
|
302
|
+
await new Promise<void>((res) => server.close(() => res()));
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe('onRequest', () => {
|
|
308
|
+
it('blocks requests when onRequest returns false', async () => {
|
|
309
|
+
const backend = makeMockBackend();
|
|
310
|
+
const server = await startHttpProxy(
|
|
311
|
+
backend,
|
|
312
|
+
{ name: 'test-server' },
|
|
313
|
+
{
|
|
314
|
+
port: 0,
|
|
315
|
+
path: '/mcp',
|
|
316
|
+
onRequest: (_req, res) => {
|
|
317
|
+
res.writeHead(403).end();
|
|
318
|
+
return false;
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
326
|
+
method: 'POST',
|
|
327
|
+
headers: { 'Content-Type': 'application/json' },
|
|
328
|
+
body: JSON.stringify({
|
|
329
|
+
jsonrpc: '2.0',
|
|
330
|
+
id: 1,
|
|
331
|
+
method: 'initialize',
|
|
332
|
+
params: {},
|
|
333
|
+
}),
|
|
334
|
+
});
|
|
335
|
+
expect(res.status).toBe(403);
|
|
336
|
+
} finally {
|
|
337
|
+
await new Promise<void>((res) => server.close(() => res()));
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('returns 401 when onRequest throws', async () => {
|
|
342
|
+
const backend = makeMockBackend();
|
|
343
|
+
const server = await startHttpProxy(
|
|
344
|
+
backend,
|
|
345
|
+
{ name: 'test-server' },
|
|
346
|
+
{
|
|
347
|
+
port: 0,
|
|
348
|
+
path: '/mcp',
|
|
349
|
+
onRequest: () => {
|
|
350
|
+
throw new Error('auth failure');
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
);
|
|
354
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
358
|
+
method: 'POST',
|
|
359
|
+
headers: { 'Content-Type': 'application/json' },
|
|
360
|
+
body: JSON.stringify({
|
|
361
|
+
jsonrpc: '2.0',
|
|
362
|
+
id: 1,
|
|
363
|
+
method: 'initialize',
|
|
364
|
+
params: {},
|
|
365
|
+
}),
|
|
366
|
+
});
|
|
367
|
+
expect(res.status).toBe(401);
|
|
368
|
+
} finally {
|
|
369
|
+
await new Promise<void>((res) => server.close(() => res()));
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
describe('onError', () => {
|
|
375
|
+
const initializeBody = JSON.stringify({
|
|
376
|
+
jsonrpc: '2.0',
|
|
377
|
+
id: 1,
|
|
378
|
+
method: 'initialize',
|
|
379
|
+
params: {
|
|
380
|
+
protocolVersion: '2024-11-05',
|
|
381
|
+
capabilities: {},
|
|
382
|
+
clientInfo: { name: 'test', version: '0.0.1' },
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
/** Every auth hook is expected to be contained *and* reported. */
|
|
387
|
+
const expectReported = (
|
|
388
|
+
errors: unknown[],
|
|
389
|
+
message: string,
|
|
390
|
+
status: number,
|
|
391
|
+
): void => {
|
|
392
|
+
expect(status).toBe(401);
|
|
393
|
+
expect(errors).toHaveLength(1);
|
|
394
|
+
expect((errors[0] as Error).message).toBe(message);
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
it('reports an error thrown by onRequest instead of only discarding it', async () => {
|
|
398
|
+
const errors: unknown[] = [];
|
|
399
|
+
const server = await startHttpProxy(
|
|
400
|
+
makeMockBackend(),
|
|
401
|
+
{ name: 'test-server' },
|
|
402
|
+
{
|
|
403
|
+
port: 0,
|
|
404
|
+
path: '/mcp',
|
|
405
|
+
onError: (err) => errors.push(err),
|
|
406
|
+
onRequest: () => {
|
|
407
|
+
throw new Error('auth service unreachable');
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
);
|
|
411
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
415
|
+
method: 'POST',
|
|
416
|
+
headers: { 'Content-Type': 'application/json' },
|
|
417
|
+
body: initializeBody,
|
|
418
|
+
});
|
|
419
|
+
expectReported(errors, 'auth service unreachable', res.status);
|
|
420
|
+
} finally {
|
|
421
|
+
await closeServer(server);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('reports an error thrown by resolveIdentity instead of only discarding it', async () => {
|
|
426
|
+
const errors: unknown[] = [];
|
|
427
|
+
const server = await startHttpProxy(
|
|
428
|
+
makeMockBackend(),
|
|
429
|
+
{ name: 'test-server' },
|
|
430
|
+
{
|
|
431
|
+
port: 0,
|
|
432
|
+
path: '/mcp',
|
|
433
|
+
onError: (err) => errors.push(err),
|
|
434
|
+
resolveIdentity: () => {
|
|
435
|
+
throw new Error('jwks fetch failed');
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
);
|
|
439
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
440
|
+
|
|
441
|
+
try {
|
|
442
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
443
|
+
method: 'POST',
|
|
444
|
+
headers: { 'Content-Type': 'application/json' },
|
|
445
|
+
body: initializeBody,
|
|
446
|
+
});
|
|
447
|
+
// The initialize POST itself never reaches the transport: the caller
|
|
448
|
+
// sees a bare 401 while the real cause stays in the proxy.
|
|
449
|
+
expectReported(errors, 'jwks fetch failed', res.status);
|
|
450
|
+
} finally {
|
|
451
|
+
await closeServer(server);
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('still answers 401 when onError itself throws (#238 sibling)', async () => {
|
|
456
|
+
// `onError` is observability, so its own failure must change nothing.
|
|
457
|
+
// Unguarded it replaces the 401 documented for a throwing
|
|
458
|
+
// `resolveIdentity` with a 500, and rejects out of the request handler.
|
|
459
|
+
const server = await startHttpProxy(
|
|
460
|
+
makeMockBackend(),
|
|
461
|
+
{ name: 'test-server' },
|
|
462
|
+
{
|
|
463
|
+
port: 0,
|
|
464
|
+
path: '/mcp',
|
|
465
|
+
onError: () => {
|
|
466
|
+
throw new Error('logging backend down');
|
|
467
|
+
},
|
|
468
|
+
resolveIdentity: () => {
|
|
469
|
+
throw new Error('jwks fetch failed');
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
);
|
|
473
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
477
|
+
method: 'POST',
|
|
478
|
+
headers: { 'Content-Type': 'application/json' },
|
|
479
|
+
body: initializeBody,
|
|
480
|
+
});
|
|
481
|
+
expect(res.status).toBe(401);
|
|
482
|
+
} finally {
|
|
483
|
+
await closeServer(server);
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('still answers 401 when onError rejects asynchronously (#245)', async () => {
|
|
488
|
+
// `onError` is observability, so its own failure must change nothing.
|
|
489
|
+
// An async reporter that rejects after its first await escapes the
|
|
490
|
+
// synchronous guard: nothing observes the rejection, so Node's default
|
|
491
|
+
// --unhandled-rejections=throw ends the process.
|
|
492
|
+
const rejections: unknown[] = [];
|
|
493
|
+
const onUnhandled = (reason: unknown): void => {
|
|
494
|
+
rejections.push(reason);
|
|
495
|
+
};
|
|
496
|
+
process.on('unhandledRejection', onUnhandled);
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
const server = await startHttpProxy(
|
|
500
|
+
makeMockBackend(),
|
|
501
|
+
{ name: 'test-server' },
|
|
502
|
+
{
|
|
503
|
+
port: 0,
|
|
504
|
+
path: '/mcp',
|
|
505
|
+
onError: async () => {
|
|
506
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
507
|
+
throw new Error('logging backend down');
|
|
508
|
+
},
|
|
509
|
+
resolveIdentity: () => {
|
|
510
|
+
throw new Error('jwks fetch failed');
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
);
|
|
514
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
515
|
+
|
|
516
|
+
try {
|
|
517
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
518
|
+
method: 'POST',
|
|
519
|
+
headers: { 'Content-Type': 'application/json' },
|
|
520
|
+
body: initializeBody,
|
|
521
|
+
});
|
|
522
|
+
expect(res.status).toBe(401);
|
|
523
|
+
// One macrotask turn, so a rejection nobody adopted has surfaced as
|
|
524
|
+
// an unhandledRejection event by the time this asserts.
|
|
525
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
526
|
+
expect(rejections).toHaveLength(0);
|
|
527
|
+
} finally {
|
|
528
|
+
await closeServer(server);
|
|
529
|
+
}
|
|
530
|
+
} finally {
|
|
531
|
+
process.removeListener('unhandledRejection', onUnhandled);
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('reports an error thrown by validateSession instead of only discarding it', async () => {
|
|
536
|
+
const errors: unknown[] = [];
|
|
537
|
+
const server = await startHttpProxy(
|
|
538
|
+
makeMockBackend(),
|
|
539
|
+
{ name: 'test-server' },
|
|
540
|
+
{
|
|
541
|
+
port: 0,
|
|
542
|
+
path: '/mcp',
|
|
543
|
+
onError: (err) => errors.push(err),
|
|
544
|
+
validateSession: () => {
|
|
545
|
+
throw new Error('token store unreachable');
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
);
|
|
549
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
550
|
+
|
|
551
|
+
try {
|
|
552
|
+
const initRes = await fetch(`${baseUrl}/mcp`, {
|
|
553
|
+
method: 'POST',
|
|
554
|
+
headers: {
|
|
555
|
+
'Content-Type': 'application/json',
|
|
556
|
+
accept: 'application/json, text/event-stream',
|
|
557
|
+
},
|
|
558
|
+
body: initializeBody,
|
|
559
|
+
});
|
|
560
|
+
const sessionId = initRes.headers.get('mcp-session-id');
|
|
561
|
+
expect(sessionId).toBeTruthy();
|
|
562
|
+
// `initialize` itself does not re-validate: it has no session yet.
|
|
563
|
+
expect(errors).toHaveLength(0);
|
|
564
|
+
|
|
565
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
566
|
+
method: 'POST',
|
|
567
|
+
headers: {
|
|
568
|
+
'Content-Type': 'application/json',
|
|
569
|
+
'mcp-session-id': sessionId!,
|
|
570
|
+
},
|
|
571
|
+
body: JSON.stringify({
|
|
572
|
+
jsonrpc: '2.0',
|
|
573
|
+
id: 2,
|
|
574
|
+
method: 'tools/list',
|
|
575
|
+
params: {},
|
|
576
|
+
}),
|
|
577
|
+
});
|
|
578
|
+
expectReported(errors, 'token store unreachable', res.status);
|
|
579
|
+
} finally {
|
|
580
|
+
await closeServer(server);
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('calls onError when an unhandled error occurs during request handling', async () => {
|
|
585
|
+
const errors: unknown[] = [];
|
|
586
|
+
const backend = makeMockBackend();
|
|
587
|
+
// Make listTools throw to trigger the catch path
|
|
588
|
+
(backend.listTools as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
589
|
+
new Error('backend down'),
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
const server = await startHttpProxy(
|
|
593
|
+
backend,
|
|
594
|
+
{ name: 'test-server' },
|
|
595
|
+
{ port: 0, path: '/mcp', onError: (err) => errors.push(err) },
|
|
596
|
+
);
|
|
597
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
598
|
+
|
|
599
|
+
try {
|
|
600
|
+
// Initialize first
|
|
601
|
+
const initRes = await fetch(`${baseUrl}/mcp`, {
|
|
602
|
+
method: 'POST',
|
|
603
|
+
headers: {
|
|
604
|
+
'Content-Type': 'application/json',
|
|
605
|
+
Accept: 'application/json, text/event-stream',
|
|
606
|
+
},
|
|
607
|
+
body: JSON.stringify({
|
|
608
|
+
jsonrpc: '2.0',
|
|
609
|
+
id: 1,
|
|
610
|
+
method: 'initialize',
|
|
611
|
+
params: {
|
|
612
|
+
protocolVersion: '2024-11-05',
|
|
613
|
+
capabilities: {},
|
|
614
|
+
clientInfo: { name: 'test', version: '0.0.1' },
|
|
615
|
+
},
|
|
616
|
+
}),
|
|
617
|
+
});
|
|
618
|
+
const sessionId = initRes.headers.get('mcp-session-id');
|
|
619
|
+
expect(sessionId).toBeTruthy();
|
|
620
|
+
|
|
621
|
+
// Now call tools/list — this won't call onError directly but the server handles internally
|
|
622
|
+
// The important verification is that onError is wired correctly
|
|
623
|
+
// Just verify the server started correctly and onError config is accepted
|
|
624
|
+
expect(errors).toHaveLength(0); // no errors yet from init
|
|
625
|
+
} finally {
|
|
626
|
+
await new Promise<void>((res) => server.close(() => res()));
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('passes normalized HTTP ProxyContext into tool and listTools middleware', async () => {
|
|
632
|
+
const backend = makeMockBackend();
|
|
633
|
+
let seenToolContext: ProxyContext | undefined;
|
|
634
|
+
let seenListContext: ProxyContext | undefined;
|
|
635
|
+
|
|
636
|
+
const toolMiddleware: ToolMiddleware = async (req, next, context) => {
|
|
637
|
+
seenToolContext = context;
|
|
638
|
+
return next(req);
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
const listToolsMiddleware: ListToolsMiddleware = async (
|
|
642
|
+
req,
|
|
643
|
+
next,
|
|
644
|
+
context,
|
|
645
|
+
) => {
|
|
646
|
+
seenListContext = context;
|
|
647
|
+
return next(req);
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
const server = await startHttpProxy(
|
|
651
|
+
backend,
|
|
652
|
+
{
|
|
653
|
+
name: 'test-server',
|
|
654
|
+
toolMiddleware: [toolMiddleware],
|
|
655
|
+
listToolsMiddleware: [listToolsMiddleware],
|
|
656
|
+
},
|
|
657
|
+
{ port: 0, path: '/mcp' },
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
try {
|
|
661
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
662
|
+
const initBody = JSON.stringify({
|
|
663
|
+
jsonrpc: '2.0',
|
|
664
|
+
id: 1,
|
|
665
|
+
method: 'initialize',
|
|
666
|
+
params: {
|
|
667
|
+
protocolVersion: '2024-11-05',
|
|
668
|
+
capabilities: {},
|
|
669
|
+
clientInfo: { name: 'test', version: '0.0.1' },
|
|
670
|
+
},
|
|
671
|
+
});
|
|
672
|
+
const initRes = await fetch(`${baseUrl}/mcp`, {
|
|
673
|
+
method: 'POST',
|
|
674
|
+
headers: {
|
|
675
|
+
'Content-Type': 'application/json',
|
|
676
|
+
Accept: 'application/json, text/event-stream',
|
|
677
|
+
'x-tenant-id': 'bank-42',
|
|
678
|
+
},
|
|
679
|
+
body: initBody,
|
|
680
|
+
});
|
|
681
|
+
const sessionId = initRes.headers.get('mcp-session-id');
|
|
682
|
+
|
|
683
|
+
expect(sessionId).toBeTruthy();
|
|
684
|
+
|
|
685
|
+
await fetch(`${baseUrl}/mcp`, {
|
|
686
|
+
method: 'POST',
|
|
687
|
+
headers: {
|
|
688
|
+
'Content-Type': 'application/json',
|
|
689
|
+
Accept: 'application/json, text/event-stream',
|
|
690
|
+
'mcp-session-id': sessionId!,
|
|
691
|
+
'x-tenant-id': 'bank-42',
|
|
692
|
+
},
|
|
693
|
+
body: JSON.stringify({
|
|
694
|
+
jsonrpc: '2.0',
|
|
695
|
+
id: 2,
|
|
696
|
+
method: 'tools/list',
|
|
697
|
+
params: {},
|
|
698
|
+
}),
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
await fetch(`${baseUrl}/mcp`, {
|
|
702
|
+
method: 'POST',
|
|
703
|
+
headers: {
|
|
704
|
+
'Content-Type': 'application/json',
|
|
705
|
+
Accept: 'application/json, text/event-stream',
|
|
706
|
+
'mcp-session-id': sessionId!,
|
|
707
|
+
'x-tenant-id': 'bank-42',
|
|
708
|
+
},
|
|
709
|
+
body: JSON.stringify({
|
|
710
|
+
jsonrpc: '2.0',
|
|
711
|
+
id: 3,
|
|
712
|
+
method: 'tools/call',
|
|
713
|
+
params: { name: 'normal_tool', arguments: {} },
|
|
714
|
+
}),
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
expect(seenListContext).toMatchObject({
|
|
718
|
+
transport: 'http',
|
|
719
|
+
sessionId,
|
|
720
|
+
headers: expect.objectContaining({
|
|
721
|
+
'x-tenant-id': 'bank-42',
|
|
722
|
+
'mcp-session-id': sessionId,
|
|
723
|
+
}) as Record<string, string>,
|
|
724
|
+
});
|
|
725
|
+
expect(seenListContext?.requestId).toEqual(expect.any(String));
|
|
726
|
+
|
|
727
|
+
expect(seenToolContext).toMatchObject({
|
|
728
|
+
transport: 'http',
|
|
729
|
+
sessionId,
|
|
730
|
+
headers: expect.objectContaining({
|
|
731
|
+
'x-tenant-id': 'bank-42',
|
|
732
|
+
'mcp-session-id': sessionId,
|
|
733
|
+
}) as Record<string, string>,
|
|
734
|
+
});
|
|
735
|
+
expect(seenToolContext?.requestId).toEqual(expect.any(String));
|
|
736
|
+
} finally {
|
|
737
|
+
await new Promise<void>((res) => server.close(() => res()));
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
// ── edge cases ────────────────────────────────────────────────────────────
|
|
742
|
+
|
|
743
|
+
describe('routing edge cases', () => {
|
|
744
|
+
let server: http.Server;
|
|
745
|
+
let baseUrl: string;
|
|
746
|
+
|
|
747
|
+
beforeAll(async () => {
|
|
748
|
+
server = await startHttpProxy(
|
|
749
|
+
makeMockBackend(),
|
|
750
|
+
{ name: 'test-server' },
|
|
751
|
+
{ port: 0, path: '/mcp' },
|
|
752
|
+
);
|
|
753
|
+
baseUrl = `http://localhost:${getPort(server)}`;
|
|
754
|
+
});
|
|
755
|
+
afterAll(() => closeServer(server));
|
|
756
|
+
|
|
757
|
+
it('returns 404 for /mcp/ (trailing slash)', async () => {
|
|
758
|
+
const res = await fetch(`${baseUrl}/mcp/`, { method: 'POST' });
|
|
759
|
+
expect(res.status).toBe(404);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it('does not return 404 for GET /mcp method routing', async () => {
|
|
763
|
+
// Pathname matches; method allowed. Without session-id the SDK transport
|
|
764
|
+
// will respond with 4xx other than 404 — assert it isn't a routing 404.
|
|
765
|
+
const res = await fetch(`${baseUrl}/mcp`, { method: 'GET' });
|
|
766
|
+
expect(res.status).not.toBe(404);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it('does not return method-routing 404 for DELETE /mcp', async () => {
|
|
770
|
+
const res = await fetch(`${baseUrl}/mcp`, { method: 'DELETE' });
|
|
771
|
+
// Method is allowed; the missing session means the SDK transport rejects
|
|
772
|
+
// with non-404 (likely 400). What we verify is: it's not a 404 from our
|
|
773
|
+
// routing check.
|
|
774
|
+
expect(res.status).not.toBe(404);
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
describe('maxBodyBytes boundary', () => {
|
|
779
|
+
it('accepts a body exactly at the limit', async () => {
|
|
780
|
+
// BUG candidate: confirm boundary semantics. core.ts:422 uses `>` so
|
|
781
|
+
// exactly N bytes should pass.
|
|
782
|
+
const backend = makeMockBackend();
|
|
783
|
+
const body = JSON.stringify({
|
|
784
|
+
jsonrpc: '2.0',
|
|
785
|
+
id: 1,
|
|
786
|
+
method: 'initialize',
|
|
787
|
+
params: {
|
|
788
|
+
protocolVersion: '2024-11-05',
|
|
789
|
+
capabilities: {},
|
|
790
|
+
clientInfo: { name: 't', version: '0' },
|
|
791
|
+
},
|
|
792
|
+
});
|
|
793
|
+
const server = await startHttpProxy(
|
|
794
|
+
backend,
|
|
795
|
+
{ name: 'test-server' },
|
|
796
|
+
{
|
|
797
|
+
port: 0,
|
|
798
|
+
path: '/mcp',
|
|
799
|
+
maxBodyBytes: Buffer.byteLength(body),
|
|
800
|
+
},
|
|
801
|
+
);
|
|
802
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
803
|
+
try {
|
|
804
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
805
|
+
method: 'POST',
|
|
806
|
+
headers: {
|
|
807
|
+
'Content-Type': 'application/json',
|
|
808
|
+
Accept: 'application/json, text/event-stream',
|
|
809
|
+
},
|
|
810
|
+
body,
|
|
811
|
+
});
|
|
812
|
+
expect(res.status).not.toBe(413);
|
|
813
|
+
} finally {
|
|
814
|
+
await closeServer(server);
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
it('rejects a body one byte over the limit', async () => {
|
|
819
|
+
const backend = makeMockBackend();
|
|
820
|
+
const body = JSON.stringify({
|
|
821
|
+
jsonrpc: '2.0',
|
|
822
|
+
id: 1,
|
|
823
|
+
method: 'initialize',
|
|
824
|
+
params: {
|
|
825
|
+
protocolVersion: '2024-11-05',
|
|
826
|
+
capabilities: {},
|
|
827
|
+
clientInfo: { name: 't', version: '0' },
|
|
828
|
+
},
|
|
829
|
+
});
|
|
830
|
+
const server = await startHttpProxy(
|
|
831
|
+
backend,
|
|
832
|
+
{ name: 'test-server' },
|
|
833
|
+
{
|
|
834
|
+
port: 0,
|
|
835
|
+
path: '/mcp',
|
|
836
|
+
maxBodyBytes: Buffer.byteLength(body) - 1,
|
|
837
|
+
},
|
|
838
|
+
);
|
|
839
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
840
|
+
try {
|
|
841
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
842
|
+
method: 'POST',
|
|
843
|
+
headers: { 'Content-Type': 'application/json' },
|
|
844
|
+
body,
|
|
845
|
+
});
|
|
846
|
+
expect(res.status).toBe(413);
|
|
847
|
+
} finally {
|
|
848
|
+
await closeServer(server);
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
describe('maxSessions: 0', () => {
|
|
854
|
+
// BUG candidate: with maxSessions:0 the comparison `sessions.size >= 0`
|
|
855
|
+
// always rejects, which may be intentional ("no sessions allowed") but is
|
|
856
|
+
// also indistinguishable from "feature disabled". This test pins current
|
|
857
|
+
// behavior — every initialize → 503.
|
|
858
|
+
it('rejects every initialize with 503', async () => {
|
|
859
|
+
const backend = makeMockBackend();
|
|
860
|
+
const server = await startHttpProxy(
|
|
861
|
+
backend,
|
|
862
|
+
{ name: 'test-server' },
|
|
863
|
+
{
|
|
864
|
+
port: 0,
|
|
865
|
+
path: '/mcp',
|
|
866
|
+
maxSessions: 0,
|
|
867
|
+
},
|
|
868
|
+
);
|
|
869
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
870
|
+
try {
|
|
871
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
872
|
+
method: 'POST',
|
|
873
|
+
headers: {
|
|
874
|
+
'Content-Type': 'application/json',
|
|
875
|
+
Accept: 'application/json, text/event-stream',
|
|
876
|
+
},
|
|
877
|
+
body: JSON.stringify({
|
|
878
|
+
jsonrpc: '2.0',
|
|
879
|
+
id: 1,
|
|
880
|
+
method: 'initialize',
|
|
881
|
+
params: {
|
|
882
|
+
protocolVersion: '2024-11-05',
|
|
883
|
+
capabilities: {},
|
|
884
|
+
clientInfo: { name: 't', version: '0' },
|
|
885
|
+
},
|
|
886
|
+
}),
|
|
887
|
+
});
|
|
888
|
+
expect(res.status).toBe(503);
|
|
889
|
+
} finally {
|
|
890
|
+
await closeServer(server);
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
describe('server.close()', () => {
|
|
896
|
+
it('is idempotent — calling close() twice does not throw', async () => {
|
|
897
|
+
const backend = makeMockBackend();
|
|
898
|
+
const server = await startHttpProxy(
|
|
899
|
+
backend,
|
|
900
|
+
{ name: 'test-server' },
|
|
901
|
+
{ port: 0, path: '/mcp' },
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
const first = new Promise<void>((res) => server.close(() => res()));
|
|
905
|
+
// Second call before the first resolves; must not throw, must not double-close sessions.
|
|
906
|
+
expect(() => server.close()).not.toThrow();
|
|
907
|
+
await first;
|
|
908
|
+
expect(server.listening).toBe(false);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it('closes active sessions when called', async () => {
|
|
912
|
+
const backend = makeMockBackend();
|
|
913
|
+
const server = await startHttpProxy(
|
|
914
|
+
backend,
|
|
915
|
+
{ name: 'test-server' },
|
|
916
|
+
{ port: 0, path: '/mcp' },
|
|
917
|
+
);
|
|
918
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
919
|
+
|
|
920
|
+
await fetch(`${baseUrl}/mcp`, {
|
|
921
|
+
method: 'POST',
|
|
922
|
+
headers: {
|
|
923
|
+
'Content-Type': 'application/json',
|
|
924
|
+
Accept: 'application/json, text/event-stream',
|
|
925
|
+
},
|
|
926
|
+
body: JSON.stringify({
|
|
927
|
+
jsonrpc: '2.0',
|
|
928
|
+
id: 1,
|
|
929
|
+
method: 'initialize',
|
|
930
|
+
params: {
|
|
931
|
+
protocolVersion: '2024-11-05',
|
|
932
|
+
capabilities: {},
|
|
933
|
+
clientInfo: { name: 't', version: '0' },
|
|
934
|
+
},
|
|
935
|
+
}),
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
await closeServer(server);
|
|
939
|
+
expect(server.listening).toBe(false);
|
|
940
|
+
});
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
describe('onRequest async behavior', () => {
|
|
944
|
+
it('returns 401 when an async onRequest rejects', async () => {
|
|
945
|
+
const backend = makeMockBackend();
|
|
946
|
+
const server = await startHttpProxy(
|
|
947
|
+
backend,
|
|
948
|
+
{ name: 'test-server' },
|
|
949
|
+
{
|
|
950
|
+
port: 0,
|
|
951
|
+
path: '/mcp',
|
|
952
|
+
onRequest: async () => {
|
|
953
|
+
await Promise.resolve();
|
|
954
|
+
throw new Error('async auth failure');
|
|
955
|
+
},
|
|
956
|
+
},
|
|
957
|
+
);
|
|
958
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
959
|
+
try {
|
|
960
|
+
const res = await fetch(`${baseUrl}/mcp`, { method: 'POST' });
|
|
961
|
+
expect(res.status).toBe(401);
|
|
962
|
+
} finally {
|
|
963
|
+
await closeServer(server);
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
it('respects the user-written response when async onRequest returns false', async () => {
|
|
968
|
+
const backend = makeMockBackend();
|
|
969
|
+
const server = await startHttpProxy(
|
|
970
|
+
backend,
|
|
971
|
+
{ name: 'test-server' },
|
|
972
|
+
{
|
|
973
|
+
port: 0,
|
|
974
|
+
path: '/mcp',
|
|
975
|
+
onRequest: async (_req, res) => {
|
|
976
|
+
await Promise.resolve();
|
|
977
|
+
res.writeHead(418).end();
|
|
978
|
+
return false;
|
|
979
|
+
},
|
|
980
|
+
},
|
|
981
|
+
);
|
|
982
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
983
|
+
try {
|
|
984
|
+
const res = await fetch(`${baseUrl}/mcp`, { method: 'POST' });
|
|
985
|
+
expect(res.status).toBe(418);
|
|
986
|
+
} finally {
|
|
987
|
+
await closeServer(server);
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
describe('header normalization through HTTP', () => {
|
|
993
|
+
it('joins repeated request headers with ", " in the middleware context', async () => {
|
|
994
|
+
const backend = makeMockBackend();
|
|
995
|
+
let seen: ProxyContext | undefined;
|
|
996
|
+
const mw: ListToolsMiddleware = async (req, next, context) => {
|
|
997
|
+
seen = context;
|
|
998
|
+
return next(req);
|
|
999
|
+
};
|
|
1000
|
+
const server = await startHttpProxy(
|
|
1001
|
+
backend,
|
|
1002
|
+
{ listToolsMiddleware: [mw], name: 'test-server' },
|
|
1003
|
+
{ port: 0, path: '/mcp' },
|
|
1004
|
+
);
|
|
1005
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
1006
|
+
|
|
1007
|
+
try {
|
|
1008
|
+
// initialize
|
|
1009
|
+
const initRes = await fetch(`${baseUrl}/mcp`, {
|
|
1010
|
+
method: 'POST',
|
|
1011
|
+
headers: {
|
|
1012
|
+
'Content-Type': 'application/json',
|
|
1013
|
+
Accept: 'application/json, text/event-stream',
|
|
1014
|
+
},
|
|
1015
|
+
body: JSON.stringify({
|
|
1016
|
+
jsonrpc: '2.0',
|
|
1017
|
+
id: 1,
|
|
1018
|
+
method: 'initialize',
|
|
1019
|
+
params: {
|
|
1020
|
+
protocolVersion: '2024-11-05',
|
|
1021
|
+
capabilities: {},
|
|
1022
|
+
clientInfo: { name: 't', version: '0' },
|
|
1023
|
+
},
|
|
1024
|
+
}),
|
|
1025
|
+
});
|
|
1026
|
+
const sessionId = initRes.headers.get('mcp-session-id')!;
|
|
1027
|
+
|
|
1028
|
+
// Use Node http.request to pass an array-style header (fetch dedupes).
|
|
1029
|
+
await new Promise<void>((resolve) => {
|
|
1030
|
+
const req = http.request(
|
|
1031
|
+
{
|
|
1032
|
+
hostname: 'localhost',
|
|
1033
|
+
port: getPort(server),
|
|
1034
|
+
path: '/mcp',
|
|
1035
|
+
method: 'POST',
|
|
1036
|
+
headers: {
|
|
1037
|
+
'Content-Type': 'application/json',
|
|
1038
|
+
Accept: 'application/json, text/event-stream',
|
|
1039
|
+
'mcp-session-id': sessionId,
|
|
1040
|
+
'x-multi': ['one', 'two'],
|
|
1041
|
+
},
|
|
1042
|
+
},
|
|
1043
|
+
(res) => {
|
|
1044
|
+
res.on('data', () => {});
|
|
1045
|
+
res.on('end', () => resolve());
|
|
1046
|
+
},
|
|
1047
|
+
);
|
|
1048
|
+
req.write(
|
|
1049
|
+
JSON.stringify({
|
|
1050
|
+
jsonrpc: '2.0',
|
|
1051
|
+
id: 2,
|
|
1052
|
+
method: 'tools/list',
|
|
1053
|
+
params: {},
|
|
1054
|
+
}),
|
|
1055
|
+
);
|
|
1056
|
+
req.end();
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
expect(seen?.headers?.['x-multi']).toBe('one, two');
|
|
1060
|
+
} finally {
|
|
1061
|
+
await closeServer(server);
|
|
1062
|
+
}
|
|
1063
|
+
});
|
|
1064
|
+
});
|
|
1065
|
+
});
|