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(
108    url: String,
109    config: &ClientConfig,
110) -> Result<super::jsonrpc_client::JsonRpcClient, A2AError> {
111    use super::jsonrpc_client::JsonRpcClient;
112
113    let mut client = match config.auth_token() {
114        Some(token) => JsonRpcClient::try_with_auth(url, token.to_string())?,
115        None => JsonRpcClient::try_new(url)?,
116    };
117    if let Some(secs) = config.timeout_secs() {
118        client = client.with_timeout(secs);
119    }
120    Ok(client)
121}
122
123/// Build a ConnectRPC client on `url` with `config` applied, reporting a URL
124/// `http::Uri` cannot represent rather than panicking on it.
125#[cfg(feature = "http-client")]
126fn connect_rpc_client(
127    url: String,
128    config: &ClientConfig,
129) -> Result<super::http::HttpClient, A2AError> {
130    use super::http::HttpClient;
131
132    let mut client = match config.auth_token() {
133        Some(token) => HttpClient::try_with_auth(url, token.to_string())?,
134        None => HttpClient::try_new(url)?,
135    };
136    if let Some(secs) = config.timeout_secs() {
137        client = client.with_timeout(secs);
138    }
139    Ok(client)
140}
141
142/// Factory for the wire-compatible JSON-RPC 2.0 transport.
143#[cfg(feature = "jsonrpc-client")]
144pub struct JsonRpcTransportFactory;
145
146#[cfg(feature = "jsonrpc-client")]
147#[async_trait]
148impl TransportFactory for JsonRpcTransportFactory {
149    fn protocol(&self) -> &str {
150        PROTOCOL_BINDING_JSONRPC
151    }
152
153    async fn create(
154        &self,
155        _card: &AgentCard,
156        iface: &AgentInterface,
157        config: &ClientConfig,
158    ) -> Result<Box<dyn Transport>, A2AError> {
159        Ok(Box::new(jsonrpc_client(iface.url.clone(), config)?))
160    }
161}
162
163/// Factory for the ConnectRPC transport.
164#[cfg(feature = "http-client")]
165pub struct ConnectRpcTransportFactory;
166
167#[cfg(feature = "http-client")]
168#[async_trait]
169impl TransportFactory for ConnectRpcTransportFactory {
170    fn protocol(&self) -> &str {
171        PROTOCOL_BINDING_CONNECTRPC
172    }
173
174    async fn create(
175        &self,
176        _card: &AgentCard,
177        iface: &AgentInterface,
178        config: &ClientConfig,
179    ) -> Result<Box<dyn Transport>, A2AError> {
180        // A URL the ConnectRPC client cannot represent is a recoverable
181        // negotiation miss — the negotiator falls through to the next
182        // interface — rather than a crash.
183        Ok(Box::new(connect_rpc_client(iface.url.clone(), config)?))
184    }
185}
186
187/// An ordered registry of [`TransportFactory`]s that negotiates a transport from
188/// an agent card. Registration order is the client's preference order.
189#[derive(Default)]
190pub struct TransportNegotiator {
191    factories: Vec<Box<dyn TransportFactory>>,
192}
193
194impl TransportNegotiator {
195    /// An empty negotiator. Add factories with [`with`](Self::with).
196    pub fn new() -> Self {
197        Self::default()
198    }
199
200    /// Register a factory (appended at lowest preference).
201    pub fn with(mut self, factory: impl TransportFactory + 'static) -> Self {
202        self.factories.push(Box::new(factory));
203        self
204    }
205
206    /// The protocols this negotiator can construct, in preference order.
207    pub fn supported(&self) -> impl Iterator<Item = &str> {
208        self.factories.iter().map(|f| f.protocol())
209    }
210
211    /// Pick and construct the first transport that matches a card interface,
212    /// ranked by client preference (registration order), with default settings.
213    pub async fn negotiate(&self, card: &AgentCard) -> Result<Box<dyn Transport>, A2AError> {
214        self.negotiate_with(card, &ClientConfig::default()).await
215    }
216
217    /// As [`negotiate`](Self::negotiate), configuring the chosen transport with
218    /// `config`.
219    pub async fn negotiate_with(
220        &self,
221        card: &AgentCard,
222        config: &ClientConfig,
223    ) -> Result<Box<dyn Transport>, A2AError> {
224        self.select(card, config)
225            .await
226            .map(|(transport, _iface)| transport)
227    }
228
229    /// [`negotiate_with`](Self::negotiate_with), also returning the interface
230    /// the transport was built from — which is where it will send every
231    /// request, and the one fact a caller holding a *different* address for
232    /// the agent needs in order to explain a failure.
233    async fn select<'c>(
234        &self,
235        card: &'c AgentCard,
236        config: &ClientConfig,
237    ) -> Result<(Box<dyn Transport>, &'c AgentInterface), A2AError> {
238        for factory in &self.factories {
239            for iface in &card.supported_interfaces {
240                if iface.protocol_binding == factory.protocol()
241                    && version_compatible(&iface.protocol_version)
242                {
243                    match factory.create(card, iface, config).await {
244                        Ok(transport) => return Ok((transport, iface)),
245                        Err(_err) => continue,
246                    }
247                }
248            }
249        }
250        Err(A2AError::UnsupportedOperation(format!(
251            "no compatible transport: client supports [{}], card offers [{}]",
252            self.supported().collect::<Vec<_>>().join(", "),
253            card.supported_interfaces
254                .iter()
255                .map(|i| i.protocol_binding.as_str())
256                .collect::<Vec<_>>()
257                .join(", "),
258        )))
259    }
260}
261
262/// Permissive major-version check: accept the v1.x line (or an unspecified
263/// version). A future major version on an interface is skipped, not errored.
264fn version_compatible(version: &str) -> bool {
265    version.is_empty() || version.split('.').next() == Some("1")
266}
267
268/// The default registry, holding every transport compiled into this build.
269///
270/// Preference order is **CONNECTRPC then JSONRPC**: ConnectRPC is the in-tree,
271/// first-class streaming transport, with JSON-RPC 2.0 as the interoperable
272/// fallback. Flip the two `with` lines below for spec-default JSONRPC-first.
273pub fn default_registry() -> TransportNegotiator {
274    #[allow(unused_mut)]
275    let mut negotiator = TransportNegotiator::new();
276    #[cfg(feature = "http-client")]
277    {
278        negotiator = negotiator.with(ConnectRpcTransportFactory);
279    }
280    #[cfg(feature = "jsonrpc-client")]
281    {
282        negotiator = negotiator.with(JsonRpcTransportFactory);
283    }
284    negotiator
285}
286
287/// Fetch an agent's card and negotiate a transport in one step.
288///
289/// Fetches `/.well-known/agent-card.json` (falling back to `/agent-card`) from
290/// `base_url`, then runs [`TransportNegotiator::negotiate`].
291#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
292pub async fn connect(
293    base_url: &str,
294    negotiator: &TransportNegotiator,
295) -> Result<Box<dyn Transport>, A2AError> {
296    connect_with(base_url, negotiator, &ClientConfig::default()).await
297}
298
299/// As [`connect`], applying `config` to both the card fetch and the negotiated
300/// transport.
301///
302/// The card fetch carries the credentials too: an agent that authenticates its
303/// RPC endpoints usually authenticates the card endpoint as well, and a fetch
304/// that 401s would otherwise sink the whole negotiation.
305///
306/// # Which URL the transport dials
307///
308/// `base_url` is where the *card* is fetched from. The transport dials the
309/// interface URL the card advertises, which is the card's to decide — an
310/// agent may serve JSON-RPC on a sub-path, or its card through a gateway that
311/// does not forward RPC — so the address a caller holds does not override it.
312/// When the two disagree in origin, an agent whose advertised URL is wrong
313/// (korps' `advertised_url`, say) fails every request even for a caller
314/// holding its real address; that is logged here, at the one moment both
315/// URLs are known, because the failure that follows names only the one that
316/// was dialed.
317#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
318pub async fn connect_with(
319    base_url: &str,
320    negotiator: &TransportNegotiator,
321    config: &ClientConfig,
322) -> Result<Box<dyn Transport>, A2AError> {
323    let card = fetch_agent_card_with(base_url, config).await?;
324    let (transport, iface) = negotiator.select(&card, config).await?;
325    if !same_origin(base_url, &iface.url) {
326        #[cfg(feature = "tracing")]
327        tracing::warn!(
328            card_url = %base_url,
329            interface_url = %iface.url,
330            protocol = %iface.protocol_binding,
331            "the agent card advertises its interface at a different origin than it was fetched from; requests go to the advertised URL"
332        );
333    }
334    Ok(transport)
335}
336
337/// Whether two URLs name the same scheme, host and port — the part of an
338/// address that decides which server answers. Paths are the card's business
339/// (an interface on a sub-path is normal); an unparseable URL is reported as a
340/// mismatch, since it cannot be the same server as a parseable one.
341fn same_origin(a: &str, b: &str) -> bool {
342    match (reqwest::Url::parse(a), reqwest::Url::parse(b)) {
343        (Ok(a), Ok(b)) => {
344            a.scheme() == b.scheme()
345                && a.host_str() == b.host_str()
346                && a.port_or_known_default() == b.port_or_known_default()
347        }
348        _ => false,
349    }
350}
351
352/// Validate `base_url`, negotiate a transport from the agent card, and fall back
353/// to a direct client when the card can't be fetched or none of its interfaces
354/// match a compiled-in transport.
355///
356/// This is the one-call ergonomic entry point shared by the CLI and the web
357/// client: it validates the URL up front (so a malformed URL is a hard error),
358/// tries [`connect`] with the [`default_registry`], and on any negotiation miss
359/// falls back to a direct client on `base_url` so the call still works against a
360/// bare agent URL with no published card. The fallback prefers the in-tree
361/// ConnectRPC transport, using JSON-RPC 2.0 when ConnectRPC isn't compiled in.
362///
363/// The fallback is for a card that cannot be *fetched or negotiated*, not for
364/// one that is wrong: when the card is served and names an interface this
365/// client speaks, requests go to the URL the card advertises, whatever
366/// `base_url` was — see [`connect_with`] for why, and for the warning logged
367/// when the two disagree.
368#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
369pub async fn auto_connect(base_url: &str) -> Result<Box<dyn Transport>, A2AError> {
370    auto_connect_with(base_url, &ClientConfig::default()).await
371}
372
373/// As [`auto_connect`], applying `config` to the card fetch, the negotiated
374/// transport, **and** the direct-client fallback.
375///
376/// This is the entry point a CLI or a web client wants whenever credentials are
377/// in play: every path out of it yields a configured client, so `--auth` behaves
378/// the same whether the card negotiated or the fallback fired.
379#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
380pub async fn auto_connect_with(
381    base_url: &str,
382    config: &ClientConfig,
383) -> Result<Box<dyn Transport>, A2AError> {
384    // Validate URL format up front so a malformed URL is a hard error rather
385    // than a silent fallback to a client that will fail on first request.
386    reqwest::Url::parse(base_url)
387        .map_err(|e| A2AError::InvalidParams(format!("invalid url {base_url}: {e}")))?;
388
389    match connect_with(base_url, &default_registry(), config).await {
390        Ok(transport) => Ok(transport),
391        // Card fetch / negotiation failed — fall back to a direct client.
392        Err(_) => direct_transport(base_url, config),
393    }
394}
395
396/// Build a direct client on `base_url`, preferring ConnectRPC when compiled in.
397///
398/// Fallible because `reqwest::Url` — which [`auto_connect_with`] validates with
399/// — is *more* permissive than the `http::Uri` the ConnectRPC client needs:
400/// `http://münchen.de` parses as the former and not the latter. Building
401/// infallibly here turned that gap into a panic on the fallback path, which is
402/// the path a bad URL is most likely to reach in the first place.
403#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
404fn direct_transport(base_url: &str, config: &ClientConfig) -> Result<Box<dyn Transport>, A2AError> {
405    #[cfg(feature = "http-client")]
406    {
407        Ok(Box::new(connect_rpc_client(base_url.to_string(), config)?))
408    }
409    #[cfg(all(not(feature = "http-client"), feature = "jsonrpc-client"))]
410    {
411        Ok(Box::new(jsonrpc_client(base_url.to_string(), config)?))
412    }
413}
414
415/// Fetch an [`AgentCard`] from the agent's well-known endpoint (plain HTTP GET).
416#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
417pub async fn fetch_agent_card(base_url: &str) -> Result<AgentCard, A2AError> {
418    fetch_agent_card_with(base_url, &ClientConfig::default()).await
419}
420
421/// As [`fetch_agent_card`], sending `config`'s credentials and honouring its
422/// timeout — for agents that guard the card endpoint too.
423#[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
424pub async fn fetch_agent_card_with(
425    base_url: &str,
426    config: &ClientConfig,
427) -> Result<AgentCard, A2AError> {
428    use crate::adapter::error::HttpClientError;
429
430    let client = crate::adapter::error::http_client()?;
431    let base = base_url.trim_end_matches('/');
432    for path in [".well-known/agent-card.json", "agent-card"] {
433        let url = format!("{base}/{path}");
434        let mut request = client.get(&url);
435        if let Some(token) = config.auth_token() {
436            request = request.bearer_auth(token);
437        }
438        if let Some(secs) = config.timeout_secs() {
439            request = request.timeout(std::time::Duration::from_secs(secs));
440        }
441        let resp = request.send().await.map_err(HttpClientError::Reqwest)?;
442        if resp.status().is_success() {
443            return resp
444                .json::<AgentCard>()
445                .await
446                .map_err(|e| A2AError::Internal(format!("Failed to parse agent card JSON: {e}")));
447        }
448    }
449    Err(A2AError::Internal(format!(
450        "Agent card not found at {base_url}"
451    )))
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    /// The origin is what decides which server answers; the path is the
459    /// card's to choose and a port left implicit is the same port.
460    #[test]
461    fn same_origin_compares_scheme_host_and_port_only() {
462        assert!(same_origin(
463            "http://127.0.0.1:8081",
464            "http://127.0.0.1:8081/jsonrpc"
465        ));
466        assert!(same_origin(
467            "https://agent.example",
468            "https://agent.example:443/"
469        ));
470        assert!(!same_origin(
471            "http://127.0.0.1:8081",
472            "http://agent.internal:8081"
473        ));
474        assert!(!same_origin(
475            "http://127.0.0.1:8081",
476            "http://127.0.0.1:8082"
477        ));
478        assert!(!same_origin(
479            "http://127.0.0.1:8081",
480            "https://127.0.0.1:8081"
481        ));
482        assert!(!same_origin("http://127.0.0.1:8081", "not a url"));
483    }
484
485    #[test]
486    fn rejects_v2_interface() {
487        assert!(version_compatible("1.0"));
488        assert!(version_compatible("")); // unspecified accepted
489        assert!(!version_compatible("2.0"));
490    }
491
492    /// A `Debug` line must not carry the credential. `ClientConfig` is what a
493    /// caller reaches for when tracing an authentication failure, so the most
494    /// likely line to be logged is the one that would have leaked the token.
495    #[test]
496    fn debug_redacts_the_token() {
497        let rendered = format!("{:?}", ClientConfig::new().with_auth_token("s3cret"));
498        assert!(!rendered.contains("s3cret"), "token leaked: {rendered}");
499        assert!(rendered.contains("redacted"), "{rendered}");
500
501        // …and says so only when there is one to hide.
502        let rendered = format!("{:?}", ClientConfig::new());
503        assert!(!rendered.contains("redacted"), "{rendered}");
504    }
505
506    /// `reqwest::Url` accepts an IDN host and normalizes it to punycode;
507    /// `http::Uri` rejects the raw bytes. `auto_connect_with` validates with the
508    /// former and the fallback built with the latter, so a URL that had just
509    /// been declared valid panicked one line later.
510    #[cfg(feature = "http-client")]
511    #[tokio::test]
512    async fn idn_url_is_an_error_not_a_panic() {
513        assert!(
514            reqwest::Url::parse("http://münchen.de").is_ok(),
515            "premise: the up-front validation accepts this"
516        );
517        match auto_connect("http://münchen.de").await {
518            Err(A2AError::InvalidParams(_)) => {}
519            Err(other) => panic!("wrong error: {other:?}"),
520            Ok(_) => panic!("expected an error for a url http::Uri cannot represent"),
521        }
522    }
523
524    #[cfg(any(feature = "http-client", feature = "jsonrpc-client"))]
525    #[tokio::test]
526    async fn auto_connect_rejects_malformed_url() {
527        // `Box<dyn Transport>` isn't `Debug`, so match rather than `unwrap_err`.
528        match auto_connect("not-a-url").await {
529            Err(A2AError::InvalidParams(_)) => {}
530            Err(other) => panic!("wrong error: {other:?}"),
531            Ok(_) => panic!("expected an error for a malformed url"),
532        }
533    }
534
535    // A well-formed URL with no agent published there: card fetch fails, so
536    // `auto_connect` must fall back to a direct client rather than erroring.
537    // Port 1 is reserved/unroutable, so the GET fails fast.
538    #[cfg(feature = "http-client")]
539    #[tokio::test]
540    async fn auto_connect_falls_back_to_direct_connectrpc() {
541        let transport = auto_connect("http://127.0.0.1:1")
542            .await
543            .expect("fallback yields a direct transport");
544        assert_eq!(transport.protocol(), "CONNECTRPC");
545    }
546
547    #[cfg(all(not(feature = "http-client"), feature = "jsonrpc-client"))]
548    #[tokio::test]
549    async fn auto_connect_falls_back_to_direct_jsonrpc() {
550        let transport = auto_connect("http://127.0.0.1:1")
551            .await
552            .expect("fallback yields a direct transport");
553        assert_eq!(transport.protocol(), "JSONRPC");
554    }
555}