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.
Files changed (101) hide show
  1. package/README.md +461 -62
  2. package/dist/backendClient.d.ts +1 -1
  3. package/dist/backendClient.d.ts.map +1 -1
  4. package/dist/backendClient.js +6 -1
  5. package/dist/backendClient.js.map +1 -1
  6. package/dist/core.d.ts +309 -35
  7. package/dist/core.d.ts.map +1 -1
  8. package/dist/core.js +1023 -186
  9. package/dist/core.js.map +1 -1
  10. package/dist/delegation.d.ts +89 -0
  11. package/dist/delegation.d.ts.map +1 -0
  12. package/dist/delegation.js +175 -0
  13. package/dist/delegation.js.map +1 -0
  14. package/dist/eventStore.d.ts +12 -0
  15. package/dist/eventStore.d.ts.map +1 -1
  16. package/dist/eventStore.js +43 -5
  17. package/dist/eventStore.js.map +1 -1
  18. package/dist/hiddenTools.d.ts +42 -0
  19. package/dist/hiddenTools.d.ts.map +1 -0
  20. package/dist/hiddenTools.js +53 -0
  21. package/dist/hiddenTools.js.map +1 -0
  22. package/dist/index.d.ts +15 -6
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +7 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/mesh.d.ts +82 -0
  27. package/dist/mesh.d.ts.map +1 -0
  28. package/dist/mesh.js +141 -0
  29. package/dist/mesh.js.map +1 -0
  30. package/dist/middleware.d.ts +13 -0
  31. package/dist/middleware.d.ts.map +1 -1
  32. package/dist/middleware.js +27 -3
  33. package/dist/middleware.js.map +1 -1
  34. package/dist/proxyContext.d.ts +65 -5
  35. package/dist/proxyContext.d.ts.map +1 -1
  36. package/dist/proxyContext.js +39 -4
  37. package/dist/proxyContext.js.map +1 -1
  38. package/dist/rejection.d.ts +7 -1
  39. package/dist/rejection.d.ts.map +1 -1
  40. package/dist/rejection.js +24 -1
  41. package/dist/rejection.js.map +1 -1
  42. package/dist/sanitizeCatalog.d.ts +37 -0
  43. package/dist/sanitizeCatalog.d.ts.map +1 -0
  44. package/dist/sanitizeCatalog.js +92 -0
  45. package/dist/sanitizeCatalog.js.map +1 -0
  46. package/dist/sessionRegistry.d.ts +79 -0
  47. package/dist/sessionRegistry.d.ts.map +1 -0
  48. package/dist/sessionRegistry.js +83 -0
  49. package/dist/sessionRegistry.js.map +1 -0
  50. package/dist/telemetry.d.ts +29 -1
  51. package/dist/telemetry.d.ts.map +1 -1
  52. package/dist/testing.d.ts +18 -2
  53. package/dist/testing.d.ts.map +1 -1
  54. package/dist/testing.js +30 -8
  55. package/dist/testing.js.map +1 -1
  56. package/dist/version.d.ts +7 -0
  57. package/dist/version.d.ts.map +1 -0
  58. package/dist/version.js +7 -0
  59. package/dist/version.js.map +1 -0
  60. package/package.json +22 -8
  61. package/src/__tests__/_helpers.ts +64 -0
  62. package/src/__tests__/backendClient.test.ts +198 -0
  63. package/src/__tests__/core.delegation.test.ts +964 -0
  64. package/src/__tests__/core.edge.test.ts +314 -0
  65. package/src/__tests__/core.hiddenTools.predicate.test.ts +236 -0
  66. package/src/__tests__/core.localTools.test.ts +314 -0
  67. package/src/__tests__/core.mesh.test.ts +1182 -0
  68. package/src/__tests__/core.stripRequestMeta.test.ts +226 -0
  69. package/src/__tests__/core.stripResultMeta.test.ts +217 -0
  70. package/src/__tests__/core.test.ts +1105 -0
  71. package/src/__tests__/core.toolCallTelemetry.test.ts +157 -0
  72. package/src/__tests__/eventStore.test.ts +141 -0
  73. package/src/__tests__/hasToolContent.test.ts +63 -0
  74. package/src/__tests__/mapToolResult.test.ts +101 -0
  75. package/src/__tests__/middleware.edge.test.ts +126 -0
  76. package/src/__tests__/middleware.test.ts +233 -0
  77. package/src/__tests__/proxyContext.test.ts +140 -0
  78. package/src/__tests__/sanitizeCatalog.test.ts +380 -0
  79. package/src/__tests__/startHttpProxy.lifecycle.test.ts +675 -0
  80. package/src/__tests__/startHttpProxy.registry.test.ts +409 -0
  81. package/src/__tests__/startHttpProxy.replay.test.ts +116 -0
  82. package/src/__tests__/startHttpProxy.security.test.ts +288 -0
  83. package/src/__tests__/startHttpProxy.test.ts +1065 -0
  84. package/src/__tests__/testing.test.ts +250 -0
  85. package/src/__tests__/version.test.ts +12 -0
  86. package/src/backendClient.ts +83 -0
  87. package/src/core.ts +2129 -0
  88. package/src/delegation.ts +224 -0
  89. package/src/eventStore.ts +106 -0
  90. package/src/hiddenTools.ts +84 -0
  91. package/src/identity.ts +15 -0
  92. package/src/index.ts +63 -0
  93. package/src/mesh.ts +204 -0
  94. package/src/middleware.ts +101 -0
  95. package/src/proxyContext.ts +108 -0
  96. package/src/rejection.ts +62 -0
  97. package/src/sanitizeCatalog.ts +126 -0
  98. package/src/sessionRegistry.ts +142 -0
  99. package/src/telemetry.ts +46 -0
  100. package/src/testing.ts +174 -0
  101. package/src/version.ts +6 -0
@@ -0,0 +1,1105 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { runInNewContext } from 'node:vm';
5
+ import {
6
+ createProxyServer,
7
+ type ListToolsMiddleware,
8
+ type PromptMiddleware,
9
+ type ToolMiddleware,
10
+ } from '../core.js';
11
+ import type { BackendClient } from '../backendClient.js';
12
+ import { markPassThroughObserver } from '../middleware.js';
13
+ import type { ProxyContext } from '../proxyContext.js';
14
+ import type { ToolCallTelemetryEvent } from '../telemetry.js';
15
+ import {
16
+ ErrorCode,
17
+ PromptListChangedNotificationSchema,
18
+ ToolListChangedNotificationSchema,
19
+ type ServerCapabilities,
20
+ } from '@modelcontextprotocol/sdk/types.js';
21
+
22
+ /** Independent copy of our own package version, read straight from disk. */
23
+ const pkgVersion = (
24
+ JSON.parse(
25
+ readFileSync(
26
+ fileURLToPath(new URL('../../package.json', import.meta.url)),
27
+ 'utf8',
28
+ ),
29
+ ) as { version: string }
30
+ ).version;
31
+
32
+ // ── Test helpers ────────────────────────────────────────────────────────────
33
+
34
+ function makeMockBackend(): BackendClient {
35
+ const notificationHandlers = new Map<string, () => Promise<void>>();
36
+
37
+ return {
38
+ getServerCapabilities: vi.fn<() => ServerCapabilities>().mockReturnValue({
39
+ tools: {},
40
+ resources: {},
41
+ prompts: {},
42
+ }),
43
+ listTools: vi.fn().mockResolvedValue({
44
+ tools: [
45
+ {
46
+ name: 'normal_tool',
47
+ description: 'Normal',
48
+ inputSchema: { type: 'object', properties: {} },
49
+ },
50
+ {
51
+ name: 'sensitive_tool',
52
+ description: 'Sensitive',
53
+ inputSchema: { type: 'object', properties: {} },
54
+ },
55
+ {
56
+ name: 'pass_tool',
57
+ description: 'Pass-through',
58
+ inputSchema: { type: 'object', properties: {} },
59
+ },
60
+ ],
61
+ }),
62
+ callTool: vi.fn().mockResolvedValue({
63
+ content: [{ type: 'text', text: 'raw upstream response' }],
64
+ }),
65
+ listResources: vi.fn().mockResolvedValue({
66
+ resources: [
67
+ {
68
+ name: 'Normal Resource',
69
+ uri: 'res://normal',
70
+ mimeType: 'text/plain',
71
+ },
72
+ {
73
+ name: 'Hidden Resource',
74
+ uri: 'res://hidden',
75
+ mimeType: 'text/plain',
76
+ },
77
+ { name: 'Pass Resource', uri: 'res://pass', mimeType: 'text/plain' },
78
+ ],
79
+ }),
80
+ readResource: vi.fn().mockResolvedValue({
81
+ contents: [
82
+ { uri: 'res://normal', text: 'raw content', mimeType: 'text/plain' },
83
+ ],
84
+ }),
85
+ listPrompts: vi.fn().mockResolvedValue({ prompts: [] }),
86
+ getPrompt: vi.fn().mockResolvedValue({ messages: [] }),
87
+ setNotificationHandler: vi.fn((schema, handler) => {
88
+ const method =
89
+ schema === ToolListChangedNotificationSchema
90
+ ? 'notifications/tools/list_changed'
91
+ : schema === PromptListChangedNotificationSchema
92
+ ? 'notifications/prompts/list_changed'
93
+ : 'notifications/resources/list_changed';
94
+
95
+ notificationHandlers.set(method, handler as () => Promise<void>);
96
+ }),
97
+ removeNotificationHandler: vi.fn(),
98
+ close: vi.fn().mockResolvedValue(undefined),
99
+ __notificationHandlers: notificationHandlers,
100
+ } as unknown as BackendClient;
101
+ }
102
+
103
+ /** Invokes a registered handler directly via `_requestHandlers` — no transport needed. */
104
+ async function invokeHandler(
105
+ server: ReturnType<typeof createProxyServer>,
106
+ method: string,
107
+ params: Record<string, unknown> = {},
108
+ extra: object = {},
109
+ ) {
110
+ const { _requestHandlers: handlers } = server as unknown as {
111
+ _requestHandlers: Map<
112
+ string,
113
+ (
114
+ req: { method: string; params: Record<string, unknown> },
115
+ extra: object,
116
+ ) => Promise<unknown>
117
+ >;
118
+ };
119
+ const handler = handlers.get(method);
120
+ if (!handler) throw new Error(`No handler registered for method: ${method}`);
121
+ return handler({ method, params }, extra);
122
+ }
123
+
124
+ // ── Tests ───────────────────────────────────────────────────────────────────
125
+
126
+ describe('createProxyServer() — hiddenTools', () => {
127
+ it('filters hidden tools out of list_tools response', async () => {
128
+ const backend = makeMockBackend();
129
+ const server = createProxyServer(backend, {
130
+ hiddenTools: ['sensitive_tool'],
131
+ name: 'test-server',
132
+ });
133
+
134
+ const result = (await invokeHandler(server, 'tools/list')) as {
135
+ tools: { name: string }[];
136
+ };
137
+
138
+ const names = result.tools.map((t) => t.name);
139
+ expect(names).not.toContain('sensitive_tool');
140
+ expect(names).toContain('normal_tool');
141
+ expect(names).toContain('pass_tool');
142
+ });
143
+
144
+ it('throws MethodNotFound when a hidden tool is called directly', async () => {
145
+ const backend = makeMockBackend();
146
+ const server = createProxyServer(backend, {
147
+ hiddenTools: ['sensitive_tool'],
148
+ name: 'test-server',
149
+ });
150
+
151
+ await expect(
152
+ invokeHandler(server, 'tools/call', {
153
+ name: 'sensitive_tool',
154
+ arguments: {},
155
+ }),
156
+ ).rejects.toMatchObject({ code: ErrorCode.MethodNotFound });
157
+ });
158
+
159
+ it('includes TOOL_HIDDEN rejection reason in error data', async () => {
160
+ const backend = makeMockBackend();
161
+ const server = createProxyServer(backend, {
162
+ hiddenTools: ['sensitive_tool'],
163
+ name: 'test-server',
164
+ });
165
+
166
+ await expect(
167
+ invokeHandler(server, 'tools/call', {
168
+ name: 'sensitive_tool',
169
+ arguments: {},
170
+ }),
171
+ ).rejects.toMatchObject({ data: { rejectionReason: 'TOOL_HIDDEN' } });
172
+ });
173
+
174
+ it('returns full tool list when hiddenTools is empty', async () => {
175
+ const backend = makeMockBackend();
176
+ const server = createProxyServer(backend, {
177
+ hiddenTools: [],
178
+ name: 'test-server',
179
+ });
180
+
181
+ const result = (await invokeHandler(server, 'tools/list')) as {
182
+ tools: { name: string }[];
183
+ };
184
+ expect(result.tools).toHaveLength(3);
185
+ });
186
+
187
+ it('still filters hidden tools after listToolsMiddleware mutates the response', async () => {
188
+ const backend = makeMockBackend();
189
+ const restoreHidden: ListToolsMiddleware = async (_req, next) => {
190
+ const result = await next({ method: 'tools/list', params: {} });
191
+ return {
192
+ ...result,
193
+ tools: [
194
+ ...result.tools,
195
+ {
196
+ name: 'sensitive_tool',
197
+ description: 'Reintroduced',
198
+ inputSchema: { type: 'object', properties: {} },
199
+ },
200
+ ],
201
+ };
202
+ };
203
+ const server = createProxyServer(backend, {
204
+ hiddenTools: ['sensitive_tool'],
205
+ listToolsMiddleware: [restoreHidden],
206
+ name: 'test-server',
207
+ });
208
+
209
+ const result = (await invokeHandler(server, 'tools/list')) as {
210
+ tools: { name: string }[];
211
+ };
212
+
213
+ expect(result.tools.map((tool) => tool.name)).not.toContain(
214
+ 'sensitive_tool',
215
+ );
216
+ });
217
+ });
218
+
219
+ describe('createProxyServer() — listToolsMiddleware', () => {
220
+ it('runs in response-processing order like other ProxyOptions middleware arrays', async () => {
221
+ const backend = makeMockBackend();
222
+ const order: string[] = [];
223
+
224
+ const descriptionMW: ListToolsMiddleware = async (req, next) => {
225
+ order.push('description-enter');
226
+ const result = await next(req);
227
+ order.push('description-exit');
228
+ return {
229
+ ...result,
230
+ tools: result.tools.map((tool) => ({
231
+ ...tool,
232
+ description: `${tool.description ?? ''} / described`,
233
+ })),
234
+ };
235
+ };
236
+
237
+ const suffixMW: ListToolsMiddleware = async (req, next) => {
238
+ order.push('suffix-enter');
239
+ const result = await next(req);
240
+ order.push('suffix-exit');
241
+ return {
242
+ ...result,
243
+ tools: result.tools.map((tool) => ({
244
+ ...tool,
245
+ description: `${tool.description ?? ''} / suffixed`,
246
+ })),
247
+ };
248
+ };
249
+
250
+ const server = createProxyServer(backend, {
251
+ listToolsMiddleware: [descriptionMW, suffixMW],
252
+ name: 'test-server',
253
+ });
254
+
255
+ const result = (await invokeHandler(server, 'tools/list')) as {
256
+ tools: { description?: string }[];
257
+ };
258
+
259
+ expect(order).toEqual([
260
+ 'suffix-enter',
261
+ 'description-enter',
262
+ 'description-exit',
263
+ 'suffix-exit',
264
+ ]);
265
+ expect(result.tools[0]?.description).toBe('Normal / described / suffixed');
266
+ });
267
+
268
+ it('passes stdio ProxyContext to listToolsMiddleware by default', async () => {
269
+ const backend = makeMockBackend();
270
+ let seenContext: ProxyContext | undefined;
271
+
272
+ const captureContext: ListToolsMiddleware = async (req, next, context) => {
273
+ seenContext = context;
274
+ return next(req);
275
+ };
276
+
277
+ const server = createProxyServer(backend, {
278
+ listToolsMiddleware: [captureContext],
279
+ name: 'test-server',
280
+ });
281
+
282
+ await invokeHandler(server, 'tools/list');
283
+
284
+ expect(seenContext?.transport).toBe('stdio');
285
+ expect(seenContext?.requestId).toEqual(expect.any(String));
286
+ expect(seenContext?.sessionId).toBeUndefined();
287
+ expect(seenContext?.headers).toBeUndefined();
288
+ });
289
+ });
290
+
291
+ describe('createProxyServer() — passThroughTools', () => {
292
+ it('bypasses middleware for pass-through tools (middleware spy not called)', async () => {
293
+ const backend = makeMockBackend();
294
+ const middlewareSpy = vi.fn<ToolMiddleware>((req, next) => next(req));
295
+ const server = createProxyServer(backend, {
296
+ passThroughTools: ['pass_tool'],
297
+ toolMiddleware: [middlewareSpy],
298
+ name: 'test-server',
299
+ });
300
+
301
+ await invokeHandler(server, 'tools/call', {
302
+ name: 'pass_tool',
303
+ arguments: {},
304
+ });
305
+
306
+ expect(middlewareSpy).not.toHaveBeenCalled();
307
+ });
308
+
309
+ it('returns the raw upstream response for pass-through tools', async () => {
310
+ const backend = makeMockBackend();
311
+ const server = createProxyServer(backend, {
312
+ passThroughTools: ['pass_tool'],
313
+ name: 'test-server',
314
+ });
315
+
316
+ const result = (await invokeHandler(server, 'tools/call', {
317
+ name: 'pass_tool',
318
+ arguments: {},
319
+ })) as { content: { type: string; text: string }[] };
320
+
321
+ expect(result.content[0]?.text).toBe('raw upstream response');
322
+ });
323
+
324
+ it('routes normal tools through the middleware pipeline', async () => {
325
+ const backend = makeMockBackend();
326
+ const middlewareSpy = vi.fn<ToolMiddleware>((req, next) => next(req));
327
+ const server = createProxyServer(backend, {
328
+ passThroughTools: ['pass_tool'],
329
+ toolMiddleware: [middlewareSpy],
330
+ name: 'test-server',
331
+ });
332
+
333
+ await invokeHandler(server, 'tools/call', {
334
+ name: 'normal_tool',
335
+ arguments: {},
336
+ });
337
+
338
+ expect(middlewareSpy).toHaveBeenCalledOnce();
339
+ });
340
+
341
+ it('passes stdio ProxyContext to tool middleware by default', async () => {
342
+ const backend = makeMockBackend();
343
+ let seenContext: ProxyContext | undefined;
344
+
345
+ const captureContext: ToolMiddleware = async (req, next, context) => {
346
+ seenContext = context;
347
+ return next(req);
348
+ };
349
+
350
+ const server = createProxyServer(backend, {
351
+ toolMiddleware: [captureContext],
352
+ name: 'test-server',
353
+ });
354
+
355
+ await invokeHandler(server, 'tools/call', {
356
+ name: 'normal_tool',
357
+ arguments: {},
358
+ });
359
+
360
+ expect(seenContext?.transport).toBe('stdio');
361
+ expect(seenContext?.requestId).toEqual(expect.any(String));
362
+ expect(seenContext?.sessionId).toBeUndefined();
363
+ expect(seenContext?.headers).toBeUndefined();
364
+ expect(seenContext?.proxy).toEqual({
365
+ name: 'test-server',
366
+ version: pkgVersion,
367
+ });
368
+ });
369
+
370
+ it('stamps a custom proxy identity onto the context', async () => {
371
+ const backend = makeMockBackend();
372
+ let seenContext: ProxyContext | undefined;
373
+
374
+ const captureContext: ToolMiddleware = async (req, next, context) => {
375
+ seenContext = context;
376
+ return next(req);
377
+ };
378
+
379
+ const server = createProxyServer(backend, {
380
+ toolMiddleware: [captureContext],
381
+ name: 'payments-proxy',
382
+ version: '9.9.9',
383
+ });
384
+
385
+ await invokeHandler(server, 'tools/call', {
386
+ name: 'normal_tool',
387
+ arguments: {},
388
+ });
389
+
390
+ expect(seenContext?.proxy).toEqual({
391
+ name: 'payments-proxy',
392
+ version: '9.9.9',
393
+ });
394
+ });
395
+ });
396
+
397
+ describe('createProxyServer() — hiddenResources', () => {
398
+ it('filters hidden resources out of list_resources response', async () => {
399
+ const backend = makeMockBackend();
400
+ const server = createProxyServer(backend, {
401
+ hiddenResources: ['res://hidden'],
402
+ name: 'test-server',
403
+ });
404
+
405
+ const result = (await invokeHandler(server, 'resources/list')) as {
406
+ resources: { uri: string }[];
407
+ };
408
+
409
+ const uris = result.resources.map((r) => r.uri);
410
+ expect(uris).not.toContain('res://hidden');
411
+ expect(uris).toContain('res://normal');
412
+ expect(uris).toContain('res://pass');
413
+ });
414
+
415
+ it('throws InvalidRequest when a hidden resource is read directly', async () => {
416
+ const backend = makeMockBackend();
417
+ const server = createProxyServer(backend, {
418
+ hiddenResources: ['res://hidden'],
419
+ name: 'test-server',
420
+ });
421
+
422
+ await expect(
423
+ invokeHandler(server, 'resources/read', { uri: 'res://hidden' }),
424
+ ).rejects.toMatchObject({ code: ErrorCode.InvalidRequest });
425
+ });
426
+
427
+ it('includes RESOURCE_HIDDEN rejection reason in error data', async () => {
428
+ const backend = makeMockBackend();
429
+ const server = createProxyServer(backend, {
430
+ hiddenResources: ['res://hidden'],
431
+ name: 'test-server',
432
+ });
433
+
434
+ await expect(
435
+ invokeHandler(server, 'resources/read', { uri: 'res://hidden' }),
436
+ ).rejects.toMatchObject({ data: { rejectionReason: 'RESOURCE_HIDDEN' } });
437
+ });
438
+
439
+ it('returns full resource list when hiddenResources is empty', async () => {
440
+ const backend = makeMockBackend();
441
+ const server = createProxyServer(backend, {
442
+ hiddenResources: [],
443
+ name: 'test-server',
444
+ });
445
+
446
+ const result = (await invokeHandler(server, 'resources/list')) as {
447
+ resources: { uri: string }[];
448
+ };
449
+ expect(result.resources).toHaveLength(3);
450
+ });
451
+ });
452
+
453
+ describe('createProxyServer() — passThroughResources', () => {
454
+ it('bypasses middleware for pass-through resources', async () => {
455
+ const backend = makeMockBackend();
456
+ const middlewareSpy = vi.fn(
457
+ (req: unknown, next: (r: unknown) => Promise<unknown>) => next(req),
458
+ );
459
+ const server = createProxyServer(backend, {
460
+ passThroughResources: ['res://pass'],
461
+ resourceMiddleware: [middlewareSpy as never],
462
+ name: 'test-server',
463
+ });
464
+
465
+ await invokeHandler(server, 'resources/read', { uri: 'res://pass' });
466
+
467
+ expect(middlewareSpy).not.toHaveBeenCalled();
468
+ });
469
+
470
+ it('returns the raw upstream response for pass-through resources', async () => {
471
+ const backend = makeMockBackend();
472
+ const server = createProxyServer(backend, {
473
+ passThroughResources: ['res://pass'],
474
+ name: 'test-server',
475
+ });
476
+
477
+ const result = (await invokeHandler(server, 'resources/read', {
478
+ uri: 'res://pass',
479
+ })) as { contents: { text: string }[] };
480
+
481
+ expect(result.contents[0]?.text).toBe('raw content');
482
+ });
483
+ });
484
+
485
+ describe('createProxyServer() — capability mirroring', () => {
486
+ it('advertises only upstream capabilities', async () => {
487
+ const backend = makeMockBackend();
488
+ vi.mocked(backend.getServerCapabilities).mockReturnValue({ tools: {} });
489
+ const server = createProxyServer(backend, { name: 'test-server' });
490
+
491
+ const result = (await invokeHandler(server, 'initialize', {
492
+ protocolVersion: '2025-03-26',
493
+ capabilities: {},
494
+ clientInfo: { name: 'test', version: '0.0.1' },
495
+ })) as { capabilities: ServerCapabilities };
496
+
497
+ expect(result.capabilities).toEqual({ tools: {} });
498
+ await expect(invokeHandler(server, 'prompts/list')).rejects.toThrow(
499
+ 'No handler registered for method: prompts/list',
500
+ );
501
+ // No prompts capability means no prompts/get handler either: the SDK
502
+ // refuses to register one, so there is no pipeline to reject inside
503
+ // and nothing for audit to record (ADR-0014).
504
+ await expect(invokeHandler(server, 'prompts/get')).rejects.toThrow(
505
+ 'No handler registered for method: prompts/get',
506
+ );
507
+ await expect(invokeHandler(server, 'resources/list')).rejects.toThrow(
508
+ 'No handler registered for method: resources/list',
509
+ );
510
+ });
511
+ });
512
+
513
+ describe('createProxyServer() — notification fanout', () => {
514
+ it('fans list-changed notifications to active proxy servers only', async () => {
515
+ const backend = makeMockBackend() as BackendClient & {
516
+ __notificationHandlers: Map<string, () => Promise<void>>;
517
+ };
518
+
519
+ vi.mocked(backend.getServerCapabilities).mockReturnValue({
520
+ tools: { listChanged: true },
521
+ });
522
+
523
+ const serverA = createProxyServer(backend, { name: 'test-server' });
524
+ const serverB = createProxyServer(backend, { name: 'test-server' });
525
+
526
+ const sendA = vi
527
+ .spyOn(serverA, 'sendToolListChanged')
528
+ .mockResolvedValue(undefined);
529
+ const sendB = vi
530
+ .spyOn(serverB, 'sendToolListChanged')
531
+ .mockResolvedValue(undefined);
532
+
533
+ await backend.__notificationHandlers.get(
534
+ 'notifications/tools/list_changed',
535
+ )?.();
536
+
537
+ expect(sendA).toHaveBeenCalledOnce();
538
+ expect(sendB).toHaveBeenCalledOnce();
539
+
540
+ serverA.onclose?.();
541
+ sendA.mockClear();
542
+ sendB.mockClear();
543
+
544
+ await backend.__notificationHandlers.get(
545
+ 'notifications/tools/list_changed',
546
+ )?.();
547
+
548
+ expect(sendA).not.toHaveBeenCalled();
549
+ expect(sendB).toHaveBeenCalledOnce();
550
+ });
551
+ });
552
+
553
+ describe('createProxyServer() — request options', () => {
554
+ it('forwards signal and progress for tool calls', async () => {
555
+ const backend = makeMockBackend();
556
+ const sendNotification = vi.fn().mockResolvedValue(undefined);
557
+ const controller = new AbortController();
558
+
559
+ vi.mocked(backend.callTool).mockImplementation(
560
+ async (_params, _schema, options) => {
561
+ options?.onprogress?.({ progress: 2, total: 5, message: 'working' });
562
+ expect(options?.signal).toBe(controller.signal);
563
+ return { content: [{ type: 'text', text: 'raw upstream response' }] };
564
+ },
565
+ );
566
+
567
+ const server = createProxyServer(backend, { name: 'test-server' });
568
+
569
+ await invokeHandler(
570
+ server,
571
+ 'tools/call',
572
+ { name: 'normal_tool', arguments: {} },
573
+ {
574
+ signal: controller.signal,
575
+ requestId: 1,
576
+ sendNotification,
577
+ _meta: { progressToken: 'progress-1' },
578
+ },
579
+ );
580
+
581
+ expect(sendNotification).toHaveBeenCalledWith({
582
+ method: 'notifications/progress',
583
+ params: {
584
+ progressToken: 'progress-1',
585
+ progress: 2,
586
+ total: 5,
587
+ message: 'working',
588
+ },
589
+ });
590
+ });
591
+
592
+ it('forwards signal for resource reads', async () => {
593
+ const backend = makeMockBackend();
594
+ const controller = new AbortController();
595
+
596
+ vi.mocked(backend.readResource).mockImplementation(
597
+ async (_params, options) => {
598
+ expect(options?.signal).toBe(controller.signal);
599
+ return {
600
+ contents: [
601
+ {
602
+ uri: 'res://normal',
603
+ text: 'raw content',
604
+ mimeType: 'text/plain',
605
+ },
606
+ ],
607
+ };
608
+ },
609
+ );
610
+
611
+ const server = createProxyServer(backend, { name: 'test-server' });
612
+
613
+ await invokeHandler(
614
+ server,
615
+ 'resources/read',
616
+ { uri: 'res://normal' },
617
+ {
618
+ signal: controller.signal,
619
+ requestId: 1,
620
+ sendNotification: vi.fn().mockResolvedValue(undefined),
621
+ },
622
+ );
623
+ });
624
+ });
625
+
626
+ describe('createProxyServer() — onTelemetry', () => {
627
+ it('emits a success event after a successful tool call', async () => {
628
+ const backend = makeMockBackend();
629
+ const events: ToolCallTelemetryEvent[] = [];
630
+ const server = createProxyServer(backend, {
631
+ onTelemetry: (e) => {
632
+ if (e.type === 'tool_call') events.push(e);
633
+ },
634
+ name: 'test-server',
635
+ });
636
+
637
+ await invokeHandler(server, 'tools/call', {
638
+ name: 'echo',
639
+ arguments: { msg: 'hi' },
640
+ });
641
+
642
+ expect(events).toHaveLength(1);
643
+ expect(events[0]).toMatchObject({
644
+ type: 'tool_call',
645
+ tool: 'echo',
646
+ outcome: 'success',
647
+ });
648
+ expect(typeof events[0]!.duration_ms).toBe('number');
649
+ });
650
+
651
+ it('stamps the configured proxy identity onto every tool_call event', async () => {
652
+ const backend = makeMockBackend();
653
+ const events: ToolCallTelemetryEvent[] = [];
654
+ const server = createProxyServer(backend, {
655
+ onTelemetry: (e) => {
656
+ if (e.type === 'tool_call') events.push(e);
657
+ },
658
+ name: 'payments-proxy',
659
+ version: '9.9.9',
660
+ });
661
+
662
+ await invokeHandler(server, 'tools/call', {
663
+ name: 'echo',
664
+ arguments: { msg: 'hi' },
665
+ });
666
+
667
+ expect(events).toHaveLength(1);
668
+ expect(events[0]?.proxy).toEqual({
669
+ name: 'payments-proxy',
670
+ version: '9.9.9',
671
+ });
672
+ });
673
+
674
+ it('emits a rejected event with TOOL_HIDDEN when a hidden tool is called', async () => {
675
+ const backend = makeMockBackend();
676
+ const events: ToolCallTelemetryEvent[] = [];
677
+ const server = createProxyServer(backend, {
678
+ hiddenTools: ['sensitive_tool'],
679
+ onTelemetry: (e) => {
680
+ if (e.type === 'tool_call') events.push(e);
681
+ },
682
+ name: 'test-server',
683
+ });
684
+
685
+ await expect(
686
+ invokeHandler(server, 'tools/call', {
687
+ name: 'sensitive_tool',
688
+ arguments: {},
689
+ }),
690
+ ).rejects.toBeDefined();
691
+
692
+ expect(events).toHaveLength(1);
693
+ expect(events[0]).toMatchObject({
694
+ type: 'tool_call',
695
+ tool: 'sensitive_tool',
696
+ outcome: 'rejected',
697
+ rejectionReason: 'TOOL_HIDDEN',
698
+ });
699
+ });
700
+
701
+ it('emits an error event when the upstream throws', async () => {
702
+ const backend = makeMockBackend();
703
+ vi.mocked(backend.callTool).mockRejectedValueOnce(
704
+ new Error('upstream failure'),
705
+ );
706
+ const events: ToolCallTelemetryEvent[] = [];
707
+ const server = createProxyServer(backend, {
708
+ onTelemetry: (e) => {
709
+ if (e.type === 'tool_call') events.push(e);
710
+ },
711
+ name: 'test-server',
712
+ });
713
+
714
+ await expect(
715
+ invokeHandler(server, 'tools/call', { name: 'echo', arguments: {} }),
716
+ ).rejects.toBeDefined();
717
+
718
+ expect(events).toHaveLength(1);
719
+ expect(events[0]).toMatchObject({
720
+ type: 'tool_call',
721
+ tool: 'echo',
722
+ outcome: 'error',
723
+ });
724
+ });
725
+
726
+ it.each(['LEDGER_POOL_RESET', 'constructor'])(
727
+ 'emits an error event, not a rejection, when the upstream throws with the unknown reason %s (#238)',
728
+ async (reason) => {
729
+ const backend = makeMockBackend();
730
+ vi.mocked(backend.callTool).mockRejectedValueOnce(
731
+ Object.assign(new Error('upstream failure'), {
732
+ data: { rejectionReason: reason },
733
+ }),
734
+ );
735
+ const events: ToolCallTelemetryEvent[] = [];
736
+ const server = createProxyServer(backend, {
737
+ onTelemetry: (e) => {
738
+ if (e.type === 'tool_call') events.push(e);
739
+ },
740
+ name: 'test-server',
741
+ });
742
+
743
+ await expect(
744
+ invokeHandler(server, 'tools/call', { name: 'echo', arguments: {} }),
745
+ ).rejects.toBeDefined();
746
+
747
+ expect(events).toHaveLength(1);
748
+ expect(events[0]?.outcome).toBe('error');
749
+ expect(Object.hasOwn(events[0]!, 'rejectionReason')).toBe(false);
750
+ },
751
+ );
752
+ });
753
+
754
+ describe('createProxyServer() — server identity', () => {
755
+ // The MCP SDK stores the advertised server info on a protected `_serverInfo`.
756
+ const serverInfo = (server: ReturnType<typeof createProxyServer>) => {
757
+ return (
758
+ server as unknown as { _serverInfo: { name: string; version: string } }
759
+ )._serverInfo;
760
+ };
761
+
762
+ it('honors an explicit name when provided', () => {
763
+ const backend = makeMockBackend();
764
+ const server = createProxyServer(backend, { name: 'my-proxy' });
765
+
766
+ expect(serverInfo(server).name).toBe('my-proxy');
767
+ });
768
+
769
+ it('defaults version to the mcpose library version when omitted', () => {
770
+ const backend = makeMockBackend();
771
+ const server = createProxyServer(backend, { name: 'test-server' });
772
+
773
+ expect(serverInfo(server).version).toBe(pkgVersion);
774
+ // Regression guard against the stale hardcoded '1.1.1'.
775
+ expect(serverInfo(server).version).not.toBe('1.1.1');
776
+ });
777
+
778
+ it('honors an explicit version, independent of the library version', () => {
779
+ const backend = makeMockBackend();
780
+ // A developer ships their own proxy version on top of a pinned library.
781
+ const server = createProxyServer(backend, {
782
+ name: 'test-server',
783
+ version: '9.9.9',
784
+ });
785
+
786
+ expect(serverInfo(server).version).toBe('9.9.9');
787
+ });
788
+ });
789
+
790
+ // ── rejection + pass-through observation ────────────────────────────────────
791
+
792
+ describe('createProxyServer() — rejection and pass-through observation', () => {
793
+ it('lets tool middleware observe a TOOL_HIDDEN rejection in-chain', async () => {
794
+ const backend = makeMockBackend();
795
+ let observed: unknown;
796
+ const mw: ToolMiddleware = async (req, next) => {
797
+ try {
798
+ return await next(req);
799
+ } catch (err) {
800
+ observed = err;
801
+ throw err;
802
+ }
803
+ };
804
+ const server = createProxyServer(backend, {
805
+ hiddenTools: ['sensitive_tool'],
806
+ toolMiddleware: [mw],
807
+ name: 'test-server',
808
+ });
809
+
810
+ await expect(
811
+ invokeHandler(server, 'tools/call', {
812
+ name: 'sensitive_tool',
813
+ arguments: {},
814
+ }),
815
+ ).rejects.toMatchObject({ code: ErrorCode.MethodNotFound });
816
+
817
+ expect(observed).toMatchObject({
818
+ data: { rejectionReason: 'TOOL_HIDDEN' },
819
+ });
820
+ // The backend must never be called for a hidden tool.
821
+ expect(backend.callTool).not.toHaveBeenCalled();
822
+ });
823
+
824
+ it('runs marked observer middleware for pass-through tools, skips unmarked', async () => {
825
+ const backend = makeMockBackend();
826
+ const calls: string[] = [];
827
+ const observer: ToolMiddleware = markPassThroughObserver(
828
+ async (req, next) => {
829
+ calls.push('observer');
830
+ return next(req);
831
+ },
832
+ );
833
+ const transformer: ToolMiddleware = async (req, next) => {
834
+ calls.push('transformer');
835
+ return next(req);
836
+ };
837
+ const server = createProxyServer(backend, {
838
+ passThroughTools: ['pass_tool'],
839
+ toolMiddleware: [transformer, observer],
840
+ name: 'test-server',
841
+ });
842
+
843
+ await invokeHandler(server, 'tools/call', {
844
+ name: 'pass_tool',
845
+ arguments: {},
846
+ });
847
+ expect(calls).toEqual(['observer']);
848
+ expect(backend.callTool).toHaveBeenCalledTimes(1);
849
+
850
+ // A non-pass-through tool runs both.
851
+ calls.length = 0;
852
+ await invokeHandler(server, 'tools/call', {
853
+ name: 'normal_tool',
854
+ arguments: {},
855
+ });
856
+ expect(calls).toEqual(['observer', 'transformer']);
857
+ });
858
+
859
+ it('keeps hidden precedence over passThrough, observed by marked middleware', async () => {
860
+ const backend = makeMockBackend();
861
+ let observed: unknown;
862
+ const observer: ToolMiddleware = markPassThroughObserver(
863
+ async (req, next) => {
864
+ try {
865
+ return await next(req);
866
+ } catch (err) {
867
+ observed = err;
868
+ throw err;
869
+ }
870
+ },
871
+ );
872
+ const server = createProxyServer(backend, {
873
+ hiddenTools: ['pass_tool'],
874
+ passThroughTools: ['pass_tool'],
875
+ toolMiddleware: [observer],
876
+ name: 'test-server',
877
+ });
878
+
879
+ await expect(
880
+ invokeHandler(server, 'tools/call', { name: 'pass_tool', arguments: {} }),
881
+ ).rejects.toMatchObject({ code: ErrorCode.MethodNotFound });
882
+ expect(observed).toMatchObject({
883
+ data: { rejectionReason: 'TOOL_HIDDEN' },
884
+ });
885
+ expect(backend.callTool).not.toHaveBeenCalled();
886
+ });
887
+ });
888
+
889
+ // ── telemetry outcomes ──────────────────────────────────────────────────────
890
+
891
+ describe('createProxyServer() — telemetry outcomes', () => {
892
+ it('reports isError tool results as outcome error', async () => {
893
+ const backend = makeMockBackend();
894
+ (backend.callTool as ReturnType<typeof vi.fn>).mockResolvedValue({
895
+ content: [{ type: 'text', text: 'tool-level failure' }],
896
+ isError: true,
897
+ });
898
+ const events: ToolCallTelemetryEvent[] = [];
899
+ const server = createProxyServer(backend, {
900
+ onTelemetry: (e) => {
901
+ if (e.type === 'tool_call') events.push(e);
902
+ },
903
+ name: 'test-server',
904
+ });
905
+
906
+ await invokeHandler(server, 'tools/call', {
907
+ name: 'normal_tool',
908
+ arguments: {},
909
+ });
910
+ expect(events).toHaveLength(1);
911
+ expect(events[0]!.outcome).toBe('error');
912
+ });
913
+
914
+ it('emits rejected telemetry with TOOL_HIDDEN for hidden tools', async () => {
915
+ const backend = makeMockBackend();
916
+ const events: ToolCallTelemetryEvent[] = [];
917
+ const server = createProxyServer(backend, {
918
+ hiddenTools: ['sensitive_tool'],
919
+ onTelemetry: (e) => {
920
+ if (e.type === 'tool_call') events.push(e);
921
+ },
922
+ name: 'test-server',
923
+ });
924
+
925
+ await expect(
926
+ invokeHandler(server, 'tools/call', {
927
+ name: 'sensitive_tool',
928
+ arguments: {},
929
+ }),
930
+ ).rejects.toMatchObject({ code: ErrorCode.MethodNotFound });
931
+ expect(events).toHaveLength(1);
932
+ expect(events[0]).toMatchObject({
933
+ outcome: 'rejected',
934
+ rejectionReason: 'TOOL_HIDDEN',
935
+ });
936
+ });
937
+
938
+ it('does not fail the call when onTelemetry throws', async () => {
939
+ const backend = makeMockBackend();
940
+ const consoleSpy = vi
941
+ .spyOn(console, 'error')
942
+ .mockImplementation(() => undefined);
943
+ const server = createProxyServer(backend, {
944
+ onTelemetry: () => {
945
+ throw new Error('sink down');
946
+ },
947
+ name: 'test-server',
948
+ });
949
+
950
+ const result = await invokeHandler(server, 'tools/call', {
951
+ name: 'normal_tool',
952
+ arguments: {},
953
+ });
954
+ expect(result).toMatchObject({
955
+ content: [{ type: 'text', text: 'raw upstream response' }],
956
+ });
957
+ expect(consoleSpy).toHaveBeenCalled();
958
+ consoleSpy.mockRestore();
959
+ });
960
+
961
+ it('does not fail the call when an async onTelemetry rejects', async () => {
962
+ const backend = makeMockBackend();
963
+ const consoleSpy = vi
964
+ .spyOn(console, 'error')
965
+ .mockImplementation(() => undefined);
966
+ const rejection = new Error('exporter down');
967
+ const server = createProxyServer(backend, {
968
+ onTelemetry: async () => {
969
+ await Promise.resolve();
970
+ throw rejection;
971
+ },
972
+ name: 'test-server',
973
+ });
974
+
975
+ const result = await invokeHandler(server, 'tools/call', {
976
+ name: 'normal_tool',
977
+ arguments: {},
978
+ });
979
+ expect(result).toMatchObject({
980
+ content: [{ type: 'text', text: 'raw upstream response' }],
981
+ });
982
+ // A rejection nobody observes would surface as an unhandled rejection and
983
+ // fail the run, so reaching here at all is part of the assertion; the spy
984
+ // pins that it was also logged rather than dropped.
985
+ await new Promise((resolve) => setTimeout(resolve, 0));
986
+ expect(consoleSpy).toHaveBeenCalledWith(rejection);
987
+ consoleSpy.mockRestore();
988
+ });
989
+
990
+ it('does not fail the call when onTelemetry returns a cross-realm promise that rejects', async () => {
991
+ const backend = makeMockBackend();
992
+ const consoleSpy = vi
993
+ .spyOn(console, 'error')
994
+ .mockImplementation(() => undefined);
995
+ const rejection = new Error('exporter down');
996
+ // A sink loaded from a `node:vm` sandbox hands back a promise built by that
997
+ // realm's `Promise`, so it is not `instanceof Promise` here; the guard has
998
+ // to adopt it rather than test its constructor.
999
+ const rejectFromOtherRealm = runInNewContext(
1000
+ '(reason) => Promise.reject(reason)',
1001
+ ) as (reason: unknown) => unknown;
1002
+ const server = createProxyServer(backend, {
1003
+ onTelemetry: () => rejectFromOtherRealm(rejection),
1004
+ name: 'test-server',
1005
+ });
1006
+
1007
+ const result = await invokeHandler(server, 'tools/call', {
1008
+ name: 'normal_tool',
1009
+ arguments: {},
1010
+ });
1011
+ expect(result).toMatchObject({
1012
+ content: [{ type: 'text', text: 'raw upstream response' }],
1013
+ });
1014
+ await new Promise((resolve) => setTimeout(resolve, 0));
1015
+ expect(consoleSpy).toHaveBeenCalledWith(rejection);
1016
+ consoleSpy.mockRestore();
1017
+ });
1018
+ });
1019
+
1020
+ describe('createProxyServer() — promptMiddleware', () => {
1021
+ it('runs around prompts/get in 1:1 mode', async () => {
1022
+ const backend = makeMockBackend();
1023
+ const seen: string[] = [];
1024
+ const mw: PromptMiddleware = async (req, next) => {
1025
+ seen.push(req.params.name);
1026
+ const result = await next(req);
1027
+ return { ...result, description: 'wrapped' };
1028
+ };
1029
+ const server = createProxyServer(backend, {
1030
+ promptMiddleware: [mw],
1031
+ name: 'test-server',
1032
+ });
1033
+
1034
+ const result = (await invokeHandler(server, 'prompts/get', {
1035
+ name: 'brief',
1036
+ })) as { description?: string };
1037
+
1038
+ expect(seen).toEqual(['brief']);
1039
+ expect(result.description).toBe('wrapped');
1040
+ expect(backend.getPrompt).toHaveBeenCalledWith(
1041
+ { name: 'brief' },
1042
+ undefined,
1043
+ );
1044
+ });
1045
+
1046
+ it('runs in response-processing order (first = innermost), per ADR-0002', async () => {
1047
+ const backend = makeMockBackend();
1048
+ const order: string[] = [];
1049
+
1050
+ const innerMW: PromptMiddleware = async (req, next) => {
1051
+ order.push('inner-enter');
1052
+ const result = await next(req);
1053
+ order.push('inner-exit');
1054
+ return result;
1055
+ };
1056
+ const outerMW: PromptMiddleware = async (req, next) => {
1057
+ order.push('outer-enter');
1058
+ const result = await next(req);
1059
+ order.push('outer-exit');
1060
+ return result;
1061
+ };
1062
+
1063
+ const server = createProxyServer(backend, {
1064
+ promptMiddleware: [innerMW, outerMW],
1065
+ name: 'test-server',
1066
+ });
1067
+
1068
+ await invokeHandler(server, 'prompts/get', { name: 'brief' });
1069
+
1070
+ expect(order).toEqual([
1071
+ 'outer-enter',
1072
+ 'inner-enter',
1073
+ 'inner-exit',
1074
+ 'outer-exit',
1075
+ ]);
1076
+ });
1077
+
1078
+ it('sees the stripped request and the stripped upstream result', async () => {
1079
+ const backend = makeMockBackend();
1080
+ vi.mocked(backend.getPrompt).mockResolvedValue({
1081
+ messages: [],
1082
+ _meta: { 'vendor/trace': 'abc' },
1083
+ });
1084
+ let observedReq: unknown;
1085
+ let observedResult: unknown;
1086
+ const mw: PromptMiddleware = async (req, next) => {
1087
+ observedReq = req.params;
1088
+ const result = await next(req);
1089
+ observedResult = result;
1090
+ return result;
1091
+ };
1092
+ const server = createProxyServer(backend, {
1093
+ promptMiddleware: [mw],
1094
+ name: 'test-server',
1095
+ });
1096
+
1097
+ await invokeHandler(server, 'prompts/get', {
1098
+ name: 'brief',
1099
+ _meta: { 'vscode/conversationId': 'c1' },
1100
+ });
1101
+
1102
+ expect(observedReq).not.toHaveProperty('_meta');
1103
+ expect(observedResult).not.toHaveProperty('_meta');
1104
+ });
1105
+ });