Agent Client Protocol Rust SDK
This repository contains the Rust SDK for the Agent-Client Protocol (ACP).
For Users
If you want to build something with these crates, see the rustdoc:
agent-client-protocol- Core SDK for building clients, agents, and proxiesagent-client-protocol-cookbook- Practical patterns and examplesagent-client-protocol-conductor- Running proxy chains
The agent-client-protocol crate includes a concepts module that explains how connections, sessions, callbacks, and message ordering work.
For Maintainers and Agents
This book documents the design and architecture for people working on the codebase itself.
Repository Structure
src/
├── agent-client-protocol/ # Core protocol SDK
├── agent-client-protocol-http/ # HTTP/SSE/WebSocket transport
├── agent-client-protocol-rmcp/ # Integration with rmcp crate
├── agent-client-protocol-cookbook/ # Usage patterns (rendered as rustdoc)
├── agent-client-protocol-derive/ # Proc macros
├── agent-client-protocol-conductor/ # Conductor binary and library
├── agent-client-protocol-polyfill/ # MCP-over-ACP transport compatibility
├── agent-client-protocol-test/ # Test utilities and fixtures
├── agent-client-protocol-trace-viewer/ # Trace visualization tool
└── yopo/ # "You Only Prompt Once" example client
Crate Relationships
graph TD
acp[agent-client-protocol<br/>Core SDK, stdio, process spawning]
http[agent-client-protocol-http<br/>HTTP/SSE/WebSocket transport]
rmcp[agent-client-protocol-rmcp<br/>rmcp integration]
conductor[agent-client-protocol-conductor<br/>Proxy orchestration]
polyfill[agent-client-protocol-polyfill<br/>MCP transport compatibility]
trace[agent-client-protocol-trace-viewer<br/>Trace visualization]
cookbook[agent-client-protocol-cookbook<br/>Usage patterns]
http --> acp
rmcp --> acp
conductor --> acp
conductor --> trace
polyfill --> acp
cookbook --> acp
cookbook --> rmcp
cookbook --> conductor
Key Design Documents
- Core Library Design - How the core crate and its transport and integration crates are organized
- Transport Architecture - The frame-aware boundary shared by transports and in-process components
- Conductor Design - How the conductor orchestrates proxy chains
- Protocol Reference - Wire protocol details and extension methods
- MCP Bridge - Adapting native MCP-over-ACP for HTTP-capable agents
- Original P/ACP Design Proposal - Historical design context; not the current wire reference
- Migrating to v2.0 - Upgrade guide from 1.x to 2.0
- Migrating to v0.11 - Upgrade guide from 0.10.x to 0.11
Core Library Design
This document describes the design of the agent-client-protocol crate and its companion transport and integration crates.
For API usage, see the rustdoc and cookbook.
Crate Organization
agent-client-protocol
The core SDK. Provides:
- Role types (
Client,Agent,Proxy,Conductor) - the identities in ACP - Connection builders (
builder(),connect_to(),connect_with()) - Message handling (
on_receive_request,on_receive_notification,on_receive_dispatch) - Protocol types (
agent_client_protocol::schema::*) - all ACP message types - Transports and process launching (
Channel,Lines,ByteStreams,Stdio,AcpAgent) - MCP server attachment - runtime-agnostic interfaces for wiring MCP servers into ACP sessions through the opt-in
unstable_mcp_over_acptransport
agent-client-protocol-http
Optional HTTP/SSE and WebSocket clients and servers built on the core transport-frame boundary.
agent-client-protocol-rmcp
Integration with the rmcp crate:
McpServer::builder()- define MCP tools in Rust codeMcpServer::from_rmcp()- wrap an rmcp server as an ACP MCP server
Standalone rmcp-backed servers need no ACP transport feature. Enable the
integration crate’s unstable_mcp_over_acp feature to advertise an attached
server as McpServer::Acp. Agents limited to HTTP MCP transports require the
separate compatibility polyfill.
Role System
The type system is built around roles - the logical identity of an endpoint.
graph LR
Client -->|connects to| Agent
Agent -->|connects to| Client
Proxy -->|connects to| Conductor
Conductor -->|connects to| Proxy
Counterpart Relationship
Each role has exactly one counterpart - who it connects to:
| Role | Counterpart |
|---|---|
Client | Agent |
Agent | Client |
Proxy | Conductor |
Conductor | Proxy |
This is encoded in the type system: impl ConnectTo<Client> for MyAgent means “MyAgent can connect to a client” (i.e., MyAgent plays the Agent role).
Peer Relationship
Some roles can communicate with multiple peers. The Proxy role is the key example:
graph TB
subgraph "Proxy's view"
Proxy
Client[Client peer]
Agent[Agent peer]
Conductor[Conductor counterpart]
end
Proxy -.->|"send_to(Client, ...)"| Client
Proxy -.->|"send_to(Agent, ...)"| Agent
Proxy -->|"connect_to(conductor)"| Conductor
- Counterpart (Conductor) - who the proxy connects to (transport layer)
- Peers (Client, Agent) - who the proxy exchanges logical messages with
Message Flow
Dispatch Loop
Each connection runs a dispatch loop that processes incoming messages:
sequenceDiagram
participant Transport
participant DispatchLoop
participant Handlers
participant UserCode
Transport->>DispatchLoop: incoming TransportFrame
DispatchLoop->>Handlers: try handlers in order
alt Handler matches
Handlers->>UserCode: invoke callback
UserCode-->>Handlers: return result
else No handler matches
Handlers->>DispatchLoop: default handler
end
Handler Chain
Handlers are tried in registration order. The first matching handler wins:
graph TD
Message[Incoming Message]
H1[Handler 1: InitializeRequest]
H2[Handler 2: PromptRequest]
H3[Handler 3: catch-all]
Message --> H1
H1 -->|not InitializeRequest| H2
H2 -->|not PromptRequest| H3
H3 --> Done[Handle or error]
H1 -->|matches| Process1[Process Initialize]
H2 -->|matches| Process2[Process Prompt]
Ordering Guarantees
The dispatch loop provides sequential processing:
- Messages are processed one at a time
- A handler runs to completion before the next message is processed
- Spawned tasks (
connection.spawn()) run concurrently with the dispatch loop
Important: Don’t block the dispatch loop. Use spawn() for long-running work.
Connection Lifecycle
stateDiagram-v2
[*] --> Building: builder()
Building --> Building: on_receive_*()
Building --> Connected: connect_to(transport)
Building --> Connected: connect_with(transport, closure)
Connected --> Running: dispatch loop starts
Running --> [*]: connection closes
Two Connection Modes
Reactive mode (connect_to): The connection runs handlers until the incoming transport reaches clean EOF, drains responses and notifications already accepted by the outgoing queue through the transport sink, then returns Ok(()), including when the builder has long-running with_spawned work. Used for agents and proxies.
Active mode (connect_with): Runs a closure with access to the connection, then closes. Used for clients that drive the interaction. Incoming EOF fails requests that still need responses, but it does not automatically cancel unrelated work in the closure.
Clean Incoming EOF
Incoming EOF is a connection event and a request-liveness boundary:
- Every pending request is completed with an internal error whose data identifies
incoming_transport_closedand the request method;is_incoming_transport_closed()detects it. - A request created after EOF fails immediately with the same error.
ConnectionTo::incoming_closed()waits for the close event, andis_incoming_closed()reports whether it has completed.Builder::on_close()runs cleanup callbacks in registration order. Returning an error terminates a still-runningconnect_withforeground; returningOk(())leaves its lifetime under application control.
This keeps request correctness separate from async cancellation policy. Applications can finish cleanup or notify a central dispatcher without having an arbitrary foreground future dropped at an await point. Pending requests are failed before close callbacks begin; the close signal is published after callbacks finish, so a callback must not await incoming_closed() itself.
Key Source Files
| File | Purpose |
|---|---|
src/agent-client-protocol/src/role.rs | Role trait and type definitions |
src/agent-client-protocol/src/role/acp.rs | Client, Agent, Proxy, Conductor roles |
src/agent-client-protocol/src/component.rs | ConnectTo component abstraction |
src/agent-client-protocol/src/jsonrpc.rs | Connection builder and frame types |
src/agent-client-protocol/src/jsonrpc/handlers.rs | Handler chain implementation |
src/agent-client-protocol/src/jsonrpc/transport_actor.rs | Line framing and JSON parsing |
src/agent-client-protocol/src/util/typed.rs | Dispatch typing and matching helpers |
src/agent-client-protocol/src/mcp_server/ | Runtime-agnostic MCP server attachment |
src/agent-client-protocol/src/concepts/ | Rustdoc concept explanations |
Design Decisions
Why Roles Instead of Links?
Earlier versions used “link types” that encoded both sides (e.g., ClientToAgent). Roles are simpler:
- One concept instead of two (role vs link)
- Role types double as peer selectors (
send_to(Agent, ...)) - Clearer mental model: “I am X, connecting to Y”
Why Witness Macros?
The on_receive_request!() macros work around Rust’s lack of return-type notation. They capture the return type of closures at the call site, enabling type inference to work.
Why Not Traits for Handlers?
Handler closures are more ergonomic than trait implementations for most use cases. The HandleDispatchFrom trait exists for advanced cases (reusable handler components).
Cargo Features
Starting with the upcoming 3.x release, the core agent-client-protocol
crate enables no features by default. Protocol serialization, connections,
sessions, custom MCP servers, and the Channel, Lines, and ByteStreams
adapters remain available without native I/O or JSON Schema generation.
[dependencies]
agent-client-protocol = "3"
You do not need default-features = false for a lean core dependency.
Native transports
| Feature | Provides | Native dependencies |
|---|---|---|
process | AcpAgent and AcpAgentConfig for launching agents | async-process, async-io, shell-words, platform process support |
stdio | Stdio for serving an agent or proxy over standard input/output | blocking |
These features are independent. process handles the child process’s stdio
itself and does not enable stdio; stdio does not enable process spawning.
LineDirection, used by native debug callbacks, is exported with either.
For a client that launches an external agent:
[dependencies]
agent-client-protocol = { version = "3", features = ["process"] }
For an agent that uses Stdio::new():
[dependencies]
agent-client-protocol = { version = "3", features = ["stdio"] }
The conductor explicitly enables both native features. Other libraries using only generic transports need neither. The native dependencies and exports remain excluded on WebAssembly, even when these features are enabled.
JSON Schema generation and typed tools
Opt into schemars to generate JSON Schemas for protocol types and typed tools:
[dependencies]
agent-client-protocol = { version = "3", features = ["schemars"] }
This enables the SDK’s direct dependency and the matching
agent-client-protocol-schema feature. Without it, protocol types still support
serialization and deserialization. Within mcp_server, these typed APIs require
schemars:
McpToolMcpToolRegistry,RegisteredMcpTool, andEnabledToolsMcpToolMetadataandMcpToolSchema- The
tool_fnandtool_fn_mutfunctions
McpServer, McpServerConnect, McpConnectionTo, and McpConnectionContext
remain available without it. MCP-over-ACP attachment still only requires its
protocol feature, not JSON Schema generation.
The agent-client-protocol-rmcp integration explicitly enables schemars for
its tool builders. HTTP and polyfill transports do not enable it themselves.
Cargo features are additive: another dependency that requests a feature can
enable it for the same core crate, even if your direct dependency omits it.
Transport and tooling dependencies
Internal crates request only the core capabilities they use:
| Consumer | Core features requested |
|---|---|
| HTTP client/server | None; transport sides remain opt-in through client and server |
| MCP-over-ACP polyfill | unstable_mcp_over_acp, with independent draft-v2 and session-fork passthrough features |
| rmcp integration | schemars, with independent draft-v2 and MCP-over-ACP passthrough features |
| Conductor | process, stdio, and unstable_mcp_over_acp for launching components, serving stdio, and classifying MCP traces |
| YOPO | process for launching the agent |
| Test utilities | process and stdio for fixture binaries and launch helpers |
Conductor integration tests and cookbook recipes explicitly enable the rmcp
integration’s unstable_mcp_over_acp feature when attaching native servers.
Conductor tests request schemars separately from production dependencies, and
enable draft-v2 polyfill support only with the conductor’s unstable_protocol_v2
feature. Internal consumers disable test-utility defaults rather than enabling
the core’s entire unstable aggregate incidentally.
The Axum consumers share HTTP1/Tokio serving and tracing/tower-log observability, but do not enable its default extractor features. The HTTP server opts into WebSockets, the polyfill opts into JSON responses, and HTTP tests opt into JSON extraction. The trace viewer serializes its responses directly and requires neither. Axum macros are not needed. Tracing subscribers retain environment filtering and text output without JSON log formatting.
The core SDK enables the futures executor only for tests and doctests. The
conductor’s Tokio/futures compatibility adapter is test-only, and the test
utilities need it only for the arrow_proxy example. The rmcp integration keeps
compatibility adapters and cancellation tokens in production; rmcp itself still
enables the futures executor transitively.
Draft protocol features
Unstable protocol features are independent of the native and schema features. For example, to use draft v2 and MCP-over-ACP without JSON Schema generation:
[dependencies]
agent-client-protocol = { version = "3", features = ["unstable_protocol_v2", "unstable_mcp_over_acp"] }
The unstable aggregate does not select draft v2; enable unstable_protocol_v2
explicitly when using that version.
Session notices and context compaction are stable in schema 1.11 and available
without feature flags in v1. Draft v2 only needs unstable_protocol_v2 for these
surfaces. The former unstable_session_notices and unstable_session_compaction
SDK features have been removed. Applications still negotiate support through
the corresponding protocol capabilities.
The unstable_subagents feature exposes the schema’s subagent capabilities,
updates, and session-message types. It is also included in unstable. The
existing session/update notification route carries subagent updates in v1 and,
with unstable_protocol_v2, draft v2. This is schema support, not a subagent
scheduler or an implementation of application-level ownership and lifecycle
policy.
Schema 1.12 adds unstable_session_system_prompt, also included in unstable.
It exposes SystemPrompt and the session system-prompt capability types in v1
and, with unstable_protocol_v2, v2. Send it on the complete lifecycle request
through build_session_from, load_session_from (v1), resume_session_from,
or fork_session_from (v2, with unstable_session_fork). Local feature flags
do not negotiate peer support: only send systemPrompt when the agent
advertises it, and only use override when mode.override is advertised.
See Client-provided system prompts.
WebAssembly and the rmcp integration
The core library compiles for wasm32-wasip1 and wasm32-wasip2 with default
features. No WASI feature or default-feature opt-out is required. For
JavaScript-hosted wasm32-unknown-unknown, enable wasm_js; it selects Web
Crypto through wasm-bindgen for UUID randomness. The target does not imply a
JavaScript host, so this feature remains opt-in.
The rmcp integration library also compiles for both WASI targets, including
native MCP-over-ACP attachment support. Its production dependencies use Tokio’s
single-thread-capable runtime and async I/O utilities, not Tokio stdio or the
multithreaded runtime. Native examples and tests enable those conveniences
separately. An application using rmcp macros or stdio transports opts into the
corresponding features on its own rmcp dependency.
Neither library provides a WASI executor or host I/O adapter. Supply a compatible runtime and transport; see Transport Architecture. This portability does not extend to the native HTTP server, polyfill, or conductor crates.
Migrating from 2.x defaults
A dependency that previously relied on native transports and typed tools:
# Before
agent-client-protocol = "2.2"
# After: select only the capabilities your application actually uses.
agent-client-protocol = { version = "3", features = ["process", "stdio", "schemars"] }
A protocol-only application can instead use agent-client-protocol = "3".
Applications that already disabled defaults must still enable process or
stdio if they use those adapters: native I/O used to be unconditional on
native targets, independently of defaults.
Core examples declare their required features explicitly. For the v2 example
pair, build with --features process,stdio,unstable_protocol_v2. The v1
simple_agent requires stdio, while yolo_one_shot_client requires process.
SDK Protocol Reference
This chapter documents shared schema features, the proxy extension implemented
by the Rust SDK’s conductor, and the opt-in native MCP-over-ACP transport.
The proxy methods are provisional SDK extensions. MCP-over-ACP is also
unstable and is available only with the unstable_mcp_over_acp feature.
Client-provided system prompts
Schema 1.12 exposes draft client-authored instructions with the
unstable_session_system_prompt feature. In v1, check
agentCapabilities.sessionCapabilities.systemPrompt; in v2, check
capabilities.session.systemPrompt. Omitted or null means unsupported.
An object advertises support for text content in append mode. Only request
override if the agent also advertises systemPrompt.mode.override.
Use the version’s SystemPrompt::new builder and pass the complete request
through the SDK’s _from session helpers:
#![allow(unused)]
fn main() {
use agent_client_protocol::schema::v1;
let instructions = v1::SystemPrompt::new(vec![
"Review changes without modifying files.".into(),
]);
let request = v1::NewSessionRequest::new("/workspace/project")
.system_prompt(instructions);
// After checking the agent's advertised capability:
// connection.build_session_from(request).block_task().start_session().await?;
let _ = request;
}
This snippet requires unstable_session_system_prompt; v2 additionally
requires unstable_protocol_v2 and uses the equivalent v2 types.
SystemPrompt::new defaults to append in Rust, but both mode and content
are required on the wire. Omitted or null systemPrompt supplies no client
instructions. Malformed values fail typed request decoding with -32602;
unknown modes are preserved for forwarding, not treated as append.
Clients should re-send the prompt on load (v1), resume, and fork. Agents that advertise support must apply a supplied prompt or reject the lifecycle operation, not silently create or restore a session without it. Override replaces agent-authored default instructions, not tools, permissions, or workspace instructions. Only supply content trusted at system-instruction precedence. Enabling the local Cargo feature does not advertise peer support.
Method Summary
| Method | JSON-RPC shape | Purpose |
|---|---|---|
_proxy/initialize | request | Initialize a component as a proxy |
_proxy/successor | request or notification | Forward one inner ACP message to the next component |
mcp/message | request or notification | Invoke one MCP operation or carry its notification |
There are no separate request and notification method names for successor or
MCP message forwarding. The presence of an outer JSON-RPC id distinguishes a
request from a notification.
Raw JSON-RPC Responses
Transport frames carry protocol-neutral RawJsonRpcResponse values. Their
boxed RawJsonRpcError preserves numeric error codes, distinguishes omitted
data from explicit null, and retains error extension fields. Raw relays
should forward these values without ACP interpretation.
Typed ACP dispatch converts raw errors to Error explicitly using
RawJsonRpcError::into_acp_error; ACP error codes are interpreted at that
boundary and error extension fields are discarded. RawJsonRpcMessage::response
still accepts an ACP Result<Value, Error> and converts its error to the raw
representation. Other protocols should construct RawJsonRpcResponse directly.
Proxy Initialization
The conductor sends _proxy/initialize to a component that has a successor.
Its parameters are the same fields as the normal InitializeRequest for the
selected ACP version. Receiving this method, rather than initialize, tells the
component that it is running as a proxy and may forward messages with
_proxy/successor.
The response is the matching version’s normal InitializeResponse result. The
stable flat schema::InitializeProxyRequest type uses v1; with
unstable_protocol_v2, schema::v2::InitializeProxyRequest preserves the v2
request and response types. The final agent receives the ordinary initialize
method and does not need to understand the proxy extension.
Successor Forwarding
_proxy/successor wraps one inner ACP method and its parameters. The inner
message is flattened into the outer parameters:
{
"jsonrpc": "2.0",
"id": 12,
"method": "_proxy/successor",
"params": {
"method": "session/prompt",
"params": {
"sessionId": "session-1",
"prompt": []
}
}
}
The conductor unwraps the message and sends the inner request to the next
component. The outer response carries the inner request’s result or error. To
forward an inner notification, omit the outer id; no response is produced.
Optional extension metadata may be included as _meta alongside the flattened
inner message.
Native MCP-over-ACP
Enable unstable_mcp_over_acp to use the draft native transport. A component
providing an MCP server adds McpServer::Acp to session setup requests
(session/new, session/load, session/resume, and the opt-in session/fork).
Its wire shape contains a human-readable name and an opaque server identifier:
{
"type": "acp",
"name": "project-tools",
"serverId": "mcp-server:01"
}
serverId identifies the declared server and is used to route mcp/message
back to the component that provided it. A provider must not reuse one server ID
for multiple visible servers on the same ACP connection. The high-level
agent_client_protocol::mcp_server::McpServer APIs create this declaration
automatically.
An agent that consumes this transport advertises
agentCapabilities.mcpCapabilities.acp. If the final agent supports HTTP but
not ACP-transport MCP servers, place the MCP-over-ACP compatibility
bridge immediately before it.
mcp/message
An agent-to-provider request invokes one MCP operation. A provider-to-agent
notification belongs to that active operation. This is an ACP envelope choice,
not an MCP method-name convention: the outer id distinguishes message kind,
and inner methods such as tools/call and notifications/progress stay distinct.
Reverse requests are not part of this binding.
{
"jsonrpc": "2.0",
"id": 21,
"method": "mcp/message",
"params": {
"serverId": "mcp-server:01",
"requestId": "logical-request:01",
"method": "tools/call",
"params": {
"name": "example",
"arguments": {},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
}
The logical ID stays stable through proxies; the outer ACP ID is hop-local.
Each inner request declares its version and capabilities. This binding exposes
MCP 2026-07-28 only, without initialize or a discovery prerequisite.
{
"jsonrpc": "2.0",
"id": 21,
"result": {
"result": { "content": [] }
}
}
Successful ACP responses carry exactly one inner result or error.
An MCP error’s code, omitted/null data, and extensions retain MCP meaning.
Outer ACP errors describe binding failures. MRTR’s input_required remains an
inner result; subscriptions remain active requests with scoped notifications.
Cancellation uses ACP’s existing best-effort mechanism. The Rust SDK supervises owned cleanup, but that stronger implementation behavior is not an extra requirement for advertising the transport capability.
See the native guide and migration guide. The previous connection IDs and connect/disconnect methods are removed.
Related Documentation
Native MCP-over-ACP
The native binding exposes MCP 2026-07-28 over an existing ACP connection.
Enable unstable_mcp_over_acp; draft ACP v2 also requires
unstable_protocol_v2. The SDK uses the released schema 1.10.1.
Services and operations
Attach an mcp_server::McpServer through the existing global or session builder
APIs. Session setup advertises an opaque serverId. Each mcp/message request
addresses that server directly; there is no MCP connect/disconnect exchange,
initialization handshake, or discovery prerequisite.
McpService is a reusable application service. Its execute method receives a
McpRequest and a request-scoped McpRequestContext. The context exposes:
- The declared server ID and logical request ID.
- Validated MCP metadata, including version and client capabilities.
- Caller cancellation and operation cancellation caused by removal or shutdown.
- An operation-scoped notification sender.
- The host ACP connection for application tools.
Share caches, tool catalogs, and database pools deliberately. Do not infer a request’s identity or capabilities from a previous call.
Use McpServer::new_service for native-only execution, or
new_service_with_standalone to also serve a separate standalone transport.
The connector-based constructor remains an adapter for backends that need a
fresh component per operation.
The rmcp builder and from_rmcp use a shared native service and rmcp’s
one-request transport. Standalone serving retains its ordinary MCP lifecycle.
Scoped tool_fn and tool_fn_mut closures remain supported; cancelling a call
must not poison the runner or start already-cancelled queued work.
Calling a server
Use MessageMcpRequest::new(server_id, request_id, method). Choose a fresh
logical ID for every operation and include these inner parameters:
{
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
The logical ID stays unchanged through proxies and becomes the inner MCP JSON-RPC ID. It is separate from the outer ACP ID, which can change on each hop.
Register a MessageMcpNotification handler before starting an operation that
streams notifications. Route notifications by server and logical request ID.
Use spawned work or the application’s connection future to wait for peer
traffic; do not block the ACP dispatcher.
Results and errors
First handle the outer ACP result, then match MessageMcpResponse:
Result { result, .. }contains an opaque MCP result, including JSON null, metadata, and MRTR’sinput_requiredresult.Error { error, .. }contains an MCP error. Preserve its numeric code, data omission versus explicit null, and unknown extensions.- An outer ACP error describes a binding failure, not an MCP method outcome.
An inner MCP -32000 is not ACP authentication-required. Tool execution
failures with isError remain MCP results. For MRTR, retry with a fresh logical
ID, current metadata, input responses, and the unchanged opaque request state.
ACP v1 and v2 have independent carrier/error types. Their wire representations currently agree; use the types for the negotiated ACP version.
Cancellation and cleanup
A subscription is one long-running request, not shared connection state.
Acknowledgments and updates carry the logical request ID in
_meta["io.modelcontextprotocol/subscriptionId"].
ACP cancellation is best effort on the wire. The Rust SDK revokes an owned operation’s notification rights, cancels its work, and joins supported cleanup. The logical ID remains active until cleanup finishes; completion may win a cancellation race.
Mutable function tools serialize user execution, not cancellation of queued work. A cancelled queued call is destroyed without entering its closure or waiting for another registration’s active call. Recoverable session-local runner failures close only that session’s registrations; they do not seal connection-wide admission.
Custom services must observe operation_cancellation() and return only after
owned cleanup finishes. This also applies when their registration is removed,
the ACP input closes, or the application foreground completes. The SDK keeps
native supervisors and scoped runners driven during that cleanup. It cannot
roll back arbitrary detached application work.
Example and scope
cargo run -p agent-client-protocol-rmcp \
--example stateless_native_mcp \
--features unstable_mcp_over_acp,unstable_protocol_v2
See the wire reference, HTTP re-export, and migration guide.
This change does not redesign generic ACP queues, frame types, task admission, or byte accounting. Native MCP protocol state is request-scoped; that is not a claim of bounded total process memory or complete optional MCP conformance.
Sending Requests
Both ConnectionTo and V2ConnectionTo support two request APIs:
send_request(request)sends immediately and returns aSentRequest. Keep this when eager publication is useful, such as starting work before choosing how to consume its result. Existing behavior is unchanged.prepare_request(request)returns aPreparedRequestwithout sending. Choose this when response handling must be selected before the peer can reply, especially for ordered callbacks from outside the connection future.
Both have an explicit-peer variant: send_request_to(peer, request) and
prepare_request_to(peer, request).
Publication means synchronously registering the pending reply and enqueueing the request. Peer transformation and physical transmission happen later in the connection driver; successful publication does not acknowledge peer receipt.
Select handling before publication
#![allow(unused)]
fn main() {
connection.prepare_request(request).on_receiving_result(async move |result| {
application_queue.enqueue(result)?;
Ok(())
})?;
}
The callback task and ordering marker are installed before the request enters the outgoing queue. When the peer response is routed during its original dispatch, the loop waits for the callback to finish before dispatching later messages. Success and error responses have the same ordering.
on_receiving_ok_result(responder, callback) and
forward_response_to(responder) also select ordered handling before sending.
map(...) and forward_cancellation_from(...) configure a prepared request
without publishing it.
Keep ordered callbacks short. They must not await another response, notification, or other inbound traffic on the same connection: dispatch cannot deliver that traffic until the callback returns. Enqueue application work and return, or spawn follow-up work.
Eager send_request(...).on_receiving_result(...) retains its existing
conditional guarantee: if response routing wins the race with registration,
the callback runs without a barrier. Even immediate chaining can race when the
connection runs concurrently. Neither API imposes a barrier on EOF-generated
failures or on a retained ResponseRouter routed after its original dispatch.
Unordered consumption
prepare_request(...).block_task() sends during the method call, then
returns a response future. It does not wait for the first poll to send:
#![allow(unused)]
fn main() {
let response = connection.prepare_request(request).block_task();
connection.send_notification(notification)?; // Queued after the request.
let result = response.await?;
}
Await that future only outside incoming handlers. Dispatch does not wait for the caller to process its result.
prepare_request(...).detach()? sends and discards the eventual response
without cancelling. It does not select ordered consumption.
Outgoing order follows publication, not preparation. A notification sent between preparation and consumption enters the queue before the request.
Cancellation after selecting a consumer
Obtain cancellation_handle() before consuming a prepared or sent request to
retain explicit cancellation control alongside its callback or response future:
#![allow(unused)]
fn main() {
let prepared = connection.prepare_request(request);
let cancellation = prepared.cancellation_handle();
prepared.on_receiving_result(async move |result| {
application_queue.enqueue(result)?;
Ok(())
})?;
caller.on_abandoned(move || cancellation.cancel());
}
Hand cancellation control to the caller’s abandonment handler only after publication returns, and have that handler deal with any immediate error. Merely dropping the public handle does not cancel. The callback owns eventual completion and any late-resource cleanup independently of the caller.
Cancelling does not discard the eventual result. The cloneable handle shares
SDK settlement and once-only cancellation state, rather than sending an
unconditional notification for a saved request ID. Dropping the handle does
nothing. Calls before publication or after a response/local failure do nothing.
Detachment suppresses automatic cancellation but preserves retained explicit
control until settlement. Ok(()) is not acknowledgment that cancellation was
sent or took effect. See Request Cancellation for
the full contract, including publication races and peer wrapping.
Drop and errors
Dropping an unconsumed PreparedRequest sends nothing. Dropping the response
future returned by block_task() asks the peer to cancel an outstanding request,
as dropping an eager SentRequest does.
Preparation and publication failures reach the selected callback or response
future. Callback registration failure returns an error without sending the
request. detach() returns immediate preparation and enqueue failures directly;
later local transformation errors and peer response errors are discarded.
Transport failures still propagate through the connection future. Returning an
error from a callback terminates the connection, so handle expected request
errors inside it.
See Ordered Application Dispatch for extending these guarantees to a separate application executor.
Request Cancellation
This chapter documents the $/cancel_request protocol-level notification and
how the SDK implements it.
For API usage (cancelling a SentRequest, observing cancellation from a
Responder, or retaining a RequestCancellationHandle alongside a callback),
see the concepts::cancellation chapter in the
agent-client-protocol rustdoc.
The $/cancel_request Notification
Either side of a connection may send $/cancel_request to ask the peer to
cancel one outstanding JSON-RPC request, identified by its ID:
{
"jsonrpc": "2.0",
"method": "$/cancel_request",
"params": {
"requestId": "70b9f1c9-c2a3-4bd2-b6b9-65a06d96b675"
}
}
requestId is the JSON-RPC id of the request to cancel, as allocated by the
sender of that request (a string, number, or null).
Semantics
Cancellation is cooperative. After receiving $/cancel_request, the peer
may:
- ignore it and respond to the request normally,
- finish early with whatever data it has, or
- respond to the original request with the standard cancellation error,
code
-32800(“Request cancelled”).
The requesting side always receives a response to the original request;
cancellation only changes which response that is. A $/cancel_request for
an unknown or already-completed request ID is silently ignored. A
$/cancel_request with malformed params (for example, a requestId that is
not a string, number, or null) is logged and ignored without a reply, like any
other malformed notification.
Interoperability
Protocol-level ($/-prefixed) notifications are optional by design. The SDK
ignores unhandled notifications instead of rejecting them with a
method-not-found error. A peer that sends $/cancel_request to a component
that does not support cancellation therefore loses nothing: the request simply
runs to completion.
Dropping an unconsumed SentRequest asks the peer to cancel it. Use
SentRequest::detach() for requests whose eventual response should be ignored,
but which should continue running on the peer. The peer is still expected to
answer the JSON-RPC request eventually; use a notification instead when no
response is expected at all.
Retaining Cancellation Control
Before consuming a PreparedRequest or SentRequest, call
cancellation_handle() to retain a cloneable RequestCancellationHandle.
Its cancel() method requests cancellation independently of response consumption:
an ordered callback or response future still receives the eventual result.
The handle shares once-only cancellation and settlement state with
SentRequest::cancel, forwarded cancellation, and automatic request-drop
cancellation. It uses the same peer and proxy wrapping. Dropping the handle
neither sends cancellation nor disables automatic request-drop cancellation.
To cancel when a caller is abandoned, its application-owned guard or abandonment
handler must explicitly call cancel() and handle any immediate error.
Calling it before a prepared request is published does nothing and is not remembered for later publication. A call racing publication may also do nothing; hand control to independent cancellers after the publishing method returns. Once the SDK routes a response or fails the request, new cancellation calls do nothing—even if the application has not consumed the result yet. An attempt begun before settlement may still enqueue afterward.
Detachment is separate from settlement: detach() discards the response and
suppresses automatic cancellation on drop, but does not revoke retained explicit
handles. They can still cancel the detached request while it remains pending.
Ok(()) means the call encountered no immediate error, not that a notification
was sent or the peer stopped work. Cancellation is cooperative; it does not
guarantee transmission, peer cooperation, or a successful response. A handle does
not abort local callback work or keep the connection driver/response consumer alive.
Request Cancellation Is Not Session Cancellation
$/cancel_request targets one pending JSON-RPC request, not the lifetime of the
session work it may start. In ACP v2, session/prompt succeeds once the user
message is inserted and returns its messageId. After insertion the Agent must
return success, not -32800, even if cancellation races the response. Stopping
active session work uses session/cancel. See the
v2 cancellation specification
and prompt lifecycle.
Proxy Chains
Cancellation propagates hop by hop rather than end to end. Request IDs are
allocated per connection, so a $/cancel_request only ever refers to a
request on the connection it is sent over:
- The client sends
$/cancel_requestfor a request it made to its direct peer (for example, a proxy). - A proxy that forwarded the request downstream (the SDK does this with
forward_response_to) reacts by sending its own$/cancel_requestfor the downstream request, using the downstream connection’s request ID. - The downstream response — normal data or the cancellation error — flows back up the chain as the response to each hop’s request.
Because the notification is hop-scoped, it is never tunneled across hops:
generic forwarding helpers (send_proxied_message_to in the SDK, and the
conductor’s internal routing) drop a raw $/cancel_request instead of
forwarding a request ID that means nothing on the next connection. The
cancellation still reaches the next hop, re-issued by forward_response_to
with that hop’s own request ID.
Proxies that intercept methods with custom handlers stay in control: the
request’s cancellation marker is their decision point, and handlers see the
raw notification before any generic forwarding fallback. A custom handler can
handle the cancellation locally, propagate it to a forwarded request
(forward_response_to, or forward_cancellation_from when the forwarding
needs custom logic), absorb it, or claim the notification and route it itself.
See the concepts::cancellation chapter in the
agent-client-protocol rustdoc for
the full decision matrix.
When the notification targets a request that was wrapped in a
_proxy/successor envelope (see the Protocol Reference), the
$/cancel_request is wrapped in the same envelope, and requestId refers to
the JSON-RPC id of the wrapped request on that connection.
The conductor translates cancellations between hops. Since request IDs are
reallocated at every hop, a raw $/cancel_request cannot match anything
beyond the hop it was sent over; the conductor re-issues cancellation with the
next hop’s request ID instead.
Related Documentation
- Protocol Reference - The
_proxy/successorenvelope protocol - agent-client-protocol rustdoc - SDK API for sending, observing, and forwarding cancellations (see
concepts::cancellation)
Logging and diagnostic privacy
Ordinary SDK warning logs describe protocol errors using the numeric error code and a fixed, code-derived reason. Dispatch warnings also identify the method and, where applicable, request ID and handler. They do not format the error’s application-controlled message or data.
This applies to malformed typed notifications, request and notification handler
failures (including dynamic handlers), and outgoing error responses. Serde parse
reasons can quote invalid strings or enum variants, so merely omitting
Error.data.json would not make those reasons safe to log. The SDK does not
extract field paths from free-form error text; there is currently no separately
trusted structured field path to include in these warnings.
Detailed diagnostics are sensitive
The errors returned to callers and sent to peers are unchanged. In particular,
util::json_cast and util::json_cast_params can include the complete failing
JSON input and a payload-bearing parse reason. Applications should not log these
errors using their ordinary Debug or Display representations unless they
intend to record that content.
Explicitly enabled protocol TRACE logging can record complete messages, including terminal output, prompts, tool arguments, and error responses. Treat these traces as private data and enable, store, and share them accordingly. This warning-log policy does not redact opt-in DEBUG/TRACE diagnostics or application-provided logging callbacks.
Ordered Application Dispatch
The SDK’s inbound ordering does not automatically extend to an application’s UI
executor. A notification handler may have finished enqueueing an update while
the application has not yet applied it. Awaiting a request with block_task()
on another task does not drain that application queue.
Use one FIFO queue with one sequential consumer for:
- Notifications, enqueued by typed connection handlers.
- Response results, enqueued by
PreparedRequest::on_receiving_result. - Connection closure, enqueued by
Builder::on_close.
Apply updates in the consumer and expose a response result only when the consumer reaches that event. For v2 resume, this makes preceding replay visible to application code before it observes the resume result. Do not spawn a separate application task for each event unless that executor also preserves their application order.
The compiled ordered application-dispatch cookbook recipe
demonstrates this with an application callback that need not be Send.
Connection handlers retain only event senders, not the application view or an
owning connection. This is an integration pattern, not a new session state
machine or coordinator.
Response barriers are short dispatch steps
Use prepare_request(...).on_receiving_result(...) to install ordered handling
before publishing the request, even from another task. Eager
send_request(...).on_receiving_result(...) only holds dispatch if registration
wins the race with response routing. The barrier is not retroactive:
already-routed responses and later routing through a retained ResponseRouter
do not hold subsequent wire traffic. See Sending Requests
for the additive API and publication timing.
The callback should enqueue the result and return. Do not await another
inbound response or notification on that connection inside the callback.
That traffic cannot be dispatched until the callback finishes, causing a
deadlock even on a multithreaded runtime. Perform work that needs later traffic
in the application consumer, the connect_with foreground, or a task started
with ConnectionTo::spawn.
If another application task needs a projection-drained acknowledgement, the consumer can send it after applying the response marker. The SDK callback should not wait for that acknowledgement.
See the SDK ordering contract for the full callback and response-routing semantics. Queue capacity is an application decision: the recipe uses an unbounded queue, while a bounded queue requires a backpressure policy that cannot deadlock inbound dispatch.
Closure is an application event too
Clean incoming EOF follows already-received notifications and timely ordered
wire responses. Putting on_close on the same queue lets the consumer apply
those events before marking the connection closed.
EOF fails pending SDK requests before running close callbacks, but their
synthetic error callbacks are not ordered wire-response barriers and can
enqueue results after the closure event. The application should settle its
outstanding operations at Closed and tolerate late completions, rather than
waiting for every callback before accepting closure. Transport and handler
errors still propagate from the connection future; this pattern does not turn
all failures into clean EOF.
Do not add prompt attribution
The queue preserves dispatch order, not a relationship the protocol does not
provide. A v2 prompt response acknowledges acceptance. Session running and
idle updates describe foreground state, not a specific prompt’s completion,
and background updates may continue while idle. Keep these as separate
application events; do not assign the next Idle to a prompt without an
additional protocol guarantee. See high-level v2 sessions.
Configurable LLM Providers
The core SDK exposes the draft configurable-provider API through the
unstable_llm_providers feature:
agent-client-protocol = { version = "...", features = ["unstable_llm_providers"] }
Draft protocol v2 applications must enable both unstable_protocol_v2 and
unstable_llm_providers.
An agent advertises support with AgentCapabilities.providers. After
initialization, clients can use three typed client-to-agent requests:
providers/listdiscovers configurable providers, supported API protocols, whether each provider is required, and its current non-secret routing target.providers/setreplaces one provider’s API protocol, base URL, and complete header map.providers/disabledisables a non-required provider.
Clients should configure providers before creating or loading sessions. Provider configuration is process-scoped and should not be persisted. Disabling an unknown provider is idempotent, while attempts to disable a required provider must be rejected.
The SDK supplies typed wire routing only. Applications remain responsible for
capability checks, validating provider IDs and API protocols, enforcing required
providers, applying configuration to sessions, and storing header values
securely. Since headers may contain credentials, providers/list intentionally
returns only apiType and baseUrl and must never echo configured headers.
Sensitive logging: SDK debug and trace instrumentation can include complete JSON-RPC bodies. Treat those logs as sensitive and do not enable body-level logging where provider headers may contain credentials.
Protocol V2
The core SDK can opt into the draft ACP protocol v2 surface with the
unstable_protocol_v2 crate feature:
agent-client-protocol = { version = "...", features = ["unstable_protocol_v2"] }
This feature is separate from the broad unstable feature because protocol v2
is a versioning experiment, not just an unstable method family.
To start from working code, build and run the companion agent and client in the Runnable Protocol V2 Quickstart. The examples exercise prompt acceptance, independent session updates, and the terminal idle state over a real stdio connection.
JSON-RPC batches
Batch framing is a shared JSON-RPC transport feature, not a v2-only protocol feature. Both v1 and v2 accept incoming batches, preserve them through relays, and group replies into one response array. The SDK does not originate batches of requests or notifications. See Transport Architecture: JSON-RPC Batch Behavior for the complete rules.
By default, Client.builder(), Agent.builder(), and Proxy.builder()
continue to expose the stable v1 API. To use the v2 API for a connection,
construct the builder with Client.v2(), Agent.v2(), or Proxy.v2().
Fluent typed handlers, spawned tasks, close callbacks, and connect_with
receive V2ConnectionTo<_>, so the protocol version is reflected in the
high-level Rust API as well as on the wire:
#![allow(unused)]
fn main() {
use agent_client_protocol::schema::{ProtocolVersion, v2};
use agent_client_protocol::{Agent, Client};
fn implementation() -> v2::Implementation {
v2::Implementation::new("example", "0.1.0")
}
async fn run(agent_transport: impl agent_client_protocol::ConnectTo<agent_client_protocol::Client>) -> agent_client_protocol::Result<()> {
Client
.v2()
.connect_with(agent_transport, async |cx| {
let initialize = cx
.send_request(v2::InitializeRequest::new(
ProtocolVersion::V2,
implementation(),
))
.block_task()
.await?;
assert_eq!(initialize.protocol_version, ProtocolVersion::V2);
Ok(())
})
.await?;
Ok(())
}
async fn serve(client_transport: impl agent_client_protocol::ConnectTo<agent_client_protocol::Agent>) -> agent_client_protocol::Result<()> {
Agent
.v2()
.on_receive_request(
async |initialize: v2::InitializeRequest, responder, _cx| {
responder.respond(v2::InitializeResponse::new(
initialize.protocol_version,
implementation(),
))
},
agent_client_protocol::on_receive_request!(),
)
.connect_to(client_transport)
.await?;
Ok(())
}
}
When v2 mode is enabled, application code should use types from
agent_client_protocol::schema::v2. The flat agent_client_protocol::schema::*
exports remain the stable v1 schema. This will likely change as v2 gets closer
to release. The preceding agent fragment demonstrates version negotiation only.
An agent that advertises session support must also implement the complete
baseline session surface shown in the runnable quickstart.
High-level v2 sessions
Stable callbacks receive ConnectionTo<_> and expose the protocol v1
build_session*, SessionBuilder, ActiveSession, and SessionMessage APIs.
Callbacks installed through Client.v2() receive V2ConnectionTo<_> and expose
the v2 build_session* and resume_session* helpers, plus feature-gated
fork_session* helpers when unstable_session_fork is enabled. The shared
names describe the same lifecycle operations while the connection type selects
their schema and return types at compile time. Resume and fork return
V2ResumeSessionBuilder and V2ForkSessionBuilder. Call start_session to
publish the request and obtain an OpenedV2Session containing the command
handle and complete operation-specific response.
Low-level custom with_handler and with_runner implementations continue to
receive the protocol-neutral ConnectionTo<_>, and generic send_request
remains schema-agnostic. These generic APIs do not infer a protocol version from
the Rust payload type: callers on a v2 connection must use schema::v2 types,
or deliberately send extension or untyped messages. The connection guard
enforces negotiation and initialization lifecycle, not Rust-type provenance.
Dynamic handlers registered through V2ConnectionTo::add_dynamic_handler use
the same low-level HandleDispatchFrom interface and therefore also receive
ConnectionTo<_>.
Nested connections preserve the stable ConnectionTo API while still typing
the child implementation’s callbacks. On a raw ConnectionTo<_>,
spawn_connection(Client.v2(), transport) returns a raw ConnectionTo<_>, and
callbacks installed on that v2 child builder still receive
V2ConnectionTo<_>. Existing spawn_connection::<Role> calls therefore remain
source-compatible.
When a raw parent also needs a typed handle to the v2 child, the
unstable_protocol_v2 feature exposes
ConnectionTo::spawn_connection_with_context, which returns the context
selected by the child builder. V2ConnectionTo::spawn_connection likewise
follows the child builder naturally, so spawning Client.v2() through an
already-typed v2 connection returns another V2ConnectionTo<_>.
A complete client installs update and interactive-request handlers before
connecting. After session/prompt is accepted, it must keep the connection
alive and consume updates until the matching session reaches idle. The
v2_one_shot_client
example demonstrates the full sequence, while the compiled cookbook
v2_one_shot_prompt recipe shows how to embed it in an application. Permission
handlers should transfer the request and responder to application-owned work
rather than waiting for user input inside the dispatch callback.
V2 deliberately separates prompt submission from session observation:
session/promptreturns aPromptResponseas soon as the agent accepts the prompt. Itsmessage_ididentifies the inserted user message, and liveUserMessageupdates for that prompt use the same ID. Persisting the message in resumable history remains optional. The response does not indicate turn completion.V2Session::send_promptreturns that request as aSentRequest<PromptResponse>. Callers must explicitly await it, register a response callback, or detach it.V2SessionBuilder::start_sessionlikewise returns a mappedSentRequest. ItsOpenedV2Sessionresult keeps the command handle separate from the completeNewSessionResponserepresented by the linked schema, rather than reconstructing a selected subset of its fields.V2ConnectionTo::resume_sessionandresume_session_fromreturn aV2ResumeSessionBuilder. Itsstart_sessionmethod publishessession/resumeand returns anOpenedV2Sessioncontaining the completeResumeSessionResponsewithout reconstructing it.- With
unstable_session_fork,V2ConnectionTo::fork_sessionandfork_session_fromreturn aV2ForkSessionBuilder. Itsstart_sessionpublishessession/fork, preserves the completeForkSessionResponse, and uses that response’s newly allocated session ID for the command handle. V2Sessionis a cloneable command handle containing only the session ID and connection. It does not own, buffer, or unregister inbound messages.- Register typed
UpdateSessionNotificationandRequestPermissionRequesthandlers onClient.v2()before connecting. Updates and interactive requests are separate protocol lanes. Permission handlers should transfer responders to application-owned work rather than waiting for user input inside the connection dispatch loop. Without matching handlers, unhandled v2 notifications are ignored and unhandled requests receive a method-not-found response. They are not retained for a later per-session receiver. session/updateevents can arrive before, during, or after a prompt request. They carry a session ID and entity IDs, but no prompt or turn ID. The SDK therefore does not attribute intervening events to a locally submitted prompt or provide a prompt-scoped text accumulator.state_updatedescribes the session-wide foreground state.idlemeans the session can accept ordinary new foreground work. It is not a wire-level boundary assigning previous events to one prompt, and background updates may continue while idle.cancel_active_worksends session-widesession/cancel. Cancellation of active foreground work completes after the requiredidleupdate with stop reasoncancelled; cancelling an idle session need not emit another completion. The client should immediately mark unfinished tool calls for the active work as cancelled and must resolve every pending permission request in that session with the cancelled outcome, without answering twice or affecting other sessions. Elicitations are not all cancelled implicitly: agents should use individual$/cancel_requestfor interactions made irrelevant by the cancelled work. Cancelling or dropping the prompt’sSentRequestis the separate JSON-RPC request-cancellation mechanism; insertion still requires a successful prompt response with its message ID.set_config_optionreturns the authoritative replacement option set, andclosereturns the complete close response. Mutable configuration is not cached on the command handle.
Install connection handlers before session/new, session/resume, and
feature-gated session/fork requests. This is especially important before
calling start_session on a V2ResumeSessionBuilder: replay updates precede
the resume response on the wire, so preinstalled typed handlers observe them in
order. If a handler forwards updates to another task, the application is
responsible for any additional projection-drained barrier it needs before
treating replay as locally applied.
See Ordered Application Dispatch for a
single-consumer queue that carries updates, response results, and closure.
For an application-owned prototype that additionally coordinates shared resume
replay, abandonment, and close, see
Session Operation Coordination. It is a
cookbook policy example, not a public SDK coordinator.
Dropping command handles has no network or inbound-routing side effect. For a
session configured with V2SessionBuilder::with_mcp_server or
V2ResumeSessionBuilder::with_mcp_server, or feature-gated
V2ForkSessionBuilder::with_mcp_server, the SDK installs the MCP routes and
initially polls their runner tasks before publishing the corresponding setup
request, so the agent can connect to those servers during setup or resume
replay. Runners may continue asynchronous initialization, so custom connectors
must be able to queue connections and messages once constructed. A successful
setup promotes the attachment to the connection lifetime. A setup failure,
including an error response after cancellation, cleans up the pending
attachment. This attachment requires both unstable_protocol_v2 and
unstable_mcp_over_acp. Fork additionally requires
unstable_session_fork.
A v2 proxy can instead attach one server globally with
Proxy.v2().with_mcp_server(...). The proxy reuses one connection-scoped
server ID and adds its declaration to v2 session/new, session/resume, and
feature-gated session/fork requests. It modifies only the mcpServers field,
preserving unrelated setup fields and extensions for downstream handlers.
V2SessionBuilder::on_proxy_session_start and
V2ResumeSessionBuilder::on_proxy_session_start, plus
V2ForkSessionBuilder::on_proxy_session_start when enabled, are the
non-blocking setup helpers for a v2 proxy:
use agent_client_protocol::schema::v2;
use agent_client_protocol::{Client, Proxy};
Proxy
.v2()
.on_receive_request_from(
Client,
async |request: v2::NewSessionRequest, responder, cx| {
cx.build_session_from(request)
.with_mcp_server(session_server)?
.on_proxy_session_start(responder, async |opened| {
let (session, setup_response) = opened.into_parts();
record_session(session.session_id(), setup_response);
Ok(())
})
},
agent_client_protocol::on_receive_request!(),
);
These helpers forward request cancellation, send an ordered downstream setup
request, and forward the complete operation-specific response without
reconstruction. For session/new and session/fork, routing is installed when
the response makes the new session ID available and before later inbound
traffic is dispatched. Fork routing uses the response’s new ID rather than the
source session ID. For session/resume, the ID is already known, so routing and any per-session
MCP attachment are ready before the downstream request is published. Replay
updates can therefore be forwarded upstream before the complete
ResumeSessionResponse, as required by the protocol. A failed or cancelled
downstream response drops pending routing and MCP attachment. Successful setup
keeps them for the connection lifetime. A cancellation signal itself remains
advisory: it is forwarded downstream while the helper awaits that response.
Each helper then spawns the callback outside the ordering barrier with an
OpenedV2Session containing the command-only session handle and complete setup
response. Updates and interactive requests remain independent connection
traffic and should still be handled by typed callbacks on Proxy.v2().
If an application wants stream ergonomics, it can fan typed updates out from the connection handler with an explicit buffering and subscriber policy.
Conductor and proxy initialization
Proxy authors should make the version boundary explicit. Proxy.builder() is
the stable v1 builder, while Proxy.v2() is v2-only and requires
_proxy/initialize to select protocol v2. A proxy built for one version rejects
the other version instead of parsing it through a permissive schema.
When one component must expose independently authored v1 and v2 proxies,
compose them with
Proxy.protocol_router().with_v1(v1_proxy).with_v2(v2_proxy). The conductor
has already selected and canonicalized the protocol before
_proxy/initialize, so the proxy router requires an exact v1 or v2 match,
preserves the complete initial transport frame, and does not downgrade or
convert later traffic.
Components implementing their own raw version selector can use
Proxy.builder().without_acp_version_guard() and keep protocol-neutral
ConnectionTo callbacks. This disables the SDK’s automatic version guard and
is not a substitute for selecting Proxy.v2() in an ordinary v2 proxy
implementation.
Enable unstable_protocol_v2 on agent-client-protocol-conductor to carry a v2
connection through a conductor proxy chain. The conductor inspects the raw
protocolVersion before parsing initialization, rewrites ordinary initialize
to _proxy/initialize without reserializing its parameters, and restores the
ordinary method before the request reaches the final agent. For an exact v2
request, info, capabilities, metadata, and unknown extension fields
therefore retain their wire shape across conductor-controlled rewrites. A proxy
implementation can still deliberately replace the request it forwards.
An exact v2 request can retain unknown raw fields, while a request for a later compatible version is canonicalized through the selected v2 schema before component instantiation.
Proxy implementations use
agent_client_protocol::schema::v2::InitializeProxyRequest. Its response is the
v2 InitializeResponse. The flat schema::InitializeProxyRequest remains the
stable v1 type. Static conductor component providers can carry either selected
schema, but each supplied component must support that version. Use
Agent.protocol_router() or Proxy.protocol_router() when a static component
has separate implementations.
Custom InstantiateProxiesAndAgent and InstantiateProxies implementations
opt into v2 by implementing their feature-gated v2 method. The default rejects
v2 rather than interpreting it as v1. Returning the initialize request
unchanged preserves its complete raw parameters for an exact-version request,
including unknown extensions. Returning a modified typed request makes that
serialized request authoritative. The conductor pins protocolVersion to its
selected implementation even if an instantiator attempts to change it, and
validates the final agent’s initialize response against that selection.
The proxy connection also routes v2 session/new requests and responses
without interpreting them as v1 payloads.
MCP compatibility polyfill
The concrete
agent_client_protocol_polyfill::mcp_over_acp::McpOverAcpPolyfill can
participate in a v2 conductor chain when its unstable_protocol_v2 feature is
enabled. It selects v1 or v2 from _proxy/initialize, uses that version’s MCP
capability and wire types, and adapts native McpServer::Acp declarations in
v2 session/new, session/resume, and feature-gated session/fork requests.
Other declarations and unrelated request fields remain unchanged. See
MCP-over-ACP Compatibility Bridge for placement and feature
configuration.
This feature extends the concrete compatibility proxy only. The core SDK’s global MCP attachment and proxy-session helpers support v1 and v2 independently, as described above.
Version routing
The SDK handles the initialize negotiation at the JSON-RPC boundary:
- Native
Client.v2()andAgent.v2()connections reject ordinary protocol traffic until the initialization response completes.$/cancel_requestremains available while initialization is in progress. The client is the initializer and the agent is the responder. Attempts in the opposite direction are rejected. An initialization error leaves the connection uninitialized so the client can retry, while a second initialization after a successful handshake is rejected. - A v2 client advertises protocol v2 as its latest supported version.
- A v2 client requires a v2 agent. If the agent responds with v1, the
initializerequest resolves with an error and the caller must explicitly fall back to a v1 client implementation if that is acceptable. - A native v1 agent selects v1 for requests advertising v1 or a newer version,
with or without
unstable_protocol_v2enabled. Newer initialization metadata is normalized into v1 identity and shared capabilities. The SDK never enables v1 file-system or terminal-execution capabilities from unknown newer fields. V1’s original fallback-to-latest rule is retained, including a v1 selection when the client requests an unsupported older version. - A standalone v2 agent accepts a request for v2 or a newer version with
compatible initialization parameters and selects v2. The request delivered
to the handler has
protocol_versionnormalized to v2, and the response selects v2. If a client initializes with v1, the request resolves with-32022(Unsupported protocol version) anddata: { "requested": 1, "supported": [2] }. The v2 initialization handler is not invoked. Use an agent router with a v1 implementation to serve v1 sessions. - If the agent responds with any other unsupported version, the request resolves with an error so the client can close the connection.
- After initialization, the local API version and negotiated wire version must match. The SDK does not convert traffic between v1 and v2.
That means v1 and v2 implementations still need separate handlers.
Agent.v2(), Client.v2(), and Proxy.v2() are v2-only. While protocol v2
stabilizes, the unstable_protocol_v2 crate feature also exposes
Agent.protocol_router(), Proxy.protocol_router(), and
Client.protocol_connector() for composing version-specific implementations.
Keep these protocol handlers as adapters around shared application logic, not as general-purpose conversions between wire types. In particular, v1 prompt responses end a turn, while v2 prompt responses acknowledge insertion and completion arrives through session state updates. Shared storage also does not automatically make sessions resumable through both versions. See the protocol’s dual-stack migration guidance for adapter boundaries and a validation checklist.
Agents can add protocol implementations independently, which makes it easy for applications built with v2 support to control v2 rollout with a runtime feature flag:
#![allow(unused)]
fn main() {
use agent_client_protocol::schema::{v1, v2};
use agent_client_protocol::{Agent, ConnectTo};
fn implementation() -> v2::Implementation {
v2::Implementation::new("example", "0.1.0")
}
async fn serve(client_transport: impl agent_client_protocol::ConnectTo<Agent>) -> agent_client_protocol::Result<()> {
let enable_protocol_v2 = true;
let v1_agent = Agent.builder().on_receive_request(
async |initialize: v1::InitializeRequest, responder, _cx| {
responder.respond(v1::InitializeResponse::new(initialize.protocol_version))
},
agent_client_protocol::on_receive_request!(),
);
let agent = Agent.protocol_router().with_v1(v1_agent);
let agent = if enable_protocol_v2 {
let v2_agent = Agent.v2().on_receive_request(
async |initialize: v2::InitializeRequest, responder, _cx| {
responder.respond(v2::InitializeResponse::new(
initialize.protocol_version,
implementation(),
))
},
agent_client_protocol::on_receive_request!(),
);
agent.with_v2(v2_agent)
} else {
agent
};
agent
.connect_to(client_transport)
.await?;
Ok(())
}
}
The agent protocol router reads the initial initialize request and selects
a version-specific implementation. Endpoints exposing v2 select the highest
configured version at or below the requested version, or reject initialization
with a structured version-mismatch error if none exists. V1-only endpoints
retain v1’s original fallback-to-latest rule. The router
normalizes a v2 initialize request when selecting a v1 implementation, but does
not convert messages between v1 and v2 after routing. For compatibility, the
initial frame may be a batch whose first call-shaped entry is initialize. The
router preserves the complete frame when handing it to the selected
implementation. Response-only frames before initialization are ignored.
Version mismatch errors
A v2-only endpoint rejects a v1 request without requiring v2’s Client identity
or manufacturing a higher-version success response. The error code is
-32022, and its data contains requested and the endpoint’s supported
versions. This diagnostic is separate from malformed initialization parameters,
which remain -32602. The router preserves response IDs and batch framing,
flushes the rejection, and closes without starting the selected application.
Custom endpoints can construct the same wire error with
agent_client_protocol::util::unsupported_protocol_version(requested, supported).
The code is defined by v2 negotiation, not by a change to the v1 protocol.
The shared SDK error model can preserve it as an unknown numeric code, so a
legacy v1 client can handle the initialization error without new v1 schema
definitions.
just test runs the negotiation tests in both the all-features workspace and
the core crate without default features, so v1 negotiation does not depend on
whether the draft v2 API is compiled in.
Clients should close after a version mismatch and display the supported versions. A successful downgrade must also be checked against the Client’s actual supported versions, because advertising a latest version does not imply support for every older version.
Proxy and client routing
The proxy protocol router reads _proxy/initialize after the conductor has
selected the chain’s wire version. It therefore requires an exact configured
v1 or v2 implementation instead of negotiating or downgrading. It validates
the selected schema, then hands the complete, unchanged initial frame to that
strict implementation.
Clients use a connector because fallback may require opening a new transport. Both client implementations and the agent transport are factories:
use agent_client_protocol::Client;
let connector = Client
.protocol_connector()
.with_v1(|| v1_client())
.with_v2(|| v2_client());
connector.connect_to(|| open_agent_transport()).await?;
The connector starts the highest configured implementation. If a successful v2 initialize response negotiates v1 and a v1 implementation is configured, the connector starts the v1 implementation and compares the complete initialize parameters it would send with the normalized v2 request already seen by the agent:
- If they match exactly, the connector reuses the current agent connection and delivers the original response to the v1 implementation with its request ID. It does not send a second initialize request.
- If they differ, the connector closes that connection, calls both factories again as needed, and performs a fresh v1 initialization on a new agent connection.
- If the agent rejects the v2 initialize request, the error is surfaced. A rejected initialize is not treated as permission to retry with v1.
The reuse probe is conservative: if parsing and serializing the raw v2 request would change any parameter, reuse is disabled and fallback opens a fresh connection. That does not turn an otherwise valid v2 request into an error.
Schema changes in schema 1.5 through 1.12
The unstable_protocol_v2 API follows the moving draft schema. Schema 1.5 added
semantic newtypes for paths, media types, IDs, and cursors, renamed
DiffPatch.diff to DiffPatch.text, and added terminal state and output update
types. Schema 1.7 removed the former schema-wide v1/v2 conversion API:
versioned implementations should remain separate, with
purpose-specific adapters at runtime boundaries where the required state and
policy are available. These are draft API changes rather than stable v1 wire
changes. See Migrating to
v2.0 for concrete source changes.
Schema 1.6 adds Cancelled tool-call and plan-entry statuses to draft v2.
Schema 1.7 stabilizes elicitation and terminal authentication, so neither
surface requires its former SDK feature flag. It also adds context compaction
updates (stable since schema 1.11). The SDK carries them through its
existing typed session/update routing in both protocol versions. V1 clients
advertise compaction support through ClientSessionCapabilities::compaction.
Schema 1.8 stabilizes optional programmatic tool-call names in both protocol
versions. No tool-name-specific feature is required. Draft v2 users only need
unstable_protocol_v2. In v1, an omitted or null name leaves the existing
value unchanged on updates. In v2, omission leaves it unchanged, null clears
it, and a string replaces it. V1 cannot express the explicit v2 null clear
operation.
Schema 1.11 stabilizes session notices and compaction. No surface-specific
feature is required. Draft v2 users still need unstable_protocol_v2.
In draft v2, failures after a prompt’s user message is inserted end active work
with StopReason::Error(ErrorStopReason) on the idle state update, optionally
including a JSON-RPC error object. Failures before insertion remain error
responses to session/prompt. StopReason now serializes as an object tagged
by stopReason, flattened into IdleStateUpdate. Its Other variant takes
OtherStopReason rather than a string and preserves unknown payload fields.
V1 prompt responses are unchanged and do not accept an error stop reason.
Schema 1.11 also rejects malformed payloads that receivers act on rather than
silently dropping invalid entries or substituting defaults. This includes
session setup’s MCP servers and additional directories, terminal launch
arguments and environment, and v2 replay cursors. Typed request decoding
reports invalid parameters (-32602) without invoking the handler. null
still means empty for the defaultable acted-on lists. Omission is accepted
only where the field is optional. Capability and display metadata retain their
lenient decoding.
Schema 1.12
Client-provided system prompts are available through the independent
unstable_session_system_prompt feature. Use the complete lifecycle request
with _from helpers and negotiate the agent’s capability first; see
Client-provided system prompts.
Next Edit Suggestions, their document events, and their position-encoding
capabilities have been removed from both protocol versions.
Foreground states are aggregate snapshots, not per-prompt events:
- Report
runningwhen work starts, orrequires_actionif it starts blocked on user action. Agent-initiated workflows and local commands use the same lifecycle without needing a model call or a synthetic user prompt. requires_actionmeans the foreground workflow as a whole is blocked. A pending permission or elicitation does not imply this state while other foreground work can progress or when it belongs to background activity. Once reported, sendrunningwhen progress resumes oridlewhen work ends.- When no foreground work remains, report
idle; normally include a stop reason. Repeated snapshots, including an old stop reason, are idempotent, not additional completions, cancellations, or token usage. - New sessions default to idle, but updates can precede the new-session response. Do not overwrite them with idle when that response arrives.
- Every successful resume must send a current-state snapshot after any replay and before its response, even without a replay cursor. Do not replay historical states as live activity or reset state to idle on the response. Testy and the echo example only resume sessions without foreground work, so their snapshot is idle without a new stop reason.
Prompt acceptance means logical user-message insertion with an ID and a
position, not receipt or queueing. Running may precede insertion. Rejection
before insertion requires an error response and no user-message updates for
that submission; it does not alone end foreground work. A pre-insertion
foreground failure can also require an error idle update. After insertion,
the response stays successful and failure is reported on idle instead.
This includes authentication failures: after insertion, auth_required
(-32000) belongs in the error stop reason, not an error prompt response.
Authenticating again does not restart ended work, and automatically retrying
an inserted prompt would insert another message and could repeat side effects.
When closing cancels active work, final updates and idle with cancelled
precede the close response. An uninserted prompt cancelled by close gets
-32800; an inserted one still gets its successful message ID.
Agents reject unsupported replay cursor types with -32602, rather than
substituting another cursor. Retained user messages preserve the prompt
response’s ID across replay. Agent and thought message IDs may change across
reconnections: rebuild that history on full replay instead of deduplicating
only by ID. A chunk replay’s empty-content primer clears only its replay ID.
Top-level _meta on upsert updates patches stored metadata by key: omission
leaves it unchanged, null clears it, and an object preserves omitted keys,
removes keys whose values are null, and replaces each other value whole.
Nested objects are not merged; nested nulls are ordinary data. An empty object
does not clear metadata. Replay uses the same rules and never implicitly resets
metadata. ToolCallUpdate::apply_update and TerminalUpdate::apply_update
apply this rule; use schema::v2::apply_meta_update for application-owned
message, session, compaction, or subagent projections. Metadata on chunks,
content, snapshots, and plan_update is not an entity patch.
Protocol v2 negotiation does not select every experimental feature. Operations such as provider configuration require their advertised capabilities. Informational notices, compaction, draft plan representations, subagent updates, and idle token usage can use their documented capability-free fallbacks; accepting an unknown variant is not a support advertisement. Unknown capability fields are ignored, while malformed known discriminators must not fall through to an unknown-variant fallback.
For v2 git_patch diffs, every path must be absolute and have no a/ or b/
prefix, including rename/copy header paths. Git quoting and /dev/null for
an added/deleted file’s missing side still apply. The SDK carries patch text;
it does not rewrite paths or compute diffs from snippets.
Runnable Protocol V2 Quickstart
The core crate includes a small ACP v2 agent and client that run together over
stdio. Both require unstable_protocol_v2; the agent also requires stdio,
and the client requires process:
simple_agent_v2.rsimplements initialization and the complete baseline session lifecycle.v2_one_shot_client.rsinitializes the agent, creates a session, sends one prompt, renders text output, waits for the matching session to become idle, and closes it.
Run the pair
Build both examples from the repository root:
cargo build -p agent-client-protocol \
--features process,stdio,unstable_protocol_v2 \
--examples
Then point the client at the agent executable:
./target/debug/examples/v2_one_shot_client \
--command ./target/debug/examples/simple_agent_v2 \
"Hello from ACP v2"
The result shows the two independent parts of a v2 prompt:
Prompt accepted; waiting for session output and completion...
Echo: Hello from ACP v2
Session is idle: Some(EndTurn)
The agent writes only JSON-RPC to stdout because ACP uses stdout as the wire. Write logs and diagnostics to stderr when extending it.
Client lifecycle
The client installs its session/update and session/request_permission
handlers before it opens a session. Permission requests are part of the
baseline client surface and have no capability marker; this non-interactive
example cancels them explicitly. It then follows this sequence:
- Send
initializeand verify that the agent advertised session support. - Send
session/newand retain the returned command handle and session ID. - Send
session/promptand await its response. This only confirms acceptance. - Ignore queued updates for that new session until its foreground state becomes
runningorrequires_action. Work may start blocked and end without ever running. The active update may already be queued when prompt acceptance arrives. - Project subsequent message updates, and treat the next matching
state_updatewithidleas completion of foreground work. An idle update queued before active work is only the session’s earlier ready state. Background updates may still arrive afterward. - Send
session/closewhen the client no longer needs the active session.
Real clients normally maintain one shared update projection for every session.
Do not install a temporary handler after sending a prompt: updates can arrive
before the prompt response and are not scoped to a prompt or turn ID.
Within that projection, message chunks append by messageId; a later message
snapshot with concrete content replaces the accumulated chunks, null clears
them, and omitted content preserves them. Rendering chunks and then rendering a
snapshot again would duplicate output. An empty content array is also a
replacement. Identical chunks append again; chunk updates are not automatically
idempotent. The example renders messages in first-seen order, even when the
first update for a message only carries metadata.
Run just test from the repository root to execute the example’s unit tests
alongside the workspace suite. They cover interleaved message IDs, metadata-only
updates, content replacement and clearing, and repeated chunks.
Agent lifecycle
Advertising AgentCapabilities::session(SessionCapabilities::new()) commits an
agent to the baseline session surface. The example handles:
session/newsession/listsession/resumesession/closesession/promptsession/cancelsession/updatenotifications sent to the client
The prompt handler validates and marks the session busy, responds to
session/prompt immediately, and moves the actual work into a spawned task so
the connection can continue dispatching cancellation and other traffic. That
task sends the accepted user message, a running update, output, and finally an
idle update with a stop reason.
The example keeps history in memory and supports replay from the start. Every successful resume sends a current idle-state snapshot after any replay and before the response, even without replay. A production agent should replace this with durable session storage, synchronize its actual foreground state, define its supported replay cursors, and make resource cleanup and cancellation robust across process failure.
For the connection APIs, proxy routing, and compatibility details surrounding these examples, continue with Protocol V2.
Session Operation Coordination
The private
v2_session_coordination
example is a cookbook prototype, not a public SDK coordinator. It shows one
application policy for sharing draft-v2 session/resume replay and sequencing
session/close on a single application executor. Its state uses Rc, so it is
not a cross-thread service.
Ownership and ordering
The example initializes the connection before creating application owners.
Sessions, pending loads, and delivered sessions are the only owners of the
command sender. SDK callbacks own a separate sender for ordered wire events;
they never capture an application owner or a connection. The caller owns and
may cancel the enclosing connection future, including during initialization.
One driver consumes notifications, resume and close response markers, and EOF from a single wire-event FIFO. This extends the ordered application dispatch pattern: callbacks only enqueue events and never await another inbound response. The driver installs cleanup state before it considers reopening a session. Already-queued application commands take priority over wire events, so release and acquisition decisions are honored before exposing a response. Synthetic failure callbacks for unanswered requests can arrive after closure; the application must not wait for them. When the last command sender is dropped, the foreground transport stops without waiting for unanswered wire operations. Keep an application owner alive to continue cleanup; the driver does not keep the transport alive solely to obtain a close acknowledgement.
Load policy
Every load uses the fixed working directory passed to with_sessions and
ReplayFrom::Start.
- Concurrent active loads for one session share one resume operation and one projection. Dropping only some loads does not affect those that remain.
- A lease covers both consumed sessions and results that were delivered but never consumed, so either path eventually releases its load.
- If every load is abandoned during resume, accumulated replay is discarded, while lightweight state remains to settle the in-flight wire response.
- A replacement arriving during abandonment or close waits for the old resume response and a successful close, then starts a fresh replay. Cleanup state is recorded before reopening.
- Resume failure is returned to that operation’s loaders; a waiting or later load may retry.
- Close failure fails queued replacements and blocks only that session until reconnect. There is no automatic close retry.
The selected policy is drain the published resume, then close. It does not cancel or detach the resume.
Projection and completion
The projection is intentionally just a raw, lossless SessionUpdate log, not a
general reducer. The complete resume setup response is retained alongside that
log by the operation result. Loads expose their shared projection only after
the resume response; live updates continue appending afterward. This example
does not expose partial replay to a loading view. Production applications
should project protocol entities according to their own UI needs, outside SDK
callbacks.
Do not generalize the next idle state into completion of a particular prompt:
v2 state is session-wide, updates have no prompt identity, and background
updates may continue while idle.
Limitations
This prototype has no timeouts, automatic retries, MCP attachments, session new/fork, or prompt scheduling. It deliberately uses unbounded command/event queues and an unbounded update log for clarity; production code needs explicit memory, backpressure, and fairness policies. UI work and user code must not run in SDK callbacks.
Run it against an agent with an existing resumable session:
cargo run -p agent-client-protocol --features process,unstable_protocol_v2 \
--example v2_session_coordination -- \
--command 'my-agent-command' --session-id 'existing-session-id'
The repository verification command is:
just test
The prototype’s focused tests exercise abandonment, replacement, failure, and shutdown behavior; this page does not duplicate the implementation.
Transport Architecture
For the broader user-facing API, see the Core Library Design and
the agent-client-protocol rustdoc.
This chapter explains how the connection layer separates protocol semantics from transport mechanisms, enabling flexible deployment patterns including in-process message passing.
Overview
The SDK’s connection core provides the JSON-RPC abstraction used by ACP components. It supports pluggable transports that work with different I/O mechanisms while maintaining consistent protocol semantics.
Design Principles
Separation of Concerns
The architecture separates two distinct responsibilities:
-
Protocol Layer: JSON-RPC semantics
- Request ID assignment
- Request/response correlation
- Method dispatch to handlers
- Error handling
-
Transport and framing layer: Message movement and JSON-RPC envelope validation
- Reading/writing from I/O sources
- Serialization/deserialization
- Preserving single-value and batch boundaries
- Connection management
This separation enables:
- In-process efficiency: Components in the same process can skip serialization
- Transport flexibility: Easy to add new transport types (WebSockets, named pipes, etc.)
- Testability: Mock transports for unit testing
- Clarity: Clear boundaries between protocol and I/O concerns
The TransportFrame Boundary
The public, transport-neutral boundary is TransportFrame. A frame contains
one RawJsonRpcMessage, one structurally non-empty TransportBatch, or a
malformed wire value that a relay must preserve. RawJsonRpcMessage is backed
by the JSON-RPC envelope types from agent-client-protocol-schema:
#![allow(unused)]
fn main() {
enum RawJsonRpcMessage {
Request(Request<RawJsonRpcParams>),
Notification(Notification<RawJsonRpcParams>),
Response(RawJsonRpcResponse),
}
}
At that boundary:
- Above: Protocol layer works with application types (
OutgoingMessage,UntypedMessage) - Below: Transport actors parse and serialize JSON-RPC frames
- Boundary:
TransportFramecarries one raw message, a structurally non-empty batch, or a malformed wire value retained for a relay - In-process API:
Channel::rxandChannel::txcarryTransportFramedirectly, so adapters cannot accidentally flatten a batch - Failures: I/O and connection failures are returned by the future driving a transport; they are not sent as channel entries
TransportFrame::parse_json returns one frame for every input string, including
malformed response-shaped input. Standalone malformed input retains its exact
text. Batch entries retain their parsed JSON values, source order, and batch
boundary, although serializing a relayed batch may normalize whitespace. The
protocol actor, not the parser, decides whether malformed input requires a
response.
Actor Architecture
Protocol Actors
These actors live in the protocol connection core and understand JSON-RPC semantics:
Outgoing Protocol Actor
Input: mpsc::UnboundedReceiver<OutgoingMessage>
Output: mpsc::UnboundedSender<TransportFrame>
Responsibilities:
- Assign unique IDs to outgoing requests
- Register pending replies before sending requests
- Convert application-level
OutgoingMessageto protocol-levelRawJsonRpcMessage
Incoming Protocol Actor
Input: mpsc::UnboundedReceiver<TransportFrame>
Output: Routes to pending request awaiters or registered handlers
Responsibilities:
- Route responses to pending request awaiters (matched by ID)
- Route requests/notifications to registered handlers
- Convert schema request/notification envelopes to
UntypedMessagefor handlers - Retain batch response slots while entries are dispatched
- Emit one response array only after every response-bearing entry has completed and all entries in the batch have been dispatched
- Emit nothing for a notification-only batch; answer an empty batch with one
standalone
Invalid Requestresponse
Pending Reply Registry
The shared pending-reply registry manages request/response correlation:
- Maintains map from request ID to response channel
- When response arrives, delivers to waiting request
Task Actor
Runs user-spawned concurrent tasks via cx.spawn().
Transport Actors
These actors are driven by physical transport components. They understand JSON-RPC framing and envelope validity, but they do not dispatch ACP methods or correlate responses with pending requests:
Transport Outgoing Actor
Input: mpsc::UnboundedReceiver<TransportFrame>
Output: Writes to I/O (byte stream, channel, socket, etc.)
For byte streams:
- Serialize a single
RawJsonRpcMessageor one non-empty batch to JSON - Write newline-delimited JSON to stream
For in-process channels:
- Directly forward
TransportFrameto the channel
Transport Incoming Actor
Input: Reads from I/O (byte stream, channel, socket, etc.)
Output: mpsc::UnboundedSender<TransportFrame>
For byte streams:
- Read newline-delimited JSON from stream
- Parse a single message or a non-empty batch array
- Retain the batch boundary while dispatching each
RawJsonRpcMessageentry to the incoming protocol actor - Retain malformed entries so relays can forward the complete frame
- Leave call/response-shape classification and Error Response decisions to the incoming protocol actor
For in-process channels:
- Directly forward
TransportFramefrom the channel
The public Channel boundary preserves complete frames. The SDK continues to
initiate requests and notifications as individual JSON-RPC messages; response
arrays are correlated replies to batch calls received from the peer. Relays and
instrumentation must forward frames intact so they do not change those wire
semantics.
JSON-RPC Batch Behavior
Batch support is shared by the stable v1 and draft v2 APIs because it belongs to the JSON-RPC transport layer:
Lines,ByteStreams,Stdio, and the HTTP/WebSocket adapters accept incoming JSON-RPC arrays.- Entries are validated and dispatched independently and in source order. An
invalid call-shaped entry receives its own
Invalid Requesterror without preventing valid siblings from running. - Responses for response-bearing entries are collected and written as one response array after dispatch completes. A notification-only batch receives no response.
- If a handler drops a batched request’s
Responder, the completed dispatch supplies anInternal Errorfor that slot so completed siblings are not stranded. Returning a handler error supplies that error instead. Dropping an individual request’s responder continues to send no automatic response. - An empty input array receives one standalone
Invalid Requestresponse; the SDK never writes an empty response array. - Response entries are routed by request ID. The framing layer retains malformed
values, but the protocol actor ignores values that are response-shaped and
not call-shaped because a JSON-RPC response must not itself receive a
response; ambiguous call-shaped values still receive
Invalid Request. - The SDK does not originate batches of requests or notifications. Individual calls continue to receive individual responses; a response array is emitted only for an incoming call batch.
Relays, wrappers, and tracing bridges must forward the complete
TransportFrame. Flattening a batch changes observable JSON-RPC semantics even
when every individual message remains valid.
Lifecycle-sensitive calls should normally be sent individually. As a
compatibility measure, AgentProtocolRouter can select a v1 or v2 agent when
the first call-shaped entry is initialize, while preserving the original
frame for the selected implementation. Response-only frames received before
initialization are ignored. ProxyProtocolRouter provides the analogous
boundary after conductor selection: it requires an exact v1 or v2
_proxy/initialize match and preserves the complete initial frame without
cross-version conversion. ClientProtocolConnector starts each attempted
client implementation with an individual initialize request.
Message Flow
Outgoing Message Flow
User Handler
|
| OutgoingMessage (request/notification/response)
v
Outgoing Protocol Actor
| - Assign ID (for requests)
| - Subscribe to replies
| - Convert to RawJsonRpcMessage
v
| TransportFrame (single message or batch response)
|
Transport Outgoing Actor
| - Serialize (byte streams)
| - Or forward directly (channels)
v
I/O Destination
Incoming Message Flow
I/O Source
|
Transport Incoming Actor
| - Parse (byte streams)
| - Or forward directly (channels)
v
| TransportFrame (single message or incoming batch)
|
Incoming Protocol Actor
| - Route responses → pending request awaiters
| - Route requests → registered handlers
v
Handler or request awaiter
Message Ordering in the Conductor
The conductor’s central routing loop serializes forwarding decisions for incoming requests and notifications. Responses stay paired with the request contexts managed by the protocol layer and may take a direct response path. The conductor therefore does not promise a global total order across unrelated concurrent requests, but every underlying transport sink preserves the order in which complete frames are accepted. See Conductor Routing and Ordering.
Component Boundary
ConnectTo
is the common component and transport abstraction. connect_to joins a
component to its counterpart and drives the connection until completion.
into_channel_and_future exposes the canonical low-level boundary as a
Channel plus an explicit connection driver:
fn into_channel_and_future(self) -> (Channel, Option<ConnectionDriver>);
The channel carries only TransportFrame wire events. The optional driver
distinguishes owned work from a passive endpoint:
| Returned work | Lifetime rule |
|---|---|
Some(ConnectionDriver::new(future)) | Poll the owned work alongside traffic; successful completion ends it after accepted output is drained. |
None | No work is owned here; each channel half determines its own lifetime. |
A ConnectionDriver always contains a real future; the optional return value
cannot itself be awaited. There is no ready-successful passive driver and no
finish hook in the None case. This makes the ownership decision explicit
rather than requiring callers to recognize a special future.
A raw Channel returns None. Its bridge preserves both directions
independently:
one sender closing must not prevent a final response in the reverse direction.
Owned completion lets a bridge stop accepting new output, drain frames already
accepted, and finish without waiting for unrelated remote input to close.
Outbound forwarding must remain polled while owned work is running; otherwise
a component waiting for a response to its own request could deadlock.
Buffered adapters are responsible for flushing their accepted output before
reporting successful completion. The built-in line and byte-stream adapters
keep the read half moving during write drain and propagate incoming errors;
their explicit finish handling does not require remote read EOF.
ConnectionDriver::with_finish(future, finish) lets custom adapters declare
the same cooperative contract. Its one-shot, nonblocking hook requests finish
after protocol output handoff; the still-polled future performs the drain and
reports completion or I/O errors. request_finish() exposes this request to
low-level callers and is idempotent: a supported request remains supported
after it has been issued. The callback still runs at most once. Neither
requesting finish nor dropping the hook proves a successful flush, and no
implicit timeout is imposed.
ConnectionDriver::new(future) remains appropriate for opaque cancellable
work. A finite foreground does not wait indefinitely for such work after
handing off protocol output. Merely wrapping an arbitrary future cannot make
an opaque custom adapter drain safely.
Most components implement only connect_to; default normalization supplies
Some(driver) containing the owned work. Direct transports override
into_channel_and_future to avoid an intermediate copy. Wrappers that expose an
existing endpoint should forward
its driver unchanged so absence and cooperative completion handling are
not lost. Wrappers that decorate execution use map_future to transform the
owned future while preserving its finish capability and requested state.
Direct custom transport entry points must also request and await cooperative
finish after a finite peer completes; a bare join does not provide that step.
See Migrating Connection Drivers for custom override changes. This lifecycle distinction does not change the existing raw channel types or introduce frame-size, queue, or task limits.
Transport Implementations
Byte Stream Transport
ByteStreams<Outgoing, Incoming> works with futures::io::AsyncWrite and
AsyncRead. It adapts them to Lines, parses each incoming JSON value into one
frame, and serializes each outgoing frame to one newline-delimited JSON value.
Runtime adapters with different I/O traits can instead bridge asynchronous
lines into Lines.
The native Stdio and AcpAgent transports build on ByteStreams and require
the independent stdio and process features, respectively. No features are
enabled by default. Their implementations depend on process spawning and
blocking-thread facilities, so their dependencies and exports remain excluded
on WebAssembly even when enabled. The runtime-neutral
protocol engine and transport abstractions compile for wasm32-wasip1 and
wasm32-wasip2 without additional features. JavaScript-hosted
wasm32-unknown-unknown builds require the opt-in wasm_js feature, which
selects Web Crypto through wasm-bindgen as the UUID randomness backend. Since
the target does not imply a JavaScript host, other OS-less WebAssembly hosts
must arrange a compatible UUID randomness backend instead. This crate does not
provide a WebAssembly executor or host I/O adapter.
The rmcp integration library also compiles for WASI with its default features or MCP-over-ACP features. It uses Tokio’s async I/O utilities and single-thread-capable runtime without requiring native stdio or a multithreaded executor. Embedders still provide their runtime and host transport. See Cargo Features for configuration and migration examples.
Use cases:
- Stdio connections to subprocess agents
- TCP socket connections
- Unix domain sockets
- Any stream-based I/O
In-Process Channel
For components in the same process, Channel::duplex() creates paired
endpoints and skips serialization entirely. Relays forward each received
TransportFrame without unpacking it; this preserves batch boundaries and the
original representation of malformed wire input.
Benefits:
- Zero serialization overhead: Messages passed by value
- Same-process efficiency: Ideal for conductor with in-process proxies
- Explicit wire state: No serialize/parse round trip is required, while a malformed value received from a physical transport remains an explicit frame
Typed Component Interface
The community yosh:acp@8.0.1 package
defines an independently versioned typed WIT projection of ACP for the WASI
0.3 async component model. In that design, the host processes the ACP JSON-RPC
connection outside the component and maps it to typed calls across the
component boundary. The package provides interface definitions, not a
transport or integration supplied by this SDK.
Use Cases
1. Standard Agent (Stdio)
Use native Stdio for the current process or AcpAgent for a child process.
Both use the same frame-aware line transport underneath.
2. In-Process Proxy Chain
Connect builders, proxies, and conductor components directly. Their default
ConnectTo adapter uses Channel, so complete frames cross each wrapper with
no serialization.
3. Network-Based Components
Split the socket and pass compatible read/write halves to ByteStreams::new.
4. WebAssembly Embedding
Embedders supply and drive their own runtime and host transport:
- Exchange
TransportFramevalues through an in-componentChannel. A caller usingConnectTo::into_channel_and_futurepolls a present owned driver alongside traffic. When no driver is returned, preserve the channel halves’ independent lifetimes; absence is not EOF. - Exchange newline-delimited JSON through
Lines, using afutures::Sink<String>andfutures::Stream<Item = std::io::Result<String>>. - Use
ByteStreamswithfutures::io::AsyncReadandAsyncWrite.
5. Testing with Mock Transport
Use Channel::duplex() to inject and inspect TransportFrame values without
real I/O.
Benefits
Performance
- In-process optimization: Skip serialization when components are co-located
- Zero-copy potential: Direct message passing for channels
- Flexible trade-offs: Choose appropriate transport for deployment
Flexibility
- Transport-agnostic handlers: Write handler logic once, use anywhere
- Easy experimentation: Try different transports without code changes
- Future-proof: Add new transports (WebSockets, gRPC, etc.) without refactoring
Testing
- Mock transports: Unit test handlers without I/O
- Deterministic tests: Control message timing precisely
- Isolated testing: Test protocol logic separate from I/O
Clarity
- Clear boundaries: Protocol semantics vs transport mechanics
- Focused implementations: Each layer has single responsibility
- Maintainability: Changes to transport don’t affect protocol logic
Related Documentation
- Core Library Design - High-level crate architecture
- Conductor Design - How the conductor uses transports
HTTP / WebSocket Transport
agent-client-protocol-http exposes ACP agents over one /acp endpoint.
POST /acpwithinitializecreates a connection and returnsAcp-Connection-Id.- Later
POST /acprequests includeAcp-Connection-Id; session-scoped requests also includeAcp-Session-Idorparams.sessionId. GET /acpwithAccept: text/event-streamstreams agent messages over SSE. Use a connection-level stream for connection-scoped messages and per-session streams for session-scoped messages.GET /acpwith a WebSocket upgrade uses text frames for JSON-RPC messages.DELETE /acptears down the connection.
POST /acp request bodies are limited to 16 MiB.
Diagnostic Privacy
Ordinary transport diagnostics use byte lengths, fixed message kinds, stream scope, and fixed failure labels rather than message bodies. They do not format raw methods, request/session IDs, WebSocket close reasons, or transport error text, which can contain prompts, file/image content, or credential-bearing URLs. This does not change messages or errors delivered through the protocol.
Opt-in conductor recordings are different: they intentionally retain protocol payloads and must be treated as sensitive. This policy covers diagnostics emitted by the HTTP transport crate, not logs from applications or underlying libraries.
JSON-RPC Batches
HttpClient starts every connection with an individual initialize and
requires an individual initialize response. For compatibility with other
clients, the server also accepts an initial batch when its first call-shaped
entry is an initialize request. Valid and malformed response-only entries may
precede it and are ignored; an invalid or call-shaped predecessor rejects the
batch as an initial frame. The server forwards the complete frame and returns
the complete grouped response in the POST response body; a successful
initialize also adds Acp-Connection-Id. Lifecycle-sensitive calls should
normally remain individual. If the agent emits a notification or callback
before the initialize response is ready, including from a batched sibling, the
server buffers that frame for the connection’s SSE stream until initialization
completes.
After initialization, both transport shapes preserve batches:
- On an established HTTP connection, one complete batch occupies one POST
body. The server returns
202 Accepted; any grouped JSON-RPC reply is delivered through SSE as one array. - WebSocket sends one complete batch in one text frame and writes its grouped reply in one text frame.
- A grouped HTTP reply is sent to a session stream only when all correlated entries have the same session route. If routes differ, it is sent on the connection-level stream so the array remains intact.
Entry validation, notification-only behavior, empty arrays, and malformed response filtering follow the shared transport batch contract.
HTTP + SSE Streams
After initialize, clients should open a connection-level SSE stream:
GET /acpAccept: text/event-streamAcp-Connection-Id: <connection id>- no
Acp-Session-Id
This stream carries connection-scoped messages.
Session-scoped messages are routed to session-specific SSE streams. For each active session, clients should also open:
GET /acpAccept: text/event-streamAcp-Connection-Id: <connection id>Acp-Session-Id: <session id>
Open a session stream before sending methods such as session/prompt,
session/load, session/resume, or other session-scoped requests. When a
session/new or session/fork response returns a new sessionId, open an SSE
stream for that returned session before expecting updates or responses for it.
Features
The crate does not enable either transport side by default. Opt into only the side(s) you need.
agent-client-protocol-http = { version = "3", features = ["client"] }
# For a server instead:
# agent-client-protocol-http = { version = "3", features = ["server"] }
# To enable both sides:
# agent-client-protocol-http = { version = "3", features = ["client", "server"] }
The client feature exposes HttpClient. The server feature exposes
AcpHttpServer, ServerOptions, and CorsOptions.
Request Cancellation
Request cancellation is available through the core SDK:
agent-client-protocol-http = { version = "3", features = ["client", "server"] }
$/cancel_request is connection-scoped. The HTTP transport does not apply
Acp-Session-Id to cancellation notifications, and routes outgoing
cancellation notifications over the connection stream rather than a session
stream.
WebSocket connections can carry cancellation at any point after the socket is
open. With HTTP + SSE, cancellation can be sent after initialize completes and
the client has received Acp-Connection-Id; an in-flight initialize request
cannot be cancelled with a hop-local $/cancel_request on this transport shape.
Server
#![allow(unused)]
fn main() {
use agent_client_protocol_http::AcpHttpServer;
let app = AcpHttpServer::new(|| my_agent()).into_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?;
axum::serve(listener, app).await?;
}
Cross-origin browser access is disabled by default. Enable it by allowlisting the browser origins that should be able to access the ACP endpoint:
#![allow(unused)]
fn main() {
use agent_client_protocol_http::{AcpHttpServer, CorsOptions, ServerOptions};
let app = AcpHttpServer::new(|| my_agent())
.with_options(
ServerOptions::default()
.with_cors(CorsOptions::allow_origins(["http://localhost:5173"])?),
)
.into_router();
}
ServerOptions is non-exhaustive. Start from ServerOptions::default() and use
with_path(...), with_cors(...), and with_health_endpoint(...); its fields
remain public for reading and mutation. Defaults remain /acp, disabled CORS,
and an enabled /health endpoint. See the config API migration
for replacing struct literals and update syntax.
Browser authentication and credential policy
CORS controls browser cross-origin access; it does not authenticate clients.
The WebSocket origin check accepts requests without an Origin header, so an
origin allowlist is not a substitute for authentication.
The built-in CORS policy allows ACP transport headers, but not Authorization,
and does not enable credentialed CORS (Access-Control-Allow-Credentials).
CorsOptions::allow_any_origin() is a wildcard origin policy, not an
authentication or credential policy.
Hosts that need bearer headers, cookies, or another credential policy should
enforce authentication on the router returned by into_router() and supply
their own CORS layer. Leave ServerOptions::cors disabled when providing that
HTTP CORS layer to avoid stacking conflicting policies. The separate WebSocket
origin check still uses CorsOptions; with the built-in policy disabled it
rejects upgrades carrying an Origin header. Browser WebSocket hosts can use
an explicit CorsOptions::allow_origins(...) allowlist, but a host needing a
different credentialed HTTP CORS policy and browser WebSocket origins on the
same endpoint needs additional host routing/policy integration beyond these
options. This SDK does not provide a general authentication configuration.
Client
#![allow(unused)]
fn main() {
use agent_client_protocol_http::HttpClient;
let transport = HttpClient::new("http://127.0.0.1:8080")?;
my_client().connect_to(transport).await?;
}
The same HttpClient also speaks WebSocket — pass a ws:// or wss:// URL
and it will open a single bidirectional connection instead of using POST + SSE:
#![allow(unused)]
fn main() {
let transport = HttpClient::new("ws://127.0.0.1:8080")?;
my_client().connect_to(transport).await?;
}
The client validates the WebSocket handshake before sending any queued ACP
messages. This transport does not negotiate WebSocket subprotocols or extensions,
including compression. Setting Sec-WebSocket-Protocol or
Sec-WebSocket-Extensions in custom default headers does not enable support for
them; a server response selecting either is rejected.
Client Configuration
Use the client builder to configure headers, proxies, DNS, TLS, and timeouts. The configuration is used for both HTTP/SSE requests and WebSocket handshakes:
#![allow(unused)]
fn main() {
use std::time::Duration;
use agent_client_protocol_http::HttpClient;
let transport = HttpClient::builder("wss://agent.example")
.configure_http(|http| {
http.user_agent("my-acp-client")
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(10))
})
.build()?;
}
configure_http transforms the current reqwest::ClientBuilder; repeated calls
retain earlier configuration. Use it for options such as default_headers,
proxy, resolve, tls_certs_merge, and identity. It does not open a
connection. URL validation and HTTP-client construction errors are returned by
build(), before connecting the transport.
builder(base_url) uses the same path rule as new: append /acp unless the
path already ends with it, after removing trailing slashes. For an exact
endpoint, including a custom server path or query string, use
builder_with_endpoint(endpoint) instead. with_endpoint(endpoint) remains
the unconfigured convenience constructor.
For WebSocket URLs, the SDK applies HTTP/1.1 and disables redirects after the configuration callback. These are transport requirements, not defaults a callback can override. A redirect fails at the original endpoint without sending handshake headers to its destination; resolve the intended WebSocket endpoint before connecting. HTTP/SSE retains the caller’s HTTP-version and redirect settings.
Proxy Headers
For WebSocket clients, do not set Host, Connection, Upgrade, or any
Sec-WebSocket-* header through reqwest::Proxy::headers. Use that API only
for non-handshake headers required by the proxy; proxy authentication can use
Proxy::basic_auth or custom_http_auth.
The SDK explicitly sets Connection, Upgrade, Sec-WebSocket-Version, and
Sec-WebSocket-Key over default_headers, but reqwest applies non-tunnel proxy
headers afterward on plain ws:// requests and can overwrite those values.
The callback’s proxy configuration is opaque: the SDK cannot
inspect, sanitize, or reject those settings at build(). This is a caller
configuration constraint, not an enforced builder restriction. Upgrade response
validation still precedes ACP data; it does not prove that every request header
was unchanged. For example, replacing the random key causes a response computed
from the replacement key to fail accept validation, but other reserved-header
overrides are not necessarily detected. For wss://, proxy headers apply to the
CONNECT request rather than the tunneled WebSocket handshake.
Reqwest’s connection timeout covers connection establishment. Its request/read timeouts also cover the opening WebSocket handshake but do not impose a lifetime or idle timeout on the upgraded socket. For HTTP/SSE, request/read timeouts retain their normal reqwest semantics, including the long-lived SSE response body.
For custom trust roots and client certificates, prefer reqwest’s TLS options.
If using tls_backend_preconfigured, configure that backend’s ALPN for
HTTP/1.1 yourself: reqwest does not rewrite a preconfigured TLS backend’s ALPN,
even when HTTP/1.1 is selected on its builder. For rustls, set
ClientConfig::alpn_protocols to vec![b"http/1.1".to_vec()]. Incompatible
negotiation is rejected before any ACP messages are sent; HTTP/2 and HTTP/3
WebSocket negotiation is not implemented.
Reusing an HTTP Client
To share an already-built reqwest client and its connection pool, use the
HTTP/SSE-only constructor with an exact endpoint, including /acp or the
server’s custom path:
#![allow(unused)]
fn main() {
let http = reqwest::Client::builder().build()?;
let transport = HttpClient::from_http_client(
"https://agent.example/acp",
http.clone(),
)?;
}
from_http_client rejects ws:// and wss:// URLs before any network request.
An already-built reqwest client cannot be reconfigured to enforce the
WebSocket connection policies. Use the SDK builder for those URLs.
HttpClient itself can also be cloned to reuse its endpoint and underlying
HTTP client. Each connection has independent ACP transport state.
Migrating Custom Client Construction
HttpClient::with_client(base_url, client) and
with_endpoint_and_client(endpoint, client) remain available but are deprecated.
Existing HTTP/SSE calls keep their path handling, supplied client, and connection
pool. They emit a deprecation warning so applications can migrate incrementally.
For ws:// and wss://, both deprecated constructors now return
HttpClientError::WebSocketRequiresBuilder before any network I/O. Those callers
must migrate to the builder: retaining the old signatures cannot make an opaque,
already-built reqwest client enforce the WebSocket connection policies.
- For HTTP/SSE or WebSocket configuration, move the reqwest builder settings into
HttpClient::builder(base_url).configure_http(|http| ...).build(). Do not call reqwest’sbuild()inside the callback. - For an exact endpoint, use
builder_with_endpoint(endpoint)instead. - To retain a shared, already-built HTTP/SSE client, use
from_http_client(exact_endpoint, client). Unlike the old base-URL constructor, this does not append/acp. - Code using
neworwith_endpointneeds no source changes, but WebSocket proxy discovery and TLS trust defaults change as described below.
WebSocket Proxy and TLS Defaults
Unconfigured WebSockets intentionally share reqwest’s defaults with HTTP/SSE.
Previously, the tungstenite connection was direct and WSS used bundled WebPKI
roots. Now new, with_endpoint, and unconfigured builders discover environment
proxies, honor system proxies when reqwest’s system-proxy feature is enabled,
and use reqwest’s TLS verifier. With this SDK’s rustls feature selection, that
is platform certificate verification instead of the old bundled roots. Downstream
reqwest feature selection and explicit TLS configuration can affect this behavior.
Consequently, an unchanged WebSocket URL may take a different network route or
trust a different set of certificate authorities.
To disable proxy discovery and explicitly supplied proxies, opt into a direct connection:
#![allow(unused)]
fn main() {
let transport = HttpClient::builder("wss://agent.example")
.configure_http(|http| http.no_proxy())
.build()?;
}
For deterministic trust, supply the application’s root certificates with
reqwest 0.13’s tls_certs_only, which disables platform/built-in roots:
#![allow(unused)]
fn main() {
use agent_client_protocol_http::HttpClient;
fn direct_with_root(root_pem: &[u8]) -> Result<HttpClient, Box<dyn std::error::Error>> {
let root = reqwest::Certificate::from_pem(root_pem)?;
Ok(HttpClient::builder("wss://agent.example")
.configure_http(|http| http.no_proxy().tls_certs_only([root]))
.build()?)
}
}
Supply the complete root set you intend to trust; this example does not recreate
the old bundled WebPKI root set. Use tls_certs_merge([root]) instead to add
private roots while retaining the selected verifier’s default trust. Do not disable
certificate validation to work around a trust-store difference. The
proxy-header constraint applies when explicitly configuring proxies.
Conductor Design
The agent-client-protocol-conductor crate runs a chain of ACP proxy
components. It presents one ACP endpoint upstream while owning the connections
to every proxy and, in agent mode, the final agent.
For API details, see the
agent-client-protocol-conductor rustdoc.
Chain Model
flowchart LR
Client[ACP client]
Conductor[Conductor]
Proxy1[Proxy 1]
Proxy2[Proxy 2]
Agent[Agent]
Client <--> Conductor
Conductor <--> Proxy1
Conductor <--> Proxy2
Conductor <--> Agent
Components do not open direct connections to one another. The conductor owns each transport and maps the logical chain onto those connections:
- Upstream traffic is delivered to the first proxy as ordinary ACP messages.
- A proxy uses
_proxy/successorto send a request or notification to the next component. - Traffic from a successor is presented to its predecessor through the same typed proxy abstraction.
- JSON-RPC responses remain paired with the request context that caused them.
The final agent receives ordinary ACP and does not need to implement the proxy extension. See the Proxy Extension Protocol Reference for the wire method shapes.
Lazy Initialization
Proxy and agent components are instantiated when the first initialize
request arrives. For each non-final component, the conductor sends
_proxy/initialize; the last component in agent mode receives ordinary
initialize. Each proxy can initialize its successor before completing its own
response, so capabilities flow back toward the client through the chain.
Lazy construction allows an InstantiateProxiesAndAgent or
InstantiateProxies implementation to inspect and, when appropriate, adjust
the initialize request before choosing components.
With the conductor crate’s unstable_protocol_v2 feature, initialization
selects the v1 or v2 schema from the raw protocolVersion before
deserialization. This prevents v2 info, capabilities, metadata, and future
extension fields from being interpreted as a permissive v1 request and dropped.
An exact-version request whose typed value is unchanged keeps its original raw
parameters, including unknown extensions. A request for a later compatible
protocol version selects v2 and is canonicalized through the selected v2
schema.
The command-line component provider, AgentOnly, ProxiesAndAgent, and static
proxy vectors can carry either selected schema, but each supplied component
must support that version. Use Agent.protocol_router() or
Proxy.protocol_router() when a static component has separate implementations.
Custom instantiators can implement the
feature-gated instantiate_v2_proxies_and_agent or instantiate_v2_proxies
method; their default implementation rejects v2 with a JSON-RPC response and
leaves the connection in a failed state that rejects later traffic. A modified
typed request is serialized as the new authoritative payload, while its
protocolVersion remains pinned to the implementation the conductor selected.
Agent and Proxy Modes
In agent mode, the conductor owns zero or more proxies followed by a final agent and acts as an agent toward its upstream client.
In proxy mode, the conductor owns only a proxy sub-chain. The final managed proxy’s successor is the conductor’s own downstream successor, allowing a sub-chain to participate as one proxy inside a larger composition.
Routing and Ordering
A central routing loop serializes forwarding decisions for incoming requests and notifications. Responses are associated with their original requests by the core JSON-RPC contexts and may use a direct response path; the conductor does not maintain a second global request-ID table.
Every physical and in-process bridge carries TransportFrame, so tracing and
delegating components preserve JSON-RPC batch boundaries. This matters because
flattening a batch would change one response array into several response
objects. The complete framing rules are documented in Transport
Architecture.
Command-Line Usage
Global options precede the subcommand. Each component argument is one shell-parsed command string, so quote commands that include arguments:
agent-client-protocol-conductor agent \
"proxy-one --flag" \
"proxy-two" \
"base-agent --acp"
The last command in agent mode is the agent; earlier commands are proxies.
Proxy mode accepts only proxy commands:
agent-client-protocol-conductor proxy "proxy-one" "proxy-two"
Tracing options are global:
agent-client-protocol-conductor --trace ./trace.jsons agent "proxy-one" "base-agent"
agent-client-protocol-conductor --serve agent "proxy-one" "base-agent"
agent-client-protocol-conductor --trace ./trace.jsons --serve agent "proxy-one" "base-agent"
Build the opt-in binary with draft-v2 proxy initialization enabled using:
cargo build -p agent-client-protocol-conductor --features unstable_protocol_v2
There is no conductor mcp subcommand. Compatibility for HTTP-capable agents that lack the
native ACP MCP transport lives in agent-client-protocol-polyfill and must
be inserted explicitly when needed.
Programmatic Usage
use agent_client_protocol_conductor::{ConductorImpl, ProxiesAndAgent};
let components = ProxiesAndAgent::new(agent)
.proxy(first_proxy)
.proxy(second_proxy);
ConductorImpl::new_agent("conductor", components)
.run(upstream_transport)
.await?;
ConductorImpl::new_proxy accepts an InstantiateProxies implementation for
the nested-proxy case. Both modes can use dynamic instantiator closures when
the chain depends on v1 initialization data. A custom instantiator type can
implement both initialization methods when dynamic selection is also needed for
v2.
MCP Compatibility
MCP-over-ACP adaptation is intentionally not built into ConductorImpl. Add
McpOverAcpPolyfill::http() as a proxy in the chain immediately before a final
agent that cannot consume native McpServer::Acp declarations. The
provider-facing side uses feature-gated, request-scoped mcp/message
operations for MCP 2026-07-28; only the final-agent side is adapted to HTTP.
Keeping the polyfill explicit prevents instrumentation or
orchestration from silently changing session MCP declarations. See MCP
Bridge.
The polyfill supports v1 by default. For a draft-v2 chain, enable
unstable_protocol_v2 on both the conductor and polyfill crates; without the
polyfill feature, it rejects v2 initialization instead of interpreting v2
traffic as v1.
Tracing
The conductor can record an idealized logical sequence of ACP and MCP messages. Its snooping bridges retain complete transport frames, so enabling tracing does not change batch behavior. See Trace Viewer for the event format and current CLI/API examples.
Request-scoped MCP HTTP Re-export
agent-client-protocol-polyfill::mcp_over_acp::McpOverAcpPolyfill adapts the
native ACP MCP transport for a final agent with an MCP 2026-07-28 HTTP client.
MCP adaptation is explicit and is not built into the conductor. There is no
fallback to older revisions.
The component-facing side of the bridge always uses the opt-in native protocol:
- Servers are declared as
McpServer::Acpwith aserverId. - Each operation uses
mcp/messagewith the server ID and a fresh logical ID. - Provider notifications belong to that operation; its final response carries an inner MCP result or error.
- There is no initialize/connect/disconnect or MCP session-header exchange.
The SDK-local underscore-prefixed method family and HTTP declarations with a special URL scheme have been retired. The polyfill now translates native declarations to real localhost HTTP URLs only at the compatibility boundary.
Native MCP-over-ACP requires the core SDK’s unstable_mcp_over_acp feature. The
polyfill enables that feature on its core dependency, so applications using the
polyfill receive it through Cargo feature unification.
The polyfill supports stable protocol v1 by default. To place it in a draft-v2
conductor chain, enable unstable_protocol_v2 on both the conductor and
polyfill dependencies:
agent-client-protocol-conductor = { version = "...", features = ["unstable_protocol_v2"] }
agent-client-protocol-polyfill = { version = "...", features = ["unstable_protocol_v2"] }
The feature makes this concrete compatibility proxy recognize v2
initialization, capability, session setup, and mcp/* wire types. It does not
change the core attachment API. Proxy.v2().with_mcp_server(...) provides
connection-global attachment. V2SessionBuilder::with_mcp_server(...) and
V2ResumeSessionBuilder::with_mcp_server(...) provide per-session attachment
for new and resumed sessions respectively. With unstable_session_fork,
V2ForkSessionBuilder::with_mcp_server(...) provides per-session fork
attachment. The polyfill adapts their native declarations when the final agent
supports only HTTP MCP.
Placement
Insert the polyfill immediately before the final agent that lacks native MCP-over-ACP support:
use agent_client_protocol_conductor::{ConductorImpl, ProxiesAndAgent};
use agent_client_protocol_polyfill::mcp_over_acp::McpOverAcpPolyfill;
let components = ProxiesAndAgent::new(agent)
.proxy(application_proxy)
.proxy(McpOverAcpPolyfill::http());
ConductorImpl::new_agent("conductor", components)
.run(upstream_transport)
.await?;
The application proxy can attach a high-level
agent_client_protocol::mcp_server::McpServer. The SDK advertises it in session
setup requests as McpServer::Acp; callers do not need to construct a transport
placeholder themselves. In v2, Proxy.v2().with_mcp_server(...) provides
connection-global attachment. V2SessionBuilder::with_mcp_server(...) and
V2ResumeSessionBuilder::with_mcp_server(...) provide per-session attachment
for new and resumed sessions respectively. With unstable_session_fork,
V2ForkSessionBuilder::with_mcp_server(...) provides per-session fork
attachment. The polyfill translates those native declarations at the final
compatibility boundary.
During initialization, the polyfill forwards the request to its successor. When the successor advertises HTTP MCP support, the polyfill advertises native ACP MCP support in the response seen upstream:
- v1 sets
agentCapabilities.mcpCapabilities.acptotrue. - v2 adds the
capabilities.session.mcp.acpmarker.
In this chain position that capability means the chain can consume native MCP-over-ACP declarations through the adapter; it does not imply that the final agent implements the transport itself.
If the successor already advertises native ACP MCP support, the polyfill leaves
the capability, declarations, and mcp/message traffic unchanged. If it
supports neither native nor HTTP MCP, the polyfill does not advertise ACP MCP
support and rejects any native declaration that is nevertheless supplied.
Transformation
For each schema-selected McpServer::Acp entry in a session setup request, the
polyfill:
- Creates or reuses one connection-scoped loopback listener.
- Replaces the declaration with a server-addressed URL and an HMAC-derived Bearer token in an Authorization header, preserving name and metadata.
- Maps each HTTP POST to one independent
mcp/messagerequest with a fresh UUID logical ID. External HTTP IDs may overlap without sharing lifetime. - Streams that operation’s notifications and projects its terminal carrier into the corresponding HTTP JSON-RPC response.
- Cancels only that operation if its HTTP response stream is dropped.
Enable the polyfill crate’s unstable_session_fork feature when adapting fork
requests. Stable v1 setup includes session/new, session/load, and
session/resume; draft v2 includes session/new and session/resume. Both
versions include session/fork when unstable_session_fork is enabled.
Declarations using another transport are left unchanged, including extension
transports represented by v2’s McpServer::Other.
Routes are derived from server IDs, not accumulated as per-server listeners or route-table entries. The token authenticates that server on this ACP connection. Keep the declaration’s header; never place its bearer credential in the URL.
The native wire envelopes are documented in the SDK Protocol Reference.
HTTP Mode
McpOverAcpPolyfill::http() replaces native declarations with loopback HTTP
URLs. Each POST carries current MCP method/version headers and required inner
version/client-capabilities metadata. Results use JSON, or an operation-scoped
SSE response when notifications are emitted.
let bridge = McpOverAcpPolyfill::http();
Authentication and Origin checks precede bounded request parsing. The adapter does not accept batches, client responses, MCP initialize/session headers, or standalone GET/DELETE streams. It has no resumable SSE event IDs.
Native-tool semantics, not another HTTP gateway’s policy
The endpoint re-exports native tools with its own routing and authentication.
It strips transport-only x-mcp-header annotations from schema positions in
tools/list, retaining argument validation, property names, defaults, examples,
and other instance data. Annotated native tools remain listed and callable.
tools/call is forwarded directly, without hidden descriptor discovery.
Mcp-Param-* headers are rejected and have no authorization or routing meaning.
An external HTTP gateway’s mirrored-header security policy is not transported;
deployments relying on it must enforce that policy separately at the new endpoint.
Lifecycle and Failure Behavior
Previously queued notifications precede the terminal outcome. Only a matching namespaced subscription ID is rewritten to the external HTTP ID; opaque retry state, unrelated metadata, and progress tokens are untouched.
Inner MCP errors retain their code, data, and extensions. Outer ACP failures are projected as binding failures, not mistaken for MCP method outcomes. Local overflow cancels that request and reports a distinct resource error.
Admission belongs to the HTTP response body until consumption or drop, including admitted validation-error responses. Notification buffering and request/terminal payload sizes have adapter-local bounds. These policies do not change generic ACP frame queues or establish a bound on arbitrary application memory.
The polyfill does not infer ACP session IDs. Native registration dispatch remains the authority for whether a server exists. The listener and signing secret live only for the containing ACP connection.
Testy ACP Test Agent
testy is a deterministic ACP agent binary for exercising clients against ACP.
It is built from the agent-client-protocol-test crate and communicates over stdio like a normal
agent. Its v1 and draft v2 implementations are native rather than protocol conversions.
The default build enables agent-client-protocol-test’s unstable cargo feature, which forwards
to the SDK’s unstable feature and builds the v1 agent:
cargo build -p agent-client-protocol-test --bin testy
Enable the separate draft v2 feature to build a dual-version binary:
cargo build -p agent-client-protocol-test --bin testy --features unstable_protocol_v2
That binary selects v1 or v2 from the client’s initialize request. just prep-tests already
builds Testy with all features, so the prebuilt test binary supports both versions.
Run the complete workspace suite with just test. Its prep-tests dependency builds
all ordinary workspace binaries and examples in one Cargo invocation, including
Testy, the conductor, mcp-echo-server, and arrow_proxy. The subsequent test run
builds and runs the test harnesses and doctests. CI uses the same recipe rather
than performing a separate all-target build first.
To build stable-only coverage:
cargo build -p agent-client-protocol-test --bin testy --no-default-features
The binary lands at target/debug/testy. Integration tests that need to spawn it should use
agent_client_protocol_test::test_binaries::testy() after prebuilding test binaries.
Prompt Commands
Prompt text can be either plain text or a JSON-serialized TestyCommand.
Plain-text commands:
helpreturns the supported commands and scenarios.echo <message>streams<message>back.wait_for_cancelaccepts the prompt and waits forsession/cancel.session_updatesemits every stablesession/updatevariant.contentemits prompt/content-focused updates, including every stableContentBlockvariant.tool_callsemits tool call create and update flows.callbackssends every stable agent-to-client request.elicitationssends only elicitation requests.cancel_statusreports whethersession/cancelhas been received.fullruns all stable scenarios in deterministic order.
callbacks and full also run the elicitation coverage.
JSON command form:
{"command":"run_scenario","scenario":"elicitations"}
Coverage
Protocol v1
The binary handles every stable client-to-agent v1 request and notification:
initialize, authenticate, logout, session/new, session/load, session/list,
session/delete, session/resume, session/close, session/set_mode,
session/set_config_option, session/prompt, and session/cancel.
The full scenario sends every stable agent-to-client callback request:
session/request_permission, fs/write_text_file, fs/read_text_file, terminal/create,
terminal/output, terminal/wait_for_exit, terminal/kill, and terminal/release.
It also emits the stable session update variants, including message chunks, tool calls, plans,
available commands, mode/config/session info, and usage.
Every path a scenario sends is rooted in the session’s cwd: the testy-write.txt file that
fs/write_text_file writes and fs/read_text_file reads back, the working directory of each
terminal/create, the tool-call locations and diff (testy-input.txt, testy-output.txt), and
the file: URIs of the image, resource link, and embedded resource. A client that confines file
and terminal access to the session’s directories can run the scenarios in a project directory,
and sessions rooted in different directories never share a file.
elicitations, callbacks, and full cover elicitation/create form mode,
URL mode, session scope, request scope, accept, decline, cancel, and elicitation/complete.
If the client does not advertise form elicitation, the scenario returns a deterministic
invalid-params prompt error before sending an elicitation request.
If the client advertises form elicitation but not URL elicitation, the URL part returns a
deterministic invalid-params prompt error.
Draft protocol v2
With unstable_protocol_v2, the binary also handles the complete advertised v2 session baseline:
initialize, session/new, session/list, session/resume, session/close, session/prompt,
session/cancel, and session/update.
The v2 implementation follows the split prompt lifecycle:
session/promptreturns an empty acceptance response.- Testy independently sends the accepted user message and a
runningstate update. - Output arrives through message updates.
- An
idlestate update with a stop reason completes the foreground work.
wait_for_cancel makes this separation deterministic for client tests: prompt acceptance returns
while work remains active, and session/cancel is confirmed by idle with the cancelled stop
reason. Testy also keeps simple message history and replays it before a session/resume response
when the client requests replay from the start. Every successful resume sends
a current idle-state snapshot after any replay and before responding, including
when replay was not requested. The snapshot is not another completed turn.
The existing v1 scenarios do not map one-to-one onto v2. V2 scenario parity, client callbacks, MCP, authentication, deletion, configuration, and other optional capabilities remain unadvertised for now.
Trace Viewer
agent-client-protocol-trace-viewer renders conductor message traces as an
interactive sequence diagram. Capture lives in
agent-client-protocol-conductor; the viewer can read the resulting file while
the conductor is running or after it exits.
Capture and View
Conductor options are global and therefore precede the subcommand:
agent-client-protocol-conductor --trace ./trace.jsons agent \
"proxy-one" "base-agent"
agent-client-protocol-trace-viewer ./trace.jsons
The standalone viewer chooses a loopback port and opens a browser. Use
--port PORT to choose the port or --no-open to suppress browser launch.
The conductor can also host the viewer directly:
# In-memory live trace
agent-client-protocol-conductor --serve agent "proxy-one" "base-agent"
# File-backed live trace
agent-client-protocol-conductor --trace ./trace.jsons --serve agent \
"proxy-one" "base-agent"
Starting file capture truncates an existing trace file. Each event is flushed as one JSON object followed by a newline.
Recording Privacy
Explicit recordings intentionally retain prompts, image/file content, and other protocol payloads. Treat both files and in-memory viewer data as sensitive. They are not the metadata-only HTTP transport diagnostics.
The conductor redacts credential headers (Authorization, Proxy-Authorization,
Cookie, Set-Cookie, and X-Api-Key) and HTTP(S) url fields in the trace
copy only. URL userinfo is removed. Query values are replaced with [REDACTED]
for these recognized keys, case-insensitively after percent decoding, ignoring
hyphens and underscores:
token,access_token,refresh_token,id_token,api_key,keysecret,client_secret,password,passwd,pwdauth,authorization,bearer,signature,sig,credential,credentials
Repeated and valueless credential parameters follow the same rule. Unparsable HTTP(S) URLs are redacted in full. Credential-free URLs remain unchanged; redacted URLs may be canonicalized, but non-secret query pair spelling, order, and encoding are retained. Redaction never changes the original wire message.
This is not a general-purpose payload sanitizer: arbitrary custom credential fields/headers/query keys, URL path or fragment secrets, and secrets embedded in prompt text cannot all be detected. Review recordings before sharing them.
Event Schema
The .jsons file contains exactly three event variants: request, response, and
notification. It does not capture stderr or general tracing log records.
type TraceEvent = RequestEvent | ResponseEvent | NotificationEvent;
interface RequestEvent {
type: "request";
ts: number;
protocol: "acp" | "mcp";
from: string;
to: string;
id: unknown;
method: string;
session?: string;
params: unknown;
}
interface ResponseEvent {
type: "response";
ts: number;
from: string;
to: string;
id: unknown;
is_error: boolean;
payload: unknown;
}
interface NotificationEvent {
type: "notification";
ts: number;
protocol: "acp" | "mcp";
from: string;
to: string;
method: string;
session?: string;
params: unknown;
}
ts is monotonic seconds since capture began. JSON-RPC IDs may be strings,
integers, or null; consumers must not assume they are numbers. The
optional session field is omitted when the tracer has no session context; it
is not serialized as null.
Components currently use the conductor’s debug names, such as Client,
Proxy(0), and Agent.
Idealized Message Flow
The trace shows logical component-to-component traffic rather than conductor plumbing:
_proxy/successoris unwrapped and logged as its inner ACP method.mcp/messageis unwrapped and its inner method is marked with protocolmcp.- Responses are correlated with the request details retained by the trace writer.
The snooping bridges carry complete TransportFrame values. Enabling tracing
therefore preserves batch boundaries even though the viewer renders the
individual logical messages.
Viewer Features
The web UI provides ACP/MCP/response filters, request-response color pairing,
active-request spans, elapsed-time labels, session/update text previews, a
resizable JSON detail panel, and adjustable swimlane width. The file-backed
server rereads the trace on each poll so new events appear during capture.
Programmatic Capture
use agent_client_protocol_conductor::{ConductorImpl, ProxiesAndAgent};
let conductor = ConductorImpl::new_agent(
"conductor",
ProxiesAndAgent::new(agent).proxy(proxy),
)
.trace_to_path("./trace.jsons")?;
conductor.run(upstream_transport).await?;
Use trace_to with a custom implementation of
agent_client_protocol_conductor::trace::WriteEvent to send events somewhere
other than a file. with_trace_writer accepts an already configured
TraceWriter.
The viewer crate also exposes serve_file and serve_memory. The memory-backed
form returns a TraceHandle for pushing JSON events and a server future that
the caller must drive.
Migrating to Core v3
The upcoming combined breaking release pairs agent-client-protocol 3.x with
agent-client-protocol-rmcp 4.x and rmcp 3.x. Applications using the
integration must upgrade both public dependencies together; see the
rmcp integration migration for service and tool API
changes. These are planned release versions, not the workspace’s current
package versions. The minimum supported Rust version remains 1.88.
For the other transport changes in core 3.x, follow:
- Migrating Connection Drivers for
(Channel, Option<ConnectionDriver>)and active/passive transport ownership. - Migrating the Native MCP Binding for the replacement of connect/disconnect with request-scoped MCP 2026-07-28.
- Migrating Custom HTTP Client Construction for WebSocket configuration through the transport builder instead of a prebuilt reqwest client.
- Native MCP-over-ACP for request-scoped provider usage and the MCP HTTP bridge for modern-only HTTP re-export.
For Cargo feature selection and defaults, see Cargo Features.
For non-exhaustive server options and CORS policies, see Migrating the Config API. Agent subprocess config constructors and fluent methods remain unchanged.
Raw responses are no longer ACP errors
RawJsonRpcMessage::Response now contains RawJsonRpcResponse, whose error
variant carries Box<RawJsonRpcError> instead of an ACP Error. Typed ACP
request APIs still return ACP Error; raw transports, interceptors, and
relays must use the protocol-neutral type.
Raw error codes are i32, not ErrorCode. Raw errors distinguish omitted
data from explicit JSON null and preserve unknown error-object fields in
extra. Do not interpret MCP or other protocols’ error codes as ACP codes.
Construct and match raw responses
Use RawJsonRpcResponse::new with a Result<Value, Box<RawJsonRpcError>>, or
construct its Result and Error variants directly. Use the constructor for
the non-exhaustive raw error struct, then set data and extensions:
use agent_client_protocol::{RawJsonRpcError, RawJsonRpcMessage, RawJsonRpcResponse};
use serde_json::{Value, json};
fn main() {
let success = RawJsonRpcResponse::new(1_i64, Ok(json!({"answer": 42})));
assert!(matches!(success, RawJsonRpcResponse::Result { .. }));
let mut error = RawJsonRpcError::new(-32000, "Tool unavailable").data(Value::Null);
error.extra.insert("retryable".into(), json!(true));
let failure = RawJsonRpcResponse::Error {
id: 2_i64.into(),
error: Box::new(error),
};
let message = RawJsonRpcMessage::Response(failure);
match message {
RawJsonRpcMessage::Response(RawJsonRpcResponse::Result { id, result }) => {
println!("{id}: {result}");
}
RawJsonRpcMessage::Response(RawJsonRpcResponse::Error { id, error }) => {
assert_eq!(error.code, -32000);
assert!(error.data.is_null());
assert_eq!(error.extra["retryable"], json!(true));
println!("{id}: {}", error.message);
}
_ => panic!("expected a response"),
}
}
RawJsonRpcError::new leaves data omitted; .data(Value::Null) explicitly
includes "data": null. The boxing is required by the response type.
Convert only at a typed ACP boundary
When a response is known to be ACP, explicitly unbox and consume the raw
error with into_acp_error. This interprets ACP error codes, keeps data
presence, and intentionally discards error extension fields:
use agent_client_protocol::{Error, ErrorCode, RawJsonRpcError, RawJsonRpcResponse};
use serde_json::{Value, json};
fn into_acp_result(response: RawJsonRpcResponse) -> Result<Value, Error> {
match response {
RawJsonRpcResponse::Result { result, .. } => Ok(result),
RawJsonRpcResponse::Error { error, .. } => Err((*error).into_acp_error()),
}
}
fn main() {
// This response belongs to ACP, where -32000 means authentication required.
let mut raw = RawJsonRpcError::new(-32000, "Authentication required").data(Value::Null);
raw.extra.insert("peerExtension".into(), json!(true));
let response = RawJsonRpcResponse::new(3_i64, Err(Box::new(raw)));
let error = into_acp_result(response).unwrap_err();
assert_eq!(error.code, ErrorCode::AuthRequired);
assert_eq!(error.data, Some(Value::Null));
let raw_again = RawJsonRpcError::from(error);
assert!(raw_again.extra.is_empty()); // Conversion is not a lossless relay.
}
For locally generated ACP responses, RawJsonRpcMessage::response(id, Result<Value, Error>) remains available and converts the ACP error to its raw
representation. It is not a constructor for another protocol’s errors.
Relay without ACP interpretation
Forward a raw message unchanged when no ID rewrite is needed. If a relay
maps request IDs, move the original boxed raw error into the new response;
do not round-trip it through Error or RawJsonRpcMessage::response:
use agent_client_protocol::{RawJsonRpcMessage, RawJsonRpcResponse};
use serde_json::{Value, json};
fn main() {
// A downstream protocol's -32000 is not necessarily ACP AuthRequired.
let wire = json!({
"jsonrpc": "2.0",
"id": "downstream",
"error": {
"code": -32000,
"message": "Tool unavailable",
"data": null,
"retryable": true
}
});
let message: RawJsonRpcMessage = serde_json::from_value(wire.clone()).unwrap();
let RawJsonRpcMessage::Response(response) = message else {
panic!("expected a response");
};
let upstream_id = String::from("upstream").into();
let relayed = match response {
RawJsonRpcResponse::Result { result, .. } => {
RawJsonRpcResponse::Result { id: upstream_id, result }
}
RawJsonRpcResponse::Error { error, .. } => {
RawJsonRpcResponse::Error { id: upstream_id, error }
}
};
let actual: Value = serde_json::to_value(RawJsonRpcMessage::Response(relayed)).unwrap();
let mut expected = wire;
expected["id"] = json!("upstream");
assert_eq!(actual, expected); // Code, explicit null, and extensions survive.
}
The same relay rule preserves omission when the input has no data field.
See the raw response reference for
the protocol boundary.
Migrating the Config API
The upcoming 3.x release makes agent-client-protocol-http::ServerOptions,
agent-client-protocol-http::CorsOptions, and
agent-client-protocol::AcpAgentConfig non-exhaustive so future configuration
fields or policies can be added without another breaking release.
HTTP server options
Breaking: downstream crates cannot construct ServerOptions with a struct
literal, including ..ServerOptions::default() update syntax.
Before (no longer compiles outside the HTTP crate):
let options = ServerOptions {
path: "/agent".into(),
cors: CorsOptions::allow_origins(["https://example.com"])?,
health_endpoint: false,
};
// Partial literals with ..ServerOptions::default() also need migration.
After:
#![allow(unused)]
fn main() {
use agent_client_protocol_http::{CorsOptions, ServerOptions};
let options = ServerOptions::default()
.with_path("/agent")
.with_cors(CorsOptions::allow_origins(["https://example.com"])?)
.with_health_endpoint(false);
}
All existing fields remain public, so reading or assigning options.path,
options.cors, or options.health_endpoint still works. Destructuring patterns
must include ...
Defaults and runtime policies are unchanged: /acp, disabled cross-origin
browser access, and an enabled /health endpoint. Setters replace the chosen
value and preserve the other options.
CORS policies
Existing variants and constructors remain available. Breaking: exhaustive
matches on CorsOptions outside the HTTP crate must add a wildcard arm:
#![allow(unused)]
fn main() {
use agent_client_protocol_http::CorsOptions;
fn is_disabled(policy: &CorsOptions) -> bool {
match policy {
CorsOptions::Disabled => true,
_ => false,
}
}
}
CORS does not authenticate requests. Authorization headers and credentialed CORS are host-owned policies; see browser authentication and credential policy.
Agent subprocess configuration
AcpAgentConfig already has private fields. Keep using its existing constructor
and fluent methods; no new builder type or Default is required because an
executable must be specified:
#![allow(unused)]
fn main() {
use agent_client_protocol::AcpAgentConfig;
let config = AcpAgentConfig::new("python")
.arg("agent.py")
.args(["--mode", "fast"])
.env("RUST_LOG", "info")
.envs([("NO_COLOR", "1")]);
}
Its getters, serialization, required command, empty argument/environment
defaults, and rejection of unknown JSON fields are unchanged.
Migrating the Native MCP Binding
The unstable connection-oriented prototype is replaced by a request-scoped binding for MCP 2026-07-28 only. Standalone MCP serving is unaffected.
| Previous prototype | New binding |
|---|---|
mcp/connect, mcp/disconnect | Removed |
McpConnectionId, connectionId | Removed |
mcp/message(connectionId, method, params) | mcp/message(serverId, requestId, method, params) |
| MCP initialization | Version and client capabilities in every request |
| Reverse MCP requests | MRTR results followed by explicit caller retries |
| Raw result or outer MCP error | Exactly one inner result or error carrier |
| Shared HTTP session and GET stream | Independent HTTP POSTs |
Keep the advertised server ID and generate a fresh McpRequestId per operation.
Use MessageMcpRequest::new(server_id, request_id, method) with MCP version and
client capabilities in the inner _meta. Do not send initialize; optional
discovery is an ordinary request.
Match MessageMcpResponse::{Result, Error} after handling outer ACP failure.
Never interpret an inner MCP error through ACP’s error-code enum. Explicit
data: null, unknown error extensions, and opaque result metadata survive.
V1 and v2 carriers remain separate Rust types.
For native providers, prefer reusable McpService implementations and
request-scoped McpRequestContext. from_rmcp lazily creates one shared
application service for ACP execution; standalone connections still use the
factory. Scoped tool builders remain available.
McpConnectionContext::Acp and McpConnectionTo::request_id() now identify an
operation, not a connection. Standalone contexts have no logical request ID.
Observe operation cancellation and finish owned cleanup before returning. Removing a registration or closing the ACP connection stops active work. There is no new unadvertisement message: omitting a declaration from a later setup request is not removal of the local registration.
The local HTTP polyfill re-exports native tools with its own server-bound
Authorization header. Preserve that header, not a bearer token in the URL.
Transport-only x-mcp-header annotations are normalized; an external HTTP
gateway’s parameter-header policy is not transported.
The merged connection-driver API is
unchanged: passive endpoints remain None, and owned adapters retain their
graceful-finish capability. Raw Channel sender/receiver types and generic
queue policies are unchanged by this migration.
See the native guide and HTTP adapter contract for usage and lifecycle details.
Migrating Connection Drivers
ConnectTo::into_channel_and_future now returns
(Channel, Option<ConnectionDriver>) instead of
(Channel, BoxFuture<'static, Result<()>>). The same change applies when
accessing a component through DynConnectTo.
This is a source-breaking transport-adapter change. It does not change ACP wire
messages, the raw Channel sender/receiver types, or unbounded_send, and it
introduces no new frame-size, queue, or task limits.
Components using the default conversion
If your component implements only connect_to, no change is needed. The
default conversion still creates a channel pair and drives your component, now
returning Some(ConnectionDriver). That default wraps opaque work; it cannot
infer a physical finish hook. A buffered transport that needs a finite
foreground to await physical flush should override normalization with
with_finish, as described below.
Low-level callers must handle the optional work explicitly. The optional value is not a future: awaiting it directly no longer compiles. For a component that is known to own work, extract its driver before polling it:
let (channel, driver) = component.into_channel_and_future();
let driver = driver.expect("this component owns connection work");
// Use channel while continuing to poll the driver.
driver.await?;
For a generic component, handle both cases: poll Some(driver) alongside
traffic and drain accepted output on completion; for None, retain the
channel’s independent halves until they close. Absence of work is not EOF.
Do not replace None with a ready-success future in a shutdown race.
Custom conversion overrides
Import ConnectionDriver from agent_client_protocol and change the return
type. Wrap a future that owns the connection work with ConnectionDriver::new:
fn into_channel_and_future(self) -> (Channel, Option<ConnectionDriver>) {
let (channel, future) = self.into_channel_transport();
(channel, Some(ConnectionDriver::new(future)))
}
For an endpoint whose work is driven elsewhere, return None instead of
wrapping a ready no-op future:
fn into_channel_and_future(self) -> (Channel, Option<ConnectionDriver>) {
(self.channel, None)
}
An existing Channel has no driver. There is no awaitable passive sentinel,
and no finish hook belongs to the None case. ConnectionDriver always holds
real owned work; cooperative drivers additionally support a finish hook.
Passive bridges retain each read/write half until its
own closure; an input half-close can still be followed by a final response.
If a wrapper simply exposes another component’s endpoint, return its original
(channel, optional_driver) pair. Re-boxing an owned driver and wrapping it
with new would hide its finish capability. Inventing a ready
driver for None would also turn absence into a false completion signal.
For tracing, error annotation, or completion cleanup, decorate the future with
map_future. This preserves both the finish capability and any already-issued
request; opaque work stays opaque:
use futures::FutureExt;
let (channel, driver) = component.into_channel_and_future();
let driver = driver.map(|driver| {
driver.map_future(|work| {
work.inspect(|result| eprintln!("transport completed: {result:?}"))
})
});
(channel, driver)
The transformed future must still drive the original work and must not report success before its accepted output has drained.
Completion and drain responsibilities
Poll owned work and outbound forwarding concurrently. A driver may need its outbound request to be delivered before it can receive a response and finish.
An adapter must not report success before flushing output it already accepted.
Use ConnectionDriver::with_finish(future, finish) for a custom normalized
transport that needs to flush during finite foreground shutdown. The
nonblocking FnOnce() hook requests graceful completion; the future proves
completion and reports any I/O error.
let (finish_tx, finish_rx) = futures::channel::oneshot::channel();
let future = async move {
// Keep processing input and output while waiting for the finish request.
// A dropped sender is not a finish request; it may simply mean that
// finish control was abandoned while normal half-closes remain in use.
//
// After a successful signal, seal the outgoing queue, drain every accepted
// frame, and flush/close the physical write half. Do not wait for remote
// read EOF; continue observing genuine I/O errors during the drain.
run_custom_adapter(outgoing_rx, physical_io, finish_rx).await
};
let driver = ConnectionDriver::with_finish(future, move || {
let _ = finish_tx.send(());
});
(channel, Some(driver))
SDK shutdown coordination invokes this hook only after protocol output has
been handed off to the normalized transport, then awaits the driver. Low-level
callers can use driver.request_finish() themselves. A true return means
cooperative finish is supported and has been requested, including a request
already issued. Requests are idempotent, but the hook runs only once. A false
return means opaque work, not “already requested.”
Requesting finish does not prove output has finished flushing; continue polling or await the driver. Capability remains intact if that driver is handed to another owner while flushing. Quiesce and hand off output before requesting finish; idempotence does not permit new output after sealing. Dropping the driver drops its owned future without a graceful request. Dropping only the hook does not invoke it or necessarily stop that future.
There is no implicit timeout. A cooperative adapter that cannot flush keeps
the connection pending, so applications that need a deadline must impose one
and accept that cancelling it can truncate output. with_finish declares the
adapter’s contract; it cannot make an arbitrary future or external buffer
flush automatically.
Built-in Lines and ByteStreams preserve normal half-close behavior. When
their owner explicitly finishes, they drain accepted output while continuing
to poll incoming I/O for errors, rather than waiting for unrelated remote
input to reach EOF. Errors may terminate the connection without graceful drain.
Direct adapter entry point
Returning a cooperative driver from into_channel_and_future lets normalized
SDK consumers coordinate finish. A custom transport’s direct connect_to
implementation must coordinate it too: try_join!(bridge, driver) alone can
wait forever after a finite peer has returned.
This scaffold follows the built-in Lines policy, using only public APIs:
use agent_client_protocol::{Channel, ConnectTo, ConnectionDriver, Result, UntypedRole};
use futures::{future::{select, Either}, FutureExt};
struct BufferedAdapter {
channel: Channel,
driver: ConnectionDriver,
}
impl ConnectTo<UntypedRole> for BufferedAdapter {
async fn connect_to(self, peer: impl ConnectTo<UntypedRole>) -> Result<()> {
let bridge = Box::pin(self.channel.connect_to(peer));
match select(bridge, self.driver).await {
Either::Left((result, mut driver)) => {
result?; // The peer's accepted output has been handed off.
if driver.request_finish() {
driver.await // Prove physical drain; propagate its errors.
} else {
// Preserve a ready error, then cancel opaque work.
driver.now_or_never().unwrap_or(Ok(()))
}
}
Either::Right((result, _bridge)) => result,
}
}
fn into_channel_and_future(self) -> (Channel, Option<ConnectionDriver>) {
(self.channel, Some(self.driver))
}
}
The adapter’s own future must own/close its physical producers before reporting completion. Its finish implementation must stop forwarding successful input to a completed peer while still observing genuine read errors during drain. Otherwise, late input can fail against the dropped receiver and cancel final output. Both direct and normalized entry points should be tested with output backpressure and independently open input.
Finite foreground shutdown
On successful Builder::connect_with foreground completion, routable queued
output is drained. Requests still blocked on unresolved readiness are failed
and removed rather than published after shutdown. Physical transport and
protocol progress continue during this drain; queued application tasks are
not started merely to finish the sink. The inherited cleanup coordinator can
still poll application tasks while protecting a close callback already
underway; a blocked close callback can delay completion.
Foreground success stops beginning new application delivery or close callbacks. Physical reads remain driven without delivering their input to the completed foreground. A close callback already underway finishes before the outgoing drain boundary seals, and its errors retain precedence.
Protocol connectors and routers use the same ownership-aware rule. An owned foreground’s completion requests cooperative drain instead of waiting for unrelated remote input. Initialization rejection also hands off its reply before requesting finish. Passive half-closes alone do not request finish; they preserve the other direction for a final response.
Cooperative drivers, both built-in and custom, are awaited through physical
write shutdown.
An opaque driver constructed with ConnectionDriver::new(future) has no
externally requestable finish control: finite foreground shutdown transfers
protocol output into its normalized channel, then cancels that work without
guaranteeing custom physical flush. Reactive connect_to still joins owned
work after input EOF. Choose new for opaque cancellable work and with_finish
when the adapter can honor an explicit graceful-finish request.
See Transport Architecture for the active/passive boundary and forwarding rules.
Migrating the rmcp Integration to v4
The next major release of agent-client-protocol-rmcp upgrades its public
rmcp dependency from 2.x to 3.4. This is a breaking change for integrations
that pass rmcp services or types across the crate boundary. It ships together
with the core agent-client-protocol 3.x release, which also changes public
APIs. Migrate both public dependencies together; the minimum supported Rust
version remains 1.88.
| Integration crate | Core ACP SDK | MCP SDK |
|---|---|---|
agent-client-protocol-rmcp 4.x (unreleased) | 3.x | rmcp 3.x |
agent-client-protocol-rmcp 3.x | 2.x | rmcp 2.x |
Upgrade the application’s core ACP and rmcp dependencies together with the
integration crate. See the core 3.x migration guide for
raw-response API changes and links to the transport migrations.
A service implementing rmcp 2.x’s Service cannot be passed to the new
McpServer::from_rmcp, even when it provides the same tools.
Custom services and tools
- Use
ServerConfigandClientConfiginstead of the deprecatedServerInfoandClientInfoaliases. - A manual
ServerHandler::call_toolimplementation now returnsResult<CallToolResponse, ErrorData>. Convert a completedCallToolResultwith.into(). The response enum also represents MRTR input-required results and task-extension results; do not assume every response is a completed tool result. - Functions registered with rmcp’s
#[tool]macro can still returnCallToolResult. The ACP integration’stoolandtool_fnbuilder APIs also remain available, and their results become completed tool responses. ToolExecutionandTool::with_executionare no longer part of the tool model. Do not add the old task-execution marker to tool definitions.
For example, a manual handler that previously returned
Ok(CallToolResult::structured(value)) returns
Ok(CallToolResult::structured(value).into()) under its new
CallToolResponse return type.
Modern MCP is now available to supplied services
rmcp 3.4 implements discovery, per-request metadata, modern result shapes,
MRTR, and subscription APIs for MCP 2026-07-28. The adapter preserves those
requests and results when using McpServer::from_rmcp; a supplied service is
still responsible for its advertised capabilities and handlers.
The integration tests exercise both the built-in tool server and a supplied
rmcp service with actual 2026-07-28 requests, without sending initialize.
They cover direct tool calls before discovery, discovery itself, per-request
version errors, and an MRTR retry with fresh request metadata.
The dependency requirement supports rmcp 3.4 and later compatible 3.x versions; the workspace lockfile currently resolves 3.5. Their version constants differ:
- In rmcp 3.4,
ProtocolVersion::LATESTis 2025-11-25. - In rmcp 3.5,
ProtocolVersion::LATESTis 2026-07-28, which has no initialize handshake.LATEST_WITH_INITIALIZEis 2025-11-25.
Do not treat a changing LATEST constant or dependency upgrade alone as
protocol selection. For modern no-initialize requests, explicitly select
ProtocolVersion::V_2026_07_28 and include the required request metadata.
For a legacy initialize handshake, select its supported revision; with rmcp
3.5, LATEST_WITH_INITIALIZE names the latest such revision. The native ACP
binding below requires modern per-request metadata regardless of the resolved
rmcp version.
Native attachments also migrate in this release
The combined release replaces ACP’s unstable mcp/connect / mcp/disconnect
binding with request-scoped, server-addressed mcp/message operations for
MCP 2026-07-28 only, without initialization. Migrate native attachments
using the native binding migration and
request-scoped MCP guide. from_rmcp lazily creates one
shared application service for native requests, whose metadata, notifications,
and cancellation belong to each operation.
Standalone rmcp connections still use the factory per connection and retain rmcp’s protocol negotiation; the native binding’s modern-only requirement does not apply to them. Custom transports and low-level connection callers must also follow the connection-driver migration for the core 3.x API.
Migrating from agent-client-protocol 1.x to 2.0
Version 2.0 makes JSON-RPC notification semantics explicit, changes the low-level in-process
transport boundary so frames remain intact across components and adapters, clarifies the
distinction between responding to requests and routing responses, makes dynamic handler lifetimes
explicit, and gives AcpAgent an SDK-owned process-launch configuration instead of reusing an MCP
wire-schema type. It also replaces the SDK-local MCP-over-ACP wire extension with the shared
schema’s opt-in native transport.
Coordinated crate versions
Most published workspace crates move to the 2.x version family together. Crates whose public APIs
expose core SDK types must use the matching major release; the trace viewer and cookbook are
version-aligned as part of the coordinated release but do not impose that public dependency
constraint. The rmcp integration moves to 3.x because both agent-client-protocol and rmcp are
public dependencies in its API:
| Crate | Coordinated release |
|---|---|
agent-client-protocol | 2.x |
agent-client-protocol-derive | 2.x |
agent-client-protocol-conductor | 2.x |
agent-client-protocol-cookbook | 2.x |
agent-client-protocol-http | 2.x |
agent-client-protocol-polyfill | 2.x |
agent-client-protocol-trace-viewer | 2.x |
agent-client-protocol-rmcp | 3.x |
Notifications cannot receive error responses
The SDK no longer exposes ConnectionTo::send_error_notification, and
Dispatch::respond_with_error has been removed. Match the dispatch variant when a catch-all
handler needs different behavior:
#![allow(unused)]
fn main() {
use agent_client_protocol::{Dispatch, Error};
fn handle(message: Dispatch) -> Result<(), Error> {
match message {
Dispatch::Request(_, responder) => {
responder.respond_with_error(Error::method_not_found())
}
Dispatch::Notification(_) => Ok(()),
Dispatch::Response(result, router) => router.route_with_result(result),
}
}
}
In most applications, omit the catch-all handler entirely. The built-in fallback responds to
unknown requests with Method not found, ignores unhandled notifications, and routes responses
to their pending requests.
TypeNotification no longer has a role parameter or takes a connection:
#![allow(unused)]
fn main() {
use agent_client_protocol::util::TypeNotification;
// 1.x
// TypeNotification::<Peer>::new(message, &connection)
// 2.0
TypeNotification::new(message)
;
}
The notification type parameter of Dispatch<Req, Notif> now requires
Notif: JsonRpcNotification, matching the variant it can contain. The duplicate
Dispatch::erase_to_json method was removed; use into_untyped_dispatch.
Raw channels carry frames
Channel is now the single batch-aware in-process transport boundary. Its rx and tx carry
TransportFrame, not Result<RawJsonRpcMessage, Error>:
#![allow(unused)]
fn main() {
use agent_client_protocol::{Channel, RawJsonRpcMessage, TransportFrame};
fn send(channel: &Channel, message: RawJsonRpcMessage) {
channel.tx.unbounded_send(TransportFrame::Single(message)).unwrap();
}
}
TransportFrame distinguishes a valid single message, a non-empty TransportBatch, and an
explicit malformed wire value. Each batch contains public TransportBatchEntry values so a
relay can retain invalid siblings without turning a protocol error into a transport failure.
All three public frame types implement Clone, and TransportBatch::into_entries() provides an
owned, source-order iterator.
Transport I/O failures are returned by the future from
ConnectTo::into_channel_and_future; they are never channel items. Components should preserve a
received frame intact when relaying it so batch response grouping remains correct.
TransportFrame::parse_json returns a frame directly for every input. It retains malformed
response-shaped values so raw framed intermediaries can preserve them; protocol actors suppress
replies to response-only shapes. Standalone malformed input keeps its exact source text. Batch
entries retain parsed JSON values and source order, but reserialization may normalize whitespace.
The standard line, byte-stream, stdio, HTTP, and WebSocket transports now accept incoming
JSON-RPC batches in both protocol v1 and v2 mode. Entries are handled independently, replies are
grouped into one response array, notification-only batches produce no reply, and an empty array
produces one standalone Invalid Request response. Malformed entries that are response-shaped but
not call-shaped are ignored because JSON-RPC responses must not themselves receive responses. The
SDK does not initiate batches of requests or notifications. See
Transport Architecture for the full framing
contract.
Dropping a Responder for one request in a batch now produces an Internal Error fallback after
dispatch completes, allowing completed sibling responses to flush. A handler error overrides the
fallback with that error. The longstanding behavior for an individual request is unchanged:
dropping its responder does not automatically send a response.
Existing ConnectTo implementations that override into_channel_and_future must now return the
frame-aware Channel. Most components need to implement only ConnectTo::connect_to; a direct
channel adapter may still override into_channel_and_future to avoid an intermediate copy.
Response routing uses routing terminology
ResponseRouter completes a local pending request; it does not send a new JSON-RPC response.
Its methods have therefore been renamed:
| 1.x | 2.0 |
|---|---|
respond_with_result | route_with_result |
respond | route |
respond_with_error | route_with_error |
respond_with_internal_error | route_with_internal_error |
Responder still uses respond*, because it sends the response to an incoming request.
If a catch-all response handler returns Err, that error is now routed to the local
SentRequest awaiter. It is never serialized as a response to the peer’s response. This replaces
the misleading generic failure that previously appeared when the real interceptor error was lost.
Response callbacks enforce ordered dispatch
The 1.x documentation said that on_receiving_result and on_receiving_ok_result callbacks held
the dispatch loop until completion, but the implementation did not enforce that ordering. In 2.0,
registering either callback before a peer response is routed during its original dispatch selects
ordered consumption: registration returns immediately, then the loop waits for response handling
to finish before processing the next message.
This is a behavioral change. An ordered response callback must not await a later response,
notification, or other inbound traffic on the same connection, because the loop cannot dispatch
that traffic until the callback returns. Spawn that follow-up work and return from the callback,
or use block_task from a task that already runs outside the dispatch loop, such as the foreground
future passed to connect_with.
The barrier is not retroactive. A pending-request failure delivered without an incoming response
(such as EOF), a response that was already routed, or a retained ResponseRouter routed after its
original dispatch runs without holding the loop.
Session-start helpers apply this barrier only to framework-owned runner and routing installation, then spawn the user callback. This preserves the actual 1.x scheduling behavior for user session work while correcting the old documentation that claimed the user callback itself blocked dispatch.
Request IDs remain typed
Responder::id, ResponseRouter::id, and SentRequest::id now return &RequestId.
Dispatch::id returns Option<&RequestId>. Clone the ID when it must outlive the handle:
#![allow(unused)]
fn main() {
let id = sent_request.id().clone();
}
This removes JSON round-trips and lets IDs pass directly to APIs such as request cancellation. If an integration still needs an untyped JSON value, serialize the borrowed ID explicitly:
#![allow(unused)]
fn main() {
let id_json = serde_json::to_value(sent_request.id())?;
let dispatch_id_json = dispatch.id().map(serde_json::to_value).transpose()?;
let _ = (id_json, dispatch_id_json);
}
Dynamic handlers use a guard
DynamicHandlerRegistration is now DynamicHandlerGuard and is exported from the crate root.
The guard is must_use and no longer Clone: keep its single owner in the object that owns the
registration. Dropping it unregisters the handler. To leave a handler registered for the rest of
the connection, replace run_indefinitely() with detach(). Detaching no longer leaks an extra
ConnectionTo handle.
Background tasks use runner terminology
Builder extensions implementing RunWithConnectionTo run alongside the connection; they do not
respond to an individual JSON-RPC request. The builder method now reflects that distinction:
| 1.x | 2.0 |
|---|---|
Builder::with_responder | Builder::with_runner |
The conductor crate applies the same terminology to its public background task:
| 1.x | 2.0 |
|---|---|
agent_client_protocol_conductor::ConductorResponder | agent_client_protocol_conductor::ConductorRunner |
This is a type rename only; custom code that names the conductor task should update its imports and type references.
Matchers use dispatch terminology
The combined matchers operate on Dispatch values, which can represent requests, notifications,
or responses. Their method names now reflect that input:
| 1.x | 2.0 |
|---|---|
MatchDispatch::if_message | MatchDispatch::if_dispatch |
MatchDispatchFrom::if_message_from | MatchDispatchFrom::if_dispatch_from |
Connection and session accessors borrow
McpConnectionTo::acp_id is now server_id and returns Option<&McpServerAcpId>. The new name
matches the native McpServer::Acp declaration; the Option reflects that a server can also be
connected directly without ACP. connection_id returns an Option<&McpConnectionId> for the
distinct active connection created by mcp/connect. Use context() to match explicitly on
McpConnectionContext::Standalone or McpConnectionContext::Acp { server_id, connection_id }.
The deprecated acp_url alias was removed. McpConnectionTo::connection_to is now connection
and returns &ConnectionTo<_>.
ActiveSession::modes and ActiveSession::meta now return Option<&T> instead of &Option<T>,
and ActiveSession::connection returns &ConnectionTo<_>.
These accessors avoid implicit allocation and handle cloning. Call .cloned() on server_id(),
connection_id(), modes, or meta, and .clone() on either connection accessor when an owned
value is required.
MCP servers use the native opt-in transport
The runtime-agnostic agent_client_protocol::mcp_server module remains available without an
unstable ACP feature, so standalone MCP servers do not allocate or retain schema transport IDs.
Enable the feature when attaching a server to ACP with Builder::with_mcp_server or
SessionBuilder::with_mcp_server:
agent-client-protocol = { version = "2", features = ["unstable_mcp_over_acp"] }
agent-client-protocol-rmcp no longer enables this feature merely to build or directly serve an
MCP server. Applications that attach an rmcp-backed server to ACP should enable its matching
unstable_mcp_over_acp passthrough feature. The transport remains unstable and may change
independently of the stable ACP surface.
In 1.x, the SDK represented an ACP-provided MCP server as McpServer::Http with an acp: URL and
routed it through SDK-local underscore-prefixed methods. In 2.0, providers and native consumers
use:
McpServer::Acp(McpServerAcp { name, server_id, .. })in session setup requests;mcp/connectwithserverId, returning a distinctconnectionId;mcp/messagerequests and notifications keyed by that connection ID; and- a request/response
mcp/disconnectexchange.
The low-level SDK-local McpConnectRequest, McpConnectResponse, McpOverAcpMessage, and
McpDisconnectNotification types were removed. Use the feature-gated schema types instead:
| 1.x SDK-local type | 2.0 schema type |
|---|---|
McpConnectRequest | schema::v1::ConnectMcpRequest |
McpConnectResponse | schema::v1::ConnectMcpResponse |
McpOverAcpMessage request | schema::v1::MessageMcpRequest |
McpOverAcpMessage notification | schema::v1::MessageMcpNotification |
McpDisconnectNotification | schema::v1::DisconnectMcpRequest and DisconnectMcpResponse |
The public method-name constants moved to the schema’s generated method-name tables:
| 1.x SDK-local constant | 2.0 schema constant |
|---|---|
METHOD_MCP_CONNECT_REQUEST | schema::v1::CLIENT_METHOD_NAMES.mcp_connect |
METHOD_MCP_MESSAGE | schema::v1::CLIENT_METHOD_NAMES.mcp_message or AGENT_METHOD_NAMES.mcp_message, depending on direction |
METHOD_MCP_DISCONNECT_NOTIFICATION | schema::v1::CLIENT_METHOD_NAMES.mcp_disconnect |
Code using Builder::with_mcp_server or SessionBuilder::with_mcp_server continues to attach the
high-level server in the same place; the emitted declaration and wire methods change. Global
builder attachment advertises the same server ID on session/new, session/load,
session/resume, and feature-gated session/fork. Stable v1 per-session attachment remains
specific to session/new; draft v2 additionally supports per-session resume attachment through
V2ResumeSessionBuilder::with_mcp_server and feature-gated fork attachment through
V2ForkSessionBuilder::with_mcp_server. Do not construct an HTTP server with an acp: URL. If the
final agent accepts HTTP but not native ACP MCP servers, insert McpOverAcpPolyfill immediately
before it. The polyfill now consumes native McpServer::Acp declarations and adapts only its
final-agent-facing side.
The polyfill’s public BridgeMode enum and McpOverAcpPolyfill::stdio were removed because the
required conductor mcp helper subcommand no longer exists. The polyfill has one supported mode;
construct it with McpOverAcpPolyfill::http() or Default, or manage a standard MCP transport
separately.
Low-level helpers have a narrower surface
NullHandler::new was removed. Construct the unit struct as NullHandler or use
NullHandler::default().
MatchDispatch::from_handled was removed because it exposed an internal composition state. Start
a standalone match with MatchDispatch::new, or continue a peer-aware chain with
MatchDispatchFrom.
Channel::copy is now an implementation detail. Connect channels through ConnectTo, or use
Channel::bridge_with_inspection when building an inspecting relay.
DynConnectTo::type_name now returns &'static str without allocating. Call .to_owned() when
an owned type name is required.
The generic util::both helper was removed. Replace util::both(a, b).await with
futures::future::try_join(a, b).await.map(|((), ())| ()).
util::process_stream_concurrently is no longer public. An equivalent unbounded fallible loop can
be written with futures::{StreamExt, TryStreamExt}:
stream
.map(Ok::<_, agent_client_protocol::Error>)
.try_for_each_concurrent(None, |item| process_fn(item))
.await
Pass a finite limit instead of None to bound concurrency.
ConnectionTo::attach_session is no longer public. Create sessions through
ConnectionTo::build_session, build_session_cwd, or build_session_from instead. Restore
stable-v1 sessions through load_session, load_session_from, resume_session, or
resume_session_from; blocking start_session returns a RestoredSession containing the
ActiveSession and exact load or resume response, while on_session_start delivers that value to
its callback. Both builder families expose on_session_start for non-blocking use and
block_task().start_session() for tasks already outside the dispatch loop. Restore routing is
acknowledged before the request is published so session/load replay cannot overtake local setup.
For new sessions, SessionBuilder::run_until is also available after block_task(). Proxy
handlers must use on_proxy_session_start; only call block_task().start_session_proxy(...) from
a task already outside the dispatch loop, such as a connect_with foreground future or a spawned
task. Directly attaching an already-returned NewSessionResponse is no longer supported, so move
request customization into build_session_from before the builder sends session/new.
For new sessions, when the response is routed during its original dispatch, on_session_start and
on_proxy_session_start install session routing under the ordered response callback, then spawn
the user callback. No user callback code runs under that ordering guarantee. The callback itself
must be 'static, but its returned future does not need an additional 'static bound and may
safely wait for later connection or session traffic. A response interceptor that retains and
routes the response later cannot retroactively order that setup before already-processed messages.
Register application state needed for routing before calling these helpers. Bookkeeping that
requires the returned session or session ID runs concurrently with later traffic. If handlers
must observe ID-keyed bookkeeping first, install a gate or placeholder before calling the helper,
have those handlers await it, and populate it from the callback.
Construct Lines and ByteStreams with Lines::new(outgoing, incoming) and
ByteStreams::new(outgoing, incoming); their stream fields are no longer public.
Draft v2 schema updates
The optional unstable_protocol_v2 surface now tracks
agent-client-protocol-schema 1.8. The draft API changes accumulated across schema 1.5
through 1.7 are included in the SDK 2.0 migration because this API is explicitly
unstable, rather than treated as stable-v1 wire changes.
- Many values that were plain
StringorPathBuffields are semantic newtypes, includingAbsolutePath,MediaType, session/message/tool/terminal IDs, and list cursors. Construct them with.into()or theirnewmethods, and useAsRef<Path>oras_ref()when borrowing their contents. v2::DiffPatch.diffis nowtext.DiffPatch::new(text)remains the preferred constructor.- Terminal state is represented by
Terminal,TerminalUpdate,TerminalOutput,TerminalOutputChunk, andTerminalExitStatus.SessionUpdatealso has terminal update and output-chunk variants, so exhaustive matches must handle the new variants. - The experimental
v2::conversionmodule and its cross-version helpers have been removed. Implement v1 and v2 handlers separately, and translate only application-owned shared state where the application’s semantics define a faithful mapping.
Schema 1.8 stabilizes optional tool-call name metadata in both v1 and draft
v2. Remove unstable_tool_call_name from your enabled Cargo features; v2 still
requires unstable_protocol_v2. The version-specific update semantics are
unchanged.
SentRequest::map accepts arbitrary output
SentRequest::map can now consume a typed response into any output type; the mapped value no
longer needs to implement JsonRpcResponse. This includes mapped values carrying non-'static
lifetimes when they are consumed with block_task; callback-style consumption still requires
'static because it is spawned onto the connection. The mapper may also be a one-shot closure.
This is additive, so existing mapping code does not need to change.
AcpAgent has its own process configuration
AcpAgent now accepts AcpAgentConfig instead of the ACP wire-schema McpServer. An ACP agent
subprocess is not an MCP server, and its local launch settings should not depend on the v1
protocol schema:
#![allow(unused)]
fn main() {
use agent_client_protocol::{AcpAgent, AcpAgentConfig};
let agent = AcpAgent::new(
AcpAgentConfig::new("my-agent")
.arg("--verbose")
.env("RUST_LOG", "info"),
);
let configuration = agent.config();
let _ = configuration;
}
server() and into_server() are now config() and into_config(). The MCP-only name and
_meta fields have no equivalents because they never affected process launching. Use command(),
arguments(), and environment() to inspect the configuration.
The JSON configuration shape also changes. The MCP type, name, and _meta fields are removed,
and environment variables change from schema objects:
{
"type": "stdio",
"name": "my-agent",
"command": "python",
"args": ["agent.py"],
"env": [{ "name": "RUST_LOG", "value": "info" }]
}
to a string map:
{
"command": "python",
"args": ["agent.py"],
"env": { "RUST_LOG": "info" }
}
Command-string and from_args construction are unchanged. HTTP and SSE McpServer variants were
never valid subprocess launch configurations; callers using them must select an appropriate
network transport separately.
The deprecated AcpAgent::zed_claude_code and AcpAgent::zed_codex constructors were removed;
use claude_agent and codex. The google_gemini convenience constructor was also removed;
replace it with the explicit command:
#![allow(unused)]
fn main() {
let agent = AcpAgent::from_args([
"npx",
"-y",
"--",
"@google/gemini-cli@latest",
"--experimental-acp",
]).unwrap();
}
Migrating from agent-client-protocol 0.10.x to 0.11
This guide explains how to move existing code to the programming model planned for agent-client-protocol 0.11.
Historical note: later releases merged subprocess and stdio support into
agent-client-protocol. The examples below use the current import path where that does not obscure the 0.11 migration itself.
Throughout this guide:
- old API =
agent-client-protocol0.10.x - new API = the planned
agent-client-protocol0.11API
All code snippets below use the intended 0.11 import paths.
1. Move message types under schema
The current 0.10.x crate exports most protocol message types at the crate root.
The 0.11 API moves most ACP request, response, and notification types under schema.
#![allow(unused)]
fn main() {
// Old (0.10.x)
use agent_client_protocol as acp;
use acp::{InitializeRequest, NewSessionRequest, PromptRequest, ProtocolVersion};
// New (0.11)
use agent_client_protocol as acp;
use acp::schema::{
InitializeRequest, NewSessionRequest, PromptRequest, ProtocolVersion,
};
}
Most ACP request, response, and notification types live under schema in 0.11
2. Replace connection construction
The main construction changes are:
ClientSideConnection::new(handler, outgoing, incoming, spawn)- becomes
Client.builder().connect_with(ByteStreams::new(outgoing, incoming), async |cx| { ... })
- becomes
AgentSideConnection::new(handler, outgoing, incoming, spawn)- becomes
Agent.builder().connect_to(ByteStreams::new(outgoing, incoming)).await?
- becomes
- custom
spawnfunction +handle_iofuture- becomes builder-managed connection execution
If you already have stdin/stdout or socket-like byte streams, wrap them with ByteStreams::new(outgoing, incoming).
If you are spawning subprocess agents, prefer the core crate’s AcpAgent over hand-rolled process wiring.
If you already have a reason to stay at the raw request/response level, you can still send PromptRequest directly with cx.send_request(...); the session helpers are just the default migration path for most client code.
3. Replace outbound trait-style calls with send_request and send_notification
In the old API, the connection itself implemented the remote trait, so calling the other side looked like this:
#![allow(unused)]
fn main() {
conn.initialize(InitializeRequest::new(ProtocolVersion::V1)).await?;
}
In 0.11, you send a typed request through ConnectionTo<Peer>:
#![allow(unused)]
fn main() {
cx.send_request(InitializeRequest::new(ProtocolVersion::V1))
.block_task()
.await?;
}
The main replacements are:
| Old style | New style |
|---|---|
conn.initialize(req).await? | cx.send_request(req).block_task().await? |
conn.new_session(req).await? | usually cx.build_session(...) or cx.build_session_cwd()? |
conn.prompt(req).await? | usually session.send_prompt(...) on an ActiveSession |
conn.cancel(notification).await? | cx.send_notification(notification)? |
A few behavioral differences matter during migration:
send_request(...)returns aSentRequest, not the response directly.- Call
.block_task().await?when you want to wait for the response from a context that does not block the dispatch loop. Themain_fnclosure passed toconnect_with(...)and tasks spawned viacx.spawn(...)are both safe; the dispatch loop continues processing messages (including the response you are waiting for) in the background. - Do not call
.block_task().await?insideon_receive_*callbacks. Those callbacks run on the dispatch loop, so blocking them deadlocks the connection. Preferon_receiving_result(...),on_receiving_ok_result(...),on_session_start(...), orcx.spawn(...)from handlers.
4. Replace manual session management with SessionBuilder
One of the biggest user-facing changes is session handling.
Old code typically looked like this:
#![allow(unused)]
fn main() {
let session = conn
.new_session(NewSessionRequest::new(cwd))
.await?;
conn.prompt(PromptRequest::new(
session.session_id.clone(),
vec!["Hello".into()],
))
.await?;
}
New code usually starts from the connection and uses a session builder:
#![allow(unused)]
fn main() {
cx.build_session_cwd()?
.block_task()
.run_until(async |mut session| {
session.send_prompt("Hello")?;
let output = session.read_to_string().await?;
println!("{output}");
Ok(())
})
.await?;
}
Useful replacements:
| Old pattern | New pattern |
|---|---|
new_session(NewSessionRequest::new(cwd)) | build_session(cwd) |
new_session(NewSessionRequest::new(current_dir)) | build_session_cwd()? |
store session_id and pass it into every PromptRequest | let ActiveSession manage the session lifecycle |
subscribe() to observe streamed session output | ActiveSession::read_update() or ActiveSession::read_to_string() |
intercept and rewrite a session/new request in a proxy | build_session_from(request) |
Also note:
- use
start_session()when you want anActiveSession<'static, _>you can keep around - use
on_session_start(...)insideon_receive_*callbacks when you need to start a session without manually blocking the current task - use
on_proxy_session_start(...)orstart_session_proxy(...)for proxy-style session startup and forwarding
5. Replace Client trait impls with builder callbacks
In 0.10.x, your client behavior lived in an impl acp::Client for T block.
In 0.11, register typed handlers on Client.builder() instead.
Each on_receive_* call takes two arguments: the async handler, and one of
the helper macros acp::on_receive_request!(),
acp::on_receive_notification!(), or acp::on_receive_dispatch!(). These
macros are a temporary workaround until return-type notation stabilizes;
pass the one that matches the method you are calling.
Common client-side method mapping
request_permission->.on_receive_request(|req: RequestPermissionRequest, responder, cx| ...)write_text_file->.on_receive_request(|req: WriteTextFileRequest, responder, cx| ...)read_text_file->.on_receive_request(|req: ReadTextFileRequest, responder, cx| ...)create_terminal->.on_receive_request(|req: CreateTerminalRequest, responder, cx| ...)terminal_output->.on_receive_request(|req: TerminalOutputRequest, responder, cx| ...)release_terminal->.on_receive_request(|req: ReleaseTerminalRequest, responder, cx| ...)wait_for_terminal_exit->.on_receive_request(|req: WaitForTerminalExitRequest, responder, cx| ...)kill_terminal->.on_receive_request(|req: KillTerminalRequest, responder, cx| ...)session_notification->.on_receive_notification(|notif: SessionNotification, cx| ...)ext_method/ext_notification-> your own derivedJsonRpcRequest/JsonRpcNotificationtypes, or a catch-allon_receive_dispatch(...)
A small client-side translation looks like this:
use agent_client_protocol as acp;
use acp::schema::{
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
SessionNotification,
};
#[tokio::main]
async fn main() -> acp::Result<()> {
let transport = todo!("create the transport that connects to your agent");
acp::Client
.builder()
.on_receive_request(
async move |_: RequestPermissionRequest, responder, _cx| {
responder.respond(RequestPermissionResponse::new(
RequestPermissionOutcome::Cancelled,
))
},
acp::on_receive_request!(),
)
.on_receive_notification(
async move |notification: SessionNotification, _cx| {
println!("{:?}", notification.update);
Ok(())
},
acp::on_receive_notification!(),
)
.connect_with(transport, async |_cx: acp::ConnectionTo<acp::Agent>| {
// send requests here, e.g. `_cx.send_request(...).block_task().await?`
Ok(())
})
.await
}
6. Replace Agent trait impls with builder callbacks
The same shift applies on the agent side.
Common agent-side method mapping
initialize->.on_receive_request(|req: InitializeRequest, responder, cx| ...)authenticate->.on_receive_request(|req: AuthenticateRequest, responder, cx| ...)new_session->.on_receive_request(|req: NewSessionRequest, responder, cx| ...)prompt->.on_receive_request(|req: PromptRequest, responder, cx| ...)cancel->.on_receive_notification(|notif: CancelNotification, cx| ...)load_session->.on_receive_request(|req: LoadSessionRequest, responder, cx| ...)set_session_mode->.on_receive_request(|req: SetSessionModeRequest, responder, cx| ...)set_session_config_option->.on_receive_request(|req: SetSessionConfigOptionRequest, responder, cx| ...)list_sessionsand other unstable session methods -> request handlers for the corresponding schema typeext_method/ext_notification-> your own derivedJsonRpcRequest/JsonRpcNotificationtypes, or a catch-allon_receive_dispatch(...)
A minimal agent skeleton now looks like this:
use agent_client_protocol as acp;
use acp::schema::{
AgentCapabilities, CancelNotification, InitializeRequest, InitializeResponse,
PromptRequest, PromptResponse, StopReason,
};
use acp::{Client, Dispatch};
#[tokio::main]
async fn main() -> acp::Result<()> {
let outgoing = todo!("create the agent's outgoing byte stream");
let incoming = todo!("create the agent's incoming byte stream");
acp::Agent
.builder()
.name("my-agent")
.on_receive_request(
async move |request: InitializeRequest, responder, _cx| {
responder.respond(
InitializeResponse::new(request.protocol_version)
.agent_capabilities(AgentCapabilities::new()),
)
},
acp::on_receive_request!(),
)
.on_receive_request(
async move |_request: PromptRequest, responder, _cx| {
responder.respond(PromptResponse::new(StopReason::EndTurn))
},
acp::on_receive_request!(),
)
.on_receive_notification(
async move |_notification: CancelNotification, _cx| {
Ok(())
},
acp::on_receive_notification!(),
)
.connect_to(acp::ByteStreams::new(outgoing, incoming))
.await
}
If you need a catch-all handler, use on_receive_dispatch(...).
7. Replace subscribe() with session readers or explicit callbacks
There is no direct connection-level replacement for ClientSideConnection::subscribe().
Choose the replacement based on what you were using it for:
- if you were reading prompt output for one session, prefer
ActiveSession::read_update()orActiveSession::read_to_string() - if you were observing inbound notifications generally, register
on_receive_notification(...) - if you were forwarding or inspecting raw messages in a proxy, use
on_receive_dispatch(...)plussend_proxied_message(...)
8. Remove LocalSet, spawn_local, and manual I/O tasks
The old crate examples needed a LocalSet because the connection futures were !Send.
Most migrations to the 0.11 API can remove:
tokio::task::LocalSettokio::task::spawn_local(...)- the custom
spawnclosure passed into connection construction - the separate
handle_iofuture you had to drive manually
When you need concurrency from a handler in 0.11, use cx.spawn(...).
9. Prefer AcpAgent for subprocess agents
If your old client code spawned an agent with tokio::process::Command, the new stack has a higher-level helper for that. AcpAgent implements ConnectTo<Client> and takes care of spawning the process and wiring up its stdio:
use agent_client_protocol as acp;
use acp::schema::{InitializeRequest, ProtocolVersion};
use agent_client_protocol::AcpAgent;
use std::str::FromStr;
#[tokio::main]
async fn main() -> acp::Result<()> {
let agent = AcpAgent::from_str("python my_agent.py")?;
acp::Client
.builder()
.name("my-client")
.connect_with(agent, async |cx| {
cx.send_request(InitializeRequest::new(ProtocolVersion::V1))
.block_task()
.await?;
// ...send more requests, build a session, etc.
Ok(())
})
.await
}
Use connect_to(agent) (without a main_fn) only when the client has no
outbound requests to send and just needs to keep the connection alive so
registered handlers can respond to the agent. In that mode the builder
runs until the transport closes or a handler returns an error.
You can still use ByteStreams::new(...) when you already own the byte
streams and do not want the extra helper crate.
10. Common gotchas
ClientandAgentare role markers now, not traits you implement.- The response type for
send_request(...)is inferred from the request type. send_request(...)does not wait by itself; use.block_task().await?oron_receiving_result(...).- Be careful about calling blocking operations from
on_receive_*callbacks. Those callbacks run in the dispatch loop and preserve message ordering. - If your old code used
subscribe()as a global message tap, plan a new strategy aroundActiveSession, notification callbacks, or proxy dispatch handlers. - For reusable ACP components, implement
ConnectTo<Role>instead of trying to recreate the old monolithic trait pattern.
Original P/ACP Design Proposal (Historical)
This document records the original proxying design and is retained for historical context. It contains method names and capability shapes that were superseded during implementation. Do not use it as a wire-protocol specification; see the current Protocol Reference instead.
In particular, the SDK-local
_mcp/*methods andMcpServer::Httpvalues using anacp:URL were retired. Current opt-in implementations useMcpServer::Acpwithmcp/connect,mcp/message, andmcp/disconnect; the compatibility polyfill translates those native declarations only for HTTP-capable agents. Do not copy the historical MCP examples below.
Elevator pitch
What are you proposing to change?
We propose to prototype P/ACP (Proxying ACP), an extension to Zed’s Agent Client Protocol (ACP) that enables composable agent architectures through proxy chains. Instead of building monolithic AI tools, P/ACP allows developers to create modular components that can intercept and transform messages flowing between editors and agents.
This RFD builds on the concepts introduced in SymmACP: extending Zed’s ACP to support Composable Agents, with the protocol renamed to P/ACP for this implementation.
Key changes:
- Define a proxy chain architecture where components can transform ACP messages
- Create an orchestrator (Conductor) that manages the proxy chain and presents as a normal ACP agent to editors
- Establish the
_proxy/successor/*protocol for proxies to communicate with downstream components - Enable composition without requiring editors to understand P/ACP internals
Status quo
How do things work today and what problems does this cause? Why would we change things?
Today’s AI agent ecosystem is dominated by monolithic agents. We want people to be able to combine independent components to build custom agents targeting their specific needs. We want them to be able to use these with whatever editors and tooling they have. This is aligned with ACP’s core values of openness, interoperability, and extensibility.
Motivating Example: Sparkle Integration
Consider integrating Sparkle (a collaborative AI framework) into a coding session with Zed and Claude. Sparkle provides an MCP server with tools, but requires an initialization sequence to load patterns and set up collaborative context.
Without P/ACP:
- Users must manually run the initialization sequence each session
- Or use agent-specific hooks (Claude Code has them, but not standardized across agents)
- Or modify the agent to handle initialization automatically
- Result: Manual intervention required, agent-specific configuration, no generic solution
With P/ACP:
flowchart LR
Editor[Editor<br/>Zed]
subgraph Conductor[Conductor Orchestrator]
Sparkle[Sparkle Component]
Agent[Base Agent]
MCP[Sparkle MCP Server]
Sparkle -->|proxy chain| Agent
Sparkle -.->|provides tools| MCP
end
Editor <-->|ACP| Conductor
The Sparkle component:
- Injects Sparkle MCP server into the agent’s tool list during
initialize - Intercepts the first
promptand prepends Sparkle embodiment sequence - Passes all other messages through transparently
From the editor’s perspective, it talks to a normal ACP agent. From the base agent’s perspective, it has Sparkle tools available. No code changes required on either side.
This demonstrates P/ACP’s core value: adding capabilities through composition rather than modification.
What we propose to do about it
What are you proposing to improve the situation?
We will develop an extension to ACP called P/ACP (Proxying ACP).
The heart of P/ACP is a proxy chain where each component adds specific capabilities:
flowchart LR
Editor[ACP Editor]
subgraph Orchestrator[P/ACP Orchestrator]
O[Orchestrator Process]
end
subgraph ProxyChain[Proxy Chain - managed by orchestrator]
P1[Proxy 1]
P2[Proxy 2]
Agent[ACP Agent]
P1 -->|_proxy/successor/*| P2
P2 -->|_proxy/successor/*| Agent
end
Editor <-->|ACP| O
O <-->|routes messages| ProxyChain
P/ACP defines three kinds of actors:
- Editors spawn the orchestrator and communicate via standard ACP
- Orchestrator manages the proxy chain, appears as a normal ACP agent to editors
- Proxies intercept and transform messages, communicate with downstream via
_proxy/successor/*protocol - Agents provide base AI model behavior using standard ACP
The orchestrator handles message routing, making the proxy chain transparent to editors. Proxies can transform requests, responses, or add side-effects without editors or agents needing P/ACP awareness.
The Orchestrator: Conductor
P/ACP’s orchestrator is called the Conductor (binary name: conductor). The conductor has three core responsibilities:
- Process Management - Creates and manages component processes based on command-line configuration
- Message Routing - Routes messages between editor, components, and agent through the proxy chain
- Capability Adaptation - Observes component capabilities and adapts between them
Key adaptation: MCP Bridge
- If the agent supports
mcpCapabilities.acp, conductor passes MCP servers with ACP transport through unchanged - If not, conductor spawns
conductor mcp $portprocesses to bridge between stdio (MCP) and ACP messages - Components can provide MCP servers without requiring agent modifications
- See “MCP Bridge” section in Implementation Details for full protocol
Other adaptations include session pre-population, streaming support, content types, and tool formats.
From the editor’s perspective, it spawns one conductor process and communicates using normal ACP over stdio. The editor doesn’t know about the proxy chain.
Command-line usage:
# Agent mode - manages proxy chain
conductor agent sparkle-acp claude-code-acp
# MCP mode - bridges stdio to TCP for MCP-over-ACP
conductor mcp 54321
To editors, the conductor is a normal ACP agent - no special capabilities are advertised upstream.
Proxy Capability Handshake:
The conductor uses a two-way capability handshake to verify that proxy components can fulfill their responsibilities:
- Conductor offers proxy capability - When initializing non-last components (proxies), the conductor includes
"proxy": truein the_metafield of the InitializeRequest - Component accepts proxy capability - The component must respond with
"proxy": truein the_metafield of its InitializeResponse - Last component (agent) - The final component is treated as a standard ACP agent and does NOT receive the proxy capability offer
Why a two-way handshake? The proxy capability is an active protocol - it requires the component to handle _proxy/successor/* messages and route communications appropriately. Unlike passive capabilities (like “http” or “sse”) which are just declarations, proxy components must actively participate in message routing. If a component doesn’t respond with the proxy capability, the conductor fails initialization with an error like “component X is not a proxy”, since that component cannot fulfill its required function in the chain.
Shiny future
How will things will play out once this feature exists?
Composable Agent Ecosystems
P/ACP enables a marketplace of reusable proxy components. Developers can:
- Compose custom agent pipelines from independently-developed proxies
- Share proxies across different editors and agents
- Test and debug proxies in isolation
- Mix community-developed and custom proxies
Simplified Agent Development
Agent developers can focus on core model behavior without implementing cross-cutting concerns:
- Logging, metrics, and observability become proxy responsibilities
- Rate limiting and caching handled externally
- Content filtering and safety policies applied consistently
Editor Simplicity
Editors gain enhanced functionality without custom integrations:
- Add sophisticated agent behaviors by changing proxy chain configuration
- Support new agent features without editor updates
- Maintain compatibility with any ACP agent
Standardization Path
As the ecosystem matures, successful patterns may be:
- Standardized in ACP specification itself
- Adopted by other agent protocols
- Used as reference implementations for proxy architectures
Implemented Extensions
MCP Bridge - ✅ Implemented via the _mcp/* protocol (see “Implementation details and plan” section). Components can provide MCP servers using ACP transport, enabling tool provision without agents needing P/ACP awareness. The conductor bridges between agents lacking native support and components.
Future Protocol Extensions
Extensions under consideration for future development:
Agent-Initiated Messages - Allow components to send messages after the agent has sent end-turn, outside the normal request-response cycle. Use cases include background task completion notifications, time-based reminders, or autonomous checkpoint creation.
Session Pre-Population - Create sessions with existing conversation history. Conductor adapts based on agent capabilities: uses native support if available, otherwise synthesizes a dummy prompt containing the history, intercepts the response, and starts the real session.
Rich Content Types - Extend content types beyond text to include HTML panels, interactive GUI components, or other structured formats. Components can transform between content types based on what downstream agents support.
Implementation details and plan
Tell me more about your implementation. What is your detailed implementation plan?
The implementation focuses on building the Conductor and demonstrating the Sparkle integration use case.
P/ACP protocol
Definition: Editor vs Agent of a proxy
For an P/ACP proxy, the “editor” is defined as the upstream connection and the “agent” is the downstream connection.
flowchart LR
Editor --> Proxy --> Agent
P/ACP editor capabilities
An P/ACP-aware editor provides the following capability during ACP initialization:
/// Including the symposium section *at all* means that the editor
/// supports symposium proxy initialization.
"_meta": {
"symposium": {
"version": "1.0"
}
}
P/ACP proxies forward the capabilities they receive from their editor.
P/ACP component capabilities
P/ACP uses capabilities in the _meta field for the proxy handshake:
Proxy capability (two-way handshake):
The conductor offers the proxy capability to non-last components in InitializeRequest:
// InitializeRequest from conductor to proxy component
"_meta": {
"symposium": {
"version": "1.0",
"proxy": true
}
}
The component must accept by responding with the proxy capability in InitializeResponse:
// InitializeResponse from proxy component to conductor
"_meta": {
"symposium": {
"version": "1.0",
"proxy": true
}
}
If a component that was offered the proxy capability does not respond with it, the conductor fails initialization.
Agent capability: The last component in the chain (the agent) is NOT offered the proxy capability and does not need to respond with it. Agents are just normal ACP agents with no P/ACP awareness required.
The _proxy/successor/{send,receive} protocol
Proxies communicate with their downstream component (next proxy or agent) through special extension messages handled by the orchestrator:
_proxy/successor/send/request - Proxy wants to send a request downstream:
{
"method": "_proxy/successor/send/request",
"params": {
"message": <ACP_REQUEST>
}
}
_proxy/successor/send/notification - Proxy wants to send a notification downstream:
{
"method": "_proxy/successor/send/notification",
"params": {
"message": <ACP_NOTIFICATION>
}
}
_proxy/successor/receive/request - Orchestrator delivers a request from downstream:
{
"method": "_proxy/successor/receive/request",
"params": {
"message": <ACP_REQUEST>
}
}
_proxy/successor/receive/notification - Orchestrator delivers a notification from downstream:
{
"method": "_proxy/successor/receive/notification",
"params": {
"message": <ACP_NOTIFICATION>
}
}
Message flow example:
- Editor sends ACP
promptrequest to orchestrator - Orchestrator forwards to Proxy1 as normal ACP message
- Proxy1 transforms and sends
_proxy/successor/send/request { message: <modified_prompt> } - Orchestrator routes that to Proxy2 as normal ACP
prompt - Eventually reaches agent, response flows back through chain
- Orchestrator wraps responses going upstream appropriately
Transparent proxy pattern: A pass-through proxy is trivial - just forward everything:
#![allow(unused)]
fn main() {
match message {
// Forward requests from editor to successor
AcpRequest(req) => send_to_successor_request(req),
// Forward notifications from editor to successor
AcpNotification(notif) => send_to_successor_notification(notif),
// Forward from successor back to editor
ExtRequest("_proxy/successor/receive/request", msg) => respond_to_editor(msg),
ExtNotification("_proxy/successor/receive/notification", msg) => forward_to_editor(msg),
}
}
The MCP Bridge: _mcp/* Protocol
P/ACP enables components to provide MCP servers that communicate over ACP messages rather than traditional stdio. This allows components to handle MCP tool calls without agents needing special P/ACP awareness.
MCP Server Declaration with ACP Transport
Components declare MCP servers with ACP transport by using the HTTP MCP server format with a special URL scheme:
{
"tools": {
"mcpServers": {
"sparkle": {
"transport": "http",
"url": "acp:550e8400-e29b-41d4-a716-446655440000",
"headers": {}
}
}
}
}
The acp:$UUID URL signals ACP transport. The component generates the UUID to identify which component handles calls to this MCP server.
Agent Capability: mcpCapabilities.acp
Agents that natively support MCP-over-ACP declare this capability:
{
"agentCapabilities": {
"mcpCapabilities": {
"acp": true
}
}
}
Conductor behavior:
- If the final agent has
mcpCapabilities.acp: true, conductor passes MCP server declarations through unchanged - If the final agent lacks this capability, conductor performs bridging adaptation:
- Binds a fresh TCP port (e.g.,
localhost:54321) - Transforms the MCP server declaration to use
conductor mcp $portas the command - Spawns
conductor mcp $portwhich connects back via TCP and bridges to ACP messages - Always advertises
mcpCapabilities.acp: trueto intermediate components
- Binds a fresh TCP port (e.g.,
Bridging Transformation Example
Original MCP server spec (from component):
{
"sparkle": {
"transport": "http",
"url": "acp:550e8400-e29b-41d4-a716-446655440000",
"headers": {}
}
}
Transformed spec (passed to agent without mcpCapabilities.acp):
{
"sparkle": {
"command": "conductor",
"args": ["mcp", "54321"],
"transport": "stdio"
}
}
The agent thinks it’s talking to a normal MCP server over stdio. The conductor mcp process bridges between stdio (MCP JSON-RPC) and TCP (connection to main conductor), which then translates to ACP _mcp/* messages.
MCP Message Flow Protocol
When MCP tool calls occur, they flow as ACP extension messages:
_mcp/client_to_server/request - Agent calling an MCP tool (flows backward up chain):
{
"jsonrpc": "2.0",
"id": "T1",
"method": "_mcp/client_to_server/request",
"params": {
"url": "acp:550e8400-e29b-41d4-a716-446655440000",
"message": {
"jsonrpc": "2.0",
"id": "mcp-123",
"method": "tools/call",
"params": {
"name": "embody_sparkle",
"arguments": {}
}
}
}
}
Response:
{
"jsonrpc": "2.0",
"id": "T1",
"result": {
"message": {
"jsonrpc": "2.0",
"id": "mcp-123",
"result": {
"content": [{ "type": "text", "text": "Embodiment complete" }]
}
}
}
}
_mcp/client_to_server/notification - Agent sending notification to MCP server:
{
"jsonrpc": "2.0",
"method": "_mcp/client_to_server/notification",
"params": {
"url": "acp:550e8400-e29b-41d4-a716-446655440000",
"message": {
"jsonrpc": "2.0",
"method": "notifications/cancelled",
"params": {}
}
}
}
_mcp/server_to_client/request - MCP server calling back to agent (flows forward down chain):
{
"jsonrpc": "2.0",
"id": "S1",
"method": "_mcp/server_to_client/request",
"params": {
"url": "acp:550e8400-e29b-41d4-a716-446655440000",
"message": {
"jsonrpc": "2.0",
"id": "mcp-456",
"method": "sampling/createMessage",
"params": {
"messages": [...],
"modelPreferences": {...}
}
}
}
}
_mcp/server_to_client/notification - MCP server sending notification to agent:
{
"jsonrpc": "2.0",
"method": "_mcp/server_to_client/notification",
"params": {
"url": "acp:550e8400-e29b-41d4-a716-446655440000",
"message": {
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {
"progressToken": "token-1",
"progress": 50,
"total": 100
}
}
}
}
Message Routing
Client→Server messages (agent calling MCP tools):
- Flow backward up the proxy chain (agent → conductor → components)
- Component matches on
params.urlto identify which MCP server - Component extracts
params.message, handles the MCP call, responds
Server→Client messages (MCP server callbacks):
- Flow forward down the proxy chain (component → conductor → agent)
- Component initiates when its MCP server needs to call back (sampling, logging, progress)
- Conductor routes to agent (or via bridge if needed)
Conductor MCP Mode
The conductor binary has two modes:
-
Agent mode:
conductor agent [proxies...] agent- Manages P/ACP proxy chain
- Routes ACP messages
-
MCP mode:
conductor mcp $port- Acts as MCP server over stdio
- Connects to
localhost:$portvia TCP - Bridges MCP JSON-RPC (stdio) ↔ raw JSON-RPC (TCP to main conductor)
When bridging is needed, the main conductor spawns conductor mcp $port as the child process that the agent communicates with via stdio.
Additional Extension Messages
Proxies can define their own extension messages beyond _proxy/successor/* to provide specific capabilities. Examples might include:
- Logging/observability:
_proxy/logmessages for structured logging - Metrics:
_proxy/metricmessages for tracking usage - Configuration:
_proxy/configmessages for dynamic reconfiguration
The orchestrator can handle routing these messages appropriately, or they can be handled by specific proxies in the chain.
These extensions are beyond the scope of this initial RFD and will be defined as needed by specific proxy implementations.
Implementation progress
What is the current status of implementation and what are the next steps?
Current Status: Implementation Phase
Completed:
- ✅ P/ACP protocol design with Conductor orchestrator architecture
- ✅
_proxy/successor/{send,receive}message protocol defined - ✅
scpRust crate with JSON-RPC layer and ACP message types - ✅ Comprehensive JSON-RPC test suite (21 tests)
- ✅ Proxy message type definitions (
ToSuccessorRequest, etc.)
In Progress:
- Conductor orchestrator implementation
- Sparkle P/ACP component
- MCP Bridge implementation (see checklist below)
MCP Bridge Implementation Checklist
Phase 1: Conductor MCP Mode (COMPLETE ✅)
- Implement
conductor mcp $portCLI parsing - TCP connection to
localhost:$port - Stdio → TCP bridging (read from stdin, send via TCP)
- TCP → Stdio bridging (read from TCP, write to stdout)
- Newline-delimited JSON framing
- Error handling (connection failures, parse errors, reconnection logic)
- Unit tests for message bridging
- Integration test: standalone MCP bridge with mock MCP client/server
Phase 2: Conductor Agent Mode - MCP Detection & Bridging
- Detect
"transport": "http", "url": "acp:$UUID"MCP servers in initialization - Check final agent for
mcpCapabilities.acpcapability - Bind ephemeral TCP ports when bridging needed
- Transform MCP server specs to use
conductor mcp $port - Spawn
conductor mcp $portsubprocess per ACP-transport MCP server - Store mapping:
UUID → TCP port → bridge process - Always advertise
mcpCapabilities.acp: trueto intermediate components - Integration test: full chain with MCP bridging
Phase 3: _mcp/* Message Routing
- Route
_mcp/client_to_server/request(TCP → ACP, backward up chain) - Route
_mcp/client_to_server/notification(TCP → ACP, backward) - Route
_mcp/server_to_client/request(ACP → TCP, forward down chain) - Route
_mcp/server_to_client/notification(ACP → TCP, forward) - URL matching for component routing (
params.urlmatches UUID) - Response routing back through bridge
- Integration test: full
_mcp/*message flow
Phase 4: Bridge Lifecycle Management
- Clean up bridge processes on session end
- Handle bridge process crashes
- Handle component crashes (clean up associated bridges)
- TCP connection cleanup on errors
- Port cleanup and reuse
Phase 5: Component-Side MCP Integration
- Sparkle component declares ACP-transport MCP server
- Sparkle handles
_mcp/client_to_server/*messages - Sparkle initiates
_mcp/server_to_client/*callbacks - End-to-end test: Sparkle embodiment via MCP bridge
Phase 1: Minimal Sparkle Demo
Goal: Demonstrate Sparkle integration through P/ACP composition.
Components:
- Conductor orchestrator - Process management, message routing, capability adaptation
- Sparkle P/ACP component - Injects Sparkle MCP server, handles embodiment sequence
- Integration test - Validates end-to-end flow with mock editor/agent
Demo flow:
Zed → Conductor → Sparkle Component → Claude
↓
Sparkle MCP Server
Success criteria:
- Sparkle MCP server appears in agent’s tool list
- First prompt triggers Sparkle embodiment sequence
- Subsequent prompts work normally
- All other ACP messages pass through unchanged
Detailed MVP Walkthrough
This section shows the exact message flows for the minimal Sparkle demo.
Understanding UUIDs in the flow:
There are two distinct types of UUIDs in these sequences:
-
Message IDs (JSON-RPC request IDs): These identify individual JSON-RPC requests and must be tracked to route responses correctly. When a component forwards a message using
_proxy/successor/request, it creates a fresh message ID for the downstream request and remembers the mapping to route the response back. -
Session IDs (ACP session identifiers): These identify ACP sessions and flow through the chain unchanged. The agent creates a session ID, and all components pass it back unmodified.
Conductor’s routing rules:
- Message from Editor → Forward “as is” to first component (same message ID)
_proxy/successor/requestfrom component → Unwrap payload and send to next component (using message ID from the wrapper)- Response from downstream → Send back to whoever made the
_proxyrequest - First component’s response → Send back to Editor
Components don’t talk directly to each other - all communication flows through Conductor via the _proxy protocol.
Scenario 1: Initialization and Session Creation
The editor spawns Conductor with component names, Conductor spawns the components, and initialization flows through the chain.
sequenceDiagram
participant Editor as Editor<br/>(Zed)
participant Conductor as Conductor<br/>Orchestrator
participant Sparkle as Sparkle<br/>Component
participant Agent as Base<br/>Agent
Note over Editor: Spawns Conductor with args:<br/>"sparkle-acp agent-acp"
Editor->>Conductor: spawn process
activate Conductor
Note over Conductor: Spawns both components
Conductor->>Sparkle: spawn "sparkle-acp"
activate Sparkle
Conductor->>Agent: spawn "agent-acp"
activate Agent
Note over Editor,Agent: === Initialization Phase ===
Editor->>Conductor: initialize (id: I0)
Conductor->>Sparkle: initialize (id: I0)<br/>(offers PROXY capability)
Note over Sparkle: Sees proxy capability offer,<br/>initializes successor
Sparkle->>Conductor: _proxy/successor/request (id: I1)<br/>payload: initialize
Conductor->>Agent: initialize (id: I1)<br/>(NO proxy capability - agent is last)
Agent-->>Conductor: initialize response (id: I1)
Conductor-->>Sparkle: _proxy/successor response (id: I1)
Note over Sparkle: Sees Agent capabilities,<br/>prepares response
Sparkle-->>Conductor: initialize response (id: I0)<br/>(accepts PROXY capability)
Note over Conductor: Verifies Sparkle accepted proxy.<br/>If not, would fail with error.
Conductor-->>Editor: initialize response (id: I0)
Note over Editor,Agent: === Session Creation ===
Editor->>Conductor: session/new (id: U0, tools: M0)
Conductor->>Sparkle: session/new (id: U0, tools: M0)
Note over Sparkle: Wants to inject Sparkle MCP server
Sparkle->>Conductor: _proxy/successor/request (id: U1)<br/>payload: session/new with tools (M0, sparkle-mcp)
Conductor->>Agent: session/new (id: U1, tools: M0 + sparkle-mcp)
Agent-->>Conductor: response (id: U1, sessionId: S1)
Conductor-->>Sparkle: response to _proxy request (id: U1, sessionId: S1)
Note over Sparkle: Remembers mapping U0 → U1
Sparkle-->>Conductor: response (id: U0, sessionId: S1)
Conductor-->>Editor: response (id: U0, sessionId: S1)
Note over Editor,Agent: Session S1 created,<br/>Sparkle MCP server available to agent
Key messages:
-
Editor → Conductor: initialize (id: I0)
{ "jsonrpc": "2.0", "id": "I0", "method": "initialize", "params": { "protocolVersion": "0.1.0", "capabilities": {}, "clientInfo": { "name": "Zed", "version": "0.1.0" } } } -
Conductor → Sparkle: initialize (id: I0, with PROXY capability)
{ "jsonrpc": "2.0", "id": "I0", "method": "initialize", "params": { "protocolVersion": "0.1.0", "capabilities": { "_meta": { "symposium": { "version": "1.0", "proxy": true } } }, "clientInfo": { "name": "Conductor", "version": "0.1.0" } } } -
Sparkle → Conductor: _proxy/successor/request (id: I1, wrapping initialize)
{ "jsonrpc": "2.0", "id": "I1", "method": "_proxy/successor/request", "params": { "message": { "method": "initialize", "params": { "protocolVersion": "0.1.0", "capabilities": {}, "clientInfo": { "name": "Sparkle", "version": "0.1.0" } } } } } -
Conductor → Agent: initialize (id: I1, unwrapped, without PROXY capability)
{ "jsonrpc": "2.0", "id": "I1", "method": "initialize", "params": { "protocolVersion": "0.1.0", "capabilities": {}, "clientInfo": { "name": "Sparkle", "version": "0.1.0" } } } -
Agent → Conductor: initialize response (id: I1)
{ "jsonrpc": "2.0", "id": "I1", "result": { "protocolVersion": "0.1.0", "capabilities": {}, "serverInfo": { "name": "claude-code-acp", "version": "0.1.0" } } } -
Conductor → Sparkle: _proxy/successor response (id: I1, wrapping Agent’s response)
{ "jsonrpc": "2.0", "id": "I1", "result": { "protocolVersion": "0.1.0", "capabilities": {}, "serverInfo": { "name": "claude-code-acp", "version": "0.1.0" } } } -
Sparkle → Conductor: initialize response (id: I0, accepting proxy capability)
{ "jsonrpc": "2.0", "id": "I0", "result": { "protocolVersion": "0.1.0", "capabilities": { "_meta": { "symposium": { "version": "1.0", "proxy": true } } }, "serverInfo": { "name": "Sparkle + claude-code-acp", "version": "0.1.0" } } }Note: Sparkle MUST include
"proxy": truein its response since it was offered the proxy capability. If this field is missing, Conductor will fail initialization with an error. -
Editor → Conductor: session/new (id: U0)
{ "jsonrpc": "2.0", "id": "U0", "method": "session/new", "params": { "tools": { "mcpServers": { "filesystem": { "command": "mcp-filesystem", "args": [] } } } } } -
Conductor → Sparkle: session/new (id: U0, forwarded as-is)
{ "jsonrpc": "2.0", "id": "U0", "method": "session/new", "params": { "tools": { "mcpServers": { "filesystem": { "command": "mcp-filesystem", "args": [] } } } } } -
Sparkle → Conductor: _proxy/successor/request (id: U1, with injected Sparkle MCP)
{
"jsonrpc": "2.0",
"id": "U1",
"method": "_proxy/successor/request",
"params": {
"message": {
"method": "session/new",
"params": {
"tools": {
"mcpServers": {
"filesystem": { "command": "mcp-filesystem", "args": [] },
"sparkle": { "command": "sparkle-mcp", "args": [] }
}
}
}
}
}
}
- Conductor → Agent: session/new (id: U1, unwrapped from _proxy message)
{
"jsonrpc": "2.0",
"id": "U1",
"method": "session/new",
"params": {
"tools": {
"mcpServers": {
"filesystem": { "command": "mcp-filesystem", "args": [] },
"sparkle": { "command": "sparkle-mcp", "args": [] }
}
}
}
}
- Agent → Conductor: response (id: U1, with new session S1)
{
"jsonrpc": "2.0",
"id": "U1",
"result": {
"sessionId": "S1",
"serverInfo": { "name": "claude-code-acp", "version": "0.1.0" }
}
}
- Conductor → Sparkle: _proxy/successor response (id: U1)
{
"jsonrpc": "2.0",
"id": "U1",
"result": {
"sessionId": "S1",
"serverInfo": { "name": "claude-code-acp", "version": "0.1.0" }
}
}
- Sparkle → Conductor: response (id: U0, with session S1)
{
"jsonrpc": "2.0",
"id": "U0",
"result": {
"sessionId": "S1",
"serverInfo": { "name": "Conductor + Sparkle", "version": "0.1.0" }
}
}
Scenario 2: First Prompt (Sparkle Embodiment)
When the first prompt arrives, Sparkle intercepts it and runs the embodiment sequence before forwarding the actual user prompt.
sequenceDiagram
participant Editor as Editor<br/>(Zed)
participant Conductor as Conductor<br/>Orchestrator
participant Sparkle as Sparkle<br/>Component
participant Agent as Base<br/>Agent
Note over Editor,Agent: === First Prompt Flow ===
Editor->>Conductor: session/prompt (id: P0, sessionId: S1)
Conductor->>Sparkle: session/prompt (id: P0, sessionId: S1)
Note over Sparkle: First prompt detected!<br/>Run embodiment sequence first
Sparkle->>Conductor: _proxy/successor/request (id: P1)<br/>payload: session/prompt (embodiment)
Conductor->>Agent: session/prompt (id: P1, embodiment)
Agent-->>Conductor: response (id: P1, tool_use: embody_sparkle)
Conductor-->>Sparkle: response to _proxy request (id: P1)
Note over Sparkle: Embodiment complete,<br/>now send real prompt
Sparkle->>Conductor: _proxy/successor/request (id: P2)<br/>payload: session/prompt (user message)
Conductor->>Agent: session/prompt (id: P2, user message)
Agent-->>Conductor: response (id: P2, actual answer)
Conductor-->>Sparkle: response to _proxy request (id: P2)
Note over Sparkle: Maps P2 → P0
Sparkle-->>Conductor: response (id: P0, actual answer)
Conductor-->>Editor: response (id: P0, actual answer)
Note over Editor,Agent: User sees response,<br/>Sparkle initialized
Key messages:
-
Editor → Conductor: session/prompt (id: P0, user’s first message)
{ "jsonrpc": "2.0", "id": "P0", "method": "session/prompt", "params": { "sessionId": "S1", "messages": [ { "role": "user", "content": "Hello! Can you help me with my code?" } ] } } -
Conductor → Sparkle: session/prompt (id: P0, forwarded as-is)
{ "jsonrpc": "2.0", "id": "P0", "method": "session/prompt", "params": { "sessionId": "S1", "messages": [ { "role": "user", "content": "Hello! Can you help me with my code?" } ] } } -
Sparkle → Conductor: _proxy/successor/request (id: P1, embodiment sequence)
{ "jsonrpc": "2.0", "id": "P1", "method": "_proxy/successor/request", "params": { "message": { "method": "session/prompt", "params": { "sessionId": "S1", "messages": [ { "role": "user", "content": "Please use the embody_sparkle tool to load your collaborative patterns." } ] } } } } -
Conductor → Agent: session/prompt (id: P1, unwrapped embodiment)
{ "jsonrpc": "2.0", "id": "P1", "method": "session/prompt", "params": { "sessionId": "S1", "messages": [ { "role": "user", "content": "Please use the embody_sparkle tool to load your collaborative patterns." } ] } } -
Agent → Conductor: response (id: P1, embodiment tool call)
{ "jsonrpc": "2.0", "id": "P1", "result": { "role": "assistant", "content": [ { "type": "tool_use", "id": "tool-1", "name": "embody_sparkle", "input": {} } ] } } -
Sparkle → Conductor: _proxy/successor/request (id: P2, actual user prompt)
{ "jsonrpc": "2.0", "id": "P2", "method": "_proxy/successor/request", "params": { "message": { "method": "session/prompt", "params": { "sessionId": "S1", "messages": [ { "role": "user", "content": "Hello! Can you help me with my code?" } ] } } } } -
Conductor → Agent: session/prompt (id: P2, unwrapped user prompt)
{ "jsonrpc": "2.0", "id": "P2", "method": "session/prompt", "params": { "sessionId": "S1", "messages": [ { "role": "user", "content": "Hello! Can you help me with my code?" } ] } } -
Sparkle → Conductor: response (id: P0, forwarded to editor)
{ "jsonrpc": "2.0", "id": "P0", "result": { "role": "assistant", "content": "I'd be happy to help you with your code! What would you like to work on?" } }
Scenario 3: Subsequent Prompts (Pass-Through)
After embodiment, Sparkle passes all messages through transparently.
sequenceDiagram
participant Editor as Editor<br/>(Zed)
participant Conductor as Conductor<br/>Orchestrator
participant Sparkle as Sparkle<br/>Component
participant Agent as Base<br/>Agent
Note over Editor,Agent: === Subsequent Prompt Flow ===
Editor->>Conductor: session/prompt (id: P3, sessionId: S1)
Conductor->>Sparkle: session/prompt (id: P3, sessionId: S1)
Note over Sparkle: Already embodied,<br/>pass through unchanged
Sparkle->>Conductor: _proxy/successor/request (id: P4)<br/>payload: session/prompt (unchanged)
Conductor->>Agent: session/prompt (id: P4, unchanged)
Agent-->>Conductor: response (id: P4)
Conductor-->>Sparkle: response to _proxy request (id: P4)
Note over Sparkle: Maps P4 → P3
Sparkle-->>Conductor: response (id: P3)
Conductor-->>Editor: response (id: P3)
Note over Editor,Agent: Normal ACP flow,<br/>Sparkle and Conductor transparent
Key messages:
-
Editor → Conductor: session/prompt (id: P3)
{ "jsonrpc": "2.0", "id": "P3", "method": "session/prompt", "params": { "sessionId": "S1", "messages": [ { "role": "user", "content": "Can you refactor the authenticate function?" } ] } } -
Sparkle → Conductor: _proxy/successor/request (id: P4, message unchanged)
{ "jsonrpc": "2.0", "id": "P4", "method": "_proxy/successor/request", "params": { "message": { "method": "session/prompt", "params": { "sessionId": "S1", "messages": [ { "role": "user", "content": "Can you refactor the authenticate function?" } ] } } } } -
Conductor → Agent: session/prompt (id: P4, unwrapped)
{ "jsonrpc": "2.0", "id": "P4", "method": "session/prompt", "params": { "sessionId": "S1", "messages": [ { "role": "user", "content": "Can you refactor the authenticate function?" } ] } } -
Sparkle → Conductor: response (id: P3, forwarded to editor)
{ "jsonrpc": "2.0", "id": "P3", "result": { "role": "assistant", "content": "I'll help you refactor the authenticate function..." } }
Note that even though Sparkle is passing messages through “transparently”, it still uses the _proxy/successor/request protocol. This maintains the consistent routing pattern where all downstream communication flows through Conductor.
Implementation Note on Embodiment Responses:
For the MVP, when Sparkle runs the embodiment sequence before the user’s actual prompt, it will buffer both responses and concatenate them before sending back to the editor. This makes the embodiment transparent but loses some structure. A future RFD will explore richer content types (like subconversation) that would allow editors to distinguish between nested exchanges and main responses.
Phase 2: Tool Interception (FUTURE)
Goal: Route MCP tool calls through the proxy chain.
Conductor registers as a dummy MCP server. When Claude calls a Sparkle tool, the call routes back through the proxy chain to the Sparkle component for handling. This enables richer component interactions without requiring agents to understand P/ACP.
Phase 3: Additional Components (FUTURE)
Build additional P/ACP components that demonstrate different use cases:
- Session history/context management
- Logging and observability
- Rate limiting
- Content filtering
These will validate the protocol design and inform refinements.
Testing Strategy
Unit tests:
- Test message serialization/deserialization
- Test process spawning logic
- Test stdio communication
Integration tests:
- Spawn real proxy chains
- Use actual ACP agents for end-to-end validation
- Test error handling and cleanup
Manual testing:
- Use with VSCode + ACP-aware agents
- Verify with different proxy configurations
- Test process management under various failure modes
Frequently asked questions
What questions have arisen over the course of authoring this document or during subsequent discussions?
What alternative approaches did you consider, and why did you settle on this one?
We considered extending MCP directly, but MCP is focused on tool provision rather than conversation flow control. We also looked at building everything as VSCode extensions, but that would lock us into a single editor ecosystem.
P/ACP’s proxy chain approach provides the right balance of modularity and compatibility - components can be developed independently while still working together.
How does this relate to other agent protocols like Google’s A2A?
P/ACP is complementary to protocols like A2A. While A2A focuses on agent-to-agent communication for remote services, P/ACP focuses on composing the user-facing development experience. You could imagine P/ACP components that use A2A internally to coordinate with remote agents.
What about security concerns with arbitrary proxy chains?
Users are responsible for the proxies they choose to run, similar to how they’re responsible for the software they install. Proxies can intercept and modify all communication, so trust is essential. For future versions, we’re considering approaches like Microsoft’s Wassette (WASM-based capability restrictions) to provide sandboxed execution environments.
What about the chat GUI interface?
We currently have a minimal chat GUI working in VSCode that can exchange basic messages with ACP agents. However, a richer chat interface with features like message history, streaming support, context providers, and interactive elements remains TBD.
Continue.dev has solved many of the hard problems for production-quality chat interfaces in VS Code extensions. Their GUI is specifically designed to be reusable - they use the exact same codebase for both VS Code and JetBrains IDEs by implementing different adapter layers.
Their architecture proves that message-passing protocols can cleanly separate GUI concerns from backend logic, which aligns perfectly with P/ACP’s composable design. When we’re ready to enhance the chat interface, we can evaluate whether to build on Continue.dev’s foundation or develop our own approach based on what we learn from the P/ACP proxy framework.
The Apache 2.0 license makes this legally straightforward, and their well-documented message protocols provide a clear integration path.
Why not just use hooks or plugins?
Hooks are fundamentally limited to what the host application anticipated. P/ACP proxies can intercept and modify the entire conversation flow, enabling innovations that the original tool designer never envisioned. This is the difference between customization and true composability.
What about performance implications of the proxy chain?
The proxy chain does add some latency as messages pass through multiple hops. However, we don’t expect this to be noticeable for typical development workflows. Most interactions are human-paced rather than high-frequency, and the benefits of composability outweigh the minimal latency cost.
How will users discover and configure proxy chains?
This will be determined over time as the ecosystem develops. We expect solutions to emerge organically, potentially including registries, configuration files, or marketplace-style discovery mechanisms.
What about resource management with multiple proxy processes?
Each proxy manages the lifecycle of processes it starts. When a proxy terminates, it cleans up its downstream processes. This creates a natural cleanup chain that prevents resource leaks.
Revision history
Initial draft based on architectural discussions.