Files
Max Isbey 98bd125ddd Serve the 2026-07-28 protocol over stdio by deciding the era from the opening message
The 2026-07-28 protocol did not work over stdio: subscriptions/listen
was hard-refused, a legacy initialize arriving during an in-flight
modern request was accepted and re-locked the connection, and a peer
cancel produced a trailing "Request cancelled" frame. All three share
one root cause: the connection's era was derived from which requests
had completed instead of being decided once, in wire order, from how
the client opened the connection.

Replace serve_dual_era_loop and serve_loop with a single serve_stream
driver that decides the era synchronously in the dispatcher's read
loop, before the request body is spawned. initialize (or any
envelope-less request) opens the legacy era, an enveloped request opens
the modern era, server/discover is answered without pinning, and a
stray leading notification opens nothing. A conflicting era claim on a
committed connection is refused (-32022 or -32600) rather than
silently switching.

Cancel silence is structural: each request answers through a one-shot
channel whose write target becomes a powerless void on a peer cancel,
so there is no cancelled-check at any write site. The generic JSON-RPC
dispatcher loses the code-0 cancel frame, the code-0 str(exc)
catch-all, and the inline_methods knob, and documents that handlers
are invoked synchronously in receive order with the returned awaitable
as the body.

Add a Posture enum (DUAL default, LEGACY_ONLY, MODERN_ONLY) on the
Server and MCPServer constructors, honoured by the stream driver and
the streamable-HTTP manager alike. Server.run(read, write) now stands
alone, Server.lifespan() is a bound context manager, and
serve_listener / newline_json_transport / close_subscriptions() give a
straightforward path for custom transports. See docs/migration.md for
the full list of observable changes.
2026-07-23 13:18:49 +00:00

459 lines
18 KiB
Python

import io
import sys
import threading
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from io import TextIOWrapper
from pathlib import Path
import anyio
import anyio.abc
import pytest
from anyio.streams.buffered import BufferedByteReceiveStream
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
PROTOCOL_VERSION_META_KEY,
SERVER_INFO_META_KEY,
JSONRPCMessage,
JSONRPCRequest,
JSONRPCResponse,
jsonrpc_message_adapter,
)
from typing_extensions import Buffer
from mcp.server import serve_stream
from mcp.server.mcpserver import MCPServer
from mcp.server.stdio import newline_json_transport, stdio_server
from mcp.shared.message import SessionMessage
@pytest.fixture(params=["asyncio", "trio"])
def anyio_backend(request: pytest.FixtureRequest) -> str:
"""Run every async test in this module on both anyio backends; the sync
`run("stdio")` tests drive their own loop in a worker thread and take neither."""
return request.param
@pytest.fixture(autouse=True)
def _module_runner_lease() -> None:
"""Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`."""
class _ByteStreamDouble(anyio.abc.ByteStream):
"""A minimal `ByteStream` over an inbound memory stream, recording what the framer writes.
`broken=True` makes every send fail the way a peer that vanished does, so the
framer's writer can be shown to end quietly instead of crashing the connection.
"""
def __init__(self, inbound: anyio.abc.ObjectReceiveStream[bytes], *, broken: bool = False) -> None:
self._inbound = inbound
self._broken = broken
self.sent: list[bytes] = []
self.closed = False
async def receive(self, max_bytes: int = 65536) -> bytes:
try:
return await self._inbound.receive()
except (anyio.EndOfStream, anyio.ClosedResourceError):
raise anyio.EndOfStream from None
async def send(self, item: bytes) -> None:
if self._broken:
raise anyio.BrokenResourceError
self.sent.append(item)
async def send_eof(self) -> None:
await self.aclose()
async def aclose(self) -> None:
self.closed = True
@pytest.mark.anyio
async def test_stdio_server_round_trips_messages_over_injected_streams() -> None:
"""stdio_server frames JSON-RPC messages as one line each in both directions.
Parses one message per stdin line and writes each outgoing message as exactly one
line, driven over injected in-process streams.
"""
stdin = io.StringIO()
stdout = io.StringIO()
messages = [
JSONRPCRequest(jsonrpc="2.0", id=1, method="ping"),
JSONRPCResponse(jsonrpc="2.0", id=2, result={}),
]
for message in messages:
stdin.write(message.model_dump_json(by_alias=True, exclude_none=True) + "\n")
stdin.seek(0)
with anyio.fail_after(5):
async with stdio_server(stdin=anyio.AsyncFile(stdin), stdout=anyio.AsyncFile(stdout)) as (
read_stream,
write_stream,
):
async with read_stream:
received_messages: list[JSONRPCMessage] = []
for _ in range(2):
received = await read_stream.receive()
assert not isinstance(received, Exception)
received_messages.append(received.message)
assert received_messages[0] == JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
assert received_messages[1] == JSONRPCResponse(jsonrpc="2.0", id=2, result={})
responses = [
JSONRPCRequest(jsonrpc="2.0", id=3, method="ping"),
JSONRPCResponse(jsonrpc="2.0", id=4, result={}),
]
for response in responses:
await write_stream.send(SessionMessage(response))
await write_stream.aclose()
stdout.seek(0)
output_lines = stdout.readlines()
assert len(output_lines) == 2
received_responses = [jsonrpc_message_adapter.validate_json(line.strip()) for line in output_lines]
assert received_responses[0] == JSONRPCRequest(jsonrpc="2.0", id=3, method="ping")
assert received_responses[1] == JSONRPCResponse(jsonrpc="2.0", id=4, result={})
@pytest.mark.anyio
async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch) -> None:
"""Non-UTF-8 stdin bytes surface as an in-stream exception without killing the stream.
Invalid bytes are replaced with U+FFFD, fail JSON parsing, and arrive as an in-stream
exception; subsequent valid messages are still processed.
"""
# \xff\xfe are invalid UTF-8 start bytes.
valid = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
raw_stdin = io.BytesIO(b"\xff\xfe\n" + valid.model_dump_json(by_alias=True, exclude_none=True).encode() + b"\n")
# Replace sys.stdin with a wrapper whose .buffer is our raw bytes, so that
# stdio_server()'s default path wraps it with errors='replace'.
monkeypatch.setattr(sys, "stdin", TextIOWrapper(raw_stdin, encoding="utf-8"))
monkeypatch.setattr(sys, "stdout", TextIOWrapper(io.BytesIO(), encoding="utf-8"))
with anyio.fail_after(5):
async with stdio_server() as (read_stream, write_stream):
await write_stream.aclose()
async with read_stream: # pragma: no branch
# First line: \xff\xfe -> U+FFFD U+FFFD -> JSON parse fails -> exception in stream
first = await read_stream.receive()
assert isinstance(first, Exception)
# Second line: valid message still comes through
second = await read_stream.receive()
assert isinstance(second, SessionMessage)
assert second.message == valid
class _GatedStdin(io.RawIOBase):
"""Raw stdin double: serves its frames, then blocks until released before EOF.
A real stdio client keeps stdin open until it has read the responses it is
awaiting; an immediate EOF after the last frame races the dispatcher's
EOF-time cancellation of in-flight handlers (only inline-handled methods
would deterministically answer first). The blocked read sits in
`stdio_server`'s reader worker thread and unblocks on `release()`.
"""
name = "<gated-stdin>"
def __init__(self, payload: bytes) -> None:
self._pending = payload
self._released = threading.Event()
def readable(self) -> bool:
return True
def readinto(self, b: Buffer) -> int:
view = memoryview(b)
if self._pending:
n = min(len(view), len(self._pending))
view[:n] = self._pending[:n]
self._pending = self._pending[n:]
return n
# A missed release falls through to EOF after the bound; the caller's
# own response assertions then report what actually arrived.
self._released.wait(5)
return 0
def release(self) -> None:
self._released.set()
class _NotifyingStdout(io.RawIOBase):
"""Raw stdout double that counts newline-terminated lines and can be awaited on.
Survives wrapper close (`close()` is a no-op) so the test can read what was
written after `run()` has torn its TextIOWrapper down.
"""
name = "<notifying-stdout>"
def __init__(self) -> None:
self._chunks: list[bytes] = []
self._lines = 0
self._cond = threading.Condition()
def writable(self) -> bool:
return True
def write(self, b: Buffer) -> int:
data = bytes(b)
with self._cond:
self._chunks.append(data)
self._lines += data.count(b"\n")
self._cond.notify_all()
return len(data)
def wait_for_lines(self, n: int, timeout: float = 5) -> bool:
with self._cond:
return self._cond.wait_for(lambda: self._lines >= n, timeout)
def getvalue(self) -> bytes:
with self._cond:
return b"".join(self._chunks)
def close(self) -> None:
pass
def _serve_stdio_and_collect(
monkeypatch: pytest.MonkeyPatch, server: MCPServer, frames: list[JSONRPCRequest], responses: int
) -> list[JSONRPCMessage]:
"""Serve `frames` over process stdio and return the parsed response lines.
Runs the blocking `server.run("stdio")` in a daemon thread (it creates its
own event loop, so a sync test cannot arm `anyio.fail_after`) and signals
stdin EOF only after `responses` lines arrive on stdout - the way a real
client closes the pipe - so spawned in-flight handlers never race the
dispatcher's EOF cancellation. The join bound turns a run loop that never
returns on stdin EOF into a red test instead of a silent CI hang; an
exception escaping `run()` still fails the test via pytest's
unhandled-thread warning, escalated by `filterwarnings = ["error"]`.
"""
payload = "".join(f.model_dump_json(by_alias=True, exclude_none=True) + "\n" for f in frames).encode()
stdin = _GatedStdin(payload)
stdout = _NotifyingStdout()
monkeypatch.setattr(sys, "stdin", TextIOWrapper(stdin, encoding="utf-8"))
monkeypatch.setattr(sys, "stdout", TextIOWrapper(stdout, encoding="utf-8"))
def target() -> None:
server.run("stdio")
thread = threading.Thread(target=target, daemon=True)
thread.start()
arrived = stdout.wait_for_lines(responses)
stdin.release()
thread.join(5)
assert not thread.is_alive(), 'run("stdio") did not return after stdin EOF'
assert arrived, f"expected {responses} response line(s); stdout carried: {stdout.getvalue()!r}"
return [jsonrpc_message_adapter.validate_json(line) for line in stdout.getvalue().decode().splitlines()]
def test_mcpserver_run_stdio_serves_until_stdin_closes(monkeypatch: pytest.MonkeyPatch) -> None:
"""`MCPServer.run("stdio")` serves over process stdio and returns at stdin EOF.
Answers a request over the process's stdio and returns when stdin reaches EOF,
rather than serving forever.
"""
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
responses = _serve_stdio_and_collect(monkeypatch, MCPServer(name="RunStdioServer"), [ping], 1)
assert responses == [JSONRPCResponse(jsonrpc="2.0", id=1, result={})]
def test_mcpserver_run_stdio_runs_lifespan_cleanup_after_stdin_closes(monkeypatch: pytest.MonkeyPatch) -> None:
"""Code after `yield` in a lifespan runs when stdin EOF ends `run("stdio")`.
Regression lock for the issue #1027 shutdown chain: the run loop must end on
stdin EOF and unwind the lifespan rather than be killed before returning.
"""
events: list[str] = []
@asynccontextmanager
async def lifespan(server: MCPServer) -> AsyncIterator[None]:
events.append("setup")
try:
yield
finally:
events.append("cleanup")
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
server = MCPServer(name="LifespanStdioServer", lifespan=lifespan)
responses = _serve_stdio_and_collect(monkeypatch, server, [ping], 1)
assert events == ["setup", "cleanup"]
assert responses == [JSONRPCResponse(jsonrpc="2.0", id=1, result={})]
def test_mcpserver_run_stdio_serves_a_modern_connection(monkeypatch: pytest.MonkeyPatch) -> None:
"""`MCPServer.run("stdio")` serves the modern era over process stdio.
A `server/discover` probe gets a DiscoverResult (no initialize handshake)
and a subsequent envelope-bearing request is served at the discovered
version - the wire exchange `Client(mode='auto')` drives against a stdio
server.
"""
envelope = {
PROTOCOL_VERSION_META_KEY: "2026-07-28",
CLIENT_INFO_META_KEY: {"name": "probe", "version": "1.0"},
CLIENT_CAPABILITIES_META_KEY: {},
}
discover = JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={"_meta": envelope})
tools = JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={"_meta": envelope})
server = MCPServer(name="ModernStdioServer", version="1.2.3")
responses = _serve_stdio_and_collect(monkeypatch, server, [discover, tools], 2)
assert isinstance(responses[0], JSONRPCResponse) and responses[0].id == 1
assert "2026-07-28" in responses[0].result["supportedVersions"]
# Server identity travels as the result `_meta` stamp, not a DiscoverResult
# body field (spec 2026-07-28, #3002).
assert responses[0].result["_meta"][SERVER_INFO_META_KEY] == {"name": "ModernStdioServer", "version": "1.2.3"}
assert isinstance(responses[1], JSONRPCResponse) and responses[1].id == 2
# `resultType` is the modern-only wire field: its presence proves the
# request was served at the discovered version, not the handshake era.
assert responses[1].result["tools"] == []
assert responses[1].result["resultType"] == "complete"
# --- newline_json_transport: the stdio framing over any byte stream --------------
@pytest.mark.anyio
async def test_custom_transport_over_a_unix_socket_needs_only_framing_plus_serve_stream(tmp_path: Path) -> None:
"""The whole custom-transport story: frame a real Unix socket with
`newline_json_transport` and hand the streams to `serve_stream` - a raw
JSON-RPC client on the other end gets a served answer, one frame per line."""
server = MCPServer(name="socket-server")
@server.tool()
def add(a: int, b: int) -> str:
"""Add two numbers."""
return str(a + b)
socket_path = tmp_path / "mcp.sock"
listener = await anyio.create_unix_listener(socket_path)
connection_served = anyio.Event()
async def handle(stream: anyio.abc.SocketStream) -> None:
async with stream, newline_json_transport(stream) as (read_stream, write_stream):
await serve_stream(server._lowlevel_server, read_stream, write_stream) # pyright: ignore[reportPrivateUsage]
connection_served.set() # the driver returned once the client closed its end
envelope = {
PROTOCOL_VERSION_META_KEY: "2026-07-28",
CLIENT_INFO_META_KEY: {"name": "socket-client", "version": "1.0"},
CLIENT_CAPABILITIES_META_KEY: {},
}
call = JSONRPCRequest(
jsonrpc="2.0",
id=1,
method="tools/call",
params={"name": "add", "arguments": {"a": 3, "b": 4}, "_meta": envelope},
)
line = b""
async with listener, anyio.create_task_group() as tg:
tg.start_soon(listener.serve, handle)
with anyio.fail_after(5):
async with await anyio.connect_unix(socket_path) as client:
await client.send(call.model_dump_json(by_alias=True, exclude_none=True).encode("utf-8") + b"\n")
line = await BufferedByteReceiveStream(client).receive_until(b"\n", 1_000_000)
# The client closed its socket: the driver sees EOF and ends the connection.
await connection_served.wait()
tg.cancel_scope.cancel()
answer = jsonrpc_message_adapter.validate_json(line, by_name=False)
assert isinstance(answer, JSONRPCResponse) and answer.id == 1
assert answer.result["content"][0]["text"] == "7"
@pytest.mark.anyio
async def test_newline_json_transport_frames_lines_both_ways_and_survives_a_malformed_line() -> None:
"""One JSON-RPC message per line in both directions: a line that is not JSON-RPC
reaches the read stream as an exception item and the frame after it is still delivered
(one bad line costs one line, not the connection), outbound messages are written as
exactly one line each, and closing the byte stream stays the caller's job."""
peer_send, transport_side = anyio.create_memory_object_stream[bytes](8)
good = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
reply = JSONRPCResponse(jsonrpc="2.0", id=1, result={})
stream = _ByteStreamDouble(transport_side)
async with (
peer_send,
transport_side,
newline_json_transport(stream) as (read_stream, write_stream),
read_stream, # the driver's ends: a driver would own and close these
write_stream,
):
await peer_send.send(b"this is not json\n")
await peer_send.send(good.model_dump_json(by_alias=True, exclude_none=True).encode("utf-8") + b"\n")
with anyio.fail_after(5):
first = await read_stream.receive()
second = await read_stream.receive()
await write_stream.send(SessionMessage(reply))
await anyio.wait_all_tasks_blocked() # let the writer flush onto the byte stream
peer_send.close() # peer EOF: the framer ends its inbound stream
with anyio.fail_after(5):
assert [item async for item in read_stream] == []
assert isinstance(first, Exception)
assert isinstance(second, SessionMessage) and second.message == good
assert stream.sent == [reply.model_dump_json(by_alias=True, exclude_unset=True).encode("utf-8") + b"\n"]
assert not stream.closed # the framer never closes the byte stream itself
await stream.send_eof()
assert stream.closed
@pytest.mark.anyio
async def test_newline_json_transport_writer_ends_quietly_when_the_peer_is_gone() -> None:
"""A write onto a byte stream whose peer has vanished ends the writer without an
error escaping: outbound frames after that are undeliverable, not a crash."""
peer_send, transport_side = anyio.create_memory_object_stream[bytes](8)
stream = _ByteStreamDouble(transport_side, broken=True)
async with (
peer_send,
transport_side,
newline_json_transport(stream) as (read_stream, write_stream),
read_stream,
write_stream,
):
with anyio.fail_after(5):
await write_stream.send(SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=1, result={})))
await anyio.wait_all_tasks_blocked() # the writer hits the broken send and returns
assert stream.sent == []
@pytest.mark.anyio
async def test_newline_json_transport_ends_the_read_side_on_an_oversized_frame() -> None:
"""A frame that overruns the bound cannot be resynchronised: the framer surfaces
the overrun as an exception item and then ends the inbound stream (EOF)."""
peer_send, transport_side = anyio.create_memory_object_stream[bytes](8)
async with (
peer_send,
transport_side,
newline_json_transport(_ByteStreamDouble(transport_side), max_frame_bytes=8) as (
read_stream,
write_stream,
),
read_stream, # the driver's ends: a driver would own and close these
write_stream,
):
await peer_send.send(b"x" * 32) # no newline within the 8-byte bound
with anyio.fail_after(5):
items = [item async for item in read_stream]
assert len(items) == 1 and isinstance(items[0], anyio.DelimiterNotFound)