Skip to content

Commit d3e0975

Browse files
Merge branch 'main' into fix/windows_stdio_subprocess
2 parents 1c6c6fb + d55cb2b commit d3e0975

File tree

3 files changed

+112
-40
lines changed

3 files changed

+112
-40
lines changed

README.md

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -315,27 +315,42 @@ async def long_task(files: list[str], ctx: Context) -> str:
315315
Authentication can be used by servers that want to expose tools accessing protected resources.
316316

317317
`mcp.server.auth` implements an OAuth 2.0 server interface, which servers can use by
318-
providing an implementation of the `OAuthServerProvider` protocol.
318+
providing an implementation of the `OAuthAuthorizationServerProvider` protocol.
319319

320-
```
321-
mcp = FastMCP("My App",
322-
auth_server_provider=MyOAuthServerProvider(),
323-
auth=AuthSettings(
324-
issuer_url="https://myapp.com",
325-
revocation_options=RevocationOptions(
326-
enabled=True,
327-
),
328-
client_registration_options=ClientRegistrationOptions(
329-
enabled=True,
330-
valid_scopes=["myscope", "myotherscope"],
331-
default_scopes=["myscope"],
332-
),
333-
required_scopes=["myscope"],
320+
```python
321+
from mcp import FastMCP
322+
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
323+
from mcp.server.auth.settings import (
324+
AuthSettings,
325+
ClientRegistrationOptions,
326+
RevocationOptions,
327+
)
328+
329+
330+
class MyOAuthServerProvider(OAuthAuthorizationServerProvider):
331+
# See an example on how to implement at `examples/servers/simple-auth`
332+
...
333+
334+
335+
mcp = FastMCP(
336+
"My App",
337+
auth_server_provider=MyOAuthServerProvider(),
338+
auth=AuthSettings(
339+
issuer_url="https://myapp.com",
340+
revocation_options=RevocationOptions(
341+
enabled=True,
342+
),
343+
client_registration_options=ClientRegistrationOptions(
344+
enabled=True,
345+
valid_scopes=["myscope", "myotherscope"],
346+
default_scopes=["myscope"],
334347
),
348+
required_scopes=["myscope"],
349+
),
335350
)
336351
```
337352

338-
See [OAuthServerProvider](src/mcp/server/auth/provider.py) for more details.
353+
See [OAuthAuthorizationServerProvider](src/mcp/server/auth/provider.py) for more details.
339354

340355
## Running Your Server
341356

@@ -462,15 +477,12 @@ For low level server with Streamable HTTP implementations, see:
462477
- Stateful server: [`examples/servers/simple-streamablehttp/`](examples/servers/simple-streamablehttp/)
463478
- Stateless server: [`examples/servers/simple-streamablehttp-stateless/`](examples/servers/simple-streamablehttp-stateless/)
464479

465-
466-
467480
The streamable HTTP transport supports:
468481
- Stateful and stateless operation modes
469482
- Resumability with event stores
470-
- JSON or SSE response formats
483+
- JSON or SSE response formats
471484
- Better scalability for multi-node deployments
472485

473-
474486
### Mounting to an Existing ASGI Server
475487

476488
> **Note**: SSE transport is being superseded by [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http).

src/mcp/client/stdio/__init__.py

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -108,20 +108,28 @@ async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stder
108108
read_stream_writer, read_stream = anyio.create_memory_object_stream(0)
109109
write_stream, write_stream_reader = anyio.create_memory_object_stream(0)
110110

111-
command = _get_executable_command(server.command)
112-
113-
# Open process with stderr piped for capture
114-
process = await _create_platform_compatible_process(
115-
command=command,
116-
args=server.args,
117-
env=(
118-
{**get_default_environment(), **server.env}
119-
if server.env is not None
120-
else get_default_environment()
121-
),
122-
errlog=errlog,
123-
cwd=server.cwd,
124-
)
111+
try:
112+
command = _get_executable_command(server.command)
113+
114+
# Open process with stderr piped for capture
115+
process = await _create_platform_compatible_process(
116+
command=command,
117+
args=server.args,
118+
env=(
119+
{**get_default_environment(), **server.env}
120+
if server.env is not None
121+
else get_default_environment()
122+
),
123+
errlog=errlog,
124+
cwd=server.cwd,
125+
)
126+
except OSError:
127+
# Clean up streams if process creation fails
128+
await read_stream.aclose()
129+
await write_stream.aclose()
130+
await read_stream_writer.aclose()
131+
await write_stream_reader.aclose()
132+
raise
125133

126134
async def stdout_reader():
127135
assert process.stdout, "Opened process is missing stdout"
@@ -177,12 +185,18 @@ async def stdin_writer():
177185
yield read_stream, write_stream
178186
finally:
179187
# Clean up process to prevent any dangling orphaned processes
180-
if sys.platform == "win32":
181-
await terminate_windows_process(process)
182-
else:
183-
process.terminate()
188+
try:
189+
if sys.platform == "win32":
190+
await terminate_windows_process(process)
191+
else:
192+
process.terminate()
193+
except ProcessLookupError:
194+
# Process already exited, which is fine
195+
pass
184196
await read_stream.aclose()
185197
await write_stream.aclose()
198+
await read_stream_writer.aclose()
199+
await write_stream_reader.aclose()
186200

187201

188202
def _get_executable_command(command: str) -> str:

tests/client/test_stdio.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@
22

33
import pytest
44

5-
from mcp.client.stdio import StdioServerParameters, stdio_client
5+
from mcp.client.session import ClientSession
6+
from mcp.client.stdio import (
7+
StdioServerParameters,
8+
stdio_client,
9+
)
10+
from mcp.shared.exceptions import McpError
611
from mcp.shared.message import SessionMessage
7-
from mcp.types import JSONRPCMessage, JSONRPCRequest, JSONRPCResponse
12+
from mcp.types import CONNECTION_CLOSED, JSONRPCMessage, JSONRPCRequest, JSONRPCResponse
813

914
tee: str = shutil.which("tee") # type: ignore
15+
python: str = shutil.which("python") # type: ignore
1016

1117

1218
@pytest.mark.anyio
@@ -50,3 +56,43 @@ async def test_stdio_client():
5056
assert read_messages[1] == JSONRPCMessage(
5157
root=JSONRPCResponse(jsonrpc="2.0", id=2, result={})
5258
)
59+
60+
61+
@pytest.mark.anyio
62+
async def test_stdio_client_bad_path():
63+
"""Check that the connection doesn't hang if process errors."""
64+
server_params = StdioServerParameters(
65+
command="python", args=["-c", "non-existent-file.py"]
66+
)
67+
async with stdio_client(server_params) as (read_stream, write_stream):
68+
async with ClientSession(read_stream, write_stream) as session:
69+
# The session should raise an error when the connection closes
70+
with pytest.raises(McpError) as exc_info:
71+
await session.initialize()
72+
73+
# Check that we got a connection closed error
74+
assert exc_info.value.error.code == CONNECTION_CLOSED
75+
assert "Connection closed" in exc_info.value.error.message
76+
77+
78+
@pytest.mark.anyio
79+
async def test_stdio_client_nonexistent_command():
80+
"""Test that stdio_client raises an error for non-existent commands."""
81+
# Create a server with a non-existent command
82+
server_params = StdioServerParameters(
83+
command="/path/to/nonexistent/command",
84+
args=["--help"],
85+
)
86+
87+
# Should raise an error when trying to start the process
88+
with pytest.raises(Exception) as exc_info:
89+
async with stdio_client(server_params) as (_, _):
90+
pass
91+
92+
# The error should indicate the command was not found
93+
error_message = str(exc_info.value)
94+
assert (
95+
"nonexistent" in error_message
96+
or "not found" in error_message.lower()
97+
or "cannot find the file" in error_message.lower() # Windows error message
98+
)

0 commit comments

Comments
 (0)