Skip to content

Commit 1704192

Browse files
authored
Merge branch 'main' into main
2 parents 4360875 + b16c2a8 commit 1704192

File tree

12 files changed

+971
-201
lines changed

12 files changed

+971
-201
lines changed

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ dependencies = [
3232
"pydantic-settings>=2.5.2",
3333
"uvicorn>=0.23.1; sys_platform != 'emscripten'",
3434
"jsonschema>=4.20.0",
35+
"pywin32>=310; sys_platform == 'win32'",
3536
]
3637

3738
[project.optional-dependencies]
@@ -125,4 +126,6 @@ filterwarnings = [
125126
"ignore::DeprecationWarning:websockets",
126127
"ignore:websockets.server.WebSocketServerProtocol is deprecated:DeprecationWarning",
127128
"ignore:Returning str or bytes.*:DeprecationWarning:mcp.server.lowlevel",
129+
# pywin32 internal deprecation warning
130+
"ignore:getargs.*The 'u' format is deprecated:DeprecationWarning"
128131
]

src/mcp/client/stdio/__init__.py

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
import os
23
import sys
34
from contextlib import asynccontextmanager
@@ -6,18 +7,22 @@
67

78
import anyio
89
import anyio.lowlevel
10+
from anyio.abc import Process
911
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
1012
from anyio.streams.text import TextReceiveStream
1113
from pydantic import BaseModel, Field
1214

1315
import mcp.types as types
14-
from mcp.shared.message import SessionMessage
15-
16-
from .win32 import (
16+
from mcp.os.posix.utilities import terminate_posix_process_tree
17+
from mcp.os.win32.utilities import (
18+
FallbackProcess,
1719
create_windows_process,
1820
get_windows_executable_command,
19-
terminate_windows_process,
21+
terminate_windows_process_tree,
2022
)
23+
from mcp.shared.message import SessionMessage
24+
25+
logger = logging.getLogger(__name__)
2126

2227
# Environment variables to inherit by default
2328
DEFAULT_INHERITED_ENV_VARS = (
@@ -38,6 +43,9 @@
3843
else ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"]
3944
)
4045

46+
# Timeout for process termination before falling back to force kill
47+
PROCESS_TERMINATION_TIMEOUT = 2.0
48+
4149

4250
def get_default_environment() -> dict[str, str]:
4351
"""
@@ -180,10 +188,12 @@ async def stdin_writer():
180188
finally:
181189
# Clean up process to prevent any dangling orphaned processes
182190
try:
183-
if sys.platform == "win32":
184-
await terminate_windows_process(process)
185-
else:
186-
process.terminate()
191+
process.terminate()
192+
with anyio.fail_after(PROCESS_TERMINATION_TIMEOUT):
193+
await process.wait()
194+
except TimeoutError:
195+
# If process doesn't terminate in time, force kill it
196+
await _terminate_process_tree(process)
187197
except ProcessLookupError:
188198
# Process already exited, which is fine
189199
pass
@@ -218,11 +228,38 @@ async def _create_platform_compatible_process(
218228
):
219229
"""
220230
Creates a subprocess in a platform-compatible way.
221-
Returns a process handle.
231+
232+
Unix: Creates process in a new session/process group for killpg support
233+
Windows: Creates process in a Job Object for reliable child termination
222234
"""
223235
if sys.platform == "win32":
224236
process = await create_windows_process(command, args, env, errlog, cwd)
225237
else:
226-
process = await anyio.open_process([command, *args], env=env, stderr=errlog, cwd=cwd)
238+
process = await anyio.open_process(
239+
[command, *args],
240+
env=env,
241+
stderr=errlog,
242+
cwd=cwd,
243+
start_new_session=True,
244+
)
227245

228246
return process
247+
248+
249+
async def _terminate_process_tree(process: Process | FallbackProcess, timeout_seconds: float = 2.0) -> None:
250+
"""
251+
Terminate a process and all its children using platform-specific methods.
252+
253+
Unix: Uses os.killpg() for atomic process group termination
254+
Windows: Uses Job Objects via pywin32 for reliable child process cleanup
255+
256+
Args:
257+
process: The process to terminate
258+
timeout_seconds: Timeout in seconds before force killing (default: 2.0)
259+
"""
260+
if sys.platform == "win32":
261+
await terminate_windows_process_tree(process, timeout_seconds)
262+
else:
263+
# FallbackProcess should only be used for Windows compatibility
264+
assert isinstance(process, Process)
265+
await terminate_posix_process_tree(process, timeout_seconds)

src/mcp/client/stdio/win32.py

Lines changed: 0 additions & 182 deletions
This file was deleted.

src/mcp/os/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Platform-specific utilities for MCP."""

src/mcp/os/posix/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""POSIX-specific utilities for MCP."""

src/mcp/os/posix/utilities.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""
2+
POSIX-specific functionality for stdio client operations.
3+
"""
4+
5+
import logging
6+
import os
7+
import signal
8+
9+
import anyio
10+
from anyio.abc import Process
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
async def terminate_posix_process_tree(process: Process, timeout_seconds: float = 2.0) -> None:
16+
"""
17+
Terminate a process and all its children on POSIX systems.
18+
19+
Uses os.killpg() for atomic process group termination.
20+
21+
Args:
22+
process: The process to terminate
23+
timeout_seconds: Timeout in seconds before force killing (default: 2.0)
24+
"""
25+
pid = getattr(process, "pid", None) or getattr(getattr(process, "popen", None), "pid", None)
26+
if not pid:
27+
# No PID means there's no process to terminate - it either never started,
28+
# already exited, or we have an invalid process object
29+
return
30+
31+
try:
32+
pgid = os.getpgid(pid)
33+
os.killpg(pgid, signal.SIGTERM)
34+
35+
with anyio.move_on_after(timeout_seconds):
36+
while True:
37+
try:
38+
# Check if process group still exists (signal 0 = check only)
39+
os.killpg(pgid, 0)
40+
await anyio.sleep(0.1)
41+
except ProcessLookupError:
42+
return
43+
44+
try:
45+
os.killpg(pgid, signal.SIGKILL)
46+
except ProcessLookupError:
47+
pass
48+
49+
except (ProcessLookupError, PermissionError, OSError) as e:
50+
logger.warning(f"Process group termination failed for PID {pid}: {e}, falling back to simple terminate")
51+
try:
52+
process.terminate()
53+
with anyio.fail_after(timeout_seconds):
54+
await process.wait()
55+
except Exception as term_error:
56+
logger.warning(f"Process termination failed for PID {pid}: {term_error}, attempting force kill")
57+
try:
58+
process.kill()
59+
except Exception as kill_error:
60+
logger.error(f"Failed to kill process {pid}: {kill_error}")

src/mcp/os/win32/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Windows-specific utilities for MCP."""

0 commit comments

Comments
 (0)