mcpose 3.0.0 → 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.
@@ -1 +1 @@
1
- {"version":3,"file":"sanitizeCatalog.d.ts","sourceRoot":"","sources":["../src/sanitizeCatalog.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;AAKrD,oDAAoD;AACpD,MAAM,WAAW,+BAA+B;IAC9C;;;;OAIG;IACH,QAAQ,CAAC,EAAE,aAAa,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC;IAC1C,uEAAuE;IACvE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AA8BD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,GAAE,+BAAoC,GAC5C,mBAAmB,CAgDrB"}
1
+ {"version":3,"file":"sanitizeCatalog.d.ts","sourceRoot":"","sources":["../src/sanitizeCatalog.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;AAWrD,oDAAoD;AACpD,MAAM,WAAW,+BAA+B;IAC9C;;;;OAIG;IACH,QAAQ,CAAC,EAAE,aAAa,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC;IAC1C,uEAAuE;IACvE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AA8BD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,GAAE,+BAAoC,GAC5C,mBAAmB,CAgDrB"}
@@ -3,8 +3,14 @@
3
3
  * descriptions verbatim into model context, and real upstreams leak org
4
4
  * slugs and internal hostnames there. See ADR-0010.
5
5
  */
6
- /** Matches http(s) URLs; always stripped regardless of `patterns`. */
7
- const URL_PATTERN = /\bhttps?:\/\/[^\s"'<>()[\]]+/g;
6
+ /**
7
+ * Matches http(s) URLs; always stripped regardless of `patterns`.
8
+ * The `i` flag is load-bearing: URI schemes are case-insensitive
9
+ * (RFC 3986 §6.2.2), so an uppercase `HTTPS://` is the same URL, and a
10
+ * case-sensitive strip would let an upstream leak a hostname simply by
11
+ * capitalizing the scheme.
12
+ */
13
+ const URL_PATTERN = /\bhttps?:\/\/[^\s"'<>()[\]]+/gi;
8
14
  /**
9
15
  * Rebuilds a schema node with every string-valued `description` property
10
16
  * sanitized, at any depth through plain objects and arrays. Returns the
@@ -1 +1 @@
1
- {"version":3,"file":"sanitizeCatalog.js","sourceRoot":"","sources":["../src/sanitizeCatalog.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sanitizeCatalog.js","sourceRoot":"","sources":["../src/sanitizeCatalog.ts"],"names":[],"mappings":"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"}
@@ -37,7 +37,7 @@ export interface SessionRecord {
37
37
  export interface SessionRegistry {
38
38
  /**
39
39
  * Persists a record. Awaited before the client learns its session id, so a
40
- * rejection fails the initialize with a 500 rather than handing out an id
40
+ * rejection fails the initialize with a 400 rather than handing out an id
41
41
  * that no other instance could honour.
42
42
  */
43
43
  set(sessionId: string, record: SessionRecord): Promise<void>;
package/dist/version.d.ts CHANGED
@@ -3,5 +3,5 @@
3
3
  * `createRequire('../package.json')` lookup breaks when consumers bundle
4
4
  * the package). Kept in sync with package.json by `version.test.ts`.
5
5
  */
6
- export declare const VERSION = "3.0.0";
6
+ export declare const VERSION = "3.1.0";
7
7
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -3,5 +3,5 @@
3
3
  * `createRequire('../package.json')` lookup breaks when consumers bundle
4
4
  * the package). Kept in sync with package.json by `version.test.ts`.
5
5
  */
6
- export const VERSION = '3.0.0';
6
+ export const VERSION = '3.1.0';
7
7
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcpose",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "The audit and governance layer for MCP",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -51,13 +51,13 @@
51
51
  "@modelcontextprotocol/sdk": "^1.17.0"
52
52
  },
53
53
  "devDependencies": {
54
- "@modelcontextprotocol/sdk": "1.30.0",
54
+ "@modelcontextprotocol/sdk": "1.32.1",
55
55
  "@stryker-mutator/core": "10.0.0",
56
56
  "@stryker-mutator/vitest-runner": "10.0.0",
57
- "@types/node": "^26.5.1",
58
- "@vitest/coverage-v8": "5.0.1",
57
+ "@types/node": "^26.6.5",
58
+ "@vitest/coverage-v8": "5.0.4",
59
59
  "typescript": "6.0.3",
60
- "vitest": "5.0.1"
60
+ "vitest": "5.0.4"
61
61
  },
62
62
  "scripts": {
63
63
  "build": "tsc -p tsconfig.build.json",
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { runInNewContext } from 'node:vm';
4
5
  import {
5
6
  createProxyServer,
6
7
  type ListToolsMiddleware,
@@ -721,6 +722,33 @@ describe('createProxyServer() — onTelemetry', () => {
721
722
  outcome: 'error',
722
723
  });
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
+ );
724
752
  });
725
753
 
726
754
  describe('createProxyServer() — server identity', () => {
@@ -929,6 +957,64 @@ describe('createProxyServer() — telemetry outcomes', () => {
929
957
  expect(consoleSpy).toHaveBeenCalled();
930
958
  consoleSpy.mockRestore();
931
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
+ });
932
1018
  });
933
1019
 
934
1020
  describe('createProxyServer() — promptMiddleware', () => {
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createProxyServer, type LocalTool } from '../core.js';
3
+ import type { BackendClient } from '../backendClient.js';
4
+ import type { TelemetryEvent } from '../telemetry.js';
5
+
6
+ /** Invokes a registered handler directly via `_requestHandlers` — no transport needed. */
7
+ async function invokeHandler(
8
+ server: ReturnType<typeof createProxyServer>,
9
+ method: string,
10
+ params: Record<string, unknown> = {},
11
+ ) {
12
+ const { _requestHandlers: handlers } = server as unknown as {
13
+ _requestHandlers: Map<
14
+ string,
15
+ (
16
+ req: { method: string; params: Record<string, unknown> },
17
+ extra: object,
18
+ ) => Promise<unknown>
19
+ >;
20
+ };
21
+ const handler = handlers.get(method);
22
+ if (!handler) throw new Error(`No handler registered for method: ${method}`);
23
+ return handler({ method, params }, {});
24
+ }
25
+
26
+ function makeBackend(): BackendClient {
27
+ return {
28
+ getServerCapabilities: vi.fn().mockReturnValue({ tools: {} }),
29
+ listTools: vi.fn().mockResolvedValue({ tools: [] }),
30
+ callTool: vi.fn(),
31
+ setNotificationHandler: vi.fn(),
32
+ } as unknown as BackendClient;
33
+ }
34
+
35
+ /**
36
+ * `hasToolContent` answers false for a value that is not a tool result instead
37
+ * of throwing on it. The `tools/call` telemetry decision reads that guard, so a
38
+ * pipeline that resolved to nothing must not be recorded as a clean call: the
39
+ * result never reached the client, and there is no `isError` to judge it by.
40
+ */
41
+ describe('tools/call telemetry — a result that is not a tool result', () => {
42
+ it('reports error when a local tool handler resolves to nothing', async () => {
43
+ const events: TelemetryEvent[] = [];
44
+ const noReturn: LocalTool = {
45
+ tool: {
46
+ name: 'silent_tool',
47
+ description: 'returns undefined',
48
+ inputSchema: { type: 'object', properties: {} },
49
+ },
50
+ // A local tool with a missing `return`: the pipeline resolves to undefined.
51
+ handler: (async () => undefined) as unknown as LocalTool['handler'],
52
+ };
53
+
54
+ const server = createProxyServer(
55
+ { crm: makeBackend() },
56
+ {
57
+ name: 'test-server',
58
+ localTools: [noReturn],
59
+ onTelemetry: (e) => events.push(e),
60
+ },
61
+ );
62
+
63
+ // The SDK rejects the malformed result on the way out, which is correct and
64
+ // unchanged by this PR. What matters is what telemetry recorded first.
65
+ await expect(
66
+ invokeHandler(server, 'tools/call', {
67
+ name: 'silent_tool',
68
+ arguments: {},
69
+ }),
70
+ ).rejects.toBeDefined();
71
+
72
+ expect(events).toHaveLength(1);
73
+ expect(events[0]).toMatchObject({
74
+ type: 'tool_call',
75
+ tool: 'silent_tool',
76
+ outcome: 'error',
77
+ });
78
+ });
79
+
80
+ it('still reports success for a real result and error for an isError one', async () => {
81
+ const events: TelemetryEvent[] = [];
82
+ const ok: LocalTool = {
83
+ tool: {
84
+ name: 'ok_tool',
85
+ description: 'ok',
86
+ inputSchema: { type: 'object', properties: {} },
87
+ },
88
+ handler: async () => ({ content: [{ type: 'text', text: 'fine' }] }),
89
+ };
90
+ const failing: LocalTool = {
91
+ tool: {
92
+ name: 'bad_tool',
93
+ description: 'bad',
94
+ inputSchema: { type: 'object', properties: {} },
95
+ },
96
+ handler: async () => ({
97
+ content: [{ type: 'text', text: 'nope' }],
98
+ isError: true,
99
+ }),
100
+ };
101
+
102
+ const server = createProxyServer(
103
+ { crm: makeBackend() },
104
+ {
105
+ name: 'test-server',
106
+ localTools: [ok, failing],
107
+ onTelemetry: (e) => events.push(e),
108
+ },
109
+ );
110
+
111
+ await invokeHandler(server, 'tools/call', {
112
+ name: 'ok_tool',
113
+ arguments: {},
114
+ });
115
+ await invokeHandler(server, 'tools/call', {
116
+ name: 'bad_tool',
117
+ arguments: {},
118
+ });
119
+
120
+ expect(events.map((e) => (e as { outcome?: string }).outcome)).toEqual([
121
+ 'success',
122
+ 'error',
123
+ ]);
124
+ });
125
+ });
126
+
127
+ /**
128
+ * The legacy `{ toolResult }` shape (protocol 2024-10-07) is a valid, documented
129
+ * result — `mapToolResult` returns it untouched and the guard answers `false` for
130
+ * it because it has no `.content`. It is not a failure, so it must not be
131
+ * telemetered as one.
132
+ */
133
+ describe('tools/call telemetry — the legacy { toolResult } shape', () => {
134
+ it('reports success for a legacy-shape result', async () => {
135
+ const events: TelemetryEvent[] = [];
136
+ const backend = makeBackend();
137
+ backend.callTool = vi.fn().mockResolvedValue({
138
+ toolResult: { raw: true },
139
+ }) as BackendClient['callTool'];
140
+
141
+ const server = createProxyServer(
142
+ { crm: backend },
143
+ { name: 'test-server', onTelemetry: (e) => events.push(e) },
144
+ );
145
+
146
+ await invokeHandler(server, 'tools/call', {
147
+ name: 'crm__legacy_tool',
148
+ arguments: {},
149
+ });
150
+
151
+ expect(events).toHaveLength(1);
152
+ expect(events[0]).toMatchObject({
153
+ type: 'tool_call',
154
+ outcome: 'success',
155
+ });
156
+ });
157
+ });
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { hasToolContent, mapToolResult } from '../core.js';
3
+ import type {
4
+ CallToolResult,
5
+ CompatibilityCallToolResult,
6
+ } from '@modelcontextprotocol/sdk/types.js';
7
+
8
+ /**
9
+ * `hasToolContent` narrows by reading `.content`, and a value that is not a
10
+ * tool result has none: the legacy `{ toolResult }` shape, and a pipeline that
11
+ * resolved to nothing at all (a local tool handler with a missing `return`, or
12
+ * a middleware that dropped the result). It is a guard, so the answer to
13
+ * "is this a CallToolResult?" has to be `false`, not a thrown TypeError.
14
+ *
15
+ * Both in-repo call sites depend on that answer rather than on a throw:
16
+ * `mapToolResult` returns the value untouched when the guard says `false`, and
17
+ * the `tools/call` handler reads `result.isError` only behind it. A throw here
18
+ * instead replaces whatever the pipeline produced with
19
+ * `TypeError: Cannot read properties of undefined (reading 'content')`.
20
+ */
21
+
22
+ /** Callers reach this through a pipeline result, which the types do not pin. */
23
+ const untyped = (value: unknown): CompatibilityCallToolResult =>
24
+ value as CompatibilityCallToolResult;
25
+
26
+ describe('hasToolContent()', () => {
27
+ it('is true for a CallToolResult, even an empty content array', () => {
28
+ const result: CallToolResult = { content: [] };
29
+ expect(hasToolContent(result)).toBe(true);
30
+ });
31
+
32
+ it('is false for the legacy { toolResult } shape, which has no content', () => {
33
+ const legacy: CompatibilityCallToolResult = {
34
+ toolResult: { raw: true },
35
+ };
36
+ expect(hasToolContent(legacy)).toBe(false);
37
+ });
38
+
39
+ it('is false for a non-object, rather than throwing', () => {
40
+ expect(hasToolContent(untyped('a string'))).toBe(false);
41
+ expect(hasToolContent(untyped(42))).toBe(false);
42
+ });
43
+
44
+ for (const [label, value] of [
45
+ ['undefined', undefined],
46
+ ['null', null],
47
+ ] as const) {
48
+ it(`is false for a ${label} result rather than throwing`, () => {
49
+ expect(hasToolContent(untyped(value))).toBe(false);
50
+ });
51
+
52
+ it(`mapToolResult returns a ${label} result untouched`, () => {
53
+ const result = untyped(value);
54
+ expect(
55
+ mapToolResult(result, {
56
+ onText: () => null,
57
+ onOther: () => null,
58
+ onStructured: () => undefined,
59
+ }),
60
+ ).toBe(result);
61
+ });
62
+ }
63
+ });
@@ -147,6 +147,27 @@ describe('sanitizeToolDescriptions()', () => {
147
147
  expect(result.tools[0]?.description).toBe('see for details');
148
148
  });
149
149
 
150
+ it.each([
151
+ ['uppercase', 'HTTPS'],
152
+ ['mixed case', 'HtTp'],
153
+ ['mixed-case with s', 'HTtpS'],
154
+ ])(
155
+ 'strips a %s scheme, which RFC 3986 treats as case-insensitive',
156
+ async (_label, scheme) => {
157
+ const upstream: ListToolsResult = {
158
+ tools: [
159
+ {
160
+ name: 't',
161
+ description: `see ${scheme}://internal.host/a for details`,
162
+ inputSchema: { type: 'object' },
163
+ },
164
+ ],
165
+ };
166
+ const result = await run(sanitizeToolDescriptions(), upstream);
167
+ expect(result.tools[0]?.description).toBe('see for details');
168
+ },
169
+ );
170
+
150
171
  it('accepts a pattern that already carries the global flag', async () => {
151
172
  const upstream: ListToolsResult = {
152
173
  tools: [
@@ -221,6 +221,8 @@ describe('startHttpProxy() session lifecycle', () => {
221
221
  ['sessionTtlMs', 0],
222
222
  ['sessionTtlMs', -1],
223
223
  ['sessionTtlMs', Number.NaN],
224
+ ['maxBodyBytes', Number.NaN],
225
+ ['maxBodyBytes', -1],
224
226
  ])('rejects %s: %s at startup', (option, value) => {
225
227
  expect(() =>
226
228
  startHttpProxy(
@@ -270,6 +272,41 @@ describe('startHttpProxy() session lifecycle', () => {
270
272
  await closeServer(server);
271
273
  }
272
274
  });
275
+
276
+ it('accepts maxBodyBytes: Infinity as the opt-out from the cap', async () => {
277
+ const server = await startHttpProxy(
278
+ makeMockBackend(),
279
+ { name: 'test-server' },
280
+ { port: 0, path: '/mcp', maxBodyBytes: Infinity },
281
+ );
282
+ const baseUrl = `http://localhost:${getPort(server)}`;
283
+ try {
284
+ await initSession(baseUrl);
285
+ } finally {
286
+ await closeServer(server);
287
+ }
288
+ });
289
+
290
+ it('keeps maxBodyBytes: 0 a cap that rejects every POST body', async () => {
291
+ // `0` is not the opt-out and not a typo to be forgiven: like
292
+ // `maxSessions: 0`, it keeps its literal meaning.
293
+ const server = await startHttpProxy(
294
+ makeMockBackend(),
295
+ { name: 'test-server' },
296
+ { port: 0, path: '/mcp', maxBodyBytes: 0 },
297
+ );
298
+ const baseUrl = `http://localhost:${getPort(server)}`;
299
+ try {
300
+ const res = await fetch(`${baseUrl}/mcp`, {
301
+ method: 'POST',
302
+ headers: MCP_HEADERS,
303
+ body: INIT_BODY,
304
+ });
305
+ expect(res.status).toBe(413);
306
+ } finally {
307
+ await closeServer(server);
308
+ }
309
+ });
273
310
  });
274
311
  });
275
312
 
@@ -452,6 +452,86 @@ describe('startHttpProxy()', () => {
452
452
  }
453
453
  });
454
454
 
455
+ it('still answers 401 when onError itself throws (#238 sibling)', async () => {
456
+ // `onError` is observability, so its own failure must change nothing.
457
+ // Unguarded it replaces the 401 documented for a throwing
458
+ // `resolveIdentity` with a 500, and rejects out of the request handler.
459
+ const server = await startHttpProxy(
460
+ makeMockBackend(),
461
+ { name: 'test-server' },
462
+ {
463
+ port: 0,
464
+ path: '/mcp',
465
+ onError: () => {
466
+ throw new Error('logging backend down');
467
+ },
468
+ resolveIdentity: () => {
469
+ throw new Error('jwks fetch failed');
470
+ },
471
+ },
472
+ );
473
+ const baseUrl = `http://localhost:${getPort(server)}`;
474
+
475
+ try {
476
+ const res = await fetch(`${baseUrl}/mcp`, {
477
+ method: 'POST',
478
+ headers: { 'Content-Type': 'application/json' },
479
+ body: initializeBody,
480
+ });
481
+ expect(res.status).toBe(401);
482
+ } finally {
483
+ await closeServer(server);
484
+ }
485
+ });
486
+
487
+ it('still answers 401 when onError rejects asynchronously (#245)', async () => {
488
+ // `onError` is observability, so its own failure must change nothing.
489
+ // An async reporter that rejects after its first await escapes the
490
+ // synchronous guard: nothing observes the rejection, so Node's default
491
+ // --unhandled-rejections=throw ends the process.
492
+ const rejections: unknown[] = [];
493
+ const onUnhandled = (reason: unknown): void => {
494
+ rejections.push(reason);
495
+ };
496
+ process.on('unhandledRejection', onUnhandled);
497
+
498
+ try {
499
+ const server = await startHttpProxy(
500
+ makeMockBackend(),
501
+ { name: 'test-server' },
502
+ {
503
+ port: 0,
504
+ path: '/mcp',
505
+ onError: async () => {
506
+ await new Promise((resolve) => setTimeout(resolve, 1));
507
+ throw new Error('logging backend down');
508
+ },
509
+ resolveIdentity: () => {
510
+ throw new Error('jwks fetch failed');
511
+ },
512
+ },
513
+ );
514
+ const baseUrl = `http://localhost:${getPort(server)}`;
515
+
516
+ try {
517
+ const res = await fetch(`${baseUrl}/mcp`, {
518
+ method: 'POST',
519
+ headers: { 'Content-Type': 'application/json' },
520
+ body: initializeBody,
521
+ });
522
+ expect(res.status).toBe(401);
523
+ // One macrotask turn, so a rejection nobody adopted has surfaced as
524
+ // an unhandledRejection event by the time this asserts.
525
+ await new Promise((resolve) => setTimeout(resolve, 20));
526
+ expect(rejections).toHaveLength(0);
527
+ } finally {
528
+ await closeServer(server);
529
+ }
530
+ } finally {
531
+ process.removeListener('unhandledRejection', onUnhandled);
532
+ }
533
+ });
534
+
455
535
  it('reports an error thrown by validateSession instead of only discarding it', async () => {
456
536
  const errors: unknown[] = [];
457
537
  const server = await startHttpProxy(