1
+ """
2
+ Test for issue #1027: The cleanup procedure after "yield" in lifespan is unreachable on Windows
3
+
4
+ This test demonstrates that on Windows, when an MCP server is terminated via process.terminate(),
5
+ the cleanup code after yield in the lifespan context manager is not executed.
6
+
7
+ The issue occurs because Windows' TerminateProcess() forcefully kills the process without
8
+ allowing cleanup handlers to run.
9
+ """
10
+
11
+ import asyncio
12
+ import sys
13
+ import tempfile
14
+ import textwrap
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+
19
+ from mcp import ClientSession , StdioServerParameters
20
+ from mcp .client .stdio import stdio_client
21
+
22
+
23
+ @pytest .mark .anyio
24
+ async def test_lifespan_cleanup_executed ():
25
+ """
26
+ Test that verifies cleanup code in MCP server lifespan is executed.
27
+
28
+ This test creates an MCP server that writes to marker files:
29
+ 1. When the server starts (before yield)
30
+ 2. When the server cleanup runs (after yield)
31
+
32
+ On Windows with the current implementation, the cleanup file is never created
33
+ because process.terminate() kills the process immediately.
34
+ """
35
+
36
+ # Create marker files to track server lifecycle
37
+ with tempfile .NamedTemporaryFile (mode = "w" , delete = False , suffix = ".txt" ) as f :
38
+ startup_marker = f .name
39
+ with tempfile .NamedTemporaryFile (mode = "w" , delete = False , suffix = ".txt" ) as f :
40
+ cleanup_marker = f .name
41
+
42
+ # Remove the files so we can detect when they're created
43
+ Path (startup_marker ).unlink ()
44
+ Path (cleanup_marker ).unlink ()
45
+
46
+ # Create a minimal MCP server that tracks lifecycle
47
+ server_code = textwrap .dedent (f"""
48
+ import asyncio
49
+ import sys
50
+ from pathlib import Path
51
+ from contextlib import asynccontextmanager
52
+ from mcp.server.lowlevel import Server
53
+ from mcp.server.stdio import stdio_server
54
+
55
+ STARTUP_MARKER = { repr (startup_marker )}
56
+ CLEANUP_MARKER = { repr (cleanup_marker )}
57
+
58
+ @asynccontextmanager
59
+ async def lifespan(server):
60
+ # Write startup marker
61
+ Path(STARTUP_MARKER).write_text("started")
62
+ try:
63
+ yield
64
+ finally:
65
+ # This cleanup code should run when server shuts down
66
+ Path(CLEANUP_MARKER).write_text("cleaned up")
67
+
68
+ async def main():
69
+ async with stdio_server() as (read, write):
70
+ server = Server("test-server", version="1.0.0")
71
+ server.set_lifespan(lifespan)
72
+
73
+ async def handle_initialize(params):
74
+ return {{"protocolVersion": "1.0.0", "capabilities": {{}}}}
75
+
76
+ server.set_request_handler("initialize", handle_initialize)
77
+
78
+ async def handle_echo(params):
79
+ return {{"text": params.get("text", "")}}
80
+
81
+ server.set_request_handler("test/echo", handle_echo)
82
+
83
+ await server.run(read, write)
84
+
85
+ if __name__ == "__main__":
86
+ import asyncio
87
+ asyncio.run(main())
88
+ """ )
89
+
90
+ # Write the server script to a temporary file
91
+ with tempfile .NamedTemporaryFile (mode = "w" , delete = False , suffix = ".py" ) as f :
92
+ server_script = f .name
93
+ f .write (server_code )
94
+
95
+ try :
96
+ # Launch the MCP server
97
+ params = StdioServerParameters (
98
+ command = sys .executable ,
99
+ args = [server_script ]
100
+ )
101
+
102
+ async with stdio_client (params ) as (read , write ):
103
+ async with ClientSession (read , write ) as session :
104
+ # Initialize the session
105
+ result = await session .initialize ()
106
+ assert result .protocolVersion == "1.0.0"
107
+
108
+ # Verify startup marker was created
109
+ assert Path (startup_marker ).exists (), "Server startup marker not created"
110
+ assert Path (startup_marker ).read_text () == "started"
111
+
112
+ # Make a test request to ensure server is working
113
+ response = await session .send_request ("test/echo" , {"text" : "hello" })
114
+ assert response .text == "hello"
115
+
116
+ # Session will be closed when exiting the context manager
117
+
118
+ # Give server a moment to run cleanup (if it can)
119
+ await asyncio .sleep (0.5 )
120
+
121
+ # Check if cleanup marker was created
122
+ if sys .platform == "win32" :
123
+ # On Windows, this assertion currently fails because cleanup doesn't run
124
+ # When issue #1027 is fixed, this should pass
125
+ pytest .xfail (
126
+ "Cleanup code after yield is not executed on Windows (issue #1027)"
127
+ if not Path (cleanup_marker ).exists ()
128
+ else "Cleanup unexpectedly succeeded - issue may be fixed!"
129
+ )
130
+ else :
131
+ # On Unix systems, cleanup should work
132
+ assert Path (cleanup_marker ).exists (), "Server cleanup marker not created"
133
+ assert Path (cleanup_marker ).read_text () == "cleaned up"
134
+
135
+ finally :
136
+ # Clean up files
137
+ for path in [server_script , startup_marker , cleanup_marker ]:
138
+ try :
139
+ Path (path ).unlink ()
140
+ except FileNotFoundError :
141
+ pass
142
+
143
+
144
+ @pytest .mark .anyio
145
+ async def test_stdin_close_triggers_cleanup ():
146
+ """
147
+ Test that verifies if closing stdin allows cleanup to run.
148
+
149
+ This is the proposed solution from PR #1044 - close stdin first
150
+ and wait for the server to exit gracefully before terminating.
151
+ """
152
+
153
+ # Create marker files to track server lifecycle
154
+ with tempfile .NamedTemporaryFile (mode = "w" , delete = False , suffix = ".txt" ) as f :
155
+ startup_marker = f .name
156
+ with tempfile .NamedTemporaryFile (mode = "w" , delete = False , suffix = ".txt" ) as f :
157
+ cleanup_marker = f .name
158
+
159
+ # Remove the files so we can detect when they're created
160
+ Path (startup_marker ).unlink ()
161
+ Path (cleanup_marker ).unlink ()
162
+
163
+ # Create an MCP server that handles stdin closure gracefully
164
+ server_code = textwrap .dedent (f"""
165
+ import asyncio
166
+ import sys
167
+ from pathlib import Path
168
+ from contextlib import asynccontextmanager
169
+ from mcp.server.lowlevel import Server
170
+ from mcp.server.stdio import stdio_server
171
+
172
+ STARTUP_MARKER = { repr (startup_marker )}
173
+ CLEANUP_MARKER = { repr (cleanup_marker )}
174
+
175
+ @asynccontextmanager
176
+ async def lifespan(server):
177
+ # Write startup marker
178
+ Path(STARTUP_MARKER).write_text("started")
179
+ try:
180
+ yield
181
+ finally:
182
+ # This cleanup code should run when stdin closes
183
+ Path(CLEANUP_MARKER).write_text("cleaned up")
184
+
185
+ async def main():
186
+ try:
187
+ async with stdio_server() as (read, write):
188
+ server = Server("test-server", version="1.0.0")
189
+ server.set_lifespan(lifespan)
190
+
191
+ async def handle_initialize(params):
192
+ return {{"protocolVersion": "1.0.0", "capabilities": {{}}}}
193
+
194
+ server.set_request_handler("initialize", handle_initialize)
195
+
196
+ # The server should exit gracefully when stdin closes
197
+ await server.run(read, write)
198
+ except Exception:
199
+ # Server might get EOF or other errors when stdin closes
200
+ pass
201
+
202
+ if __name__ == "__main__":
203
+ asyncio.run(main())
204
+ """ )
205
+
206
+ # Write the server script to a temporary file
207
+ with tempfile .NamedTemporaryFile (mode = "w" , delete = False , suffix = ".py" ) as f :
208
+ server_script = f .name
209
+ f .write (server_code )
210
+
211
+ try :
212
+ # This test manually manages the process to test stdin closure
213
+ import anyio
214
+ from mcp .client .stdio import _create_platform_compatible_process
215
+
216
+ # Start the server process
217
+ process = await _create_platform_compatible_process (
218
+ command = sys .executable ,
219
+ args = [server_script ],
220
+ env = None ,
221
+ stderr = None ,
222
+ cwd = None
223
+ )
224
+
225
+ # Wait for server to start
226
+ await asyncio .sleep (0.5 )
227
+ assert Path (startup_marker ).exists (), "Server startup marker not created"
228
+
229
+ # Close stdin to signal shutdown
230
+ if process .stdin :
231
+ await process .stdin .aclose ()
232
+
233
+ # Wait for process to exit gracefully
234
+ try :
235
+ with anyio .fail_after (2.0 ):
236
+ await process .wait ()
237
+ except TimeoutError :
238
+ # If it doesn't exit after stdin close, terminate it
239
+ process .terminate ()
240
+ await process .wait ()
241
+
242
+ # Check if cleanup ran
243
+ await asyncio .sleep (0.5 )
244
+
245
+ # This should work if the server properly handles stdin closure
246
+ if Path (cleanup_marker ).exists ():
247
+ assert Path (cleanup_marker ).read_text () == "cleaned up"
248
+ # If this works, it shows stdin closure can trigger graceful shutdown
249
+ else :
250
+ pytest .xfail (
251
+ "Server did not run cleanup after stdin closure - "
252
+ "may need additional server-side handling"
253
+ )
254
+
255
+ finally :
256
+ # Clean up files
257
+ for path in [server_script , startup_marker , cleanup_marker ]:
258
+ try :
259
+ Path (path ).unlink ()
260
+ except FileNotFoundError :
261
+ pass
0 commit comments