Skip to content

Commit 3c13fc9

Browse files
committed
Conform stdio docstrings to Google style
Every docstring now opens with a single-line summary sentence followed by a blank line and the body. Restores the useful content that an earlier slimming pass cut from create_windows_process: the SelectorEventLoop / NotImplementedError fallback explanation, the Job Object purpose, the assignment-window caveat, and a Returns section explaining the Process | FallbackProcess union. Docstring-only change; no code or behavior changes.
1 parent 7adb379 commit 3c13fc9

12 files changed

Lines changed: 425 additions & 251 deletions

File tree

src/mcp/client/stdio.py

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
"""stdio client transport: run an MCP server as a subprocess and exchange
2-
newline-delimited JSON-RPC messages with it over stdin/stdout.
3-
4-
Two pipe tasks bridge the server's pipes to the session's in-memory streams.
5-
Shutdown follows the MCP spec sequence (close stdin, wait, then kill the
6-
process tree) inside a cancellation shield with every wait bounded, so a
7-
cancelled caller can neither leak a live server process nor hang on one.
1+
"""stdio client transport.
2+
3+
Runs an MCP server as a subprocess and exchanges newline-delimited JSON-RPC
4+
messages with it over stdin/stdout. Two pipe tasks bridge the server's pipes
5+
to the session's in-memory streams; shutdown follows the MCP spec sequence
6+
(close stdin, wait, then kill the process tree) inside a cancellation shield
7+
with every wait bounded, so a cancelled caller can neither leak a live server
8+
process nor hang on one.
89
"""
910

1011
import logging
@@ -72,7 +73,7 @@
7273

7374

7475
def get_default_environment() -> dict[str, str]:
75-
"""Return only the environment variables that are safe to inherit."""
76+
"""Returns only the environment variables that are safe to inherit."""
7677
env: dict[str, str] = {}
7778

7879
for key in DEFAULT_INHERITED_ENV_VARS:
@@ -113,11 +114,11 @@ class StdioServerParameters(BaseModel):
113114
async def stdio_client(
114115
server: StdioServerParameters, errlog: TextIO = sys.stderr
115116
) -> AsyncGenerator[TransportStreams, None]:
116-
"""Spawn an MCP server subprocess and connect to it over stdin/stdout.
117+
"""Spawns an MCP server subprocess and connects to it over stdin/stdout.
117118
118119
Raises:
119-
OSError: if the server process cannot be spawned.
120-
ValueError: if the spawn parameters are invalid (embedded NUL bytes).
120+
OSError: If the server process cannot be spawned.
121+
ValueError: If the spawn parameters are invalid (embedded NUL bytes).
121122
"""
122123
command = _get_executable_command(server.command)
123124

@@ -181,7 +182,7 @@ async def stdin_writer() -> None:
181182
writer_done.set()
182183

183184
async def shutdown() -> None:
184-
"""Stop traffic, flush, stop the server process, release the streams."""
185+
"""Winds the transport down: stop traffic, flush, stop the server, release the streams."""
185186
# Unblock the reader into its drain: a server stuck writing stdout cannot
186187
# read its stdin, so draining is what lets the flush below complete.
187188
read_stream.close()
@@ -215,7 +216,7 @@ async def shutdown() -> None:
215216

216217

217218
def _parse_line(line: str) -> SessionMessage | Exception:
218-
"""Parse one stdout line; parse errors are returned as values for the session."""
219+
"""Parses one stdout line, returning parse errors as values for the session to surface."""
219220
try:
220221
message = types.jsonrpc_message_adapter.validate_json(line, by_name=False)
221222
except ValueError as exc:
@@ -225,8 +226,12 @@ def _parse_line(line: str) -> SessionMessage | Exception:
225226

226227

227228
async def _drain_stdout(process: ServerProcess) -> None:
228-
"""Consume leftover stdout so a flushing server cannot block on a full pipe
229-
and miss its chance to exit; shielded, raw bytes, ends when shutdown closes it."""
229+
"""Consumes and discards the server's remaining stdout.
230+
231+
Keeps a server flushing buffered output from blocking on a full pipe and
232+
missing its chance to exit; shielded, raw bytes, ends when shutdown closes
233+
the pipe.
234+
"""
230235
assert process.stdout
231236
with anyio.CancelScope(shield=True):
232237
with suppress(
@@ -241,7 +246,7 @@ async def _drain_stdout(process: ServerProcess) -> None:
241246

242247

243248
async def _stop_server_process(process: ServerProcess) -> None:
244-
"""Close stdin, give the server a grace period, then kill its whole tree.
249+
"""Closes stdin, waits out the grace period, then kills the whole tree.
245250
246251
The escalation order is spec text; timeouts and tree-wide scope are SDK policy:
247252
https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#shutdown
@@ -263,13 +268,13 @@ async def _stop_server_process(process: ServerProcess) -> None:
263268

264269

265270
async def _close_pipe(stream: AsyncResource) -> None:
266-
"""Close a pipe stream, tolerating one already closed, broken, or contended."""
271+
"""Closes a pipe stream, tolerating one already closed, broken, or contended."""
267272
with suppress(OSError, anyio.BrokenResourceError, anyio.ClosedResourceError):
268273
await stream.aclose()
269274

270275

271276
async def _wait_for_process_exit(process: ServerProcess, timeout: float) -> bool:
272-
"""Whether the process died within timeout, by polling returncode.
277+
"""Returns whether the process died within the timeout, by polling returncode.
273278
274279
Not process.wait(): on asyncio 3.11+ it also waits for pipe EOF, and a
275280
child that inherited the pipes makes an exited server look hung.
@@ -283,8 +288,11 @@ async def _wait_for_process_exit(process: ServerProcess, timeout: float) -> bool
283288

284289

285290
async def _terminate_process_tree(process: ServerProcess) -> None:
286-
"""Kill the process tree: SIGTERM then SIGKILL to the POSIX process group,
287-
or immediate Job Object termination on Windows."""
291+
"""Kills the process and all its descendants.
292+
293+
POSIX: SIGTERM to the process group, SIGKILL after FORCE_KILL_TIMEOUT.
294+
Windows: immediate Job Object termination (already a hard kill).
295+
"""
288296
if sys.platform == "win32": # pragma: no cover
289297
await terminate_windows_process_tree(process)
290298
else: # pragma: lax no cover
@@ -294,9 +302,12 @@ async def _terminate_process_tree(process: ServerProcess) -> None:
294302

295303

296304
def _close_subprocess_transport(process: ServerProcess) -> None:
297-
"""Close the asyncio subprocess transport, which otherwise stays open (and
298-
warns at GC) while a surviving descendant holds a pipe; nothing public
299-
exposes it, hence the attribute walk. No-op on trio and the fallback."""
305+
"""Closes the asyncio subprocess transport, if there is one.
306+
307+
The transport otherwise stays open (and warns at GC) while a surviving
308+
descendant holds a pipe end; nothing public exposes it, hence the attribute
309+
walk. No-op on trio and the Windows fallback.
310+
"""
300311
transport = getattr(getattr(process, "_process", None), "_transport", None)
301312
# Duck-typed: uvloop's UVProcessTransport is not an asyncio.SubprocessTransport.
302313
close = getattr(transport, "close", None)
@@ -307,7 +318,7 @@ def _close_subprocess_transport(process: ServerProcess) -> None:
307318

308319

309320
def _get_executable_command(command: str) -> str:
310-
"""Normalize the command for the current platform."""
321+
"""Normalizes the command for the current platform."""
311322
if sys.platform == "win32": # pragma: no cover
312323
return get_windows_executable_command(command)
313324
else: # pragma: lax no cover
@@ -321,8 +332,10 @@ async def _create_platform_compatible_process(
321332
errlog: TextIO = sys.stderr,
322333
cwd: Path | str | None = None,
323334
) -> ServerProcess:
324-
"""Spawn the server in its own kill scope: a new session/process group on
325-
POSIX, a Job Object on Windows."""
335+
"""Spawns the server in its own kill scope.
336+
337+
A new session/process group on POSIX, a Job Object on Windows.
338+
"""
326339
if sys.platform == "win32": # pragma: no cover
327340
return await create_windows_process(command, args, env, errlog, cwd)
328341
else: # pragma: lax no cover
@@ -336,6 +349,6 @@ async def _create_platform_compatible_process(
336349

337350

338351
async def _aclose_all(*streams: AsyncResource) -> None:
339-
"""Close every given stream."""
352+
"""Closes every given stream."""
340353
for stream in streams:
341354
await stream.aclose()

src/mcp/os/posix/utilities.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@
1515

1616

1717
async def terminate_posix_process_tree(process: Process, timeout_seconds: float = 2.0) -> None:
18-
"""SIGTERM the process group, wait up to timeout_seconds for it to
19-
disappear, then SIGKILL whatever remains.
20-
21-
killpg reaches every descendant atomically, even ones whose parent already
22-
exited; daemonizers that left the group escape by design. A group only
23-
disappears once every member is dead and reaped, so a client running as
24-
PID 1 should reap orphans (e.g. docker run --init) or the wait below runs
25-
its full timeout.
18+
"""Terminates a process and all its descendants on POSIX.
19+
20+
SIGTERMs the process group, waits up to timeout_seconds for it to
21+
disappear, then SIGKILLs whatever remains. killpg reaches every descendant
22+
atomically, even ones whose parent already exited; daemonizers that left
23+
the group escape by design. A group only disappears once every member is
24+
dead and reaped, so a client running as PID 1 should reap orphans (e.g.
25+
docker run --init) or the wait below runs its full timeout.
2626
"""
2727
# The leader's pid is the pgid (start_new_session). Never use getpgid():
2828
# it fails once the leader is reaped, even with live members left.
@@ -53,7 +53,7 @@ async def terminate_posix_process_tree(process: Process, timeout_seconds: float
5353

5454

5555
def _group_alive(pgid: int) -> bool:
56-
"""Probe the group with signal 0; only ESRCH proves it is gone."""
56+
"""Probes the group with signal 0; only ESRCH proves it is gone."""
5757
try:
5858
os.killpg(pgid, 0)
5959
except ProcessLookupError:

src/mcp/os/win32/utilities.py

Lines changed: 53 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,11 @@
3939

4040

4141
def get_windows_executable_command(command: str) -> str:
42-
"""Resolve the command to a Windows executable path, trying the bare name
43-
first and then the common script extensions (.cmd, .bat, .exe, .ps1)."""
42+
"""Resolves the command to a Windows executable path.
43+
44+
Tries the bare name first, then the common script extensions (.cmd, .bat,
45+
.exe, .ps1).
46+
"""
4447
try:
4548
if command_path := shutil.which(command):
4649
return command_path
@@ -56,8 +59,11 @@ def get_windows_executable_command(command: str) -> str:
5659

5760

5861
class FallbackProcess:
59-
"""Async wrapper around subprocess.Popen for Windows event loops without
60-
async subprocess support (SelectorEventLoop)."""
62+
"""Async wrapper around subprocess.Popen for SelectorEventLoop.
63+
64+
Windows event loops without async subprocess support get this Popen-backed
65+
fallback, with anyio file streams wrapping the pipes.
66+
"""
6167

6268
def __init__(self, popen_obj: subprocess.Popen[bytes]) -> None:
6369
self.popen: subprocess.Popen[bytes] = popen_obj
@@ -68,29 +74,34 @@ def __init__(self, popen_obj: subprocess.Popen[bytes]) -> None:
6874
self.stdout = FileReadStream(cast(BinaryIO, stdout)) if stdout else None
6975

7076
async def wait(self) -> int:
71-
"""Wait for exit by polling; a thread blocked in Popen.wait() cannot be
72-
cancelled by anyio, which would defeat every timeout around this call."""
77+
"""Waits for exit by polling the Popen.
78+
79+
A thread blocked in Popen.wait() cannot be cancelled by anyio, which
80+
would defeat every timeout placed around this call.
81+
"""
7382
while (returncode := self.popen.poll()) is None:
7483
await anyio.sleep(_EXIT_POLL_INTERVAL)
7584
return returncode
7685

7786
def terminate(self) -> None:
78-
"""Terminate the subprocess."""
87+
"""Terminates the subprocess."""
7988
self.popen.terminate()
8089

8190
def kill(self) -> None:
82-
"""Kill the subprocess (same hard kill as terminate on Windows)."""
91+
"""Kills the subprocess (on Windows the same hard kill as terminate)."""
8392
self.popen.kill()
8493

8594
@property
8695
def pid(self) -> int:
87-
"""Return the process ID."""
96+
"""Returns the process ID."""
8897
return self.popen.pid
8998

9099
@property
91100
def returncode(self) -> int | None:
92-
"""Exit code, or None while running; polls Popen so death is observable
93-
without anyone calling wait()."""
101+
"""The exit code, or None while the process is still running.
102+
103+
Polls the Popen so death is observable without anyone calling wait().
104+
"""
94105
return self.popen.poll()
95106

96107

@@ -106,9 +117,18 @@ async def create_windows_process(
106117
errlog: TextIO | None = sys.stderr,
107118
cwd: Path | str | None = None,
108119
) -> Process | FallbackProcess:
109-
"""Spawn the server inside a Job Object so its children can be terminated
110-
with it; falls back to subprocess.Popen on event loops without async
111-
subprocess support."""
120+
"""Creates a subprocess with Job Object support for tree termination.
121+
122+
Spawns via anyio's open_process; event loops without async subprocess
123+
support (notably the SelectorEventLoop) raise NotImplementedError, in which
124+
case the spawn falls back to a Popen-backed FallbackProcess. Either way the
125+
process is then assigned to a Job Object so its children can be terminated
126+
with it; children spawned before the assignment completes are not captured
127+
(see the inline note below).
128+
129+
Returns:
130+
Process | FallbackProcess: The spawned process with async stdin/stdout streams.
131+
"""
112132
try:
113133
process = await anyio.open_process(
114134
[command, *args],
@@ -137,7 +157,7 @@ async def _create_windows_fallback_process(
137157
errlog: TextIO | None = sys.stderr,
138158
cwd: Path | str | None = None,
139159
) -> FallbackProcess:
140-
"""Spawn via subprocess.Popen and wrap it in FallbackProcess."""
160+
"""Spawns via subprocess.Popen and wraps it in FallbackProcess."""
141161
popen_obj = subprocess.Popen(
142162
[command, *args],
143163
stdin=subprocess.PIPE,
@@ -152,7 +172,7 @@ async def _create_windows_fallback_process(
152172

153173

154174
def _create_job_object() -> object | None:
155-
"""Create a Windows Job Object configured to terminate all processes when closed."""
175+
"""Creates a Windows Job Object configured to terminate all its processes when closed."""
156176
if sys.platform != "win32" or not win32api or not win32job:
157177
return None
158178

@@ -173,8 +193,10 @@ def _create_job_object() -> object | None:
173193

174194

175195
def _maybe_assign_process_to_job(process: Process | FallbackProcess, job: object | None) -> None:
176-
"""Assign the process to the job and record it for tree termination; on
177-
any failure the job handle is closed instead."""
196+
"""Assigns the process to the job and records it for tree termination.
197+
198+
On any failure the job handle is closed instead.
199+
"""
178200
if job is None:
179201
return
180202

@@ -201,9 +223,12 @@ def _maybe_assign_process_to_job(process: Process | FallbackProcess, job: object
201223

202224

203225
def close_process_job(process: Process | FallbackProcess) -> None:
204-
"""Close the process's Job Object handle, killing any members still alive
205-
(KILL_ON_JOB_CLOSE) deterministically rather than at GC time; a deliberate
206-
divergence from POSIX, where a graceful server's children are left alive."""
226+
"""Closes the process's Job Object handle, if it still has one.
227+
228+
KILL_ON_JOB_CLOSE makes the close also kill any members still alive,
229+
deterministically rather than at GC time; a deliberate divergence from
230+
POSIX, where a graceful server's children are left alive.
231+
"""
207232
if sys.platform != "win32":
208233
return
209234

@@ -213,9 +238,12 @@ def close_process_job(process: Process | FallbackProcess) -> None:
213238

214239

215240
async def terminate_windows_process_tree(process: Process | FallbackProcess) -> None:
216-
"""Terminate the job (an immediate hard kill of every member), or just the
217-
process if it has no job. Windows has no tree-wide SIGTERM; the stdin-close
218-
grace period is the server's chance to exit cleanly."""
241+
"""Terminates the process's job, or just the process if it has no job.
242+
243+
Job termination is an immediate hard kill of every member. Windows has no
244+
tree-wide SIGTERM; the stdin-close grace period is the server's chance to
245+
exit cleanly.
246+
"""
219247
if sys.platform != "win32":
220248
return
221249

@@ -235,7 +263,7 @@ async def terminate_windows_process_tree(process: Process | FallbackProcess) ->
235263

236264

237265
def _close_job_handle(job: object) -> None:
238-
"""Close a Job Object handle, tolerating one that is already closed."""
266+
"""Closes a Job Object handle, tolerating one that is already closed."""
239267
if win32api and pywintypes:
240268
with suppress(pywintypes.error):
241269
win32api.CloseHandle(job)

0 commit comments

Comments
 (0)