Skip to content

Commit ad2fd2a

Browse files
claude[bot]olaservo
andcommitted
Fix STDIO server restart issue and add comprehensive tests
This commit resolves issue #654 where STDIO servers with lifespan contexts failed to restart after disconnection. ## Problem STDIO servers with lifespan contexts couldn't restart after disconnect because: - Transport references remained in global Maps after disconnect - Stale SSEServerTransport instances tried to send through closed connections - Resulted in "Not connected" errors and ERR_CONNECTION_REFUSED on reconnect ## Solution - Added optional onCleanup callback to mcpProxy function - Modified server routes to provide cleanup functions that remove transport references - Ensures webAppTransports and serverTransports Maps are cleaned up properly - Cleanup triggers when either client or server transports close ## Tests Added - Unit tests for mcpProxy function covering message forwarding, error handling, and cleanup - Integration tests simulating real-world STDIO server restart scenarios - Tests demonstrate both the original bug and the fix 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Ola Hungerford <[email protected]>
1 parent 7fc4e87 commit ad2fd2a

File tree

7 files changed

+715
-2
lines changed

7 files changed

+715
-2
lines changed

server/jest.config.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export default {
2+
preset: "ts-jest",
3+
testEnvironment: "node",
4+
roots: ["<rootDir>/src"],
5+
testMatch: ["**/__tests__/**/*.test.ts", "**/*.test.ts"],
6+
transform: {
7+
"^.+\\.ts$": "ts-jest",
8+
},
9+
moduleNameMapping: {
10+
"^(\\.{1,2}/.*)\\.js$": "$1",
11+
},
12+
extensionsToTreatAsEsm: [".ts"],
13+
globals: {
14+
"ts-jest": {
15+
useESM: true,
16+
},
17+
},
18+
};

server/package.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,28 @@
1717
"build": "tsc",
1818
"start": "node build/index.js",
1919
"dev": "tsx watch --clear-screen=false src/index.ts",
20-
"dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL"
20+
"dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL",
21+
"test": "jest"
2122
},
2223
"devDependencies": {
2324
"@types/cors": "^2.8.19",
2425
"@types/express": "^4.17.23",
26+
"@types/jest": "^29.5.14",
27+
"@types/shell-quote": "^1.7.5",
28+
"@types/supertest": "^6.0.2",
2529
"@types/ws": "^8.5.12",
30+
"jest": "^29.7.0",
31+
"supertest": "^7.0.0",
32+
"ts-jest": "^29.4.0",
2633
"tsx": "^4.19.0",
2734
"typescript": "^5.6.2"
2835
},
2936
"dependencies": {
3037
"@modelcontextprotocol/sdk": "^1.17.3",
3138
"cors": "^2.8.5",
3239
"express": "^5.1.0",
40+
"shell-quote": "^1.8.3",
41+
"spawn-rx": "^5.1.2",
3342
"ws": "^8.18.0",
3443
"zod": "^3.25.76"
3544
}
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
import { jest } from "@jest/globals";
2+
import mcpProxy from "../mcpProxy.js";
3+
4+
// Mock transport interface
5+
interface MockTransport {
6+
sessionId?: string;
7+
onmessage: ((message: any) => void) | null;
8+
onclose: (() => void) | null;
9+
onerror: ((error: Error) => void) | null;
10+
send: jest.Mock;
11+
close: jest.Mock;
12+
}
13+
14+
// Create mock transport
15+
function createMockTransport(sessionId?: string): MockTransport {
16+
return {
17+
sessionId,
18+
onmessage: null,
19+
onclose: null,
20+
onerror: null,
21+
send: jest.fn().mockResolvedValue(undefined),
22+
close: jest.fn().mockResolvedValue(undefined),
23+
};
24+
}
25+
26+
describe("mcpProxy", () => {
27+
let mockClientTransport: MockTransport;
28+
let mockServerTransport: MockTransport;
29+
let mockCleanup: jest.Mock;
30+
31+
beforeEach(() => {
32+
mockClientTransport = createMockTransport("client-session-123");
33+
mockServerTransport = createMockTransport("server-session-456");
34+
mockCleanup = jest.fn();
35+
});
36+
37+
afterEach(() => {
38+
jest.clearAllMocks();
39+
});
40+
41+
describe("message forwarding", () => {
42+
it("should forward messages from client to server", async () => {
43+
mcpProxy({
44+
transportToClient: mockClientTransport as any,
45+
transportToServer: mockServerTransport as any,
46+
onCleanup: mockCleanup,
47+
});
48+
49+
const testMessage = {
50+
jsonrpc: "2.0" as const,
51+
method: "test/method",
52+
params: { test: "data" },
53+
id: 1,
54+
};
55+
56+
// Simulate client message
57+
mockClientTransport.onmessage!(testMessage);
58+
59+
expect(mockServerTransport.send).toHaveBeenCalledWith(testMessage);
60+
});
61+
62+
it("should forward messages from server to client", async () => {
63+
mcpProxy({
64+
transportToClient: mockClientTransport as any,
65+
transportToServer: mockServerTransport as any,
66+
onCleanup: mockCleanup,
67+
});
68+
69+
const testMessage = {
70+
jsonrpc: "2.0" as const,
71+
result: { test: "response" },
72+
id: 1,
73+
};
74+
75+
// Simulate server message
76+
mockServerTransport.onmessage!(testMessage);
77+
78+
expect(mockClientTransport.send).toHaveBeenCalledWith(testMessage);
79+
});
80+
});
81+
82+
describe("error handling", () => {
83+
it("should send error response when server send fails for request", async () => {
84+
const serverError = new Error("Server send failed");
85+
mockServerTransport.send.mockRejectedValue(serverError);
86+
87+
mcpProxy({
88+
transportToClient: mockClientTransport as any,
89+
transportToServer: mockServerTransport as any,
90+
onCleanup: mockCleanup,
91+
});
92+
93+
const testRequest = {
94+
jsonrpc: "2.0" as const,
95+
method: "test/method",
96+
params: { test: "data" },
97+
id: 1,
98+
};
99+
100+
// Simulate client request that fails on server
101+
mockClientTransport.onmessage!(testRequest);
102+
103+
// Wait for the async error handling
104+
await new Promise((resolve) => setTimeout(resolve, 0));
105+
106+
expect(mockClientTransport.send).toHaveBeenCalledWith({
107+
jsonrpc: "2.0",
108+
id: 1,
109+
error: {
110+
code: -32001,
111+
message: "Server send failed",
112+
data: serverError,
113+
},
114+
});
115+
});
116+
117+
it("should not send error response when client transport is closed", async () => {
118+
const serverError = new Error("Server send failed");
119+
mockServerTransport.send.mockRejectedValue(serverError);
120+
121+
mcpProxy({
122+
transportToClient: mockClientTransport as any,
123+
transportToServer: mockServerTransport as any,
124+
onCleanup: mockCleanup,
125+
});
126+
127+
// Close client transport first
128+
mockClientTransport.onclose!();
129+
130+
const testRequest = {
131+
jsonrpc: "2.0" as const,
132+
method: "test/method",
133+
params: { test: "data" },
134+
id: 1,
135+
};
136+
137+
// Now try to send message
138+
mockClientTransport.onmessage!(testRequest);
139+
140+
// Wait for the async error handling
141+
await new Promise((resolve) => setTimeout(resolve, 0));
142+
143+
// Should not send error response since client transport is closed
144+
expect(mockClientTransport.send).toHaveBeenCalledTimes(0);
145+
});
146+
147+
it("should not send error response for notifications (no id)", async () => {
148+
const serverError = new Error("Server send failed");
149+
mockServerTransport.send.mockRejectedValue(serverError);
150+
151+
mcpProxy({
152+
transportToClient: mockClientTransport as any,
153+
transportToServer: mockServerTransport as any,
154+
onCleanup: mockCleanup,
155+
});
156+
157+
const testNotification = {
158+
jsonrpc: "2.0" as const,
159+
method: "test/notification",
160+
params: { test: "data" },
161+
};
162+
163+
// Simulate client notification that fails on server
164+
mockClientTransport.onmessage!(testNotification);
165+
166+
// Wait for the async error handling
167+
await new Promise((resolve) => setTimeout(resolve, 0));
168+
169+
// Should not send error response for notifications
170+
expect(mockClientTransport.send).toHaveBeenCalledTimes(0);
171+
});
172+
});
173+
174+
describe("connection cleanup", () => {
175+
it("should call cleanup when client transport closes", () => {
176+
mcpProxy({
177+
transportToClient: mockClientTransport as any,
178+
transportToServer: mockServerTransport as any,
179+
onCleanup: mockCleanup,
180+
});
181+
182+
// Simulate client transport closing
183+
mockClientTransport.onclose!();
184+
185+
expect(mockCleanup).toHaveBeenCalledTimes(1);
186+
expect(mockServerTransport.close).toHaveBeenCalledTimes(1);
187+
});
188+
189+
it("should call cleanup when server transport closes", () => {
190+
mcpProxy({
191+
transportToClient: mockClientTransport as any,
192+
transportToServer: mockServerTransport as any,
193+
onCleanup: mockCleanup,
194+
});
195+
196+
// Simulate server transport closing
197+
mockServerTransport.onclose!();
198+
199+
expect(mockCleanup).toHaveBeenCalledTimes(1);
200+
expect(mockClientTransport.close).toHaveBeenCalledTimes(1);
201+
});
202+
203+
it("should not call cleanup twice if both transports close", () => {
204+
mcpProxy({
205+
transportToClient: mockClientTransport as any,
206+
transportToServer: mockServerTransport as any,
207+
onCleanup: mockCleanup,
208+
});
209+
210+
// Simulate both transports closing
211+
mockClientTransport.onclose!();
212+
mockServerTransport.onclose!();
213+
214+
expect(mockCleanup).toHaveBeenCalledTimes(1);
215+
});
216+
217+
it("should work without cleanup callback", () => {
218+
expect(() => {
219+
mcpProxy({
220+
transportToClient: mockClientTransport as any,
221+
transportToServer: mockServerTransport as any,
222+
});
223+
224+
// Should not throw when cleanup is not provided
225+
mockClientTransport.onclose!();
226+
}).not.toThrow();
227+
});
228+
229+
it("should handle cleanup callback errors gracefully", () => {
230+
const errorCleanup = jest.fn().mockImplementation(() => {
231+
throw new Error("Cleanup failed");
232+
});
233+
234+
expect(() => {
235+
mcpProxy({
236+
transportToClient: mockClientTransport as any,
237+
transportToServer: mockServerTransport as any,
238+
onCleanup: errorCleanup,
239+
});
240+
241+
// Should not throw even if cleanup fails
242+
mockClientTransport.onclose!();
243+
}).not.toThrow();
244+
245+
expect(errorCleanup).toHaveBeenCalledTimes(1);
246+
});
247+
});
248+
249+
describe("transport close synchronization", () => {
250+
it("should not close server transport if already closed by server", () => {
251+
mcpProxy({
252+
transportToClient: mockClientTransport as any,
253+
transportToServer: mockServerTransport as any,
254+
onCleanup: mockCleanup,
255+
});
256+
257+
// First, server transport closes
258+
mockServerTransport.onclose!();
259+
260+
// Reset mock to check if close is called again
261+
mockServerTransport.close.mockClear();
262+
263+
// Then client transport tries to close
264+
mockClientTransport.onclose!();
265+
266+
// Server transport should not be closed again
267+
expect(mockServerTransport.close).toHaveBeenCalledTimes(0);
268+
expect(mockCleanup).toHaveBeenCalledTimes(1);
269+
});
270+
271+
it("should not close client transport if already closed by client", () => {
272+
mcpProxy({
273+
transportToClient: mockClientTransport as any,
274+
transportToServer: mockServerTransport as any,
275+
onCleanup: mockCleanup,
276+
});
277+
278+
// First, client transport closes
279+
mockClientTransport.onclose!();
280+
281+
// Reset mock to check if close is called again
282+
mockClientTransport.close.mockClear();
283+
284+
// Then server transport tries to close
285+
mockServerTransport.onclose!();
286+
287+
// Client transport should not be closed again
288+
expect(mockClientTransport.close).toHaveBeenCalledTimes(0);
289+
expect(mockCleanup).toHaveBeenCalledTimes(1);
290+
});
291+
});
292+
293+
describe("error handlers", () => {
294+
it("should set error handlers on both transports", () => {
295+
mcpProxy({
296+
transportToClient: mockClientTransport as any,
297+
transportToServer: mockServerTransport as any,
298+
onCleanup: mockCleanup,
299+
});
300+
301+
expect(mockClientTransport.onerror).toBeTruthy();
302+
expect(mockServerTransport.onerror).toBeTruthy();
303+
});
304+
305+
it("should handle client errors without throwing", () => {
306+
mcpProxy({
307+
transportToClient: mockClientTransport as any,
308+
transportToServer: mockServerTransport as any,
309+
onCleanup: mockCleanup,
310+
});
311+
312+
const testError = new Error("Client error");
313+
expect(() => {
314+
mockClientTransport.onerror!(testError);
315+
}).not.toThrow();
316+
});
317+
318+
it("should handle server errors without throwing", () => {
319+
mcpProxy({
320+
transportToClient: mockClientTransport as any,
321+
transportToServer: mockServerTransport as any,
322+
onCleanup: mockCleanup,
323+
});
324+
325+
const testError = new Error("Server error");
326+
expect(() => {
327+
mockServerTransport.onerror!(testError);
328+
}).not.toThrow();
329+
});
330+
});
331+
});

0 commit comments

Comments
 (0)