Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
b04d7e0
test: add interaction-model e2e suite with requirements manifest
maxisbey May 23, 2026
5710662
test: add lifecycle, completion, logging, and MCPServer feature inter…
maxisbey May 23, 2026
5216997
test: add server-initiated request and notification interaction tests
maxisbey May 23, 2026
d4a3558
test: add URL elicitation, subscriptions, pagination, timeouts, and m…
maxisbey May 23, 2026
d6c9b63
test: add lifecycle edge cases, concurrency, and behaviour-gap intera…
maxisbey May 23, 2026
a358aa4
test: add wire-level invariant tests via a recording transport
maxisbey May 23, 2026
d739975
test: add in-process streamable HTTP transport smoke tests
maxisbey May 23, 2026
2f0da6e
test: document the interaction suite's conventions and manifest workflow
maxisbey May 23, 2026
cce06b2
test: correct spec anchors and record further divergences in the requ…
maxisbey May 26, 2026
7709b98
test: add output schema, sampling constraint, roots error, and versio…
maxisbey May 26, 2026
bdfded0
test: align requirement IDs, add transport applicability, and enforce…
maxisbey May 26, 2026
d07f01f
test: track the full requirements surface in the interaction manifest
maxisbey May 26, 2026
c1eab9d
test: add an in-process streaming ASGI transport and cover server-ini…
maxisbey May 26, 2026
d64f525
test: run the interaction suite over both in-memory and streamable HT…
maxisbey May 26, 2026
8353a9b
test: run the interaction suite over the legacy SSE transport in-process
maxisbey May 26, 2026
584e098
test: add an SDK-client to SDK-server stdio end-to-end interaction test
maxisbey May 26, 2026
538136a
test: add streamable HTTP hosting, resumability, and client transport…
maxisbey May 27, 2026
c13d6ae
test: cover protocol/lifecycle gap requirements and refine the diverg…
maxisbey May 27, 2026
01f6a63
test: cover sampling, client output-schema, and mcpserver gap require…
maxisbey May 27, 2026
1e0d4f6
test: cover server-feature, pagination, elicitation, and mcpserver ga…
maxisbey May 27, 2026
4a7d563
test: cover composed flow scenarios and stdio framing requirements
maxisbey May 27, 2026
9fb50a1
test: add end-to-end OAuth authorization tests with an in-process AS/…
maxisbey May 27, 2026
cec4a2d
test: tighten remaining deferral reasons to reflect SDK feature gaps
maxisbey May 27, 2026
0157444
docs: update interaction suite README for transports, auth, and decor…
maxisbey May 27, 2026
e0e8e57
test: fix interaction suite for 3.10/3.11/3.14 and lowest-direct CI legs
maxisbey May 27, 2026
9f2b105
test: close leaked SSE receive streams instead of gc-collecting them
maxisbey May 27, 2026
7a026f2
test: correct manifest divergence notes and route in-handler notifica…
maxisbey May 27, 2026
171a01f
test: mark replay_sender's stream-id check no-branch for 3.10 coverage
maxisbey May 27, 2026
05a41e1
test: tighten manifest wording and assertion conventions from review …
maxisbey May 27, 2026
ca0ba11
test: declare capabilities the notification tests rely on
maxisbey May 27, 2026
2d62124
test: prove json-response Content-Type and explicit resumption-token …
maxisbey May 27, 2026
5e129bf
test: cancel only the abandoned call so 3.11/3.14 trace the resumptio…
maxisbey May 27, 2026
2ee59b7
test: restructure resumption test so 3.11 traces every line
maxisbey May 27, 2026
93cc828
test: mark the resumption-token test's post-exit line lax-no-cover fo…
maxisbey May 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ dev = [
# We add mcp[cli,ws] so `uv sync` considers the extras.
"mcp[cli,ws]",
"pyright>=1.1.400",
"pytest>=8.3.4",
"pytest>=8.4.0",
"ruff>=0.8.5",
"trio>=0.26.2",
"pytest-flakefinder>=1.1.0",
Expand Down Expand Up @@ -193,6 +193,9 @@ strict-no-cover = { git = "https://github.com/pydantic/strict-no-cover" }
[tool.pytest.ini_options]
log_cli = true
xfail_strict = true
markers = [
"requirement(id): links a test to the entry in tests/interaction/_requirements.py it exercises",
]
addopts = """
--color=yes
--capture=fd
Expand Down
20 changes: 10 additions & 10 deletions src/mcp/client/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,10 +360,10 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]:
auth_code, returned_state = await self.context.callback_handler()

if returned_state is None or not secrets.compare_digest(returned_state, state):
raise OAuthFlowError(f"State parameter mismatch: {returned_state} != {state}") # pragma: no cover
raise OAuthFlowError(f"State parameter mismatch: {returned_state} != {state}")

if not auth_code:
raise OAuthFlowError("No authorization code received") # pragma: no cover
raise OAuthFlowError("No authorization code received")

# Return auth code and code verifier for token exchange
return auth_code, pkce_params.code_verifier
Expand Down Expand Up @@ -452,7 +452,7 @@ async def _refresh_token(self) -> httpx.Request:

return httpx.Request("POST", token_url, data=refresh_data, headers=headers)

async def _handle_refresh_response(self, response: httpx.Response) -> bool: # pragma: no cover
async def _handle_refresh_response(self, response: httpx.Response) -> bool:
"""Handle token refresh response. Returns True if successful."""
if response.status_code != 200:
logger.warning(f"Token refresh failed: {response.status_code}")
Expand All @@ -468,12 +468,12 @@ async def _handle_refresh_response(self, response: httpx.Response) -> bool: # p
await self.context.storage.set_tokens(token_response)

return True
except ValidationError:
except ValidationError: # pragma: no cover
logger.exception("Invalid refresh response")
self.context.clear_tokens()
return False

async def _initialize(self) -> None: # pragma: no cover
async def _initialize(self) -> None:
"""Load stored tokens and client info."""
self.context.current_tokens = await self.context.storage.get_tokens()
self.context.client_info = await self.context.storage.get_client_info()
Expand Down Expand Up @@ -507,17 +507,17 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
"""HTTPX auth flow integration."""
async with self.context.lock:
if not self._initialized:
await self._initialize() # pragma: no cover
await self._initialize()

# Capture protocol version from request headers
self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION)

if not self.context.is_token_valid() and self.context.can_refresh_token():
# Try to refresh token
refresh_request = await self._refresh_token() # pragma: no cover
refresh_response = yield refresh_request # pragma: no cover
refresh_request = await self._refresh_token()
refresh_response = yield refresh_request

if not await self._handle_refresh_response(refresh_response): # pragma: no cover
if not await self._handle_refresh_response(refresh_response):
# Refresh failed, need full re-authentication
self._initialized = False

Expand Down Expand Up @@ -612,7 +612,7 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
# Step 5: Perform authorization and complete token exchange
token_response = yield await self._perform_authorization()
await self._handle_token_response(token_response)
except Exception: # pragma: no cover
except Exception:
logger.exception("OAuth flow error")
raise

Expand Down
2 changes: 1 addition & 1 deletion src/mcp/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,4 +305,4 @@ async def list_tools(self, *, cursor: str | None = None, meta: RequestParamsMeta
async def send_roots_list_changed(self) -> None:
"""Send a notification that the roots list has changed."""
# TODO(Marcelo): Currently, there is no way for the server to handle this. We should add support.
await self.session.send_roots_list_changed() # pragma: no cover
await self.session.send_roots_list_changed()
10 changes: 4 additions & 6 deletions src/mcp/client/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ async def _default_elicitation_callback(
context: RequestContext[ClientSession],
params: types.ElicitRequestParams,
) -> types.ElicitResult | types.ErrorData:
return types.ErrorData( # pragma: no cover
return types.ErrorData(
code=types.INVALID_REQUEST,
message="Elicitation not supported",
)
Expand Down Expand Up @@ -337,9 +337,7 @@ async def _validate_tool_result(self, name: str, result: types.CallToolResult) -
from jsonschema import SchemaError, ValidationError, validate

if result.structured_content is None:
raise RuntimeError(
f"Tool {name} has an output schema but did not return structured content"
) # pragma: no cover
raise RuntimeError(f"Tool {name} has an output schema but did not return structured content")
try:
validate(result.structured_content, output_schema)
except ValidationError as e:
Expand Down Expand Up @@ -408,7 +406,7 @@ async def list_tools(self, *, params: types.PaginatedRequestParams | None = None

return result

async def send_roots_list_changed(self) -> None: # pragma: no cover
async def send_roots_list_changed(self) -> None:
"""Send a roots/list_changed notification."""
await self.send_notification(types.RootsListChangedNotification())

Expand Down Expand Up @@ -449,7 +447,7 @@ async def _received_request(self, responder: RequestResponder[types.ServerReques
client_response = ClientResponse.validate_python(response)
await responder.respond(client_response)

case types.PingRequest(): # pragma: no cover
case types.PingRequest():
with responder:
return await responder.respond(types.EmptyResult())

Expand Down
16 changes: 8 additions & 8 deletions src/mcp/client/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ async def handle_get_stream(self, client: httpx.AsyncClient, read_stream_writer:
# Stream ended normally (server closed) - reset attempt counter
attempt = 0

except Exception: # pragma: lax no cover
except Exception:
logger.debug("GET stream error", exc_info=True)
attempt += 1

Expand Down Expand Up @@ -267,8 +267,8 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
logger.debug("Received 202 Accepted")
return

if response.status_code == 404: # pragma: no branch
if isinstance(message, JSONRPCRequest): # pragma: no branch
if response.status_code == 404:
if isinstance(message, JSONRPCRequest):
error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated")
session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))
await ctx.read_stream_writer.send(session_message)
Expand Down Expand Up @@ -492,17 +492,17 @@ async def handle_request_async():

async def terminate_session(self, client: httpx.AsyncClient) -> None:
"""Terminate the session by sending a DELETE request."""
if not self.session_id: # pragma: lax no cover
return
if not self.session_id:
return # pragma: no cover

try:
headers = self._prepare_headers()
response = await client.delete(self.url, headers=headers)

if response.status_code == 405: # pragma: lax no cover
if response.status_code == 405:
logger.debug("Server does not allow session termination")
elif response.status_code not in (200, 204): # pragma: lax no cover
logger.warning(f"Session termination failed: {response.status_code}")
elif response.status_code not in (200, 204):
logger.warning(f"Session termination failed: {response.status_code}") # pragma: no cover
except Exception as exc: # pragma: no cover
logger.warning(f"Session termination failed: {exc}")

Expand Down
2 changes: 1 addition & 1 deletion src/mcp/server/auth/handlers/authorize.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ async def error_response(
pass

# the error response MUST contain the state specified by the client, if any
if state is None: # pragma: no cover
if state is None:
# make last-ditch effort to load state
state = best_effort_extract_string("state", params)

Expand Down
2 changes: 1 addition & 1 deletion src/mcp/server/auth/middleware/bearer_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ async def _send_auth_error(self, send: Send, status_code: int, error: str, descr
"""Send an authentication error response with WWW-Authenticate header."""
# Build WWW-Authenticate header value
www_auth_parts = [f'error="{error}"', f'error_description="{description}"']
if self.resource_metadata_url: # pragma: no cover
if self.resource_metadata_url:
www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"')

www_authenticate = f"Bearer {', '.join(www_auth_parts)}"
Expand Down
16 changes: 8 additions & 8 deletions src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,12 +349,12 @@ def session_manager(self) -> StreamableHTTPSessionManager:
Raises:
RuntimeError: If called before streamable_http_app() has been called.
"""
if self._session_manager is None: # pragma: no cover
raise RuntimeError(
if self._session_manager is None:
raise RuntimeError( # pragma: no cover
"Session manager can only be accessed after calling streamable_http_app(). "
"The session manager is created lazily to avoid unnecessary initialization."
)
return self._session_manager # pragma: no cover
return self._session_manager

async def run(
self,
Expand Down Expand Up @@ -513,7 +513,7 @@ async def _handle_request(
if raise_exceptions: # pragma: no cover
raise err
response = types.ErrorData(code=0, message=str(err))
else: # pragma: no cover
else:
response = types.ErrorData(code=types.METHOD_NOT_FOUND, message="Method not found")

if isinstance(response, types.ErrorData) and span is not None:
Expand Down Expand Up @@ -603,7 +603,7 @@ def streamable_http_app(
required_scopes: list[str] = []

# Set up auth if configured
if auth: # pragma: no cover
if auth:
required_scopes = auth.required_scopes or []

# Add auth middleware if token verifier is available
Expand All @@ -629,10 +629,10 @@ def streamable_http_app(
)

# Set up routes with or without auth
if token_verifier: # pragma: no cover
if token_verifier:
# Determine resource metadata URL
resource_metadata_url = None
if auth and auth.resource_server_url:
if auth and auth.resource_server_url: # pragma: no branch
# Build compliant metadata URL for WWW-Authenticate header
resource_metadata_url = build_resource_metadata_url(auth.resource_server_url)

Expand All @@ -652,7 +652,7 @@ def streamable_http_app(
)

# Add protected resource metadata endpoint if configured as RS
if auth and auth.resource_server_url: # pragma: no cover
if auth and auth.resource_server_url:
routes.extend(
create_protected_resource_routes(
resource_url=auth.resource_server_url,
Expand Down
4 changes: 2 additions & 2 deletions src/mcp/server/mcpserver/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ async def report_progress(self, progress: float, total: float | None = None, mes
"""
progress_token = self.request_context.meta.get("progress_token") if self.request_context.meta else None

if progress_token is None: # pragma: no cover
if progress_token is None:
return

await self.request_context.session.send_progress_notification(
Expand Down Expand Up @@ -237,7 +237,7 @@ async def close_sse_stream(self) -> None:
This is a no-op if not using StreamableHTTP transport with event_store.
The callback is only available when event_store is configured.
"""
if self._request_context and self._request_context.close_sse_stream: # pragma: no cover
if self._request_context and self._request_context.close_sse_stream: # pragma: no branch
await self._request_context.close_sse_stream()

async def close_standalone_sse_stream(self) -> None:
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/server/mcpserver/prompts/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,5 +185,5 @@ async def render(
raise ValueError(f"Could not convert prompt result to message: {msg}")

return messages
except Exception as e: # pragma: no cover
except Exception as e:
raise ValueError(f"Error rendering prompt {self.name}: {e}")
2 changes: 1 addition & 1 deletion src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ def session_manager(self) -> StreamableHTTPSessionManager:
Raises:
RuntimeError: If called before streamable_http_app() has been called.
"""
return self._lowlevel_server.session_manager # pragma: no cover
return self._lowlevel_server.session_manager

@overload
def run(self, transport: Literal["stdio"] = ...) -> None: ...
Expand Down
8 changes: 4 additions & 4 deletions src/mcp/server/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ async def send_log_message(
related_request_id,
)

async def send_resource_updated(self, uri: str | AnyUrl) -> None: # pragma: no cover
async def send_resource_updated(self, uri: str | AnyUrl) -> None:
"""Send a resource updated notification."""
await self.send_notification(
types.ResourceUpdatedNotification(
Expand Down Expand Up @@ -447,7 +447,7 @@ async def elicit_url(
metadata=ServerMessageMetadata(related_request_id=related_request_id),
)

async def send_ping(self) -> types.EmptyResult: # pragma: no cover
async def send_ping(self) -> types.EmptyResult:
"""Send a ping request."""
return await self.send_request(
types.PingRequest(),
Expand Down Expand Up @@ -479,11 +479,11 @@ async def send_resource_list_changed(self) -> None:
"""Send a resource list changed notification."""
await self.send_notification(types.ResourceListChangedNotification())

async def send_tool_list_changed(self) -> None: # pragma: no cover
async def send_tool_list_changed(self) -> None:
"""Send a tool list changed notification."""
await self.send_notification(types.ToolListChangedNotification())

async def send_prompt_list_changed(self) -> None: # pragma: no cover
async def send_prompt_list_changed(self) -> None:
"""Send a prompt list changed notification."""
await self.send_notification(types.PromptListChangedNotification())

Expand Down
13 changes: 7 additions & 6 deletions src/mcp/server/sse.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,15 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings |
logger.debug(f"SseServerTransport initialized with endpoint: {endpoint}")

@asynccontextmanager
async def connect_sse(self, scope: Scope, receive: Receive, send: Send): # pragma: no cover
if scope["type"] != "http":
async def connect_sse(self, scope: Scope, receive: Receive, send: Send):
if scope["type"] != "http": # pragma: no cover
logger.error("connect_sse received non-HTTP request")
raise ValueError("connect_sse can only handle HTTP requests")

# Validate request headers for DNS rebinding protection
request = Request(scope, receive)
error_response = await self._security.validate_request(request, is_post=False)
if error_response:
if error_response: # pragma: no cover
await error_response(scope, receive, send)
raise ValueError("Request validation failed")

Expand Down Expand Up @@ -179,6 +179,7 @@ async def response_wrapper(scope: Scope, receive: Receive, send: Send):
await EventSourceResponse(content=sse_stream_reader, data_sender_callable=sse_writer)(
scope, receive, send
)
await sse_stream_reader.aclose()
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note for reviewers: this fixes a resource leak that broke the tests. current unit tests don't catch it because they run in a subpricess

await read_stream_writer.aclose()
await write_stream_reader.aclose()
self._read_stream_writers.pop(session_id, None)
Expand All @@ -190,13 +191,13 @@ async def response_wrapper(scope: Scope, receive: Receive, send: Send):
logger.debug("Yielding read and write streams")
yield (read_stream, write_stream)

async def handle_post_message(self, scope: Scope, receive: Receive, send: Send) -> None: # pragma: no cover
async def handle_post_message(self, scope: Scope, receive: Receive, send: Send) -> None:
logger.debug("Handling POST message")
request = Request(scope, receive)

# Validate request headers for DNS rebinding protection
error_response = await self._security.validate_request(request, is_post=True)
if error_response:
if error_response: # pragma: no cover
return await error_response(scope, receive, send)

session_id_param = request.query_params.get("session_id")
Expand Down Expand Up @@ -225,7 +226,7 @@ async def handle_post_message(self, scope: Scope, receive: Receive, send: Send)
try:
message = types.jsonrpc_message_adapter.validate_json(body, by_name=False)
logger.debug(f"Validated client message: {message}")
except ValidationError as err:
except ValidationError as err: # pragma: no cover
logger.exception("Failed to parse message")
response = Response("Could not parse message", status_code=400)
await response(scope, receive, send)
Expand Down
Loading
Loading