Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ regex = "1.12.2"

[workspace.lints.rust]
future_incompatible = { level = "warn", priority = -1 }
let-underscore = "warn"
let_underscore = "warn"
missing_debug_implementations = "warn"
# missing_docs = "warn"
nonstandard_style = { level = "warn", priority = -1 }
Expand Down
19 changes: 14 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@ Native MCP-over-ACP support is currently opt-in through the core crate's
`unstable_mcp_over_acp` feature. Standalone MCP servers need no ACP transport
feature; the rmcp integration exposes a matching passthrough feature when those
servers are attached to ACP. Stable protocol v1 supports per-session and global
proxy attachment. Per-session attachment through the draft `V2SessionBuilder`
is also available when both `unstable_protocol_v2` and
`unstable_mcp_over_acp` are enabled. Successful v2 attachments remain active
for the connection lifetime; global proxy attachment and proxy-session helpers
remain v1-only.
proxy attachment. Draft protocol v2 supports the same two attachment scopes
when both `unstable_protocol_v2` and `unstable_mcp_over_acp` are enabled:
`Proxy.v2().with_mcp_server(...)` injects a global declaration into each
supported session setup request, while
`V2SessionBuilder::with_mcp_server(...)` attaches a server to one new session.
Successful v2 attachments remain active for the connection lifetime, and
`V2SessionBuilder::on_proxy_session_start` forwards a proxied setup response
without coupling later session events to that response.

**Proxy orchestration**

Expand All @@ -49,6 +52,12 @@ remain v1-only.
session usage are covered in
[Protocol V2](./md/protocol-v2.md).

`Client.builder()`, `Agent.builder()`, and `Proxy.builder()` remain stable-v1
entry points; their `.v2()` counterparts select the draft-v2 API. Raw proxy
routing infrastructure that selects and validates a version itself can use
`without_acp_version_guard`, but ordinary v2 proxy implementations should use
`Proxy.v2()`.

## Integrations

- [Protocol schema and documentation](https://agentclientprotocol.com/)
Expand Down
65 changes: 57 additions & 8 deletions md/protocol-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ of requests or notifications. See [Transport Architecture: JSON-RPC Batch
Behavior](./transport-architecture.md#json-rpc-batch-behavior) for the complete
rules.

By default, `Client.builder()` and `Agent.builder()` continue to expose the
stable v1 API and advertise protocol v1. To use the v2 API for a connection,
construct the builder with `Client.v2()` or `Agent.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:
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:

```rust
use agent_client_protocol::schema::{ProtocolVersion, v2};
Expand Down Expand Up @@ -215,14 +215,63 @@ Runners may continue asynchronous initialization; custom connectors must be
able to queue connections and messages once constructed. A successful setup
promotes the attachment to the connection lifetime; any setup failure cleans it
up. This attachment requires both `unstable_protocol_v2` and
`unstable_mcp_over_acp`. Global proxy attachment and proxy-session helpers
remain v1-only.
`unstable_mcp_over_acp`.

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` is the non-blocking setup helper for
a v2 proxy:

```rust,ignore
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!(),
);
```

The helper forwards request cancellation, sends an ordered downstream
`session/new`, installs session routing before later inbound traffic is
dispatched, and forwards the complete `NewSessionResponse`. It 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.

Raw routing infrastructure is the exception. If a component deliberately
selects and validates the version itself, it 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`
Expand Down
6 changes: 5 additions & 1 deletion src/agent-client-protocol-conductor/src/conductor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -779,12 +779,16 @@ where
// passes through messages but which can trigger the
// tracing events.
if self.trace_handle.is_some() && num_proxies == 0 {
let trace_proxy = Proxy.builder();
#[cfg(feature = "unstable_protocol_v2")]
let trace_proxy = trace_proxy.without_acp_version_guard();

self.connect_to_proxy(
&client,
0,
ComponentIndex::Client,
ComponentIndex::Agent,
Proxy.builder(),
trace_proxy,
)?;
} else {
// Spawn each proxy component
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,18 @@ async fn run_bad_proxy_test(
.await
}

fn assert_initialize_proxy_rejection(error: &agent_client_protocol::Error) {
#[cfg(feature = "unstable_protocol_v2")]
let expected = "_proxy/initialize";
#[cfg(not(feature = "unstable_protocol_v2"))]
let expected = "initialize/proxy";

assert!(
error.to_string().contains(expected),
"error should mention {expected}: {error:?}"
);
}

#[tokio::test]
async fn test_conductor_rejects_initialize_proxy_forwarded_to_agent()
-> Result<(), agent_client_protocol::Error> {
Expand All @@ -327,10 +339,7 @@ async fn test_conductor_rejects_initialize_proxy_forwarded_to_agent()
.await;

if let Err(err) = init_response {
assert!(
err.to_string().contains("initialize/proxy"),
"Error should mention initialize/proxy: {err:?}"
);
assert_initialize_proxy_rejection(&err);
}

Ok::<(), agent_client_protocol::Error>(())
Expand All @@ -340,12 +349,7 @@ async fn test_conductor_rejects_initialize_proxy_forwarded_to_agent()

match result {
Ok(()) => panic!("Expected error when proxy forwards InitializeProxyRequest to agent"),
Err(err) => {
assert!(
err.to_string().contains("initialize/proxy"),
"Error should mention initialize/proxy: {err:?}"
);
}
Err(err) => assert_initialize_proxy_rejection(&err),
}

Ok(())
Expand All @@ -370,10 +374,7 @@ async fn test_conductor_rejects_initialize_proxy_forwarded_to_proxy()

// The error may come through recv() or bubble up through the test harness
if let Err(err) = init_response {
assert!(
err.to_string().contains("initialize/proxy"),
"Error should mention initialize/proxy: {err:?}"
);
assert_initialize_proxy_rejection(&err);
}

Ok::<(), agent_client_protocol::Error>(())
Expand All @@ -384,12 +385,7 @@ async fn test_conductor_rejects_initialize_proxy_forwarded_to_proxy()
// The error might bubble up through run_test_with_components instead
match result {
Ok(()) => panic!("Expected error when proxy forwards InitializeProxyRequest to proxy"),
Err(err) => {
assert!(
err.to_string().contains("initialize/proxy"),
"Error should mention initialize/proxy: {err:?}"
);
}
Err(err) => assert_initialize_proxy_rejection(&err),
}

Ok(())
Expand Down
Loading