Skip to content

Commit 9e1e945

Browse files
benbrandtclaude
andauthored
feat(acp)!: version-neutral raw JSON-RPC request IDs (#466)
* test(acp): pin the request ID wire format of v1, v2 and HTTP connections Drive agents over newline-delimited JSON, and an HttpClient against an AcpHttpServer over HTTP and WebSocket, comparing each message's JSON text. Integer, string and (v1 only) null IDs chosen by the peer are echoed exactly, IDs the SDK chooses match the peer's responses, and IDs outside the 64-bit signed range are rejected with id: null. Co-Authored-By: Claude <noreply@anthropic.com> * feat(acp)!: version-neutral raw JSON-RPC request IDs Add `RawJsonRpcId`, the SDK's own JSON-RPC ID (null, i64 or string), and use it throughout the raw layer instead of `schema::v1::RequestId`: raw requests and responses, `UntypedMessage` conversions, the incoming and outgoing actors, protocol negotiation, and the request handles. The HTTP transport routes responses by it, and the conductor's tracer records it. `RawJsonRpcId` serializes exactly like the schema IDs, so the wire format is unchanged. It converts losslessly to and from `v1::RequestId` and from `v2::RequestId`; converting to `v2::RequestId` fails only for null, with the Invalid Request error a v2 connection sends for a null request ID. `RawJsonRpcMessage::Request` now holds the SDK's `RawJsonRpcRequest`, and `RawJsonRpcResponse` is the SDK's own enum with the same variants and constructor. The `v2_id()` accessors stay; `Responder::v2_id` is now a plain conversion and the private `v2_request_id` helper is gone. BREAKING CHANGE: `id()` on `Responder`, `ResponseRouter`, `SentRequest` and `PreparedRequest` returns `&RawJsonRpcId`, `Dispatch::id()` and `RawJsonRpcMessage::response_id()` return `Option<&RawJsonRpcId>`, `RawJsonRpcMessage::Request` holds a `RawJsonRpcRequest` instead of `schema::v1::Request<RawJsonRpcParams>`, and `RawJsonRpcResponse` is no longer an alias of `schema::v1::Response`. Constructors and `send_cancel_request` take `impl Into<RawJsonRpcId>`, so existing `v1::RequestId` arguments still compile. See `md/migration-v4.md`. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ec06aaa commit 9e1e945

39 files changed

Lines changed: 1181 additions & 335 deletions

‎md/migration-v4.md‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,71 @@ Because the two used to be the same type, some code imported `Error` from
117117
`schema::v1` and used it with SDK callbacks. Import
118118
`agent_client_protocol::Error` instead, or convert with `.into()`.
119119

120+
## A version-neutral `RawJsonRpcId`
121+
122+
The raw JSON-RPC layer and the request handles used `schema::v1::RequestId`
123+
as their ID type, even on v2 connections. They now use the SDK's own
124+
`agent_client_protocol::RawJsonRpcId`, the same on every protocol version:
125+
126+
```rust,ignore
127+
pub enum RawJsonRpcId {
128+
Null,
129+
Number(i64),
130+
Str(String),
131+
}
132+
```
133+
134+
It has the same variants, serialization, `Display`, `Hash` and ordering as
135+
`schema::v1::RequestId`, so IDs are sent on the wire exactly as before: `1`
136+
and `"1"` stay distinct, and integers keep the full 64-bit signed range.
137+
138+
### What changed
139+
140+
| Before | After |
141+
| --- | --- |
142+
| `RawJsonRpcMessage::Request(schema::v1::Request<RawJsonRpcParams>)` | `RawJsonRpcMessage::Request(RawJsonRpcRequest)`, with the same `id`, `method` and `params` fields |
143+
| `RawJsonRpcResponse`, an alias of `schema::v1::Response<Value, Box<RawJsonRpcError>>` | `RawJsonRpcResponse`, the SDK's own enum with the same `Result { id, result }` and `Error { id, error }` variants and `new` |
144+
| `RawJsonRpcMessage::response_id()` returned `Option<&v1::RequestId>` | returns `Option<&RawJsonRpcId>` |
145+
| `id()` on `Responder`, `ResponseRouter`, `SentRequest` and `PreparedRequest` returned `&v1::RequestId` | returns `&RawJsonRpcId` |
146+
| `Dispatch::id()` returned `Option<&v1::RequestId>` | returns `Option<&RawJsonRpcId>` |
147+
148+
`RawJsonRpcMessage::request`, `RawJsonRpcMessage::response` and
149+
`RawJsonRpcResponse::new` take `impl Into<RawJsonRpcId>`, so passing a
150+
`v1::RequestId`, `v2::RequestId`, `i64` or `String` still compiles. So do
151+
`ConnectionTo::send_cancel_request` and `send_cancel_request_to`. A bare
152+
`1.into()` no longer infers its type; write `RawJsonRpcId::Number(1)`.
153+
154+
### Updating your code
155+
156+
Replace `schema::v1::RequestId` with `RawJsonRpcId` where you build or match
157+
raw messages or store handle IDs:
158+
159+
```rust,ignore
160+
use agent_client_protocol::{RawJsonRpcId, RawJsonRpcMessage};
161+
162+
// Before
163+
let request = RawJsonRpcMessage::request(method, params, v1::RequestId::Number(1))?;
164+
let pending: HashMap<v1::RequestId, Route> = HashMap::new();
165+
166+
// After
167+
let request = RawJsonRpcMessage::request(method, params, RawJsonRpcId::Number(1))?;
168+
let pending: HashMap<RawJsonRpcId, Route> = HashMap::new();
169+
```
170+
171+
Where a schema type needs a request ID, such as `v1::CancelRequestNotification`
172+
or an elicitation's `requestId`, convert. `RawJsonRpcId` converts losslessly to
173+
and from `v1::RequestId`, and from `v2::RequestId`. Converting to
174+
`v2::RequestId` fails only for `null`, with the same Invalid Request error
175+
(`-32600`) a v2 connection sends for a `null` request ID:
176+
177+
```rust,ignore
178+
let v1_id = v1::RequestId::from(responder.id().clone());
179+
let raw_id = RawJsonRpcId::from(cancel.request_id);
180+
let v2_id = v2::RequestId::try_from(responder.id().clone())?;
181+
```
182+
183+
The `v2_id()` accessors are unchanged.
184+
120185
## Proxy method names
121186

122187
The proxy protocol now uses the method names defined by the

‎md/protocol-v2.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -407,8 +407,8 @@ The SDK handles the `initialize` negotiation at the JSON-RPC boundary:
407407
`null` ids.
408408
- Since schema 1.13, v2 has its own `schema::v2::RequestId` without a `Null`
409409
variant. The `id()` accessors on `PreparedRequest`, `SentRequest`,
410-
`Responder`, `ResponseRouter`, and `Dispatch` keep returning the
411-
version-neutral `schema::v1::RequestId`. Use `v2_id()` for the v2 form, for
410+
`Responder`, `ResponseRouter`, and `Dispatch` return the SDK's
411+
version-neutral `RawJsonRpcId`. Use `v2_id()` for the v2 form, for
412412
example to match an elicitation's `requestId`. It returns the ID directly for
413413
requests the SDK sends, since the SDK never assigns `null`. For `Responder`
414414
and `Dispatch` it returns an `Option` that is `None` only for a `null` ID,

‎md/transport-architecture.md‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,21 @@ This separation enables:
3737
### The `TransportFrame` Boundary
3838

3939
The public, transport-neutral boundary is `TransportFrame`. A frame contains
40-
one `RawJsonRpcMessage` or a malformed wire value that a relay must preserve. `RawJsonRpcMessage` is backed
41-
by the JSON-RPC envelope types from `agent-client-protocol-schema`:
40+
one `RawJsonRpcMessage` or a malformed wire value that a relay must preserve.
41+
`RawJsonRpcMessage` is the same for every ACP protocol version:
4242

4343
```rust
4444
enum RawJsonRpcMessage {
45-
Request(Request<RawJsonRpcParams>),
45+
Request(RawJsonRpcRequest),
4646
Notification(Notification<RawJsonRpcParams>),
4747
Response(RawJsonRpcResponse),
4848
}
4949
```
5050

51+
Requests and responses carry a `RawJsonRpcId`: the JSON-RPC `id` exactly as
52+
it appears on the wire (an integer, a string or `null`). Each protocol
53+
version's schema has its own request ID type, and converts to and from it.
54+
5155
At that boundary:
5256

5357
- **Above**: Protocol layer works with application types (`OutgoingMessage`, `UntypedMessage`)

‎src/agent-client-protocol-conductor/src/trace.rs‎

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,10 @@ use std::time::Instant;
1212
use agent_client_protocol::schema::SuccessorMessage;
1313
use agent_client_protocol::schema::v1::{
1414
MessageMcpNotification, MessageMcpRequest, MessageMcpResponse, Notification as RpcNotification,
15-
Request as RpcRequest, RequestId,
1615
};
1716
use agent_client_protocol::{
18-
DynConnectTo, JsonRpcMessage, RawJsonRpcMessage, RawJsonRpcParams,
19-
RawJsonRpcResponse as RpcResponse, Role, UntypedMessage,
17+
DynConnectTo, JsonRpcMessage, RawJsonRpcId, RawJsonRpcMessage, RawJsonRpcParams,
18+
RawJsonRpcRequest, RawJsonRpcResponse as RpcResponse, Role, UntypedMessage,
2019
};
2120
use rustc_hash::FxHashMap;
2221
use serde::{Deserialize, Serialize};
@@ -390,7 +389,7 @@ impl TraceWriter {
390389
component_index: ComponentIndex,
391390
successor_index: ComponentIndex,
392391
successor: Successor,
393-
id: Option<RequestId>,
392+
id: Option<RawJsonRpcId>,
394393
protocol: Protocol,
395394
method: String,
396395
params: serde_json::Value,
@@ -522,8 +521,8 @@ impl TraceHandle {
522521
}
523522

524523
/// Convert a JSON-RPC id to serde_json::Value.
525-
fn id_to_json(id: &RequestId) -> serde_json::Value {
526-
serde_json::to_value(id).expect("RequestId serializes infallibly")
524+
fn id_to_json(id: &RawJsonRpcId) -> serde_json::Value {
525+
serde_json::to_value(id).expect("RawJsonRpcId serializes infallibly")
527526
}
528527

529528
fn params_from_transport(params: Option<RawJsonRpcParams>) -> serde_json::Value {
@@ -706,7 +705,7 @@ struct TracedMessage {
706705
#[derive(Debug)]
707706
struct MessageInfo {
708707
successor: Successor,
709-
id: Option<RequestId>,
708+
id: Option<RawJsonRpcId>,
710709
protocol: Protocol,
711710
method: String,
712711
params: serde_json::Value,
@@ -726,7 +725,7 @@ impl MessageInfo {
726725
/// - `mcp/message` messages are detected and marked as MCP protocol
727726
///
728727
/// Returns (protocol, method, params).
729-
fn from_request(req: RpcRequest<RawJsonRpcParams>) -> Self {
728+
fn from_request(req: RawJsonRpcRequest) -> Self {
730729
let untyped =
731730
UntypedMessage::parse_message(&req.method, &params_from_transport(req.params))
732731
.expect("untyped message is infallible");
@@ -744,7 +743,7 @@ impl MessageInfo {
744743

745744
fn from_untyped_request(
746745
successor: Successor,
747-
id: Option<RequestId>,
746+
id: Option<RawJsonRpcId>,
748747
protocol: Protocol,
749748
untyped: UntypedMessage,
750749
) -> Self {
@@ -798,7 +797,7 @@ impl MessageInfo {
798797

799798
fn new(
800799
successor: Successor,
801-
id: Option<RequestId>,
800+
id: Option<RawJsonRpcId>,
802801
protocol: Protocol,
803802
untyped: UntypedMessage,
804803
) -> Self {
@@ -926,7 +925,7 @@ mod tests {
926925
let bridge = tokio::task::spawn_local(bridge.connect_to(client));
927926
drop(handle);
928927

929-
let request = RawJsonRpcMessage::request("test/request".into(), payload.clone(), 1.into()).unwrap();
928+
let request = RawJsonRpcMessage::request("test/request".into(), payload.clone(), agent_client_protocol::RawJsonRpcId::Number(1)).unwrap();
930929
let notification = RawJsonRpcMessage::notification("test/notification".into(), payload.clone()).unwrap();
931930
for message in [request, notification] {
932931
let frame = TransportFrame::Single(message);
@@ -935,7 +934,7 @@ mod tests {
935934
let forwarded = base_peer.rx.next().await.unwrap();
936935
assert_eq!(forwarded.to_json().unwrap(), original_wire);
937936
}
938-
let response = TransportFrame::Single(RawJsonRpcMessage::response(1.into(), Ok(payload.clone())));
937+
let response = TransportFrame::Single(RawJsonRpcMessage::response(agent_client_protocol::RawJsonRpcId::Number(1), Ok(payload.clone())));
939938
let original_wire = response.to_json().unwrap();
940939
base_peer.tx.unbounded_send(response).unwrap();
941940
assert_eq!(client_peer.rx.next().await.unwrap().to_json().unwrap(), original_wire);

‎src/agent-client-protocol-conductor/tests/initialization_v2.rs‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,7 +1028,10 @@ async fn v2_proxy_session_helper_reissues_cancellation_for_the_downstream_hop()
10281028
.await
10291029
.expect("agent should observe the reissued cancellation")
10301030
.ok_or_else(|| Error::internal_error().data("cancellation channel closed"))?;
1031-
assert_eq!(cancelled_id, parked_id);
1031+
assert_eq!(
1032+
agent_client_protocol::RawJsonRpcId::from(cancelled_id),
1033+
parked_id
1034+
);
10321035
assert!(
10331036
cancel_rx.try_recv().is_err(),
10341037
"the downstream hop must receive exactly one cancellation"
@@ -1147,7 +1150,10 @@ async fn v2_proxy_resume_helper_reissues_cancellation_for_the_downstream_hop() -
11471150
.await
11481151
.expect("agent should observe the reissued resume cancellation")
11491152
.ok_or_else(|| Error::internal_error().data("cancellation channel closed"))?;
1150-
assert_eq!(cancelled_id, parked_id);
1153+
assert_eq!(
1154+
agent_client_protocol::RawJsonRpcId::from(cancelled_id),
1155+
parked_id
1156+
);
11511157
assert!(
11521158
cancel_rx.try_recv().is_err(),
11531159
"the downstream hop must receive exactly one cancellation"

‎src/agent-client-protocol-conductor/tests/request_cancellation.rs‎

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,12 @@ use std::sync::atomic::{AtomicBool, Ordering};
1818
use std::sync::{Arc, Mutex};
1919
use std::time::Duration;
2020

21-
use agent_client_protocol::DynConnectTo;
2221
use agent_client_protocol::schema::ProtocolVersion;
2322
use agent_client_protocol::schema::v1::{
2423
CancelRequestNotification, ContentBlock, ContentChunk, InitializeRequest, InitializeResponse,
2524
McpRequestId, McpServer as SchemaMcpServer, McpServerAcpId, MessageMcpNotification,
2625
MessageMcpRequest, MessageMcpResponse, NewSessionRequest, NewSessionResponse, PermissionOption,
27-
PermissionOptionKind, PromptRequest, PromptResponse, RequestId, RequestPermissionOutcome,
26+
PermissionOptionKind, PromptRequest, PromptResponse, RequestPermissionOutcome,
2827
RequestPermissionRequest, RequestPermissionResponse, SelectedPermissionOutcome, SessionId,
2928
SessionNotification, SessionUpdate, StopReason, ToolCallUpdate, ToolCallUpdateFields,
3029
};
@@ -34,6 +33,7 @@ use agent_client_protocol::{
3433
mcp_server::{McpConnectionTo, McpServer, McpServerConnect},
3534
role,
3635
};
36+
use agent_client_protocol::{DynConnectTo, RawJsonRpcId};
3737
use agent_client_protocol_conductor::{ConductorImpl, ProxiesAndAgent};
3838
use futures::StreamExt as _;
3939
use futures::channel::mpsc;
@@ -180,7 +180,9 @@ async fn client_cancellation_propagates_hop_by_hop_to_agent() -> Result<(), Erro
180180
)
181181
.on_receive_notification(
182182
async move |cancel: CancelRequestNotification, _cx: ConnectionTo<Client>| {
183-
agent_cancel_tx.unbounded_send(cancel.request_id).unwrap();
183+
agent_cancel_tx
184+
.unbounded_send(RawJsonRpcId::from(cancel.request_id))
185+
.unwrap();
184186
Ok(())
185187
},
186188
agent_client_protocol::on_receive_notification!(),
@@ -357,7 +359,9 @@ async fn agent_cancellation_propagates_hop_by_hop_to_client() -> Result<(), Erro
357359
)
358360
.on_receive_notification(
359361
async move |cancel: CancelRequestNotification, _cx: ConnectionTo<Agent>| {
360-
client_cancel_tx.unbounded_send(cancel.request_id).unwrap();
362+
client_cancel_tx
363+
.unbounded_send(RawJsonRpcId::from(cancel.request_id))
364+
.unwrap();
361365
Ok(())
362366
},
363367
agent_client_protocol::on_receive_notification!(),
@@ -513,7 +517,9 @@ async fn prompt_cancellation_cascades_through_real_proxy_chain() -> Result<(), E
513517
)
514518
.on_receive_notification(
515519
async move |cancel: CancelRequestNotification, _cx: ConnectionTo<Client>| {
516-
agent_cancel_tx.unbounded_send(cancel.request_id).unwrap();
520+
agent_cancel_tx
521+
.unbounded_send(RawJsonRpcId::from(cancel.request_id))
522+
.unwrap();
517523
Ok(())
518524
},
519525
agent_client_protocol::on_receive_notification!(),
@@ -572,7 +578,9 @@ async fn prompt_cancellation_cascades_through_real_proxy_chain() -> Result<(), E
572578
)
573579
.on_receive_notification(
574580
async move |cancel: CancelRequestNotification, _cx: ConnectionTo<Agent>| {
575-
client_cancel_tx.unbounded_send(cancel.request_id).unwrap();
581+
client_cancel_tx
582+
.unbounded_send(RawJsonRpcId::from(cancel.request_id))
583+
.unwrap();
576584
Ok(())
577585
},
578586
agent_client_protocol::on_receive_notification!(),
@@ -694,7 +702,9 @@ async fn session_new_cancellation_propagates_through_proxy() -> Result<(), Error
694702
)
695703
.on_receive_notification(
696704
async move |cancel: CancelRequestNotification, _cx: ConnectionTo<Client>| {
697-
agent_cancel_tx.unbounded_send(cancel.request_id).unwrap();
705+
agent_cancel_tx
706+
.unbounded_send(RawJsonRpcId::from(cancel.request_id))
707+
.unwrap();
698708
Ok(())
699709
},
700710
agent_client_protocol::on_receive_notification!(),
@@ -813,7 +823,9 @@ async fn proxy_session_helper_cancellation_propagates_to_agent() -> Result<(), E
813823
)
814824
.on_receive_notification(
815825
async move |cancel: CancelRequestNotification, _cx: ConnectionTo<Client>| {
816-
agent_cancel_tx.unbounded_send(cancel.request_id).unwrap();
826+
agent_cancel_tx
827+
.unbounded_send(RawJsonRpcId::from(cancel.request_id))
828+
.unwrap();
817829
Ok(())
818830
},
819831
agent_client_protocol::on_receive_notification!(),
@@ -1009,7 +1021,9 @@ async fn proxy_session_helper_cleans_up_mcp_handlers_after_cancelled_session() -
10091021
)
10101022
.on_receive_notification(
10111023
async move |cancel: CancelRequestNotification, _cx: ConnectionTo<Client>| {
1012-
agent_cancel_tx.unbounded_send(cancel.request_id).unwrap();
1024+
agent_cancel_tx
1025+
.unbounded_send(RawJsonRpcId::from(cancel.request_id))
1026+
.unwrap();
10131027
Ok(())
10141028
},
10151029
agent_client_protocol::on_receive_notification!(),
@@ -1123,8 +1137,8 @@ async fn proxy_session_helper_cleans_up_mcp_handlers_after_cancelled_session() -
11231137

11241138
#[derive(Clone)]
11251139
struct ParkedMcpServer {
1126-
started_tx: mpsc::UnboundedSender<RequestId>,
1127-
stopped_tx: mpsc::UnboundedSender<RequestId>,
1140+
started_tx: mpsc::UnboundedSender<RawJsonRpcId>,
1141+
stopped_tx: mpsc::UnboundedSender<RawJsonRpcId>,
11281142
dropped_tx: mpsc::UnboundedSender<()>,
11291143
late_tx: mpsc::UnboundedSender<ConnectionTo<role::mcp::Client>>,
11301144
}
@@ -1325,7 +1339,7 @@ async fn mcp_request_cancellation_crosses_proxy_and_tears_down_backend() -> Resu
13251339
assert_ne!(outer_id, backend_id, "JSON-RPC IDs must be hop-local");
13261340
assert_eq!(
13271341
backend_id,
1328-
RequestId::Str("logical-mcp-request".to_owned()),
1342+
RawJsonRpcId::Str("logical-mcp-request".to_owned()),
13291343
"the inner MCP ID must survive the proxy unchanged"
13301344
);
13311345
cancel_gate_tx
@@ -1390,7 +1404,9 @@ async fn initialize_cancellation_propagates_through_proxy() -> Result<(), Error>
13901404
// separately so the request handler above can keep its captures.
13911405
let agent = agent.on_receive_notification(
13921406
async move |cancel: CancelRequestNotification, _cx: ConnectionTo<Client>| {
1393-
agent_cancel_tx.unbounded_send(cancel.request_id).unwrap();
1407+
agent_cancel_tx
1408+
.unbounded_send(RawJsonRpcId::from(cancel.request_id))
1409+
.unwrap();
13941410
Ok(())
13951411
},
13961412
agent_client_protocol::on_receive_notification!(),

0 commit comments

Comments
 (0)