|
5 | 5 |
|
6 | 6 | import anyio |
7 | 7 | import pytest |
8 | | -from pydantic import AnyUrl, TypeAdapter |
| 8 | +from pydantic import AnyHttpUrl, AnyUrl, TypeAdapter |
9 | 9 |
|
10 | 10 | from mcp import types |
11 | 11 | from mcp.server import ServerRequestContext |
12 | 12 | from mcp.server.lowlevel.server import Server |
| 13 | +from mcp.server.mcpserver import MCPServer |
13 | 14 | from mcp.server.stdio import stdio_server |
14 | 15 | from mcp.types import JSONRPCError, JSONRPCResponse, jsonrpc_message_adapter |
15 | 16 |
|
@@ -70,3 +71,46 @@ async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequ |
70 | 71 | assert isinstance(responses[1], JSONRPCError) |
71 | 72 | assert responses[1].id == 3 |
72 | 73 | assert responses[1].error.message |
| 74 | + |
| 75 | + |
| 76 | +@pytest.mark.anyio |
| 77 | +async def test_stdio_server_stays_alive_when_tool_validation_finishes_after_stdin_eof(): |
| 78 | + """The MCPServer tool path should not crash if validation loses the response race.""" |
| 79 | + |
| 80 | + mcp = MCPServer("test") |
| 81 | + |
| 82 | + @mcp.tool() |
| 83 | + async def fetch(url: str) -> str: |
| 84 | + # Delay validation so stdin can hit EOF and close the session write |
| 85 | + # stream before the tool returns its validation failure. |
| 86 | + await anyio.sleep(0.1) |
| 87 | + return str(TypeAdapter(AnyHttpUrl).validate_python(url)) |
| 88 | + |
| 89 | + raw_stdin = io.BytesIO( |
| 90 | + b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}\n' |
| 91 | + b'{"jsonrpc":"2.0","method":"notifications/initialized"}\n' |
| 92 | + b'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"fetch","arguments":{"url":"http://x\xff\xfe"}}}\n' |
| 93 | + ) |
| 94 | + raw_stdout = io.BytesIO() |
| 95 | + stdout = TextIOWrapper(raw_stdout, encoding="utf-8") |
| 96 | + |
| 97 | + async with stdio_server( |
| 98 | + stdin=anyio.wrap_file(TextIOWrapper(raw_stdin, encoding="utf-8", errors="replace")), |
| 99 | + stdout=anyio.wrap_file(stdout), |
| 100 | + ) as (read_stream, write_stream): |
| 101 | + with anyio.fail_after(5): |
| 102 | + await mcp._lowlevel_server.run( |
| 103 | + read_stream, |
| 104 | + write_stream, |
| 105 | + mcp._lowlevel_server.create_initialization_options(), |
| 106 | + ) |
| 107 | + |
| 108 | + stdout.flush() |
| 109 | + responses = [ |
| 110 | + jsonrpc_message_adapter.validate_json(line) |
| 111 | + for line in raw_stdout.getvalue().decode("utf-8").splitlines() |
| 112 | + ] |
| 113 | + |
| 114 | + assert responses |
| 115 | + assert isinstance(responses[0], JSONRPCResponse) |
| 116 | + assert responses[0].id == 1 |
0 commit comments