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.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +494 -37
  3. package/dist/backendClient.d.ts +5 -0
  4. package/dist/backendClient.d.ts.map +1 -1
  5. package/dist/backendClient.js +17 -9
  6. package/dist/backendClient.js.map +1 -1
  7. package/dist/core.d.ts +293 -28
  8. package/dist/core.d.ts.map +1 -1
  9. package/dist/core.js +969 -175
  10. package/dist/core.js.map +1 -1
  11. package/dist/delegation.d.ts +89 -0
  12. package/dist/delegation.d.ts.map +1 -0
  13. package/dist/delegation.js +175 -0
  14. package/dist/delegation.js.map +1 -0
  15. package/dist/eventStore.d.ts +12 -0
  16. package/dist/eventStore.d.ts.map +1 -1
  17. package/dist/eventStore.js +43 -5
  18. package/dist/eventStore.js.map +1 -1
  19. package/dist/hiddenTools.d.ts +42 -0
  20. package/dist/hiddenTools.d.ts.map +1 -0
  21. package/dist/hiddenTools.js +53 -0
  22. package/dist/hiddenTools.js.map +1 -0
  23. package/dist/index.d.ts +15 -6
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +7 -3
  26. package/dist/index.js.map +1 -1
  27. package/dist/mesh.d.ts +82 -0
  28. package/dist/mesh.d.ts.map +1 -0
  29. package/dist/mesh.js +141 -0
  30. package/dist/mesh.js.map +1 -0
  31. package/dist/middleware.d.ts +13 -0
  32. package/dist/middleware.d.ts.map +1 -1
  33. package/dist/middleware.js +27 -3
  34. package/dist/middleware.js.map +1 -1
  35. package/dist/proxyContext.d.ts +65 -5
  36. package/dist/proxyContext.d.ts.map +1 -1
  37. package/dist/proxyContext.js +39 -4
  38. package/dist/proxyContext.js.map +1 -1
  39. package/dist/rejection.d.ts +1 -1
  40. package/dist/rejection.d.ts.map +1 -1
  41. package/dist/rejection.js +1 -1
  42. package/dist/rejection.js.map +1 -1
  43. package/dist/sanitizeCatalog.d.ts +37 -0
  44. package/dist/sanitizeCatalog.d.ts.map +1 -0
  45. package/dist/sanitizeCatalog.js +86 -0
  46. package/dist/sanitizeCatalog.js.map +1 -0
  47. package/dist/sessionRegistry.d.ts +79 -0
  48. package/dist/sessionRegistry.d.ts.map +1 -0
  49. package/dist/sessionRegistry.js +83 -0
  50. package/dist/sessionRegistry.js.map +1 -0
  51. package/dist/telemetry.d.ts +29 -1
  52. package/dist/telemetry.d.ts.map +1 -1
  53. package/dist/testing.d.ts +18 -2
  54. package/dist/testing.d.ts.map +1 -1
  55. package/dist/testing.js +30 -8
  56. package/dist/testing.js.map +1 -1
  57. package/dist/version.d.ts +7 -0
  58. package/dist/version.d.ts.map +1 -0
  59. package/dist/version.js +7 -0
  60. package/dist/version.js.map +1 -0
  61. package/package.json +30 -14
  62. package/src/__tests__/_helpers.ts +64 -0
  63. package/src/__tests__/backendClient.test.ts +198 -0
  64. package/src/__tests__/core.delegation.test.ts +964 -0
  65. package/src/__tests__/core.edge.test.ts +314 -0
  66. package/src/__tests__/core.hiddenTools.predicate.test.ts +236 -0
  67. package/src/__tests__/core.localTools.test.ts +314 -0
  68. package/src/__tests__/core.mesh.test.ts +1182 -0
  69. package/src/__tests__/core.stripRequestMeta.test.ts +226 -0
  70. package/src/__tests__/core.stripResultMeta.test.ts +217 -0
  71. package/src/__tests__/core.test.ts +1019 -0
  72. package/src/__tests__/eventStore.test.ts +141 -0
  73. package/src/__tests__/mapToolResult.test.ts +101 -0
  74. package/src/__tests__/middleware.edge.test.ts +126 -0
  75. package/src/__tests__/middleware.test.ts +233 -0
  76. package/src/__tests__/proxyContext.test.ts +140 -0
  77. package/src/__tests__/sanitizeCatalog.test.ts +359 -0
  78. package/src/__tests__/startHttpProxy.lifecycle.test.ts +638 -0
  79. package/src/__tests__/startHttpProxy.registry.test.ts +409 -0
  80. package/src/__tests__/startHttpProxy.replay.test.ts +116 -0
  81. package/src/__tests__/startHttpProxy.security.test.ts +288 -0
  82. package/src/__tests__/startHttpProxy.test.ts +985 -0
  83. package/src/__tests__/testing.test.ts +250 -0
  84. package/src/__tests__/version.test.ts +12 -0
  85. package/src/backendClient.ts +83 -0
  86. package/src/core.ts +2042 -0
  87. package/src/delegation.ts +224 -0
  88. package/src/eventStore.ts +106 -0
  89. package/src/hiddenTools.ts +84 -0
  90. package/src/identity.ts +15 -0
  91. package/src/index.ts +63 -0
  92. package/src/mesh.ts +204 -0
  93. package/src/middleware.ts +101 -0
  94. package/src/proxyContext.ts +108 -0
  95. package/src/rejection.ts +37 -0
  96. package/src/sanitizeCatalog.ts +120 -0
  97. package/src/sessionRegistry.ts +142 -0
  98. package/src/telemetry.ts +46 -0
  99. package/src/testing.ts +174 -0
  100. package/src/version.ts +6 -0
@@ -0,0 +1,314 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createProxyServer, type ToolMiddleware } from '../core.js';
3
+ import type { BackendClient } from '../backendClient.js';
4
+ import {
5
+ ErrorCode,
6
+ PromptListChangedNotificationSchema,
7
+ ResourceListChangedNotificationSchema,
8
+ ToolListChangedNotificationSchema,
9
+ type ServerCapabilities,
10
+ } from '@modelcontextprotocol/sdk/types.js';
11
+
12
+ // ── helpers ────────────────────────────────────────────────────────────────
13
+
14
+ function makeRichBackend(
15
+ capabilities: ServerCapabilities | undefined,
16
+ ): BackendClient & {
17
+ __notificationHandlers: Map<string, () => Promise<void>>;
18
+ } {
19
+ const notificationHandlers = new Map<string, () => Promise<void>>();
20
+ return {
21
+ getServerCapabilities: vi.fn().mockReturnValue(capabilities),
22
+ listTools: vi.fn().mockResolvedValue({
23
+ tools: [
24
+ {
25
+ name: 'normal_tool',
26
+ description: 'n',
27
+ inputSchema: { type: 'object', properties: {} },
28
+ },
29
+ ],
30
+ }),
31
+ callTool: vi
32
+ .fn()
33
+ .mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }),
34
+ listResources: vi.fn().mockResolvedValue({
35
+ resources: [
36
+ { name: 'a', uri: 'res://a', mimeType: 'text/plain' },
37
+ { name: 'b', uri: 'res://b', mimeType: 'text/plain' },
38
+ ],
39
+ }),
40
+ readResource: vi.fn().mockResolvedValue({ contents: [] }),
41
+ listPrompts: vi.fn().mockResolvedValue({ prompts: [] }),
42
+ getPrompt: vi.fn().mockResolvedValue({ messages: [] }),
43
+ setNotificationHandler: vi.fn((schema, handler) => {
44
+ const method =
45
+ schema === ToolListChangedNotificationSchema
46
+ ? 'notifications/tools/list_changed'
47
+ : schema === PromptListChangedNotificationSchema
48
+ ? 'notifications/prompts/list_changed'
49
+ : schema === ResourceListChangedNotificationSchema
50
+ ? 'notifications/resources/list_changed'
51
+ : 'unknown';
52
+ notificationHandlers.set(method, handler as () => Promise<void>);
53
+ }),
54
+ removeNotificationHandler: vi.fn(),
55
+ close: vi.fn().mockResolvedValue(undefined),
56
+ __notificationHandlers: notificationHandlers,
57
+ } as unknown as BackendClient & {
58
+ __notificationHandlers: Map<string, () => Promise<void>>;
59
+ };
60
+ }
61
+
62
+ async function invoke(
63
+ server: ReturnType<typeof createProxyServer>,
64
+ method: string,
65
+ params: Record<string, unknown> = {},
66
+ extra: object = {},
67
+ ): Promise<unknown> {
68
+ const { _requestHandlers: handlers } = server as unknown as {
69
+ _requestHandlers: Map<
70
+ string,
71
+ (
72
+ req: { method: string; params: Record<string, unknown> },
73
+ extra: object,
74
+ ) => Promise<unknown>
75
+ >;
76
+ };
77
+ const handler = handlers.get(method);
78
+ if (!handler) throw new Error(`No handler registered for method: ${method}`);
79
+ return handler({ method, params }, extra);
80
+ }
81
+
82
+ // ── name validation ────────────────────────────────────────────────────────
83
+
84
+ describe('createProxyServer() — name is required and non-blank', () => {
85
+ // The name is provenance on every audit event and manifest (ADR-0012), so a
86
+ // blank one is a trail that looks attributed and is not (#122). Rejected at
87
+ // construction, like a duplicate local tool name.
88
+ it.each([
89
+ ['empty', ''],
90
+ ['whitespace-only', ' '],
91
+ ['a tab and a newline', '\t\n'],
92
+ ])('throws on a %s name', (_label, name) => {
93
+ expect(() => createProxyServer(makeRichBackend({ tools: {} }), { name })) //
94
+ .toThrow(/name must be a non-empty string/);
95
+ });
96
+
97
+ it('throws when a JS caller omits the name the type requires', () => {
98
+ expect(() =>
99
+ createProxyServer(
100
+ makeRichBackend({ tools: {} }),
101
+ {} as unknown as { name: string },
102
+ ),
103
+ ).toThrow(/name must be a non-empty string/);
104
+ });
105
+
106
+ it('accepts a name that is non-blank once trimmed', () => {
107
+ expect(() =>
108
+ createProxyServer(makeRichBackend({ tools: {} }), { name: ' edge ' }),
109
+ ).not.toThrow();
110
+ });
111
+ });
112
+
113
+ // ── capability mirroring ──────────────────────────────────────────────────
114
+
115
+ describe('createProxyServer() — capability edge cases', () => {
116
+ it('throws when the backend is not connected (undefined capabilities)', () => {
117
+ const backend = makeRichBackend(undefined);
118
+
119
+ // A disconnected backend would otherwise produce a proxy that silently
120
+ // answers nothing — fail loudly instead.
121
+ expect(() => createProxyServer(backend, { name: 'test-server' })).toThrow(
122
+ /backend is not connected/,
123
+ );
124
+ expect(backend.setNotificationHandler).not.toHaveBeenCalled();
125
+ });
126
+
127
+ it('mirrors tools without listChanged as {} (not {listChanged:true})', async () => {
128
+ const backend = makeRichBackend({ tools: {} });
129
+ const server = createProxyServer(backend, { name: 'test-server' });
130
+
131
+ const result = (await invoke(server, 'initialize', {
132
+ protocolVersion: '2025-03-26',
133
+ capabilities: {},
134
+ clientInfo: { name: 't', version: '0' },
135
+ })) as { capabilities: ServerCapabilities };
136
+
137
+ expect(result.capabilities.tools).toEqual({});
138
+ expect(backend.setNotificationHandler).not.toHaveBeenCalled();
139
+ });
140
+
141
+ it('does not register list-changed handlers when upstream lacks listChanged', () => {
142
+ const backend = makeRichBackend({ tools: {}, resources: {}, prompts: {} });
143
+
144
+ createProxyServer(backend, { name: 'test-server' });
145
+
146
+ expect(backend.setNotificationHandler).not.toHaveBeenCalled();
147
+ });
148
+ });
149
+
150
+ // ── notification fanout multi-proxy ───────────────────────────────────────
151
+
152
+ describe('createProxyServer() — shared notification bus', () => {
153
+ it('registers backend handler exactly once for two proxies sharing one backend', () => {
154
+ const backend = makeRichBackend({ tools: { listChanged: true } });
155
+
156
+ createProxyServer(backend, { name: 'test-server' });
157
+ createProxyServer(backend, { name: 'test-server' });
158
+
159
+ const calls = (
160
+ backend.setNotificationHandler as ReturnType<typeof vi.fn>
161
+ ).mock.calls.filter(
162
+ ([schema]) => schema === ToolListChangedNotificationSchema,
163
+ );
164
+ expect(calls).toHaveLength(1);
165
+ });
166
+
167
+ it('re-registers handler after all proxies close (bus is dropped)', async () => {
168
+ const backend = makeRichBackend({ tools: { listChanged: true } });
169
+
170
+ const a = createProxyServer(backend, { name: 'test-server' });
171
+ a.onclose?.();
172
+
173
+ // The bus should have been deleted; a fresh proxy registers a new handler.
174
+ createProxyServer(backend, { name: 'test-server' });
175
+
176
+ const calls = (
177
+ backend.setNotificationHandler as ReturnType<typeof vi.fn>
178
+ ).mock.calls.filter(
179
+ ([schema]) => schema === ToolListChangedNotificationSchema,
180
+ );
181
+ expect(calls.length).toBeGreaterThanOrEqual(2);
182
+ });
183
+ });
184
+
185
+ // ── hidden / passthrough overlap ──────────────────────────────────────────
186
+
187
+ describe('createProxyServer() — hidden vs passthrough precedence', () => {
188
+ it('hides a tool that is also in passThroughTools (hidden wins)', async () => {
189
+ const backend = makeRichBackend({ tools: {} });
190
+ const server = createProxyServer(backend, {
191
+ hiddenTools: ['shared_name'],
192
+ passThroughTools: ['shared_name'],
193
+ name: 'test-server',
194
+ });
195
+
196
+ await expect(
197
+ invoke(server, 'tools/call', { name: 'shared_name', arguments: {} }),
198
+ ).rejects.toMatchObject({ code: ErrorCode.MethodNotFound });
199
+ });
200
+
201
+ it('returns the upstream list reference unchanged when hiddenTools is empty', async () => {
202
+ const backend = makeRichBackend({ tools: {} });
203
+ const upstream = {
204
+ tools: [
205
+ {
206
+ name: 't',
207
+ description: '',
208
+ inputSchema: { type: 'object', properties: {} },
209
+ },
210
+ ],
211
+ };
212
+ (backend.listTools as ReturnType<typeof vi.fn>).mockResolvedValue(upstream);
213
+
214
+ const server = createProxyServer(backend, {
215
+ name: 'test-server',
216
+ hiddenTools: [],
217
+ });
218
+ const result = await invoke(server, 'tools/list');
219
+
220
+ expect(result).toBe(upstream);
221
+ });
222
+ });
223
+
224
+ // ── createRequestOptions branches (asserted via backend args) ─────────────
225
+
226
+ describe('createProxyServer() — request options shapes', () => {
227
+ it('passes undefined when neither signal nor progressToken is present', async () => {
228
+ const backend = makeRichBackend({ tools: {} });
229
+ const server = createProxyServer(backend, { name: 'test-server' });
230
+
231
+ await invoke(server, 'tools/list');
232
+
233
+ expect(backend.listTools).toHaveBeenCalledWith({}, undefined);
234
+ });
235
+
236
+ it('passes only { signal } when there is no progressToken', async () => {
237
+ const backend = makeRichBackend({ tools: {} });
238
+ const server = createProxyServer(backend, { name: 'test-server' });
239
+ const controller = new AbortController();
240
+
241
+ await invoke(
242
+ server,
243
+ 'tools/list',
244
+ {},
245
+ { signal: controller.signal, requestId: 1 },
246
+ );
247
+
248
+ expect(backend.listTools).toHaveBeenCalledWith(
249
+ {},
250
+ { signal: controller.signal },
251
+ );
252
+ });
253
+
254
+ it('passes undefined when progressToken is present but sendNotification is missing', async () => {
255
+ const backend = makeRichBackend({ tools: {} });
256
+ const server = createProxyServer(backend, { name: 'test-server' });
257
+
258
+ await invoke(
259
+ server,
260
+ 'tools/list',
261
+ {},
262
+ { _meta: { progressToken: 'p1' }, requestId: 1 },
263
+ );
264
+
265
+ expect(backend.listTools).toHaveBeenCalledWith({}, undefined);
266
+ });
267
+
268
+ it('omits total/message from progress notifications when undefined', async () => {
269
+ const backend = makeRichBackend({ tools: {} });
270
+ const sendNotification = vi.fn().mockResolvedValue(undefined);
271
+
272
+ vi.mocked(backend.callTool).mockImplementation(async (_p, _s, options) => {
273
+ options?.onprogress?.({ progress: 1 });
274
+ return { content: [] };
275
+ });
276
+
277
+ const server = createProxyServer(backend, { name: 'test-server' });
278
+ await invoke(
279
+ server,
280
+ 'tools/call',
281
+ { name: 'normal_tool', arguments: {} },
282
+ { sendNotification, _meta: { progressToken: 'tok' }, requestId: 1 },
283
+ );
284
+
285
+ expect(sendNotification).toHaveBeenCalledWith({
286
+ method: 'notifications/progress',
287
+ params: { progressToken: 'tok', progress: 1 },
288
+ });
289
+ const call = sendNotification.mock.calls[0]?.[0] as {
290
+ params: Record<string, unknown>;
291
+ };
292
+ expect('total' in call.params).toBe(false);
293
+ expect('message' in call.params).toBe(false);
294
+ });
295
+ });
296
+
297
+ // ── tool middleware mutation safety ───────────────────────────────────────
298
+
299
+ describe('createProxyServer() — middleware behavior', () => {
300
+ it('propagates errors from tool middleware to the caller', async () => {
301
+ const backend = makeRichBackend({ tools: {} });
302
+ const failing: ToolMiddleware = async () => {
303
+ throw new Error('mw failure');
304
+ };
305
+ const server = createProxyServer(backend, {
306
+ name: 'test-server',
307
+ toolMiddleware: [failing],
308
+ });
309
+
310
+ await expect(
311
+ invoke(server, 'tools/call', { name: 'normal_tool', arguments: {} }),
312
+ ).rejects.toThrow('mw failure');
313
+ });
314
+ });
@@ -0,0 +1,236 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createProxyServer } from '../core.js';
3
+ import { dispatcherAwareBlock } from '../hiddenTools.js';
4
+ import type { BackendClient } from '../backendClient.js';
5
+ import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';
6
+
7
+ // ── Test helpers ────────────────────────────────────────────────────────────
8
+
9
+ const TOOLS = ['update_issue', 'search_issues', 'execute_sentry_tool'].map(
10
+ (name) => ({
11
+ name,
12
+ description: name,
13
+ inputSchema: { type: 'object', properties: {} },
14
+ }),
15
+ );
16
+
17
+ function makeMockBackend(): BackendClient {
18
+ return {
19
+ getServerCapabilities: vi.fn().mockReturnValue({ tools: {} }),
20
+ listTools: vi.fn().mockResolvedValue({ tools: TOOLS }),
21
+ callTool: vi.fn().mockResolvedValue({
22
+ content: [{ type: 'text', text: 'upstream response' }],
23
+ }),
24
+ setNotificationHandler: vi.fn(),
25
+ removeNotificationHandler: vi.fn(),
26
+ close: vi.fn().mockResolvedValue(undefined),
27
+ } as unknown as BackendClient;
28
+ }
29
+
30
+ /** Invokes a registered handler directly via `_requestHandlers` — no transport needed. */
31
+ async function invokeHandler(
32
+ server: ReturnType<typeof createProxyServer>,
33
+ method: string,
34
+ params: Record<string, unknown> = {},
35
+ ) {
36
+ const { _requestHandlers: handlers } = server as unknown as {
37
+ _requestHandlers: Map<
38
+ string,
39
+ (
40
+ req: { method: string; params: Record<string, unknown> },
41
+ extra: object,
42
+ ) => Promise<unknown>
43
+ >;
44
+ };
45
+ const handler = handlers.get(method);
46
+ if (!handler) throw new Error(`No handler registered for method: ${method}`);
47
+ return handler({ method, params }, {});
48
+ }
49
+
50
+ const blockUpdateIssue = () =>
51
+ dispatcherAwareBlock({
52
+ tools: ['update_issue'],
53
+ dispatchers: ['execute_sentry_tool'],
54
+ argPath: 'name',
55
+ });
56
+
57
+ // ── The predicate in the proxy ──────────────────────────────────────────────
58
+
59
+ describe('createProxyServer() — hiddenTools accepts a predicate', () => {
60
+ it('filters tools the predicate hides out of tools/list, with undefined args', async () => {
61
+ const seen: Array<[string, unknown]> = [];
62
+ const server = createProxyServer(makeMockBackend(), {
63
+ name: 'test-server',
64
+ hiddenTools: (name, args) => {
65
+ seen.push([name, args]);
66
+ return name === 'update_issue';
67
+ },
68
+ });
69
+
70
+ const result = (await invokeHandler(server, 'tools/list')) as {
71
+ tools: { name: string }[];
72
+ };
73
+
74
+ expect(result.tools.map((t) => t.name)).toEqual([
75
+ 'search_issues',
76
+ 'execute_sentry_tool',
77
+ ]);
78
+ expect(seen.every(([, args]) => args === undefined)).toBe(true);
79
+ });
80
+
81
+ it('stays authoritative after listToolsMiddleware: a re-added hidden tool is filtered again', async () => {
82
+ const server = createProxyServer(makeMockBackend(), {
83
+ name: 'test-server',
84
+ hiddenTools: blockUpdateIssue(),
85
+ listToolsMiddleware: [
86
+ async (req, next) => {
87
+ const result = await next(req);
88
+ return {
89
+ ...result,
90
+ tools: [
91
+ ...result.tools,
92
+ {
93
+ name: 'update_issue',
94
+ description: 'Reintroduced',
95
+ inputSchema: { type: 'object', properties: {} },
96
+ },
97
+ ],
98
+ };
99
+ },
100
+ ],
101
+ });
102
+
103
+ const result = (await invokeHandler(server, 'tools/list')) as {
104
+ tools: { name: string }[];
105
+ };
106
+ expect(result.tools.map((t) => t.name)).not.toContain('update_issue');
107
+ });
108
+
109
+ it('rejects a call the predicate hides with TOOL_HIDDEN, upstream never called', async () => {
110
+ const backend = makeMockBackend();
111
+ const server = createProxyServer(backend, {
112
+ name: 'test-server',
113
+ hiddenTools: blockUpdateIssue(),
114
+ });
115
+
116
+ await expect(
117
+ invokeHandler(server, 'tools/call', {
118
+ name: 'update_issue',
119
+ arguments: {},
120
+ }),
121
+ ).rejects.toMatchObject({
122
+ code: ErrorCode.MethodNotFound,
123
+ data: { rejectionReason: 'TOOL_HIDDEN' },
124
+ });
125
+ expect(backend.callTool).not.toHaveBeenCalled();
126
+ });
127
+
128
+ it('passes an empty object, not undefined, when the client sent no arguments', async () => {
129
+ const seen: unknown[] = [];
130
+ const server = createProxyServer(makeMockBackend(), {
131
+ name: 'test-server',
132
+ hiddenTools: (_name, args) => {
133
+ seen.push(args);
134
+ return false;
135
+ },
136
+ });
137
+
138
+ await invokeHandler(server, 'tools/call', { name: 'search_issues' });
139
+ expect(seen).toEqual([{}]);
140
+ });
141
+ });
142
+
143
+ // ── dispatcherAwareBlock through the proxy ──────────────────────────────────
144
+
145
+ describe('createProxyServer() — dispatcherAwareBlock closes the dispatcher bypass', () => {
146
+ it('blocks a dispatcher call targeting a hidden tool, upstream never called', async () => {
147
+ const backend = makeMockBackend();
148
+ const server = createProxyServer(backend, {
149
+ name: 'test-server',
150
+ hiddenTools: blockUpdateIssue(),
151
+ });
152
+
153
+ await expect(
154
+ invokeHandler(server, 'tools/call', {
155
+ name: 'execute_sentry_tool',
156
+ arguments: { name: 'update_issue', arguments: {} },
157
+ }),
158
+ ).rejects.toMatchObject({ data: { rejectionReason: 'TOOL_HIDDEN' } });
159
+ expect(backend.callTool).not.toHaveBeenCalled();
160
+ });
161
+
162
+ it('keeps the dispatcher listed and callable with a permitted target', async () => {
163
+ const backend = makeMockBackend();
164
+ const server = createProxyServer(backend, {
165
+ name: 'test-server',
166
+ hiddenTools: blockUpdateIssue(),
167
+ });
168
+
169
+ const listed = (await invokeHandler(server, 'tools/list')) as {
170
+ tools: { name: string }[];
171
+ };
172
+ expect(listed.tools.map((t) => t.name)).toContain('execute_sentry_tool');
173
+
174
+ const result = await invokeHandler(server, 'tools/call', {
175
+ name: 'execute_sentry_tool',
176
+ arguments: { name: 'search_issues', arguments: {} },
177
+ });
178
+ expect(result).toMatchObject({
179
+ content: [{ type: 'text', text: 'upstream response' }],
180
+ });
181
+ });
182
+ });
183
+
184
+ // ── dispatcherAwareBlock fail-closed cases ──────────────────────────────────
185
+
186
+ describe('dispatcherAwareBlock() — fails closed on malformed dispatcher calls', () => {
187
+ const predicate = dispatcherAwareBlock({
188
+ tools: ['update_issue'],
189
+ dispatchers: ['dispatch'],
190
+ argPath: 'request.tool.name',
191
+ });
192
+
193
+ it.each([
194
+ ['missing target', {}],
195
+ ['null target', { request: { tool: { name: null } } }],
196
+ ['numeric target', { request: { tool: { name: 42 } } }],
197
+ ['object target', { request: { tool: { name: { nested: true } } } }],
198
+ ['array target', { request: { tool: { name: ['update_issue'] } } }],
199
+ ['path through a non-object', { request: 'not-an-object' }],
200
+ ['path through an array', { request: [{ tool: { name: 'x' } }] }],
201
+ ])('blocks a dispatcher call with %s', (_label, args) => {
202
+ expect(predicate('dispatch', args as Record<string, unknown>)).toBe(true);
203
+ });
204
+
205
+ it('blocks a dispatcher call with no arguments at all (call phase = empty object)', () => {
206
+ expect(predicate('dispatch', {})).toBe(true);
207
+ });
208
+
209
+ it('does not resolve prototype keys such as constructor', () => {
210
+ const proto = dispatcherAwareBlock({
211
+ tools: ['update_issue'],
212
+ dispatchers: ['dispatch'],
213
+ argPath: 'constructor',
214
+ });
215
+ expect(proto('dispatch', {})).toBe(true);
216
+ });
217
+
218
+ it('allows a dispatcher call with a permitted string target', () => {
219
+ expect(
220
+ predicate('dispatch', { request: { tool: { name: 'search_issues' } } }),
221
+ ).toBe(false);
222
+ });
223
+
224
+ it('blocks the hidden tool directly regardless of phase', () => {
225
+ expect(predicate('update_issue', undefined)).toBe(true);
226
+ expect(predicate('update_issue', {})).toBe(true);
227
+ });
228
+
229
+ it('keeps the dispatcher visible during the list phase', () => {
230
+ expect(predicate('dispatch', undefined)).toBe(false);
231
+ });
232
+
233
+ it('ignores non-dispatcher, non-hidden tools', () => {
234
+ expect(predicate('search_issues', { anything: 1 })).toBe(false);
235
+ });
236
+ });