Skip to content

Commit e39a5ad

Browse files
committed
Implemented safe disconnect for quick disconnect test cases (that failed infrequently).
1 parent e1bb086 commit e39a5ad

9 files changed

Lines changed: 54 additions & 135 deletions

core/__tests__/mcp/state/managedPromptsState.test.ts

Lines changed: 4 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* ManagedPromptsState tests use a real InspectorClient and test server (same model
33
* as managedToolsState.test.ts) so we exercise actual client/server behavior.
44
*/
5-
import { describe, it, expect, afterEach, beforeEach } from "vitest";
5+
import { describe, it, expect, afterEach } from "vitest";
66
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
77
import { InspectorClient } from "../../../mcp/inspectorClient.js";
88
import { createTransportNode } from "../../../mcp/node/transport.js";
@@ -20,37 +20,13 @@ describe("ManagedPromptsState", () => {
2020
let server: TestServerHttp | null = null;
2121
let state: ManagedPromptsState | null = null;
2222

23-
let unhandledRejectionHandler: (
24-
reason: unknown,
25-
promise: Promise<unknown>,
26-
) => void;
27-
beforeEach(() => {
28-
unhandledRejectionHandler = (
29-
reason: unknown,
30-
promise: Promise<unknown>,
31-
) => {
32-
const err = reason as { code?: number; message?: string };
33-
if (err?.code === -32000 || err?.message?.includes("Connection closed")) {
34-
promise.catch(() => {});
35-
return;
36-
}
37-
throw reason;
38-
};
39-
process.on("unhandledRejection", unhandledRejectionHandler);
40-
});
4123
afterEach(async () => {
4224
if (state) {
4325
state.destroy();
4426
state = null;
4527
}
46-
if (client) {
47-
try {
48-
await client.disconnect();
49-
} catch {
50-
// ignore
51-
}
52-
client = null;
53-
}
28+
if (client) await client.disconnect(100);
29+
client = null;
5430
if (server) {
5531
try {
5632
await server.stop();
@@ -59,7 +35,6 @@ describe("ManagedPromptsState", () => {
5935
}
6036
server = null;
6137
}
62-
process.off("unhandledRejection", unhandledRejectionHandler);
6338
});
6439

6540
function waitForPromptsChange(s: ManagedPromptsState): Promise<Prompt[]> {
@@ -168,7 +143,7 @@ describe("ManagedPromptsState", () => {
168143
await client.connect();
169144
await waitForPromptsChange(state!);
170145
expect(state!.getPrompts().length).toBeGreaterThan(0);
171-
await client!.disconnect();
146+
await client!.disconnect(100);
172147
expect(state!.getPrompts()).toEqual([]);
173148
});
174149

core/__tests__/mcp/state/managedResourceTemplatesState.test.ts

Lines changed: 4 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* ManagedResourceTemplatesState tests use a real InspectorClient and test server (same model
33
* as managedToolsState.test.ts) so we exercise actual client/server behavior.
44
*/
5-
import { describe, it, expect, afterEach, beforeEach } from "vitest";
5+
import { describe, it, expect, afterEach } from "vitest";
66
import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js";
77
import { InspectorClient } from "../../../mcp/inspectorClient.js";
88
import { createTransportNode } from "../../../mcp/node/transport.js";
@@ -20,38 +20,13 @@ describe("ManagedResourceTemplatesState", () => {
2020
let server: TestServerHttp | null = null;
2121
let state: ManagedResourceTemplatesState | null = null;
2222

23-
let unhandledRejectionHandler: (
24-
reason: unknown,
25-
promise: Promise<unknown>,
26-
) => void;
27-
beforeEach(() => {
28-
unhandledRejectionHandler = (
29-
reason: unknown,
30-
promise: Promise<unknown>,
31-
) => {
32-
const err = reason as { code?: number; message?: string };
33-
if (err?.code === -32000 || err?.message?.includes("Connection closed")) {
34-
promise.catch(() => {});
35-
return;
36-
}
37-
throw reason;
38-
};
39-
process.on("unhandledRejection", unhandledRejectionHandler);
40-
});
41-
4223
afterEach(async () => {
4324
if (state) {
4425
state.destroy();
4526
state = null;
4627
}
47-
if (client) {
48-
try {
49-
await client.disconnect();
50-
} catch {
51-
// ignore
52-
}
53-
client = null;
54-
}
28+
if (client) await client.disconnect(100);
29+
client = null;
5530
if (server) {
5631
try {
5732
await server.stop();
@@ -60,7 +35,6 @@ describe("ManagedResourceTemplatesState", () => {
6035
}
6136
server = null;
6237
}
63-
process.off("unhandledRejection", unhandledRejectionHandler);
6438
});
6539

6640
function waitForResourceTemplatesChange(
@@ -140,7 +114,7 @@ describe("ManagedResourceTemplatesState", () => {
140114
await client.connect();
141115
await waitForResourceTemplatesChange(state!);
142116
expect(state!.getResourceTemplates().length).toBeGreaterThan(0);
143-
await client!.disconnect();
117+
await client!.disconnect(100);
144118
expect(state!.getResourceTemplates()).toEqual([]);
145119
});
146120

core/__tests__/mcp/state/managedResourcesState.test.ts

Lines changed: 5 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* ManagedResourcesState tests use a real InspectorClient and test server (same model
33
* as managedToolsState.test.ts) so we exercise actual client/server behavior.
44
*/
5-
import { describe, it, expect, afterEach, beforeEach } from "vitest";
5+
import { describe, it, expect, afterEach } from "vitest";
66
import type { Resource } from "@modelcontextprotocol/sdk/types.js";
77
import { InspectorClient } from "../../../mcp/inspectorClient.js";
88
import { createTransportNode } from "../../../mcp/node/transport.js";
@@ -20,37 +20,13 @@ describe("ManagedResourcesState", () => {
2020
let server: TestServerHttp | null = null;
2121
let state: ManagedResourcesState | null = null;
2222

23-
let unhandledRejectionHandler: (
24-
reason: unknown,
25-
promise: Promise<unknown>,
26-
) => void;
27-
beforeEach(() => {
28-
unhandledRejectionHandler = (
29-
reason: unknown,
30-
promise: Promise<unknown>,
31-
) => {
32-
const err = reason as { code?: number; message?: string };
33-
if (err?.code === -32000 || err?.message?.includes("Connection closed")) {
34-
promise.catch(() => {});
35-
return;
36-
}
37-
throw reason;
38-
};
39-
process.on("unhandledRejection", unhandledRejectionHandler);
40-
});
4123
afterEach(async () => {
4224
if (state) {
4325
state.destroy();
4426
state = null;
4527
}
46-
if (client) {
47-
try {
48-
await client.disconnect();
49-
} catch {
50-
// ignore
51-
}
52-
client = null;
53-
}
28+
if (client) await client.disconnect(100);
29+
client = null;
5430
if (server) {
5531
try {
5632
await server.stop();
@@ -59,7 +35,6 @@ describe("ManagedResourcesState", () => {
5935
}
6036
server = null;
6137
}
62-
process.off("unhandledRejection", unhandledRejectionHandler);
6338
});
6439

6540
function waitForResourcesChange(
@@ -112,6 +87,7 @@ describe("ManagedResourcesState", () => {
11287
{ type: "streamable-http", url: server.url },
11388
{
11489
environment: { transport: createTransportNode },
90+
clientIdentity: { name: "test", version: "1.0.0" },
11591
},
11692
);
11793
await client.connect();
@@ -175,7 +151,7 @@ describe("ManagedResourcesState", () => {
175151
await client.connect();
176152
await waitForResourcesChange(state!);
177153
expect(state!.getResources().length).toBeGreaterThan(0);
178-
await client!.disconnect();
154+
await client!.disconnect(100);
179155
expect(state!.getResources()).toEqual([]);
180156
});
181157

core/__tests__/mcp/state/managedToolsState.test.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,8 @@ describe("ManagedToolsState", () => {
2525
state.destroy();
2626
state = null;
2727
}
28-
if (client) {
29-
try {
30-
await client.disconnect();
31-
} catch {
32-
// ignore
33-
}
34-
client = null;
35-
}
28+
if (client) await client.disconnect(100);
29+
client = null;
3630
if (server) {
3731
try {
3832
await server.stop();
@@ -167,7 +161,7 @@ describe("ManagedToolsState", () => {
167161
await client.connect();
168162
await waitForToolsChange(state!);
169163
expect(state!.getTools().length).toBeGreaterThan(0);
170-
await client!.disconnect();
164+
await client!.disconnect(100);
171165
expect(state!.getTools()).toEqual([]);
172166
});
173167

core/__tests__/mcp/state/pagedPromptsState.test.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,8 @@ describe("PagedPromptsState", () => {
4444
state.destroy();
4545
state = null;
4646
}
47-
if (client) {
48-
try {
49-
await client.disconnect();
50-
} catch {
51-
// ignore
52-
}
53-
client = null;
54-
}
47+
if (client) await client.disconnect(100);
48+
client = null;
5549
if (server) {
5650
try {
5751
await server.stop();
@@ -161,7 +155,7 @@ describe("PagedPromptsState", () => {
161155
await client.connect();
162156
await state.loadPage();
163157
expect(state.getPrompts().length).toBeGreaterThan(0);
164-
await client!.disconnect();
158+
await client!.disconnect(100);
165159
expect(state!.getPrompts()).toEqual([]);
166160
});
167161

core/__tests__/mcp/state/pagedResourceTemplatesState.test.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,8 @@ describe("PagedResourceTemplatesState", () => {
4444
state.destroy();
4545
state = null;
4646
}
47-
if (client) {
48-
try {
49-
await client.disconnect();
50-
} catch {
51-
// ignore
52-
}
53-
client = null;
54-
}
47+
if (client) await client.disconnect(100);
48+
client = null;
5549
if (server) {
5650
try {
5751
await server.stop();
@@ -163,7 +157,7 @@ describe("PagedResourceTemplatesState", () => {
163157
await client.connect();
164158
await state.loadPage();
165159
expect(state.getResourceTemplates().length).toBeGreaterThan(0);
166-
await client!.disconnect();
160+
await client!.disconnect(100);
167161
expect(state!.getResourceTemplates()).toEqual([]);
168162
});
169163

core/__tests__/mcp/state/pagedResourcesState.test.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,8 @@ describe("PagedResourcesState", () => {
4444
state.destroy();
4545
state = null;
4646
}
47-
if (client) {
48-
try {
49-
await client.disconnect();
50-
} catch {
51-
// ignore
52-
}
53-
client = null;
54-
}
47+
if (client) await client.disconnect(100);
48+
client = null;
5549
if (server) {
5650
try {
5751
await server.stop();
@@ -161,7 +155,7 @@ describe("PagedResourcesState", () => {
161155
await client.connect();
162156
await state.loadPage();
163157
expect(state.getResources().length).toBeGreaterThan(0);
164-
await client!.disconnect();
158+
await client!.disconnect(100);
165159
expect(state!.getResources()).toEqual([]);
166160
});
167161

core/__tests__/mcp/state/pagedToolsState.test.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,8 @@ describe("PagedToolsState", () => {
2525
state.destroy();
2626
state = null;
2727
}
28-
if (client) {
29-
try {
30-
await client.disconnect();
31-
} catch {
32-
// ignore
33-
}
34-
client = null;
35-
}
28+
if (client) await client.disconnect(100);
29+
client = null;
3630
if (server) {
3731
try {
3832
await server.stop();
@@ -149,7 +143,7 @@ describe("PagedToolsState", () => {
149143
await client.connect();
150144
await state.loadPage();
151145
expect(state.getTools().length).toBeGreaterThan(0);
152-
await client!.disconnect();
146+
await client!.disconnect(100);
153147
expect(state!.getTools()).toEqual([]);
154148
});
155149

core/mcp/inspectorClient.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -854,10 +854,34 @@ export class InspectorClient extends InspectorClientEventTarget {
854854
}
855855

856856
/**
857-
* Disconnect from the MCP server
857+
* Disconnect from the MCP server.
858+
* @param safeDisconnectTimeout If > 0, poll every 10ms until SDK _responseHandlers is empty or this many ms have elapsed, then close. Default 0 = close immediately.
858859
*/
859-
async disconnect(): Promise<void> {
860+
async disconnect(safeDisconnectTimeout = 0): Promise<void> {
860861
if (this.client) {
862+
if (safeDisconnectTimeout > 0) {
863+
// This is pretty creepy, but there are test cases where client calls return but there
864+
// are still response handlers pending. Usually a single macrotask delay is enough to
865+
// clear them, but not always (it's been >10ms in some cases). The pending handlers
866+
// themselves get the error (and in cases where those aren't awaited, the errors fly
867+
// out of the test). This workaround where we directly access the handlers (otherwise
868+
// private member of the SDK client) is creepy, but the least ugly working solution.
869+
// We will re-valuate this with the v2 SDK. Currenly only tests that do quick disconnects
870+
// use this setting.
871+
//
872+
const protocol = this.client as unknown as {
873+
_responseHandlers?: Map<unknown, unknown>;
874+
};
875+
const handlers = protocol._responseHandlers;
876+
const deadline = Date.now() + safeDisconnectTimeout;
877+
while (
878+
handlers?.size !== undefined &&
879+
handlers.size > 0 &&
880+
Date.now() < deadline
881+
) {
882+
await new Promise((r) => setTimeout(r, 10));
883+
}
884+
}
861885
try {
862886
await this.client.close();
863887
} catch {

0 commit comments

Comments
 (0)