Skip to content

Commit f7abb9d

Browse files
committed
feat(server): forward optional stdin/stdout to stdio_server
MCPServer.run_stdio_async and run(..., transport="stdio") now accept optional async text streams, matching mcp.server.stdio.stdio_server. This supports hosts that redirect process stdout for logging while keeping a dedicated MCP JSON-RPC stream. Made-with: Cursor
1 parent 7ba4fb8 commit f7abb9d

File tree

2 files changed

+78
-5
lines changed

2 files changed

+78
-5
lines changed

src/mcp/server/mcpserver/server.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,13 @@ def session_manager(self) -> StreamableHTTPSessionManager:
238238
return self._lowlevel_server.session_manager # pragma: no cover
239239

240240
@overload
241-
def run(self, transport: Literal["stdio"] = ...) -> None: ...
241+
def run(
242+
self,
243+
transport: Literal["stdio"] = ...,
244+
*,
245+
stdin: anyio.AsyncFile[str] | None = ...,
246+
stdout: anyio.AsyncFile[str] | None = ...,
247+
) -> None: ...
242248

243249
@overload
244250
def run(
@@ -270,12 +276,19 @@ def run(
270276
def run(
271277
self,
272278
transport: Literal["stdio", "sse", "streamable-http"] = "stdio",
279+
*,
280+
stdin: anyio.AsyncFile[str] | None = None,
281+
stdout: anyio.AsyncFile[str] | None = None,
273282
**kwargs: Any,
274283
) -> None:
275284
"""Run the MCP server. Note this is a synchronous function.
276285
277286
Args:
278287
transport: Transport protocol to use ("stdio", "sse", or "streamable-http")
288+
stdin: Optional async text stream for MCP input (stdio transport only).
289+
When omitted, uses process stdin. See :func:`mcp.server.stdio.stdio_server`.
290+
stdout: Optional async text stream for MCP output (stdio transport only).
291+
When omitted, uses process stdout.
279292
**kwargs: Transport-specific options (see overloads for details)
280293
"""
281294
TRANSPORTS = Literal["stdio", "sse", "streamable-http"]
@@ -284,7 +297,7 @@ def run(
284297

285298
match transport:
286299
case "stdio":
287-
anyio.run(self.run_stdio_async)
300+
anyio.run(lambda: self.run_stdio_async(stdin=stdin, stdout=stdout))
288301
case "sse": # pragma: no cover
289302
anyio.run(lambda: self.run_sse_async(**kwargs))
290303
case "streamable-http": # pragma: no cover
@@ -836,9 +849,25 @@ def decorator( # pragma: no cover
836849

837850
return decorator # pragma: no cover
838851

839-
async def run_stdio_async(self) -> None:
840-
"""Run the server using stdio transport."""
841-
async with stdio_server() as (read_stream, write_stream):
852+
async def run_stdio_async(
853+
self,
854+
*,
855+
stdin: anyio.AsyncFile[str] | None = None,
856+
stdout: anyio.AsyncFile[str] | None = None,
857+
) -> None:
858+
"""Run the server using stdio transport.
859+
860+
Args:
861+
stdin: Async text stream to read JSON-RPC lines from. When ``None``,
862+
uses the process stdin (see :func:`mcp.server.stdio.stdio_server`).
863+
stdout: Async text stream to write JSON-RPC lines to. When ``None``,
864+
uses the process stdout.
865+
866+
Custom streams are useful when the process ``sys.stdout`` / ``sys.stdin``
867+
must be redirected (for example so logging or subprocess output does not
868+
corrupt the MCP JSON-RPC stream on fd 1).
869+
"""
870+
async with stdio_server(stdin=stdin, stdout=stdout) as (read_stream, write_stream):
842871
await self._lowlevel_server.run(
843872
read_stream,
844873
write_stream,
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""MCPServer.run_stdio_async forwards optional stdin/stdout to stdio_server."""
2+
3+
from __future__ import annotations
4+
5+
import io
6+
from contextlib import asynccontextmanager
7+
from unittest.mock import AsyncMock
8+
9+
import anyio
10+
import pytest
11+
12+
from mcp.server.mcpserver import MCPServer
13+
14+
15+
@pytest.mark.anyio
16+
async def test_run_stdio_async_passes_streams_to_stdio_server(monkeypatch: pytest.MonkeyPatch) -> None:
17+
captured: dict[str, object] = {}
18+
19+
@asynccontextmanager
20+
async def spy_stdio_server(stdin=None, stdout=None):
21+
captured["stdin"] = stdin
22+
captured["stdout"] = stdout
23+
read_stream = AsyncMock()
24+
write_stream = AsyncMock()
25+
yield read_stream, write_stream
26+
27+
async def noop_run(*_args, **_kwargs):
28+
return None
29+
30+
monkeypatch.setattr("mcp.server.mcpserver.server.stdio_server", spy_stdio_server)
31+
32+
server = MCPServer("test-stdio-spy")
33+
monkeypatch.setattr(server._lowlevel_server, "run", noop_run)
34+
monkeypatch.setattr(server._lowlevel_server, "create_initialization_options", lambda: object())
35+
36+
sin = io.StringIO()
37+
sout = io.StringIO()
38+
await server.run_stdio_async(
39+
stdin=anyio.AsyncFile(sin),
40+
stdout=anyio.AsyncFile(sout),
41+
)
42+
43+
assert captured["stdin"] is not None
44+
assert captured["stdout"] is not None

0 commit comments

Comments
 (0)