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) Waiting to run
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) Waiting to run
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
"""`docs/run/deploy.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import pytest
|
||||
from mcp_types import (
|
||||
INVALID_PARAMS,
|
||||
CallToolResult,
|
||||
ElicitResult,
|
||||
InputRequiredResult,
|
||||
ResourceUpdatedNotification,
|
||||
SubscriptionFilter,
|
||||
SubscriptionsListenRequest,
|
||||
SubscriptionsListenRequestParams,
|
||||
SubscriptionsListenResult,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
from docs_src.deploy import tutorial001, tutorial002, tutorial003, tutorial004
|
||||
from mcp import Client, MCPError
|
||||
from mcp.server import MCPServer
|
||||
from mcp.server.mcpserver import Context, RequestStateSecurity
|
||||
from mcp.server.subscriptions import InMemorySubscriptionBus
|
||||
|
||||
# See test_index.py for why this is a per-module mark and not a conftest hook.
|
||||
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
|
||||
|
||||
_KEY = "0123456789abcdef0123456789abcdef" # 32 bytes: the smallest secret the SDK accepts.
|
||||
|
||||
INITIALIZE = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "b", "version": "1"}},
|
||||
}
|
||||
MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
# -- the Host allowlist ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_the_default_app_rejects_a_real_hostname_before_mcp_runs() -> None:
|
||||
"""The section's `!!! check`: without `transport_security=`, a deployed hostname gets the page's exact 421."""
|
||||
bare = MCPServer("Notes")
|
||||
app = bare.streamable_http_app()
|
||||
async with bare.session_manager.run():
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://api.example.com") as h:
|
||||
response = await h.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
|
||||
assert (response.status_code, response.text) == (421, "Invalid Host header")
|
||||
|
||||
|
||||
async def test_the_allowlisted_app_serves_its_hostname_and_still_rejects_others() -> None:
|
||||
"""tutorial001: `allowed_hosts=` opens exactly the hostname you named, and nothing else."""
|
||||
transport = httpx.ASGITransport(app=tutorial001.app)
|
||||
async with tutorial001.mcp.session_manager.run():
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
|
||||
allowed = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://api.example.com") as http:
|
||||
rejected = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
|
||||
assert allowed.status_code == 200
|
||||
assert allowed.headers["mcp-session-id"]
|
||||
assert (rejected.status_code, rejected.text) == (421, "Invalid Host header")
|
||||
|
||||
|
||||
# -- `requestState` across workers -----------------------------------------------------
|
||||
|
||||
|
||||
async def _first_round(client: Client, amount: int) -> str:
|
||||
"""Round one of `refund`: no answers yet, so the server returns the `InputRequiredResult`."""
|
||||
first = await client.session.call_tool("refund", {"amount": amount}, allow_input_required=True)
|
||||
assert isinstance(first, InputRequiredResult)
|
||||
assert first.request_state is not None
|
||||
return first.request_state
|
||||
|
||||
|
||||
async def _retry(client: Client, amount: int, token: str) -> CallToolResult | InputRequiredResult:
|
||||
"""The retry: same tool, same arguments, the elicited answer, and the echoed token."""
|
||||
return await client.session.call_tool(
|
||||
"refund",
|
||||
{"amount": amount},
|
||||
input_responses={"ok": ElicitResult(action="accept", content={"ok": True})},
|
||||
request_state=token,
|
||||
allow_input_required=True,
|
||||
)
|
||||
|
||||
|
||||
def _assert_frozen_rejection(exc: pytest.ExceptionInfo[MCPError]) -> None:
|
||||
"""The one wire shape every inbound `requestState` verification failure produces."""
|
||||
assert exc.value.error.code == INVALID_PARAMS
|
||||
assert exc.value.error.message == "Invalid or expired requestState"
|
||||
assert exc.value.error.data == {"reason": "invalid_request_state"}
|
||||
|
||||
|
||||
async def test_a_retry_that_reaches_a_different_worker_is_rejected_by_default() -> None:
|
||||
"""tutorial002: two default servers hold two `os.urandom(32)` keys, so a cross-instance retry is refused."""
|
||||
worker_a = tutorial002.make_server()
|
||||
worker_b = tutorial002.make_server()
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(worker_a) as on_a, Client(worker_b) as on_b:
|
||||
token = await _first_round(on_a, 120)
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await _retry(on_b, 120, token)
|
||||
# Land back on the worker that minted the token and the identical retry completes.
|
||||
second = await _retry(on_a, 120, token)
|
||||
|
||||
_assert_frozen_rejection(exc)
|
||||
assert isinstance(second, CallToolResult)
|
||||
assert second.content == [TextContent(type="text", text="refunded $120")]
|
||||
|
||||
|
||||
async def test_a_refund_the_human_declined_is_not_issued() -> None:
|
||||
"""tutorial002/003: the second round reads the answer, so anything but an accepted ok is no refund."""
|
||||
server = tutorial002.make_server()
|
||||
with anyio.fail_after(5):
|
||||
async with Client(server) as client:
|
||||
token = await _first_round(client, 120)
|
||||
declined = await client.session.call_tool(
|
||||
"refund",
|
||||
{"amount": 120},
|
||||
input_responses={"ok": ElicitResult(action="decline")},
|
||||
request_state=token,
|
||||
allow_input_required=True,
|
||||
)
|
||||
assert isinstance(declined, CallToolResult)
|
||||
assert declined.content == [TextContent(type="text", text="refund cancelled")]
|
||||
|
||||
|
||||
async def test_a_shared_key_and_name_let_any_worker_finish_a_round_trip() -> None:
|
||||
"""tutorial003: instances built with the same key and the same name unseal what a sibling minted."""
|
||||
worker_a = tutorial003.make_server(_KEY)
|
||||
worker_b = tutorial003.make_server(_KEY)
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(worker_a) as on_a, Client(worker_b) as on_b:
|
||||
token = await _first_round(on_a, 120)
|
||||
second = await _retry(on_b, 120, token)
|
||||
|
||||
assert isinstance(second, CallToolResult)
|
||||
assert not second.is_error
|
||||
assert second.content == [TextContent(type="text", text="refunded $120")]
|
||||
|
||||
|
||||
async def test_a_shared_key_is_not_enough_without_a_shared_name() -> None:
|
||||
"""The `!!! warning`: the server name is the default `audience` claim, so keys alone don't cross instances."""
|
||||
|
||||
def named(name: str) -> MCPServer:
|
||||
mcp = MCPServer(name, request_state_security=RequestStateSecurity(keys=[_KEY]))
|
||||
|
||||
@mcp.tool()
|
||||
async def refund(amount: int, ctx: Context) -> str | InputRequiredResult:
|
||||
if ctx.input_responses is None:
|
||||
return InputRequiredResult(input_requests={"ok": tutorial002.CONFIRM}, request_state="pending")
|
||||
return f"refunded ${amount}"
|
||||
|
||||
return mcp
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(named("billing-1")) as on_one, Client(named("billing-2")) as on_two:
|
||||
token = await _first_round(on_one, 120)
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await _retry(on_two, 120, token)
|
||||
# Same keys AND the same name: back on the instance that minted it, the retry completes.
|
||||
second = await _retry(on_one, 120, token)
|
||||
|
||||
_assert_frozen_rejection(exc)
|
||||
assert isinstance(second, CallToolResult)
|
||||
assert second.content == [TextContent(type="text", text="refunded $120")]
|
||||
|
||||
|
||||
# -- change notifications across replicas ----------------------------------------------
|
||||
|
||||
|
||||
class _Stream:
|
||||
"""Collects a listen stream's frames and lets the test await arrival counts."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.received: list[object] = []
|
||||
self._arrival = anyio.Event()
|
||||
|
||||
async def handler(self, message: object) -> None:
|
||||
self.received.append(message)
|
||||
self._arrival.set()
|
||||
self._arrival = anyio.Event()
|
||||
|
||||
async def wait_for(self, count: int) -> None:
|
||||
with anyio.fail_after(5):
|
||||
while len(self.received) < count:
|
||||
await self._arrival.wait()
|
||||
|
||||
|
||||
async def test_one_bus_carries_a_publish_on_one_replica_to_a_stream_on_another() -> None:
|
||||
"""tutorial004: a `subscriptions/listen` stream on replica A hears a publish that happened on replica B."""
|
||||
bus = InMemorySubscriptionBus()
|
||||
replica_a = tutorial004.make_server(bus)
|
||||
replica_b = tutorial004.make_server(bus)
|
||||
stream = _Stream()
|
||||
|
||||
with anyio.fail_after(10):
|
||||
await _listen_and_edit(replica_a, replica_b, stream)
|
||||
|
||||
|
||||
async def _listen_and_edit(replica_a: MCPServer, replica_b: MCPServer, stream: _Stream) -> None:
|
||||
"""Open a listen stream on replica A, edit on replica B, and wait for the update to cross the bus."""
|
||||
async with (
|
||||
Client(replica_a, mode="2026-07-28", message_handler=stream.handler) as on_a,
|
||||
Client(replica_b) as on_b,
|
||||
):
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def listen() -> None:
|
||||
await on_a.session.send_request(
|
||||
SubscriptionsListenRequest(
|
||||
params=SubscriptionsListenRequestParams(
|
||||
notifications=SubscriptionFilter(resource_subscriptions=["note://todo"])
|
||||
)
|
||||
),
|
||||
SubscriptionsListenResult,
|
||||
)
|
||||
|
||||
tg.start_soon(listen)
|
||||
await stream.wait_for(1) # the acknowledgment: the stream is live on replica A
|
||||
|
||||
await on_b.call_tool("edit_note", {"name": "todo", "text": "water plants"})
|
||||
await stream.wait_for(2)
|
||||
updated = stream.received[1]
|
||||
assert isinstance(updated, ResourceUpdatedNotification)
|
||||
assert updated.params.uri == "note://todo"
|
||||
|
||||
tg.cancel_scope.cancel()
|
||||
Reference in New Issue
Block a user