Expand description
Building proxies that intercept and modify messages.
A proxy sits between a client and an agent, intercepting messages in both directions. This is how you add capabilities like MCP tools, logging, or message transformation.
§The Proxy Role Type
Proxies use the Proxy role type, which has two peers:
Unlike simpler links, there’s no default peer - you must always specify which direction you’re communicating with.
§Choosing a Protocol Version
Proxy::builder creates a stable protocol v1 proxy. With the
unstable_protocol_v2 feature, Proxy.v2() creates a v2-only proxy whose
fluent callbacks receive V2ConnectionTo<Conductor>. The builder
validates _proxy/initialize and later traffic against the selected
version.
Use Proxy.protocol_router() to package separate v1 and v2 implementations
behind one ConnectTo<Conductor> component. It dispatches the
conductor-selected _proxy/initialize version exactly and performs no
cross-version conversion.
Low-level infrastructure implementing custom raw version routing can use
Proxy.builder().without_acp_version_guard(). Disabling the guard is an
explicit version-neutral escape hatch, not the ordinary way to author a v2
proxy.
§Default Forwarding
By default, Proxy forwards all messages it doesn’t handle.
This means a minimal stable v1 proxy that does nothing is just:
Proxy.builder()
.connect_to(transport)
.await?;All messages pass through unchanged.
§Intercepting Messages
To intercept specific messages, use on_receive_*_from with explicit peers:
Proxy.builder()
// Intercept requests from the client
.on_receive_request_from(Client, async |req: ProcessRequest, responder, cx| {
// Modify the request
let modified = ProcessRequest {
data: format!("prefix: {}", req.data),
};
// Forward to agent and relay the response back
cx.send_request_to(Agent, modified)
.forward_response_to(responder)
}, agent_client_protocol::on_receive_request!())
.connect_to(transport)
.await?;Messages you don’t handle are forwarded automatically.
§Adding MCP Servers
A common use case is adding tools via MCP. You can add them globally (available in all sessions) or per-session.
These ACP attachment APIs require the unstable_mcp_over_acp feature.
Draft v2 attachment additionally requires unstable_protocol_v2.
§Global MCP Server
Proxy.builder()
.with_mcp_server(my_mcp_server)
.connect_to(transport)
.await?;For draft v2, select the v2 proxy builder before attaching the global server:
Proxy.v2()
.with_mcp_server(my_mcp_server)
.connect_to(transport)
.await?;The v1 builder injects the declaration into new, load, resume, and feature-gated fork requests. The v2 builder injects it into new, resume, and feature-gated fork requests while preserving unrelated setup fields. Both reuse one connection-scoped server ID.
§Per-Session MCP Server
Proxy.builder()
.on_receive_request_from(Client, async |req: NewSessionRequest, responder, cx| {
let my_mcp_server = McpServer::<Conductor, _>::builder("tools").build();
cx.build_session_from(req)
.with_mcp_server(my_mcp_server)?
.on_proxy_session_start(responder, async |session_id| {
// Session started with MCP server attached
Ok(())
})
}, agent_client_protocol::on_receive_request!())
.connect_to(transport)
.await?;The corresponding v2 proxy uses Proxy.v2(), a
schema::v2::NewSessionRequest, and the same fluent session-builder shape.
Its V2SessionBuilder::on_proxy_session_start callback receives an
OpenedV2Session containing both the command-only v2 session handle and the
complete NewSessionResponse:
Proxy.v2()
.on_receive_request_from(
Client,
async |request: schema::v2::NewSessionRequest, responder, cx| {
cx.build_session_from(request)
.with_mcp_server(my_mcp_server)?
.on_proxy_session_start(responder, async |opened| {
let (session, response) = opened.into_parts();
track_session(session.session_id(), response);
Ok(())
})
},
agent_client_protocol::on_receive_request!(),
);For schema::v2::ResumeSessionRequest, use
cx.resume_session_from(request) and the resulting
V2ResumeSessionBuilder in the same shape. With
unstable_session_fork, use cx.fork_session_from(request) and
V2ForkSessionBuilder for ForkSessionRequest; the returned session and
installed route use the new ID from ForkSessionResponse, not the source
session ID. Resume routing and any per-session MCP attachment are ready
before the downstream request is published, allowing replay to precede the
complete response. All setup helpers forward that operation’s response
before spawning the callback. Later updates and interactive requests remain
independent traffic handled by typed connection callbacks.
§The Conductor
Proxies don’t run standalone - they’re orchestrated by a conductor. The conductor:
- Spawns proxy processes
- Chains them together
- Connects the final proxy to the agent
The agent-client-protocol-conductor crate provides a conductor binary. You configure
it with a list of proxies to run.
§Proxy Chains
Multiple proxies can be chained:
Client <-> Proxy A <-> Proxy B <-> AgentEach proxy sees messages from its perspective:
Clientis “toward the client” (Proxy A, or conductor if first)Agentis “toward the agent” (Proxy B, or agent if last)
Messages flow through each proxy in order. Each can inspect, modify, or handle messages before they continue.
§Summary
| Task | Approach |
|---|---|
| Forward everything | Just connect_to(transport) |
| Author a v1 or v2 proxy | Proxy.builder() or Proxy.v2() |
| Package v1 and v2 implementations | Proxy.protocol_router().with_v1(...).with_v2(...) |
| Implement custom raw routing | without_acp_version_guard on the raw proxy builder |
| Intercept specific messages | on_receive_*_from with explicit peers |
| Add global tools | with_mcp_server on builder |
| Add per-session tools | with_mcp_server on session builder |