mcpose 2.0.2 → 3.0.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/LICENSE +21 -0
- package/README.md +494 -37
- package/dist/backendClient.d.ts +5 -0
- package/dist/backendClient.d.ts.map +1 -1
- package/dist/backendClient.js +17 -9
- package/dist/backendClient.js.map +1 -1
- package/dist/core.d.ts +293 -28
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +969 -175
- 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 +1 -1
- package/dist/rejection.d.ts.map +1 -1
- package/dist/rejection.js +1 -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 +86 -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 +30 -14
- 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 +1019 -0
- package/src/__tests__/eventStore.test.ts +141 -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 +359 -0
- package/src/__tests__/startHttpProxy.lifecycle.test.ts +638 -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 +985 -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 +2042 -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 +37 -0
- package/src/sanitizeCatalog.ts +120 -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,638 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { startHttpProxy } from '../core.js';
|
|
3
|
+
import {
|
|
4
|
+
makeMockBackend,
|
|
5
|
+
getPort,
|
|
6
|
+
closeServer,
|
|
7
|
+
postOnFreshConnection,
|
|
8
|
+
} from './_helpers.js';
|
|
9
|
+
|
|
10
|
+
const MCP_HEADERS = {
|
|
11
|
+
'Content-Type': 'application/json',
|
|
12
|
+
Accept: 'application/json, text/event-stream',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const INIT_BODY = JSON.stringify({
|
|
16
|
+
jsonrpc: '2.0',
|
|
17
|
+
id: 1,
|
|
18
|
+
method: 'initialize',
|
|
19
|
+
params: {
|
|
20
|
+
protocolVersion: '2024-11-05',
|
|
21
|
+
capabilities: {},
|
|
22
|
+
clientInfo: { name: 'test', version: '0.0.1' },
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
async function initSession(baseUrl: string): Promise<string> {
|
|
27
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: MCP_HEADERS,
|
|
30
|
+
body: INIT_BODY,
|
|
31
|
+
});
|
|
32
|
+
const sessionId = res.headers.get('mcp-session-id');
|
|
33
|
+
expect(sessionId).toBeTruthy();
|
|
34
|
+
return sessionId!;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('startHttpProxy() session lifecycle', () => {
|
|
38
|
+
describe('TTL expiry', () => {
|
|
39
|
+
beforeEach(() => vi.useFakeTimers());
|
|
40
|
+
afterEach(() => vi.useRealTimers());
|
|
41
|
+
|
|
42
|
+
it('fires onSessionClosed exactly once on TTL expiry', async () => {
|
|
43
|
+
const closed: string[] = [];
|
|
44
|
+
const server = await startHttpProxy(
|
|
45
|
+
makeMockBackend(),
|
|
46
|
+
{ name: 'test-server' },
|
|
47
|
+
{
|
|
48
|
+
port: 0,
|
|
49
|
+
path: '/mcp',
|
|
50
|
+
sessionTtlMs: 5000,
|
|
51
|
+
onSessionClosed: (id) => closed.push(id),
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const sessionId = await initSession(baseUrl);
|
|
58
|
+
|
|
59
|
+
await vi.advanceTimersByTimeAsync(5001);
|
|
60
|
+
expect(closed).toEqual([sessionId]);
|
|
61
|
+
|
|
62
|
+
// Session is gone
|
|
63
|
+
const after = await postOnFreshConnection(
|
|
64
|
+
`${baseUrl}/mcp`,
|
|
65
|
+
{ ...MCP_HEADERS, 'mcp-session-id': sessionId },
|
|
66
|
+
JSON.stringify({
|
|
67
|
+
jsonrpc: '2.0',
|
|
68
|
+
id: 2,
|
|
69
|
+
method: 'tools/list',
|
|
70
|
+
params: {},
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
expect(after).toBe(404);
|
|
74
|
+
|
|
75
|
+
// No double-fire later
|
|
76
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
77
|
+
expect(closed).toEqual([sessionId]);
|
|
78
|
+
} finally {
|
|
79
|
+
await closeServer(server);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('does not crash when onSessionClosed throws on TTL expiry', async () => {
|
|
84
|
+
const errors: unknown[] = [];
|
|
85
|
+
const server = await startHttpProxy(
|
|
86
|
+
makeMockBackend(),
|
|
87
|
+
{ name: 'test-server' },
|
|
88
|
+
{
|
|
89
|
+
port: 0,
|
|
90
|
+
path: '/mcp',
|
|
91
|
+
sessionTtlMs: 1000,
|
|
92
|
+
onError: (err) => errors.push(err),
|
|
93
|
+
onSessionClosed: () => {
|
|
94
|
+
throw new Error('flush failed');
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const sessionId = await initSession(baseUrl);
|
|
102
|
+
await vi.advanceTimersByTimeAsync(1001);
|
|
103
|
+
|
|
104
|
+
// Session torn down despite the throwing hook, error routed to onError
|
|
105
|
+
const after = await postOnFreshConnection(
|
|
106
|
+
`${baseUrl}/mcp`,
|
|
107
|
+
{ ...MCP_HEADERS, 'mcp-session-id': sessionId },
|
|
108
|
+
JSON.stringify({
|
|
109
|
+
jsonrpc: '2.0',
|
|
110
|
+
id: 2,
|
|
111
|
+
method: 'tools/list',
|
|
112
|
+
params: {},
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
expect(after).toBe(404);
|
|
116
|
+
expect(errors).toHaveLength(1);
|
|
117
|
+
expect((errors[0] as Error).message).toBe('flush failed');
|
|
118
|
+
} finally {
|
|
119
|
+
await closeServer(server);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('routes a rejected async onSessionClosed on TTL expiry to onError (#176)', async () => {
|
|
124
|
+
const errors: unknown[] = [];
|
|
125
|
+
const server = await startHttpProxy(
|
|
126
|
+
makeMockBackend(),
|
|
127
|
+
{ name: 'test-server' },
|
|
128
|
+
{
|
|
129
|
+
port: 0,
|
|
130
|
+
path: '/mcp',
|
|
131
|
+
sessionTtlMs: 1000,
|
|
132
|
+
onError: (err) => errors.push(err),
|
|
133
|
+
onSessionClosed: () => Promise.reject(new Error('flush rejected')),
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await initSession(baseUrl);
|
|
140
|
+
await vi.advanceTimersByTimeAsync(1001);
|
|
141
|
+
expect(errors).toHaveLength(1);
|
|
142
|
+
expect((errors[0] as Error).message).toBe('flush rejected');
|
|
143
|
+
} finally {
|
|
144
|
+
await closeServer(server);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('bounded-by-default lifecycle (#107)', () => {
|
|
150
|
+
describe('default TTL', () => {
|
|
151
|
+
beforeEach(() => vi.useFakeTimers());
|
|
152
|
+
afterEach(() => vi.useRealTimers());
|
|
153
|
+
|
|
154
|
+
it('closes a session after the default 30 minutes when sessionTtlMs is omitted', async () => {
|
|
155
|
+
const closed: string[] = [];
|
|
156
|
+
const server = await startHttpProxy(
|
|
157
|
+
makeMockBackend(),
|
|
158
|
+
{ name: 'test-server' },
|
|
159
|
+
{ port: 0, path: '/mcp', onSessionClosed: (id) => closed.push(id) },
|
|
160
|
+
);
|
|
161
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const sessionId = await initSession(baseUrl);
|
|
165
|
+
|
|
166
|
+
// Just under the default: still alive.
|
|
167
|
+
await vi.advanceTimersByTimeAsync(30 * 60 * 1000 - 1);
|
|
168
|
+
expect(closed).toEqual([]);
|
|
169
|
+
|
|
170
|
+
await vi.advanceTimersByTimeAsync(2);
|
|
171
|
+
expect(closed).toEqual([sessionId]);
|
|
172
|
+
} finally {
|
|
173
|
+
await closeServer(server);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('keeps a session alive past the default TTL when sessionTtlMs is Infinity', async () => {
|
|
178
|
+
const closed: string[] = [];
|
|
179
|
+
const server = await startHttpProxy(
|
|
180
|
+
makeMockBackend(),
|
|
181
|
+
{ name: 'test-server' },
|
|
182
|
+
{
|
|
183
|
+
port: 0,
|
|
184
|
+
path: '/mcp',
|
|
185
|
+
sessionTtlMs: Infinity,
|
|
186
|
+
onSessionClosed: (id) => closed.push(id),
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const sessionId = await initSession(baseUrl);
|
|
193
|
+
|
|
194
|
+
await vi.advanceTimersByTimeAsync(60 * 60 * 1000);
|
|
195
|
+
expect(closed).toEqual([]);
|
|
196
|
+
|
|
197
|
+
const after = await postOnFreshConnection(
|
|
198
|
+
`${baseUrl}/mcp`,
|
|
199
|
+
{ ...MCP_HEADERS, 'mcp-session-id': sessionId },
|
|
200
|
+
JSON.stringify({
|
|
201
|
+
jsonrpc: '2.0',
|
|
202
|
+
id: 2,
|
|
203
|
+
method: 'tools/list',
|
|
204
|
+
params: {},
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
expect(after).not.toBe(404);
|
|
208
|
+
} finally {
|
|
209
|
+
await closeServer(server);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('option validation', () => {
|
|
215
|
+
// The default cap of 1000 is not exercised end to end: opening 1001
|
|
216
|
+
// real sessions costs more than the assertion is worth. `maxSessions: 1`
|
|
217
|
+
// above covers the enforcement path the default feeds.
|
|
218
|
+
it.each([
|
|
219
|
+
['maxSessions', -1],
|
|
220
|
+
['maxSessions', Number.NaN],
|
|
221
|
+
['sessionTtlMs', 0],
|
|
222
|
+
['sessionTtlMs', -1],
|
|
223
|
+
['sessionTtlMs', Number.NaN],
|
|
224
|
+
])('rejects %s: %s at startup', (option, value) => {
|
|
225
|
+
expect(() =>
|
|
226
|
+
startHttpProxy(
|
|
227
|
+
makeMockBackend(),
|
|
228
|
+
{ name: 'test-server' },
|
|
229
|
+
{ port: 0, path: '/mcp', [option]: value },
|
|
230
|
+
),
|
|
231
|
+
).toThrow(new RegExp(`mcpose: ${option}`));
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it.each([2_147_483_648, 30 * 24 * 60 * 60 * 1000])(
|
|
235
|
+
'rejects finite session TTLs above the Node timer limit: %s',
|
|
236
|
+
(sessionTtlMs) => {
|
|
237
|
+
expect(() =>
|
|
238
|
+
startHttpProxy(
|
|
239
|
+
makeMockBackend(),
|
|
240
|
+
{ name: 'test-server' },
|
|
241
|
+
{ port: 0, path: '/mcp', sessionTtlMs },
|
|
242
|
+
),
|
|
243
|
+
).toThrow(/sessionTtlMs must be <= 2147483647/);
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
it('accepts the maximum finite session TTL', async () => {
|
|
248
|
+
const server = await startHttpProxy(
|
|
249
|
+
makeMockBackend(),
|
|
250
|
+
{ name: 'test-server' },
|
|
251
|
+
{ port: 0, path: '/mcp', sessionTtlMs: 2_147_483_647 },
|
|
252
|
+
);
|
|
253
|
+
try {
|
|
254
|
+
expect(server.listening).toBe(true);
|
|
255
|
+
} finally {
|
|
256
|
+
await closeServer(server);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('accepts maxSessions: Infinity', async () => {
|
|
261
|
+
const server = await startHttpProxy(
|
|
262
|
+
makeMockBackend(),
|
|
263
|
+
{ name: 'test-server' },
|
|
264
|
+
{ port: 0, path: '/mcp', maxSessions: Infinity },
|
|
265
|
+
);
|
|
266
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
267
|
+
try {
|
|
268
|
+
await initSession(baseUrl);
|
|
269
|
+
} finally {
|
|
270
|
+
await closeServer(server);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe('client DELETE', () => {
|
|
277
|
+
it('fires onSessionClosed once and clears the TTL timer', async () => {
|
|
278
|
+
const closed: string[] = [];
|
|
279
|
+
const server = await startHttpProxy(
|
|
280
|
+
makeMockBackend(),
|
|
281
|
+
{ name: 'test-server' },
|
|
282
|
+
{
|
|
283
|
+
port: 0,
|
|
284
|
+
path: '/mcp',
|
|
285
|
+
sessionTtlMs: 60_000,
|
|
286
|
+
onSessionClosed: (id) => closed.push(id),
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const sessionId = await initSession(baseUrl);
|
|
293
|
+
|
|
294
|
+
const del = await fetch(`${baseUrl}/mcp`, {
|
|
295
|
+
method: 'DELETE',
|
|
296
|
+
headers: { ...MCP_HEADERS, 'mcp-session-id': sessionId },
|
|
297
|
+
});
|
|
298
|
+
expect(del.status).toBeLessThan(500);
|
|
299
|
+
expect(closed).toEqual([sessionId]);
|
|
300
|
+
} finally {
|
|
301
|
+
await closeServer(server);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe('session-less requests', () => {
|
|
307
|
+
it('rejects a session-less GET with 400 without creating anything', async () => {
|
|
308
|
+
const server = await startHttpProxy(
|
|
309
|
+
makeMockBackend(),
|
|
310
|
+
{ name: 'test-server' },
|
|
311
|
+
{ port: 0, path: '/mcp' },
|
|
312
|
+
);
|
|
313
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
314
|
+
try {
|
|
315
|
+
const res = await fetch(`${baseUrl}/mcp`, { method: 'GET' });
|
|
316
|
+
expect(res.status).toBe(400);
|
|
317
|
+
} finally {
|
|
318
|
+
await closeServer(server);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('rejects a session-less DELETE with 400', async () => {
|
|
323
|
+
const server = await startHttpProxy(
|
|
324
|
+
makeMockBackend(),
|
|
325
|
+
{ name: 'test-server' },
|
|
326
|
+
{ port: 0, path: '/mcp' },
|
|
327
|
+
);
|
|
328
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
329
|
+
try {
|
|
330
|
+
const res = await fetch(`${baseUrl}/mcp`, { method: 'DELETE' });
|
|
331
|
+
expect(res.status).toBe(400);
|
|
332
|
+
} finally {
|
|
333
|
+
await closeServer(server);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('does not leak a session for a session-less non-initialize POST', async () => {
|
|
338
|
+
const server = await startHttpProxy(
|
|
339
|
+
makeMockBackend(),
|
|
340
|
+
{ name: 'test-server' },
|
|
341
|
+
{ port: 0, path: '/mcp', maxSessions: 1 },
|
|
342
|
+
);
|
|
343
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
344
|
+
try {
|
|
345
|
+
// Non-initialize body without a session id — transport rejects it,
|
|
346
|
+
// and no session slot may be consumed.
|
|
347
|
+
const bad = await fetch(`${baseUrl}/mcp`, {
|
|
348
|
+
method: 'POST',
|
|
349
|
+
headers: MCP_HEADERS,
|
|
350
|
+
body: JSON.stringify({
|
|
351
|
+
jsonrpc: '2.0',
|
|
352
|
+
id: 1,
|
|
353
|
+
method: 'tools/list',
|
|
354
|
+
params: {},
|
|
355
|
+
}),
|
|
356
|
+
});
|
|
357
|
+
expect(bad.status).toBeGreaterThanOrEqual(400);
|
|
358
|
+
|
|
359
|
+
// The single session slot is still available.
|
|
360
|
+
await initSession(baseUrl);
|
|
361
|
+
} finally {
|
|
362
|
+
await closeServer(server);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe('maxSessions under concurrency', () => {
|
|
368
|
+
it('never overshoots the cap with concurrent initializes', async () => {
|
|
369
|
+
const maxSessions = 3;
|
|
370
|
+
const server = await startHttpProxy(
|
|
371
|
+
makeMockBackend(),
|
|
372
|
+
{ name: 'test-server' },
|
|
373
|
+
{
|
|
374
|
+
port: 0,
|
|
375
|
+
path: '/mcp',
|
|
376
|
+
maxSessions,
|
|
377
|
+
resolveIdentity: async () => {
|
|
378
|
+
// Hold every initialize in-flight long enough that all 10
|
|
379
|
+
// requests overlap.
|
|
380
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
381
|
+
return {
|
|
382
|
+
sub: 'user',
|
|
383
|
+
type: 'human' as const,
|
|
384
|
+
roles: [],
|
|
385
|
+
claims: {},
|
|
386
|
+
resolvedAt: new Date().toISOString(),
|
|
387
|
+
source: 'custom' as const,
|
|
388
|
+
};
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
);
|
|
392
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
const results = await Promise.all(
|
|
396
|
+
Array.from({ length: 10 }, () =>
|
|
397
|
+
fetch(`${baseUrl}/mcp`, {
|
|
398
|
+
method: 'POST',
|
|
399
|
+
headers: MCP_HEADERS,
|
|
400
|
+
body: INIT_BODY,
|
|
401
|
+
}),
|
|
402
|
+
),
|
|
403
|
+
);
|
|
404
|
+
const accepted = results.filter((r) => r.status !== 503);
|
|
405
|
+
const rejected = results.filter((r) => r.status === 503);
|
|
406
|
+
expect(accepted.length).toBeLessThanOrEqual(maxSessions);
|
|
407
|
+
expect(rejected.length).toBeGreaterThanOrEqual(10 - maxSessions);
|
|
408
|
+
} finally {
|
|
409
|
+
await closeServer(server);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('returns a JSON body with rejectionReason SESSION_LIMIT on 503', async () => {
|
|
414
|
+
const server = await startHttpProxy(
|
|
415
|
+
makeMockBackend(),
|
|
416
|
+
{ name: 'test-server' },
|
|
417
|
+
{ port: 0, path: '/mcp', maxSessions: 0 },
|
|
418
|
+
);
|
|
419
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
420
|
+
try {
|
|
421
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
422
|
+
method: 'POST',
|
|
423
|
+
headers: MCP_HEADERS,
|
|
424
|
+
body: INIT_BODY,
|
|
425
|
+
});
|
|
426
|
+
expect(res.status).toBe(503);
|
|
427
|
+
const body = (await res.json()) as {
|
|
428
|
+
error: { data: { rejectionReason: string } };
|
|
429
|
+
};
|
|
430
|
+
expect(body.error.data.rejectionReason).toBe('SESSION_LIMIT');
|
|
431
|
+
} finally {
|
|
432
|
+
await closeServer(server);
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
describe('validateSession', () => {
|
|
438
|
+
it('rejects a routed request with 401 when validateSession returns false', async () => {
|
|
439
|
+
let allow = true;
|
|
440
|
+
const server = await startHttpProxy(
|
|
441
|
+
makeMockBackend(),
|
|
442
|
+
{ name: 'test-server' },
|
|
443
|
+
{
|
|
444
|
+
port: 0,
|
|
445
|
+
path: '/mcp',
|
|
446
|
+
validateSession: () => allow,
|
|
447
|
+
},
|
|
448
|
+
);
|
|
449
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
450
|
+
|
|
451
|
+
try {
|
|
452
|
+
const sessionId = await initSession(baseUrl);
|
|
453
|
+
const listBody = JSON.stringify({
|
|
454
|
+
jsonrpc: '2.0',
|
|
455
|
+
id: 2,
|
|
456
|
+
method: 'tools/list',
|
|
457
|
+
params: {},
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const ok = await fetch(`${baseUrl}/mcp`, {
|
|
461
|
+
method: 'POST',
|
|
462
|
+
headers: { ...MCP_HEADERS, 'mcp-session-id': sessionId },
|
|
463
|
+
body: listBody,
|
|
464
|
+
});
|
|
465
|
+
expect(ok.status).not.toBe(401);
|
|
466
|
+
|
|
467
|
+
allow = false;
|
|
468
|
+
const denied = await fetch(`${baseUrl}/mcp`, {
|
|
469
|
+
method: 'POST',
|
|
470
|
+
headers: { ...MCP_HEADERS, 'mcp-session-id': sessionId },
|
|
471
|
+
body: listBody,
|
|
472
|
+
});
|
|
473
|
+
expect(denied.status).toBe(401);
|
|
474
|
+
} finally {
|
|
475
|
+
await closeServer(server);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('treats a throwing validateSession as rejection', async () => {
|
|
480
|
+
const server = await startHttpProxy(
|
|
481
|
+
makeMockBackend(),
|
|
482
|
+
{ name: 'test-server' },
|
|
483
|
+
{
|
|
484
|
+
port: 0,
|
|
485
|
+
path: '/mcp',
|
|
486
|
+
validateSession: () => {
|
|
487
|
+
throw new Error('token expired');
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
);
|
|
491
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
const sessionId = await initSession(baseUrl);
|
|
495
|
+
const res = await fetch(`${baseUrl}/mcp`, {
|
|
496
|
+
method: 'POST',
|
|
497
|
+
headers: { ...MCP_HEADERS, 'mcp-session-id': sessionId },
|
|
498
|
+
body: JSON.stringify({
|
|
499
|
+
jsonrpc: '2.0',
|
|
500
|
+
id: 2,
|
|
501
|
+
method: 'tools/list',
|
|
502
|
+
params: {},
|
|
503
|
+
}),
|
|
504
|
+
});
|
|
505
|
+
expect(res.status).toBe(401);
|
|
506
|
+
} finally {
|
|
507
|
+
await closeServer(server);
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
describe('header redaction', () => {
|
|
513
|
+
it('strips credential headers from the middleware context', async () => {
|
|
514
|
+
let seenHeaders: Readonly<Record<string, string>> | undefined;
|
|
515
|
+
const server = await startHttpProxy(
|
|
516
|
+
makeMockBackend(),
|
|
517
|
+
{
|
|
518
|
+
name: 'test-server',
|
|
519
|
+
listToolsMiddleware: [
|
|
520
|
+
async (req, next, context) => {
|
|
521
|
+
seenHeaders = context.headers;
|
|
522
|
+
return next(req);
|
|
523
|
+
},
|
|
524
|
+
],
|
|
525
|
+
},
|
|
526
|
+
{ port: 0, path: '/mcp' },
|
|
527
|
+
);
|
|
528
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
529
|
+
|
|
530
|
+
try {
|
|
531
|
+
const sessionId = await initSession(baseUrl);
|
|
532
|
+
await fetch(`${baseUrl}/mcp`, {
|
|
533
|
+
method: 'POST',
|
|
534
|
+
headers: {
|
|
535
|
+
...MCP_HEADERS,
|
|
536
|
+
'mcp-session-id': sessionId,
|
|
537
|
+
authorization: 'Bearer secret-token',
|
|
538
|
+
cookie: 'session=abc',
|
|
539
|
+
'x-api-key': 'key-123',
|
|
540
|
+
'x-tenant-id': 'bank-42',
|
|
541
|
+
},
|
|
542
|
+
body: JSON.stringify({
|
|
543
|
+
jsonrpc: '2.0',
|
|
544
|
+
id: 2,
|
|
545
|
+
method: 'tools/list',
|
|
546
|
+
params: {},
|
|
547
|
+
}),
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
expect(seenHeaders).toBeDefined();
|
|
551
|
+
expect(seenHeaders).not.toHaveProperty('authorization');
|
|
552
|
+
expect(seenHeaders).not.toHaveProperty('cookie');
|
|
553
|
+
expect(seenHeaders).not.toHaveProperty('x-api-key');
|
|
554
|
+
expect(seenHeaders!['x-tenant-id']).toBe('bank-42');
|
|
555
|
+
} finally {
|
|
556
|
+
await closeServer(server);
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
describe('server shutdown', () => {
|
|
562
|
+
it('flushes onSessionClosed for active sessions on close()', async () => {
|
|
563
|
+
const closed: string[] = [];
|
|
564
|
+
const server = await startHttpProxy(
|
|
565
|
+
makeMockBackend(),
|
|
566
|
+
{ name: 'test-server' },
|
|
567
|
+
{
|
|
568
|
+
port: 0,
|
|
569
|
+
path: '/mcp',
|
|
570
|
+
onSessionClosed: (id) => closed.push(id),
|
|
571
|
+
},
|
|
572
|
+
);
|
|
573
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
574
|
+
|
|
575
|
+
const sessionId = await initSession(baseUrl);
|
|
576
|
+
await closeServer(server);
|
|
577
|
+
|
|
578
|
+
expect(closed).toEqual([sessionId]);
|
|
579
|
+
expect(server.listening).toBe(false);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('awaits an async onSessionClosed before the close callback runs (#176)', async () => {
|
|
583
|
+
let release!: () => void;
|
|
584
|
+
const pending = new Promise<void>((resolve) => (release = resolve));
|
|
585
|
+
let finished = false;
|
|
586
|
+
const server = await startHttpProxy(
|
|
587
|
+
makeMockBackend(),
|
|
588
|
+
{ name: 'test-server' },
|
|
589
|
+
{
|
|
590
|
+
port: 0,
|
|
591
|
+
path: '/mcp',
|
|
592
|
+
onSessionClosed: async () => {
|
|
593
|
+
await pending;
|
|
594
|
+
finished = true;
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
);
|
|
598
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
599
|
+
|
|
600
|
+
await initSession(baseUrl);
|
|
601
|
+
let closeCallbackRan = false;
|
|
602
|
+
const closing = closeServer(server).then(() => {
|
|
603
|
+
closeCallbackRan = true;
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
await new Promise((r) => setImmediate(r));
|
|
607
|
+
expect(finished).toBe(false);
|
|
608
|
+
expect(closeCallbackRan).toBe(false);
|
|
609
|
+
|
|
610
|
+
release();
|
|
611
|
+
await closing;
|
|
612
|
+
expect(finished).toBe(true);
|
|
613
|
+
expect(server.listening).toBe(false);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it('routes a rejected onSessionClosed to onError and still closes (#176)', async () => {
|
|
617
|
+
const errors: unknown[] = [];
|
|
618
|
+
const server = await startHttpProxy(
|
|
619
|
+
makeMockBackend(),
|
|
620
|
+
{ name: 'test-server' },
|
|
621
|
+
{
|
|
622
|
+
port: 0,
|
|
623
|
+
path: '/mcp',
|
|
624
|
+
onError: (err) => errors.push(err),
|
|
625
|
+
onSessionClosed: () => Promise.reject(new Error('flush failed')),
|
|
626
|
+
},
|
|
627
|
+
);
|
|
628
|
+
const baseUrl = `http://localhost:${getPort(server)}`;
|
|
629
|
+
|
|
630
|
+
await initSession(baseUrl);
|
|
631
|
+
await closeServer(server);
|
|
632
|
+
|
|
633
|
+
expect(errors).toHaveLength(1);
|
|
634
|
+
expect((errors[0] as Error).message).toBe('flush failed');
|
|
635
|
+
expect(server.listening).toBe(false);
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
});
|