feat: CapiscioMCPServer.connect() one-liner, decision cache, keeper domain#32
Conversation
…er domain support - Add CapiscioMCPServer.connect() class method — one-liner server setup from env - Add MCPServerIdentity.connect_sync() / from_env_sync() for sync callers - Add 5s decision cache in guard (eliminates repeat gRPC calls within bursts) - Pass domain to ServerBadgeKeeper for badge renewal - Look for 'token' key in badge renewal response (alongside 'badge') - Suppress gRPC C-core stderr noise at package import time - Downgrade noisy guard/PDP log levels (warning → debug/info) - Update all docs and examples to use new connect() API
There was a problem hiding this comment.
Pull request overview
This PR streamlines MCP server setup by introducing a one-liner CapiscioMCPServer.connect() factory and updates docs/examples accordingly, while also adding a short-lived guard decision cache and improving badge renewal compatibility with domain-scoped issuance.
Changes:
- Add
CapiscioMCPServer.connect()factory plus related credential/telemetry conveniences. - Add a 5-second decision cache in
@guardto reduce repeated gRPC calls. - Extend badge renewal to support a
domainparameter and broaden renewal response parsing (badge/token).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Updates examples to use CapiscioMCPServer.connect() and documents the new entrypoint. |
| docs/guides/deployment.md | Updates deployment snippets and env var docs to reference CapiscioMCPServer.connect(). |
| capiscio_mcp/keeper.py | Adds optional domain to renewal requests and accepts token in renewal responses. |
| capiscio_mcp/integrations/mcp.py | Adds CapiscioMCPServer.connect() factory and flushes pending telemetry on shutdown. |
| capiscio_mcp/guard.py | Introduces a global decision cache and adjusts some log levels. |
| capiscio_mcp/connect.py | Passes a derived domain into ServerBadgeKeeper and adds sync convenience methods. |
| capiscio_mcp/init.py | Suppresses gRPC C-core verbosity at import time; updates top-level package docstring examples. |
Comments suppressed due to low confidence (1)
docs/guides/deployment.md:131
- The Lambda snippet uses
async def handler(...)but callsCapiscioMCPServer.connect()(sync). With the current implementation relying onasyncio.run(), this is likely to raise when the runtime already has an event loop. Consider updating the snippet to use an async identity setup path (or provide a truly asyncCapiscioMCPServer.connect).
```python
import json
from capiscio_mcp.integrations.mcp import CapiscioMCPServer
async def handler(event, context):
# Key injected via Lambda environment variables or Secrets Manager
server = CapiscioMCPServer.connect()
- Fix guard test failures: clear decision cache between tests (autouse fixture) - Fix api_key bug: use effective_api_key for BadgeKeeper construction - Improve decision cache: add max size bound (256), thread safety via lock - Fix deployment.md: remove async from sync connect() examples
- Test cache put/get, miss, expiry eviction, key independence - Covers _cache_get, _cache_put, and _decision_cache internals
|
✅ Integration tests passed! capiscio-core gRPC tests working. |
- Test eviction of oldest entries when cache hits max size - Test expired entry sweep before oldest-entry eviction - Covers guard.py lines 150-158 (eviction logic)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (3)
capiscio_mcp/guard.py:360
- The decision cache ignores inputs that can change the result for the same
(badge, tool)such aseffective_config(min_trust_level, trusted_issuers, allowed_tools, policy_version, require_badge),capability_class, anddeny_on_unknown_class. Because the cache is module-global, running multiple servers/tools in the same process (or callingevaluate_tool_access()with different configs) can incorrectly reuse a prior decision and weaken enforcement. Consider including the relevant config/capability fields (or a stable hash of them) in the cache key, or scoping the cache per-server instance instead of globally.
# Check decision cache — same badge + same tool = same decision
cache_key_jws = effective_credential.badge_jws or ""
cached = _cache_get(cache_key_jws, tool_name)
if cached is not None:
logger.debug("Decision cache hit: tool=%s decision=%s", tool_name, cached.decision.value)
return cached
capiscio_mcp/guard.py:462
- Caching and returning a full
GuardResultreusesevidence_id/evidence_jsonacross multiple tool invocations, and also skips the core RPC that appears to generate per-call evidence. This breaks the stated “every guarded tool call produces a cryptographic evidence record” behavior and can lead to duplicate evidence IDs being emitted/logged. If caching is required, avoid caching evidence-bearing results (cache only the policy decision inputs/outputs and still generate fresh evidence per call), or move caching into capiscio-core where evidence can still be minted per invocation.
result = GuardResult(
decision=decision,
deny_reason=deny_reason,
deny_detail=response.deny_detail or None,
agent_did=response.agent_did or None,
badge_jti=response.badge_jti or None,
auth_level=auth_level,
trust_level=response.trust_level,
evidence_id=response.evidence_id,
evidence_json=response.evidence_json,
error_code=response.error_code or None,
requested_capability=response.requested_capability or None,
presented_capability=response.presented_capability or None,
)
# Cache for subsequent calls with the same badge + tool
_cache_put(cache_key_jws, tool_name, result)
return result
docs/guides/deployment.md:118
- The Docker “Server Code” example defines
main()but never calls it (nomain()invocation /if __name__ == "__main__": ...). As written,python server.pywould do nothing, which makes the deployment snippet misleading. Either remove themain()wrapper and run at module scope, or add an explicit call tomain()/server.run()in the example.
```python
from capiscio_mcp.integrations.mcp import CapiscioMCPServer
def main():
# Reads CAPISCIO_SERVER_ID, CAPISCIO_API_KEY, and
# CAPISCIO_SERVER_PRIVATE_KEY_PEM from environment
server = CapiscioMCPServer.connect()
@server.tool(min_trust_level=2)
async def my_tool(param: str) -> str:
return f"Result: {param}"
server.run()
</details>
|
✅ Integration tests passed! capiscio-core gRPC tests working. |
The Python guard was making an HTTP POST to the cloud PDP endpoint on every ALLOW decision that missed the 5s cache, adding 50-60ms latency. This is unnecessary because the Go sidecar already evaluates OPA policy locally via BundleManager when CAPISCIO_BUNDLE_URL is set (which connect() already configures). Removed: - _evaluate_org_policy() and the second-phase PDP block in guard.py - _pip_config singleton, set_pip_config(), get_pip_config() - pdp_endpoint parameter from connect() - Step 7 (auto-configure PDP) from connect() - Corresponding exports from __init__.py PIPConfig and PolicyClient remain as public API for advanced direct use. Resolves: enforcement-demo scenarios 2/3 latency (50-60ms → sub-10ms)
|
Reopening to retrigger CI |
The PDP auto-configuration block from main referenced pdp_endpoint but it was missing from the function signature after the merge, causing NameError at runtime.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 11 comments.
Comments suppressed due to low confidence (2)
capiscio_mcp/guard.py:329
- The cache key collapses every non-badge caller to the empty string, so an API-key decision and an anonymous decision for the same tool can be reused interchangeably. For example, an API-key ALLOW can let a later anonymous caller bypass the core check, or an anonymous DENY can incorrectly block an API-key caller; include the credential type/API key (or avoid caching non-badge credentials).
# Check decision cache — same badge + same tool = same decision
cache_key_jws = effective_credential.badge_jws or ""
cached = _cache_get(cache_key_jws, tool_name)
capiscio_mcp/integrations/mcp.py:418
- This new public factory has no test coverage. A small test that patches MCPServerIdentity.from_env_sync and asserts the resulting server is constructed with that identity would catch regressions in the one-liner API without hitting the registry.
@classmethod
def connect(cls, **kwargs: Any) -> "CapiscioMCPServer":
"""Connect to the CapiscIO registry and return a ready-to-use server.
Combines ``MCPServerIdentity.from_env_sync()`` and server construction
into a single call — the MCP server equivalent of ``CapiscIO.connect()``
from the agent SDK.
Any extra keyword arguments are forwarded to the constructor
(e.g. ``default_min_trust_level``, ``name``).
Example::
server = CapiscioMCPServer.connect()
@server.tool(min_trust_level=1)
async def place_order(sku: str, quantity: int) -> str:
...
server.run()
"""
from capiscio_mcp.connect import MCPServerIdentity
identity = MCPServerIdentity.from_env_sync()
return cls(identity=identity, **kwargs)
The branch's guard.py delegates policy evaluation to Go core via gRPC (EvaluateToolAccess), not via the removed set_pip_config/PIPConfig module-level singleton. The PDP block was brought in during merge resolution from main but is incompatible with the branch's architecture. Go core reads CAPISCIO_PDP_ENDPOINT directly from the environment.
|
✅ Integration tests passed! capiscio-core gRPC tests working. |
| # Check decision cache — same badge + same tool = same decision | ||
| cache_key_jws = effective_credential.badge_jws or "" |
| # Check decision cache — same badge + same tool = same decision | ||
| cache_key_jws = effective_credential.badge_jws or "" | ||
| cached = _cache_get(cache_key_jws, tool_name) | ||
| if cached is not None: | ||
| logger.debug("Decision cache hit: tool=%s decision=%s", tool_name, cached.decision.value) | ||
| return cached |
| from capiscio_mcp.connect import MCPServerIdentity | ||
|
|
||
| identity = MCPServerIdentity.from_env_sync() | ||
| return cls(identity=identity, **kwargs) |
| "X-Capiscio-Registry-Key": self.api_key, | ||
| "Content-Type": "application/json", | ||
| } | ||
| body: dict = {} |
| body: dict = {} | ||
| if self.domain: | ||
| body["domain"] = self.domain |
| guard_sync, | ||
| GuardConfig, | ||
| GuardResult, | ||
| compute_params_hash, | ||
| evaluate_tool_access, |
| @classmethod | ||
| def connect_sync( | ||
| cls, | ||
| server_id: str, | ||
| api_key: Optional[str] = None, |
| # Check decision cache — same badge + same tool = same decision | ||
| cache_key_jws = effective_credential.badge_jws or "" |
| import os as _os | ||
|
|
||
| # Suppress gRPC C-core stderr noise (ev_poll_posix.cc, fork_posix.cc, etc.) | ||
| # before any gRPC import. Library users should not see low-level C-core logs. | ||
| _os.environ.setdefault("GRPC_VERBOSITY", "NONE") |
| @classmethod | ||
| def connect(cls, **kwargs: Any) -> "CapiscioMCPServer": | ||
| """Connect to the CapiscIO registry and return a ready-to-use server. | ||
|
|
||
| Combines ``MCPServerIdentity.from_env_sync()`` and server construction |
Summary
Adds
CapiscioMCPServer.connect()— a one-liner factory that replaces the multi-stepMCPServerIdentity+CapiscioMCPServerpattern. Also includes performance and DX improvements.Changes
New API surface:
CapiscioMCPServer.connect(**kwargs)— reads env, creates identity, returns ready-to-use serverMCPServerIdentity.connect_sync()/from_env_sync()— sync wrappers for non-async callersPerformance:
(badge_jws, tool_name). Same badge string = same decision. First call ~3ms, subsequent ~0.01ms within a burst.Bug fixes:
ServerBadgeKeepernow acceptsdomainparameter (required by registry for domain-scoped badges)tokenkey (alongsidebadge)DX:
GRPC_VERBOSITY=NONE) at package import timewarning→debug/info)connect()APIRelated PRs
refactor/connect-api-renamerefactor/connect-api-rename"