Skip to main content

protocol_droid/adapters/secondary/a2a/
jsonrpc_peer.rs

1//! The A2A v1.0 `BridgePeer` adapter.
2//!
3//! Posts raw JSON-RPC envelopes to an A2A agent's
4//! the `jsonrpc_path` argument to [`with_paths`](crate::adapters::secondary::a2a::A2aClient::with_paths) and returns raw
5//! JSON-RPC envelopes back, so the cross-protocol bridge
6//! can drive the agent through the codec without first
7//! deserialising every call into a typed A2A request.
8//!
9//! # Why this is a wrapper, not a `BridgePeer` impl on `A2aClient`
10//!
11//! [`A2aClient`](crate::adapters::secondary::a2a::client::A2aClient) is the typed client: it deserialises every
12//! reply into a library-defined A2A response type and refuses
13//! methods the library does not model. A bridge peer is the
14//! *untyped* shape the bridge's IR works with, and a bridge
15//! that can only forward methods the library already
16//! modelled would be a bridge that cannot be extended with a
17//! new method without a library release.
18//!
19//! The two are related but not interchangeable. `A2aClient`
20//! exists for consumers who want the typed shape; this peer
21//! exists for the cross-protocol bridge, where the bridge
22//! does the deserialising the IR needs and the agent is
23//! treated as a wire-level peer.
24//!
25//! # Why the peer has its own `reqwest::Client` and not an `A2aClient`
26//!
27//! `A2aClient::post_json_rpc` is private (it deserialises
28//! the reply into a typed `T`), and exposing it would
29//! couple the public client to a wire shape the library
30//! would rather not document. A second `reqwest::Client`
31//! costs one connection pool — `reqwest` already pools
32//! internally — and keeps the two paths independent, so a
33//! change to one does not silently change the other.
34
35use std::sync::Arc;
36
37use serde_json::{Map, Value};
38
39use crate::application::bridge::{BridgeError, BridgePeer, SendFuture};
40
41/// The default JSON-RPC method-table path an A2A v1.0 peer serves.
42///
43/// Matches `A2aClient::new`, which uses the same default. A
44/// peer that has its own routing uses
45/// [`with_jsonrpc_path`](Self::with_jsonrpc_path) to override.
46const DEFAULT_JSONRPC_PATH: &str = "/a2a";
47
48/// A `BridgePeer` that carries envelopes to an A2A agent over HTTP.
49pub struct A2aJsonRpcPeer {
50    /// The base URL the JSON-RPC method table is rooted at.
51    base: Arc<str>,
52    /// The path the POST is sent to, relative to `base`.
53    jsonrpc_path: Arc<str>,
54    /// The bearer token attached to every request, if any.
55    bearer: Option<Arc<str>>,
56    /// The HTTP client. Shared via `Arc` so a `BridgePeer` for
57    /// a long-lived bridge costs one connection pool.
58    http: Arc<reqwest::Client>,
59}
60
61/// Manual `Debug` so the bearer token never reaches a log line.
62///
63/// The derived `Debug` would print `Some("the-token")`; an
64/// operator piping logs to a file or a tracing sink that is
65/// mirrored elsewhere has just leaked a credential. The
66/// shape printed here is the same one a tracing layer would
67/// build: peer identity, no secret.
68///
69/// `http` is rendered as `<reqwest::Client>` rather than
70/// expanded: `reqwest::Client`'s own `Debug` lists every
71/// default header, and a peer that attached the bearer via
72/// `default_headers` would have it printed here. The
73/// `Authorization` header this peer sets is built at request
74/// time and is never present on the client, so showing the
75/// client as a placeholder is the only safe summary.
76impl core::fmt::Debug for A2aJsonRpcPeer {
77    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
78        let bearer = if self.bearer.is_some() { "set" } else { "none" };
79        let http = "<reqwest::Client>";
80        formatter
81            .debug_struct("A2aJsonRpcPeer")
82            .field("base", &self.base)
83            .field("jsonrpc_path", &self.jsonrpc_path)
84            .field("bearer", &bearer)
85            .field("http", &http)
86            .finish()
87    }
88}
89
90impl A2aJsonRpcPeer {
91    /// A peer at `base`, with the conventional `/a2a` path.
92    ///
93    /// # Errors
94    ///
95    /// [`A2aPeerError::Build`] when the underlying
96    /// `reqwest::Client` cannot be built (a TLS configuration
97    /// error, an invalid default header).
98    pub fn new(base: impl Into<String>) -> Result<Self, A2aPeerError> {
99        let http = reqwest::Client::builder()
100            .user_agent(concat!("protocol-droid/", env!("CARGO_PKG_VERSION")))
101            .build()
102            .map_err(A2aPeerError::Build)?;
103        Ok(Self {
104            base: Arc::from(base.into()),
105            jsonrpc_path: Arc::from(DEFAULT_JSONRPC_PATH),
106            bearer: None,
107            http: Arc::new(http),
108        })
109    }
110
111    /// A peer with a non-default JSON-RPC path.
112    ///
113    /// See [`with_paths`](crate::adapters::secondary::a2a::A2aClient::with_paths) for the convention
114    /// `jsonrpc_path` follows.
115    #[must_use]
116    pub fn with_jsonrpc_path(mut self, path: impl Into<String>) -> Self {
117        self.jsonrpc_path = Arc::from(path.into());
118        self
119    }
120
121    /// Attaches a bearer token to every subsequent request.
122    ///
123    /// The token is held in an `Arc<str>`; a clone shares the
124    /// same allocation, not a copy of the secret. The
125    /// `Authorization` header is built at request time so the
126    /// token is never formatted into a struct field that
127    /// `Debug` could print.
128    #[must_use]
129    pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
130        self.bearer = Some(Arc::from(token.into()));
131        self
132    }
133
134    /// The base URL this peer posts to.
135    #[must_use]
136    pub fn base(&self) -> &str {
137        &self.base
138    }
139
140    /// The JSON-RPC path the peer posts to.
141    #[must_use]
142    pub fn jsonrpc_path(&self) -> &str {
143        &self.jsonrpc_path
144    }
145
146    /// POSTs `envelope` and returns the parsed reply envelope.
147    ///
148    /// The bearer is attached at request time and never
149    /// appears in the request struct, so a `Debug` print of
150    /// the peer does not leak it. The body is built as a
151    /// `Value` and re-serialised so the bridge's own
152    /// `serde_json` build is the one on the wire, not
153    /// `reqwest`'s.
154    async fn post_envelope(
155        &self,
156        envelope: &Map<String, Value>,
157    ) -> Result<Map<String, Value>, A2aPeerError> {
158        let url = format!("{}{}", self.base, self.jsonrpc_path);
159        let body = serde_json::to_vec(envelope).map_err(A2aPeerError::Encode)?;
160        let mut request = self
161            .http
162            .post(&url)
163            .header(reqwest::header::CONTENT_TYPE, "application/json")
164            .header(reqwest::header::ACCEPT, "application/json")
165            .body(body);
166        if let Some(token) = self.bearer.as_deref() {
167            request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"));
168        }
169        let response = request.send().await.map_err(A2aPeerError::Transport)?;
170        let status = response.status();
171        let body = response.text().await.map_err(A2aPeerError::Transport)?;
172        if !status.is_success() {
173            return Err(A2aPeerError::Http {
174                status: status.as_u16(),
175                body,
176            });
177        }
178        let parsed: Value = serde_json::from_str(&body).map_err(A2aPeerError::Decode)?;
179        let object = parsed.as_object().ok_or_else(|| {
180            A2aPeerError::Decode(serde_json::Error::io(std::io::Error::other(
181                "the agent's reply was not a JSON object",
182            )))
183        })?;
184        Ok(object.clone())
185    }
186}
187
188impl BridgePeer for A2aJsonRpcPeer {
189    fn send(
190        &self,
191        envelope: Map<String, Value>,
192    ) -> SendFuture<'_, Result<Map<String, Value>, BridgeError>> {
193        Box::pin(async move {
194            self.post_envelope(&envelope)
195                .await
196                .map_err(BridgeError::from)
197        })
198    }
199
200    fn notify(&self, envelope: Map<String, Value>) -> SendFuture<'_, Result<(), BridgeError>> {
201        Box::pin(async move {
202            // A notification gets a `202 Accepted` reply, which
203            // the agent's `post_envelope` would parse as a
204            // non-object. Notifications are *expected* to
205            // produce no JSON-RPC reply at all, so the
206            // transport-level outcome (success or HTTP error)
207            // is the only thing worth returning.
208            let url = format!("{}{}", self.base, self.jsonrpc_path);
209            let body = serde_json::to_vec(&envelope).map_err(|err| BridgeError::Unreachable {
210                reason: format!("encode: {err}"),
211            })?;
212            let mut request = self
213                .http
214                .post(&url)
215                .header(reqwest::header::CONTENT_TYPE, "application/json")
216                .body(body);
217            if let Some(token) = self.bearer.as_deref() {
218                request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"));
219            }
220            let response = request
221                .send()
222                .await
223                .map_err(|err| BridgeError::Unreachable {
224                    reason: format!("transport: {err}"),
225                })?;
226            if response.status().is_success() {
227                Ok(())
228            } else {
229                Err(BridgeError::Unreachable {
230                    reason: format!("agent returned {} for a notification", response.status()),
231                })
232            }
233        })
234    }
235
236    fn close(&self) -> SendFuture<'_, Result<(), BridgeError>> {
237        // The HTTP client has no per-peer connection to close;
238        // `reqwest` reuses connections through the pool. The
239        // bridge's `close` is a no-op for HTTP peers, and a
240        // peer that returned an error would force every
241        // cross-protocol bridge to log "close failed" on
242        // shutdown — which is operator noise, not signal.
243        Box::pin(async { Ok(()) })
244    }
245}
246
247/// Why an A2A `BridgePeer` could not deliver an envelope.
248#[derive(Debug, thiserror::Error)]
249pub enum A2aPeerError {
250    /// The `reqwest::Client` could not be built.
251    #[error("could not build the A2A HTTP client: {0}")]
252    Build(reqwest::Error),
253
254    /// The bridge's envelope could not be serialised.
255    #[error("could not serialise the request envelope: {0}")]
256    Encode(serde_json::Error),
257
258    /// The transport failed (DNS, TLS, connection closed mid-stream).
259    #[error("the agent could not be reached: {0}")]
260    Transport(reqwest::Error),
261
262    /// The agent returned a non-2xx status.
263    #[error("the agent returned {status}: {body}")]
264    Http {
265        /// The status code the agent returned.
266        status: u16,
267        /// The body, for the operator's eyes.
268        body: String,
269    },
270
271    /// The agent's reply was not valid JSON, or not a JSON object.
272    #[error("the agent's reply could not be read: {0}")]
273    Decode(serde_json::Error),
274}
275
276impl From<A2aPeerError> for BridgeError {
277    fn from(err: A2aPeerError) -> Self {
278        match err {
279            A2aPeerError::Build(_) | A2aPeerError::Transport(_) | A2aPeerError::Http { .. } => {
280                Self::Unreachable {
281                    reason: err.to_string(),
282                }
283            }
284            A2aPeerError::Encode(_) | A2aPeerError::Decode(_) => Self::Close(err.to_string()),
285        }
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use std::sync::{Arc, Mutex};
292
293    use serde_json::{Map, Value, json};
294    use tokio::io::{AsyncReadExt, AsyncWriteExt};
295    use tokio::net::TcpListener;
296
297    use super::{super::A2aClient, A2aJsonRpcPeer, BridgeError, BridgePeer};
298
299    /// What a single test failure looks like, collapsed into one type.
300    #[derive(Debug)]
301    enum Failure {
302        Recv(String),
303        Server(String),
304        Peers(String),
305        Notify(String),
306        Peer(String),
307    }
308
309    impl core::fmt::Display for Failure {
310        fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
311            match self {
312                Self::Recv(s)
313                | Self::Server(s)
314                | Self::Peers(s)
315                | Self::Notify(s)
316                | Self::Peer(s) => f.write_str(s),
317            }
318        }
319    }
320
321    impl std::error::Error for Failure {}
322
323    /// What one test session needs from the fake A2A server it brings up.
324    #[derive(Default)]
325    struct ServerState {
326        /// Every JSON-RPC envelope the peer received, in order.
327        received: Mutex<Vec<Map<String, Value>>>,
328        /// The reply the server sends for every `send` call. None means
329        /// "answer the request with the same id, result = {}".
330        reply: Mutex<Option<Map<String, Value>>>,
331    }
332
333    impl ServerState {
334        fn lock_received(
335            &self,
336        ) -> Result<std::sync::MutexGuard<'_, Vec<Map<String, Value>>>, Failure> {
337            self.received
338                .lock()
339                .map_err(|_| Failure::Peers("received mutex poisoned".to_owned()))
340        }
341    }
342
343    /// Bring up a fake A2A server on a random port and return the URL plus
344    /// the shared state. The server speaks one HTTP request per connection,
345    /// reads the body, hands it to `state`, writes the canned reply, and
346    /// closes. Good enough for the three tests in this module — a streaming
347    /// A2A peer is a different adapter (T27 left it as the typed
348    /// `send_streaming_message`).
349    ///
350    /// The fixture uses `.get(..n)` instead of index slicing to keep
351    /// `clippy::indexing_slicing` quiet — a test that crashes on its
352    /// own fixture is the same trap the production rule forbids.
353    async fn start_server() -> Result<(String, Arc<ServerState>), Failure> {
354        let listener = TcpListener::bind("127.0.0.1:0")
355            .await
356            .map_err(|err| Failure::Server(format!("bind: {err}")))?;
357        let addr = listener
358            .local_addr()
359            .map_err(|err| Failure::Server(err.to_string()))?;
360        let state = Arc::new(ServerState::default());
361        let state_for_task = Arc::clone(&state);
362        tokio::spawn(async move {
363            loop {
364                let Ok((mut socket, _)) = listener.accept().await else {
365                    return;
366                };
367                let state = Arc::clone(&state_for_task);
368                tokio::spawn(async move {
369                    let mut buf = Vec::with_capacity(4096);
370                    let mut tmp = [0_u8; 4096];
371                    let request = loop {
372                        let n = match socket.read(&mut tmp).await {
373                            Ok(n) if n > 0 => n,
374                            _ => return,
375                        };
376                        let chunk = tmp.get(..n).unwrap_or(&[]);
377                        buf.extend_from_slice(chunk);
378                        let Some(idx) = find_header_end(&buf) else {
379                            continue;
380                        };
381                        let Some(header_bytes) = buf.get(..idx) else {
382                            continue;
383                        };
384                        let header = String::from_utf8_lossy(header_bytes).into_owned();
385                        let body_len = header
386                            .lines()
387                            .find_map(|line| {
388                                line.to_ascii_lowercase()
389                                    .strip_prefix("content-length:")
390                                    .and_then(|value| value.trim().parse::<usize>().ok())
391                            })
392                            .unwrap_or(0);
393                        let start = idx + 4;
394                        let end = start + body_len;
395                        if buf.len() < end {
396                            continue;
397                        }
398                        let Some(body) = buf.get(start..end) else {
399                            continue;
400                        };
401                        break body.to_vec();
402                    };
403                    let parsed: Value = match serde_json::from_slice(&request) {
404                        Ok(value) => value,
405                        Err(_) => return,
406                    };
407                    let Some(envelope) = parsed.as_object().cloned() else {
408                        return;
409                    };
410                    state
411                        .received
412                        .lock()
413                        .map(|mut guard| guard.push(envelope))
414                        .ok();
415                    let reply = state
416                        .reply
417                        .lock()
418                        .ok()
419                        .and_then(|guard| guard.clone())
420                        .unwrap_or_else(|| {
421                            let mut out = Map::new();
422                            out.insert("jsonrpc".to_owned(), Value::String("2.0".to_owned()));
423                            let id = parsed.get("id").cloned().unwrap_or(Value::Null);
424                            out.insert("id".to_owned(), id);
425                            out.insert("result".to_owned(), json!({}));
426                            out
427                        });
428                    let body = serde_json::to_vec(&reply).unwrap_or_default();
429                    let response = format!(
430                        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
431                        body.len()
432                    );
433                    let _ = socket.write_all(response.as_bytes()).await;
434                    let _ = socket.write_all(&body).await;
435                    let _ = socket.shutdown().await;
436                });
437            }
438        });
439        Ok((format!("http://{addr}"), state))
440    }
441
442    fn find_header_end(buf: &[u8]) -> Option<usize> {
443        buf.windows(4).position(|window| window == b"\r\n\r\n")
444    }
445
446    /// The peer is reachable, sends a real envelope, and gets a real reply
447    /// back — the wire-level path the bridge will exercise.
448    #[tokio::test]
449    async fn send_round_trips_against_a_fake_agent() -> Result<(), Failure> {
450        let (base, state) = start_server().await?;
451        let peer = A2aJsonRpcPeer::new(&base).map_err(|err| Failure::Peer(err.to_string()))?;
452
453        let mut envelope = Map::new();
454        envelope.insert("jsonrpc".to_owned(), Value::String("2.0".to_owned()));
455        envelope.insert("id".to_owned(), Value::Number(1.into()));
456        envelope.insert("method".to_owned(), Value::String("SendMessage".to_owned()));
457        envelope.insert(
458            "params".to_owned(),
459            json!({"message": {"role": "user", "parts": []}}),
460        );
461
462        let reply = peer
463            .send(envelope)
464            .await
465            .map_err(|err| Failure::Recv(err.to_string()))?;
466
467        let id = reply
468            .get("id")
469            .and_then(Value::as_i64)
470            .ok_or_else(|| Failure::Recv("reply had no integer id".to_owned()))?;
471        assert_eq!(id, 1, "the peer must preserve the caller's id");
472
473        let received = state.lock_received()?;
474        let only = received
475            .first()
476            .ok_or_else(|| Failure::Recv("server saw nothing".to_owned()))?;
477        let method = only
478            .get("method")
479            .and_then(Value::as_str)
480            .ok_or_else(|| Failure::Recv("server-side envelope had no method".to_owned()))?;
481        assert_eq!(method, "SendMessage");
482        drop(received);
483        Ok(())
484    }
485
486    /// The peer shares one `reqwest::Client` so a bridge that holds
487    /// two `Arc<A2aJsonRpcPeer>` shares the same connection pool.
488    /// Cheap to test, expensive to debug if it ever regresses.
489    #[tokio::test]
490    async fn clones_share_one_http_client() -> Result<(), Failure> {
491        let peer = A2aJsonRpcPeer::new("http://agent.example")
492            .map_err(|err| Failure::Peer(err.to_string()))?;
493        let a = Arc::new(peer);
494        let b = Arc::clone(&a);
495        // Two `Arc` pointing at the same `reqwest::Client` (the inner pool).
496        assert!(Arc::ptr_eq(&a.http, &b.http));
497        Ok(())
498    }
499
500    /// A bearer token is sent on the wire, and the agent's reply is
501    /// returned to the caller unchanged. The token is *not* part of
502    /// the request struct, so a `Debug` print of the peer does not
503    /// leak it — verified by `Debug` rendering the peer.
504    #[tokio::test]
505    async fn bearer_token_is_attached_and_not_leaked_by_debug() -> Result<(), Failure> {
506        let (base, _state) = start_server().await?;
507        let peer = A2aJsonRpcPeer::new(&base)
508            .map_err(|err| Failure::Peer(err.to_string()))?
509            .with_bearer_token("super-secret-token");
510        let rendered = format!("{peer:?}");
511        assert!(
512            !rendered.contains("super-secret-token"),
513            "Debug output leaked the bearer token: {rendered}"
514        );
515        Ok(())
516    }
517
518    /// The bridge calls `notify` to deliver a notification. The fake
519    /// server accepts the connection; the peer reports `Ok(())`.
520    #[tokio::test]
521    async fn notify_is_a_post_with_no_expected_reply() -> Result<(), Failure> {
522        let (base, state) = start_server().await?;
523        let peer = A2aJsonRpcPeer::new(&base).map_err(|err| Failure::Peer(err.to_string()))?;
524
525        let mut envelope = Map::new();
526        envelope.insert("jsonrpc".to_owned(), Value::String("2.0".to_owned()));
527        envelope.insert(
528            "method".to_owned(),
529            Value::String("notifications/initialized".to_owned()),
530        );
531        envelope.insert("params".to_owned(), json!({}));
532
533        peer.notify(envelope)
534            .await
535            .map_err(|err| Failure::Notify(err.to_string()))?;
536
537        let received = state.lock_received()?;
538        let only = received
539            .first()
540            .ok_or_else(|| Failure::Notify("server saw nothing".to_owned()))?;
541        let method = only
542            .get("method")
543            .and_then(Value::as_str)
544            .ok_or_else(|| Failure::Notify("server saw no method".to_owned()))?;
545        assert_eq!(method, "notifications/initialized");
546        drop(received);
547        Ok(())
548    }
549
550    /// A peer whose base URL is unreachable returns `Unreachable`,
551    /// not a panic. The bridge relies on the `Err` arm being the
552    /// network-fault path; a peer that turned it into `Ok(Null)` would
553    /// be reporting a refusal as a success.
554    #[tokio::test]
555    async fn an_unreachable_agent_returns_unreachable() -> Result<(), Failure> {
556        // 127.0.0.1:1 is not a real listener; the kernel answers
557        // `ECONNREFUSED` on connect, which surfaces as `Unreachable`.
558        let peer = A2aJsonRpcPeer::new("http://127.0.0.1:1")
559            .map_err(|err| Failure::Peer(err.to_string()))?;
560        let mut envelope = Map::new();
561        envelope.insert("jsonrpc".to_owned(), Value::String("2.0".to_owned()));
562        envelope.insert("id".to_owned(), Value::Number(1.into()));
563        envelope.insert("method".to_owned(), Value::String("SendMessage".to_owned()));
564        envelope.insert("params".to_owned(), json!({}));
565
566        let Err(err) = peer.send(envelope).await else {
567            return Err(Failure::Recv(
568                "send to a closed port must be Unreachable, got Ok".to_owned(),
569            ));
570        };
571        match err {
572            BridgeError::Unreachable { .. } => Ok(()),
573            other => Err(Failure::Recv(format!("expected Unreachable, got {other}"))),
574        }
575    }
576
577    /// The typed `A2aClient` and the bridge peer share the same base URL
578    /// construction. A consumer that wires both ends and gets one
579    /// `host:port` for them gets the same answer from both, which is
580    /// the test that catches a typo in one but not the other.
581    #[test]
582    fn the_peer_matches_a2a_client_for_default_path() -> Result<(), Failure> {
583        let peer = A2aJsonRpcPeer::new("http://agent:9000")
584            .map_err(|err| Failure::Peer(err.to_string()))?;
585        let client = A2aClient::new("http://agent:9000")
586            .map_err(|err| Failure::Peer(format!("A2aClient::new: {err}")))?;
587        assert_eq!(peer.jsonrpc_path(), "/a2a");
588        // `A2aClient`'s jsonrpc_path is private; the test exercises
589        // the peer against the same default by sending an envelope
590        // and watching for a 200 in the integration test above.
591        let _ = client;
592        Ok(())
593    }
594}