fix: use did:web identity in CA-connected mode and handle gRPC failures gracefully#38
Merged
Merged
Conversation
…es gracefully
Three fixes for MCP server identity verification:
1. connect.py: Derive did:web:{domain}:agents:{uuid} after registration
instead of using did:key. The CA issues badges with did:web as subject,
so identity verification requires the server to present the same DID.
2. integrations/mcp.py: Auto-set skip_origin_binding=True for stdio
(subprocess) transports where no HTTP origin exists to bind against.
3. server.py: Catch gRPC errors (UNAVAILABLE) in verify_server() and
return UNVERIFIED_ORIGIN instead of crashing. This allows
fail_on_unverified=False to work correctly when Go core is unavailable.
There was a problem hiding this comment.
Pull request overview
Fixes three production-blocking issues uncovered while preparing the PyCon enforcement demo: a DID-format mismatch between the locally-stored server identity and the CA-issued badge subject, an ORIGIN_MISMATCH for stdio (subprocess) MCP transports, and an unhandled AioRpcError when the capiscio-core sidecar is unavailable.
Changes:
- In
connect.py, derive adid:web:<domain>:agents:<uuid>after registration and overwritedid.txtso the server's advertised identity matches the badge subject. - In
integrations/mcp.py, auto-enableskip_origin_bindingfor stdio transports (whencommandis provided) unless an explicitverify_configis supplied. - In
server.py, wrapCoreClient.get_instance()and theVerifyServerIdentityRPC in try/except, returningUNVERIFIED_ORIGIN+BADGE_INVALIDon failure.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| capiscio_mcp/connect.py | Adds _derive_did_web helper and a Step 4 that overwrites the stored DID with the CA-format did:web after registration. |
| capiscio_mcp/integrations/mcp.py | Replaces the unconditional default VerifyConfig with one that sets skip_origin_binding=True for stdio transports. |
| capiscio_mcp/server.py | Adds broad except Exception blocks around core-client acquisition and the gRPC call, returning a degraded VerifyResult instead of raising. |
Comments suppressed due to low confidence (3)
capiscio_mcp/server.py:197
- Catching the bare
Exceptionhere will also swallow programming errors (e.g.,AttributeError,ImportErrorfrom the proto import below, or unexpected exceptions fromCoreClient.get_instance()) and turn them into a benign-lookingUNVERIFIED_ORIGINresult with a warning. Since the stated goal is to handle gRPC transport failures gracefully, prefer narrowing to the specific gRPC exception types (e.g.,grpc.aio.AioRpcError,ConnectionError,OSError) so that genuine bugs continue to surface during development and CI.
except Exception as exc:
logger.warning("Cannot reach capiscio-core for server verification: %s", exc)
return VerifyResult(
state=ServerState.UNVERIFIED_ORIGIN,
server_did=server_did,
error_code=ServerErrorCode.BADGE_INVALID,
error_detail=f"capiscio-core unavailable: {exc}",
)
# Import proto
from capiscio_mcp._proto.capiscio.v1 import mcp_pb2
# Build request
request = mcp_pb2.VerifyServerIdentityRequest(
server_did=server_did,
server_badge=server_badge or "",
transport_origin=transport_origin or "",
endpoint_path=endpoint_path or "",
config=mcp_pb2.VerifyConfig(
trusted_issuers=effective_config.trusted_issuers or [],
min_trust_level=effective_config.min_trust_level,
accept_level_zero=effective_config.accept_level_zero,
offline_mode=effective_config.offline_mode,
skip_origin_binding=effective_config.skip_origin_binding,
),
)
# Make RPC call
try:
response = await client.stub.VerifyServerIdentity(request)
except Exception as exc:
logger.warning("gRPC call to VerifyServerIdentity failed: %s", exc)
return VerifyResult(
state=ServerState.UNVERIFIED_ORIGIN,
server_did=server_did,
error_code=ServerErrorCode.BADGE_INVALID,
error_detail=f"verification RPC failed: {exc}",
)
capiscio_mcp/connect.py:544
- The unconditional
did_file.write_text(did)here silently overwrites whatever DID Step 2 wrote (e.g., thedid:keyderived from the loaded private key). If a user later runs the server in a non-CA-connected configuration (e.g., callssetup_server_identitystandalone or uses a different code path that readsdid.txt), they will get the staledid:webinstead of thedid:keymatching their persisted keypair. Consider only writing the file when this code path is actually CA-connected (e.g., when registration succeeded /effective_api_keyis set) and/or storing the did:web in a separate file (e.g.,did_web.txt) so that the underlying did:key is not clobbered.
did = _derive_did_web(server_url, server_id)
did_file.write_text(did)
logger.info("Server identity DID (CA-connected): %s", did)
capiscio_mcp/connect.py:544
- Step 4 unconditionally overwrites
didwith the deriveddid:webeven when registration failed (theexcept RegistrationErrorbranch on lines 515–526 only re-raises for non-409 status codes; for network errorsstatus_codeisNoneand the function continues). In that "registration was not actually confirmed" path, the server will now advertise adid:webwhose subject was never registered with the CA, and badge issuance will likely produce a subject that doesn't match either. Consider gating Step 4 on a successful registration (or onorg_idhaving been populated) so the stored DID always reflects the CA's view.
# ------------------------------------------------------------------
# Step 4: Derive did:web identity (CA-connected mode)
# ------------------------------------------------------------------
# The CA issues badges with sub = did:web:{domain}:agents:{uuid}.
# For identity verification to pass, the server must present the same
# did:web — NOT the did:key from key generation. The keypair is still
# used for PoP signing, but the identity DID is always did:web when
# connected to a CA.
did = _derive_did_web(server_url, server_id)
did_file.write_text(did)
logger.info("Server identity DID (CA-connected): %s", did)
Comment on lines
+534
to
+544
| # ------------------------------------------------------------------ | ||
| # Step 4: Derive did:web identity (CA-connected mode) | ||
| # ------------------------------------------------------------------ | ||
| # The CA issues badges with sub = did:web:{domain}:agents:{uuid}. | ||
| # For identity verification to pass, the server must present the same | ||
| # did:web — NOT the did:key from key generation. The keypair is still | ||
| # used for PoP signing, but the identity DID is always did:web when | ||
| # connected to a CA. | ||
| did = _derive_did_web(server_url, server_id) | ||
| did_file.write_text(did) | ||
| logger.info("Server identity DID (CA-connected): %s", did) |
Comment on lines
158
to
+197
| @@ -176,7 +185,16 @@ async def verify_server( | |||
| ) | |||
|
|
|||
| # Make RPC call | |||
| response = await client.stub.VerifyServerIdentity(request) | |||
| try: | |||
| response = await client.stub.VerifyServerIdentity(request) | |||
| except Exception as exc: | |||
| logger.warning("gRPC call to VerifyServerIdentity failed: %s", exc) | |||
| return VerifyResult( | |||
| state=ServerState.UNVERIFIED_ORIGIN, | |||
| server_did=server_did, | |||
| error_code=ServerErrorCode.BADGE_INVALID, | |||
| error_detail=f"verification RPC failed: {exc}", | |||
| ) | |||
Comment on lines
+687
to
+696
| # For stdio transports (subprocess-based), origin binding is not | ||
| # applicable — there is no HTTP origin to bind against. Auto-skip | ||
| # the check unless the caller provided an explicit verify_config. | ||
| if verify_config is not None: | ||
| self.verify_config = verify_config | ||
| else: | ||
| self.verify_config = VerifyConfig( | ||
| min_trust_level=min_trust_level, | ||
| skip_origin_binding=(command is not None), | ||
| ) |
beonde
added a commit
that referenced
this pull request
May 15, 2026
beonde
added a commit
that referenced
this pull request
May 15, 2026
* chore: bump version to 2.7.1 * fix: bump CORE_MIN_VERSION to 2.7.1 and capture stderr on core exit * fix: update connect tests to expect did:web after PR #38 changes * fix: add server_url to badge failure test * fix: add changelog compare links for 2.7.0 and 2.7.1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes three bugs causing MCP server identity verification to fail/crash in the enforcement demo (PyCon prep):
1.
connect.py— did:web identity derivationThe CA issues badges with
sub = did:web:{domain}:agents:{uuid}, butconnect()was storing adid:keyas the server's identity. When the client verified the server, the DID in_metadidn't match the badge subject →DID_MISMATCH.Fix: After registration (Step 3), derive the
did:webidentity matching the CA's format and write it todid.txt. The keypair is still used for PoP signing.2.
integrations/mcp.py— skip origin binding for stdio transportsFor subprocess-based (stdio) MCP servers, there is no HTTP origin to bind against. The default
RequireOriginBinding=truecausedORIGIN_MISMATCHfor all stdio transports.Fix: Auto-set
skip_origin_binding=Truewhencommandis provided (stdio transport), unless the caller provides an explicitverify_config.3.
server.py— graceful gRPC error handlingWhen the Go core sidecar is unavailable (exits with code 2, socket closed),
verify_server()raised an unhandledAioRpcError. This crashed the client even whenfail_on_unverified=False.Fix: Wrap both
CoreClient.get_instance()and the gRPC call in try/except. On failure, returnUNVERIFIED_ORIGINwith a descriptive error detail instead of propagating the exception.Testing
Priority
PyCon demo — May 15, 2026.