Skip to content
Open
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():
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:
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:
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
2 changes: 1 addition & 1 deletion src/mcp/client/session_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ async def disconnect_from_server(self, session: mcp.ClientSession) -> None:
)
)

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

# Remove prompts associated with the session.
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:
logger.exception("Encountered SSE exception")
raise sse_exc
except Exception as exc:
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:
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)
26 changes: 13 additions & 13 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,19 +190,19 @@ 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)

# Stream ended normally (server closed) - reset attempt counter
attempt = 0

except Exception as exc: # pragma: no cover
logger.debug(f"GET stream error: {exc}")
attempt += 1
except Exception as exc:
logger.debug(f"GET stream error: {exc}") # pragma: no cover
attempt += 1 # pragma: no cover

if attempt >= MAX_RECONNECTION_ATTEMPTS: # pragma: no cover
logger.debug(f"GET stream max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded")
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: no branch
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: no branch
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: no branch
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():
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
10 changes: 4 additions & 6 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 @@ -318,7 +317,7 @@ async def elicit_url(
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
except anyio.get_cancelled_exc_class():
await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING)
raise

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 Expand Up @@ -488,7 +486,7 @@ async def elicit_as_task(
await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING)
return result

except anyio.get_cancelled_exc_class(): # pragma: no cover
except anyio.get_cancelled_exc_class():
await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING)
raise

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
2 changes: 1 addition & 1 deletion src/mcp/server/fastmcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -873,7 +873,7 @@ 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:
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):
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
Loading
Loading