Skip to content

Commit eae718c

Browse files
committed
feat(stdio): support preexec_fn and process_group in StdioServerParameters (#3457)
Add preexec_fn and process_group to StdioServerParameters, forwarding them through stdio_client and _create_platform_compatible_process to anyio.open_process on POSIX systems. Includes unit tests for parameter parsing, forwarding, and child-process execution.
1 parent 08a3bc8 commit eae718c

2 files changed

Lines changed: 163 additions & 12 deletions

File tree

src/mcp/client/stdio.py

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,19 @@
1010

1111
import logging
1212
import os
13+
import subprocess
1314
import sys
14-
from collections.abc import AsyncGenerator
15+
from collections.abc import AsyncGenerator, Callable
1516
from contextlib import asynccontextmanager, suppress
1617
from pathlib import Path
17-
from typing import Literal, TextIO
18+
from typing import Any, Literal, TextIO
1819

1920
import anyio
2021
import anyio.lowlevel
2122
import mcp_types as types
2223
from anyio.abc import AsyncResource, Process
2324
from anyio.streams.text import TextReceiveStream
24-
from pydantic import BaseModel, Field
25+
from pydantic import BaseModel, ConfigDict, Field
2526

2627
from mcp.client._transport import TransportStreams
2728
from mcp.os.posix.utilities import terminate_posix_process_tree
@@ -91,6 +92,8 @@ def get_default_environment() -> dict[str, str]:
9192

9293

9394
class StdioServerParameters(BaseModel):
95+
model_config = ConfigDict(arbitrary_types_allowed=True)
96+
9497
command: str
9598
"""The executable to run to start the server."""
9699

@@ -109,6 +112,12 @@ class StdioServerParameters(BaseModel):
109112
encoding_error_handler: Literal["strict", "ignore", "replace"] = "strict"
110113
"""Encoding error handler; see https://docs.python.org/3/library/codecs.html#error-handlers."""
111114

115+
preexec_fn: Callable[[], Any] | None = None
116+
"""Function called in the child process just before the child is executed (POSIX only)."""
117+
118+
process_group: int | None = None
119+
"""Process group to set in the child process (POSIX only)."""
120+
112121

113122
@asynccontextmanager
114123
async def stdio_client(
@@ -122,12 +131,19 @@ async def stdio_client(
122131
"""
123132
command = _get_executable_command(server.command)
124133

134+
extra_spawn_kwargs: dict[str, Any] = {}
135+
if server.preexec_fn is not None:
136+
extra_spawn_kwargs["preexec_fn"] = server.preexec_fn
137+
if server.process_group is not None:
138+
extra_spawn_kwargs["process_group"] = server.process_group
139+
125140
process = await _create_platform_compatible_process(
126141
command=command,
127142
args=server.args,
128143
env=get_default_environment() | (server.env or {}),
129144
errlog=errlog,
130145
cwd=server.cwd,
146+
**extra_spawn_kwargs,
131147
)
132148

133149
# The spawn succeeded; no awaits until the task group is entered, or a
@@ -331,6 +347,8 @@ async def _create_platform_compatible_process(
331347
env: dict[str, str] | None = None,
332348
errlog: TextIO = sys.stderr,
333349
cwd: Path | str | None = None,
350+
preexec_fn: Callable[[], Any] | None = None,
351+
process_group: int | None = None,
334352
) -> ServerProcess:
335353
"""Spawns the server in its own kill scope.
336354
@@ -339,13 +357,44 @@ async def _create_platform_compatible_process(
339357
if sys.platform == "win32": # pragma: no cover
340358
return await create_windows_process(command, args, env, errlog, cwd)
341359
else: # pragma: lax no cover
342-
return await anyio.open_process(
343-
[command, *args],
344-
env=env,
345-
stderr=errlog,
346-
cwd=cwd,
347-
start_new_session=True,
348-
)
360+
extra_kwargs: dict[str, Any] = {}
361+
if preexec_fn is not None:
362+
extra_kwargs["preexec_fn"] = preexec_fn
363+
if process_group is not None:
364+
extra_kwargs["process_group"] = process_group
365+
366+
start_new_session = True if process_group is None else False
367+
368+
if not extra_kwargs:
369+
return await anyio.open_process(
370+
[command, *args],
371+
env=env,
372+
stderr=errlog,
373+
cwd=cwd,
374+
start_new_session=start_new_session,
375+
)
376+
377+
try:
378+
return await anyio.open_process(
379+
[command, *args],
380+
env=env,
381+
stderr=errlog,
382+
cwd=cwd,
383+
start_new_session=start_new_session,
384+
**extra_kwargs,
385+
)
386+
except TypeError:
387+
backend: Any = getattr(anyio.lowlevel, "get_async_backend")()
388+
return await backend.open_process(
389+
[command, *args],
390+
stdin=subprocess.PIPE,
391+
stdout=subprocess.PIPE,
392+
stderr=errlog,
393+
cwd=cwd,
394+
env=env,
395+
start_new_session=start_new_session,
396+
**extra_kwargs,
397+
)
349398

350399

351400
async def _aclose_all(*streams: AsyncResource) -> None:

tests/client/test_stdio.py

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
from collections.abc import Callable
1818
from contextlib import AsyncExitStack, suppress
1919
from pathlib import Path
20-
from typing import TextIO, cast
20+
from typing import Any, TextIO, cast
21+
from unittest.mock import MagicMock
2122

2223
import anyio
2324
import anyio.abc
@@ -1203,8 +1204,12 @@ async def recording_spawn(
12031204
env: dict[str, str] | None = None,
12041205
errlog: TextIO = sys.stderr,
12051206
cwd: Path | str | None = None,
1207+
*spawn_args: Any,
1208+
**spawn_kwargs: Any,
12061209
) -> anyio.abc.Process | FallbackProcess:
1207-
process = await _create_platform_compatible_process(command, args, env, errlog, cwd)
1210+
process = await _create_platform_compatible_process(
1211+
command, args, env, errlog, cwd, *spawn_args, **spawn_kwargs
1212+
)
12081213
spawned.append(process)
12091214
return process
12101215

@@ -1406,3 +1411,100 @@ async def test_a_graceful_exit_with_a_surviving_child_leaks_no_pipe_fds( # prag
14061411
# Subset, not equality: other machinery may close fds, but never open new
14071412
# ones; a leaked pipe fd would show up as an extra entry.
14081413
assert set(os.listdir("/proc/self/fd")) <= baseline
1414+
1415+
1416+
def test_stdio_server_parameters_preexec_and_process_group() -> None:
1417+
"""StdioServerParameters accepts preexec_fn and process_group with appropriate defaults."""
1418+
# Defaults
1419+
params = StdioServerParameters(command="echo")
1420+
assert params.preexec_fn is None
1421+
assert params.process_group is None
1422+
1423+
# Custom callable and process group
1424+
def hook() -> None:
1425+
pass
1426+
1427+
params_custom = StdioServerParameters(
1428+
command="echo",
1429+
preexec_fn=hook,
1430+
process_group=123,
1431+
)
1432+
assert params_custom.preexec_fn is hook
1433+
assert params_custom.process_group == 123
1434+
1435+
1436+
@pytest.mark.anyio
1437+
async def test_create_platform_compatible_process_forwards_preexec_and_process_group(
1438+
monkeypatch: pytest.MonkeyPatch,
1439+
) -> None:
1440+
"""_create_platform_compatible_process forwards preexec_fn and process_group to anyio.open_process."""
1441+
captured_kwargs: dict[str, Any] = {}
1442+
1443+
async def mock_open_process(*args: Any, **kwargs: Any) -> anyio.abc.Process:
1444+
captured_kwargs.update(kwargs)
1445+
return cast(anyio.abc.Process, MagicMock())
1446+
1447+
monkeypatch.setattr(anyio, "open_process", mock_open_process)
1448+
1449+
def dummy_preexec() -> None:
1450+
pass
1451+
1452+
await _create_platform_compatible_process(
1453+
"test-command",
1454+
["--flag"],
1455+
preexec_fn=dummy_preexec,
1456+
process_group=12345,
1457+
)
1458+
1459+
assert captured_kwargs.get("preexec_fn") is dummy_preexec
1460+
assert captured_kwargs.get("process_group") == 12345
1461+
assert captured_kwargs.get("start_new_session") is False
1462+
1463+
1464+
@pytest.mark.anyio
1465+
@pytest.mark.skipif(sys.platform == "win32", reason="preexec_fn is POSIX only")
1466+
async def test_preexec_fn_executes_in_child_process(tmp_path: Path) -> None:
1467+
"""preexec_fn is called and executes in the child process before exec."""
1468+
log_file = tmp_path / "preexec.log"
1469+
1470+
def hook() -> None:
1471+
with open(log_file, "w") as f:
1472+
f.write(f"{os.getpid()}")
1473+
1474+
server_params = StdioServerParameters(
1475+
command=sys.executable,
1476+
args=["-c", "import sys; sys.stdin.read()"],
1477+
preexec_fn=hook,
1478+
)
1479+
1480+
with anyio.fail_after(5.0):
1481+
async with stdio_client(server_params):
1482+
while not log_file.exists():
1483+
await anyio.sleep(0.01)
1484+
child_pid = int(log_file.read_text().strip())
1485+
assert child_pid != os.getpid()
1486+
1487+
1488+
@pytest.mark.anyio
1489+
@pytest.mark.skipif(sys.platform == "win32", reason="process_group is POSIX only")
1490+
async def test_process_group_sets_child_process_group(tmp_path: Path) -> None:
1491+
"""process_group is passed and configured in the child process."""
1492+
pgid_file = tmp_path / "pgid.log"
1493+
script = (
1494+
f"import os, pathlib, sys\n"
1495+
f"pathlib.Path({str(pgid_file)!r}).write_text(f'{{os.getpid()}}:{{os.getpgrp()}}')\n"
1496+
f"sys.stdin.read()\n"
1497+
)
1498+
server_params = StdioServerParameters(
1499+
command=sys.executable,
1500+
args=["-c", script],
1501+
process_group=0,
1502+
)
1503+
1504+
with anyio.fail_after(5.0):
1505+
async with stdio_client(server_params):
1506+
while not pgid_file.exists():
1507+
await anyio.sleep(0.01)
1508+
pid_str, pgid_str = pgid_file.read_text().strip().split(":")
1509+
assert pid_str == pgid_str
1510+
assert int(pid_str) != os.getpid()

0 commit comments

Comments
 (0)