Skip to content

fix: use did:web identity in CA-connected mode and handle gRPC failures gracefully#38

Merged
beonde merged 1 commit into
mainfrom
fix/did-web-identity-and-grpc-resilience
May 15, 2026
Merged

fix: use did:web identity in CA-connected mode and handle gRPC failures gracefully#38
beonde merged 1 commit into
mainfrom
fix/did-web-identity-and-grpc-resilience

Conversation

@beonde
Copy link
Copy Markdown
Member

@beonde beonde commented May 14, 2026

Summary

Fixes three bugs causing MCP server identity verification to fail/crash in the enforcement demo (PyCon prep):

1. connect.py — did:web identity derivation

The CA issues badges with sub = did:web:{domain}:agents:{uuid}, but connect() was storing a did:key as the server's identity. When the client verified the server, the DID in _meta didn't match the badge subject → DID_MISMATCH.

Fix: After registration (Step 3), derive the did:web identity matching the CA's format and write it to did.txt. The keypair is still used for PoP signing.

2. integrations/mcp.py — skip origin binding for stdio transports

For subprocess-based (stdio) MCP servers, there is no HTTP origin to bind against. The default RequireOriginBinding=true caused ORIGIN_MISMATCH for all stdio transports.

Fix: Auto-set skip_origin_binding=True when command is provided (stdio transport), unless the caller provides an explicit verify_config.

3. server.py — graceful gRPC error handling

When the Go core sidecar is unavailable (exits with code 2, socket closed), verify_server() raised an unhandled AioRpcError. This crashed the client even when fail_on_unverified=False.

Fix: Wrap both CoreClient.get_instance() and the gRPC call in try/except. On failure, return UNVERIFIED_ORIGIN with a descriptive error detail instead of propagating the exception.

Testing

$ cd a2a-demos/enforcement-demo && python run_demo.py --auto
All 5 scenarios passed. ✓

Priority

PyCon demo — May 15, 2026.

…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.
Copilot AI review requested due to automatic review settings May 14, 2026 22:23
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

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 a did:web:<domain>:agents:<uuid> after registration and overwrite did.txt so the server's advertised identity matches the badge subject.
  • In integrations/mcp.py, auto-enable skip_origin_binding for stdio transports (when command is provided) unless an explicit verify_config is supplied.
  • In server.py, wrap CoreClient.get_instance() and the VerifyServerIdentity RPC in try/except, returning UNVERIFIED_ORIGIN+BADGE_INVALID on 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 Exception here will also swallow programming errors (e.g., AttributeError, ImportError from the proto import below, or unexpected exceptions from CoreClient.get_instance()) and turn them into a benign-looking UNVERIFIED_ORIGIN result 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., the did:key derived from the loaded private key). If a user later runs the server in a non-CA-connected configuration (e.g., calls setup_server_identity standalone or uses a different code path that reads did.txt), they will get the stale did:web instead of the did:key matching their persisted keypair. Consider only writing the file when this code path is actually CA-connected (e.g., when registration succeeded / effective_api_key is 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 did with the derived did:web even when registration failed (the except RegistrationError branch on lines 515–526 only re-raises for non-409 status codes; for network errors status_code is None and the function continues). In that "registration was not actually confirmed" path, the server will now advertise a did:web whose 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 on org_id having 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 thread capiscio_mcp/connect.py
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 thread capiscio_mcp/server.py
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 beonde merged commit b3d69f8 into main May 15, 2026
6 of 14 checks passed
@beonde beonde deleted the fix/did-web-identity-and-grpc-resilience branch May 15, 2026 07:03
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants