From 4c29a3efcb0f17996b825f04ac36d6d269f21f1e Mon Sep 17 00:00:00 2001 From: Beon de Nood Date: Sat, 17 Jan 2026 18:49:12 -0500 Subject: [PATCH 1/5] feat: add MCP service gRPC client - Add MCP service client methods to _rpc/client.py - Add mcp_pb2 imports to gen/__init__.py - Update protobuf generated files for MCP service - Add docs/guides/mcp.md guide - Add integration tests for MCP service - Update API reference and mkdocs nav --- capiscio_sdk/_rpc/client.py | 402 ++++++++++++++ capiscio_sdk/_rpc/gen/capiscio/v1/__init__.py | 2 + .../_rpc/gen/capiscio/v1/badge_pb2.py | 4 +- .../_rpc/gen/capiscio/v1/common_pb2.py | 4 +- capiscio_sdk/_rpc/gen/capiscio/v1/did_pb2.py | 4 +- .../_rpc/gen/capiscio/v1/registry_pb2.py | 4 +- .../_rpc/gen/capiscio/v1/revocation_pb2.py | 4 +- .../_rpc/gen/capiscio/v1/scoring_pb2.py | 4 +- .../_rpc/gen/capiscio/v1/simpleguard_pb2.py | 4 +- .../_rpc/gen/capiscio/v1/trust_pb2.py | 4 +- docs/api-reference.md | 129 +++++ docs/guides/mcp.md | 521 ++++++++++++++++++ mkdocs.yml | 1 + tests/integration/test_mcp_service.py | 377 +++++++++++++ 14 files changed, 1448 insertions(+), 16 deletions(-) create mode 100644 docs/guides/mcp.md create mode 100644 tests/integration/test_mcp_service.py diff --git a/capiscio_sdk/_rpc/client.py b/capiscio_sdk/_rpc/client.py index ea770d3..038b49b 100644 --- a/capiscio_sdk/_rpc/client.py +++ b/capiscio_sdk/_rpc/client.py @@ -9,6 +9,7 @@ # Import generated stubs from capiscio_sdk._rpc.gen.capiscio.v1 import badge_pb2, badge_pb2_grpc from capiscio_sdk._rpc.gen.capiscio.v1 import did_pb2, did_pb2_grpc +from capiscio_sdk._rpc.gen.capiscio.v1 import mcp_pb2, mcp_pb2_grpc from capiscio_sdk._rpc.gen.capiscio.v1 import trust_pb2, trust_pb2_grpc from capiscio_sdk._rpc.gen.capiscio.v1 import revocation_pb2, revocation_pb2_grpc from capiscio_sdk._rpc.gen.capiscio.v1 import scoring_pb2, scoring_pb2_grpc @@ -59,6 +60,7 @@ def __init__( # Service stubs (initialized on connect) self._badge_stub: Optional[badge_pb2_grpc.BadgeServiceStub] = None self._did_stub: Optional[did_pb2_grpc.DIDServiceStub] = None + self._mcp_stub: Optional[mcp_pb2_grpc.MCPServiceStub] = None self._trust_stub: Optional[trust_pb2_grpc.TrustStoreServiceStub] = None self._revocation_stub: Optional[revocation_pb2_grpc.RevocationServiceStub] = None self._scoring_stub: Optional[scoring_pb2_grpc.ScoringServiceStub] = None @@ -68,6 +70,7 @@ def __init__( # Service wrappers self._badge: Optional["BadgeClient"] = None self._did: Optional["DIDClient"] = None + self._mcp: Optional["MCPClient"] = None self._trust: Optional["TrustStoreClient"] = None self._revocation: Optional["RevocationClient"] = None self._scoring: Optional["ScoringClient"] = None @@ -103,6 +106,7 @@ def connect(self, timeout: float = 10.0) -> "CapiscioRPCClient": # Initialize stubs self._badge_stub = badge_pb2_grpc.BadgeServiceStub(self._channel) self._did_stub = did_pb2_grpc.DIDServiceStub(self._channel) + self._mcp_stub = mcp_pb2_grpc.MCPServiceStub(self._channel) self._trust_stub = trust_pb2_grpc.TrustStoreServiceStub(self._channel) self._revocation_stub = revocation_pb2_grpc.RevocationServiceStub(self._channel) self._scoring_stub = scoring_pb2_grpc.ScoringServiceStub(self._channel) @@ -112,6 +116,7 @@ def connect(self, timeout: float = 10.0) -> "CapiscioRPCClient": # Initialize service wrappers self._badge = BadgeClient(self._badge_stub) self._did = DIDClient(self._did_stub) + self._mcp = MCPClient(self._mcp_stub) self._trust = TrustStoreClient(self._trust_stub) self._revocation = RevocationClient(self._revocation_stub) self._scoring = ScoringClient(self._scoring_stub) @@ -129,6 +134,7 @@ def close(self) -> None: # Clear stubs self._badge_stub = None self._did_stub = None + self._mcp_stub = None self._trust_stub = None self._revocation_stub = None self._scoring_stub = None @@ -159,6 +165,13 @@ def did(self) -> "DIDClient": assert self._did is not None return self._did + @property + def mcp(self) -> "MCPClient": + """Access the MCPService (RFC-006 / RFC-007).""" + self._ensure_connected() + assert self._mcp is not None + return self._mcp + @property def trust(self) -> "TrustStoreClient": """Access the TrustStoreService.""" @@ -1264,6 +1277,395 @@ def get_key_info(self, key_id: str) -> tuple[Optional[dict], Optional[str]]: }, None +class MCPClient: + """Client wrapper for MCPService (RFC-006 Tool Authority + RFC-007 Server Identity). + + This client provides access to MCP security operations including: + - Tool access evaluation (RFC-006 §6.2-6.4) + - Server identity verification (RFC-007 §7.2) + - Server identity parsing from HTTP/JSON-RPC transports + + Example: + from capiscio_sdk._rpc.client import CapiscioRPCClient + + client = CapiscioRPCClient() + client.connect() + + # Evaluate tool access with a badge + result = client.mcp.evaluate_tool_access( + tool_name="write_file", + params_hash="abc123", + server_origin="https://files.example.com", + badge_jws=badge_token, + ) + + if result["decision"] == "allow": + print(f"Tool access granted for {result['agent_did']}") + else: + print(f"Access denied: {result['deny_reason']}") + + # Verify server identity + server_result = client.mcp.verify_server_identity( + server_did="did:web:example.com:mcp:files", + server_badge=server_badge, + transport_origin="https://files.example.com", + ) + """ + + def __init__(self, stub: mcp_pb2_grpc.MCPServiceStub) -> None: + self._stub = stub + + def evaluate_tool_access( + self, + tool_name: str, + params_hash: str = "", + server_origin: str = "", + *, + badge_jws: Optional[str] = None, + api_key: Optional[str] = None, + policy_version: str = "", + trusted_issuers: Optional[list[str]] = None, + min_trust_level: int = 0, + accept_level_zero: bool = False, + allowed_tools: Optional[list[str]] = None, + ) -> dict: + """Evaluate tool access request (RFC-006 §6.2-6.4). + + Evaluates whether a caller (identified by badge or API key) is + authorized to invoke a specific tool. Returns both a decision + and evidence record atomically. + + Args: + tool_name: Name of the tool being invoked + params_hash: Hash of the tool parameters (for audit) + server_origin: Origin of the MCP server handling the request + badge_jws: Caller's badge JWT (for badged access) + api_key: Caller's API key (for API key access) + policy_version: Optional policy version to use + trusted_issuers: List of trusted badge issuers + min_trust_level: Minimum required trust level (0-4) + accept_level_zero: Accept self-signed (level 0) badges + allowed_tools: Explicit list of allowed tools (if set, tool_name must match) + + Returns: + Dict with: + decision: "allow" or "deny" + deny_reason: Reason if denied (e.g., "badge_missing", "trust_insufficient") + deny_detail: Detailed error message if denied + agent_did: DID of the authenticated agent (if authenticated) + badge_jti: Badge JTI (if badge was used) + auth_level: Authentication level ("anonymous", "api_key", "badge") + trust_level: Agent's trust level (0-4) + evidence_json: RFC-006 §7 evidence record as JSON + evidence_id: Unique evidence record ID + timestamp: Evaluation timestamp (ISO format) + + Example: + # Evaluate with badge + result = client.mcp.evaluate_tool_access( + tool_name="write_file", + params_hash=hashlib.sha256(json.dumps(params).encode()).hexdigest(), + server_origin="https://files.example.com", + badge_jws=badge_token, + min_trust_level=2, # Require OV or higher + ) + + if result["decision"] == "allow": + # Proceed with tool execution + pass + else: + raise PermissionError(result["deny_detail"]) + """ + # Build config + config = mcp_pb2.EvaluateConfig( + trusted_issuers=trusted_issuers or [], + min_trust_level=min_trust_level, + accept_level_zero=accept_level_zero, + allowed_tools=allowed_tools or [], + ) + + # Build request with caller credential + request = mcp_pb2.EvaluateToolAccessRequest( + tool_name=tool_name, + params_hash=params_hash, + server_origin=server_origin, + policy_version=policy_version, + config=config, + ) + + # Set credential (badge or api_key, mutually exclusive) + if badge_jws: + request.badge_jws = badge_jws + elif api_key: + request.api_key = api_key + + response = self._stub.EvaluateToolAccess(request) + + # Map enums to strings + decision_map = { + mcp_pb2.MCP_DECISION_UNSPECIFIED: "unspecified", + mcp_pb2.MCP_DECISION_ALLOW: "allow", + mcp_pb2.MCP_DECISION_DENY: "deny", + } + + deny_reason_map = { + mcp_pb2.MCP_DENY_REASON_UNSPECIFIED: "", + mcp_pb2.MCP_DENY_REASON_BADGE_MISSING: "badge_missing", + mcp_pb2.MCP_DENY_REASON_BADGE_INVALID: "badge_invalid", + mcp_pb2.MCP_DENY_REASON_BADGE_EXPIRED: "badge_expired", + mcp_pb2.MCP_DENY_REASON_BADGE_REVOKED: "badge_revoked", + mcp_pb2.MCP_DENY_REASON_TRUST_INSUFFICIENT: "trust_insufficient", + mcp_pb2.MCP_DENY_REASON_TOOL_NOT_ALLOWED: "tool_not_allowed", + mcp_pb2.MCP_DENY_REASON_ISSUER_UNTRUSTED: "issuer_untrusted", + mcp_pb2.MCP_DENY_REASON_POLICY_DENIED: "policy_denied", + } + + auth_level_map = { + mcp_pb2.MCP_AUTH_LEVEL_UNSPECIFIED: "unspecified", + mcp_pb2.MCP_AUTH_LEVEL_ANONYMOUS: "anonymous", + mcp_pb2.MCP_AUTH_LEVEL_API_KEY: "api_key", + mcp_pb2.MCP_AUTH_LEVEL_BADGE: "badge", + } + + # Format timestamp + timestamp_str = "" + if response.timestamp: + from datetime import datetime, timezone + timestamp_str = datetime.fromtimestamp( + response.timestamp.seconds + response.timestamp.nanos / 1e9, + timezone.utc + ).isoformat() + + return { + "decision": decision_map.get(response.decision, "unspecified"), + "deny_reason": deny_reason_map.get(response.deny_reason, ""), + "deny_detail": response.deny_detail, + "agent_did": response.agent_did, + "badge_jti": response.badge_jti, + "auth_level": auth_level_map.get(response.auth_level, "unspecified"), + "trust_level": response.trust_level, + "evidence_json": response.evidence_json, + "evidence_id": response.evidence_id, + "timestamp": timestamp_str, + } + + def verify_server_identity( + self, + server_did: str, + server_badge: str = "", + transport_origin: str = "", + endpoint_path: str = "", + *, + trusted_issuers: Optional[list[str]] = None, + min_trust_level: int = 0, + accept_level_zero: bool = False, + offline_mode: bool = False, + skip_origin_binding: bool = False, + ) -> dict: + """Verify MCP server identity (RFC-007 §7.2). + + Verifies a server's disclosed identity (DID + badge) and checks + that it matches the transport origin. Returns the verification + state and any errors. + + Args: + server_did: Server's DID (did:web:... or did:key:...) + server_badge: Server's badge JWT (optional for level 0) + transport_origin: Origin from the transport (e.g., "https://files.example.com") + endpoint_path: Endpoint path being accessed + trusted_issuers: List of trusted badge issuers + min_trust_level: Minimum required trust level (0-4) + accept_level_zero: Accept self-signed (level 0) servers + offline_mode: Skip online validation (use cache only) + skip_origin_binding: Skip transport origin binding check (RFC-007 §5.3) + + Returns: + Dict with: + state: Server state ("verified_principal", "declared_principal", "unverified_origin") + trust_level: Server's trust level (0-4) + server_did: Verified server DID + badge_jti: Server badge JTI (if badge was provided) + error_code: Error code if verification failed + error_detail: Detailed error message + + Example: + # Verify server before trusting tool results + result = client.mcp.verify_server_identity( + server_did="did:web:files.example.com:mcp:files", + server_badge=server_badge_token, + transport_origin="https://files.example.com", + min_trust_level=1, # Require at least DV + ) + + if result["state"] == "verified_principal": + print(f"Server verified at trust level {result['trust_level']}") + else: + print(f"Server verification failed: {result['error_detail']}") + """ + # Build config + config = mcp_pb2.MCPVerifyConfig( + trusted_issuers=trusted_issuers or [], + min_trust_level=min_trust_level, + accept_level_zero=accept_level_zero, + offline_mode=offline_mode, + skip_origin_binding=skip_origin_binding, + ) + + request = mcp_pb2.VerifyServerIdentityRequest( + server_did=server_did, + server_badge=server_badge, + transport_origin=transport_origin, + endpoint_path=endpoint_path, + config=config, + ) + + response = self._stub.VerifyServerIdentity(request) + + # Map enums to strings + state_map = { + mcp_pb2.MCP_SERVER_STATE_UNSPECIFIED: "unspecified", + mcp_pb2.MCP_SERVER_STATE_VERIFIED_PRINCIPAL: "verified_principal", + mcp_pb2.MCP_SERVER_STATE_DECLARED_PRINCIPAL: "declared_principal", + mcp_pb2.MCP_SERVER_STATE_UNVERIFIED_ORIGIN: "unverified_origin", + } + + error_code_map = { + mcp_pb2.MCP_SERVER_ERROR_NONE: "", + mcp_pb2.MCP_SERVER_ERROR_DID_INVALID: "did_invalid", + mcp_pb2.MCP_SERVER_ERROR_BADGE_INVALID: "badge_invalid", + mcp_pb2.MCP_SERVER_ERROR_BADGE_EXPIRED: "badge_expired", + mcp_pb2.MCP_SERVER_ERROR_BADGE_REVOKED: "badge_revoked", + mcp_pb2.MCP_SERVER_ERROR_TRUST_INSUFFICIENT: "trust_insufficient", + mcp_pb2.MCP_SERVER_ERROR_ORIGIN_MISMATCH: "origin_mismatch", + mcp_pb2.MCP_SERVER_ERROR_PATH_MISMATCH: "path_mismatch", + mcp_pb2.MCP_SERVER_ERROR_ISSUER_UNTRUSTED: "issuer_untrusted", + } + + return { + "state": state_map.get(response.state, "unspecified"), + "trust_level": response.trust_level, + "server_did": response.server_did, + "badge_jti": response.badge_jti, + "error_code": error_code_map.get(response.error_code, ""), + "error_detail": response.error_detail, + } + + def parse_server_identity_http( + self, + capiscio_server_did: str = "", + capiscio_server_badge: str = "", + ) -> dict: + """Parse server identity from HTTP headers (RFC-007 §5.2). + + Extracts server identity from HTTP headers. Use this before + verify_server_identity() to extract the DID and badge. + + Args: + capiscio_server_did: Value of Capiscio-Server-DID header + capiscio_server_badge: Value of Capiscio-Server-Badge header + + Returns: + Dict with: + server_did: Extracted server DID + server_badge: Extracted server badge JWT + identity_present: Whether identity was present + + Example: + # Extract from HTTP headers + headers = response.headers + identity = client.mcp.parse_server_identity_http( + capiscio_server_did=headers.get("Capiscio-Server-DID", ""), + capiscio_server_badge=headers.get("Capiscio-Server-Badge", ""), + ) + + if identity["identity_present"]: + # Verify the extracted identity + result = client.mcp.verify_server_identity( + server_did=identity["server_did"], + server_badge=identity["server_badge"], + transport_origin="https://files.example.com", + ) + """ + http_headers = mcp_pb2.MCPHttpHeaders( + capiscio_server_did=capiscio_server_did, + capiscio_server_badge=capiscio_server_badge, + ) + + request = mcp_pb2.ParseServerIdentityRequest(http_headers=http_headers) + response = self._stub.ParseServerIdentity(request) + + return { + "server_did": response.server_did, + "server_badge": response.server_badge, + "identity_present": response.identity_present, + } + + def parse_server_identity_jsonrpc(self, meta_json: str) -> dict: + """Parse server identity from JSON-RPC _meta (RFC-007 §5.3). + + Extracts server identity from JSON-RPC _meta field. Use this + for stdio transport or any JSON-RPC based MCP connection. + + Args: + meta_json: JSON string of the _meta object containing + "serverDid" and "serverBadge" fields + + Returns: + Dict with: + server_did: Extracted server DID + server_badge: Extracted server badge JWT + identity_present: Whether identity was present + + Example: + # Extract from JSON-RPC response + meta = response.get("_meta", {}) + identity = client.mcp.parse_server_identity_jsonrpc( + meta_json=json.dumps(meta) + ) + + if identity["identity_present"]: + # Verify the extracted identity + result = client.mcp.verify_server_identity( + server_did=identity["server_did"], + server_badge=identity["server_badge"], + transport_origin="", # N/A for stdio + ) + """ + jsonrpc_meta = mcp_pb2.MCPJsonRpcMeta(meta_json=meta_json) + + request = mcp_pb2.ParseServerIdentityRequest(jsonrpc_meta=jsonrpc_meta) + response = self._stub.ParseServerIdentity(request) + + return { + "server_did": response.server_did, + "server_badge": response.server_badge, + "identity_present": response.identity_present, + } + + def health(self, client_version: str = "") -> dict: + """Check MCP service health and version compatibility. + + Args: + client_version: Client's version for compatibility check + + Returns: + Dict with: + healthy: Whether service is healthy + core_version: capiscio-core version + proto_version: Proto/gRPC version + version_compatible: Whether versions are compatible + """ + request = mcp_pb2.MCPHealthRequest(client_version=client_version) + response = self._stub.Health(request) + + return { + "healthy": response.healthy, + "core_version": response.core_version, + "proto_version": response.proto_version, + "version_compatible": response.version_compatible, + } + + class RegistryClient: """Client wrapper for RegistryService.""" diff --git a/capiscio_sdk/_rpc/gen/capiscio/v1/__init__.py b/capiscio_sdk/_rpc/gen/capiscio/v1/__init__.py index 262854a..9b365e9 100644 --- a/capiscio_sdk/_rpc/gen/capiscio/v1/__init__.py +++ b/capiscio_sdk/_rpc/gen/capiscio/v1/__init__.py @@ -4,6 +4,8 @@ from capiscio_sdk._rpc.gen.capiscio.v1 import common_pb2 as common_pb2 from capiscio_sdk._rpc.gen.capiscio.v1 import did_pb2 as did_pb2 from capiscio_sdk._rpc.gen.capiscio.v1 import did_pb2_grpc as did_pb2_grpc +from capiscio_sdk._rpc.gen.capiscio.v1 import mcp_pb2 as mcp_pb2 +from capiscio_sdk._rpc.gen.capiscio.v1 import mcp_pb2_grpc as mcp_pb2_grpc from capiscio_sdk._rpc.gen.capiscio.v1 import registry_pb2 as registry_pb2 from capiscio_sdk._rpc.gen.capiscio.v1 import registry_pb2_grpc as registry_pb2_grpc from capiscio_sdk._rpc.gen.capiscio.v1 import revocation_pb2 as revocation_pb2 diff --git a/capiscio_sdk/_rpc/gen/capiscio/v1/badge_pb2.py b/capiscio_sdk/_rpc/gen/capiscio/v1/badge_pb2.py index d11bfa8..13cad94 100644 --- a/capiscio_sdk/_rpc/gen/capiscio/v1/badge_pb2.py +++ b/capiscio_sdk/_rpc/gen/capiscio/v1/badge_pb2.py @@ -2,7 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE # source: capiscio/v1/badge.proto -# Protobuf Python Version: 6.33.2 +# Protobuf Python Version: 6.33.4 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -13,7 +13,7 @@ _runtime_version.Domain.PUBLIC, 6, 33, - 2, + 4, '', 'capiscio/v1/badge.proto' ) diff --git a/capiscio_sdk/_rpc/gen/capiscio/v1/common_pb2.py b/capiscio_sdk/_rpc/gen/capiscio/v1/common_pb2.py index 48007f4..6d3d214 100644 --- a/capiscio_sdk/_rpc/gen/capiscio/v1/common_pb2.py +++ b/capiscio_sdk/_rpc/gen/capiscio/v1/common_pb2.py @@ -2,7 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE # source: capiscio/v1/common.proto -# Protobuf Python Version: 6.33.2 +# Protobuf Python Version: 6.33.4 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -13,7 +13,7 @@ _runtime_version.Domain.PUBLIC, 6, 33, - 2, + 4, '', 'capiscio/v1/common.proto' ) diff --git a/capiscio_sdk/_rpc/gen/capiscio/v1/did_pb2.py b/capiscio_sdk/_rpc/gen/capiscio/v1/did_pb2.py index ef41e73..20c5b59 100644 --- a/capiscio_sdk/_rpc/gen/capiscio/v1/did_pb2.py +++ b/capiscio_sdk/_rpc/gen/capiscio/v1/did_pb2.py @@ -2,7 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE # source: capiscio/v1/did.proto -# Protobuf Python Version: 6.33.2 +# Protobuf Python Version: 6.33.4 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -13,7 +13,7 @@ _runtime_version.Domain.PUBLIC, 6, 33, - 2, + 4, '', 'capiscio/v1/did.proto' ) diff --git a/capiscio_sdk/_rpc/gen/capiscio/v1/registry_pb2.py b/capiscio_sdk/_rpc/gen/capiscio/v1/registry_pb2.py index 075c0f4..609109e 100644 --- a/capiscio_sdk/_rpc/gen/capiscio/v1/registry_pb2.py +++ b/capiscio_sdk/_rpc/gen/capiscio/v1/registry_pb2.py @@ -2,7 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE # source: capiscio/v1/registry.proto -# Protobuf Python Version: 6.33.2 +# Protobuf Python Version: 6.33.4 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -13,7 +13,7 @@ _runtime_version.Domain.PUBLIC, 6, 33, - 2, + 4, '', 'capiscio/v1/registry.proto' ) diff --git a/capiscio_sdk/_rpc/gen/capiscio/v1/revocation_pb2.py b/capiscio_sdk/_rpc/gen/capiscio/v1/revocation_pb2.py index a66d4bc..0a94e60 100644 --- a/capiscio_sdk/_rpc/gen/capiscio/v1/revocation_pb2.py +++ b/capiscio_sdk/_rpc/gen/capiscio/v1/revocation_pb2.py @@ -2,7 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE # source: capiscio/v1/revocation.proto -# Protobuf Python Version: 6.33.2 +# Protobuf Python Version: 6.33.4 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -13,7 +13,7 @@ _runtime_version.Domain.PUBLIC, 6, 33, - 2, + 4, '', 'capiscio/v1/revocation.proto' ) diff --git a/capiscio_sdk/_rpc/gen/capiscio/v1/scoring_pb2.py b/capiscio_sdk/_rpc/gen/capiscio/v1/scoring_pb2.py index 213d97a..fea3be4 100644 --- a/capiscio_sdk/_rpc/gen/capiscio/v1/scoring_pb2.py +++ b/capiscio_sdk/_rpc/gen/capiscio/v1/scoring_pb2.py @@ -2,7 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE # source: capiscio/v1/scoring.proto -# Protobuf Python Version: 6.33.2 +# Protobuf Python Version: 6.33.4 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -13,7 +13,7 @@ _runtime_version.Domain.PUBLIC, 6, 33, - 2, + 4, '', 'capiscio/v1/scoring.proto' ) diff --git a/capiscio_sdk/_rpc/gen/capiscio/v1/simpleguard_pb2.py b/capiscio_sdk/_rpc/gen/capiscio/v1/simpleguard_pb2.py index 862b0ec..cba00f2 100644 --- a/capiscio_sdk/_rpc/gen/capiscio/v1/simpleguard_pb2.py +++ b/capiscio_sdk/_rpc/gen/capiscio/v1/simpleguard_pb2.py @@ -2,7 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE # source: capiscio/v1/simpleguard.proto -# Protobuf Python Version: 6.33.2 +# Protobuf Python Version: 6.33.4 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -13,7 +13,7 @@ _runtime_version.Domain.PUBLIC, 6, 33, - 2, + 4, '', 'capiscio/v1/simpleguard.proto' ) diff --git a/capiscio_sdk/_rpc/gen/capiscio/v1/trust_pb2.py b/capiscio_sdk/_rpc/gen/capiscio/v1/trust_pb2.py index 3800132..6a56043 100644 --- a/capiscio_sdk/_rpc/gen/capiscio/v1/trust_pb2.py +++ b/capiscio_sdk/_rpc/gen/capiscio/v1/trust_pb2.py @@ -2,7 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE # source: capiscio/v1/trust.proto -# Protobuf Python Version: 6.33.2 +# Protobuf Python Version: 6.33.4 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -13,7 +13,7 @@ _runtime_version.Domain.PUBLIC, 6, 33, - 2, + 4, '', 'capiscio/v1/trust.proto' ) diff --git a/docs/api-reference.md b/docs/api-reference.md index 165c5f0..13e6160 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -13,6 +13,11 @@ This section provides detailed API documentation for all public modules in the C - SecurityConfig - SimpleGuard - validate_agent_card + - verify_badge + - parse_badge + - request_badge + - BadgeClaims + - TrustLevel show_root_heading: false ## Configuration @@ -25,6 +30,130 @@ This section provides detailed API documentation for all public modules in the C - UpstreamConfig show_root_heading: false +## Trust Badge API + +::: capiscio_sdk.badge + options: + members: + - verify_badge + - parse_badge + - request_badge + - request_badge_sync + - request_pop_badge + - request_pop_badge_sync + - start_badge_keeper + - BadgeClaims + - VerifyOptions + - VerifyResult + - VerifyMode + - TrustLevel + show_root_heading: false + +## Badge Keeper + +::: capiscio_sdk.badge_keeper + options: + members: + - BadgeKeeper + - BadgeKeeperConfig + show_root_heading: false + +## Domain Validation (DV) API + +::: capiscio_sdk.dv + options: + members: + - create_dv_order + - get_dv_order + - finalize_dv_order + - DVOrder + - DVGrant + show_root_heading: false + +## RPC Client + +### CapiscioRPCClient + +::: capiscio_sdk._rpc.client.CapiscioRPCClient + options: + show_root_heading: true + members: + - connect + - close + - badge + - did + - mcp + - scoring + - simpleguard + +### MCPClient (RFC-006 / RFC-007) + +::: capiscio_sdk._rpc.client.MCPClient + options: + show_root_heading: true + members: + - evaluate_tool_access + - verify_server_identity + - parse_server_identity_http + - parse_server_identity_jsonrpc + - health + +### BadgeClient + +::: capiscio_sdk._rpc.client.BadgeClient + options: + show_root_heading: true + members: + - sign_badge + - verify_badge + - verify_badge_with_options + - parse_badge + - request_badge + - request_pop_badge + - start_keeper + - create_dv_order + - get_dv_order + - finalize_dv_order + +### DIDClient + +::: capiscio_sdk._rpc.client.DIDClient + options: + show_root_heading: true + members: + - parse + - new_agent_did + - new_capiscio_agent_did + - document_url + - is_agent_did + +### ScoringClient + +::: capiscio_sdk._rpc.client.ScoringClient + options: + show_root_heading: true + members: + - score_agent_card + - validate_rule + - list_rule_sets + - get_rule_set + - aggregate_scores + +### SimpleGuardClient + +::: capiscio_sdk._rpc.client.SimpleGuardClient + options: + show_root_heading: true + members: + - sign + - verify + - sign_attached + - verify_attached + - generate_key_pair + - load_key + - export_key + - get_key_info + ## Validators ### Core Validator (Go-backed) diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md new file mode 100644 index 0000000..756f226 --- /dev/null +++ b/docs/guides/mcp.md @@ -0,0 +1,521 @@ +# MCP Security Integration + +**Model Context Protocol (MCP) integration for validating tool access and server identity.** + +The CapiscIO Python SDK provides security middleware for MCP tools, implementing: + +- **RFC-006**: Tool access evaluation based on trust levels +- **RFC-007**: Server identity verification via trust badges + +## Installation + +The MCP module requires the `mcp` extra: + +```bash +pip install capiscio-sdk[mcp] +``` + +## Quick Start + +### Evaluate Tool Access (RFC-006) + +Before allowing a tool to execute, evaluate whether the calling server has sufficient trust: + +```python +from capiscio_sdk.mcp import evaluate_tool_access, TrustLevel, DenyReason + +# Evaluate whether a server can access a tool +result = evaluate_tool_access( + tool_name="file_read", + server_endpoint="https://agent.example.com", + trust_level=TrustLevel.VERIFIED +) + +if result.allow: + # Proceed with tool execution + print(f"Access granted with trust level: {result.trust_level}") +else: + # Handle denial + print(f"Access denied: {result.deny_reason}") +``` + +### Verify Server Identity (RFC-007) + +Verify that an MCP server has a valid trust badge: + +```python +from capiscio_sdk.mcp import verify_server_identity, ServerState + +result = verify_server_identity( + server_endpoint="https://mcp-server.example.com", + expected_did="did:web:example.com" # Optional +) + +if result.state == ServerState.VERIFIED: + print(f"Server verified! DID: {result.did}") + print(f"Trust badge: {result.badge_jws}") +else: + print(f"Verification failed: {result.state}") +``` + +--- + +## Core Functions + +### `evaluate_tool_access()` + +Evaluates whether a tool access request should be allowed based on the server's trust level. + +```python +def evaluate_tool_access( + tool_name: str, + server_endpoint: str, + trust_level: TrustLevel, + required_level: TrustLevel = TrustLevel.REGISTERED, + tool_policy: ToolPolicy | None = None, +) -> ToolAccessResult: + """ + Evaluate tool access based on trust level. + + Args: + tool_name: Name of the tool being accessed + server_endpoint: URL of the requesting MCP server + trust_level: Current trust level of the server + required_level: Minimum trust level required (default: REGISTERED) + tool_policy: Optional custom policy for this tool + + Returns: + ToolAccessResult with allow/deny decision and metadata + """ +``` + +#### Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `tool_name` | `str` | ✅ | Name of the tool being accessed | +| `server_endpoint` | `str` | ✅ | URL of the requesting MCP server | +| `trust_level` | `TrustLevel` | ✅ | Server's current trust level | +| `required_level` | `TrustLevel` | ❌ | Minimum required trust (default: `REGISTERED`) | +| `tool_policy` | `ToolPolicy` | ❌ | Custom access policy for this tool | + +#### Return Value + +`ToolAccessResult` with the following attributes: + +```python +@dataclass +class ToolAccessResult: + allow: bool # Whether access is granted + trust_level: TrustLevel # Effective trust level + deny_reason: DenyReason | None # Reason if denied + tool_name: str # Tool that was evaluated + server_endpoint: str # Server that requested access + evaluated_at: datetime # When evaluation occurred +``` + +--- + +### `verify_server_identity()` + +Verifies an MCP server's identity through its trust badge. + +```python +def verify_server_identity( + server_endpoint: str, + expected_did: str | None = None, + timeout: float = 10.0, + verify_tls: bool = True, +) -> ServerIdentityResult: + """ + Verify server identity via trust badge. + + Args: + server_endpoint: MCP server URL to verify + expected_did: Optional expected DID for binding verification + timeout: Request timeout in seconds + verify_tls: Whether to verify TLS certificates + + Returns: + ServerIdentityResult with verification state and badge data + """ +``` + +#### Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `server_endpoint` | `str` | ✅ | MCP server URL to verify | +| `expected_did` | `str` | ❌ | Expected DID for binding check | +| `timeout` | `float` | ❌ | Request timeout (default: 10.0s) | +| `verify_tls` | `bool` | ❌ | Verify TLS certs (default: `True`) | + +#### Return Value + +`ServerIdentityResult` with the following attributes: + +```python +@dataclass +class ServerIdentityResult: + state: ServerState # Verification result state + server_endpoint: str # Server that was verified + did: str | None # Resolved DID (if verified) + badge_jws: str | None # Raw badge JWS (if retrieved) + badge_payload: dict | None # Decoded badge payload + error: str | None # Error message (if failed) + verified_at: datetime # When verification occurred +``` + +--- + +### `parse_server_identity_http()` + +Extract server identity from HTTP response headers. + +```python +def parse_server_identity_http( + headers: dict[str, str] +) -> ServerIdentity | None: + """ + Parse server identity from HTTP headers. + + Looks for: + - X-CapiscIO-Badge: JWS trust badge + - X-CapiscIO-DID: Server DID + + Args: + headers: HTTP response headers + + Returns: + ServerIdentity if badge found, None otherwise + """ +``` + +#### Example + +```python +import httpx +from capiscio_sdk.mcp import parse_server_identity_http + +response = httpx.get("https://mcp-server.example.com/health") +identity = parse_server_identity_http(dict(response.headers)) + +if identity: + print(f"Server DID: {identity.did}") + print(f"Badge issued: {identity.badge_issued_at}") +``` + +--- + +### `parse_server_identity_jsonrpc()` + +Extract server identity from JSON-RPC response metadata. + +```python +def parse_server_identity_jsonrpc( + response: dict +) -> ServerIdentity | None: + """ + Parse server identity from JSON-RPC response. + + Looks for identity in: + - response["_meta"]["capiscio"] + - response["result"]["_meta"]["capiscio"] + + Args: + response: JSON-RPC response dict + + Returns: + ServerIdentity if found, None otherwise + """ +``` + +#### Example + +```python +from capiscio_sdk.mcp import parse_server_identity_jsonrpc + +# JSON-RPC response with embedded identity +response = { + "jsonrpc": "2.0", + "id": 1, + "result": {...}, + "_meta": { + "capiscio": { + "did": "did:web:example.com", + "badge": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9..." + } + } +} + +identity = parse_server_identity_jsonrpc(response) +if identity: + print(f"Verified server: {identity.did}") +``` + +--- + +## Types Reference + +### TrustLevel + +Trust levels in ascending order of privilege: + +```python +class TrustLevel(str, Enum): + UNKNOWN = "unknown" # No trust information + REGISTERED = "registered" # Basic registration only + VERIFIED = "verified" # Identity verified + AUDITED = "audited" # Security audit passed + CERTIFIED = "certified" # Full certification +``` + +### DenyReason + +Reasons for access denial: + +```python +class DenyReason(str, Enum): + INSUFFICIENT_TRUST = "insufficient_trust" # Trust level too low + EXPIRED_BADGE = "expired_badge" # Badge has expired + REVOKED_BADGE = "revoked_badge" # Badge was revoked + INVALID_SIGNATURE = "invalid_signature" # Badge signature invalid + POLICY_VIOLATION = "policy_violation" # Tool policy not satisfied + UNKNOWN_SERVER = "unknown_server" # Server not recognized +``` + +### ServerState + +Server verification states: + +```python +class ServerState(str, Enum): + VERIFIED = "verified" # Successfully verified + UNVERIFIED = "unverified" # No badge found + EXPIRED = "expired" # Badge expired + REVOKED = "revoked" # Badge revoked + INVALID = "invalid" # Invalid badge data + UNREACHABLE = "unreachable" # Could not reach server + TIMEOUT = "timeout" # Request timed out + ERROR = "error" # Other error occurred +``` + +### ServerIdentity + +Server identity information: + +```python +@dataclass +class ServerIdentity: + did: str # Server's DID + badge_jws: str # Raw JWS badge + badge_issued_at: datetime # When badge was issued + badge_expires_at: datetime # When badge expires + trust_level: TrustLevel # Server's trust level +``` + +### ToolPolicy + +Custom tool access policies: + +```python +@dataclass +class ToolPolicy: + required_trust_level: TrustLevel # Minimum trust required + allowed_dids: list[str] | None # Allowlist of DIDs + blocked_dids: list[str] | None # Blocklist of DIDs + require_badge: bool # Require valid badge +``` + +--- + +## Integration Patterns + +### FastAPI Middleware + +```python +from fastapi import FastAPI, Request, HTTPException +from capiscio_sdk.mcp import ( + verify_server_identity, + parse_server_identity_http, + ServerState +) + +app = FastAPI() + +@app.middleware("http") +async def mcp_security_middleware(request: Request, call_next): + # Check for CapiscIO badge in headers + identity = parse_server_identity_http(dict(request.headers)) + + if identity: + # Verify the badge + result = verify_server_identity( + server_endpoint=str(request.client.host), + expected_did=identity.did + ) + + if result.state != ServerState.VERIFIED: + raise HTTPException( + status_code=403, + detail=f"Server verification failed: {result.state}" + ) + + # Attach verified identity to request state + request.state.mcp_identity = identity + + return await call_next(request) +``` + +### Tool Decorator + +```python +from functools import wraps +from capiscio_sdk.mcp import evaluate_tool_access, TrustLevel + +def require_trust(level: TrustLevel): + """Decorator to require minimum trust level for tool access.""" + def decorator(func): + @wraps(func) + def wrapper(tool_name: str, server_endpoint: str, trust_level: TrustLevel, *args, **kwargs): + result = evaluate_tool_access( + tool_name=tool_name, + server_endpoint=server_endpoint, + trust_level=trust_level, + required_level=level + ) + + if not result.allow: + raise PermissionError( + f"Tool '{tool_name}' requires {level.value} trust, " + f"but server has {trust_level.value}: {result.deny_reason}" + ) + + return func(tool_name, server_endpoint, trust_level, *args, **kwargs) + return wrapper + return decorator + +# Usage +@require_trust(TrustLevel.VERIFIED) +def sensitive_tool(tool_name: str, server_endpoint: str, trust_level: TrustLevel): + """This tool requires VERIFIED trust level.""" + return {"result": "sensitive operation completed"} +``` + +### Async Verification + +```python +import asyncio +from capiscio_sdk.mcp import verify_server_identity, ServerState + +async def verify_servers(endpoints: list[str]) -> dict[str, ServerState]: + """Verify multiple servers concurrently.""" + + async def verify_one(endpoint: str) -> tuple[str, ServerState]: + # verify_server_identity is sync, run in executor + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + verify_server_identity, + endpoint + ) + return endpoint, result.state + + tasks = [verify_one(ep) for ep in endpoints] + results = await asyncio.gather(*tasks) + + return dict(results) + +# Usage +async def main(): + servers = [ + "https://server1.example.com", + "https://server2.example.com", + "https://server3.example.com", + ] + + states = await verify_servers(servers) + for server, state in states.items(): + print(f"{server}: {state}") +``` + +--- + +## Best Practices + +### 1. Cache Verification Results + +Server identity verification involves network calls. Cache results appropriately: + +```python +from functools import lru_cache +from datetime import datetime, timedelta + +@lru_cache(maxsize=100) +def cached_verify(server_endpoint: str, cache_key: str) -> ServerIdentityResult: + """Cache verification for 5 minutes using time-based cache key.""" + return verify_server_identity(server_endpoint) + +def verify_with_cache(server_endpoint: str) -> ServerIdentityResult: + # Generate cache key that expires every 5 minutes + cache_key = datetime.now().strftime("%Y%m%d%H%M")[:-1] # Truncate to 5min + return cached_verify(server_endpoint, cache_key) +``` + +### 2. Fail Secure + +Default to denying access when verification fails: + +```python +def secure_tool_access(tool_name: str, server_endpoint: str) -> bool: + try: + result = verify_server_identity(server_endpoint) + return result.state == ServerState.VERIFIED + except Exception as e: + # Log the error but default to DENY + logger.error(f"Verification failed for {server_endpoint}: {e}") + return False +``` + +### 3. Use Tool Policies for Sensitive Operations + +```python +# Define policies for sensitive tools +TOOL_POLICIES = { + "file_write": ToolPolicy( + required_trust_level=TrustLevel.AUDITED, + require_badge=True + ), + "execute_code": ToolPolicy( + required_trust_level=TrustLevel.CERTIFIED, + allowed_dids=["did:web:trusted-partner.com"], + require_badge=True + ), + "read_config": ToolPolicy( + required_trust_level=TrustLevel.VERIFIED, + require_badge=True + ), +} + +def evaluate_with_policy(tool_name: str, server_endpoint: str, trust_level: TrustLevel): + policy = TOOL_POLICIES.get(tool_name) + return evaluate_tool_access( + tool_name=tool_name, + server_endpoint=server_endpoint, + trust_level=trust_level, + tool_policy=policy + ) +``` + +--- + +## Related Documentation + +- [Badge Verification Guide](badge-verification.md) - Core badge verification patterns +- [Configuration Guide](configuration.md) - SDK configuration options +- [Scoring System](scoring.md) - Trust scoring methodology + +## RFC References + +- [RFC-006: MCP Tool Access Evaluation](/rfcs/rfc-006/) +- [RFC-007: MCP Server Identity Verification](/rfcs/rfc-007/) diff --git a/mkdocs.yml b/mkdocs.yml index d1aaa51..463a0a6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -60,6 +60,7 @@ nav: - Core Concepts: getting-started/concepts.md - Guides: - Badge Verification: guides/badge-verification.md + - MCP Security: guides/mcp.md - Scoring System: guides/scoring.md - Configuration: guides/configuration.md - API Reference: api-reference.md diff --git a/tests/integration/test_mcp_service.py b/tests/integration/test_mcp_service.py new file mode 100644 index 0000000..195b7ed --- /dev/null +++ b/tests/integration/test_mcp_service.py @@ -0,0 +1,377 @@ +""" +Integration tests for MCP (Model Context Protocol) service against live server. + +Tests RFC-006 Tool Authority and RFC-007 Server Identity verification +via the Python SDK MCPClient against the capiscio-core gRPC server. + +These tests validate the full flow: + Python SDK MCPClient → gRPC → capiscio-core MCPService +""" + +import os +import pytest +import grpc +from capiscio_sdk._rpc.client import CapiscioRPCClient, MCPClient +from capiscio_sdk._rpc.gen.capiscio.v1 import mcp_pb2 + +# gRPC server address (capiscio-core) +GRPC_ADDRESS = os.getenv("GRPC_ADDRESS", "localhost:50051") + + +@pytest.fixture(scope="module") +def grpc_client() -> CapiscioRPCClient: + """Create and connect gRPC client to server.""" + client = CapiscioRPCClient(address=GRPC_ADDRESS, auto_start=False) + try: + client.connect() + yield client + except grpc.RpcError as e: + pytest.skip(f"gRPC server not available at {GRPC_ADDRESS}: {e}") + finally: + client.close() + + +@pytest.fixture(scope="module") +def mcp_client(grpc_client: CapiscioRPCClient) -> MCPClient: + """Get the MCP client from the gRPC client.""" + return grpc_client.mcp + + +class TestMCPHealth: + """Test MCPService health endpoint.""" + + def test_health_check_basic(self, mcp_client: MCPClient): + """Test: Health check returns service status.""" + result = mcp_client.health() + + assert "healthy" in result + assert result["healthy"] is True + assert "core_version" in result + assert result["core_version"] != "" + print(f"✓ MCP service healthy, version: {result['core_version']}") + + def test_health_check_with_client_version(self, mcp_client: MCPClient): + """Test: Health check accepts client version.""" + result = mcp_client.health(client_version="capiscio-sdk-python/1.0.0") + + assert result["healthy"] is True + print("✓ Health check with client version succeeded") + + +class TestMCPToolAccessEvaluation: + """Test RFC-006 Tool Authority evaluation (§6.2-6.4).""" + + def test_evaluate_tool_access_with_badge(self, mcp_client: MCPClient): + """Test: Tool access evaluation with a badge.""" + # Mock badge JWS for testing + mock_badge = "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWQ6a2V5Ono2TWtoYVhnQlpEdm90RGtMNTI1N2ZhaXp0aUdpQzJRdEtMR3Bibm5FR3RhMmRvSyIsImlzcyI6ImRpZDp3ZWI6Y2FwaXNjaW8uaW8iLCJpYXQiOjE3MTcxNjMwMDAsImV4cCI6MTcxNzI0OTQwMH0.sig" + + result = mcp_client.evaluate_tool_access( + tool_name="read_file", + params_hash="abc123", + server_origin="https://example.com", + badge_jws=mock_badge, + ) + + assert "decision" in result + assert result["decision"] in ["allow", "deny"] + assert "timestamp" in result + print(f"✓ Tool access decision: {result['decision']}") + + def test_evaluate_tool_access_with_minimal_params(self, mcp_client: MCPClient): + """Test: Tool access with minimal required parameters.""" + result = mcp_client.evaluate_tool_access( + tool_name="test_tool", + ) + + assert "decision" in result + assert "auth_level" in result + print(f"✓ Minimal tool access result: decision={result['decision']}, auth_level={result['auth_level']}") + + def test_evaluate_tool_access_with_api_key(self, mcp_client: MCPClient): + """Test: Tool access with API key authentication.""" + result = mcp_client.evaluate_tool_access( + tool_name="write_file", + api_key="test-api-key-12345", + server_origin="https://files.example.com", + ) + + # Should deny (invalid API key) or allow (in dev mode) + assert result["decision"] in ["allow", "deny"] + print(f"✓ API key tool access: {result['decision']}") + + def test_evaluate_tool_access_with_trust_requirements(self, mcp_client: MCPClient): + """Test: Tool access with minimum trust level requirement.""" + result = mcp_client.evaluate_tool_access( + tool_name="dangerous_tool", + min_trust_level=2, # Require OV or higher + accept_level_zero=False, + ) + + # Should deny without proper authentication + assert result["decision"] == "deny" + assert "deny_reason" in result + print(f"✓ Trust-required tool access denied: {result.get('deny_reason', 'N/A')}") + + def test_evaluate_tool_access_with_allowed_tools(self, mcp_client: MCPClient): + """Test: Tool access with explicit allowed tools list.""" + result = mcp_client.evaluate_tool_access( + tool_name="read_file", + allowed_tools=["read_file", "list_dir"], + accept_level_zero=True, + ) + + assert "decision" in result + print(f"✓ Allowed tools check: {result['decision']}") + + def test_evaluate_tool_access_not_in_allowed_list(self, mcp_client: MCPClient): + """Test: Tool access denied when not in allowed list.""" + result = mcp_client.evaluate_tool_access( + tool_name="delete_file", + allowed_tools=["read_file", "list_dir"], # delete not allowed + ) + + assert result["decision"] == "deny" + print("✓ Tool correctly denied (not in allowed list)") + + +class TestMCPServerIdentityVerification: + """Test RFC-007 Server Identity verification (§7.2).""" + + def test_verify_server_identity_valid_did(self, mcp_client: MCPClient): + """Test: Verify server identity with valid DID.""" + result = mcp_client.verify_server_identity( + server_did="did:web:example.com:servers:main", + ) + + assert "state" in result + assert result["state"] in [ + "verified_principal", "declared_principal", "unverified_origin" + ] + assert "trust_level" in result + print(f"✓ Server identity state: {result['state']}, trust: {result['trust_level']}") + + def test_verify_server_identity_with_badge(self, mcp_client: MCPClient): + """Test: Verify server with badge.""" + mock_badge = "eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJkaWQ6d2ViOmV4YW1wbGUuY29tOnNlcnZlcnM6bWFpbiJ9.sig" + + result = mcp_client.verify_server_identity( + server_did="did:web:example.com:servers:main", + server_badge=mock_badge, + transport_origin="https://example.com", + ) + + assert "state" in result + print(f"✓ Server with badge: state={result['state']}") + + def test_verify_server_identity_with_trust_requirements(self, mcp_client: MCPClient): + """Test: Server verification with trust requirements.""" + result = mcp_client.verify_server_identity( + server_did="did:web:untrusted.example.com", + min_trust_level=2, # Require OV + accept_level_zero=False, + ) + + # Should fail to verify at level 2 without proper badge + assert result["state"] in ["declared_principal", "unverified_origin"] + print(f"✓ Trust requirements enforced: {result['state']}") + + def test_verify_server_identity_did_key(self, mcp_client: MCPClient): + """Test: Verify did:key server (self-signed).""" + result = mcp_client.verify_server_identity( + server_did="did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + accept_level_zero=True, + ) + + assert "state" in result + assert result["trust_level"] == 0 # did:key is always level 0 + print(f"✓ did:key server verified at level 0") + + def test_verify_server_identity_offline_mode(self, mcp_client: MCPClient): + """Test: Server verification in offline mode.""" + result = mcp_client.verify_server_identity( + server_did="did:web:example.com:server", + offline_mode=True, + ) + + assert "state" in result + print(f"✓ Offline verification: {result['state']}") + + +class TestMCPServerIdentityParsing: + """Test RFC-007 Server Identity parsing from protocol messages.""" + + def test_parse_server_identity_http_headers(self, mcp_client: MCPClient): + """Test: Parse server identity from HTTP headers.""" + result = mcp_client.parse_server_identity_http( + capiscio_server_did="did:web:example.com:servers:main", + capiscio_server_badge="eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJ0ZXN0In0.sig", + ) + + assert "server_did" in result + assert result["server_did"] == "did:web:example.com:servers:main" + assert "identity_present" in result + assert result["identity_present"] is True + print(f"✓ Parsed HTTP headers: {result['server_did']}") + + def test_parse_server_identity_http_minimal(self, mcp_client: MCPClient): + """Test: Parse with only DID header.""" + result = mcp_client.parse_server_identity_http( + capiscio_server_did="did:web:minimal.example.com", + ) + + assert "server_did" in result + assert result["server_did"] == "did:web:minimal.example.com" + print("✓ Parsed minimal HTTP headers") + + def test_parse_server_identity_http_empty(self, mcp_client: MCPClient): + """Test: Parse with no headers.""" + result = mcp_client.parse_server_identity_http() + + assert "identity_present" in result + assert result["identity_present"] is False + print("✓ Empty headers correctly detected") + + def test_parse_server_identity_jsonrpc_meta(self, mcp_client: MCPClient): + """Test: Parse server identity from JSON-RPC meta.""" + import json + meta = json.dumps({ + "_meta": { + "serverDid": "did:web:jsonrpc.example.com", + "serverBadge": "eyJhbGciOiJFZERTQSJ9.sig" + } + }) + + result = mcp_client.parse_server_identity_jsonrpc(meta_json=meta) + + assert "server_did" in result + print(f"✓ Parsed JSON-RPC meta: {result.get('server_did', 'N/A')}") + + def test_parse_server_identity_jsonrpc_initialize_response(self, mcp_client: MCPClient): + """Test: Parse from MCP initialize response format.""" + import json + meta = json.dumps({ + "serverInfo": { + "name": "Official MCP Server", + "version": "0.1.0" + }, + "protocolVersion": "0.1", + "_meta": { + "serverDid": "did:web:mcp.example.com:server", + } + }) + + result = mcp_client.parse_server_identity_jsonrpc(meta_json=meta) + + assert "identity_present" in result + print(f"✓ Parsed MCP initialize response") + + +class TestMCPDecisionEnums: + """Test MCP decision and state enums are properly returned.""" + + def test_decision_values(self, mcp_client: MCPClient): + """Test: Decision values are returned correctly.""" + result = mcp_client.evaluate_tool_access( + tool_name="test_tool", + ) + + assert result["decision"] in ["allow", "deny"] + print(f"✓ Decision value: {result['decision']}") + + def test_auth_level_values(self, mcp_client: MCPClient): + """Test: Auth level is returned correctly.""" + result = mcp_client.evaluate_tool_access( + tool_name="test_tool", + ) + + assert "auth_level" in result + assert result["auth_level"] in ["anonymous", "api_key", "badge"] + print(f"✓ Auth level: {result['auth_level']}") + + def test_server_state_values(self, mcp_client: MCPClient): + """Test: Server state is returned correctly.""" + result = mcp_client.verify_server_identity( + server_did="did:web:example.com:test" + ) + + assert result["state"] in [ + "verified_principal", "declared_principal", "unverified_origin" + ] + print(f"✓ Server state: {result['state']}") + + +class TestMCPErrorHandling: + """Test MCP service error handling.""" + + def test_empty_tool_name_handled(self, mcp_client: MCPClient): + """Test: Empty tool name is handled gracefully.""" + try: + result = mcp_client.evaluate_tool_access( + tool_name="", + ) + # Empty tool name may allow or deny depending on policy + assert result["decision"] in ["allow", "deny"] + print(f"✓ Empty tool name handled: {result['decision']}") + except grpc.RpcError as e: + # RPC error is acceptable for invalid input + assert e.code() in [grpc.StatusCode.INVALID_ARGUMENT, grpc.StatusCode.INTERNAL] + print(f"✓ Empty tool name raised gRPC error: {e.code()}") + + def test_invalid_did_handled(self, mcp_client: MCPClient): + """Test: Invalid DID format is handled.""" + result = mcp_client.verify_server_identity( + server_did="not-a-valid-did" + ) + + # Should return an error state, not crash + assert "state" in result or "error_code" in result + print(f"✓ Invalid DID handled gracefully") + + +class TestMCPIntegrationScenarios: + """End-to-end integration scenarios.""" + + def test_full_mcp_flow_http_headers(self, mcp_client: MCPClient): + """Test: Full MCP flow - parse HTTP headers, verify, then evaluate.""" + # 1. Parse server identity from HTTP headers + parsed = mcp_client.parse_server_identity_http( + capiscio_server_did="did:web:production.example.com:mcp", + capiscio_server_badge="", + ) + + server_did = parsed["server_did"] + + # 2. Verify server identity + verification = mcp_client.verify_server_identity( + server_did=server_did, + accept_level_zero=True, + ) + + # 3. Evaluate tool access + access = mcp_client.evaluate_tool_access( + tool_name="read_file", + server_origin="https://production.example.com", + ) + + print(f"✓ Full MCP flow completed:") + print(f" Server DID: {server_did}") + print(f" Server state: {verification['state']}") + print(f" Tool access: {access['decision']}") + + def test_mcp_flow_with_badge(self, mcp_client: MCPClient): + """Test: MCP flow including badge verification.""" + mock_badge = "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWQ6a2V5Ono2TWtoYVhnQlpEdm90RGtMNTI1N2ZhaXp0aUdpQzJRdEtMR3Bibm5FR3RhMmRvSyJ9.sig" + + result = mcp_client.evaluate_tool_access( + tool_name="read_file", + badge_jws=mock_badge, + server_origin="https://example.com", + ) + + assert "decision" in result + print(f"✓ MCP flow with badge: {result['decision']}") + + +# Skip markers for when server is not available +pytestmark = pytest.mark.integration From b3c50bbf1fee03126e445e1896ac5d5c8d7036ec Mon Sep 17 00:00:00 2001 From: Beon de Nood Date: Sat, 17 Jan 2026 19:30:23 -0500 Subject: [PATCH 2/5] fix: mkdocs strict mode warnings for griffe - Add Generator return type annotation to start_badge_keeper in badge.py - Add Generator return type annotation to start_keeper in client.py - Fix Google-style docstring format for Yields section - Configure mkdocstrings options for better compatibility --- capiscio_sdk/_rpc/client.py | 8 ++++---- capiscio_sdk/badge.py | 8 ++++---- mkdocs.yml | 4 ++++ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/capiscio_sdk/_rpc/client.py b/capiscio_sdk/_rpc/client.py index 038b49b..d182616 100644 --- a/capiscio_sdk/_rpc/client.py +++ b/capiscio_sdk/_rpc/client.py @@ -1,6 +1,6 @@ """gRPC client wrapper for capiscio-core.""" -from typing import Optional +from typing import Generator, Optional import grpc @@ -514,7 +514,7 @@ def start_keeper( renew_before_seconds: int = 60, check_interval_seconds: int = 30, trust_level: int = 1, - ): + ) -> Generator[dict, None, None]: """Start a badge keeper daemon (RFC-002 §7.3). The keeper automatically renews badges before they expire, ensuring @@ -534,8 +534,8 @@ def start_keeper( trust_level: Trust level for CA mode (1-4, default: 1) Yields: - KeeperEvent dicts with: type, badge_jti, subject, trust_level, - expires_at, error, error_code, timestamp, token + dict: KeeperEvent dicts with keys: type, badge_jti, subject, trust_level, + expires_at, error, error_code, timestamp, token Example: # CA mode diff --git a/capiscio_sdk/badge.py b/capiscio_sdk/badge.py index 753c52a..a68ad2f 100644 --- a/capiscio_sdk/badge.py +++ b/capiscio_sdk/badge.py @@ -30,7 +30,7 @@ from dataclasses import dataclass, field from datetime import datetime from enum import Enum -from typing import List, Optional, Union +from typing import Generator, List, Optional, Union from capiscio_sdk._rpc.client import CapiscioRPCClient @@ -652,7 +652,7 @@ def start_badge_keeper( renew_before_seconds: int = 60, check_interval_seconds: int = 30, trust_level: Union[TrustLevel, str, int] = TrustLevel.LEVEL_1, -): +) -> Generator[dict, None, None]: """Start a badge keeper daemon (RFC-002 §7.3). The keeper automatically renews badges before they expire, ensuring @@ -672,8 +672,8 @@ def start_badge_keeper( trust_level: Trust level for CA mode (1-4, default: 1) Yields: - KeeperEvent dicts with: type, badge_jti, subject, trust_level, - expires_at, error, error_code, timestamp, token + dict: KeeperEvent dicts with keys: type, badge_jti, subject, trust_level, + expires_at, error, error_code, timestamp, token Example: # CA mode - production diff --git a/mkdocs.yml b/mkdocs.yml index 463a0a6..6ad09ad 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -38,6 +38,10 @@ plugins: docstring_style: google show_source: true show_root_heading: true + show_if_no_docstring: false + allow_inspection: true + show_symbol_type_heading: true + show_symbol_type_toc: true markdown_extensions: - admonition From 66162bb1db12cbb9592df60033a7a5245cfbccff Mon Sep 17 00:00:00 2001 From: Beon de Nood Date: Sat, 17 Jan 2026 19:33:27 -0500 Subject: [PATCH 3/5] fix: add missing mcp_pb2 and mcp_pb2_grpc files These files were ignored by .gitignore but are required for MCP service support. --- capiscio_sdk/_rpc/gen/capiscio/v1/mcp_pb2.py | 72 ++++++ .../_rpc/gen/capiscio/v1/mcp_pb2_grpc.py | 214 ++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 capiscio_sdk/_rpc/gen/capiscio/v1/mcp_pb2.py create mode 100644 capiscio_sdk/_rpc/gen/capiscio/v1/mcp_pb2_grpc.py diff --git a/capiscio_sdk/_rpc/gen/capiscio/v1/mcp_pb2.py b/capiscio_sdk/_rpc/gen/capiscio/v1/mcp_pb2.py new file mode 100644 index 0000000..1cb5121 --- /dev/null +++ b/capiscio_sdk/_rpc/gen/capiscio/v1/mcp_pb2.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: capiscio/v1/mcp.proto +# Protobuf Python Version: 6.33.4 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 33, + 4, + '', + 'capiscio/v1/mcp.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x15\x63\x61piscio/v1/mcp.proto\x12\x0b\x63\x61piscio.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xa9\x02\n\x19\x45valuateToolAccessRequest\x12\x1b\n\ttool_name\x18\x01 \x01(\tR\x08toolName\x12\x1f\n\x0bparams_hash\x18\x02 \x01(\tR\nparamsHash\x12#\n\rserver_origin\x18\x03 \x01(\tR\x0cserverOrigin\x12\x1d\n\tbadge_jws\x18\x04 \x01(\tH\x00R\x08\x62\x61\x64geJws\x12\x19\n\x07\x61pi_key\x18\x05 \x01(\tH\x00R\x06\x61piKey\x12%\n\x0epolicy_version\x18\x06 \x01(\tR\rpolicyVersion\x12\x33\n\x06\x63onfig\x18\x07 \x01(\x0b\x32\x1b.capiscio.v1.EvaluateConfigR\x06\x63onfigB\x13\n\x11\x63\x61ller_credential\"\xb2\x01\n\x0e\x45valuateConfig\x12\'\n\x0ftrusted_issuers\x18\x01 \x03(\tR\x0etrustedIssuers\x12&\n\x0fmin_trust_level\x18\x02 \x01(\x05R\rminTrustLevel\x12*\n\x11\x61\x63\x63\x65pt_level_zero\x18\x03 \x01(\x08R\x0f\x61\x63\x63\x65ptLevelZero\x12#\n\rallowed_tools\x18\x04 \x03(\tR\x0c\x61llowedTools\"\xc5\x03\n\x1a\x45valuateToolAccessResponse\x12\x34\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32\x18.capiscio.v1.MCPDecisionR\x08\x64\x65\x63ision\x12;\n\x0b\x64\x65ny_reason\x18\x02 \x01(\x0e\x32\x1a.capiscio.v1.MCPDenyReasonR\ndenyReason\x12\x1f\n\x0b\x64\x65ny_detail\x18\x03 \x01(\tR\ndenyDetail\x12\x1b\n\tagent_did\x18\x04 \x01(\tR\x08\x61gentDid\x12\x1b\n\tbadge_jti\x18\x05 \x01(\tR\x08\x62\x61\x64geJti\x12\x38\n\nauth_level\x18\x06 \x01(\x0e\x32\x19.capiscio.v1.MCPAuthLevelR\tauthLevel\x12\x1f\n\x0btrust_level\x18\x07 \x01(\x05R\ntrustLevel\x12#\n\revidence_json\x18\x08 \x01(\tR\x0c\x65videnceJson\x12\x1f\n\x0b\x65vidence_id\x18\t \x01(\tR\nevidenceId\x12\x38\n\ttimestamp\x18\n \x01(\x0b\x32\x1a.google.protobuf.TimestampR\ttimestamp\"\xe5\x01\n\x1bVerifyServerIdentityRequest\x12\x1d\n\nserver_did\x18\x01 \x01(\tR\tserverDid\x12!\n\x0cserver_badge\x18\x02 \x01(\tR\x0bserverBadge\x12)\n\x10transport_origin\x18\x03 \x01(\tR\x0ftransportOrigin\x12#\n\rendpoint_path\x18\x04 \x01(\tR\x0c\x65ndpointPath\x12\x34\n\x06\x63onfig\x18\x05 \x01(\x0b\x32\x1c.capiscio.v1.MCPVerifyConfigR\x06\x63onfig\"\xe1\x01\n\x0fMCPVerifyConfig\x12\'\n\x0ftrusted_issuers\x18\x01 \x03(\tR\x0etrustedIssuers\x12&\n\x0fmin_trust_level\x18\x02 \x01(\x05R\rminTrustLevel\x12*\n\x11\x61\x63\x63\x65pt_level_zero\x18\x03 \x01(\x08R\x0f\x61\x63\x63\x65ptLevelZero\x12!\n\x0coffline_mode\x18\x04 \x01(\x08R\x0bofflineMode\x12.\n\x13skip_origin_binding\x18\x05 \x01(\x08R\x11skipOriginBinding\"\x91\x02\n\x1cVerifyServerIdentityResponse\x12\x31\n\x05state\x18\x01 \x01(\x0e\x32\x1b.capiscio.v1.MCPServerStateR\x05state\x12\x1f\n\x0btrust_level\x18\x02 \x01(\x05R\ntrustLevel\x12\x1d\n\nserver_did\x18\x03 \x01(\tR\tserverDid\x12\x1b\n\tbadge_jti\x18\x04 \x01(\tR\x08\x62\x61\x64geJti\x12>\n\nerror_code\x18\x05 \x01(\x0e\x32\x1f.capiscio.v1.MCPServerErrorCodeR\terrorCode\x12!\n\x0c\x65rror_detail\x18\x06 \x01(\tR\x0b\x65rrorDetail\"\xaa\x01\n\x1aParseServerIdentityRequest\x12@\n\x0chttp_headers\x18\x01 \x01(\x0b\x32\x1b.capiscio.v1.MCPHttpHeadersH\x00R\x0bhttpHeaders\x12@\n\x0cjsonrpc_meta\x18\x02 \x01(\x0b\x32\x1b.capiscio.v1.MCPJsonRpcMetaH\x00R\x0bjsonrpcMetaB\x08\n\x06source\"t\n\x0eMCPHttpHeaders\x12.\n\x13\x63\x61piscio_server_did\x18\x01 \x01(\tR\x11\x63\x61piscioServerDid\x12\x32\n\x15\x63\x61piscio_server_badge\x18\x02 \x01(\tR\x13\x63\x61piscioServerBadge\"-\n\x0eMCPJsonRpcMeta\x12\x1b\n\tmeta_json\x18\x01 \x01(\tR\x08metaJson\"\x8a\x01\n\x1bParseServerIdentityResponse\x12\x1d\n\nserver_did\x18\x01 \x01(\tR\tserverDid\x12!\n\x0cserver_badge\x18\x02 \x01(\tR\x0bserverBadge\x12)\n\x10identity_present\x18\x03 \x01(\x08R\x0fidentityPresent\"9\n\x10MCPHealthRequest\x12%\n\x0e\x63lient_version\x18\x01 \x01(\tR\rclientVersion\"\xa4\x01\n\x11MCPHealthResponse\x12\x18\n\x07healthy\x18\x01 \x01(\x08R\x07healthy\x12!\n\x0c\x63ore_version\x18\x02 \x01(\tR\x0b\x63oreVersion\x12#\n\rproto_version\x18\x03 \x01(\tR\x0cprotoVersion\x12-\n\x12version_compatible\x18\x04 \x01(\x08R\x11versionCompatible*Z\n\x0bMCPDecision\x12\x1c\n\x18MCP_DECISION_UNSPECIFIED\x10\x00\x12\x16\n\x12MCP_DECISION_ALLOW\x10\x01\x12\x15\n\x11MCP_DECISION_DENY\x10\x02*\x82\x01\n\x0cMCPAuthLevel\x12\x1e\n\x1aMCP_AUTH_LEVEL_UNSPECIFIED\x10\x00\x12\x1c\n\x18MCP_AUTH_LEVEL_ANONYMOUS\x10\x01\x12\x1a\n\x16MCP_AUTH_LEVEL_API_KEY\x10\x02\x12\x18\n\x14MCP_AUTH_LEVEL_BADGE\x10\x03*\xd3\x02\n\rMCPDenyReason\x12\x1f\n\x1bMCP_DENY_REASON_UNSPECIFIED\x10\x00\x12!\n\x1dMCP_DENY_REASON_BADGE_MISSING\x10\x01\x12!\n\x1dMCP_DENY_REASON_BADGE_INVALID\x10\x02\x12!\n\x1dMCP_DENY_REASON_BADGE_EXPIRED\x10\x03\x12!\n\x1dMCP_DENY_REASON_BADGE_REVOKED\x10\x04\x12&\n\"MCP_DENY_REASON_TRUST_INSUFFICIENT\x10\x05\x12$\n MCP_DENY_REASON_TOOL_NOT_ALLOWED\x10\x06\x12$\n MCP_DENY_REASON_ISSUER_UNTRUSTED\x10\x07\x12!\n\x1dMCP_DENY_REASON_POLICY_DENIED\x10\x08*\xac\x01\n\x0eMCPServerState\x12 \n\x1cMCP_SERVER_STATE_UNSPECIFIED\x10\x00\x12\'\n#MCP_SERVER_STATE_VERIFIED_PRINCIPAL\x10\x01\x12\'\n#MCP_SERVER_STATE_DECLARED_PRINCIPAL\x10\x02\x12&\n\"MCP_SERVER_STATE_UNVERIFIED_ORIGIN\x10\x03*\xd7\x02\n\x12MCPServerErrorCode\x12\x19\n\x15MCP_SERVER_ERROR_NONE\x10\x00\x12 \n\x1cMCP_SERVER_ERROR_DID_INVALID\x10\x01\x12\"\n\x1eMCP_SERVER_ERROR_BADGE_INVALID\x10\x02\x12\"\n\x1eMCP_SERVER_ERROR_BADGE_EXPIRED\x10\x03\x12\"\n\x1eMCP_SERVER_ERROR_BADGE_REVOKED\x10\x04\x12\'\n#MCP_SERVER_ERROR_TRUST_INSUFFICIENT\x10\x05\x12$\n MCP_SERVER_ERROR_ORIGIN_MISMATCH\x10\x06\x12\"\n\x1eMCP_SERVER_ERROR_PATH_MISMATCH\x10\x07\x12%\n!MCP_SERVER_ERROR_ISSUER_UNTRUSTED\x10\x08\x32\x93\x03\n\nMCPService\x12\x65\n\x12\x45valuateToolAccess\x12&.capiscio.v1.EvaluateToolAccessRequest\x1a\'.capiscio.v1.EvaluateToolAccessResponse\x12k\n\x14VerifyServerIdentity\x12(.capiscio.v1.VerifyServerIdentityRequest\x1a).capiscio.v1.VerifyServerIdentityResponse\x12h\n\x13ParseServerIdentity\x12\'.capiscio.v1.ParseServerIdentityRequest\x1a(.capiscio.v1.ParseServerIdentityResponse\x12G\n\x06Health\x12\x1d.capiscio.v1.MCPHealthRequest\x1a\x1e.capiscio.v1.MCPHealthResponseB\xae\x01\n\x0f\x63om.capiscio.v1B\x08McpProtoP\x01ZDgithub.com/capiscio/capiscio-core/pkg/rpc/gen/capiscio/v1;capisciov1\xa2\x02\x03\x43XX\xaa\x02\x0b\x43\x61piscio.V1\xca\x02\x0b\x43\x61piscio\\V1\xe2\x02\x17\x43\x61piscio\\V1\\GPBMetadata\xea\x02\x0c\x43\x61piscio::V1b\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'capiscio.v1.mcp_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + _globals['DESCRIPTOR']._loaded_options = None + _globals['DESCRIPTOR']._serialized_options = b'\n\017com.capiscio.v1B\010McpProtoP\001ZDgithub.com/capiscio/capiscio-core/pkg/rpc/gen/capiscio/v1;capisciov1\242\002\003CXX\252\002\013Capiscio.V1\312\002\013Capiscio\\V1\342\002\027Capiscio\\V1\\GPBMetadata\352\002\014Capiscio::V1' + _globals['_MCPDECISION']._serialized_start=2449 + _globals['_MCPDECISION']._serialized_end=2539 + _globals['_MCPAUTHLEVEL']._serialized_start=2542 + _globals['_MCPAUTHLEVEL']._serialized_end=2672 + _globals['_MCPDENYREASON']._serialized_start=2675 + _globals['_MCPDENYREASON']._serialized_end=3014 + _globals['_MCPSERVERSTATE']._serialized_start=3017 + _globals['_MCPSERVERSTATE']._serialized_end=3189 + _globals['_MCPSERVERERRORCODE']._serialized_start=3192 + _globals['_MCPSERVERERRORCODE']._serialized_end=3535 + _globals['_EVALUATETOOLACCESSREQUEST']._serialized_start=72 + _globals['_EVALUATETOOLACCESSREQUEST']._serialized_end=369 + _globals['_EVALUATECONFIG']._serialized_start=372 + _globals['_EVALUATECONFIG']._serialized_end=550 + _globals['_EVALUATETOOLACCESSRESPONSE']._serialized_start=553 + _globals['_EVALUATETOOLACCESSRESPONSE']._serialized_end=1006 + _globals['_VERIFYSERVERIDENTITYREQUEST']._serialized_start=1009 + _globals['_VERIFYSERVERIDENTITYREQUEST']._serialized_end=1238 + _globals['_MCPVERIFYCONFIG']._serialized_start=1241 + _globals['_MCPVERIFYCONFIG']._serialized_end=1466 + _globals['_VERIFYSERVERIDENTITYRESPONSE']._serialized_start=1469 + _globals['_VERIFYSERVERIDENTITYRESPONSE']._serialized_end=1742 + _globals['_PARSESERVERIDENTITYREQUEST']._serialized_start=1745 + _globals['_PARSESERVERIDENTITYREQUEST']._serialized_end=1915 + _globals['_MCPHTTPHEADERS']._serialized_start=1917 + _globals['_MCPHTTPHEADERS']._serialized_end=2033 + _globals['_MCPJSONRPCMETA']._serialized_start=2035 + _globals['_MCPJSONRPCMETA']._serialized_end=2080 + _globals['_PARSESERVERIDENTITYRESPONSE']._serialized_start=2083 + _globals['_PARSESERVERIDENTITYRESPONSE']._serialized_end=2221 + _globals['_MCPHEALTHREQUEST']._serialized_start=2223 + _globals['_MCPHEALTHREQUEST']._serialized_end=2280 + _globals['_MCPHEALTHRESPONSE']._serialized_start=2283 + _globals['_MCPHEALTHRESPONSE']._serialized_end=2447 + _globals['_MCPSERVICE']._serialized_start=3538 + _globals['_MCPSERVICE']._serialized_end=3941 +# @@protoc_insertion_point(module_scope) diff --git a/capiscio_sdk/_rpc/gen/capiscio/v1/mcp_pb2_grpc.py b/capiscio_sdk/_rpc/gen/capiscio/v1/mcp_pb2_grpc.py new file mode 100644 index 0000000..cac02f6 --- /dev/null +++ b/capiscio_sdk/_rpc/gen/capiscio/v1/mcp_pb2_grpc.py @@ -0,0 +1,214 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +from capiscio_sdk._rpc.gen.capiscio.v1 import mcp_pb2 as capiscio_dot_v1_dot_mcp__pb2 + + +class MCPServiceStub(object): + """MCPService provides unified MCP security operations (RFC-006 + RFC-007) + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.EvaluateToolAccess = channel.unary_unary( + '/capiscio.v1.MCPService/EvaluateToolAccess', + request_serializer=capiscio_dot_v1_dot_mcp__pb2.EvaluateToolAccessRequest.SerializeToString, + response_deserializer=capiscio_dot_v1_dot_mcp__pb2.EvaluateToolAccessResponse.FromString, + _registered_method=True) + self.VerifyServerIdentity = channel.unary_unary( + '/capiscio.v1.MCPService/VerifyServerIdentity', + request_serializer=capiscio_dot_v1_dot_mcp__pb2.VerifyServerIdentityRequest.SerializeToString, + response_deserializer=capiscio_dot_v1_dot_mcp__pb2.VerifyServerIdentityResponse.FromString, + _registered_method=True) + self.ParseServerIdentity = channel.unary_unary( + '/capiscio.v1.MCPService/ParseServerIdentity', + request_serializer=capiscio_dot_v1_dot_mcp__pb2.ParseServerIdentityRequest.SerializeToString, + response_deserializer=capiscio_dot_v1_dot_mcp__pb2.ParseServerIdentityResponse.FromString, + _registered_method=True) + self.Health = channel.unary_unary( + '/capiscio.v1.MCPService/Health', + request_serializer=capiscio_dot_v1_dot_mcp__pb2.MCPHealthRequest.SerializeToString, + response_deserializer=capiscio_dot_v1_dot_mcp__pb2.MCPHealthResponse.FromString, + _registered_method=True) + + +class MCPServiceServicer(object): + """MCPService provides unified MCP security operations (RFC-006 + RFC-007) + """ + + def EvaluateToolAccess(self, request, context): + """RFC-006: Evaluate tool access and emit evidence atomically + Single RPC returns both decision and evidence to avoid partial failures + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def VerifyServerIdentity(self, request, context): + """RFC-007: Verify server identity from disclosed DID + badge + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def ParseServerIdentity(self, request, context): + """RFC-007: Extract server identity from transport headers/meta + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def Health(self, request, context): + """Health check for client supervision and version handshake + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_MCPServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'EvaluateToolAccess': grpc.unary_unary_rpc_method_handler( + servicer.EvaluateToolAccess, + request_deserializer=capiscio_dot_v1_dot_mcp__pb2.EvaluateToolAccessRequest.FromString, + response_serializer=capiscio_dot_v1_dot_mcp__pb2.EvaluateToolAccessResponse.SerializeToString, + ), + 'VerifyServerIdentity': grpc.unary_unary_rpc_method_handler( + servicer.VerifyServerIdentity, + request_deserializer=capiscio_dot_v1_dot_mcp__pb2.VerifyServerIdentityRequest.FromString, + response_serializer=capiscio_dot_v1_dot_mcp__pb2.VerifyServerIdentityResponse.SerializeToString, + ), + 'ParseServerIdentity': grpc.unary_unary_rpc_method_handler( + servicer.ParseServerIdentity, + request_deserializer=capiscio_dot_v1_dot_mcp__pb2.ParseServerIdentityRequest.FromString, + response_serializer=capiscio_dot_v1_dot_mcp__pb2.ParseServerIdentityResponse.SerializeToString, + ), + 'Health': grpc.unary_unary_rpc_method_handler( + servicer.Health, + request_deserializer=capiscio_dot_v1_dot_mcp__pb2.MCPHealthRequest.FromString, + response_serializer=capiscio_dot_v1_dot_mcp__pb2.MCPHealthResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'capiscio.v1.MCPService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('capiscio.v1.MCPService', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class MCPService(object): + """MCPService provides unified MCP security operations (RFC-006 + RFC-007) + """ + + @staticmethod + def EvaluateToolAccess(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/capiscio.v1.MCPService/EvaluateToolAccess', + capiscio_dot_v1_dot_mcp__pb2.EvaluateToolAccessRequest.SerializeToString, + capiscio_dot_v1_dot_mcp__pb2.EvaluateToolAccessResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def VerifyServerIdentity(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/capiscio.v1.MCPService/VerifyServerIdentity', + capiscio_dot_v1_dot_mcp__pb2.VerifyServerIdentityRequest.SerializeToString, + capiscio_dot_v1_dot_mcp__pb2.VerifyServerIdentityResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def ParseServerIdentity(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/capiscio.v1.MCPService/ParseServerIdentity', + capiscio_dot_v1_dot_mcp__pb2.ParseServerIdentityRequest.SerializeToString, + capiscio_dot_v1_dot_mcp__pb2.ParseServerIdentityResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def Health(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/capiscio.v1.MCPService/Health', + capiscio_dot_v1_dot_mcp__pb2.MCPHealthRequest.SerializeToString, + capiscio_dot_v1_dot_mcp__pb2.MCPHealthResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) From e208c293f0a95397fdc9dc1dd4b1e69fc421a1d1 Mon Sep 17 00:00:00 2001 From: Beon de Nood Date: Sat, 17 Jan 2026 19:53:30 -0500 Subject: [PATCH 4/5] ci: skip test_mcp_service.py in integration tests The test_mcp_service.py tests require capiscio-core gRPC server running on localhost:50051, which is not set up in the Docker Compose integration test environment. These tests are better suited for E2E testing with full infrastructure. --- .github/workflows/integration-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index e5dd525..48f2bdf 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -107,6 +107,7 @@ jobs: --ignore=tests/integration/test_dv_badge_flow.py \ --ignore=tests/integration/test_dv_order_api.py \ --ignore=tests/integration/test_dv_sdk.py \ + --ignore=tests/integration/test_mcp_service.py \ -v --tb=short --junit-xml=/workspace/test-results.xml - name: Upload test results From 5f47e49c5c476adb9bb34f77a6dc5a9e6144182f Mon Sep 17 00:00:00 2001 From: Beon de Nood Date: Sat, 17 Jan 2026 20:52:10 -0500 Subject: [PATCH 5/5] docs: Replace misleading MCP docs with accurate gRPC MCPClient guide The previous mcp.md documented a high-level capiscio_sdk.mcp API that doesn't exist. The SDK only provides the low-level gRPC MCPClient via _rpc.client. Changes: - Rewrite mcp.md to document the actual MCPClient gRPC methods - Add note directing users to capiscio-mcp-python for high-level API - Update mkdocs nav title from 'MCP Security' to 'MCP Service (gRPC)' Addresses PR review feedback about documentation/API mismatch. --- docs/guides/mcp.md | 632 ++++++++++++++------------------------------- mkdocs.yml | 2 +- 2 files changed, 198 insertions(+), 436 deletions(-) diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index 756f226..0c35137 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -1,521 +1,283 @@ -# MCP Security Integration +# MCP Service Client (Low-Level gRPC) -**Model Context Protocol (MCP) integration for validating tool access and server identity.** +**Low-level gRPC client for MCP security operations via capiscio-core.** -The CapiscIO Python SDK provides security middleware for MCP tools, implementing: +The CapiscIO Python SDK provides access to MCP security operations (RFC-006 Tool Authority +and RFC-007 Server Identity) through the `MCPClient` gRPC wrapper. -- **RFC-006**: Tool access evaluation based on trust levels -- **RFC-007**: Server identity verification via trust badges +!!! note "Looking for high-level MCP integration?" + This guide documents the **low-level gRPC API** for direct capiscio-core access. + + For a high-level MCP integration library with decorators like `@guard`, see the + **[capiscio-mcp-python](https://github.com/capiscio/capiscio-mcp-python)** package: + + ```bash + pip install capiscio-mcp + ``` -## Installation +## Overview -The MCP module requires the `mcp` extra: +The SDK's `MCPClient` provides direct access to capiscio-core's MCPService gRPC methods: -```bash -pip install capiscio-sdk[mcp] -``` +- **`evaluate_tool_access()`** - RFC-006 §6.2-6.4: Evaluate tool access and emit evidence +- **`verify_server_identity()`** - RFC-007 §7.2: Verify server identity from DID + badge +- **`parse_server_identity_http()`** - RFC-007 §5.2: Extract identity from HTTP headers +- **`parse_server_identity_jsonrpc()`** - RFC-007 §5.3: Extract identity from JSON-RPC _meta +- **`health()`** - Service health and version check ## Quick Start -### Evaluate Tool Access (RFC-006) - -Before allowing a tool to execute, evaluate whether the calling server has sufficient trust: - ```python -from capiscio_sdk.mcp import evaluate_tool_access, TrustLevel, DenyReason - -# Evaluate whether a server can access a tool -result = evaluate_tool_access( - tool_name="file_read", - server_endpoint="https://agent.example.com", - trust_level=TrustLevel.VERIFIED -) - -if result.allow: - # Proceed with tool execution - print(f"Access granted with trust level: {result.trust_level}") -else: - # Handle denial - print(f"Access denied: {result.deny_reason}") -``` - -### Verify Server Identity (RFC-007) - -Verify that an MCP server has a valid trust badge: - -```python -from capiscio_sdk.mcp import verify_server_identity, ServerState - -result = verify_server_identity( - server_endpoint="https://mcp-server.example.com", - expected_did="did:web:example.com" # Optional -) +from capiscio_sdk._rpc.client import CapiscioRPCClient + +# Connect to capiscio-core gRPC server +client = CapiscioRPCClient(address="localhost:50051") +client.connect() + +try: + # Check service health + health = client.mcp.health() + print(f"MCP service: {health['core_version']}") + + # Evaluate tool access (RFC-006) + result = client.mcp.evaluate_tool_access( + tool_name="read_file", + params_hash="sha256:abc123", + server_origin="https://example.com", + badge_jws=badge_token, # Caller's badge + min_trust_level=1, + ) + + if result["decision"] == "allow": + print(f"Access granted for {result['agent_did']}") + else: + print(f"Access denied: {result['deny_reason']}") -if result.state == ServerState.VERIFIED: - print(f"Server verified! DID: {result.did}") - print(f"Trust badge: {result.badge_jws}") -else: - print(f"Verification failed: {result.state}") +finally: + client.close() ``` --- -## Core Functions +## MCPClient Methods ### `evaluate_tool_access()` -Evaluates whether a tool access request should be allowed based on the server's trust level. - -```python -def evaluate_tool_access( - tool_name: str, - server_endpoint: str, - trust_level: TrustLevel, - required_level: TrustLevel = TrustLevel.REGISTERED, - tool_policy: ToolPolicy | None = None, -) -> ToolAccessResult: - """ - Evaluate tool access based on trust level. - - Args: - tool_name: Name of the tool being accessed - server_endpoint: URL of the requesting MCP server - trust_level: Current trust level of the server - required_level: Minimum trust level required (default: REGISTERED) - tool_policy: Optional custom policy for this tool - - Returns: - ToolAccessResult with allow/deny decision and metadata - """ -``` - -#### Parameters - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `tool_name` | `str` | ✅ | Name of the tool being accessed | -| `server_endpoint` | `str` | ✅ | URL of the requesting MCP server | -| `trust_level` | `TrustLevel` | ✅ | Server's current trust level | -| `required_level` | `TrustLevel` | ❌ | Minimum required trust (default: `REGISTERED`) | -| `tool_policy` | `ToolPolicy` | ❌ | Custom access policy for this tool | - -#### Return Value - -`ToolAccessResult` with the following attributes: +Evaluate tool access request (RFC-006 §6.2-6.4). Returns both a decision and evidence record atomically. ```python -@dataclass -class ToolAccessResult: - allow: bool # Whether access is granted - trust_level: TrustLevel # Effective trust level - deny_reason: DenyReason | None # Reason if denied - tool_name: str # Tool that was evaluated - server_endpoint: str # Server that requested access - evaluated_at: datetime # When evaluation occurred +result = client.mcp.evaluate_tool_access( + tool_name="write_file", + params_hash="sha256:...", # Hash of tool parameters for audit + server_origin="https://...", # MCP server origin + badge_jws=badge_token, # Caller's badge (or use api_key) + # api_key="...", # Alternative: API key auth + min_trust_level=2, # Minimum required trust (0-4) + accept_level_zero=False, # Accept self-signed badges? + allowed_tools=["read_file", "write_file"], # Optional allowlist + trusted_issuers=["did:web:capiscio.io"], # Trusted badge issuers +) ``` ---- +**Returns dict with:** + +| Field | Type | Description | +|-------|------|-------------| +| `decision` | `str` | `"allow"` or `"deny"` | +| `deny_reason` | `str` | Reason if denied (e.g., `"trust_insufficient"`) | +| `deny_detail` | `str` | Detailed error message | +| `agent_did` | `str` | DID of authenticated agent | +| `badge_jti` | `str` | Badge JTI if badge was used | +| `auth_level` | `str` | `"anonymous"`, `"api_key"`, or `"badge"` | +| `trust_level` | `int` | Agent's trust level (0-4) | +| `evidence_json` | `str` | RFC-006 §7 evidence record | +| `evidence_id` | `str` | Unique evidence ID | +| `timestamp` | `str` | ISO timestamp | ### `verify_server_identity()` -Verifies an MCP server's identity through its trust badge. +Verify MCP server identity (RFC-007 §7.2). Checks DID + badge and transport origin binding. ```python -def verify_server_identity( - server_endpoint: str, - expected_did: str | None = None, - timeout: float = 10.0, - verify_tls: bool = True, -) -> ServerIdentityResult: - """ - Verify server identity via trust badge. - - Args: - server_endpoint: MCP server URL to verify - expected_did: Optional expected DID for binding verification - timeout: Request timeout in seconds - verify_tls: Whether to verify TLS certificates - - Returns: - ServerIdentityResult with verification state and badge data - """ +result = client.mcp.verify_server_identity( + server_did="did:web:example.com:mcp:server", + server_badge=badge_token, # Server's badge JWT + transport_origin="https://example.com", + endpoint_path="/mcp", + min_trust_level=1, + accept_level_zero=False, + offline_mode=False, # Use cache only? + skip_origin_binding=False, # Skip RFC-007 §5.3 check? +) ``` -#### Parameters - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `server_endpoint` | `str` | ✅ | MCP server URL to verify | -| `expected_did` | `str` | ❌ | Expected DID for binding check | -| `timeout` | `float` | ❌ | Request timeout (default: 10.0s) | -| `verify_tls` | `bool` | ❌ | Verify TLS certs (default: `True`) | +**Returns dict with:** -#### Return Value - -`ServerIdentityResult` with the following attributes: - -```python -@dataclass -class ServerIdentityResult: - state: ServerState # Verification result state - server_endpoint: str # Server that was verified - did: str | None # Resolved DID (if verified) - badge_jws: str | None # Raw badge JWS (if retrieved) - badge_payload: dict | None # Decoded badge payload - error: str | None # Error message (if failed) - verified_at: datetime # When verification occurred -``` - ---- +| Field | Type | Description | +|-------|------|-------------| +| `state` | `str` | `"verified_principal"`, `"declared_principal"`, or `"unverified_origin"` | +| `trust_level` | `int` | Server's trust level (0-4) | +| `server_did` | `str` | Verified server DID | +| `badge_jti` | `str` | Server badge JTI | +| `error_code` | `str` | Error code if verification failed | +| `error_detail` | `str` | Detailed error message | ### `parse_server_identity_http()` -Extract server identity from HTTP response headers. +Parse server identity from HTTP headers (RFC-007 §5.2). ```python -def parse_server_identity_http( - headers: dict[str, str] -) -> ServerIdentity | None: - """ - Parse server identity from HTTP headers. - - Looks for: - - X-CapiscIO-Badge: JWS trust badge - - X-CapiscIO-DID: Server DID - - Args: - headers: HTTP response headers - - Returns: - ServerIdentity if badge found, None otherwise - """ -``` - -#### Example - -```python -import httpx -from capiscio_sdk.mcp import parse_server_identity_http - -response = httpx.get("https://mcp-server.example.com/health") -identity = parse_server_identity_http(dict(response.headers)) +result = client.mcp.parse_server_identity_http( + capiscio_server_did="did:web:example.com:server", + capiscio_server_badge="eyJhbGc...", +) -if identity: - print(f"Server DID: {identity.did}") - print(f"Badge issued: {identity.badge_issued_at}") +if result["identity_present"]: + # Verify the extracted identity + verification = client.mcp.verify_server_identity( + server_did=result["server_did"], + server_badge=result["server_badge"], + transport_origin="https://example.com", + ) ``` ---- - -### `parse_server_identity_jsonrpc()` +**Returns dict with:** -Extract server identity from JSON-RPC response metadata. +| Field | Type | Description | +|-------|------|-------------| +| `server_did` | `str` | Extracted server DID | +| `server_badge` | `str` | Extracted server badge | +| `identity_present` | `bool` | Whether identity was found | -```python -def parse_server_identity_jsonrpc( - response: dict -) -> ServerIdentity | None: - """ - Parse server identity from JSON-RPC response. - - Looks for identity in: - - response["_meta"]["capiscio"] - - response["result"]["_meta"]["capiscio"] - - Args: - response: JSON-RPC response dict - - Returns: - ServerIdentity if found, None otherwise - """ -``` +### `parse_server_identity_jsonrpc()` -#### Example +Parse server identity from JSON-RPC _meta (RFC-007 §5.3). For stdio transport. ```python -from capiscio_sdk.mcp import parse_server_identity_jsonrpc +import json -# JSON-RPC response with embedded identity -response = { - "jsonrpc": "2.0", - "id": 1, - "result": {...}, +meta = json.dumps({ "_meta": { - "capiscio": { - "did": "did:web:example.com", - "badge": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9..." - } + "serverDid": "did:web:example.com", + "serverBadge": "eyJhbGc..." } -} +}) -identity = parse_server_identity_jsonrpc(response) -if identity: - print(f"Verified server: {identity.did}") +result = client.mcp.parse_server_identity_jsonrpc(meta_json=meta) ``` ---- - -## Types Reference - -### TrustLevel +### `health()` -Trust levels in ascending order of privilege: +Check MCP service health and version compatibility. ```python -class TrustLevel(str, Enum): - UNKNOWN = "unknown" # No trust information - REGISTERED = "registered" # Basic registration only - VERIFIED = "verified" # Identity verified - AUDITED = "audited" # Security audit passed - CERTIFIED = "certified" # Full certification -``` - -### DenyReason +result = client.mcp.health(client_version="capiscio-sdk-python/1.0.0") -Reasons for access denial: - -```python -class DenyReason(str, Enum): - INSUFFICIENT_TRUST = "insufficient_trust" # Trust level too low - EXPIRED_BADGE = "expired_badge" # Badge has expired - REVOKED_BADGE = "revoked_badge" # Badge was revoked - INVALID_SIGNATURE = "invalid_signature" # Badge signature invalid - POLICY_VIOLATION = "policy_violation" # Tool policy not satisfied - UNKNOWN_SERVER = "unknown_server" # Server not recognized +print(f"Healthy: {result['healthy']}") +print(f"Core version: {result['core_version']}") +print(f"Proto version: {result['proto_version']}") +print(f"Compatible: {result['version_compatible']}") ``` -### ServerState +--- -Server verification states: +## Trust Levels (RFC-002 §5) -```python -class ServerState(str, Enum): - VERIFIED = "verified" # Successfully verified - UNVERIFIED = "unverified" # No badge found - EXPIRED = "expired" # Badge expired - REVOKED = "revoked" # Badge revoked - INVALID = "invalid" # Invalid badge data - UNREACHABLE = "unreachable" # Could not reach server - TIMEOUT = "timeout" # Request timed out - ERROR = "error" # Other error occurred -``` +| Level | Name | Description | +|-------|------|-------------| +| 0 | Self-Signed | Development only, no verification | +| 1 | Registered | Basic registration with registry | +| 2 | Domain-Validated (DV) | Domain ownership verified | +| 3 | Organization-Validated (OV) | Organization identity verified | +| 4 | Extended-Validation (EV) | Full audit and certification | -### ServerIdentity +--- -Server identity information: +## Deny Reasons -```python -@dataclass -class ServerIdentity: - did: str # Server's DID - badge_jws: str # Raw JWS badge - badge_issued_at: datetime # When badge was issued - badge_expires_at: datetime # When badge expires - trust_level: TrustLevel # Server's trust level -``` +| Reason | Description | +|--------|-------------| +| `badge_missing` | No badge or API key provided | +| `badge_invalid` | Badge signature invalid | +| `badge_expired` | Badge has expired | +| `badge_revoked` | Badge was revoked | +| `trust_insufficient` | Trust level below minimum | +| `tool_not_allowed` | Tool not in allowed list | +| `issuer_untrusted` | Badge issuer not trusted | +| `policy_denied` | Custom policy denied access | -### ToolPolicy +--- -Custom tool access policies: +## Server States -```python -@dataclass -class ToolPolicy: - required_trust_level: TrustLevel # Minimum trust required - allowed_dids: list[str] | None # Allowlist of DIDs - blocked_dids: list[str] | None # Blocklist of DIDs - require_badge: bool # Require valid badge -``` +| State | Description | +|-------|-------------| +| `verified_principal` | Server identity fully verified | +| `declared_principal` | DID declared but not fully verified | +| `unverified_origin` | Could not verify transport origin binding | --- -## Integration Patterns - -### FastAPI Middleware +## Example: Full MCP Flow ```python -from fastapi import FastAPI, Request, HTTPException -from capiscio_sdk.mcp import ( - verify_server_identity, - parse_server_identity_http, - ServerState -) +from capiscio_sdk._rpc.client import CapiscioRPCClient -app = FastAPI() - -@app.middleware("http") -async def mcp_security_middleware(request: Request, call_next): - # Check for CapiscIO badge in headers - identity = parse_server_identity_http(dict(request.headers)) +def mcp_tool_handler(request, badge_token: str): + """Handle an MCP tool call with security validation.""" + + client = CapiscioRPCClient() + client.connect() - if identity: - # Verify the badge - result = verify_server_identity( - server_endpoint=str(request.client.host), - expected_did=identity.did + try: + # 1. Evaluate tool access + access = client.mcp.evaluate_tool_access( + tool_name=request.tool_name, + params_hash=hash_params(request.params), + server_origin=request.origin, + badge_jws=badge_token, + min_trust_level=2, # Require DV or higher ) - if result.state != ServerState.VERIFIED: - raise HTTPException( - status_code=403, - detail=f"Server verification failed: {result.state}" - ) + if access["decision"] != "allow": + return {"error": access["deny_detail"]} - # Attach verified identity to request state - request.state.mcp_identity = identity - - return await call_next(request) -``` - -### Tool Decorator - -```python -from functools import wraps -from capiscio_sdk.mcp import evaluate_tool_access, TrustLevel - -def require_trust(level: TrustLevel): - """Decorator to require minimum trust level for tool access.""" - def decorator(func): - @wraps(func) - def wrapper(tool_name: str, server_endpoint: str, trust_level: TrustLevel, *args, **kwargs): - result = evaluate_tool_access( - tool_name=tool_name, - server_endpoint=server_endpoint, - trust_level=trust_level, - required_level=level - ) - - if not result.allow: - raise PermissionError( - f"Tool '{tool_name}' requires {level.value} trust, " - f"but server has {trust_level.value}: {result.deny_reason}" - ) - - return func(tool_name, server_endpoint, trust_level, *args, **kwargs) - return wrapper - return decorator - -# Usage -@require_trust(TrustLevel.VERIFIED) -def sensitive_tool(tool_name: str, server_endpoint: str, trust_level: TrustLevel): - """This tool requires VERIFIED trust level.""" - return {"result": "sensitive operation completed"} -``` - -### Async Verification - -```python -import asyncio -from capiscio_sdk.mcp import verify_server_identity, ServerState - -async def verify_servers(endpoints: list[str]) -> dict[str, ServerState]: - """Verify multiple servers concurrently.""" - - async def verify_one(endpoint: str) -> tuple[str, ServerState]: - # verify_server_identity is sync, run in executor - loop = asyncio.get_event_loop() - result = await loop.run_in_executor( - None, - verify_server_identity, - endpoint - ) - return endpoint, result.state - - tasks = [verify_one(ep) for ep in endpoints] - results = await asyncio.gather(*tasks) - - return dict(results) - -# Usage -async def main(): - servers = [ - "https://server1.example.com", - "https://server2.example.com", - "https://server3.example.com", - ] - - states = await verify_servers(servers) - for server, state in states.items(): - print(f"{server}: {state}") + # 2. Execute tool (access granted) + result = execute_tool(request.tool_name, request.params) + + # 3. Return with evidence ID for audit trail + return { + "result": result, + "_evidence_id": access["evidence_id"], + } + + finally: + client.close() ``` --- -## Best Practices - -### 1. Cache Verification Results - -Server identity verification involves network calls. Cache results appropriately: +## Related Documentation -```python -from functools import lru_cache -from datetime import datetime, timedelta - -@lru_cache(maxsize=100) -def cached_verify(server_endpoint: str, cache_key: str) -> ServerIdentityResult: - """Cache verification for 5 minutes using time-based cache key.""" - return verify_server_identity(server_endpoint) - -def verify_with_cache(server_endpoint: str) -> ServerIdentityResult: - # Generate cache key that expires every 5 minutes - cache_key = datetime.now().strftime("%Y%m%d%H%M")[:-1] # Truncate to 5min - return cached_verify(server_endpoint, cache_key) -``` +- [Badge Verification Guide](badge-verification.md) - Trust badge operations +- [Configuration Guide](configuration.md) - SDK configuration +- [API Reference](../api-reference.md#mcpclient-rfc-006--rfc-007) - Full MCPClient API -### 2. Fail Secure +## High-Level Alternative -Default to denying access when verification fails: +For high-level MCP integration with decorators and middleware, use **capiscio-mcp-python**: -```python -def secure_tool_access(tool_name: str, server_endpoint: str) -> bool: - try: - result = verify_server_identity(server_endpoint) - return result.state == ServerState.VERIFIED - except Exception as e: - # Log the error but default to DENY - logger.error(f"Verification failed for {server_endpoint}: {e}") - return False +```bash +pip install capiscio-mcp ``` -### 3. Use Tool Policies for Sensitive Operations - ```python -# Define policies for sensitive tools -TOOL_POLICIES = { - "file_write": ToolPolicy( - required_trust_level=TrustLevel.AUDITED, - require_badge=True - ), - "execute_code": ToolPolicy( - required_trust_level=TrustLevel.CERTIFIED, - allowed_dids=["did:web:trusted-partner.com"], - require_badge=True - ), - "read_config": ToolPolicy( - required_trust_level=TrustLevel.VERIFIED, - require_badge=True - ), -} - -def evaluate_with_policy(tool_name: str, server_endpoint: str, trust_level: TrustLevel): - policy = TOOL_POLICIES.get(tool_name) - return evaluate_tool_access( - tool_name=tool_name, - server_endpoint=server_endpoint, - trust_level=trust_level, - tool_policy=policy - ) -``` - ---- +from capiscio_mcp import guard -## Related Documentation - -- [Badge Verification Guide](badge-verification.md) - Core badge verification patterns -- [Configuration Guide](configuration.md) - SDK configuration options -- [Scoring System](scoring.md) - Trust scoring methodology - -## RFC References +@guard(min_trust_level=2) +async def my_tool(param: str) -> str: + """This tool requires Trust Level 2+.""" + return f"Result: {param}" +``` -- [RFC-006: MCP Tool Access Evaluation](/rfcs/rfc-006/) -- [RFC-007: MCP Server Identity Verification](/rfcs/rfc-007/) +See [capiscio-mcp documentation](https://docs.capiscio.io/mcp-python/) for details. diff --git a/mkdocs.yml b/mkdocs.yml index 6ad09ad..8fe4c55 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -64,7 +64,7 @@ nav: - Core Concepts: getting-started/concepts.md - Guides: - Badge Verification: guides/badge-verification.md - - MCP Security: guides/mcp.md + - MCP Service (gRPC): guides/mcp.md - Scoring System: guides/scoring.md - Configuration: guides/configuration.md - API Reference: api-reference.md