chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
# legacy-routing
|
||||
|
||||
The exported era classifier. `classify_inbound_request(body, headers=...)` from
|
||||
`mcp.shared.inbound` is the body-primary test for "is this a 2026-era request?";
|
||||
wrap it as `classify_era()` to route eras to different backends in your own
|
||||
ASGI/ingress layer. Unlike most SDKs, the Python SDK's built-in
|
||||
`streamable_http_app()` already serves **sessionful** 2025 alongside stateless
|
||||
2026 on one `/mcp` route — so the predicate is for when you need *different*
|
||||
arms (per-era auth, separate ports, an existing v1 deployment to keep), not to
|
||||
make dual-era work at all.
|
||||
|
||||
Also shown: the CORS recipe (methods, request headers, and `expose_headers`)
|
||||
browser-based MCP clients need.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
# HTTP only — the predicate is an HTTP-transport concern. The client
|
||||
# self-hosts the app on a free port, runs, then tears it down.
|
||||
uv run python -m stories.legacy_routing.client --http
|
||||
# same, against the lowlevel-API server variant
|
||||
uv run python -m stories.legacy_routing.client --http --server server_lowlevel
|
||||
|
||||
# against a server you run yourself (real uvicorn on :8000)
|
||||
uv run python -m stories.legacy_routing.server --port 8000 &
|
||||
SERVER_PID=$!
|
||||
uv run python -m stories.legacy_routing.client --http http://127.0.0.1:8000/mcp
|
||||
kill "$SERVER_PID"
|
||||
```
|
||||
|
||||
## What to look at
|
||||
|
||||
- `client.py` — two visible connections to the SAME `/mcp` endpoint from one
|
||||
`targets()` factory: `Client(targets(), mode=mode)` (default `"auto"` →
|
||||
`server/discover` → the modern arm) and `Client(targets(), mode="legacy")`
|
||||
(the `initialize` handshake → the legacy arm). Each asserts `which_arm`
|
||||
reports the era the built-in router actually dispatched to. The era decision
|
||||
is one explicit `mode=` argument at construction.
|
||||
- `client.py` — the predicate then shown directly against a modern body, a
|
||||
legacy body, and a malformed-modern body. The runnable `build_app()` uses the
|
||||
SDK's built-in router; the predicate itself is exercised as a pure
|
||||
function — see the user-land composition recipe below for wiring it into
|
||||
your own ingress.
|
||||
- `server.py` `classify_era` — the tri-state wrapper. `InboundModernRoute` →
|
||||
`"modern"`; rung-1 `INVALID_PARAMS` (no envelope keys) → `"legacy"`; any
|
||||
other `InboundLadderRejection` is a malformed-modern request to **reject**,
|
||||
not route to legacy. When headers are supplied, both `Mcp-Protocol-Version`
|
||||
and `Mcp-Method` must mirror the body — a disagreement (or an unsupported
|
||||
version) is what produces that third arm; `client.py` shows both.
|
||||
- `server.py` `build_app` — `streamable_http_app()` + `CORSMiddleware`. The
|
||||
`which_arm` tool reads `ctx.request_context.protocol_version` to prove which
|
||||
path the built-in router took.
|
||||
- `server_lowlevel.py` — the CORS recipe re-used from `server.py` (the
|
||||
`MCP_*` header and method constants); `build_app` wires `lowlevel.Server`
|
||||
instead of `MCPServer` and reads `ctx.protocol_version` directly. The
|
||||
predicate is tier-agnostic, so `classify_era` lives only in `server.py`.
|
||||
|
||||
## User-land composition (when you need different backends)
|
||||
|
||||
There is no `legacy="reject"` flag yet. To route eras to different handlers,
|
||||
buffer the body, classify, replay:
|
||||
|
||||
```python
|
||||
async def mcp_endpoint(scope, receive, send):
|
||||
body, replay = await buffer_body(receive) # your ASGI helper
|
||||
headers = {k.decode("ascii").lower(): v.decode("latin-1") for k, v in scope["headers"]}
|
||||
match classify_era(json.loads(body or b"{}"), headers):
|
||||
case "legacy":
|
||||
await my_existing_v1_manager.handle_request(scope, replay, send)
|
||||
case "modern":
|
||||
await modern_manager.handle_request(scope, replay, send)
|
||||
case rejection:
|
||||
await send_jsonrpc_error(send, rejection) # map via ERROR_CODE_HTTP_STATUS
|
||||
```
|
||||
|
||||
Non-POST verbs (`GET` standalone-SSE, `DELETE` session termination) are
|
||||
sessionful-2025-only — route them straight to the legacy arm.
|
||||
|
||||
## Two ports instead of one
|
||||
|
||||
Run two `uvicorn` processes from the same `build_app()` on different ports and
|
||||
put `classify_era()` (or a header check) in your ingress. Useful when the two
|
||||
eras need different auth, rate limits, or scaling.
|
||||
|
||||
## Caveats
|
||||
|
||||
- The SDK's **built-in** routing is currently header-only — a 2026 client that
|
||||
omits `MCP-Protocol-Version` is mis-routed to legacy.
|
||||
`classify_inbound_request()` is body-primary and is what the built-in moves
|
||||
to in a later release; user-land routing with the predicate is already
|
||||
correct today.
|
||||
- `ctx.request_context.protocol_version` is the interim 2-hop reach; a later
|
||||
release will shorten it.
|
||||
- DNS-rebinding protection is on by default; the harness disables it
|
||||
(`NO_DNS_REBIND`) because the in-process httpx client sends no `Origin`.
|
||||
Drop the kwarg for a real deployment.
|
||||
- `mcp.shared.inbound` is a deep import path — a shorter re-export is planned
|
||||
before beta.
|
||||
|
||||
## Spec
|
||||
|
||||
- [Versioning — backward compatibility](https://modelcontextprotocol.io/specification/draft/basic/versioning)
|
||||
- [Transports — protocol version header](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports)
|
||||
|
||||
## See also
|
||||
|
||||
`dual_era/` (the simple case: one factory, built-in routing, no predicate),
|
||||
`stateless_legacy/` (`stateless_http=True`), `starlette_mount/` (mount inside
|
||||
FastAPI).
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Connect at both eras to one app — so `main` takes `targets` — and assert the built-in router and predicate agree."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import mcp_types as types
|
||||
from mcp_types import CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY
|
||||
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
|
||||
|
||||
from mcp.client import Client
|
||||
from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_PROTOCOL_VERSION_HEADER, InboundLadderRejection
|
||||
from stories._harness import TargetFactory, run_client
|
||||
|
||||
from .server import classify_era
|
||||
|
||||
|
||||
def _arm(result: types.CallToolResult) -> str:
|
||||
first = result.content[0]
|
||||
assert isinstance(first, types.TextContent)
|
||||
return first.text
|
||||
|
||||
|
||||
async def main(targets: TargetFactory, *, mode: str = "auto") -> None:
|
||||
# ── modern arm: the caller's mode (the real-user "auto" default) probes
|
||||
# ``server/discover`` → the stateless 2026 path.
|
||||
async with Client(targets(), mode=mode) as modern:
|
||||
assert modern.protocol_version == LATEST_MODERN_VERSION
|
||||
assert _arm(await modern.call_tool("which_arm", {})) == "modern"
|
||||
|
||||
# ── legacy arm: the SAME /mcp endpoint, ``initialize`` handshake → sessionful 2025 path.
|
||||
async with Client(targets(), mode="legacy") as legacy:
|
||||
assert legacy.protocol_version == LATEST_HANDSHAKE_VERSION
|
||||
assert _arm(await legacy.call_tool("which_arm", {})) == "legacy"
|
||||
|
||||
# ── the exported predicate, shown directly. A 2026 _meta envelope whose
|
||||
# `Mcp-Protocol-Version`/`Mcp-Method` headers mirror it is modern; a bare
|
||||
# initialize body is legacy; a header that disagrees is a rejection (NOT legacy).
|
||||
modern_body: dict[str, Any] = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/list",
|
||||
"params": {
|
||||
"_meta": {
|
||||
PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION,
|
||||
CLIENT_INFO_META_KEY: {"name": "demo", "version": "0"},
|
||||
CLIENT_CAPABILITIES_META_KEY: {},
|
||||
}
|
||||
},
|
||||
}
|
||||
modern_headers = {MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION, MCP_METHOD_HEADER: "tools/list"}
|
||||
assert classify_era(modern_body, headers=modern_headers) == "modern"
|
||||
|
||||
legacy_body: dict[str, Any] = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}
|
||||
assert classify_era(legacy_body, headers={}) == "legacy"
|
||||
|
||||
# The SAME complete header set, with only the protocol version disagreeing with the body.
|
||||
mismatched_headers = modern_headers | {MCP_PROTOCOL_VERSION_HEADER: LATEST_HANDSHAKE_VERSION}
|
||||
mismatched = classify_era(modern_body, headers=mismatched_headers)
|
||||
assert isinstance(mismatched, InboundLadderRejection), mismatched
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_client(main)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Exported era classifier: the body-primary predicate, the built-in dual-era app, and CORS — exports `build_app()`."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Literal
|
||||
|
||||
from mcp_types import INVALID_PARAMS
|
||||
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.shared.inbound import InboundLadderRejection, InboundModernRoute, classify_inbound_request
|
||||
from stories._hosting import NO_DNS_REBIND, run_app_from_args
|
||||
|
||||
#: Response headers a browser-based MCP client must be able to read.
|
||||
MCP_EXPOSED_HEADERS = ["Mcp-Session-Id", "WWW-Authenticate", "Last-Event-Id", "Mcp-Protocol-Version"]
|
||||
#: Request headers a browser-based MCP client must be allowed to send.
|
||||
MCP_ALLOWED_HEADERS = ["Authorization", "Content-Type", "Mcp-Protocol-Version", "Mcp-Session-Id", "Last-Event-Id"]
|
||||
#: Streamable HTTP verbs: POST requests, the standalone GET stream, DELETE session end.
|
||||
MCP_ALLOWED_METHODS = ["GET", "POST", "DELETE"]
|
||||
|
||||
|
||||
def classify_era(
|
||||
body: Mapping[str, Any], headers: Mapping[str, str]
|
||||
) -> Literal["modern", "legacy"] | InboundLadderRejection:
|
||||
"""Tri-state era classifier built on the exported `classify_inbound_request` predicate.
|
||||
|
||||
Compose this in your own ASGI/ingress layer when the two eras need different
|
||||
backends. Only a rung-1 ``INVALID_PARAMS`` rejection (no envelope keys) means
|
||||
"treat as legacy"; other rejections are malformed-modern and should be refused.
|
||||
"""
|
||||
verdict = classify_inbound_request(body, headers=headers)
|
||||
if isinstance(verdict, InboundModernRoute):
|
||||
return "modern"
|
||||
if verdict.code == INVALID_PARAMS:
|
||||
return "legacy"
|
||||
return verdict
|
||||
|
||||
|
||||
def build_app() -> Starlette:
|
||||
mcp = MCPServer("legacy-routing-example")
|
||||
|
||||
@mcp.tool()
|
||||
async def which_arm(ctx: Context) -> str:
|
||||
"""Report which era the built-in router dispatched this request to."""
|
||||
pv = ctx.request_context.protocol_version
|
||||
return "modern" if pv in MODERN_PROTOCOL_VERSIONS else "legacy"
|
||||
|
||||
# One Starlette app, one /mcp route, both eras: sessionful 2025 (initialize +
|
||||
# Mcp-Session-Id + GET stream) and stateless 2026 (per-request _meta envelope).
|
||||
app = mcp.streamable_http_app(transport_security=NO_DNS_REBIND)
|
||||
|
||||
# CORS for browser-based clients. DEMO ONLY — restrict allow_origins in production.
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=MCP_ALLOWED_METHODS,
|
||||
allow_headers=MCP_ALLOWED_HEADERS,
|
||||
expose_headers=MCP_EXPOSED_HEADERS,
|
||||
)
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_app_from_args(build_app)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Exported era classifier (lowlevel API): the same dual-era app + CORS — the predicate stays in `server.py`."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import mcp_types as types
|
||||
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel import Server
|
||||
from stories._hosting import NO_DNS_REBIND, run_app_from_args
|
||||
|
||||
from .server import MCP_ALLOWED_HEADERS, MCP_ALLOWED_METHODS, MCP_EXPOSED_HEADERS
|
||||
|
||||
WHICH_ARM = types.Tool(
|
||||
name="which_arm",
|
||||
description="Report which era the built-in router dispatched this request to.",
|
||||
input_schema={"type": "object", "properties": {}},
|
||||
)
|
||||
|
||||
|
||||
def build_app() -> Starlette:
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(tools=[WHICH_ARM])
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
|
||||
assert params.name == "which_arm"
|
||||
arm = "modern" if ctx.protocol_version in MODERN_PROTOCOL_VERSIONS else "legacy"
|
||||
return types.CallToolResult(content=[types.TextContent(text=arm)])
|
||||
|
||||
server = Server("legacy-routing-example", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
|
||||
app = server.streamable_http_app(transport_security=NO_DNS_REBIND)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=MCP_ALLOWED_METHODS,
|
||||
allow_headers=MCP_ALLOWED_HEADERS,
|
||||
expose_headers=MCP_EXPOSED_HEADERS,
|
||||
)
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_app_from_args(build_app)
|
||||
Reference in New Issue
Block a user