Chio/Docs
LOGIN · JOIN

Listeners & Edges

Expose governed tools to MCP, A2A, and ACP clients, with the same capability checks and signed receipts behind every protocol.

Concept chio@5f63e6e

On this page

MCP, A2A, and ACP clients all reach the same kernel. Each edge translates its protocol’s discovery and calls into the kernel’s execution model, then returns the signed decision in the caller’s protocol, so authority is checked the same way whichever protocol carries the request.

MCP has two listeners in the CLI: chio mcp serve over stdio and chio mcp serve-http over Streamable HTTP. The A2A and ACP edges are libraries. A host constructs ChioA2aEdge or ChioAcpEdge from validated manifests and serves their JSON-RPC values over its own transport.

Three protocols reach one kernel

MCP’s tools/call enters the kernel directly. A2A and ACP calls converge on the cross-protocol orchestrator, which narrows the capability and plans a route before the kernel evaluates.

Protocol edgesChoose a route.
tools/callmessage/sendtool/invokeattenuatedsignsa1MCP clienta2A2A clienta3ACP clientd1MCP edged2A2A edged3ACP edgeσorchestratorKρreceipts1registered targets2tool serverattenuatedsignsa1MCP clienta2A2A clienta3ACP clientd1MCP edged2A2A edged3ACP edgeσorchestratorKρreceipts1registered targets2tool server

Three protocols reach one kernel

Each edge translates its protocol’s discovery and calls into the kernel’s execution model, then returns the signed decision in the caller’s protocol. MCP calls enter the kernel directly. A2A and ACP calls pass through the cross-protocol orchestrator first. The Mediated Call.

Three edges over one kernel, with the route each protocol’s governed call takes. The passthrough is drawn from the ACP edge, and the A2A edge carries the same route; discovery, deferred task methods, and output checks after dispatch are left out.
EdgeGoverned callDeferred methodsHosted by
MCPtools/calltasks/list, tasks/get, tasks/result, tasks/cancelchio mcp serve, chio mcp serve-http
A2Amessage/sendmessage/stream, then task/get or task/cancelYour host, over its transport
ACPtool/invoketool/stream, then tool/resume or tool/cancelYour host, over its transport

The A2A and ACP stream methods defer: a stream call stores the request as a working task, and the follow-up call runs it once through the orchestrator and returns the terminal payload, on that poll and every later one. Each response carries a runtimeLifecycle record that states the delivery mode, so a client reads it instead of guessing.

Read more in Bridges.

A bridged call is narrowed before the kernel sees it

Both bridged edges reach the kernel through CrossProtocolOrchestrator::execute, each supplying only a CapabilityBridge for its envelope. The orchestrator checks the request before any lineage exists.

  1. Identity. origin_request_id, kernel_request_id, target_server_id, target_tool_name, and agent_id must be non-empty, unpadded, and free of control characters. They are never trimmed, so signed lineage describes exactly what the caller sent.
  2. Lineage. A caller-supplied capability reference is cross-checked against the active capability’s id, its parent hash, and the bridge’s source_protocol; a protocol mismatch is refused even when id and hash match.
  3. Attenuation. The scope is cut to the target server and tool, and the call is refused unless the result is a subset of the parent.
crates/protocol/chio-cross-protocol/src/orchestrator.rsrust
let attenuated_scope = attenuate_scope_for_tool(
    &request.capability.scope,
    &request.target_server_id,
    &request.target_tool_name,
);
if !attenuated_scope.is_subset_of(&request.capability.scope) {
    return Err(BridgeError::InvalidAttenuation(
        "attenuated scope must remain a strict subset of the parent capability".to_string(),
    ));
}

attenuate_scope_for_tool keeps only the grants whose server and tool match the target, or match by *, rewrites the wildcard to the exact pair, and drops resource and prompt grants. What crosses a protocol hop is narrower than the parent capability by construction, and its envelope carries schema chio.cross-protocol-cap.v1.

A refused route still returns a signed receipt

Route planning runs before dispatch and returns one of three decisions: Select keeps the requested target, Attenuate falls back to another available one, and Deny refuses.

Preference, native fallback, and a ban on projected protocols come from a chioControlPlane object in the governed intent’s context, read under camelCase and snake_case keys. The planner selects a non-native target only when an executor is registered for it, and with no candidate available it fails closed to Deny. A deny is a signed response: the kernel signs it through sign_planned_deny_response, and the orchestrator still assembles route and trace evidence.

Every governed result comes back as one OrchestratedToolCall, whose metadata() renders the chio object each edge attaches to its protocol response: the receipt and its id, the decision (allow, deny, or pending_approval), authorityPath fixed at cross_protocol_orchestrator, authoritative: true, and the capability envelope, route, and trace records. Each edge also records one chio_receipt_write_total outcome per terminal response.

Read more in Route Plans, which covers the signed route plan.

An edge advertises only what its protocol can represent

A manifest carries permissions, effects, and protocol meaning as well as tool names. BridgeFidelity grades each projection, and the A2A and ACP edges withhold a tool their protocol cannot carry from its discovery document.

crates/protocol/chio-cross-protocol/src/semantic_hints.rs8-13rust
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum BridgeFidelity {
    Lossless,
    Adapted { caveats: Vec<String> },
    Unsupported { reason: String },
}

A withheld tool stays queryable: bridge_fidelity(id) returns its recorded verdict, including Unsupported entries, with a reason naming the cause. A published but lossy tool carries its caveats on Adapted; on A2A, a stream-capable tool’s chunks are collated into the terminal task payload.

The MCP edge lists every tool in the manifests it validates, and a duplicate tool name across manifests fails construction with ManifestError::DuplicateToolName. To keep a tool from MCP clients, leave it out of the manifest you hand that edge.

The compatibility passthrough is a declared bypass

For bounded migration and tests, the A2A and ACP edges each carry a route that calls a raw dyn ToolServerConnection without the kernel. It evaluates no capability, runs no guard, signs nothing, and moves no receipt-write counter.

The route compiles only under cfg(test) or the compatibility-surface feature, which neither crate enables by default, and a host reaches it only through the wrapper that compatibility() returns: ChioA2aEdgeCompatibility or ChioAcpEdgeCompatibility. Its responses label themselves with authoritative: false, receiptBearing: false, and null receipt fields.

Keep passthrough results visibly apart from governed ones in the host and its interface. In a build with the feature enabled, a flat receipt-write counter is unexplained rather than idle, because passthrough traffic never appears in that series.

Edge reference

Discovery per edge

CrateDiscoveryBlocking call
chio-mcp-edge
tools/list over McpExposedTool
tools/call
chio-a2a-edge
AgentCard, one A2aSkillEntry per published skill
message/send
chio-acp-edge
session/list_capabilities over AcpCapability
tool/invoke

All three take manifests in, return kernel-signed receipt metadata, and write the shared receipt-write metric for governed outcomes. The hello-a2a and hello-acp examples serve the bridged edges over stdin; serve_stdio in examples/hello-a2a/src/lib.rs is the loop.

Deferred tasks

On A2A, message/stream allocates a task id and stores a Working DeferredA2aTask without calling the kernel; task/get executes the stored request once through the orchestrator, writes the terminal TaskResponse back into the table, and returns that stored value on every later poll. ACP has the same shape with tool/resume in place of task/get. Both edges check owner_agent_id against the calling agent_id before serving or changing a task.

Each table lives in process memory and holds at most 1024 tasks in a non-terminal status (MAX_DEFERRED_A2A_TASKS, and the ACP pair of the same shape). A sweep before every stream, poll, and cancel prunes tasks past a 5 minute TTL (DEFERRED_A2A_TASK_TTL_MILLIS). ensure_deferred_task_capacity counts only non-terminal tasks, so terminal ones are bounded by the TTL sweep alone, and a restart clears both tables.

Lifecycle records

runtime_lifecycle_contract returns a fixed record per case, embedded in response metadata under runtimeLifecycle.

crates/protocol/chio-cross-protocol/src/lifecycle.rsrust
RuntimeLifecycleSurface::AcpAuthoritative => RuntimeLifecycleContract {
    surface: "acp_authoritative".to_string(),
    blocking_entrypoint: "tool/invoke".to_string(),
    stream_entrypoint: "tool/stream".to_string(),
    follow_up_entrypoint: "tool/resume".to_string(),
    cancel_entrypoint: "tool/cancel".to_string(),
    stream_delivery: "resumed_terminal_payload".to_string(),
    partial_output_delivery: "resumed_terminal_payload".to_string(),
    claim_eligible: true,
    compatibility_only: false,
},

There are four cases: A2aAuthoritative, A2aCompatibility, AcpAuthoritative, and AcpCompatibility. The compatibility records report claim_eligible: false, compatibility_only: true, and "unsupported" for the stream, follow-up, and cancel entrypoints. They reuse the authoritative blocking entrypoint string, so branch on claimEligible, never on the method name.

Fidelity hints

Hints come from the tool definition and from x-chio-* extensions on its input or output schema, read in that order by semantic_hints_for_tool. published_by_default() is !matches!(self, Self::Unsupported { .. }).

ExtensionFieldDefault when absent
x-chio-publish
publish
true
x-chio-approval-required
approval_required
false
x-chio-streaming
streams_output
True when latency_hint is Moderate or Slow
x-chio-cancellation
supports_cancellation
True when latency_hint is Slow
x-chio-partial-output
partial_output
Whatever streams_output resolved to

The sixth extension, x-chio-target-protocol, resolves through parse_discovery_protocol, which trims and lowercases, to one of six families: native, http, mcp, a2a, acp, and open_ai (it also accepts openai). Absent, it takes the registry default, which both bridged edges set to Native. Both register two executors, McpTargetExecutor from chio-mcp-edge and OpenAiTargetExecutor, in authoritative_target_registry, so supports_target_protocol holds for native, mcp, and open_ai. A tool naming http, a2a, or acp resolves to Unsupported and is withheld from A2A and ACP discovery; an unparseable value fails edge construction. The MCP edge reads no x-chio-target-protocol and lists the tool.

Publication gates per edge

EdgeWithheld from discoveryName collision across manifests
chio-a2a-edge
x-chio-publish=false, an unregistered target protocol, or approval_required set
Qualified as {server_id}::{tool_name} with a chio:collision-qualified tag
chio-acp-edge
x-chio-publish=false, an unregistered target, AcpCategory::Browser, or a side-effecting AcpCategory::Tool
Withheld; fidelity becomes Unsupported
chio-mcp-edge
Nothing beyond manifest and schema validation
Construction fails: ManifestError::DuplicateToolName

The A2A edge withholds approval-required tools because it cannot project an interactive approval prompt. A second identical qualified id gets a #n ordinal suffix, and looking up the bare name returns an ambiguity error that lists the qualified ids. On ACP, a collision also removes any capability or binding already recorded for that name; re-declaring the identical server and tool pair is not a collision. The MCP edge’s build_exposed_tool_bindings validates each manifest and schema and publishes every remaining tool, without consulting BridgeFidelity, semantic_hints_for_tool, or x-chio-publish.

The MCP projection carries readOnlyHint and destructiveHint, derived from has_side_effects and always each other’s inverse, plus an execution block with taskSupport: "optional" and, when a latency hint exists, a suggestedLatency label.

Route and receipt evidence

The chio metadata object also carries a receiptRef with trace and bridge ids, the terminal state, and the execution nonce. trace_id and session_fingerprint are SHA-256 over canonical JSON: the first over the request and route identity, the second over agent, capability, source protocol, and bridge id.

Route selection evidence and any caller-supplied receipt_context are threaded into an executed kernel call, so they land inside the signed receipt metadata. A route deny carries the route evidence without the caller’s receipt_context, because metadata_with_source_receipt_context runs on an executed target only. The MCP edge appears on the far side of the orchestrator as McpTargetExecutor, the registered executor for an mcp target.

A multi-hop route writes one receipt-write outcome, at the source edge. McpTargetExecutor::execute calls bridge_mcp_tool_call_from_response with record_receipt_write set to false, so an ACP or A2A call routed to an MCP target advances the source edge’s counter alone. Both edges build their McpTargetExecutor as a static with peer_supports_chio_tool_streaming: false, so a bridged MCP hop does not negotiate chunk streaming.

Passthrough metadata

crates/protocol/chio-acp-edge/src/conversion.rsrust
#[cfg(any(test, feature = "compatibility-surface"))]
fn passthrough_metadata(reason: Option<&str>) -> Value {
    json!({
        "chio": {
            "receiptId": Value::Null,
            "receipt": Value::Null,
            "decision": "passthrough",
            "capabilityId": Value::Null,
            "authorityPath": "passthrough_compatibility",
            "authoritative": false,
            "compatibilityOnly": true,
            "claimEligible": false,
            "receiptBearing": false,
            "reason": reason,
        }
    })
}

The A2A block adds a runtimeLifecycle record from runtime_lifecycle_metadata(RuntimeLifecycleSurface::A2aCompatibility) and a lifecycle object reporting messageSend: "blocking_terminal_task" and messageStream: "unsupported". On ACP, session/list_capabilities attaches compatibility_surface_metadata(): receiptBearingInvoke: false, the compatibility lifecycle record, and unsupportedLifecycleMethods naming tool/stream, tool/cancel, and tool/resume, each of which returns JSON-RPC -32601 with a chio data block repeating authoritative: false.

The A2A passthrough refuses message/stream with -32601 through jsonrpc_stream_not_supported, a bare message with no chio data block. evaluate_permission_passthrough, on ACP only, answers from configuration: allow when the published capability needs no permission, deny when it does or the id is unknown, with no capability token consulted. invoke_passthrough and handle_send_message_passthrough call no record_receipt_write* helper.

Both edges cross from async into sync through block_on_tool_server_invoke, which mirrors the kernel’s runtime-flavor gate: block_in_place on a multi-thread runtime, futures::executor::block_on with no runtime, and a typed SyncBridgeIncompatibleWithCurrentThreadRuntime error under a current-thread runtime. The ACP call site turns that error into a failed passthrough response with the same metadata.

Kernel-backed permission preview

session/request_permission on the authoritative ACP path runs evaluate_permission_with_kernel, which checks the capability signature, its validity window, its subject against the calling agent, and the request against the matched grant, and calls verify_dpop_for_permission_preview when the grant requires sender binding. It returns permission_preview_metadata with authoritative: false and previewOnly: true, because it leaves the DPoP nonce for the later tool/invoke to spend. The two previews differ in invokeAuthorityPath: cross_protocol_orchestrator for the kernel-backed one, passthrough_compatibility for the passthrough.

Constructing an edge

The running binary decides which protocols a node terminates, and an embedded host decides by the edge types it constructs: ChioA2aEdge::new(A2aEdgeConfig, Vec<ToolManifest>) or ChioAcpEdge::new(AcpEdgeConfig, Vec<ToolManifest>), with the host owning the socket or pipe. Every manifest passes chio_manifest::validate_manifest first, and one failure rejects the edge.

The A2A config’s agent name, version, endpoint URL, and protocol binding must each be non-empty without leading or trailing whitespace; a violation fails construction. The ACP config has two fields: require_permission (default true) and default_category (default AcpCategory::Tool). A published capability’s requires_permission is config.require_permission || tool.has_side_effects.

An edges entry in chio.yaml declares an edge: an id, a protocol string, and the expose_from adapter it publishes. validate_edges checks that ids are unique and non-empty and that expose_from names a declared adapter. Listeners start from the command line or from an edge a host constructs. The rest of the file is in chio.yaml Configuration.

chio.yamlyaml
adapters:
  - id: "petstore"
    protocol: "openapi"
    upstream: "http://localhost:8000"
    spec: "./petstore.yaml"

edges:
  - id: "mcp-bridge"
    protocol: "mcp"
    expose_from: "petstore"

Both bridged edges ship an empty default feature set. compatibility-surface compiles the passthrough into a non-test build. otel on chio-a2a-edge and chio-mcp-edge enables the GenAI span helpers. fuzz on chio-acp-edge and chio-mcp-edge exposes a libFuzzer entry point over the decode-then-dispatch pipeline, enabled only by the standalone fuzz workspace. An audit of what a node terminates covers the enabled features as well as the command line.

Receipt-write metrics

Every edge writes chio_receipt_write_total, declared once in chio-metrics-spec as a counter with a single outcome label. chio-edge-metrics re-exports the registered name and implements the counter.

crates/protocol/chio-edge-metrics/src/lib.rs34-40rust
/// Stable exporter order for receipt-write outcome samples.
pub const RECEIPT_WRITE_OUTCOMES: [ReceiptWriteOutcome; 4] = [
    ReceiptWriteOutcome::Allow,
    ReceiptWriteOutcome::Deny,
    ReceiptWriteOutcome::PendingApproval,
    ReceiptWriteOutcome::Error,
];
LabelWritten whenA failure
allow
Verdict::Allow reaches the edge sink
No
deny
Verdict::Deny; a refusal is a successful write
No
pending_approval
Verdict::PendingApproval, normal approval flow
No
error
No verdict obtained, or an unrecognized label passed to record
Yes

The MCP edge records kernel errors through record_receipt_write_kernel_error, which excludes RequestCancelled and UrlElicitationsRequired, both retryable or caller-driven. The bridged edges use record_receipt_write_bridge_error, which counts only BridgeError::Kernel; an unregistered target protocol, a malformed envelope, or a failed attenuation returns an error and writes no counter.

Each edge holds its own static COUNTERS: ReceiptWriteCounters = ReceiptWriteCounters::new();. Increments use fetch_update with saturating_add, so a counter caps at u64::MAX, pinned by receipt_write_counters_saturate_instead_of_wrapping. record and total map an unknown label to the error bucket, so total("totally-unknown") returns the error count.

render_acp_edge_metrics_prometheus(ReceiptWriterLiveness::Wedged)text
# HELP chio_receipt_write_total Total receipt write outcomes after policy or guard evaluation.
# TYPE chio_receipt_write_total counter
chio_receipt_write_total{outcome="allow"} 128
chio_receipt_write_total{outcome="deny"} 9
chio_receipt_write_total{outcome="pending_approval"} 3
chio_receipt_write_total{outcome="error"} 0
# TYPE chio_receipt_writer_healthy gauge
chio_receipt_writer_healthy 0
# TYPE chio_receipt_writer_liveness gauge
chio_receipt_writer_liveness{state="wedged"} 1

The shape, the order, and the two gauge lines are what the renderer emits, byte-stable across scrapes. chio_receipt_writer_healthy renders ReceiptWriterLiveness::healthy() as 0 or 1, and that predicate is matches!(self, Self::Healthy | Self::Unknown): a host that passes Unknown reports healthy, so read the liveness line beside it. The five states are healthy, saturated, wedged, dead, and unknown.

render_mcp_edge_metrics_prometheus, render_a2a_edge_metrics_prometheus, and render_acp_edge_metrics_prometheus each return the exposition as a String, and the host that embeds an edge serves it on its scrape route. deploy/prometheus/chio-recording-rules.yml defines chio:receipt_write_error_ratio_5m, _1h, and _6h as the rate of outcome="error" over the clamped rate of the family. chio-alert-rules.yml pages at severity: p1 on a two-window burn (ChioReceiptWriteErrorBudgetBurn1h) and on absent_over_time(chio_receipt_write_total[10m]) (ChioReceiptWriteMetricsMissing), which fires while the exposition is unserved.

receipt_write_recording_rules_only_count_infrastructure_errors reads the rule files as text and fails the build if pending_approval appears in them or the family is counted as outcome!="success": denials and pending approvals are normal traffic and stay out of the error budget. Node Observability covers the families a node exports on its own.

Edge conformance tests

  • acp_edge_emits_chio_receipt_write_total and a2a_edge_emits_chio_receipt_write_total in crates/tooling/chio-conformance/tests/metrics_registry_consumed.rs: a call routed to an MCP target advances the source edge’s allow counter by exactly one (acp_before_target + 1) and leaves the MCP edge’s unchanged.
  • permission_preview_accepts_valid_dpop_without_consuming_invocation_nonce in chio-acp-edge/src/tests/all.rs: the preview mirrors invoke-time DPoP policy and leaves the nonce unspent.
  • strict_nonce_retries_require_and_accept_stable_request_ids in chio-acp-edge/src/tests/nonce_preflight.rs: under strict execution nonces, a retry needs a stable request id.
  • receipt_write_recording_rules_only_count_infrastructure_errors: pending_approval stays out of the error-budget numerator.

Next: run a remote MCP edge