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 - name: Install the project
run: uv sync ${{ matrix.dep-resolution.install-flags }} --all-extras --python ${{ matrix.python-version }} run: uv sync ${{ matrix.dep-resolution.install-flags }} --all-extras --python ${{ matrix.python-version }}
- name: Run pytest - name: Run pytest with coverage
run: uv run ${{ matrix.dep-resolution.install-flags }} --no-sync pytest 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: env:
UV_RESOLUTION: ${{ matrix.dep-resolution.name == 'lowest-direct' && 'lowest-direct' || 'highest' }} UV_RESOLUTION: ${{ matrix.dep-resolution.name == 'lowest-direct' && 'lowest-direct' || 'highest' }}

View file

@ -60,6 +60,7 @@ dev = [
"pytest-pretty>=1.2.0", "pytest-pretty>=1.2.0",
"inline-snapshot>=0.23.0", "inline-snapshot>=0.23.0",
"dirty-equals>=0.9.0", "dirty-equals>=0.9.0",
"coverage[toml]==7.10.7",
] ]
docs = [ docs = [
"mkdocs>=1.6.1", "mkdocs>=1.6.1",
@ -168,3 +169,40 @@ MD033 = false # no-inline-html Inline HTML
MD041 = false # first-line-heading/first-line-h1 MD041 = false # first-line-heading/first-line-h1
MD046 = false # indented-code-blocks MD046 = false # indented-code-blocks
MD059 = false # descriptive-link-text 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 from .cli import app
if __name__ == "__main__": if __name__ == "__main__": # pragma: no cover
app() app()

View file

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

View file

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

View file

@ -34,15 +34,15 @@ class JWTParameters(BaseModel):
assertion = self.assertion assertion = self.assertion
else: else:
if not self.jwt_signing_key: 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: 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: 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 audience = self.audience if self.audience else with_audience_fallback
if not audience: 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()) now = int(time.time())
claims: dict[str, Any] = { claims: dict[str, Any] = {
@ -83,14 +83,14 @@ class RFC7523OAuthClientProvider(OAuthClientProvider):
async def _exchange_token_authorization_code( async def _exchange_token_authorization_code(
self, auth_code: str, code_verifier: str, *, token_data: dict[str, Any] | None = None 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.""" """Build token exchange request for authorization_code flow."""
token_data = token_data or {} token_data = token_data or {}
if self.context.client_metadata.token_endpoint_auth_method == "private_key_jwt": if self.context.client_metadata.token_endpoint_auth_method == "private_key_jwt":
self._add_client_authentication_jwt(token_data=token_data) self._add_client_authentication_jwt(token_data=token_data)
return await super()._exchange_token_authorization_code(auth_code, code_verifier, 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.""" """Perform the authorization flow."""
if "urn:ietf:params:oauth:grant-type:jwt-bearer" in self.context.client_metadata.grant_types: if "urn:ietf:params:oauth:grant-type:jwt-bearer" in self.context.client_metadata.grant_types:
token_request = await self._exchange_token_jwt_bearer() token_request = await self._exchange_token_jwt_bearer()
@ -98,7 +98,7 @@ class RFC7523OAuthClientProvider(OAuthClientProvider):
else: else:
return await super()._perform_authorization() 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.""" """Add JWT assertion for client authentication to token endpoint parameters."""
if not self.jwt_parameters: if not self.jwt_parameters:
raise OAuthTokenError("Missing JWT parameters for private_key_jwt flow") 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: async def _exchange_token_jwt_bearer(self) -> httpx.Request:
"""Build token exchange request for JWT bearer grant.""" """Build token exchange request for JWT bearer grant."""
if not self.context.client_info: if not self.context.client_info:
raise OAuthFlowError("Missing client info") raise OAuthFlowError("Missing client info") # pragma: no cover
if not self.jwt_parameters: if not self.jwt_parameters:
raise OAuthFlowError("Missing JWT parameters") raise OAuthFlowError("Missing JWT parameters") # pragma: no cover
if not self.context.oauth_metadata: 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 # 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 # 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, "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() 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_data["scope"] = self.context.client_metadata.scope
token_url = self._get_token_endpoint() token_url = self._get_token_endpoint()

View file

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

View file

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

View file

@ -129,7 +129,7 @@ class ClientSessionGroup:
self._session_exit_stacks = {} self._session_exit_stacks = {}
self._component_name_hook = component_name_hook 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 # Enter the exit stack only if we created it ourselves
if self._owns_exit_stack: if self._owns_exit_stack:
await self._exit_stack.__aenter__() await self._exit_stack.__aenter__()
@ -140,7 +140,7 @@ class ClientSessionGroup:
_exc_type: type[BaseException] | None, _exc_type: type[BaseException] | None,
_exc_val: BaseException | None, _exc_val: BaseException | None,
_exc_tb: TracebackType | None, _exc_tb: TracebackType | None,
) -> bool | None: ) -> bool | None: # pragma: no cover
"""Closes session exit stacks and main exit stack upon completion.""" """Closes session exit stacks and main exit stack upon completion."""
# Only close the main exit stack if we created it # Only close the main exit stack if we created it
@ -155,7 +155,7 @@ class ClientSessionGroup:
@property @property
def sessions(self) -> list[mcp.ClientSession]: def sessions(self) -> list[mcp.ClientSession]:
"""Returns the list of sessions being managed.""" """Returns the list of sessions being managed."""
return list(self._sessions.keys()) return list(self._sessions.keys()) # pragma: no cover
@property @property
def prompts(self) -> dict[str, types.Prompt]: 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 component_names = self._sessions.pop(session) # Pop from _sessions tracking
# Remove prompts associated with the session. # Remove prompts associated with the session.
@ -212,8 +212,8 @@ class ClientSessionGroup:
# Clean up the session's resources via its dedicated exit stack # Clean up the session's resources via its dedicated exit stack
if session_known_for_stack: if session_known_for_stack:
session_stack_to_close = self._session_exit_stacks.pop(session) session_stack_to_close = self._session_exit_stacks.pop(session) # pragma: no cover
await session_stack_to_close.aclose() await session_stack_to_close.aclose() # pragma: no cover
async def connect_with_session( async def connect_with_session(
self, server_info: types.Implementation, session: mcp.ClientSession self, server_info: types.Implementation, session: mcp.ClientSession
@ -270,7 +270,7 @@ class ClientSessionGroup:
await self._exit_stack.enter_async_context(session_stack) await self._exit_stack.enter_async_context(session_stack)
return result.serverInfo, session return result.serverInfo, session
except Exception: except Exception: # pragma: no cover
# If anything during this setup fails, ensure the session-specific # If anything during this setup fails, ensure the session-specific
# stack is closed. # stack is closed.
await session_stack.aclose() await session_stack.aclose()
@ -298,7 +298,7 @@ class ClientSessionGroup:
name = self._component_name(prompt.name, server_info) name = self._component_name(prompt.name, server_info)
prompts_temp[name] = prompt prompts_temp[name] = prompt
component_names.prompts.add(name) component_names.prompts.add(name)
except McpError as err: except McpError as err: # pragma: no cover
logging.warning(f"Could not fetch prompts: {err}") logging.warning(f"Could not fetch prompts: {err}")
# Query the server for its resources and aggregate to list. # Query the server for its resources and aggregate to list.
@ -308,7 +308,7 @@ class ClientSessionGroup:
name = self._component_name(resource.name, server_info) name = self._component_name(resource.name, server_info)
resources_temp[name] = resource resources_temp[name] = resource
component_names.resources.add(name) component_names.resources.add(name)
except McpError as err: except McpError as err: # pragma: no cover
logging.warning(f"Could not fetch resources: {err}") logging.warning(f"Could not fetch resources: {err}")
# Query the server for its tools and aggregate to list. # Query the server for its tools and aggregate to list.
@ -319,18 +319,18 @@ class ClientSessionGroup:
tools_temp[name] = tool tools_temp[name] = tool
tool_to_session_temp[name] = session tool_to_session_temp[name] = session
component_names.tools.add(name) component_names.tools.add(name)
except McpError as err: except McpError as err: # pragma: no cover
logging.warning(f"Could not fetch tools: {err}") logging.warning(f"Could not fetch tools: {err}")
# Clean up exit stack for session if we couldn't retrieve anything # Clean up exit stack for session if we couldn't retrieve anything
# from the server. # from the server.
if not any((prompts_temp, resources_temp, tools_temp)): 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. # Check for duplicates.
matching_prompts = prompts_temp.keys() & self._prompts.keys() matching_prompts = prompts_temp.keys() & self._prompts.keys()
if matching_prompts: if matching_prompts:
raise McpError( raise McpError( # pragma: no cover
types.ErrorData( types.ErrorData(
code=types.INVALID_PARAMS, code=types.INVALID_PARAMS,
message=f"{matching_prompts} already exist in group prompts.", message=f"{matching_prompts} already exist in group prompts.",
@ -338,7 +338,7 @@ class ClientSessionGroup:
) )
matching_resources = resources_temp.keys() & self._resources.keys() matching_resources = resources_temp.keys() & self._resources.keys()
if matching_resources: if matching_resources:
raise McpError( raise McpError( # pragma: no cover
types.ErrorData( types.ErrorData(
code=types.INVALID_PARAMS, code=types.INVALID_PARAMS,
message=f"{matching_resources} already exist in group resources.", 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, task_status: TaskStatus[str] = anyio.TASK_STATUS_IGNORED,
): ):
try: 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}") logger.debug(f"Received SSE event: {sse.event}")
match sse.event: match sse.event:
case "endpoint": case "endpoint":
@ -79,15 +79,15 @@ async def sse_client(
url_parsed = urlparse(url) url_parsed = urlparse(url)
endpoint_parsed = urlparse(endpoint_url) endpoint_parsed = urlparse(endpoint_url)
if ( if ( # pragma: no cover
url_parsed.netloc != endpoint_parsed.netloc url_parsed.netloc != endpoint_parsed.netloc
or url_parsed.scheme != endpoint_parsed.scheme or url_parsed.scheme != endpoint_parsed.scheme
): ):
error_msg = ( error_msg = ( # pragma: no cover
f"Endpoint origin does not match connection origin: {endpoint_url}" f"Endpoint origin does not match connection origin: {endpoint_url}"
) )
logger.error(error_msg) logger.error(error_msg) # pragma: no cover
raise ValueError(error_msg) raise ValueError(error_msg) # pragma: no cover
task_status.started(endpoint_url) task_status.started(endpoint_url)
@ -97,21 +97,21 @@ async def sse_client(
sse.data sse.data
) )
logger.debug(f"Received server message: {message}") logger.debug(f"Received server message: {message}")
except Exception as exc: except Exception as exc: # pragma: no cover
logger.exception("Error parsing server message") logger.exception("Error parsing server message") # pragma: no cover
await read_stream_writer.send(exc) await read_stream_writer.send(exc) # pragma: no cover
continue continue # pragma: no cover
session_message = SessionMessage(message) session_message = SessionMessage(message)
await read_stream_writer.send(session_message) await read_stream_writer.send(session_message)
case _: case _: # pragma: no cover
logger.warning(f"Unknown SSE event: {sse.event}") logger.warning(f"Unknown SSE event: {sse.event}") # pragma: no cover
except SSEError as sse_exc: except SSEError as sse_exc: # pragma: no cover
logger.exception("Encountered SSE exception") logger.exception("Encountered SSE exception") # pragma: no cover
raise sse_exc raise sse_exc # pragma: no cover
except Exception as exc: except Exception as exc: # pragma: no cover
logger.exception("Error in sse_reader") logger.exception("Error in sse_reader") # pragma: no cover
await read_stream_writer.send(exc) await read_stream_writer.send(exc) # pragma: no cover
finally: finally:
await read_stream_writer.aclose() await read_stream_writer.aclose()
@ -130,8 +130,8 @@ async def sse_client(
) )
response.raise_for_status() response.raise_for_status()
logger.debug(f"Client message sent successfully: {response.status_code}") logger.debug(f"Client message sent successfully: {response.status_code}")
except Exception: except Exception: # pragma: no cover
logger.exception("Error in post_writer") logger.exception("Error in post_writer") # pragma: no cover
finally: finally:
await write_stream.aclose() await write_stream.aclose()

View file

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

View file

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

View file

@ -59,7 +59,7 @@ async def websocket_client(
message = types.JSONRPCMessage.model_validate_json(raw_text) message = types.JSONRPCMessage.model_validate_json(raw_text)
session_message = SessionMessage(message) session_message = SessionMessage(message)
await read_stream_writer.send(session_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 # If JSON parse or model validation fails, send the exception
await read_stream_writer.send(exc) 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: 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 return None
value = params.get(key) value = params.get(key)
if isinstance(value, str): if isinstance(value, str):
@ -116,7 +116,7 @@ class AuthorizationHandler:
pass pass
# the error response MUST contain the state specified by the client, if any # 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 # make last-ditch effort to load state
state = best_effort_extract_string("state", params) 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 # 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) 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 # Catch-all for unexpected errors
logger.exception("Unexpected error in authorization_handler", exc_info=validation_error) logger.exception("Unexpected error in authorization_handler", exc_info=validation_error)
return await error_response(error="server_error", error_description="An unexpected error occurred") 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_id = str(uuid4())
client_secret = None 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 # cryptographically secure random 32-byte hex string
client_secret = secrets.token_hex(32) 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: elif client_metadata.scope is not None and self.options.valid_scopes is not None:
requested_scopes = set(client_metadata.scope.split()) requested_scopes = set(client_metadata.scope.split())
valid_scopes = set(self.options.valid_scopes) 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( return PydanticJSONResponse(
content=RegistrationErrorResponse( content=RegistrationErrorResponse(
error="invalid_client_metadata", error="invalid_client_metadata",

View file

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

View file

@ -107,7 +107,7 @@ class TokenHandler:
client_id=token_request.client_id, client_id=token_request.client_id,
client_secret=token_request.client_secret, client_secret=token_request.client_secret,
) )
except AuthenticationError as e: except AuthenticationError as e: # pragma: no cover
return self.response( return self.response(
TokenErrorResponse( TokenErrorResponse(
error="unauthorized_client", 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( return self.response(
TokenErrorResponse( TokenErrorResponse(
error="unsupported_grant_type", error="unsupported_grant_type",
@ -151,7 +151,7 @@ class TokenHandler:
# see https://datatracker.ietf.org/doc/html/rfc6749#section-10.6 # see https://datatracker.ietf.org/doc/html/rfc6749#section-10.6
if auth_code.redirect_uri_provided_explicitly: if auth_code.redirect_uri_provided_explicitly:
authorize_request_redirect_uri = auth_code.redirect_uri authorize_request_redirect_uri = auth_code.redirect_uri
else: else: # pragma: no cover
authorize_request_redirect_uri = None authorize_request_redirect_uri = None
# Convert both sides to strings for comparison to handle AnyUrl vs string issues # 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) 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 refresh_token is None or refresh_token.client_id != token_request.client_id:
# if token belongs to different client, pretend it doesn't exist # 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.""" """Send an authentication error response with WWW-Authenticate header."""
# Build WWW-Authenticate header value # Build WWW-Authenticate header value
www_auth_parts = [f'error="{error}"', f'error_description="{description}"'] 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_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"')
www_authenticate = f"Bearer {', '.join(www_auth_parts)}" www_authenticate = f"Bearer {', '.join(www_auth_parts)}"

View file

@ -7,7 +7,7 @@ from mcp.shared.auth import OAuthClientInformationFull
class AuthenticationError(Exception): class AuthenticationError(Exception):
def __init__(self, message: str): def __init__(self, message: str):
self.message = message self.message = message # pragma: no cover
class ClientAuthenticator: class ClientAuthenticator:
@ -34,18 +34,18 @@ class ClientAuthenticator:
# Look up client information # Look up client information
client = await self.provider.get_client(client_id) client = await self.provider.get_client(client_id)
if not client: 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 # If client from the store expects a secret, validate that the request provides
# that secret # that secret
if client.client_secret: if client.client_secret: # pragma: no branch
if not client_secret: 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: 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()): 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 return client

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