Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions .github/workflows/shared.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ jobs:
uv run --frozen --no-sync coverage combine
uv run --frozen --no-sync coverage report

- name: Check for unnecessary no cover pragmas
if: runner.os != 'Windows'
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@Kludex without this all the windows pipelines fail to even run, seems like a strict-no-cover bug? Example failure from previous CI run: https://github.com/modelcontextprotocol/python-sdk/actions/runs/21070443439/job/60598385217

run: uv run --frozen --no-sync strict-no-cover

readme-snippets:
runs-on: ubuntu-latest
steps:
Expand Down
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,9 @@ dev = [
"pytest-pretty>=1.2.0",
"inline-snapshot>=0.23.0",
"dirty-equals>=0.9.0",
"coverage[toml]>=7.13.1",
"coverage[toml]>=7.10.7,<=7.13",
"pillow>=12.0",
"strict-no-cover",
]
docs = [
"mkdocs>=1.6.1",
Expand Down Expand Up @@ -164,6 +165,7 @@ members = ["examples/clients/*", "examples/servers/*", "examples/snippets"]

[tool.uv.sources]
mcp = { workspace = true }
strict-no-cover = { git = "https://github.com/pydantic/strict-no-cover" }

[tool.pytest.ini_options]
log_cli = true
Expand Down Expand Up @@ -199,7 +201,6 @@ branch = true
patch = ["subprocess"]
concurrency = ["multiprocessing", "thread"]
source = ["src", "tests"]
relative_files = true
omit = [
"src/mcp/client/__main__.py",
"src/mcp/server/__main__.py",
Expand All @@ -216,6 +217,7 @@ ignore_errors = true
precision = 2
exclude_lines = [
"pragma: no cover",
"pragma: lax no cover",
"if TYPE_CHECKING:",
"@overload",
"raise NotImplementedError",
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/cli/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def update_claude_config(
)

config_file = config_dir / "claude_desktop_config.json"
if not config_file.exists(): # pragma: no cover
if not config_file.exists(): # pragma: lax no cover
try:
config_file.write_text("{}")
except Exception:
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def _build_uv_command(

if with_packages:
for pkg in with_packages:
if pkg: # pragma: no cover
if pkg: # pragma: no branch
cmd.extend(["--with", pkg])

# Add mcp run command
Expand Down
6 changes: 3 additions & 3 deletions src/mcp/client/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ def prepare_token_auth(
headers = {} # pragma: no cover

if not self.client_info:
return data, headers # pragma: no cover
return data, headers

auth_method = self.client_info.token_endpoint_auth_method

Expand Down Expand Up @@ -418,7 +418,7 @@ async def _refresh_token(self) -> httpx.Request:
raise OAuthTokenError("No client info available") # pragma: no cover

if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint:
token_url = str(self.context.oauth_metadata.token_endpoint) # pragma: no cover
token_url = str(self.context.oauth_metadata.token_endpoint)
else:
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
token_url = urljoin(auth_base_url, "/token")
Expand Down Expand Up @@ -534,7 +534,7 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
)

# Step 2: Discover OAuth Authorization Server Metadata (OASM) (with fallback for legacy servers)
for url in asm_discovery_urls: # pragma: no cover
for url in asm_discovery_urls: # pragma: no branch
oauth_metadata_request = create_oauth_metadata_request(url)
oauth_metadata_response = yield oauth_metadata_request

Expand Down
8 changes: 4 additions & 4 deletions src/mcp/client/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ async def set_logging_level(
meta: RequestParamsMeta | None = None,
) -> types.EmptyResult:
"""Send a logging/setLevel request."""
return await self.send_request( # pragma: no cover
return await self.send_request(
types.SetLevelRequest(params=types.SetLevelRequestParams(level=level, _meta=meta)),
types.EmptyResult,
)
Expand Down Expand Up @@ -285,14 +285,14 @@ async def read_resource(self, uri: str, *, meta: RequestParamsMeta | None = None

async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult:
"""Send a resources/subscribe request."""
return await self.send_request( # pragma: no cover
return await self.send_request(
types.SubscribeRequest(params=types.SubscribeRequestParams(uri=uri, _meta=meta)),
types.EmptyResult,
)

async def unsubscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult:
"""Send a resources/unsubscribe request."""
return await self.send_request( # pragma: no cover
return await self.send_request(
types.UnsubscribeRequest(params=types.UnsubscribeRequestParams(uri=uri, _meta=meta)),
types.EmptyResult,
)
Expand Down Expand Up @@ -344,7 +344,7 @@ async def _validate_tool_result(self, name: str, result: types.CallToolResult) -
try:
validate(result.structured_content, output_schema)
except ValidationError as e:
raise RuntimeError(f"Invalid structured content returned by tool {name}: {e}") # pragma: no cover
raise RuntimeError(f"Invalid structured content returned by tool {name}: {e}")
except SchemaError as e: # pragma: no cover
raise RuntimeError(f"Invalid schema for tool {name}: {e}") # pragma: no cover

Expand Down
10 changes: 5 additions & 5 deletions src/mcp/client/session_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,22 +223,22 @@ async def disconnect_from_server(self, session: mcp.ClientSession) -> None:
)
)

if session_known_for_components: # pragma: no cover
if session_known_for_components: # pragma: no branch
component_names = self._sessions.pop(session) # Pop from _sessions tracking

# Remove prompts associated with the session.
for name in component_names.prompts:
if name in self._prompts:
if name in self._prompts: # pragma: no branch
del self._prompts[name]
# Remove resources associated with the session.
for name in component_names.resources:
if name in self._resources:
if name in self._resources: # pragma: no branch
del self._resources[name]
# Remove tools associated with the session.
for name in component_names.tools:
if name in self._tools:
if name in self._tools: # pragma: no branch
del self._tools[name]
if name in self._tool_to_session:
if name in self._tool_to_session: # pragma: no branch
del self._tool_to_session[name]

# Clean up the session's resources via its dedicated exit stack
Expand Down
16 changes: 8 additions & 8 deletions src/mcp/client/sse.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,12 @@ async def sse_reader(task_status: TaskStatus[str] = anyio.TASK_STATUS_IGNORED):
await read_stream_writer.send(session_message)
case _: # pragma: no cover
logger.warning(f"Unknown SSE event: {sse.event}") # pragma: no cover
except SSEError as sse_exc: # pragma: no cover
logger.exception("Encountered SSE exception") # pragma: no cover
raise sse_exc # pragma: no cover
except Exception as exc: # pragma: no cover
logger.exception("Error in sse_reader") # pragma: no cover
await read_stream_writer.send(exc) # pragma: no cover
except SSEError as sse_exc: # pragma: lax no cover
logger.exception("Encountered SSE exception")
raise sse_exc
except Exception as exc: # pragma: lax no cover
logger.exception("Error in sse_reader")
await read_stream_writer.send(exc)
finally:
await read_stream_writer.aclose()

Expand All @@ -143,8 +143,8 @@ async def post_writer(endpoint_url: str):
)
response.raise_for_status()
logger.debug(f"Client message sent successfully: {response.status_code}")
except Exception: # pragma: no cover
logger.exception("Error in post_writer") # pragma: no cover
except Exception: # pragma: lax no cover
logger.exception("Error in post_writer")
finally:
await write_stream.aclose()

Expand Down
10 changes: 5 additions & 5 deletions src/mcp/client/stdio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def get_default_environment() -> dict[str, str]:
for key in DEFAULT_INHERITED_ENV_VARS:
value = os.environ.get(key)
if value is None:
continue # pragma: no cover
continue

if value.startswith("()"): # pragma: no cover
# Skip functions, which are a security risk
Expand Down Expand Up @@ -158,7 +158,7 @@ async def stdout_reader():

session_message = SessionMessage(message)
await read_stream_writer.send(session_message)
except anyio.ClosedResourceError: # pragma: no cover
except anyio.ClosedResourceError:
await anyio.lowlevel.checkpoint()

async def stdin_writer():
Expand Down Expand Up @@ -226,7 +226,7 @@ def _get_executable_command(command: str) -> str:
if sys.platform == "win32": # pragma: no cover
return get_windows_executable_command(command)
else:
return command # pragma: no cover
return command


async def _create_platform_compatible_process(
Expand All @@ -250,7 +250,7 @@ async def _create_platform_compatible_process(
stderr=errlog,
cwd=cwd,
start_new_session=True,
) # pragma: no cover
)

return process

Expand All @@ -267,7 +267,7 @@ async def _terminate_process_tree(process: Process | FallbackProcess, timeout_se
"""
if sys.platform == "win32": # pragma: no cover
await terminate_windows_process_tree(process, timeout_seconds)
else: # pragma: no cover
else:
# FallbackProcess should only be used for Windows compatibility
assert isinstance(process, Process)
await terminate_posix_process_tree(process, timeout_seconds)
20 changes: 10 additions & 10 deletions src/mcp/client/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ async def handle_get_stream(self, client: httpx.AsyncClient, read_stream_writer:

headers = self._prepare_headers()
if last_event_id:
headers[LAST_EVENT_ID] = last_event_id # pragma: no cover
headers[LAST_EVENT_ID] = last_event_id

async with aconnect_sse(client, "GET", self.url, headers=headers) as event_source:
event_source.response.raise_for_status()
Expand All @@ -190,10 +190,10 @@ async def handle_get_stream(self, client: httpx.AsyncClient, read_stream_writer:
async for sse in event_source.aiter_sse():
# Track last event ID for reconnection
if sse.id:
last_event_id = sse.id # pragma: no cover
last_event_id = sse.id
# Track retry interval from server
if sse.retry is not None:
retry_interval_ms = sse.retry # pragma: no cover
retry_interval_ms = sse.retry

await self._handle_sse_event(sse, read_stream_writer)

Expand Down Expand Up @@ -333,8 +333,8 @@ async def _handle_sse_response(
if is_complete:
await response.aclose()
return # Normal completion, no reconnect needed
except Exception as e: # pragma: no cover
logger.debug(f"SSE stream ended: {e}")
except Exception as e:
logger.debug(f"SSE stream ended: {e}") # pragma: no cover

# Stream ended without response - reconnect if we received an event with ID
if last_event_id is not None: # pragma: no branch
Expand Down Expand Up @@ -472,20 +472,20 @@ async def handle_request_async():
await read_stream_writer.aclose()
await write_stream.aclose()

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

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

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

def get_session_id(self) -> str | None:
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/server/auth/handlers/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ async def handle(self, request: Request):
except TokenError as e:
return self.response(TokenErrorResponse(error=e.error, error_description=e.error_description))

case RefreshTokenRequest(): # pragma: no cover
case RefreshTokenRequest(): # pragma: no branch
refresh_token = await self.provider.load_refresh_token(client_info, token_request.refresh_token)
if refresh_token is None or refresh_token.client_id != token_request.client_id:
# if token belongs to different client, pretend it doesn't exist
Expand Down
8 changes: 4 additions & 4 deletions src/mcp/server/auth/middleware/client_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

class AuthenticationError(Exception):
def __init__(self, message: str):
self.message = message # pragma: no cover
self.message = message


class ClientAuthenticator:
Expand Down Expand Up @@ -96,15 +96,15 @@ async def authenticate_request(self, request: Request) -> OAuthClientInformation

# If client from the store expects a secret, validate that the request provides
# that secret
if client.client_secret: # pragma: no branch
if client.client_secret:
if not request_client_secret:
raise AuthenticationError("Client secret is required") # pragma: no cover
raise AuthenticationError("Client secret is required")

# hmac.compare_digest requires that both arguments are either bytes or a `str` containing
# only ASCII characters. Since we do not control `request_client_secret`, we encode both
# arguments to bytes.
if not hmac.compare_digest(client.client_secret.encode(), request_client_secret.encode()):
raise AuthenticationError("Invalid client_secret") # pragma: no cover
raise AuthenticationError("Invalid client_secret")

if client.client_secret_expires_at and client.client_secret_expires_at < int(time.time()):
raise AuthenticationError("Client secret has expired") # pragma: no cover
Expand Down
6 changes: 2 additions & 4 deletions src/mcp/server/experimental/task_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,7 @@ async def elicit(
response_data = await resolver.wait()
await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING)
return ElicitResult.model_validate(response_data)
except anyio.get_cancelled_exc_class(): # pragma: no cover
# Coverage can't track async exception handlers reliably.
except anyio.get_cancelled_exc_class():
# This path is tested in test_elicit_restores_status_on_cancellation
# which verifies status is restored to "working" after cancellation.
await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING)
Expand Down Expand Up @@ -408,8 +407,7 @@ async def create_message(
response_data = await resolver.wait()
await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING)
return CreateMessageResult.model_validate(response_data)
except anyio.get_cancelled_exc_class(): # pragma: no cover
# Coverage can't track async exception handlers reliably.
except anyio.get_cancelled_exc_class():
# This path is tested in test_create_message_restores_status_on_cancellation
# which verifies status is restored to "working" after cancellation.
await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING)
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/server/fastmcp/resources/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ class FileResource(Resource):

@pydantic.field_validator("path")
@classmethod
def validate_absolute_path(cls, path: Path) -> Path: # pragma: no cover
def validate_absolute_path(cls, path: Path) -> Path:
"""Ensure path is absolute."""
if not path.is_absolute():
raise ValueError("Path must be absolute")
Expand Down
4 changes: 2 additions & 2 deletions src/mcp/server/fastmcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -873,10 +873,10 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no
app=RequireAuthMiddleware(sse.handle_post_message, required_scopes, resource_metadata_url),
)
)
else: # pragma: no cover
else:
# Auth is disabled, no need for RequireAuthMiddleware
# Since handle_sse is an ASGI app, we need to create a compatible endpoint
async def sse_endpoint(request: Request) -> Response:
async def sse_endpoint(request: Request) -> Response: # pragma: no cover
# Convert the Starlette request to ASGI parameters
return await handle_sse(request.scope, request.receive, request._send) # type: ignore[reportPrivateUsage]

Expand Down
2 changes: 1 addition & 1 deletion src/mcp/server/fastmcp/tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ async def run(


def _is_async_callable(obj: Any) -> bool:
while isinstance(obj, functools.partial): # pragma: no cover
while isinstance(obj, functools.partial): # pragma: lax no cover
obj = obj.func

return inspect.iscoroutinefunction(obj) or (
Expand Down
3 changes: 1 addition & 2 deletions src/mcp/server/fastmcp/utilities/context_injection.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ def find_context_parameter(fn: Callable[..., Any]) -> str | None:
# Get type hints to properly resolve string annotations
try:
hints = typing.get_type_hints(fn)
# TODO(Marcelo): Drop the pragma once https://github.com/coveragepy/coveragepy/issues/1987 is fixed.
except Exception: # pragma: no cover
except Exception:
# If we can't resolve type hints, we can't find the context parameter
return None

Expand Down
2 changes: 1 addition & 1 deletion src/mcp/server/fastmcp/utilities/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def configure_logging(
level: the log level to use
"""
handlers: list[logging.Handler] = []
try: # pragma: no cover
try:
from rich.console import Console
from rich.logging import RichHandler

Expand Down
Loading
Loading