Skip to main content

a2a_rs/adapter/transport/
negotiation.rs

1//! Client-side transport negotiation.
2//!
3//! A [`TransportFactory`] knows how to build a [`Transport`] for one wire
4//! protocol from an agent interface. A [`TransportNegotiator`] holds an ordered
5//! set of factories and, given an [`AgentCard`], picks the first interface it can
6//! satisfy — ranked by **client preference** (factory registration order), which
7//! dominates the card's own `preferred_transport`.
8//!
9//! This is composition-at-the-edge: the application assembles a negotiator with
10//! exactly the transports it compiled in, then calls [`connect`] (or
11//! [`TransportNegotiator::negotiate`]) to obtain a ready `Box<dyn Transport>`.
12//!
13//! A [`ClientConfig`] carries the settings that are not derivable from the card —
14//! credentials and request timeout — through negotiation, so a negotiated client
15//! is configured exactly like a hand-built one. Without it, authentication would
16//! only be reachable by bypassing negotiation entirely.
17
18use async_trait::async_trait;
19
20#[cfg(feature = "http-client")]
21use crate::domain::PROTOCOL_BINDING_CONNECTRPC;
22#[cfg(feature = "jsonrpc-client")]
23use crate::domain::PROTOCOL_BINDING_JSONRPC;
24use crate::domain::{A2AError, AgentCard, AgentInterface};
25use crate::port::Transport;
26
27/// Client-side connection settings applied to every transport built during
28/// negotiation, and to the agent-card fetch that precedes it.
29///
30/// The agent card describes *where and how* to reach an agent; this describes
31/// what the caller brings to the call. Both are needed to build a usable client,
32/// and only the card comes off the wire.
33#[derive(Clone, Default)]
34pub struct ClientConfig {
35    auth_token: Option<String>,
36    timeout_secs: Option<u64>,
37}
38
39/// Redacts the token. A derived `Debug` would print the credential verbatim,
40/// and this type is exactly the thing a caller reaches for when tracing why a
41/// connection did not authenticate — so the one line most likely to be written
42/// is the one that must not carry the secret.
43impl std::fmt::Debug for ClientConfig {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.debug_struct("ClientConfig")
46            .field(
47                "auth_token",
48                match &self.auth_token {
49                    Some(_) => &"<redacted>",
50                    None => &"None",
51                },
52            )
53            .field("timeout_secs", &self.timeout_secs)
54            .finish()
55    }
56}
57
58impl ClientConfig {
59    /// Unauthenticated, transport-default timeout.
60    pub fn new() -> Self {
61        Self::default()
62    }
63
64    /// Send `token` as an HTTP `Authorization: Bearer` credential.
65    pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
66        self.auth_token = Some(token.into());
67        self
68    }
69
70    /// Bound each request to `secs` seconds.
71    pub fn with_timeout(mut self, secs: u64) -> Self {
72        self.timeout_secs = Some(secs);
73        self
74    }
75
76    /// The bearer token, when one was set.
77    pub fn auth_token(&self) -> Option<&str> {
78        self.auth_token.as_deref()
79    }
80
81    /// The per-request timeout in seconds, when one was set.
82    pub fn timeout_secs(&self) -> Option<u64> {
83        self.timeout_secs
84    }
85}
86
87/// Builds a [`Transport`] for a single wire protocol from an agent interface.
88#[async_trait]
89pub trait TransportFactory: Send + Sync {
90    /// The protocol this factory handles, matching `AgentInterface::protocol_binding`
91    /// (e.g. `"JSONRPC"`, `"CONNECTRPC"`).
92    fn protocol(&self) -> &str;
93
94    /// Construct a transport for `iface`, configured with `config`. Returning
95    /// `Err` lets the negotiator fall through to the next compatible
96    /// interface/factory.
97    async fn create(
98        &self,
99        card: &AgentCard,
100        iface: &AgentInterface,
101        config: &ClientConfig,
102    ) -> Result<Box<dyn Transport>, A2AError>;
103}
104
105/// Build a JSON-RPC client on `url` with `config` applied.
106#[cfg(feature = "jsonrpc-client")]
107fn jsonrpc_client(url: String, config: &ClientConfig) -> super::jsonrpc_client::JsonRpcClient {
108    use super::jsonrpc_client::JsonRpcClient;
109
110    let mut client = match config.auth_token() {
111        Some(token) => JsonRpcClient::with_auth(url, token.to_string()),
112        None => JsonRpcClient::new(url),
113    };
114    if let Some(secs) = config.timeout_secs() {
115        client = client.with_timeout(secs);
116    }
117    client
118}
119
120/// Build a ConnectRPC client on `url` with `config` applied, reporting a URL
121/// `http::Uri` cannot represent rather than panicking on it.
122#[cfg(feature = "http-client")]
123fn connect_rpc_client(
124    url: String,
125    config: &ClientConfig,
126) -> Result<super::http::HttpClient, A2AError> {
127    use super::http::HttpClient;
128
129    let mut client = match config.auth_token() {
130        Some(token) => HttpClient::try_with_auth(url, token.to_string())?,
131        None => HttpClient::try_new(url)?,
132    };
133    if let Some(secs) = config.timeout_secs() {
134        client = client.with_timeout(secs);
135    }
136    Ok(client)
137}
138
139/// Factory for the wire-compatible JSON-RPC 2.0 transport.
140#[cfg(feature = "jsonrpc-client")]
141pub struct JsonRpcTransportFactory;
142
143#[cfg(feature = "jsonrpc-client")]
144#[async_trait]
145impl TransportFactory for JsonRpcTransportFactory {
146    fn protocol(&self) -> &str {
147        PROTOCOL_BINDING_JSONRPC
148    }
149
150    async fn create(
151        &self,
152        _card: &AgentCard,
153        iface: &AgentInterface,
154        config: &ClientConfig,
155    ) -> Result<Box<dyn Transport>, A2AError> {
156        Ok(Box::new(jsonrpc_client(iface.url.clone(), config)))
157    }
158}
159
160/// Factory for the ConnectRPC transport.
161#[cfg(feature = "http-client")]
162pub struct ConnectRpcTransportFactory;
163
164#[cfg(feature = "http-client")]
165#[async_trait]
166impl TransportFactory for ConnectRpcTransportFactory {
167    fn protocol(&self) -> &str {
168        PROTOCOL_BINDING_CONNECTRPC
169    }
170
171    async fn create(
172        &self,
173        _card: &AgentCard,
174        iface: &AgentInterface,
175        config: &ClientConfig,
176    ) -> Result<Box<dyn Transport>, A2AError> {
177        // A URL the ConnectRPC client cannot represent is a recoverable
178        // negotiation miss — the negotiator falls through to the next
179        // interface — rather than a crash.
180        Ok(Box::new(connect_rpc_client(iface.url.clone(), config)?))
181    }
182}
183
184/// An ordered registry of [`TransportFactory`]s that negotiates a transport from
185/// an agent card. Registration order is the client's preference order.
186#[derive(Default)]
187pub struct TransportNegotiator {
188    factories: Vec<Box<dyn TransportFactory>>,
189}
190
191impl TransportNegotiator {
192    /// An empty negotiator. Add factories with [`with`](Self::with).
193    pub fn new() -> Self {
194        Self::default()
195    }
196
197    /// Register a factory (appended at lowest preference).
198    pub fn with(mut self, factory: impl TransportFactory + 'static) -> Self {
199        self.factories.push(Box::new(factory));
200        self
201    }
202
203    /// The protocols this negotiator can construct, in preference order.
204    pub fn supported(&self) -> impl Iterator<Item = &str> {
205        self.factories.iter().map(|f| f.protocol())
206    }
207
208    /// Pick and construct the first transport that matches a card interface,
209    /// ranked by client preference (registration order), with default settings.
210    pub async fn negotiate(&self, card: &AgentCard) -> Result<Box<dyn Transport>, A2AError> {
211        self.negotiate_with(card, &ClientConfig::default()).await
212    }
213
214    /// As [`negotiate`](Self::negotiate), configuring the chosen transport with
215    /// `config`.
216    pub async fn negotiate_with(
217        &self,
218        card: &AgentCard,
219        config: &ClientConfig,
220    ) -> Result<Box<dyn Transport>, A2AError> {
221        for factory in &self.factories {
222            for iface in &card.supported_interfaces {
223                if iface.protocol_binding == factory.protocol()
224                    && version_compatible(&iface.protocol_version)
225                {
226                    match factory.create(card, iface, config).await {
227                        Ok(transport) => return Ok(transport),
228                        Err(_err) => continue,
229                    }
230                }
231            }
232        }
233        Err(A2AError::UnsupportedOperation(format!(
234            "no compatible transport: client supports [{}], card offers [{}]",
235            self.supported().collect::<Vec<_>>().join(", "),
236            card.supported_interfaces
237                .iter()
238                .map(|i| i.protocol_binding.as_str())
239                .collect::<Vec<_>>()
240                .join(", "),
241        )))
242    }
243}
244
245/// Permissive major-version check: accept the v1.x line (or an unspecified
246/// version). A future major version on an interface is skipped, not errored.
247fn version_compatible(version: &str) -> bool {
248    version.is_empty() || version.split('.').next() == Some("1")
249}
250
251/// The default registry, holding every transport compiled into this build.
252///
253/// Preference order is **CONNECTRPC then JSONRPC**: ConnectRPC is the in-tree,
254/// first-class streaming transport, with JSON-RPC 2.0 as the interoperable
255/// fallback. Flip the two `with` lines below for spec-default JSONRPC-first.
256pub fn default_registry() -> TransportNegotiator {
257    #[allow(unused_mut)]
258    let mut negotiator = TransportNegotiator::new();
259    #[cfg(feature = "http-client")]
260    {
261        negotiator = negotiator.with(ConnectRpcTransportFactory);
262    }
263    #[cfg(feature = "jsonrpc-client")]
264    {
265        negotiator = negotiator.with(JsonRpcTransportFactory);
266    }
267    negotiator
268}
269
270/// Fetch an agent's card and negotiate a transport in one step.
271///
272/// Fetches `/.well-known/agent-card.json` (falling back to `/agent-card`) from
273/// `base_url`, then runs [`TransportNegotiator::negotiate`].
274#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
275pub async fn connect(
276    base_url: &str,
277    negotiator: &TransportNegotiator,
278) -> Result<Box<dyn Transport>, A2AError> {
279    connect_with(base_url, negotiator, &ClientConfig::default()).await
280}
281
282/// As [`connect`], applying `config` to both the card fetch and the negotiated
283/// transport.
284///
285/// The card fetch carries the credentials too: an agent that authenticates its
286/// RPC endpoints usually authenticates the card endpoint as well, and a fetch
287/// that 401s would otherwise sink the whole negotiation.
288#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
289pub async fn connect_with(
290    base_url: &str,
291    negotiator: &TransportNegotiator,
292    config: &ClientConfig,
293) -> Result<Box<dyn Transport>, A2AError> {
294    let card = fetch_agent_card_with(base_url, config).await?;
295    negotiator.negotiate_with(&card, config).await
296}
297
298/// Validate `base_url`, negotiate a transport from the agent card, and fall back
299/// to a direct client when the card can't be fetched or none of its interfaces
300/// match a compiled-in transport.
301///
302/// This is the one-call ergonomic entry point shared by the CLI and the web
303/// client: it validates the URL up front (so a malformed URL is a hard error),
304/// tries [`connect`] with the [`default_registry`], and on any negotiation miss
305/// falls back to a direct client on `base_url` so the call still works against a
306/// bare agent URL with no published card. The fallback prefers the in-tree
307/// ConnectRPC transport, using JSON-RPC 2.0 when ConnectRPC isn't compiled in.
308#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
309pub async fn auto_connect(base_url: &str) -> Result<Box<dyn Transport>, A2AError> {
310    auto_connect_with(base_url, &ClientConfig::default()).await
311}
312
313/// As [`auto_connect`], applying `config` to the card fetch, the negotiated
314/// transport, **and** the direct-client fallback.
315///
316/// This is the entry point a CLI or a web client wants whenever credentials are
317/// in play: every path out of it yields a configured client, so `--auth` behaves
318/// the same whether the card negotiated or the fallback fired.
319#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
320pub async fn auto_connect_with(
321    base_url: &str,
322    config: &ClientConfig,
323) -> Result<Box<dyn Transport>, A2AError> {
324    // Validate URL format up front so a malformed URL is a hard error rather
325    // than a silent fallback to a client that will fail on first request.
326    reqwest::Url::parse(base_url)
327        .map_err(|e| A2AError::InvalidParams(format!("invalid url {base_url}: {e}")))?;
328
329    match connect_with(base_url, &default_registry(), config).await {
330        Ok(transport) => Ok(transport),
331        // Card fetch / negotiation failed — fall back to a direct client.
332        Err(_) => direct_transport(base_url, config),
333    }
334}
335
336/// Build a direct client on `base_url`, preferring ConnectRPC when compiled in.
337///
338/// Fallible because `reqwest::Url` — which [`auto_connect_with`] validates with
339/// — is *more* permissive than the `http::Uri` the ConnectRPC client needs:
340/// `http://münchen.de` parses as the former and not the latter. Building
341/// infallibly here turned that gap into a panic on the fallback path, which is
342/// the path a bad URL is most likely to reach in the first place.
343#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
344fn direct_transport(base_url: &str, config: &ClientConfig) -> Result<Box<dyn Transport>, A2AError> {
345    #[cfg(feature = "http-client")]
346    {
347        Ok(Box::new(connect_rpc_client(base_url.to_string(), config)?))
348    }
349    #[cfg(all(not(feature = "http-client"), feature = "jsonrpc-client"))]
350    {
351        Ok(Box::new(jsonrpc_client(base_url.to_string(), config)))
352    }
353}
354
355/// Fetch an [`AgentCard`] from the agent's well-known endpoint (plain HTTP GET).
356#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
357pub async fn fetch_agent_card(base_url: &str) -> Result<AgentCard, A2AError> {
358    fetch_agent_card_with(base_url, &ClientConfig::default()).await
359}
360
361/// As [`fetch_agent_card`], sending `config`'s credentials and honouring its
362/// timeout — for agents that guard the card endpoint too.
363#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
364pub async fn fetch_agent_card_with(
365    base_url: &str,
366    config: &ClientConfig,
367) -> Result<AgentCard, A2AError> {
368    use crate::adapter::error::HttpClientError;
369
370    let client = reqwest::Client::new();
371    let base = base_url.trim_end_matches('/');
372    for path in [".well-known/agent-card.json", "agent-card"] {
373        let url = format!("{base}/{path}");
374        let mut request = client.get(&url);
375        if let Some(token) = config.auth_token() {
376            request = request.bearer_auth(token);
377        }
378        if let Some(secs) = config.timeout_secs() {
379            request = request.timeout(std::time::Duration::from_secs(secs));
380        }
381        let resp = request.send().await.map_err(HttpClientError::Reqwest)?;
382        if resp.status().is_success() {
383            return resp
384                .json::<AgentCard>()
385                .await
386                .map_err(|e| A2AError::Internal(format!("Failed to parse agent card JSON: {e}")));
387        }
388    }
389    Err(A2AError::Internal(format!(
390        "Agent card not found at {base_url}"
391    )))
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn rejects_v2_interface() {
400        assert!(version_compatible("1.0"));
401        assert!(version_compatible("")); // unspecified accepted
402        assert!(!version_compatible("2.0"));
403    }
404
405    /// A `Debug` line must not carry the credential. `ClientConfig` is what a
406    /// caller reaches for when tracing an authentication failure, so the most
407    /// likely line to be logged is the one that would have leaked the token.
408    #[test]
409    fn debug_redacts_the_token() {
410        let rendered = format!("{:?}", ClientConfig::new().with_auth_token("s3cret"));
411        assert!(!rendered.contains("s3cret"), "token leaked: {rendered}");
412        assert!(rendered.contains("redacted"), "{rendered}");
413
414        // …and says so only when there is one to hide.
415        let rendered = format!("{:?}", ClientConfig::new());
416        assert!(!rendered.contains("redacted"), "{rendered}");
417    }
418
419    /// `reqwest::Url` accepts an IDN host and normalizes it to punycode;
420    /// `http::Uri` rejects the raw bytes. `auto_connect_with` validates with the
421    /// former and the fallback built with the latter, so a URL that had just
422    /// been declared valid panicked one line later.
423    #[cfg(feature = "http-client")]
424    #[tokio::test]
425    async fn idn_url_is_an_error_not_a_panic() {
426        assert!(
427            reqwest::Url::parse("http://münchen.de").is_ok(),
428            "premise: the up-front validation accepts this"
429        );
430        match auto_connect("http://münchen.de").await {
431            Err(A2AError::InvalidParams(_)) => {}
432            Err(other) => panic!("wrong error: {other:?}"),
433            Ok(_) => panic!("expected an error for a url http::Uri cannot represent"),
434        }
435    }
436
437    #[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
438    #[tokio::test]
439    async fn auto_connect_rejects_malformed_url() {
440        // `Box<dyn Transport>` isn't `Debug`, so match rather than `unwrap_err`.
441        match auto_connect("not-a-url").await {
442            Err(A2AError::InvalidParams(_)) => {}
443            Err(other) => panic!("wrong error: {other:?}"),
444            Ok(_) => panic!("expected an error for a malformed url"),
445        }
446    }
447
448    // A well-formed URL with no agent published there: card fetch fails, so
449    // `auto_connect` must fall back to a direct client rather than erroring.
450    // Port 1 is reserved/unroutable, so the GET fails fast.
451    #[cfg(feature = "http-client")]
452    #[tokio::test]
453    async fn auto_connect_falls_back_to_direct_connectrpc() {
454        let transport = auto_connect("http://127.0.0.1:1")
455            .await
456            .expect("fallback yields a direct transport");
457        assert_eq!(transport.protocol(), "CONNECTRPC");
458    }
459
460    #[cfg(all(not(feature = "http-client"), feature = "jsonrpc-client"))]
461    #[tokio::test]
462    async fn auto_connect_falls_back_to_direct_jsonrpc() {
463        let transport = auto_connect("http://127.0.0.1:1")
464            .await
465            .expect("fallback yields a direct transport");
466        assert_eq!(transport.protocol(), "JSONRPC");
467    }
468}