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
1011import logging
7273
7374
7475def 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):
113114async 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
217218def _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
227228async 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
243248async 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
265270async 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
271276async 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
285290async 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
296304def _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
309320def _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
338351async 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 ()
0 commit comments