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
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

Comment thread
beonde marked this conversation as resolved.
Comment thread
beonde marked this conversation as resolved.
## [2.7.1] - 2026-05-15

### Fixed
- Use `did:web` identity in CA-connected mode instead of `did:key` — fixes `DID_MISMATCH` during server verification (#38)
- Auto-skip origin binding for stdio (subprocess) transports — fixes `ORIGIN_MISMATCH` for `CapiscioMCPClient` with `command=` (#38)
- Graceful gRPC error handling in `verify_server()` — returns `UNVERIFIED_ORIGIN` instead of crashing when capiscio-core is unavailable (#38)

## [2.7.0] - 2026-05-13

### Added
Expand Down Expand Up @@ -88,5 +95,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Python 3.10+
- Optional: mcp >= 1.0 for MCP SDK integration

[Unreleased]: https://github.com/capiscio/capiscio-mcp-python/compare/v0.1.0...HEAD
[Unreleased]: https://github.com/capiscio/capiscio-mcp-python/compare/v2.7.1...HEAD
[2.7.1]: https://github.com/capiscio/capiscio-mcp-python/compare/v2.7.0...v2.7.1
[2.7.0]: https://github.com/capiscio/capiscio-mcp-python/compare/v0.1.0...v2.7.0
[0.1.0]: https://github.com/capiscio/capiscio-mcp-python/releases/tag/v0.1.0
15 changes: 14 additions & 1 deletion capiscio_mcp/_core/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,20 @@ async def _supervise(self) -> None:
if not self._running:
break # Intentional shutdown

logger.warning(f"capiscio-core exited with code {return_code}")
# Capture stderr for diagnostics
stderr_text = ""
if self._process.stderr:
try:
stderr_bytes = await self._process.stderr.read()
stderr_text = stderr_bytes.decode(errors="replace").strip()
except Exception:
pass

logger.warning(
"capiscio-core exited with code %d%s",
return_code,
f": {stderr_text}" if stderr_text else "",
)
Comment thread
beonde marked this conversation as resolved.

if self._restart_count >= self.max_restarts:
logger.error(f"Max restarts ({self.max_restarts}) exceeded")
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)
# 2.7.0 is required for local OPA policy evaluation (PDP wiring).
CORE_MIN_VERSION = "2.7.0"
# 2.7.1 is required: fixes nil-pointer panic in VerifyServerIdentity
# when badgeVerifier is not initialized (verifier.go:162).
CORE_MIN_VERSION = "2.7.1"
CORE_MAX_VERSION = "3.0.0" # exclusive

# Proto schema version for wire compatibility
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "capiscio-mcp"
version = "2.7.0"
version = "2.7.1"
Comment thread
beonde marked this conversation as resolved.
description = "Trust badges for MCP tool calls - RFC-006 & RFC-007 implementation"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
27 changes: 19 additions & 8 deletions tests/test_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from capiscio_mcp.connect import (
MCPServerIdentity,
_derive_did_web,
_issue_badge_sync,
_load_private_key_pem,
_log_key_capture_hint,
Expand All @@ -20,7 +21,9 @@

SERVER_ID = "550e8400-e29b-41d4-a716-446655440000"
API_KEY = "sk_test_abc123"
TEST_SERVER_URL = "http://localhost:8080"
FAKE_DID = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
FAKE_DID_WEB = _derive_did_web(TEST_SERVER_URL, SERVER_ID)
Comment thread
beonde marked this conversation as resolved.
FAKE_BADGE = "eyJhbGciOiJFZERTQSJ9.eyJleHAiOjk5OTk5OTk5OTl9.fakesig"
FAKE_PRIV_KEY_PEM = "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n"
FAKE_PUB_KEY_PEM = "-----BEGIN PUBLIC KEY-----\nfake\n-----END PUBLIC KEY-----\n"
Expand Down Expand Up @@ -188,11 +191,12 @@ async def test_connect_generates_keys_when_none_exist(self, tmp_keys_dir):
identity = await MCPServerIdentity.connect(
server_id=SERVER_ID,
api_key=API_KEY,
server_url="http://localhost:8080",
server_url=TEST_SERVER_URL,
keys_dir=tmp_keys_dir,
)

assert identity.did == FAKE_DID
# connect() derives did:web from server_url + server_id (Step 4)
assert identity.did == FAKE_DID_WEB
assert identity.server_id == SERVER_ID
assert identity.api_key == API_KEY
assert identity.badge == FAKE_BADGE
Expand Down Expand Up @@ -222,13 +226,14 @@ async def test_connect_recovers_existing_keys(self, tmp_keys_dir):
identity = await MCPServerIdentity.connect(
server_id=SERVER_ID,
api_key=API_KEY,
server_url="http://localhost:8080",
server_url=TEST_SERVER_URL,
keys_dir=tmp_keys_dir,
)

# Should NOT have regenerated keys
mock_gen.assert_not_called()
assert identity.did == FAKE_DID
# connect() derives did:web from server_url + server_id (Step 4)
assert identity.did == FAKE_DID_WEB

async def test_connect_no_badge_when_auto_badge_false(self, tmp_keys_dir):
"""connect(auto_badge=False) should skip badge issuance."""
Expand Down Expand Up @@ -272,12 +277,14 @@ async def test_connect_handles_badge_failure_gracefully(self, tmp_keys_dir):
identity = await MCPServerIdentity.connect(
server_id=SERVER_ID,
api_key=API_KEY,
server_url=TEST_SERVER_URL,
keys_dir=tmp_keys_dir,
)

assert identity.badge is None
assert identity._keeper is None
assert identity.did == FAKE_DID
# connect() derives did:web from server_url + server_id (Step 4)
assert identity.did == FAKE_DID_WEB

async def test_connect_uses_env_var_private_key(self, tmp_keys_dir):
"""connect() should load identity from CAPISCIO_SERVER_PRIVATE_KEY_PEM."""
Expand All @@ -292,16 +299,18 @@ async def test_connect_uses_env_var_private_key(self, tmp_keys_dir):
identity = await MCPServerIdentity.connect(
server_id=SERVER_ID,
api_key=API_KEY,
server_url=TEST_SERVER_URL,
keys_dir=tmp_keys_dir,
)

# Should NOT have generated a new keypair
mock_gen.assert_not_called()
assert identity.did == real_did
# connect() derives did:web from server_url + server_id (Step 4)
assert identity.did == FAKE_DID_WEB
Comment thread
beonde marked this conversation as resolved.
# Should have persisted key to disk
assert (tmp_keys_dir / "private_key.pem").exists()
assert (tmp_keys_dir / "public_key.pem").exists()
assert (tmp_keys_dir / "did.txt").read_text() == real_did
assert (tmp_keys_dir / "did.txt").read_text() == FAKE_DID_WEB

async def test_connect_env_var_takes_precedence_over_local_file(self, tmp_keys_dir):
"""Env var key should override a different key on disk."""
Expand All @@ -321,11 +330,13 @@ async def test_connect_env_var_takes_precedence_over_local_file(self, tmp_keys_d
identity = await MCPServerIdentity.connect(
server_id=SERVER_ID,
api_key=API_KEY,
server_url=TEST_SERVER_URL,
keys_dir=tmp_keys_dir,
)

mock_gen.assert_not_called()
assert identity.did == real_did # env var DID, not FAKE_DID
# connect() derives did:web from server_url + server_id (Step 4)
assert identity.did == FAKE_DID_WEB

async def test_connect_logs_capture_hint_on_new_generation(self, tmp_keys_dir):
"""connect() should log a capture hint when generating a new identity."""
Expand Down
8 changes: 4 additions & 4 deletions tests/test_core_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ async def test_compatible_version(self):
mock_stub = MagicMock()
mock_response = MagicMock()
mock_response.healthy = True
mock_response.core_version = "2.7.0"
mock_response.core_version = "2.7.1"
mock_response.proto_version = "1.0"
mock_response.version_compatible = True

Expand Down Expand Up @@ -53,7 +53,7 @@ async def test_sends_client_version(self):
mock_stub = MagicMock()
mock_response = MagicMock()
mock_response.healthy = True
mock_response.core_version = "2.7.0"
mock_response.core_version = "2.7.1"
mock_response.version_compatible = True

mock_stub.Health = AsyncMock(return_value=mock_response)
Expand Down Expand Up @@ -219,7 +219,7 @@ async def test_full_health_check_flow(self):
# Health response with version info
mock_response = MagicMock()
mock_response.healthy = True
mock_response.core_version = "2.7.0"
mock_response.core_version = "2.7.1"
mock_response.proto_version = "1.0"
mock_response.version_compatible = True

Expand All @@ -246,7 +246,7 @@ async def slow_startup(*args, **kwargs):
response = MagicMock()
# Simulate slow startup - healthy after 5 calls
response.healthy = call_count >= 5
response.core_version = "2.7.0"
response.core_version = "2.7.1"
response.version_compatible = True
return response

Expand Down
Loading