Get baseline 100% clean coverage (#1553)

This commit is contained in:
Max Isbey 2025-11-11 13:09:32 +00:00 • committed by GitHub
commit 89e9c43acf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
111 changed files with 1032 additions and 1084 deletions

View file

@ -55,8 +55,11 @@ jobs:
- name: Install the project
run: uv sync ${{ matrix.dep-resolution.install-flags }} --all-extras --python ${{ matrix.python-version }}
- name: Run pytest
run: uv run ${{ matrix.dep-resolution.install-flags }} --no-sync pytest
- name: Run pytest with coverage
run: |
uv run ${{ matrix.dep-resolution.install-flags }} --no-sync coverage run -m pytest
uv run ${{ matrix.dep-resolution.install-flags }} --no-sync coverage combine
uv run ${{ matrix.dep-resolution.install-flags }} --no-sync coverage report
env:
UV_RESOLUTION: ${{ matrix.dep-resolution.name == 'lowest-direct' && 'lowest-direct' || 'highest' }}

View file

@ -60,6 +60,7 @@ dev = [
"pytest-pretty>=1.2.0",
"inline-snapshot>=0.23.0",
"dirty-equals>=0.9.0",
"coverage[toml]==7.10.7",
]
docs = [
"mkdocs>=1.6.1",
@ -168,3 +169,40 @@ MD033 = false # no-inline-html Inline HTML
MD041 = false # first-line-heading/first-line-h1
MD046 = false # indented-code-blocks
MD059 = false # descriptive-link-text
# https://coverage.readthedocs.io/en/latest/config.html#run
[tool.coverage.run]
branch = true
patch = ["subprocess"]
concurrency = ["multiprocessing", "thread"]
source = ["src", "tests"]
relative_files = true
omit = [
"src/mcp/client/__main__.py",
"src/mcp/server/__main__.py",
"src/mcp/os/posix/utilities.py",
"src/mcp/os/win32/utilities.py",
]
# https://coverage.readthedocs.io/en/latest/config.html#report
[tool.coverage.report]
fail_under = 100
skip_covered = true
show_missing = true
ignore_errors = true
precision = 2
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"@overload",
"raise NotImplementedError",
"^\\s*\\.\\.\\.\\s*$",
]
# https://coverage.readthedocs.io/en/latest/config.html#paths
[tool.coverage.paths]
source = [
"src/",
"/home/runner/work/python-sdk/python-sdk/src/",
'D:\a\python-sdk\python-sdk\src',
]

7
scripts/test Executable file
View file

@ -0,0 +1,7 @@
#!/bin/sh
set -ex
uv run --frozen coverage run -m pytest -n auto $@
uv run --frozen coverage combine
uv run --frozen coverage report

View file

@ -2,5 +2,5 @@
from .cli import app
if __name__ == "__main__":
if __name__ == "__main__": # pragma: no cover
app()

View file

@ -14,7 +14,7 @@ logger = get_logger(__name__)
MCP_PACKAGE = "mcp[cli]"
def get_claude_config_path() -> Path | None:
def get_claude_config_path() -> Path | None: # pragma: no cover
"""Get the Claude config directory based on platform."""
if sys.platform == "win32":
path = Path(Path.home(), "AppData", "Roaming", "Claude")
@ -33,7 +33,7 @@ def get_claude_config_path() -> Path | None:
def get_uv_path() -> str:
"""Get the full path to the uv executable."""
uv_path = shutil.which("uv")
if not uv_path:
if not uv_path: # pragma: no cover
logger.error(
"uv executable not found in PATH, falling back to 'uv'. Please ensure uv is installed and in your PATH"
)
@ -65,14 +65,14 @@ def update_claude_config(
"""
config_dir = get_claude_config_path()
uv_path = get_uv_path()
if not config_dir:
if not config_dir: # pragma: no cover
raise RuntimeError(
"Claude Desktop config directory not found. Please ensure Claude Desktop"
" is installed and has been run at least once to initialize its config."
)
config_file = config_dir / "claude_desktop_config.json"
if not config_file.exists():
if not config_file.exists(): # pragma: no cover
try:
config_file.write_text("{}")
except Exception:
@ -90,7 +90,7 @@ def update_claude_config(
config["mcpServers"] = {}
# Always preserve existing env vars and merge with new ones
if server_name in config["mcpServers"] and "env" in config["mcpServers"][server_name]:
if server_name in config["mcpServers"] and "env" in config["mcpServers"][server_name]: # pragma: no cover
existing_env = config["mcpServers"][server_name]["env"]
if env_vars:
# New vars take precedence over existing ones
@ -103,14 +103,14 @@ def update_claude_config(
# Collect all packages in a set to deduplicate
packages = {MCP_PACKAGE}
if with_packages:
if with_packages: # pragma: no cover
packages.update(pkg for pkg in with_packages if pkg)
# Add all packages with --with
for pkg in sorted(packages):
args.extend(["--with", pkg])
if with_editable:
if with_editable: # pragma: no cover
args.extend(["--with-editable", str(with_editable)])
# Convert file path to absolute before adding to command
@ -118,7 +118,7 @@ def update_claude_config(
if ":" in file_spec:
file_path, server_object = file_spec.rsplit(":", 1)
file_spec = f"{Path(file_path).resolve()}:{server_object}"
else:
else: # pragma: no cover
file_spec = str(Path(file_spec).resolve())
# Add fastmcp run command
@ -127,7 +127,7 @@ def update_claude_config(
server_config: dict[str, Any] = {"command": uv_path, "args": args}
# Add environment variables if specified
if env_vars:
if env_vars: # pragma: no cover
server_config["env"] = env_vars
config["mcpServers"][server_name] = server_config
@ -138,7 +138,7 @@ def update_claude_config(
extra={"config_file": str(config_file)},
)
return True
except Exception:
except Exception: # pragma: no cover
logger.exception(
"Failed to update Claude config",
extra={

View file

@ -13,20 +13,20 @@ from mcp.server import Server as LowLevelServer
try:
import typer
except ImportError:
except ImportError: # pragma: no cover
print("Error: typer is required. Install with 'pip install mcp[cli]'")
sys.exit(1)
try:
from mcp.cli import claude
from mcp.server.fastmcp.utilities.logging import get_logger
except ImportError:
except ImportError: # pragma: no cover
print("Error: mcp.server.fastmcp is not installed or not in PYTHONPATH")
sys.exit(1)
try:
import dotenv
except ImportError:
except ImportError: # pragma: no cover
dotenv = None
logger = get_logger("cli")
@ -53,7 +53,7 @@ def _get_npx_command():
return "npx" # On Unix-like systems, just use npx
def _parse_env_var(env_var: str) -> tuple[str, str]:
def _parse_env_var(env_var: str) -> tuple[str, str]: # pragma: no cover
"""Parse environment variable string in format KEY=VALUE."""
if "=" not in env_var:
logger.error(f"Invalid environment variable format: {env_var}. Must be KEY=VALUE")
@ -77,7 +77,7 @@ def _build_uv_command(
if with_packages:
for pkg in with_packages:
if pkg:
if pkg: # pragma: no cover
cmd.extend(["--with", pkg])
# Add mcp run command
@ -116,7 +116,7 @@ def _parse_file_path(file_spec: str) -> tuple[Path, str | None]:
return file_path, server_object
def _import_server(file: Path, server_object: str | None = None):
def _import_server(file: Path, server_object: str | None = None): # pragma: no cover
"""Import an MCP server from a file.
Args:
@ -209,7 +209,7 @@ def _import_server(file: Path, server_object: str | None = None):
@app.command()
def version() -> None:
def version() -> None: # pragma: no cover
"""Show the MCP version."""
try:
version = importlib.metadata.version("mcp")
@ -243,7 +243,7 @@ def dev(
help="Additional packages to install",
),
] = [],
) -> None:
) -> None: # pragma: no cover
"""Run an MCP server with the MCP Inspector."""
file, server_object = _parse_file_path(file_spec)
@ -316,7 +316,7 @@ def run(
help="Transport protocol to use (stdio or sse)",
),
] = None,
) -> None:
) -> None: # pragma: no cover
"""Run an MCP server.
The server can be specified in two ways:\n
@ -411,7 +411,7 @@ def install(
resolve_path=True,
),
] = None,
) -> None:
) -> None: # pragma: no cover
"""Install an MCP server in the Claude desktop app.
Environment variables are preserved once added and only updated if new values

View file

@ -34,15 +34,15 @@ class JWTParameters(BaseModel):
assertion = self.assertion
else:
if not self.jwt_signing_key:
raise OAuthFlowError("Missing signing key for JWT bearer grant")
raise OAuthFlowError("Missing signing key for JWT bearer grant") # pragma: no cover
if not self.issuer:
raise OAuthFlowError("Missing issuer for JWT bearer grant")
raise OAuthFlowError("Missing issuer for JWT bearer grant") # pragma: no cover
if not self.subject:
raise OAuthFlowError("Missing subject for JWT bearer grant")
raise OAuthFlowError("Missing subject for JWT bearer grant") # pragma: no cover
audience = self.audience if self.audience else with_audience_fallback
if not audience:
raise OAuthFlowError("Missing audience for JWT bearer grant")
raise OAuthFlowError("Missing audience for JWT bearer grant") # pragma: no cover
now = int(time.time())
claims: dict[str, Any] = {
@ -83,14 +83,14 @@ class RFC7523OAuthClientProvider(OAuthClientProvider):
async def _exchange_token_authorization_code(
self, auth_code: str, code_verifier: str, *, token_data: dict[str, Any] | None = None
) -> httpx.Request:
) -> httpx.Request: # pragma: no cover
"""Build token exchange request for authorization_code flow."""
token_data = token_data or {}
if self.context.client_metadata.token_endpoint_auth_method == "private_key_jwt":
self._add_client_authentication_jwt(token_data=token_data)
return await super()._exchange_token_authorization_code(auth_code, code_verifier, token_data=token_data)
async def _perform_authorization(self) -> httpx.Request:
async def _perform_authorization(self) -> httpx.Request: # pragma: no cover
"""Perform the authorization flow."""
if "urn:ietf:params:oauth:grant-type:jwt-bearer" in self.context.client_metadata.grant_types:
token_request = await self._exchange_token_jwt_bearer()
@ -98,7 +98,7 @@ class RFC7523OAuthClientProvider(OAuthClientProvider):
else:
return await super()._perform_authorization()
def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]):
def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]): # pragma: no cover
"""Add JWT assertion for client authentication to token endpoint parameters."""
if not self.jwt_parameters:
raise OAuthTokenError("Missing JWT parameters for private_key_jwt flow")
@ -120,11 +120,11 @@ class RFC7523OAuthClientProvider(OAuthClientProvider):
async def _exchange_token_jwt_bearer(self) -> httpx.Request:
"""Build token exchange request for JWT bearer grant."""
if not self.context.client_info:
raise OAuthFlowError("Missing client info")
raise OAuthFlowError("Missing client info") # pragma: no cover
if not self.jwt_parameters:
raise OAuthFlowError("Missing JWT parameters")
raise OAuthFlowError("Missing JWT parameters") # pragma: no cover
if not self.context.oauth_metadata:
raise OAuthTokenError("Missing OAuth metadata")
raise OAuthTokenError("Missing OAuth metadata") # pragma: no cover
# We need to set the audience to the issuer identifier of the authorization server
# https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rfc7523bis-01#name-updates-to-rfc-7523
@ -136,10 +136,10 @@ class RFC7523OAuthClientProvider(OAuthClientProvider):
"assertion": assertion,
}
if self.context.should_include_resource_param(self.context.protocol_version):
if self.context.should_include_resource_param(self.context.protocol_version): # pragma: no branch
token_data["resource"] = self.context.get_resource_url()
if self.context.client_metadata.scope:
if self.context.client_metadata.scope: # pragma: no branch
token_data["scope"] = self.context.client_metadata.scope
token_url = self._get_token_endpoint()

View file

@ -117,7 +117,7 @@ class OAuthContext:
"""Update token expiry time."""
if token.expires_in:
self.token_expiry_time = time.time() + token.expires_in
else:
else: # pragma: no cover
self.token_expiry_time = None
def is_token_valid(self) -> bool:
@ -265,7 +265,7 @@ class OAuthClientProvider(httpx.Auth):
Returns:
Resource metadata URL if found in WWW-Authenticate header, None otherwise
"""
if not init_response or init_response.status_code != 401:
if not init_response or init_response.status_code != 401: # pragma: no cover
return None
return self._extract_field_from_www_auth(init_response, "resource_metadata")
@ -293,11 +293,11 @@ class OAuthClientProvider(httpx.Auth):
content = await response.aread()
metadata = ProtectedResourceMetadata.model_validate_json(content)
self.context.protected_resource_metadata = metadata
if metadata.authorization_servers:
if metadata.authorization_servers: # pragma: no branch
self.context.auth_server_url = str(metadata.authorization_servers[0])
return True
except ValidationError:
except ValidationError: # pragma: no cover
# Invalid metadata - try next URL
logger.warning(f"Invalid protected resource metadata at {response.request.url}")
return False
@ -305,7 +305,7 @@ class OAuthClientProvider(httpx.Auth):
# Not found - try next URL in fallback chain
logger.debug(f"Protected resource metadata not found at {response.request.url}, trying next URL")
return False
else:
else: # pragma: no cover
# Other error - fail immediately
raise OAuthFlowError(f"Protected Resource Metadata request failed: {response.status_code}")
@ -385,7 +385,7 @@ class OAuthClientProvider(httpx.Auth):
client_info = OAuthClientInformationFull.model_validate_json(content)
self.context.client_info = client_info
await self.context.storage.set_client_info(client_info)
except ValidationError as e:
except ValidationError as e: # pragma: no cover
raise OAuthRegistrationError(f"Invalid registration response: {e}")
async def _perform_authorization(self) -> httpx.Request:
@ -397,20 +397,20 @@ class OAuthClientProvider(httpx.Auth):
async def _perform_authorization_code_grant(self) -> tuple[str, str]:
"""Perform the authorization redirect and get auth code."""
if self.context.client_metadata.redirect_uris is None:
raise OAuthFlowError("No redirect URIs provided for authorization code grant")
raise OAuthFlowError("No redirect URIs provided for authorization code grant") # pragma: no cover
if not self.context.redirect_handler:
raise OAuthFlowError("No redirect handler provided for authorization code grant")
raise OAuthFlowError("No redirect handler provided for authorization code grant") # pragma: no cover
if not self.context.callback_handler:
raise OAuthFlowError("No callback handler provided for authorization code grant")
raise OAuthFlowError("No callback handler provided for authorization code grant") # pragma: no cover
if self.context.oauth_metadata and self.context.oauth_metadata.authorization_endpoint:
auth_endpoint = str(self.context.oauth_metadata.authorization_endpoint)
auth_endpoint = str(self.context.oauth_metadata.authorization_endpoint) # pragma: no cover
else:
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
auth_endpoint = urljoin(auth_base_url, "/authorize")
if not self.context.client_info:
raise OAuthFlowError("No client info available for authorization")
raise OAuthFlowError("No client info available for authorization") # pragma: no cover
# Generate PKCE parameters
pkce_params = PKCEParameters.generate()
@ -427,9 +427,9 @@ class OAuthClientProvider(httpx.Auth):
# Only include resource param if conditions are met
if self.context.should_include_resource_param(self.context.protocol_version):
auth_params["resource"] = self.context.get_resource_url() # RFC 8707
auth_params["resource"] = self.context.get_resource_url() # RFC 8707 # pragma: no cover
if self.context.client_metadata.scope:
if self.context.client_metadata.scope: # pragma: no cover
auth_params["scope"] = self.context.client_metadata.scope
authorization_url = f"{auth_endpoint}?{urlencode(auth_params)}"
@ -439,10 +439,10 @@ class OAuthClientProvider(httpx.Auth):
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}")
raise OAuthFlowError(f"State parameter mismatch: {returned_state} != {state}") # pragma: no cover
if not auth_code:
raise OAuthFlowError("No authorization code received")
raise OAuthFlowError("No authorization code received") # pragma: no cover
# Return auth code and code verifier for token exchange
return auth_code, pkce_params.code_verifier
@ -460,9 +460,9 @@ class OAuthClientProvider(httpx.Auth):
) -> httpx.Request:
"""Build token exchange request for authorization_code flow."""
if self.context.client_metadata.redirect_uris is None:
raise OAuthFlowError("No redirect URIs provided for authorization code grant")
raise OAuthFlowError("No redirect URIs provided for authorization code grant") # pragma: no cover
if not self.context.client_info:
raise OAuthFlowError("Missing client info")
raise OAuthFlowError("Missing client info") # pragma: no cover
token_url = self._get_token_endpoint()
token_data = token_data or {}
@ -489,7 +489,7 @@ class OAuthClientProvider(httpx.Auth):
async def _handle_token_response(self, response: httpx.Response) -> None:
"""Handle token exchange response."""
if response.status_code != 200:
if response.status_code != 200: # pragma: no cover
body = await response.aread()
body = body.decode("utf-8")
raise OAuthTokenError(f"Token exchange failed ({response.status_code}): {body}")
@ -504,24 +504,26 @@ class OAuthClientProvider(httpx.Auth):
returned_scopes = set(token_response.scope.split())
unauthorized_scopes = returned_scopes - requested_scopes
if unauthorized_scopes:
raise OAuthTokenError(f"Server granted unauthorized scopes: {unauthorized_scopes}")
raise OAuthTokenError(
f"Server granted unauthorized scopes: {unauthorized_scopes}"
) # pragma: no cover
self.context.current_tokens = token_response
self.context.update_token_expiry(token_response)
await self.context.storage.set_tokens(token_response)
except ValidationError as e:
except ValidationError as e: # pragma: no cover
raise OAuthTokenError(f"Invalid token response: {e}")
async def _refresh_token(self) -> httpx.Request:
"""Build token refresh request."""
if not self.context.current_tokens or not self.context.current_tokens.refresh_token:
raise OAuthTokenError("No refresh token available")
raise OAuthTokenError("No refresh token available") # pragma: no cover
if not self.context.client_info:
raise OAuthTokenError("No client info available")
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)
token_url = str(self.context.oauth_metadata.token_endpoint) # pragma: no cover
else:
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
token_url = urljoin(auth_base_url, "/token")
@ -536,14 +538,14 @@ class OAuthClientProvider(httpx.Auth):
if self.context.should_include_resource_param(self.context.protocol_version):
refresh_data["resource"] = self.context.get_resource_url() # RFC 8707
if self.context.client_info.client_secret:
if self.context.client_info.client_secret: # pragma: no branch
refresh_data["client_secret"] = self.context.client_info.client_secret
return httpx.Request(
"POST", token_url, data=refresh_data, headers={"Content-Type": "application/x-www-form-urlencoded"}
)
async def _handle_refresh_response(self, response: httpx.Response) -> bool:
async def _handle_refresh_response(self, response: httpx.Response) -> bool: # pragma: no cover
"""Handle token refresh response. Returns True if successful."""
if response.status_code != 200:
logger.warning(f"Token refresh failed: {response.status_code}")
@ -564,7 +566,7 @@ class OAuthClientProvider(httpx.Auth):
self.context.clear_tokens()
return False
async def _initialize(self) -> None:
async def _initialize(self) -> None: # pragma: no cover
"""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()
@ -572,7 +574,7 @@ class OAuthClientProvider(httpx.Auth):
def _add_auth_header(self, request: httpx.Request) -> None:
"""Add authorization header to request if we have valid tokens."""
if self.context.current_tokens and self.context.current_tokens.access_token:
if self.context.current_tokens and self.context.current_tokens.access_token: # pragma: no branch
request.headers["Authorization"] = f"Bearer {self.context.current_tokens.access_token}"
def _create_oauth_metadata_request(self, url: str) -> httpx.Request:
@ -587,12 +589,12 @@ class OAuthClientProvider(httpx.Auth):
"""HTTPX auth flow integration."""
async with self.context.lock:
if not self._initialized:
await self._initialize()
await self._initialize() # pragma: no cover
# 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():
if not self.context.is_token_valid() and self.context.can_refresh_token(): # pragma: no cover
# Try to refresh token
refresh_request = await self._refresh_token()
refresh_response = yield refresh_request
@ -613,7 +615,7 @@ class OAuthClientProvider(httpx.Auth):
# Step 1: Discover protected resource metadata (SEP-985 with fallback support)
discovery_urls = self._build_protected_resource_discovery_urls(response)
discovery_success = False
for url in discovery_urls:
for url in discovery_urls: # pragma: no cover
discovery_request = httpx.Request(
"GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION}
)
@ -623,14 +625,16 @@ class OAuthClientProvider(httpx.Auth):
break
if not discovery_success:
raise OAuthFlowError("Protected resource metadata discovery failed: no valid metadata found")
raise OAuthFlowError(
"Protected resource metadata discovery failed: no valid metadata found"
) # pragma: no cover
# Step 2: Apply scope selection strategy
self._select_scopes(response)
# Step 3: Discover OAuth metadata (with fallback for legacy servers)
discovery_urls = self._get_discovery_urls()
for url in discovery_urls:
for url in discovery_urls: # pragma: no branch
oauth_metadata_request = self._create_oauth_metadata_request(url)
oauth_metadata_response = yield oauth_metadata_request
@ -638,7 +642,7 @@ class OAuthClientProvider(httpx.Auth):
try:
await self._handle_oauth_metadata_response(oauth_metadata_response)
break
except ValidationError:
except ValidationError: # pragma: no cover
continue
elif oauth_metadata_response.status_code < 400 or oauth_metadata_response.status_code >= 500:
break # Non-4XX error, stop trying
@ -652,7 +656,7 @@ class OAuthClientProvider(httpx.Auth):
# Step 5: Perform authorization and complete token exchange
token_response = yield await self._perform_authorization()
await self._handle_token_response(token_response)
except Exception:
except Exception: # pragma: no cover
logger.exception("OAuth flow error")
raise
@ -664,7 +668,7 @@ class OAuthClientProvider(httpx.Auth):
error = self._extract_field_from_www_auth(response, "error")
# Step 2: Check if we need to step-up authorization
if error == "insufficient_scope":
if error == "insufficient_scope": # pragma: no branch
try:
# Step 2a: Update the required scopes
self._select_scopes(response)
@ -672,7 +676,7 @@ class OAuthClientProvider(httpx.Auth):
# Step 2b: Perform (re-)authorization and token exchange
token_response = yield await self._perform_authorization()
await self._handle_token_response(token_response)
except Exception:
except Exception: # pragma: no cover
logger.exception("OAuth flow error")
raise

View file

@ -24,7 +24,7 @@ class SamplingFnT(Protocol):
self,
context: RequestContext["ClientSession", Any],
params: types.CreateMessageRequestParams,
) -> types.CreateMessageResult | types.ErrorData: ...
) -> types.CreateMessageResult | types.ErrorData: ... # pragma: no branch
class ElicitationFnT(Protocol):
@ -32,27 +32,27 @@ class ElicitationFnT(Protocol):
self,
context: RequestContext["ClientSession", Any],
params: types.ElicitRequestParams,
) -> types.ElicitResult | types.ErrorData: ...
) -> types.ElicitResult | types.ErrorData: ... # pragma: no branch
class ListRootsFnT(Protocol):
async def __call__(
self, context: RequestContext["ClientSession", Any]
) -> types.ListRootsResult | types.ErrorData: ...
) -> types.ListRootsResult | types.ErrorData: ... # pragma: no branch
class LoggingFnT(Protocol):
async def __call__(
self,
params: types.LoggingMessageNotificationParams,
) -> None: ...
) -> None: ... # pragma: no branch
class MessageHandlerFnT(Protocol):
async def __call__(
self,
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
) -> None: ...
) -> None: ... # pragma: no branch
async def _default_message_handler(
@ -75,7 +75,7 @@ async def _default_elicitation_callback(
context: RequestContext["ClientSession", Any],
params: types.ElicitRequestParams,
) -> types.ElicitResult | types.ErrorData:
return types.ErrorData(
return types.ErrorData( # pragma: no cover
code=types.INVALID_REQUEST,
message="Elicitation not supported",
)
@ -214,7 +214,7 @@ class ClientSession(
async def set_logging_level(self, level: types.LoggingLevel) -> types.EmptyResult:
"""Send a logging/setLevel request."""
return await self.send_request(
return await self.send_request( # pragma: no cover
types.ClientRequest(
types.SetLevelRequest(
params=types.SetLevelRequestParams(level=level),
@ -312,7 +312,7 @@ class ClientSession(
async def subscribe_resource(self, uri: AnyUrl) -> types.EmptyResult:
"""Send a resources/subscribe request."""
return await self.send_request(
return await self.send_request( # pragma: no cover
types.ClientRequest(
types.SubscribeRequest(
params=types.SubscribeRequestParams(uri=uri),
@ -323,7 +323,7 @@ class ClientSession(
async def unsubscribe_resource(self, uri: AnyUrl) -> types.EmptyResult:
"""Send a resources/unsubscribe request."""
return await self.send_request(
return await self.send_request( # pragma: no cover
types.ClientRequest(
types.UnsubscribeRequest(
params=types.UnsubscribeRequestParams(uri=uri),
@ -377,13 +377,15 @@ class ClientSession(
if output_schema is not None:
if result.structuredContent is None:
raise RuntimeError(f"Tool {name} has an output schema but did not return structured content")
raise RuntimeError(
f"Tool {name} has an output schema but did not return structured content"
) # pragma: no cover
try:
validate(result.structuredContent, output_schema)
except ValidationError as e:
raise RuntimeError(f"Invalid structured content returned by tool {name}: {e}")
except SchemaError as e:
raise RuntimeError(f"Invalid schema for tool {name}: {e}")
raise RuntimeError(f"Invalid structured content returned by tool {name}: {e}") # pragma: no cover
except SchemaError as e: # pragma: no cover
raise RuntimeError(f"Invalid schema for tool {name}: {e}") # pragma: no cover
@overload
@deprecated("Use list_prompts(params=PaginatedRequestParams(...)) instead")
@ -501,7 +503,7 @@ class ClientSession(
return result
async def send_roots_list_changed(self) -> None:
async def send_roots_list_changed(self) -> None: # pragma: no cover
"""Send a roots/list_changed notification."""
await self.send_notification(types.ClientNotification(types.RootsListChangedNotification()))
@ -532,7 +534,7 @@ class ClientSession(
client_response = ClientResponse.validate_python(response)
await responder.respond(client_response)
case types.PingRequest():
case types.PingRequest(): # pragma: no cover
with responder:
return await responder.respond(types.ClientResult(root=types.EmptyResult()))

View file

@ -129,7 +129,7 @@ class ClientSessionGroup:
self._session_exit_stacks = {}
self._component_name_hook = component_name_hook
async def __aenter__(self) -> Self:
async def __aenter__(self) -> Self: # pragma: no cover
# Enter the exit stack only if we created it ourselves
if self._owns_exit_stack:
await self._exit_stack.__aenter__()
@ -140,7 +140,7 @@ class ClientSessionGroup:
_exc_type: type[BaseException] | None,
_exc_val: BaseException | None,
_exc_tb: TracebackType | None,
) -> bool | None:
) -> bool | None: # pragma: no cover
"""Closes session exit stacks and main exit stack upon completion."""
# Only close the main exit stack if we created it
@ -155,7 +155,7 @@ class ClientSessionGroup:
@property
def sessions(self) -> list[mcp.ClientSession]:
"""Returns the list of sessions being managed."""
return list(self._sessions.keys())
return list(self._sessions.keys()) # pragma: no cover
@property
def prompts(self) -> dict[str, types.Prompt]:
@ -192,7 +192,7 @@ class ClientSessionGroup:
)
)
if session_known_for_components:
if session_known_for_components: # pragma: no cover
component_names = self._sessions.pop(session) # Pop from _sessions tracking
# Remove prompts associated with the session.
@ -212,8 +212,8 @@ class ClientSessionGroup:
# Clean up the session's resources via its dedicated exit stack
if session_known_for_stack:
session_stack_to_close = self._session_exit_stacks.pop(session)
await session_stack_to_close.aclose()
session_stack_to_close = self._session_exit_stacks.pop(session) # pragma: no cover
await session_stack_to_close.aclose() # pragma: no cover
async def connect_with_session(
self, server_info: types.Implementation, session: mcp.ClientSession
@ -270,7 +270,7 @@ class ClientSessionGroup:
await self._exit_stack.enter_async_context(session_stack)
return result.serverInfo, session
except Exception:
except Exception: # pragma: no cover
# If anything during this setup fails, ensure the session-specific
# stack is closed.
await session_stack.aclose()
@ -298,7 +298,7 @@ class ClientSessionGroup:
name = self._component_name(prompt.name, server_info)
prompts_temp[name] = prompt
component_names.prompts.add(name)
except McpError as err:
except McpError as err: # pragma: no cover
logging.warning(f"Could not fetch prompts: {err}")
# Query the server for its resources and aggregate to list.
@ -308,7 +308,7 @@ class ClientSessionGroup:
name = self._component_name(resource.name, server_info)
resources_temp[name] = resource
component_names.resources.add(name)
except McpError as err:
except McpError as err: # pragma: no cover
logging.warning(f"Could not fetch resources: {err}")
# Query the server for its tools and aggregate to list.
@ -319,18 +319,18 @@ class ClientSessionGroup:
tools_temp[name] = tool
tool_to_session_temp[name] = session
component_names.tools.add(name)
except McpError as err:
except McpError as err: # pragma: no cover
logging.warning(f"Could not fetch tools: {err}")
# Clean up exit stack for session if we couldn't retrieve anything
# from the server.
if not any((prompts_temp, resources_temp, tools_temp)):
del self._session_exit_stacks[session]
del self._session_exit_stacks[session] # pragma: no cover
# Check for duplicates.
matching_prompts = prompts_temp.keys() & self._prompts.keys()
if matching_prompts:
raise McpError(
raise McpError( # pragma: no cover
types.ErrorData(
code=types.INVALID_PARAMS,
message=f"{matching_prompts} already exist in group prompts.",
@ -338,7 +338,7 @@ class ClientSessionGroup:
)
matching_resources = resources_temp.keys() & self._resources.keys()
if matching_resources:
raise McpError(
raise McpError( # pragma: no cover
types.ErrorData(
code=types.INVALID_PARAMS,
message=f"{matching_resources} already exist in group resources.",

View file

@ -70,7 +70,7 @@ async def sse_client(
task_status: TaskStatus[str] = anyio.TASK_STATUS_IGNORED,
):
try:
async for sse in event_source.aiter_sse():
async for sse in event_source.aiter_sse(): # pragma: no branch
logger.debug(f"Received SSE event: {sse.event}")
match sse.event:
case "endpoint":
@ -79,15 +79,15 @@ async def sse_client(
url_parsed = urlparse(url)
endpoint_parsed = urlparse(endpoint_url)
if (
if ( # pragma: no cover
url_parsed.netloc != endpoint_parsed.netloc
or url_parsed.scheme != endpoint_parsed.scheme
):
error_msg = (
error_msg = ( # pragma: no cover
f"Endpoint origin does not match connection origin: {endpoint_url}"
)
logger.error(error_msg)
raise ValueError(error_msg)
logger.error(error_msg) # pragma: no cover
raise ValueError(error_msg) # pragma: no cover
task_status.started(endpoint_url)
@ -97,21 +97,21 @@ async def sse_client(
sse.data
)
logger.debug(f"Received server message: {message}")
except Exception as exc:
logger.exception("Error parsing server message")
await read_stream_writer.send(exc)
continue
except Exception as exc: # pragma: no cover
logger.exception("Error parsing server message") # pragma: no cover
await read_stream_writer.send(exc) # pragma: no cover
continue # pragma: no cover
session_message = SessionMessage(message)
await read_stream_writer.send(session_message)
case _:
logger.warning(f"Unknown SSE event: {sse.event}")
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)
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
finally:
await read_stream_writer.aclose()
@ -130,8 +130,8 @@ async def sse_client(
)
response.raise_for_status()
logger.debug(f"Client message sent successfully: {response.status_code}")
except Exception:
logger.exception("Error in post_writer")
except Exception: # pragma: no cover
logger.exception("Error in post_writer") # pragma: no cover
finally:
await write_stream.aclose()

View file

@ -58,11 +58,11 @@ def get_default_environment() -> dict[str, str]:
for key in DEFAULT_INHERITED_ENV_VARS:
value = os.environ.get(key)
if value is None:
continue
continue # pragma: no cover
if value.startswith("()"):
if value.startswith("()"): # pragma: no cover
# Skip functions, which are a security risk
continue
continue # pragma: no cover
env[key] = value
@ -153,14 +153,14 @@ async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stder
for line in lines:
try:
message = types.JSONRPCMessage.model_validate_json(line)
except Exception as exc:
except Exception as exc: # pragma: no cover
logger.exception("Failed to parse JSONRPC message from server")
await read_stream_writer.send(exc)
continue
session_message = SessionMessage(message)
await read_stream_writer.send(session_message)
except anyio.ClosedResourceError:
except anyio.ClosedResourceError: # pragma: no cover
await anyio.lowlevel.checkpoint()
async def stdin_writer():
@ -176,7 +176,7 @@ async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stder
errors=server.encoding_error_handler,
)
)
except anyio.ClosedResourceError:
except anyio.ClosedResourceError: # pragma: no cover
await anyio.lowlevel.checkpoint()
async with (
@ -192,10 +192,10 @@ async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stder
# 1. Close input stream to server
# 2. Wait for server to exit, or send SIGTERM if it doesn't exit in time
# 3. Send SIGKILL if still not exited
if process.stdin:
if process.stdin: # pragma: no branch
try:
await process.stdin.aclose()
except Exception:
except Exception: # pragma: no cover
# stdin might already be closed, which is fine
pass
@ -207,7 +207,7 @@ async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stder
# Process didn't exit from stdin closure, use platform-specific termination
# which handles SIGTERM -> SIGKILL escalation
await _terminate_process_tree(process)
except ProcessLookupError:
except ProcessLookupError: # pragma: no cover
# Process already exited, which is fine
pass
await read_stream.aclose()
@ -226,10 +226,10 @@ def _get_executable_command(command: str) -> str:
Returns:
str: Platform-appropriate command
"""
if sys.platform == "win32":
if sys.platform == "win32": # pragma: no cover
return get_windows_executable_command(command)
else:
return command
return command # pragma: no cover
async def _create_platform_compatible_process(
@ -245,7 +245,7 @@ async def _create_platform_compatible_process(
Unix: Creates process in a new session/process group for killpg support
Windows: Creates process in a Job Object for reliable child termination
"""
if sys.platform == "win32":
if sys.platform == "win32": # pragma: no cover
process = await create_windows_process(command, args, env, errlog, cwd)
else:
process = await anyio.open_process(
@ -254,7 +254,7 @@ async def _create_platform_compatible_process(
stderr=errlog,
cwd=cwd,
start_new_session=True,
)
) # pragma: no cover
return process
@ -270,9 +270,9 @@ async def _terminate_process_tree(process: Process | FallbackProcess, timeout_se
process: The process to terminate
timeout_seconds: Timeout in seconds before force killing (default: 2.0)
"""
if sys.platform == "win32":
if sys.platform == "win32": # pragma: no cover
await terminate_windows_process_tree(process, timeout_seconds)
else:
else: # pragma: no cover
# FallbackProcess should only be used for Windows compatibility
assert isinstance(process, Process)
await terminate_posix_process_tree(process, timeout_seconds)

View file

@ -138,14 +138,16 @@ class StreamableHTTPTransport:
message: JSONRPCMessage,
) -> None:
"""Extract protocol version from initialization response message."""
if isinstance(message.root, JSONRPCResponse) and message.root.result:
if isinstance(message.root, JSONRPCResponse) and message.root.result: # pragma: no branch
try:
# Parse the result as InitializeResult for type safety
init_result = InitializeResult.model_validate(message.root.result)
self.protocol_version = str(init_result.protocolVersion)
logger.info(f"Negotiated protocol version: {self.protocol_version}")
except Exception as exc:
logger.warning(f"Failed to parse initialization response as InitializeResult: {exc}")
except Exception as exc: # pragma: no cover
logger.warning(
f"Failed to parse initialization response as InitializeResult: {exc}"
) # pragma: no cover
logger.warning(f"Raw result: {message.root.result}")
async def _handle_sse_event(
@ -181,11 +183,11 @@ class StreamableHTTPTransport:
# Otherwise, return False to continue listening
return isinstance(message.root, JSONRPCResponse | JSONRPCError)
except Exception as exc:
except Exception as exc: # pragma: no cover
logger.exception("Error parsing SSE message")
await read_stream_writer.send(exc)
return False
else:
else: # pragma: no cover
logger.warning(f"Unknown SSE event: {sse.event}")
return False
@ -215,7 +217,7 @@ class StreamableHTTPTransport:
await self._handle_sse_event(sse, read_stream_writer)
except Exception as exc:
logger.debug(f"GET stream error (non-fatal): {exc}")
logger.debug(f"GET stream error (non-fatal): {exc}") # pragma: no cover
async def _handle_resumption_request(self, ctx: RequestContext) -> None:
"""Handle a resumption request using GET with SSE."""
@ -223,11 +225,11 @@ class StreamableHTTPTransport:
if ctx.metadata and ctx.metadata.resumption_token:
headers[LAST_EVENT_ID] = ctx.metadata.resumption_token
else:
raise ResumptionError("Resumption request requires a resumption token")
raise ResumptionError("Resumption request requires a resumption token") # pragma: no cover
# Extract original request ID to map responses
original_request_id = None
if isinstance(ctx.session_message.message.root, JSONRPCRequest):
if isinstance(ctx.session_message.message.root, JSONRPCRequest): # pragma: no branch
original_request_id = ctx.session_message.message.root.id
async with aconnect_sse(
@ -240,7 +242,7 @@ class StreamableHTTPTransport:
event_source.response.raise_for_status()
logger.debug("Resumption GET SSE connection established")
async for sse in event_source.aiter_sse():
async for sse in event_source.aiter_sse(): # pragma: no branch
is_complete = await self._handle_sse_event(
sse,
ctx.read_stream_writer,
@ -267,13 +269,13 @@ class StreamableHTTPTransport:
logger.debug("Received 202 Accepted")
return
if response.status_code == 404:
if response.status_code == 404: # pragma: no branch
if isinstance(message.root, JSONRPCRequest):
await self._send_session_terminated_error(
ctx.read_stream_writer,
message.root.id,
)
return
await self._send_session_terminated_error( # pragma: no cover
ctx.read_stream_writer, # pragma: no cover
message.root.id, # pragma: no cover
) # pragma: no cover
return # pragma: no cover
response.raise_for_status()
if is_initialization:
@ -288,10 +290,10 @@ class StreamableHTTPTransport:
elif content_type.startswith(SSE):
await self._handle_sse_response(response, ctx, is_initialization)
else:
await self._handle_unexpected_content_type(
content_type,
ctx.read_stream_writer,
)
await self._handle_unexpected_content_type( # pragma: no cover
content_type, # pragma: no cover
ctx.read_stream_writer, # pragma: no cover
) # pragma: no cover
async def _handle_json_response(
self,
@ -310,7 +312,7 @@ class StreamableHTTPTransport:
session_message = SessionMessage(message)
await read_stream_writer.send(session_message)
except Exception as exc:
except Exception as exc: # pragma: no cover
logger.exception("Error parsing JSON response")
await read_stream_writer.send(exc)
@ -323,7 +325,7 @@ class StreamableHTTPTransport:
"""Handle SSE response from the server."""
try:
event_source = EventSource(response)
async for sse in event_source.aiter_sse():
async for sse in event_source.aiter_sse(): # pragma: no branch
is_complete = await self._handle_sse_event(
sse,
ctx.read_stream_writer,
@ -336,18 +338,18 @@ class StreamableHTTPTransport:
await response.aclose()
break
except Exception as e:
logger.exception("Error reading SSE stream:")
await ctx.read_stream_writer.send(e)
logger.exception("Error reading SSE stream:") # pragma: no cover
await ctx.read_stream_writer.send(e) # pragma: no cover
async def _handle_unexpected_content_type(
self,
content_type: str,
read_stream_writer: StreamWriter,
) -> None:
) -> None: # pragma: no cover
"""Handle unexpected content type in response."""
error_msg = f"Unexpected content type: {content_type}"
logger.error(error_msg)
await read_stream_writer.send(ValueError(error_msg))
error_msg = f"Unexpected content type: {content_type}" # pragma: no cover
logger.error(error_msg) # pragma: no cover
await read_stream_writer.send(ValueError(error_msg)) # pragma: no cover
async def _send_session_terminated_error(
self,
@ -415,12 +417,12 @@ class StreamableHTTPTransport:
await handle_request_async()
except Exception:
logger.exception("Error in post_writer")
logger.exception("Error in post_writer") # pragma: no cover
finally:
await read_stream_writer.aclose()
await write_stream.aclose()
async def terminate_session(self, client: httpx.AsyncClient) -> None:
async def terminate_session(self, client: httpx.AsyncClient) -> None: # pragma: no cover
"""Terminate the session by sending a DELETE request."""
if not self.session_id:
return

View file

@ -59,7 +59,7 @@ async def websocket_client(
message = types.JSONRPCMessage.model_validate_json(raw_text)
session_message = SessionMessage(message)
await read_stream_writer.send(session_message)
except ValidationError as exc:
except ValidationError as exc: # pragma: no cover
# If JSON parse or model validation fails, send the exception
await read_stream_writer.send(exc)

View file

@ -50,7 +50,7 @@ class AuthorizationErrorResponse(BaseModel):
def best_effort_extract_string(key: str, params: None | FormData | QueryParams) -> str | None:
if params is None:
if params is None: # pragma: no cover
return None
value = params.get(key)
if isinstance(value, str):
@ -116,7 +116,7 @@ class AuthorizationHandler:
pass
# the error response MUST contain the state specified by the client, if any
if state is None:
if state is None: # pragma: no cover
# make last-ditch effort to load state
state = best_effort_extract_string("state", params)
@ -218,7 +218,7 @@ class AuthorizationHandler:
# Handle authorization errors as defined in RFC 6749 Section 4.1.2.1
return await error_response(error=e.error, error_description=e.error_description)
except Exception as validation_error:
except Exception as validation_error: # pragma: no cover
# Catch-all for unexpected errors
logger.exception("Unexpected error in authorization_handler", exc_info=validation_error)
return await error_response(error="server_error", error_description="An unexpected error occurred")

View file

@ -50,7 +50,7 @@ class RegistrationHandler:
client_id = str(uuid4())
client_secret = None
if client_metadata.token_endpoint_auth_method != "none":
if client_metadata.token_endpoint_auth_method != "none": # pragma: no branch
# cryptographically secure random 32-byte hex string
client_secret = secrets.token_hex(32)
@ -59,7 +59,7 @@ class RegistrationHandler:
elif client_metadata.scope is not None and self.options.valid_scopes is not None:
requested_scopes = set(client_metadata.scope.split())
valid_scopes = set(self.options.valid_scopes)
if not requested_scopes.issubset(valid_scopes):
if not requested_scopes.issubset(valid_scopes): # pragma: no branch
return PydanticJSONResponse(
content=RegistrationErrorResponse(
error="invalid_client_metadata",

View file

@ -56,7 +56,7 @@ class RevocationHandler:
client = await self.client_authenticator.authenticate(
revocation_request.client_id, revocation_request.client_secret
)
except AuthenticationError as e:
except AuthenticationError as e: # pragma: no cover
return PydanticJSONResponse(
status_code=401,
content=RevocationErrorResponse(
@ -69,7 +69,7 @@ class RevocationHandler:
self.provider.load_access_token,
partial(self.provider.load_refresh_token, client),
]
if revocation_request.token_type_hint == "refresh_token":
if revocation_request.token_type_hint == "refresh_token": # pragma: no cover
loaders = reversed(loaders)
token: None | AccessToken | RefreshToken = None

View file

@ -107,7 +107,7 @@ class TokenHandler:
client_id=token_request.client_id,
client_secret=token_request.client_secret,
)
except AuthenticationError as e:
except AuthenticationError as e: # pragma: no cover
return self.response(
TokenErrorResponse(
error="unauthorized_client",
@ -115,7 +115,7 @@ class TokenHandler:
)
)
if token_request.grant_type not in client_info.grant_types:
if token_request.grant_type not in client_info.grant_types: # pragma: no cover
return self.response(
TokenErrorResponse(
error="unsupported_grant_type",
@ -151,7 +151,7 @@ class TokenHandler:
# see https://datatracker.ietf.org/doc/html/rfc6749#section-10.6
if auth_code.redirect_uri_provided_explicitly:
authorize_request_redirect_uri = auth_code.redirect_uri
else:
else: # pragma: no cover
authorize_request_redirect_uri = None
# Convert both sides to strings for comparison to handle AnyUrl vs string issues
@ -192,7 +192,7 @@ class TokenHandler:
)
)
case RefreshTokenRequest():
case RefreshTokenRequest(): # pragma: no cover
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

View file

@ -99,7 +99,7 @@ class RequireAuthMiddleware:
"""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:
if self.resource_metadata_url: # pragma: no cover
www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"')
www_authenticate = f"Bearer {', '.join(www_auth_parts)}"

View file

@ -7,7 +7,7 @@ from mcp.shared.auth import OAuthClientInformationFull
class AuthenticationError(Exception):
def __init__(self, message: str):
self.message = message
self.message = message # pragma: no cover
class ClientAuthenticator:
@ -34,18 +34,18 @@ class ClientAuthenticator:
# Look up client information
client = await self.provider.get_client(client_id)
if not client:
raise AuthenticationError("Invalid client_id")
raise AuthenticationError("Invalid client_id") # pragma: no cover
# If client from the store expects a secret, validate that the request provides
# that secret
if client.client_secret:
if client.client_secret: # pragma: no branch
if not client_secret:
raise AuthenticationError("Client secret is required")
raise AuthenticationError("Client secret is required") # pragma: no cover
if client.client_secret != client_secret:
raise AuthenticationError("Invalid client_secret")
raise AuthenticationError("Invalid client_secret") # pragma: no cover
if client.client_secret_expires_at and client.client_secret_expires_at < int(time.time()):
raise AuthenticationError("Client secret has expired")
raise AuthenticationError("Client secret has expired") # pragma: no cover
return client

Some files were not shown because too many files have changed in this diff Show more