Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions capiscio_mcp/_core/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@
import logging
from typing import TYPE_CHECKING

from capiscio_mcp._core.version import is_core_compatible, PROTO_VERSION
from capiscio_mcp._core.version import (
is_core_compatible,
PROTO_VERSION,
CORE_MIN_VERSION,
CORE_MAX_VERSION,
)
from capiscio_mcp.errors import CoreConnectionError, CoreVersionError

if TYPE_CHECKING:
Expand Down Expand Up @@ -54,7 +59,7 @@ async def check_version_compatibility(
if not is_core_compatible(response.core_version):
raise CoreVersionError(
f"capiscio-core version {response.core_version} is not compatible. "
f"This SDK requires core version >= 2.5.0 and < 3.0.0"
f"This SDK requires core version >= {CORE_MIN_VERSION} and < {CORE_MAX_VERSION}"
)

# Check proto version
Expand Down
102 changes: 70 additions & 32 deletions capiscio_mcp/_core/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@
from pathlib import Path
from typing import Optional, Tuple

import time

import requests
from platformdirs import user_cache_dir

from capiscio_mcp._core.version import (
CORE_MIN_VERSION,
Expand Down Expand Up @@ -72,8 +73,11 @@ def get_platform_info() -> Tuple[str, str]:


def get_cache_dir() -> Path:
"""Get the directory where binaries are cached."""
cache_dir = Path(user_cache_dir("capiscio-mcp", "capiscio")) / "bin"
"""Get the directory where binaries are cached.

Uses ~/.capiscio/bin/ to share cache with capiscio-sdk-python.
"""
cache_dir = Path.home() / ".capiscio" / "bin"
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir

Expand Down Expand Up @@ -118,36 +122,70 @@ def download_binary(version: Optional[str] = None) -> Path:
os_name, arch_name = get_platform_info()
url = get_download_url(version, os_name, arch_name)

logger.info(f"Downloading capiscio-core v{version} for {os_name}/{arch_name}...")
logger.info(
f"capiscio-core v{version} not found. "
f"Downloading for {os_name}/{arch_name}..."
)

try:
response = requests.get(url, stream=True, timeout=60)
response.raise_for_status()

# Ensure directory exists
target_path.parent.mkdir(parents=True, exist_ok=True)

# Write binary
with open(target_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)

# Make executable (Unix)
if os_name != "windows":
st = os.stat(target_path)
os.chmod(target_path, st.st_mode | stat.S_IEXEC)

logger.info(f"Successfully installed capiscio-core v{version}")
return target_path

except requests.exceptions.RequestException as e:
if target_path.exists():
target_path.unlink()
raise CoreConnectionError(f"Failed to download binary from {url}: {e}") from e
except Exception as e:
if target_path.exists():
target_path.unlink()
raise CoreConnectionError(f"Failed to install binary: {e}") from e
# Ensure directory exists
target_path.parent.mkdir(parents=True, exist_ok=True)

max_attempts = 3
for attempt in range(1, max_attempts + 1):
try:
with requests.get(url, stream=True, timeout=60) as response:
response.raise_for_status()

# Write binary
with open(target_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)

# Make executable (Unix)
if os_name != "windows":
st = os.stat(target_path)
os.chmod(target_path, st.st_mode | stat.S_IEXEC)

logger.info(f"Installed capiscio-core v{version} at {target_path}")
return target_path

except requests.exceptions.HTTPError as e:
if target_path.exists():
target_path.unlink()
# Fail fast on client errors (4xx) — not transient
if e.response is not None and 400 <= e.response.status_code < 500:
raise CoreConnectionError(
f"Failed to download binary from {url}: {e}"
) from e
if attempt < max_attempts:
delay = 2 ** (attempt - 1)
logger.warning(
f"Download attempt {attempt}/{max_attempts} failed: {e}. "
f"Retrying in {delay}s..."
)
time.sleep(delay)
else:
raise CoreConnectionError(
f"Failed to download binary from {url} "
f"after {max_attempts} attempts: {e}"
) from e
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, OSError) as e:
if target_path.exists():
target_path.unlink()
if attempt < max_attempts:
delay = 2 ** (attempt - 1)
logger.warning(
f"Download attempt {attempt}/{max_attempts} failed: {e}. "
f"Retrying in {delay}s..."
)
time.sleep(delay)
else:
Comment on lines +133 to +182
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

This retry loop will also retry (and sleep) on permanent failures like HTTP 404/400 from raise_for_status(), which adds avoidable startup latency and slows the existing unit test that mocks a 404. Consider retrying only on transient errors (timeouts/connection errors/5xx), and fail fast on 4xx.

Copilot uses AI. Check for mistakes.
raise CoreConnectionError(
f"Failed to download binary from {url} "
f"after {max_attempts} attempts: {e}"
) from e
# unreachable, but keeps type checker happy
raise CoreConnectionError("Download failed")


async def ensure_binary(version: Optional[str] = None) -> Path:
Expand Down
5 changes: 3 additions & 2 deletions capiscio_mcp/_core/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@
MCP_VERSION = "0.1.0"

# Compatible capiscio-core versions (internal constraint)
# Note: MCP integration was added in 2.3.1
CORE_MIN_VERSION = "2.3.0"
# Note: MCP integration was added in 2.3.1, but 2.4.0 is required
# for full compatibility with the current SDK.
CORE_MIN_VERSION = "2.4.0"
CORE_MAX_VERSION = "3.0.0" # exclusive

# Proto schema version for wire compatibility
Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ dependencies = [
"grpcio-tools>=1.60.0",
"protobuf>=4.25.0",
"requests>=2.31.0",
"platformdirs>=4.0.0",
"cryptography>=42.0.0",
"base58>=2.1.0",
]
Expand Down
8 changes: 6 additions & 2 deletions tests/test_core_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ def test_download_success(self, mock_path, mock_get):
@patch("requests.get")
@patch("capiscio_mcp._core.lifecycle.get_binary_path")
def test_download_404_error(self, mock_path, mock_get):
"""404 response should raise error."""
"""404 response should raise error immediately (no retry)."""
import requests

with tempfile.TemporaryDirectory() as tmpdir:
Expand All @@ -190,7 +190,11 @@ def test_download_404_error(self, mock_path, mock_get):

mock_response = MagicMock()
mock_response.status_code = 404
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("404")
http_err = requests.exceptions.HTTPError("404")
http_err.response = mock_response
mock_response.raise_for_status.side_effect = http_err
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
mock_get.return_value = mock_response

from capiscio_mcp.errors import CoreConnectionError
Expand Down
Loading