98bd125ddd
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.
88 lines
3.2 KiB
Python
88 lines
3.2 KiB
Python
"""Server-side hosting scaffold for story examples.
|
|
|
|
A story's ``server.py`` / ``server_lowlevel.py`` imports only from here. The
|
|
marked lines touch entry-point APIs that a later release reshapes into
|
|
free-function entries; isolating them here keeps story bodies stable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from collections.abc import Callable
|
|
from typing import Any, TypeAlias
|
|
|
|
import anyio
|
|
import uvicorn
|
|
from starlette.applications import Starlette
|
|
|
|
from mcp.server.lowlevel import Server
|
|
from mcp.server.mcpserver import MCPServer
|
|
from mcp.server.stdio import stdio_server
|
|
from mcp.server.transport_security import TransportSecuritySettings
|
|
|
|
AnyServer: TypeAlias = "MCPServer | Server[Any]"
|
|
ServerFactory = Callable[[], AnyServer]
|
|
AppFactory = Callable[[], Starlette]
|
|
|
|
NO_DNS_REBIND = TransportSecuritySettings(enable_dns_rebinding_protection=False)
|
|
"""Harness servers bind 127.0.0.1 and the in-process httpx2 client sends no Origin header."""
|
|
|
|
|
|
def argv_after(flag: str, *, default: str | None = None) -> str:
|
|
"""Return the argv token following ``flag``, or ``default`` when the flag is absent."""
|
|
try:
|
|
return sys.argv[sys.argv.index(flag) + 1]
|
|
except ValueError:
|
|
if default is None:
|
|
raise SystemExit(f"missing required {flag}") from None
|
|
return default
|
|
|
|
|
|
def asgi_from(server: AnyServer, *, path: str = "/mcp") -> Starlette:
|
|
"""Wrap a server instance in its streamable-HTTP ASGI app for in-process driving."""
|
|
return server.streamable_http_app( # becomes free fn streamable_http(server, legacy=...)
|
|
streamable_http_path=path,
|
|
stateless_http=False, # bool folds into a legacy= enum in a later release
|
|
transport_security=NO_DNS_REBIND,
|
|
)
|
|
|
|
|
|
def run_server_from_args(build_server: ServerFactory) -> None:
|
|
"""Entry point for ``if __name__ == "__main__"`` in every ``server*.py``.
|
|
|
|
Bare argv serves over stdio; ``--http --port N [--path /mcp]`` serves over
|
|
uvicorn on 127.0.0.1:N.
|
|
"""
|
|
server = build_server()
|
|
if "--http" in sys.argv:
|
|
port = int(argv_after("--port", default="8000"))
|
|
path = argv_after("--path", default="/mcp")
|
|
anyio.run(_serve_http, server, port, path)
|
|
else:
|
|
anyio.run(_serve_stdio, server)
|
|
|
|
|
|
async def _serve_stdio(server: AnyServer) -> None:
|
|
if isinstance(server, MCPServer):
|
|
await server.run_stdio_async() # becomes await serve_stdio(server)
|
|
else:
|
|
async with stdio_server() as (read, write): # becomes await serve_stdio(server)
|
|
await server.run(read, write)
|
|
|
|
|
|
async def _serve_http(server: AnyServer, port: int, path: str) -> None:
|
|
app = asgi_from(server, path=path)
|
|
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
|
|
await uvicorn.Server(config).serve()
|
|
|
|
|
|
def run_app_from_args(build_app: AppFactory) -> None:
|
|
"""Entry point for ``if __name__ == "__main__"`` in app-exporting ``server*.py``.
|
|
|
|
App-exporting stories are HTTP-only; ``--port N`` serves the Starlette app over
|
|
uvicorn on 127.0.0.1:N (uvicorn drives the app's own lifespan). No stdio leg.
|
|
"""
|
|
port = int(argv_after("--port", default="8000"))
|
|
config = uvicorn.Config(build_app(), host="127.0.0.1", port=port, log_level="error")
|
|
anyio.run(uvicorn.Server(config).serve)
|