Get baseline 100% clean coverage (#1553)
This commit is contained in:
parent
da4fce2195
commit
89e9c43acf
111 changed files with 1032 additions and 1084 deletions
7
.github/workflows/shared.yml
vendored
7
.github/workflows/shared.yml
vendored
|
|
@ -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' }}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
7
scripts/test
Executable 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
|
||||
|
|
@ -2,5 +2,5 @@
|
|||
|
||||
from .cli import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app()
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue