49b9bb6724
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
282 lines
14 KiB
Python
282 lines
14 KiB
Python
"""`docs/troubleshooting.md`: every error string the page names, reproduced against the real SDK."""
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
from mcp_types import (
|
|
INVALID_PARAMS,
|
|
INVALID_REQUEST,
|
|
MISSING_REQUIRED_CLIENT_CAPABILITY,
|
|
ElicitRequestParams,
|
|
ElicitResult,
|
|
ErrorData,
|
|
TextContent,
|
|
)
|
|
|
|
from docs_src.troubleshooting import (
|
|
tutorial001,
|
|
tutorial002,
|
|
tutorial003,
|
|
tutorial004,
|
|
tutorial005,
|
|
tutorial006,
|
|
tutorial007,
|
|
tutorial008,
|
|
)
|
|
from mcp import Client, MCPError
|
|
from mcp.client import ClientRequestContext
|
|
from mcp.client.streamable_http import streamable_http_client
|
|
from mcp.server import MCPServer
|
|
from mcp.server.mcpserver import RequestStateSecurity
|
|
|
|
# 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")]
|
|
|
|
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"}
|
|
|
|
|
|
async def _confirm(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
|
"""The page's one `elicitation_callback`: always accept the booking."""
|
|
return ElicitResult(action="accept", content={"confirm": True})
|
|
|
|
|
|
async def test_an_error_leaving_the_async_with_block_arrives_wrapped_in_an_exception_group() -> None:
|
|
"""The `unhandled errors in a TaskGroup` entry: anyio group-wraps whatever escapes the block."""
|
|
with pytest.raises(Exception) as exc_info:
|
|
async with Client(tutorial001.mcp) as client:
|
|
await client.read_resource("weather://Atlantis")
|
|
assert not isinstance(exc_info.value, MCPError)
|
|
assert exc_info.group_contains(MCPError, match=r"^No forecast for 'Atlantis'\.$")
|
|
|
|
|
|
async def test_the_same_error_caught_inside_the_block_is_the_bare_mcp_error() -> None:
|
|
"""The fix on the page: `except MCPError` inside the `async with` never sees an `ExceptionGroup`."""
|
|
async with Client(tutorial001.mcp) as client:
|
|
with pytest.raises(MCPError) as exc_info:
|
|
await client.read_resource("weather://Atlantis")
|
|
assert str(exc_info.value) == "No forecast for 'Atlantis'."
|
|
assert exc_info.value.error.code == INVALID_PARAMS
|
|
|
|
|
|
async def test_a_client_outside_its_async_with_refuses_every_call() -> None:
|
|
"""`Client(...)` only constructs. Nothing connects until `async with`, so every call refuses."""
|
|
client = Client(tutorial001.mcp)
|
|
with pytest.raises(RuntimeError, match="^Client must be used within an async context manager$"):
|
|
await client.list_tools()
|
|
|
|
|
|
async def test_a_failing_tool_returns_is_error_true_instead_of_raising() -> None:
|
|
"""The `Error executing tool` entry: it is a result, not an exception. Nothing to `except`."""
|
|
async with Client(tutorial001.mcp) as client:
|
|
result = await client.call_tool("forecast", {"city": "Atlantis"})
|
|
assert result.is_error
|
|
assert result.content == [
|
|
TextContent(type="text", text="Error executing tool forecast: No forecast for 'Atlantis'.")
|
|
]
|
|
|
|
|
|
async def test_an_unknown_tool_is_the_same_kind_of_result() -> None:
|
|
"""`Unknown tool: <name>` travels the same `is_error=True` path as a failing tool."""
|
|
async with Client(tutorial001.mcp) as client:
|
|
result = await client.call_tool("get_forecast", {"city": "London"})
|
|
assert result.is_error
|
|
assert result.content == [TextContent(type="text", text="Unknown tool: get_forecast")]
|
|
|
|
|
|
async def test_the_tool_decorator_without_parentheses_raises_at_import_time() -> None:
|
|
"""`@mcp.tool` (no parentheses) hands the function itself to `name=`; the SDK refuses immediately."""
|
|
mcp = MCPServer("Weather")
|
|
undecorated: Any = mcp.tool
|
|
with pytest.raises(TypeError, match=r"Use @tool\(\) instead of @tool"):
|
|
|
|
@undecorated
|
|
def forecast(city: str) -> None:
|
|
"""Today's forecast for one city. Never called: the decoration itself is what raises."""
|
|
|
|
|
|
async def test_a_duplicate_tool_name_keeps_the_first_and_drops_the_second() -> None:
|
|
"""tutorial002: `tools/list` reports one `forecast`, and it is the first registration that won."""
|
|
async with Client(tutorial002.mcp) as client:
|
|
(tool,) = (await client.list_tools()).tools
|
|
assert tool.name == "forecast"
|
|
assert tool.description == "Today's forecast for one city."
|
|
|
|
|
|
async def test_a_duplicate_registration_logs_tool_already_exists(caplog: pytest.LogCaptureFixture) -> None:
|
|
"""The only signal for a dropped duplicate is the `Tool already exists:` warning in the server log."""
|
|
with caplog.at_level(logging.WARNING, logger="mcp.server.mcpserver.tools.tool_manager"):
|
|
|
|
@tutorial002.mcp.tool(name="forecast")
|
|
def forecast_weekly(city: str) -> None:
|
|
"""The week ahead for one city. Never called: it is the duplicate that gets dropped."""
|
|
|
|
assert "Tool already exists: forecast" in caplog.messages
|
|
|
|
|
|
async def test_the_default_streamable_http_app_answers_a_real_hostname_with_421(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""tutorial003: one 421, three spellings. The page presents all three as the same event."""
|
|
transport = httpx.ASGITransport(app=tutorial003.app)
|
|
async with tutorial003.mcp.session_manager.run():
|
|
# What curl (or the reverse proxy's access log) shows: the status and the plain-text body.
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://mcp.example.com") as raw:
|
|
with caplog.at_level(logging.WARNING, logger="mcp.server.transport_security"):
|
|
response = await raw.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
|
|
assert (response.status_code, response.text) == (421, "Invalid Host header")
|
|
# No `Content-Type: application/json`, which is exactly why the python client cannot show the body.
|
|
assert response.headers.get("content-type") is None
|
|
# What the server operator finds by grepping the server log.
|
|
assert "Invalid Host header: mcp.example.com" in caplog.messages
|
|
# What the python `Client` raises instead: the generic stand-in, wrapped by the task group.
|
|
async with httpx.AsyncClient(transport=transport) as http_client:
|
|
client = Client(streamable_http_client("http://mcp.example.com/mcp", http_client=http_client))
|
|
with pytest.raises(Exception) as exc_info: # pragma: no branch
|
|
await client.__aenter__() # the connection attempt itself is what fails
|
|
assert not isinstance(exc_info.value, MCPError)
|
|
assert exc_info.group_contains(MCPError, match="^Server returned an error response$")
|
|
|
|
|
|
async def test_an_allowlisted_hostname_connects_and_calls_a_tool() -> None:
|
|
"""tutorial004: `transport_security=` names the deployed hostname, and the same client connects."""
|
|
transport = httpx.ASGITransport(app=tutorial004.app)
|
|
async with tutorial004.mcp.session_manager.run():
|
|
async with httpx.AsyncClient(transport=transport) as http_client:
|
|
allowed = streamable_http_client("http://mcp.example.com/mcp", http_client=http_client)
|
|
async with Client(allowed) as c: # pragma: no branch
|
|
assert c.protocol_version == "2026-07-28"
|
|
result = await c.call_tool("forecast", {"city": "London"})
|
|
assert result.structured_content == {"result": "London: Rain."}
|
|
|
|
|
|
async def test_a_mounted_app_without_a_lifespan_fails_on_the_first_request() -> None:
|
|
"""tutorial005: Starlette never runs a mounted sub-app's lifespan, so nothing starts the manager."""
|
|
transport = httpx.ASGITransport(app=tutorial005.app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http:
|
|
with pytest.raises(RuntimeError, match=r"Task group is not initialized\. Make sure to use run\(\)\."):
|
|
await http.post("/mcp")
|
|
|
|
|
|
async def test_a_session_id_the_server_never_issued_gets_a_404_session_not_found() -> None:
|
|
"""`Session not found` is a 404 with a JSON-RPC body, so the python `Client` surfaces it verbatim."""
|
|
mcp = MCPServer("Weather")
|
|
app = mcp.streamable_http_app()
|
|
async with mcp.session_manager.run():
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://127.0.0.1:8000") as h:
|
|
response = await h.post(
|
|
"/mcp",
|
|
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
|
|
headers={**MCP_HEADERS, "mcp-session-id": "deadbeef"},
|
|
)
|
|
assert response.status_code == 404
|
|
assert response.headers["content-type"] == "application/json"
|
|
assert response.json() == {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Session not found"}}
|
|
|
|
|
|
async def test_ctx_elicit_at_2026_has_no_back_channel() -> None:
|
|
"""tutorial006: at 2026-07-28 the server refuses to send `elicitation/create` at all."""
|
|
async with Client(tutorial006.mcp) as client:
|
|
assert client.protocol_version == "2026-07-28"
|
|
with pytest.raises(MCPError) as exc_info:
|
|
await client.call_tool("book_table", {"date": "Friday"})
|
|
assert exc_info.value.error == ErrorData(
|
|
code=INVALID_REQUEST,
|
|
message=(
|
|
"Cannot send 'elicitation/create': "
|
|
"this transport context has no back-channel for server-initiated requests."
|
|
),
|
|
)
|
|
|
|
|
|
async def test_an_elicitation_callback_does_not_fix_ctx_elicit_at_2026() -> None:
|
|
"""The page's claim: registering the callback changes nothing. No request ever reaches the client."""
|
|
async with Client(tutorial006.mcp, elicitation_callback=_confirm) as client:
|
|
with pytest.raises(MCPError, match="no back-channel for server-initiated requests"):
|
|
await client.call_tool("book_table", {"date": "Friday"})
|
|
|
|
|
|
async def test_ctx_elicit_on_a_legacy_connection_works() -> None:
|
|
"""The legacy aside: `ctx.elicit` is a server-to-client request, and only a legacy session has those."""
|
|
async with Client(tutorial006.mcp, mode="legacy", elicitation_callback=_confirm) as client:
|
|
result = await client.call_tool("book_table", {"date": "Friday"})
|
|
assert result.structured_content == {"result": "Booked for Friday."}
|
|
|
|
|
|
async def test_the_resolver_form_works_on_a_2026_connection() -> None:
|
|
"""tutorial007: the fix. Same question, same callback, but the server returns it instead of calling back."""
|
|
async with Client(tutorial007.mcp, elicitation_callback=_confirm) as client:
|
|
assert client.protocol_version == "2026-07-28"
|
|
result = await client.call_tool("book_table", {"date": "Friday"})
|
|
assert result.structured_content == {"result": "Booked for Friday."}
|
|
|
|
|
|
async def test_the_resolver_form_without_a_callback_names_the_missing_capability() -> None:
|
|
"""The `-32021` entry: the server refuses up front, and `data` names the capability to declare."""
|
|
async with Client(tutorial007.mcp) as client:
|
|
with pytest.raises(MCPError) as exc_info:
|
|
await client.call_tool("book_table", {"date": "Friday"})
|
|
assert exc_info.value.error == ErrorData(
|
|
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
|
|
message=(
|
|
"Client did not declare the form elicitation capability required by resolver "
|
|
"'docs_src.troubleshooting.tutorial007:ask_to_confirm'"
|
|
),
|
|
data={"requiredCapabilities": {"elicitation": {"form": {}}}},
|
|
)
|
|
|
|
|
|
async def test_a_legacy_ctx_elicit_without_a_callback_says_elicitation_not_supported() -> None:
|
|
"""The `Elicitation not supported` entry: no `elicitation_callback` means nobody to ask."""
|
|
async with Client(tutorial006.mcp, mode="legacy") as client:
|
|
with pytest.raises(MCPError) as exc_info:
|
|
await client.call_tool("book_table", {"date": "Friday"})
|
|
assert exc_info.value.error == ErrorData(code=INVALID_REQUEST, message="Elicitation not supported")
|
|
|
|
|
|
async def test_ctx_elicit_over_stateless_http_has_no_back_channel() -> None:
|
|
"""tutorial008: `stateless_http=True` leaves the server no channel to send `elicitation/create`."""
|
|
transport = httpx.ASGITransport(app=tutorial008.app)
|
|
async with tutorial008.mcp.session_manager.run():
|
|
async with httpx.AsyncClient(transport=transport) as http_client:
|
|
stateless = streamable_http_client("http://127.0.0.1:8000/mcp", http_client=http_client)
|
|
async with Client(stateless) as c: # pragma: no branch
|
|
with pytest.raises(MCPError) as exc_info: # pragma: no branch
|
|
await c.call_tool("book_table", {"date": "Friday"})
|
|
assert exc_info.value.error == ErrorData(
|
|
code=INVALID_REQUEST,
|
|
message=(
|
|
"Cannot send 'elicitation/create': "
|
|
"this transport context has no back-channel for server-initiated requests."
|
|
),
|
|
)
|
|
|
|
|
|
async def test_a_request_state_the_server_did_not_mint_is_rejected(caplog: pytest.LogCaptureFixture) -> None:
|
|
"""The wire message is deliberately frozen; the real reason goes only to the server log."""
|
|
async with Client(tutorial001.mcp) as client:
|
|
with caplog.at_level(logging.WARNING, logger="mcp.server.request_state"):
|
|
with pytest.raises(MCPError) as exc_info: # pragma: no branch
|
|
await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a")
|
|
assert exc_info.value.error == ErrorData(
|
|
code=INVALID_PARAMS, message="Invalid or expired requestState", data={"reason": "invalid_request_state"}
|
|
)
|
|
assert "requestState rejected on tools/call: malformed" in caplog.messages
|
|
|
|
|
|
async def test_a_short_request_state_key_is_rejected_at_construction() -> None:
|
|
"""`RequestStateSecurity(keys=[...])` refuses anything under 32 bytes and says how to make one."""
|
|
with pytest.raises(ValueError) as exc_info:
|
|
RequestStateSecurity(keys=[b"hunter2"])
|
|
assert str(exc_info.value) == (
|
|
"request-state keys must be at least 32 bytes of secret randomness; keys[0] is 7 bytes. "
|
|
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
|
|
)
|