feat: MCPServerIdentity.connect(), ServerBadgeKeeper, stdio credential-in-_meta#7
feat: MCPServerIdentity.connect(), ServerBadgeKeeper, stdio credential-in-_meta#7
Conversation
…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.
|
✅ Integration tests passed! capiscio-core gRPC tests working. |
There was a problem hiding this comment.
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
ServerBadgeKeeperto renew server badges based on JWTexp. - Extends
capiscio_mcp.integrations.mcpto inject server identity into initialize responses and to forward/extract caller credentials via_metafor 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,
)
| meta = self.create_initialize_response_meta() | ||
| token = _capiscio_meta_ctx.set(meta) | ||
| try: | ||
| self._server.run(transport=transport) | ||
| finally: | ||
| _capiscio_meta_ctx.reset(token) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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)") |
There was a problem hiding this comment.
_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).
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
| self._thread.join(timeout=5) | |
| self._thread.join() |
There was a problem hiding this comment.
Fixed — stop() now checks is_alive() after the 5s join timeout and logs a warning if the thread hasn't exited. See commit b0b457d.
capiscio_mcp/connect.py
Outdated
| ) | ||
| logger.debug("Re-registered server identity (idempotent)") | ||
| except RegistrationError as exc: | ||
| logger.debug("Re-registration returned: %s — continuing", exc) |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| # 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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.
|
✅ 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
|
✅ Integration tests passed! capiscio-core gRPC tests working. |
There was a problem hiding this comment.
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
cryptographyas a base dependency makes thecryptoextra (which also pinscryptography) redundant for consumers. Consider either removing/repurposing thecryptoextra, or keepingcryptographyoptional 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",
tests/test_connect.py
Outdated
| import asyncio | ||
| import base64 | ||
| import json | ||
| import os | ||
| import tempfile | ||
| from pathlib import Path | ||
| from unittest.mock import AsyncMock, MagicMock, patch, mock_open |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
Fixed — removed all unused imports. See commit b0b457d.
tests/test_integrations.py
Outdated
| _install_credential_extraction, | ||
| ) | ||
| from capiscio_mcp.types import ServerState | ||
| from capiscio_mcp.errors import GuardError, ServerVerifyError |
There was a problem hiding this comment.
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.
| from capiscio_mcp.errors import GuardError, ServerVerifyError |
There was a problem hiding this comment.
Fixed — removed unused GuardError and ServerVerifyError imports. See commit b0b457d.
capiscio_mcp/connect.py
Outdated
| import base64 | ||
| import logging | ||
| import os | ||
| import sys | ||
| from dataclasses import dataclass, field |
There was a problem hiding this comment.
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.
| import base64 | |
| import logging | |
| import os | |
| import sys | |
| from dataclasses import dataclass, field | |
| import logging | |
| import os | |
| from dataclasses import dataclass |
There was a problem hiding this comment.
Fixed — removed unused base64 and sys imports. See commit b0b457d.
capiscio_mcp/connect.py
Outdated
| # 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) | ||
|
|
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
Fixed — removed dead der_b64 computation. See commit b0b457d.
| 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, | ||
| ) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed — removed unused threading, DEFAULT_CHECK_INTERVAL, and DEFAULT_RENEWAL_THRESHOLD imports. See commit b0b457d.
| 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 |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
Fixed — connect() now calls await self._session.__aexit__(None, None, None) before clearing the reference on failure. See commit b0b457d.
| 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" | ||
| ) |
There was a problem hiding this comment.
_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).
There was a problem hiding this comment.
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.
| from capiscio_mcp.keeper import ServerBadgeKeeper | ||
| from capiscio_mcp.registration import ( | ||
| KeyGenerationError, | ||
| RegistrationError, | ||
| generate_server_keypair, | ||
| register_server_identity, | ||
| ) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
|
✅ 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.
|
✅ Integration tests passed! capiscio-core gRPC tests working. |
There was a problem hiding this comment.
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",
]
capiscio_mcp/keeper.py
Outdated
| """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") |
There was a problem hiding this comment.
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().
| """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 |
There was a problem hiding this comment.
Fixed — same approach as above: is_alive() check after timeout with warning log. See commit b0b457d.
| 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") | ||
| ) |
There was a problem hiding this comment.
_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.
There was a problem hiding this comment.
Fixed — added try/except ValueError around resp.json() with warning log. See commit b0b457d.
capiscio_mcp/connect.py
Outdated
| import base64 | ||
| import logging | ||
| import os | ||
| import sys |
There was a problem hiding this comment.
Unused imports (base64, sys) will trigger ruff F401 in CI. Remove them or use them.
| import base64 | |
| import logging | |
| import os | |
| import sys | |
| import logging | |
| import os |
There was a problem hiding this comment.
Fixed — removed unused base64 and sys imports. See commit b0b457d.
capiscio_mcp/connect.py
Outdated
| try: | ||
| resp = requests.post(url, headers=headers, json={"domain": effective_domain}, timeout=30) | ||
| if resp.status_code in (200, 201): | ||
| data = resp.json() |
There was a problem hiding this comment.
_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.
| data = resp.json() | |
| try: | |
| data = resp.json() | |
| except ValueError as exc: | |
| logger.warning("Badge issuance response was not valid JSON: %s", exc) | |
| return None |
There was a problem hiding this comment.
Fixed — added try/except ValueError around resp.json() in _issue_badge_sync(), returns None on invalid JSON. See commit b0b457d.
| # 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| ### 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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
✅ Integration tests passed! capiscio-core gRPC tests working. |
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 identityNew:
ServerBadgeKeeperBackground thread that monitors badge expiry (
expclaim) and automatically renews viaPOST /v1/sdk/servers/{id}/badge. Mirrors the SDK'sBadgeKeeper.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:
_install_credential_extraction()wraps the FastMCPCallToolRequesthandler to extractcapiscio_caller_badge/capiscio_caller_api_keyfromreq.params.metaand set_current_credentialbefore the guarded tool runs.CapiscioMCPClient.call_tool()now passes badge/api_key inmeta={"capiscio_caller_badge": ...}tosession.call_tool(), which the MCP SDK serializes into the JSON-RPC_metafield — the stdio equivalent of theX-Capiscio-BadgeHTTP header (RFC-002 §9.1).Fix:
CapiscioMCPClient.connect()— full identity verificationClient now:
_metacapiscio_server_did/capiscio_server_badgefromInitializeResult.metaverify_server()and enforcesmin_trust_level/fail_on_unverifiedFiles
capiscio_mcp/connect.pyMCPServerIdentitycapiscio_mcp/keeper.pyServerBadgeKeepercapiscio_mcp/integrations/mcp.pycapiscio_mcp/__init__.pyMCPServerIdentity,ServerBadgeKeepertests/test_connect.pytests/test_keeper.pytests/test_integrations.pyTestInstallCredentialExtraction,TestClientCallToolMetaPropagation)Tests
346/346 passing (333 → 346, +13 new integration tests).