Skip to content

feat: MCPServerIdentity.connect(), ServerBadgeKeeper, stdio credential-in-_meta#7

Merged
beonde merged 7 commits intomainfrom
feature/mcp-server-connect
Mar 3, 2026
Merged

feat: MCPServerIdentity.connect(), ServerBadgeKeeper, stdio credential-in-_meta#7
beonde merged 7 commits intomainfrom
feature/mcp-server-connect

Conversation

@beonde
Copy link
Member

@beonde beonde commented Mar 2, 2026

Summary

Implements the "Let's Encrypt" style MCP server identity pattern (RFC-007) plus fixes the stdio transport credential gap.

New: MCPServerIdentity.connect() — one-liner server identity

from capiscio_mcp import MCPServerIdentity
from capiscio_mcp.integrations.mcp import CapiscioMCPServer

identity = await MCPServerIdentity.connect(
    server_id=os.environ["CAPISCIO_SERVER_ID"],
    api_key=os.environ["CAPISCIO_API_KEY"],
)

server = CapiscioMCPServer(identity=identity)

@server.tool(min_trust_level=2)
async def read_file(path: str) -> str:
    with open(path) as f:
        return f.read()

server.run()

New: ServerBadgeKeeper

Background thread that monitors badge expiry (exp claim) and automatically renews via POST /v1/sdk/servers/{id}/badge. Mirrors the SDK's BadgeKeeper.

Fix: stdio credential propagation (_install_credential_extraction)

Problem: For stdio transport (server runs in a subprocess), set_credential() sets a contextvar in the client process — completely invisible to the server-side @guard. The caller badge never reached the guard.

Fix:

  • Server-side: _install_credential_extraction() wraps the FastMCP CallToolRequest handler to extract capiscio_caller_badge / capiscio_caller_api_key from req.params.meta and set _current_credential before the guarded tool runs.
  • Client-side: CapiscioMCPClient.call_tool() now passes badge/api_key in meta={"capiscio_caller_badge": ...} to session.call_tool(), which the MCP SDK serializes into the JSON-RPC _meta field — the stdio equivalent of the X-Capiscio-Badge HTTP header (RFC-002 §9.1).

Fix: CapiscioMCPClient.connect() — full identity verification

Client now:

  1. Sends PoP nonce in initialize request _meta
  2. Extracts capiscio_server_did / capiscio_server_badge from InitializeResult.meta
  3. Calls verify_server() and enforces min_trust_level / fail_on_unverified

Files

File Change
capiscio_mcp/connect.py NewMCPServerIdentity
capiscio_mcp/keeper.py NewServerBadgeKeeper
capiscio_mcp/integrations/mcp.py Extended — credential extraction, client verification
capiscio_mcp/__init__.py Export MCPServerIdentity, ServerBadgeKeeper
tests/test_connect.py New — 43 tests
tests/test_keeper.py New — 43 tests
tests/test_integrations.py +13 tests (TestInstallCredentialExtraction, TestClientCallToolMetaPropagation)

Tests

346/346 passing (333 → 346, +13 new integration tests).

…l-in-_meta

- Add capiscio_mcp/connect.py: MCPServerIdentity dataclass with connect()
  and from_env() class methods. Handles idempotent key generation,
  registration, badge issuance, and keeper start. "Let's Encrypt" pattern
  for one-liner MCP server identity setup (RFC-007).

- Add capiscio_mcp/keeper.py: ServerBadgeKeeper background thread that
  monitors badge expiry (exp claim) and calls POST /v1/sdk/servers/{id}/badge
  for renewal. Mirrors SDK's BadgeKeeper.

- Update capiscio_mcp/integrations/mcp.py:
  * CapiscioMCPServer.__init__: accept identity=MCPServerIdentity shortcut
  * _meta injection: patch ServerSession._received_request once (idempotent)
    wrapping responder.respond for InitializeRequest to inject identity meta
  * _install_credential_extraction: new function wrapping the FastMCP
    CallToolRequest handler to extract capiscio_caller_badge /
    capiscio_caller_api_key from _meta and set _current_credential contextvar
    before the guarded tool runs. Fixes stdio transport where HTTP headers
    are not available (RFC-002 ss9.1 equivalent for stdio).
  * CapiscioMCPClient.call_tool: forward badge/api_key in JSON-RPC _meta
    (meta={"capiscio_caller_badge": ...}) instead of setting a contextvar
    in the client process which had no effect on the subprocess server.
  * CapiscioMCPClient.connect: send PoP nonce in initialize request _meta,
    extract server identity from InitializeResult.meta, verify via
    verify_server(), enforce min_trust_level/fail_on_unverified.

- Update capiscio_mcp/__init__.py: export MCPServerIdentity, ServerBadgeKeeper

- Add tests/test_connect.py: 43 tests for MCPServerIdentity
- Add tests/test_keeper.py: 43 tests for ServerBadgeKeeper
- Update tests/test_integrations.py: 13 new tests for
  _install_credential_extraction and client meta propagation (346 total)

All 346 tests pass.
Copilot AI review requested due to automatic review settings March 2, 2026 18:07
@github-actions
Copy link

github-actions bot commented Mar 2, 2026

✅ Integration tests passed! capiscio-core gRPC tests working.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a higher-level “server identity bootstrap” API and expands the MCP integration to (a) propagate caller credentials over stdio via JSON-RPC _meta and (b) verify server identity from initialize _meta, aligning the library more closely with RFC-007 identity disclosure patterns.

Changes:

  • Introduces MCPServerIdentity.connect() for key generation/recovery, registry registration, badge issuance, and optional background badge auto-renewal.
  • Adds ServerBadgeKeeper to renew server badges based on JWT exp.
  • Extends capiscio_mcp.integrations.mcp to inject server identity into initialize responses and to forward/extract caller credentials via _meta for stdio transport.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
capiscio_mcp/connect.py New identity bootstrap helper (MCPServerIdentity) + badge issuance utilities.
capiscio_mcp/keeper.py New background renewal thread for server badges (ServerBadgeKeeper).
capiscio_mcp/integrations/mcp.py Adds _meta injection, stdio credential extraction, and server verification from initialize meta.
capiscio_mcp/__init__.py Exports MCPServerIdentity and ServerBadgeKeeper and updates top-level docs.
tests/test_connect.py Adds unit tests for MCPServerIdentity and badge issuance helper.
tests/test_keeper.py Adds unit tests for JWT exp decoding + badge renewal behavior.
tests/test_integrations.py Adds tests for stdio credential propagation/extraction and server init hook installation.
Comments suppressed due to low confidence (1)

capiscio_mcp/connect.py:107

  • In async code, asyncio.get_event_loop() is deprecated in newer Python versions in favor of asyncio.get_running_loop(). Using get_running_loop() here avoids deprecation warnings and future breakage for supported versions (3.10–3.13).
    """Async wrapper for badge issuance."""
    loop = asyncio.get_event_loop()
    return await loop.run_in_executor(
        None,
        _issue_badge_sync,
        server_id,
        api_key,
        ca_url,
    )

Comment on lines +558 to +563
meta = self.create_initialize_response_meta()
token = _capiscio_meta_ctx.set(meta)
try:
self._server.run(transport=transport)
finally:
_capiscio_meta_ctx.reset(token)
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

CapiscioMCPServer.run() injects a static _meta dict (built without request_meta), which means PoP signatures can’t be included because they depend on the client’s initialize-request nonce. If PoP signing is intended, the injection point needs access to the InitializeRequest’s _meta so you can call create_initialize_response_meta(request_meta=...) per request, not once at server start.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Acknowledged — this is a known limitation of the current MCP SDK. The FastMCP.run() API doesn't expose a per-request hook for the initialize handshake, so we inject static _meta at server start. PoP verification currently works for HTTP transport (where the nonce can be sent in headers) but not for the stdio initialize flow. Tracked for follow-up when the MCP SDK adds initialize-request meta support.

Comment on lines +762 to +768
if not meta or not isinstance(meta, dict):
if self.fail_on_unverified and self.min_trust_level > 0:
raise ServerVerifyError(
f"Server did not disclose identity (_meta missing) but "
f"min_trust_level={self.min_trust_level} is required"
)
logger.debug("Server did not disclose identity (_meta absent or non-dict)")
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

_verify_server_from_meta only raises on missing/invalid _meta when both fail_on_unverified is True and min_trust_level > 0. This makes fail_on_unverified ineffective when min_trust_level=0, despite the docstring saying it should raise when the server doesn’t disclose identity. Consider enforcing fail_on_unverified regardless of min_trust_level (or adjust the parameter semantics/documentation).

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed — fail_on_unverified now enforces regardless of min_trust_level. Also fixed the ServerVerifyError constructor calls to use proper error_code/detail parameters.

logger.info("Stopping ServerBadgeKeeper for server %s...", self.server_id)
self._stop_event.set()
if self._thread:
self._thread.join(timeout=5)
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

ServerBadgeKeeper.stop() joins the background thread with a 5s timeout but then unconditionally sets _running=False and drops the thread reference. If the thread is blocked in requests.post(timeout=30) during renewal, stop() can return while the thread is still alive, potentially allowing multiple renewal threads and making is_running() inaccurate. Consider joining without a timeout, or only clearing _running / _thread if the thread has actually exited.

Suggested change
self._thread.join(timeout=5)
self._thread.join()

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed — stop() now checks is_alive() after the 5s join timeout and logs a warning if the thread hasn't exited. See commit b0b457d.

)
logger.debug("Re-registered server identity (idempotent)")
except RegistrationError as exc:
logger.debug("Re-registration returned: %s — continuing", exc)
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

When recovering existing keys, connect() catches all RegistrationError from register_server_identity() and continues. This can silently ignore real failures like invalid API key, server not found, or network errors, returning an identity that may not actually be registered. Consider only swallowing clearly-idempotent cases (e.g., specific status codes) and re-raising or warning for other failures.

Suggested change
logger.debug("Re-registration returned: %s — continuing", exc)
status_code = getattr(exc, "status_code", None)
if status_code == 409:
# Identity already exists in registry; safe to continue.
logger.info(
"Server identity %s already registered (status %s); continuing",
did,
status_code,
)
else:
logger.warning(
"Failed to re-register server identity %s: %s (status: %s)",
did,
exc,
status_code,
)
raise

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed — now only swallows 409 (already-registered) and None status codes. All other failures (401 invalid key, 404 not found, etc.) are re-raised. See commit b0b457d.

Comment on lines +849 to +853
# Initialize the session and capture the result
result = await self._session.initialize()
# Extract _meta — InitializeResult.meta is the _meta dict
response_meta: Optional[Dict[str, Any]] = getattr(result, "meta", None)
await self._verify_server_from_meta(response_meta)
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

CapiscioMCPClient.connect() calls session.initialize() without passing the PoP nonce in _meta, so the PoP handshake described in the PR (and supported by create_initialize_request_meta()/verify_initialize_response()) never actually runs. Pass the nonce via the MCP SDK’s initialize meta/_meta parameter and then verify the PoP response using the returned InitializeResult.meta.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Same limitation as the server-side run() thread above — the MCP SDK's session.initialize() doesn't expose an _meta parameter for the initialize request. PoP works for HTTP transport but not the stdio initialize flow. Tracked for follow-up when the MCP SDK adds initialize-request meta support.

beonde added 2 commits March 2, 2026 13:37
The badge endpoint returns the JWS under data.data.token (not data.data.badge).
Update _issue_badge_sync to check both .token and .badge in the nested payload,
and add .domain auto-derivation from the CA URL so the badge request includes
the required domain field.

Also reads CAPISCIO_SERVER_DOMAIN env var in from_env() for user override.
mcp.client.stdio.get_default_environment() only passes a small whitelist of
vars (HOME, PATH, USER, etc.) to the subprocess. CAPISCIO_* credentials were
being stripped, causing the server subprocess to fail with a missing env var
error.

CapiscioMCPClient now auto-forwards all CAPISCIO_* vars (and MCP_SERVER_COMMAND)
from the parent process via StdioServerParameters.env. An explicit env dict can
also be passed to __init__() for callers that need per-connection overrides.
@github-actions
Copy link

github-actions bot commented Mar 2, 2026

✅ Integration tests passed! capiscio-core gRPC tests working.

…ER_PRIVATE_KEY_PEM)

Add support for injecting the server private key via environment variable
for containerised/serverless deployments where ~/.capiscio is ephemeral.

Key priority: env var > local file > generate new keypair.

On first-run keygen, a capture hint is logged to stderr with the
PEM-encoded key for the operator to persist in their secrets manager.

- Add _load_private_key_pem() and _did_from_ed25519_pub_raw() helpers
- Add _log_key_capture_hint() with box-formatted capture hint
- Move cryptography and base58 to base dependencies
- Add 4 unit tests for env var injection and capture hint
Copilot AI review requested due to automatic review settings March 2, 2026 21:46
@github-actions
Copy link

github-actions bot commented Mar 2, 2026

✅ Integration tests passed! capiscio-core gRPC tests working.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.

Comments suppressed due to low confidence (1)

pyproject.toml:36

  • Adding cryptography as a base dependency makes the crypto extra (which also pins cryptography) redundant for consumers. Consider either removing/repurposing the crypto extra, or keeping cryptography optional and gating crypto-only imports behind that extra.
dependencies = [
    "grpcio>=1.60.0",
    "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",

Comment on lines +3 to +9
import asyncio
import base64
import json
import os
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch, mock_open
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

Several imports at the top of this file appear unused (e.g., asyncio, base64, json, tempfile, mock_open). With Ruff enabled, this will raise F401 unused-import errors; please remove unused imports to keep the test suite lint-clean.

Suggested change
import asyncio
import base64
import json
import os
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch, mock_open
import os
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed — removed all unused imports. See commit b0b457d.

_install_credential_extraction,
)
from capiscio_mcp.types import ServerState
from capiscio_mcp.errors import GuardError, ServerVerifyError
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

GuardError and ServerVerifyError are imported but not referenced anywhere in this test file. This will trigger Ruff F401 unused-import errors; remove the imports or add tests that exercise these error types.

Suggested change
from capiscio_mcp.errors import GuardError, ServerVerifyError

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed — removed unused GuardError and ServerVerifyError imports. See commit b0b457d.

Comment on lines +29 to +33
import base64
import logging
import os
import sys
from dataclasses import dataclass, field
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

There are unused imports here (e.g., base64 and sys). With the repo's Ruff config, this will raise F401 unused-import errors; please remove any imports that aren't referenced.

Suggested change
import base64
import logging
import os
import sys
from dataclasses import dataclass, field
import logging
import os
from dataclasses import dataclass

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed — removed unused base64 and sys imports. See commit b0b457d.

Comment on lines +97 to +100
# Strip PEM headers to get the single-line base64 DER payload
lines = [l for l in private_key_pem.strip().splitlines() if not l.startswith("-----")]
der_b64 = "".join(lines)

Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

der_b64 is computed but never used. This is dead code and will be flagged by linters; either remove it or incorporate it into the hint message (e.g., as a compact key fingerprint/string).

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed — removed dead der_b64 computation. See commit b0b457d.

Comment on lines +3 to +16
import base64
import json
import time
import threading
from unittest.mock import MagicMock, patch

import pytest

from capiscio_mcp.keeper import (
ServerBadgeKeeper,
_decode_jwt_exp,
DEFAULT_CHECK_INTERVAL,
DEFAULT_RENEWAL_THRESHOLD,
)
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

Unused imports: threading, DEFAULT_CHECK_INTERVAL, and DEFAULT_RENEWAL_THRESHOLD aren't referenced in this test module. With Ruff enabled, this will raise F401 unused-import errors; please remove them or add assertions that use the constants.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed — removed unused threading, DEFAULT_CHECK_INTERVAL, and DEFAULT_RENEWAL_THRESHOLD imports. See commit b0b457d.

Comment on lines 862 to 873
read_stream, write_stream = await self._context_manager.__aenter__()
self._session = McpClientSession(read_stream, write_stream)
try:
await self._session.__aenter__()
# Initialize the session
await self._session.initialize()
# Initialize the session and capture the result
result = await self._session.initialize()
# Extract _meta — InitializeResult.meta is the _meta dict
response_meta: Optional[Dict[str, Any]] = getattr(result, "meta", None)
await self._verify_server_from_meta(response_meta)
except Exception:
# Clean up session on failure
self._session = None
raise
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

In connect(), if an exception happens after await self._session.__aenter__() succeeds (e.g., initialize/verification fails), the code sets self._session = None without calling __aexit__() on the session. This can leak session resources/tasks. Consider await self._session.__aexit__(...) in the inner except before clearing the reference (and then let the outer handler close the stdio context).

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed — connect() now calls await self._session.__aexit__(None, None, None) before clearing the reference on failure. See commit b0b457d.

Comment on lines +95 to +118
def _log_key_capture_hint(server_id: str, private_key_pem: str) -> None:
"""Log a one-time hint telling the user how to persist key material."""
# Strip PEM headers to get the single-line base64 DER payload
lines = [l for l in private_key_pem.strip().splitlines() if not l.startswith("-----")]
der_b64 = "".join(lines)

logger.warning(
"\n"
" ╔══════════════════════════════════════════════════════════════╗\n"
" ║ New server identity generated — save key for persistence ║\n"
" ╚══════════════════════════════════════════════════════════════╝\n"
"\n"
" If this server runs in an ephemeral environment (containers,\n"
" serverless, CI) the identity will be lost on restart unless\n"
" you persist the private key.\n"
"\n"
" Add to your secrets manager / .env:\n"
"\n"
" CAPISCIO_SERVER_PRIVATE_KEY_PEM=\"" # noqa: E501
+ private_key_pem.replace("\n", "\\n")
+ "\"\n"
"\n"
" The DID will be re-derived automatically on startup.\n"
)
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

_log_key_capture_hint logs the full PEM-encoded private key at WARNING level. This risks leaking the server's long-term signing key into application logs, log aggregation, or crash reports. Recommend not logging private key material by default (e.g., log only the key path/fingerprint, or gate printing the PEM behind an explicit opt-in env flag).

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed — _log_key_capture_hint() now uses print(..., file=sys.stderr, flush=True) instead of logger.warning(). This keeps the capture hint as a direct stderr message for the operator but prevents the PEM from entering log aggregation pipelines. See commit b0b457d.

Comment on lines +49 to +55
from capiscio_mcp.keeper import ServerBadgeKeeper
from capiscio_mcp.registration import (
KeyGenerationError,
RegistrationError,
generate_server_keypair,
register_server_identity,
)
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

KeyGenerationError is imported but never used in this module. If key generation failures should be handled specially, add the handling; otherwise remove the unused import to avoid Ruff F401 failures.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed — removed unused KeyGenerationError import. See commit b0b457d.

- Add new docs/guides/deployment.md covering Docker, Lambda, Cloud Run, K8s
- Update README with MCPServerIdentity.connect() section and env var table
- Update server-registration.md with new env vars and deployment link
- Add Deployment to mkdocs nav
@github-actions
Copy link

github-actions bot commented Mar 3, 2026

✅ Integration tests passed! capiscio-core gRPC tests working.

The registry assigns did:web when an API key is used. did:key is only
for local dev mode without a registry.
Copilot AI review requested due to automatic review settings March 3, 2026 17:29
@github-actions
Copy link

github-actions bot commented Mar 3, 2026

✅ Integration tests passed! capiscio-core gRPC tests working.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 7 comments.

Comments suppressed due to low confidence (3)

tests/test_keeper.py:16

  • threading and the DEFAULT_* constants are imported but unused in this test module, which will trigger ruff F401. Remove unused imports to keep tests lint-clean.
import base64
import json
import time
import threading
from unittest.mock import MagicMock, patch

import pytest

from capiscio_mcp.keeper import (
    ServerBadgeKeeper,
    _decode_jwt_exp,
    DEFAULT_CHECK_INTERVAL,
    DEFAULT_RENEWAL_THRESHOLD,
)

tests/test_connect.py:10

  • Several imports in this new test module appear unused (e.g., asyncio, base64, json, tempfile, mock_open), which will trigger ruff F401. Remove unused imports to keep the test suite lint-clean.
import asyncio
import base64
import json
import os
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch, mock_open

pyproject.toml:45

  • cryptography is now a core dependency and also listed in the optional 'crypto' extra, making the extra redundant/misleading. Consider either removing cryptography from the extra or keeping it optional and guarding any always-on imports accordingly.
    "cryptography>=42.0.0",
    "base58>=2.1.0",
]

[project.optional-dependencies]
mcp = [
    "mcp>=1.0",
]
crypto = [
    "cryptography>=42.0.0",
]

Comment on lines +144 to +153
"""Stop the background renewal thread (blocks until it exits)."""
if not self._running:
return
logger.info("Stopping ServerBadgeKeeper for server %s...", self.server_id)
self._stop_event.set()
if self._thread:
self._thread.join(timeout=5)
self._thread = None
self._running = False
logger.info("ServerBadgeKeeper stopped")
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

stop() claims to block until the background thread exits, but join(timeout=5) can return while the thread is still alive (e.g., blocked in requests.post(timeout=30)). Either join without a timeout, or detect a still-alive thread after the timeout and reflect that in state/logging to avoid leaving renewal running after stop().

Suggested change
"""Stop the background renewal thread (blocks until it exits)."""
if not self._running:
return
logger.info("Stopping ServerBadgeKeeper for server %s...", self.server_id)
self._stop_event.set()
if self._thread:
self._thread.join(timeout=5)
self._thread = None
self._running = False
logger.info("ServerBadgeKeeper stopped")
"""Request the background renewal thread to stop and wait up to 5s for it."""
if not self._running:
return
logger.info("Stopping ServerBadgeKeeper for server %s...", self.server_id)
self._stop_event.set()
if self._thread:
self._thread.join(timeout=5)
if self._thread.is_alive():
logger.warning(
"ServerBadgeKeeper thread for server %s did not exit within "
"5s; background renewal may still be running",
self.server_id,
)
else:
logger.info("ServerBadgeKeeper stopped")
self._thread = None

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed — same approach as above: is_alive() check after timeout with warning log. See commit b0b457d.

Comment on lines +218 to +226
resp = requests.post(url, headers=headers, json={}, timeout=30)
if resp.status_code in (200, 201):
data = resp.json()
# Try multiple common response shapes
new_badge = (
(data.get("data") or {}).get("badge")
or data.get("badge")
or data.get("token")
)
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

_renew() calls resp.json() without handling JSON decode errors; a non-JSON 200/201 response will raise and can bubble up when _renew() is called directly (and will at least spam warnings in the background loop). Catch JSON parsing errors and log a concise warning instead.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed — added try/except ValueError around resp.json() with warning log. See commit b0b457d.

Comment on lines +29 to +32
import base64
import logging
import os
import sys
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

Unused imports (base64, sys) will trigger ruff F401 in CI. Remove them or use them.

Suggested change
import base64
import logging
import os
import sys
import logging
import os

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed — removed unused base64 and sys imports. See commit b0b457d.

try:
resp = requests.post(url, headers=headers, json={"domain": effective_domain}, timeout=30)
if resp.status_code in (200, 201):
data = resp.json()
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

_issue_badge_sync() only catches requests.RequestException, but resp.json() can raise ValueError/JSONDecodeError (e.g., proxy returns HTML with 200/201). Catch JSON parsing errors too and treat them as badge issuance failure, otherwise MCPServerIdentity.connect() can crash unexpectedly.

Suggested change
data = resp.json()
try:
data = resp.json()
except ValueError as exc:
logger.warning("Badge issuance response was not valid JSON: %s", exc)
return None

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed — added try/except ValueError around resp.json() in _issue_badge_sync(), returns None on invalid JSON. See commit b0b457d.

Comment on lines +866 to +870
# Initialize the session and capture the result
result = await self._session.initialize()
# Extract _meta — InitializeResult.meta is the _meta dict
response_meta: Optional[Dict[str, Any]] = getattr(result, "meta", None)
await self._verify_server_from_meta(response_meta)
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

CapiscioMCPClient.connect() initializes the session without sending the PoP nonce in the initialize request _meta, and it never calls verify_initialize_response() to verify the PoP signature. This makes require_pop ineffective and contradicts the documented PoP handshake; pass the nonce via initialize request meta and verify the PoP fields in the initialize response meta before accepting the connection.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Same limitation as thread above — the MCP SDK's session.initialize() doesn't expose an _meta parameter for the initialize request. PoP works for HTTP transport but not the stdio initialize flow. Tracked for follow-up when the MCP SDK adds initialize-request meta support.

Comment on lines +26 to +42
### First-Run Capture

On the very first run (when no key exists anywhere), the SDK generates a keypair and logs a capture hint to stderr:

```
╔══════════════════════════════════════════════════════════════════╗
║ New server identity generated — save key for persistence ║
╚══════════════════════════════════════════════════════════════════╝

Add to your secrets manager / .env:

CAPISCIO_SERVER_PRIVATE_KEY_PEM='-----BEGIN PRIVATE KEY-----\nMC4CAQ...xYz\n-----END PRIVATE KEY-----\n'

The DID will be recovered automatically from the key on startup.
```

Copy the entire PEM string (including `\n` line breaks) and store it in your secrets manager.
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

The "First-Run Capture" flow documents that the SDK logs the full PEM-encoded server private key to stderr so it can be copied into CAPISCIO_SERVER_PRIVATE_KEY_PEM. Logging long‑term private keys in cleartext is highly risky because logs are often aggregated and widely accessible, and anyone with log access could recover the key and fully impersonate the MCP server. This flow should avoid emitting the private key to logs at all (e.g., use a dedicated, access‑controlled export mechanism or only show a non-sensitive fingerprint) and the documentation should be updated to match the safer behaviour.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

The code-level concern was addressed in commit b0b457d: _log_key_capture_hint() now uses print(..., file=sys.stderr, flush=True) instead of logger.warning(), so the PEM does not enter log aggregation pipelines. The capture hint is intentionally printed to stderr as an operator-facing first-run UX — it only fires once (on first key generation) and only when no key exists anywhere. The documentation accurately describes this stderr-based flow. The operator's responsibility is to capture the key from the local terminal session and store it in a secrets manager.

Comment on lines +232 to +245
In ephemeral environments (Docker, Lambda, Cloud Run) the local `~/.capiscio/` directory
doesn't survive restarts. On first run the SDK generates a keypair and logs a capture hint:

```
╔══════════════════════════════════════════════════════════╗
║ New server identity generated — save key for persistence ║
╚══════════════════════════════════════════════════════════╝

Add to your secrets manager / .env:

CAPISCIO_SERVER_PRIVATE_KEY_PEM='-----BEGIN PRIVATE KEY-----\nMC4C...\n-----END PRIVATE KEY-----\n'
```

Copy that value into your secrets manager and set it as an environment variable.
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

This "Deploying to Containers / Serverless" section describes a flow where the SDK logs the full PEM-encoded server private key so it can be copied into CAPISCIO_SERVER_PRIVATE_KEY_PEM. Emitting a long‑term private key in cleartext logs is dangerous, because log streams are often aggregated and broadly accessible, allowing anyone with log access to recover the key and impersonate the MCP server. The recommended flow should avoid logging private keys entirely and instead rely on a more controlled key export mechanism or non-sensitive fingerprints, and the documentation should be updated to stop encouraging copying keys from logs.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Same as the deployment.md thread — the code now uses print(file=sys.stderr) instead of logger.warning(), preventing the PEM from entering log aggregation. The capture hint is a deliberate first-run-only operator UX for local terminal sessions. See commit b0b457d.

- Remove unused imports (base64, sys, KeyGenerationError, threading, etc.)
- Remove dead code (der_b64 variable)
- Security: write capture hint to stderr instead of logger.warning to
  prevent private key leaking into log aggregation pipelines
- Add JSON decode error handling in _issue_badge_sync and keeper._renew
- Fix keeper.stop() to detect still-alive threads after 5s timeout
- Fix session resource leak: call __aexit__ on session before clearing
- Fix fail_on_unverified to enforce regardless of min_trust_level
- Fix ServerVerifyError constructor calls with proper error_code/detail
- Fix registration error handling to only swallow 409/None status codes

All 350 tests passing.
@github-actions
Copy link

github-actions bot commented Mar 3, 2026

✅ Integration tests passed! capiscio-core gRPC tests working.

@beonde beonde merged commit 5f62eae into main Mar 3, 2026
10 checks passed
@beonde beonde deleted the feature/mcp-server-connect branch March 3, 2026 18:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants