Skip to main content

protocol_droid/application/client/
mod.rs

1//! [`Client`] — the calling side of a negotiated session.
2//!
3//! # What it is
4//!
5//! A client holds the revisions it will speak, in preference order, and the
6//! features it wants. It does not open a connection: a transport does that, and
7//! until T13 supplies one, a test can hand the client a closure directly.
8//!
9//! # The gate, and where it lives
10//!
11//! Every typed method consults the resolved [`FeatureProfile`] before it does
12//! anything, exactly as the server's
13//! [`RequestContext`](crate::application::server::RequestContext) does. The
14//! asymmetry is deliberate: the server's gate protects the _peer_ from a
15//! feature the server did not negotiate, and the client's gate protects the
16//! _caller_ from a round trip that cannot work.
17//!
18//! Both produce the same [`ClientError`], because from the caller's side
19//! "this session cannot do that" is one fact with one fix — configure a
20//! version that can, or drop the call.
21
22use std::sync::Arc;
23
24use crate::domain::TransportSpec;
25use crate::domain::feature::{Feature, FeatureProfile, Negotiation};
26use crate::domain::ir::JsonObject;
27use crate::domain::{
28    CapabilitySet, NegotiatedSession, ProtocolVersion, SupportedVersions, TransportKind,
29};
30
31pub mod bearer;
32mod connect;
33pub mod handler;
34mod handshake;
35pub mod inputs;
36
37pub use connect::{ConnectError, default_versions, open};
38
39pub use handshake::{
40    DISCOVER, HandshakeError, INITIALIZE, INITIALIZED, discover_session, handshake,
41    initialize_params, initialize_session, offer,
42};
43
44/// Sends one request to the peer and returns its answer.
45///
46/// The port a client needs, and the only one. A transport implements it; the
47/// client does not know about sockets, headers, or framing.
48///
49/// The boxed form because a client's exchange is chosen at run time from a
50/// configuration and a test substitutes a different one — there is no
51/// monomorphisation to win here, and holding a `dyn` is the honest shape.
52pub trait ClientTransport: Send + Sync {
53    /// Sends `method` with `params` and yields the peer's answer.
54    ///
55    /// # Errors
56    ///
57    /// [`TransportError`] when the request could not be delivered or answered.
58    /// A client that waited forever would be a caller that cannot be
59    /// interrupted, so a non-answer is an error rather than a hang.
60    fn send(
61        &self,
62        method: &str,
63        params: &JsonObject,
64    ) -> std::pin::Pin<Box<dyn Future<Output = Result<JsonObject, TransportError>> + Send + '_>>;
65
66    /// Sends `method` with `params` and expects no answer.
67    ///
68    /// Separate from [`send`](Self::send) because a notification is not a
69    /// request that tolerates a missing reply — it has no `id` at all, and a
70    /// transport that waited for one would wait forever, because a conforming
71    /// peer correctly sends nothing. The `notifications/initialized` that ends
72    /// the handshake is one of these, so a client that could not send it had no
73    /// way to finish connecting.
74    ///
75    /// # Errors
76    ///
77    /// [`TransportError`] when the notification could not be written. Nothing
78    /// else is possible: there is no reply to be wrong about.
79    fn notify(
80        &self,
81        method: &str,
82        params: &JsonObject,
83    ) -> std::pin::Pin<Box<dyn Future<Output = Result<(), TransportError>> + Send + '_>>;
84}
85
86/// Why a request did not complete.
87#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
88pub enum TransportError {
89    /// The transport could not deliver the request, or the peer did not
90    /// answer it.
91    ///
92    /// A `String` for the same reason the server's
93    /// [`ExchangeError`](crate::application::server::ExchangeError) is: the
94    /// transport names its own failures and the application layer has no
95    /// business enumerating them.
96    #[error("{0}")]
97    Unavailable(String),
98
99    /// The peer answered, and the answer was a JSON-RPC error.
100    ///
101    /// Separate from [`Unavailable`](Self::Unavailable) because the peer is
102    /// fine and the request was not: a caller can act on the code. SEP-2575's
103    /// `-32022` carries the revisions the server does speak, and `-32021` the
104    /// capabilities it needs, and a client that flattened them to a string
105    /// could neither renegotiate nor retry.
106    #[error("the peer refused with {code}: {message}")]
107    Refused {
108        /// The JSON-RPC error code.
109        code: i64,
110        /// The peer's message.
111        message: String,
112        /// The error's `data` member as JSON text, when there was one.
113        data: Option<String>,
114    },
115}
116
117impl TransportError {
118    /// The revisions a `-32022` refusal says the server supports.
119    #[must_use]
120    pub fn supported_versions(&self) -> Option<Vec<String>> {
121        let Self::Refused {
122            code: -32022,
123            data: Some(data),
124            ..
125        } = self
126        else {
127            return None;
128        };
129        let parsed: serde_json::Value = serde_json::from_str(data).ok()?;
130        Some(
131            parsed
132                .get("supported")?
133                .as_array()?
134                .iter()
135                .filter_map(|version| version.as_str().map(ToOwned::to_owned))
136                .collect(),
137        )
138    }
139}
140
141/// Why a client call did not happen.
142#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
143pub enum ClientError {
144    /// The negotiated session does not have the feature the call needs.
145    ///
146    /// The revision is carried for the same reason the server's is: "sampling
147    /// is unavailable" is not something a caller can act on without knowing
148    /// what _was_ negotiated.
149    #[error("{feature} was not negotiated (session is {negotiated})")]
150    FeatureUnavailable {
151        /// The feature the call needed.
152        feature: Feature,
153        /// The revision this session settled on.
154        negotiated: ProtocolVersion,
155    },
156
157    /// The request could not be delivered or answered.
158    #[error("the request did not complete: {0}")]
159    Transport(#[from] TransportError),
160
161    /// The server asked for input and nothing could provide it.
162    ///
163    /// Names the callback so a one-shot caller sees what to wire rather than
164    /// a hang: an agent loop that waits for a person who is not there is worse
165    /// than one that fails.
166    #[error("the server asked for `{method}` input, which no resolver provided")]
167    InputUnresolved {
168        /// The input request's method, e.g. `elicitation/create`.
169        method: String,
170    },
171
172    /// The server kept asking for input past the round limit.
173    #[error("the server asked for input more than {0} times")]
174    TooManyRounds(u32),
175}
176
177impl ClientError {
178    /// The feature this refusal is about, when there is one.
179    #[must_use]
180    pub const fn feature(&self) -> Option<Feature> {
181        match self {
182            Self::FeatureUnavailable { feature, .. } => Some(*feature),
183            Self::Transport(_) | Self::InputUnresolved { .. } | Self::TooManyRounds(_) => None,
184        }
185    }
186}
187
188/// What failed when [`Client::connect_stdio`] could not open a session.
189///
190/// Mirrors [`ConnectError`] but is stdio-specific: the spawn stage is the
191/// one the HTTP paths do not have, and the typed inner errors are the
192/// adapter's spawn / handshake / versions errors rather than the HTTP
193/// ones. Kept as a distinct type so a caller that handles "could not
194/// connect" once does not have to know which transport it was.
195#[cfg(feature = "stdio")]
196#[derive(Debug, thiserror::Error)]
197pub enum StdioConnectError {
198    /// The offered revisions could not be built.
199    #[error("this build's own revision table is unusable: {0}")]
200    Versions(#[from] crate::domain::SupportedVersionsError),
201
202    /// The transport config could not be built.
203    #[error("the transport config is not usable: {0}")]
204    Config(#[from] ClientError),
205
206    /// The child could not be spawned.
207    #[error("the child could not be spawned: {0}")]
208    Spawn(#[from] crate::adapters::secondary::stdio::SpawnError),
209
210    /// The handshake did not complete.
211    #[error("the handshake did not complete: {0}")]
212    Handshake(#[from] HandshakeError),
213}
214
215/// What a client offers a server, before anything is negotiated.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct ClientConfig {
218    /// The revisions this client speaks, most preferred first.
219    ///
220    /// An ordered list rather than one version because a client that can speak
221    /// three revisions should say so and take the best the server agrees to,
222    /// rather than pick one and fail when the server prefers another. A
223    /// single-element list is the "I only speak this" case and needs no
224    /// separate type.
225    versions: Vec<ProtocolVersion>,
226    capabilities: CapabilitySet,
227    transport: TransportKind,
228}
229
230impl ClientConfig {
231    /// A client that speaks `version`, with nothing advertised.
232    #[must_use]
233    pub fn new(version: ProtocolVersion, transport: TransportKind) -> Self {
234        Self {
235            versions: vec![version],
236            capabilities: CapabilitySet::empty(),
237            transport,
238        }
239    }
240
241    /// A client that speaks several, most preferred first.
242    ///
243    /// # Errors
244    ///
245    /// Refuses an empty list, because a client that speaks no revision cannot
246    /// negotiate and would otherwise fail much later with a message about
247    /// something else entirely.
248    pub fn speaking(
249        versions: impl IntoIterator<Item = ProtocolVersion>,
250        transport: TransportKind,
251    ) -> Result<Self, ClientError> {
252        let versions: Vec<ProtocolVersion> = versions.into_iter().collect();
253        if versions.is_empty() {
254            return Err(ClientError::Transport(TransportError::Unavailable(
255                "a client must offer at least one revision".to_string(),
256            )));
257        }
258        Ok(Self {
259            versions,
260            capabilities: CapabilitySet::empty(),
261            transport,
262        })
263    }
264
265    /// Declares the capabilities the client supports.
266    #[must_use]
267    pub const fn with_capabilities(mut self, capabilities: CapabilitySet) -> Self {
268        self.capabilities = capabilities;
269        self
270    }
271
272    /// The revisions offered, most preferred first.
273    #[must_use]
274    pub fn versions(&self) -> &[ProtocolVersion] {
275        &self.versions
276    }
277
278    /// The capabilities this client declared.
279    ///
280    /// Read by the handshake to render the `initialize` request, so it has to
281    /// be the same set the profile will be resolved from — a handshake that
282    /// advertised a different one would produce a session that claims to do
283    /// something it never told the server it could.
284    #[must_use]
285    pub const fn capabilities(&self) -> CapabilitySet {
286        self.capabilities
287    }
288
289    /// The transport the client will run over.
290    #[must_use]
291    pub const fn transport(&self) -> TransportKind {
292        self.transport
293    }
294
295    /// The negotiation this offer produces against a server's answer.
296    ///
297    /// The one place the two halves are put together. A caller that already
298    /// knows the version and the server's capabilities — which is what a
299    /// transport knows once the handshake is done — passes them here rather
300    /// than leaving this type to invent a half-negotiation.
301    #[must_use]
302    pub const fn negotiated_with(
303        &self,
304        version: ProtocolVersion,
305        server_caps: CapabilitySet,
306    ) -> Negotiation {
307        Negotiation {
308            version,
309            client_caps: self.capabilities,
310            server_caps,
311            transport: self.transport,
312        }
313    }
314}
315
316/// A client bound to a negotiated session.
317///
318/// # Examples
319///
320/// ```
321/// use std::sync::Arc;
322///
323/// use protocol_droid::application::client::{Client, ClientConfig, TransportError};
324/// use protocol_droid::domain::ir::JsonObject;
325/// use protocol_droid::domain::{ProtocolVersion, TransportKind};
326///
327/// /// A transport that always refuses, which is enough to show the gate.
328/// struct Dead;
329/// # impl protocol_droid::application::client::ClientTransport for Dead {
330/// #     fn send(&self, _: &str, _: &JsonObject)
331/// #         -> std::pin::Pin<Box<dyn Future<Output = Result<JsonObject, TransportError>> + Send>>
332/// #     { Box::pin(async { Err(TransportError::Unavailable("no route".to_string())) }) }
333/// #     fn notify(&self, _: &str, _: &JsonObject)
334/// #         -> std::pin::Pin<Box<dyn Future<Output = Result<(), TransportError>> + Send>>
335/// #     { Box::pin(async { Err(TransportError::Unavailable("no route".to_string())) }) }
336/// # }
337///
338/// let version = ProtocolVersion::V2024_11_05;
339/// let config = ClientConfig::new(version, TransportKind::Stdio);
340/// let client = Client::connect(
341///     config.negotiated_with(version, protocol_droid::domain::CapabilitySet::all_for(version)),
342///     Arc::new(Dead),
343/// );
344///
345/// // Tools exist in every revision, and both sides declared everything that
346/// // revision defines, so the gate is open and the dead transport is what
347/// // fails the call.
348/// assert!(client.supports(protocol_droid::domain::Feature::Tools));
349///
350/// let outcome = tokio::runtime::Builder::new_current_thread()
351///     .build()
352///     .map_err(|e| e.to_string())?
353///     .block_on(client.list_tools());
354/// assert_eq!(
355///     outcome.map_or_else(|e| e.to_string(), |_| "succeeded".to_string()),
356///     "the request did not complete: no route",
357/// );
358/// # Ok::<(), String>(())
359/// ```
360#[derive(Clone)]
361pub struct Client {
362    session: Arc<NegotiatedSession>,
363    transport: Arc<dyn ClientTransport>,
364}
365
366impl core::fmt::Debug for Client {
367    /// Prints the negotiation, not the transport.
368    ///
369    /// For the same reason `PeerSession` does: a transport is a socket and a
370    /// vtable pointer, and neither survives being printed at a call site.
371    ///
372    /// The session is printed as its version and feature count rather than in
373    /// full because it carries both peers' `PeerInfo`, which is caller-supplied
374    /// text and does not belong in a log line nobody asked for.
375    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
376        f.debug_struct("Client")
377            .field("negotiated", &self.session.version)
378            .field("transport", &self.session.transport)
379            .field("feature_count", &self.session.profile.len())
380            .finish_non_exhaustive()
381    }
382}
383
384impl Client {
385    /// Binds a transport to a session that has already negotiated.
386    ///
387    /// Takes the whole [`Negotiation`] rather than just the version. The
388    /// profile is a function of four inputs, and a client knows only two of
389    /// them — its own capabilities and the transport. Re-deriving it here
390    /// would mean guessing the server's half, and a guess that leaves the
391    /// server's capabilities empty turns a feature nobody declared into a
392    /// feature nobody may use. The transport did the handshake and has the
393    /// real answer, so it passes the real answer.
394    ///
395    /// Prefer [`from_session`](Self::from_session) when a
396    /// [`NegotiatedSession`] is to hand: it is this constructor minus the copy,
397    /// and without the copy's chance of being wrong. This one exists for a
398    /// caller assembling a [`Negotiation`] directly, where re-deriving is the
399    /// only way to get a profile at all.
400    #[must_use]
401    pub fn connect(negotiation: Negotiation, transport: Arc<dyn ClientTransport>) -> Self {
402        Self {
403            session: Arc::new(NegotiatedSession::new(
404                negotiation.version,
405                negotiation.transport,
406                negotiation.client_caps,
407                negotiation.server_caps,
408            )),
409            transport,
410        }
411    }
412
413    /// Binds a transport to a session that has already negotiated.
414    ///
415    /// The one to reach for. [`connect`](Self::connect) takes the four inputs
416    /// [`resolve`](crate::domain::feature::resolve) needs and re-derives a profile from them — but a
417    /// [`NegotiatedSession`] has _already_ resolved one, from those same four
418    /// fields, so a caller going through `connect` rebuilds a struct literal
419    /// and re-runs a pure function to arrive back where it started.
420    ///
421    /// That is worse than redundant. Nothing obliges the four fields a caller
422    /// copies to be the four the session was built from, so a caller who
423    /// transposes one — or leaves `client_caps` empty because the transport
424    /// made it awkward — gets a client whose profile disagrees with the session
425    /// it just negotiated, and the disagreement surfaces much later as a
426    /// feature that was advertised and then refused. Reading the profile off
427    /// the session removes the copy, and with it the possibility of copying it
428    /// wrong.
429    ///
430    /// # Examples
431    ///
432    /// ```
433    /// use std::sync::Arc;
434    ///
435    /// use protocol_droid::application::client::{Client, ClientConfig, ClientTransport, handshake};
436    /// use protocol_droid::domain::{ProtocolVersion, TransportKind};
437    ///
438    /// async fn connect(transport: Arc<dyn ClientTransport>) -> Result<(), String> {
439    ///     let config = ClientConfig::speaking(
440    ///         [ProtocolVersion::V2025_06_18].into_iter(),
441    ///         TransportKind::Stdio,
442    ///     )
443    ///     .map_err(|e| e.to_string())?;
444    ///     let session = handshake(transport.as_ref(), &config)
445    ///         .await
446    ///         .map_err(|e| e.to_string())?;
447    ///     let client = Client::from_session(&session, transport);
448    ///     assert!(client.supports(protocol_droid::domain::Feature::Tools));
449    ///     Ok(())
450    /// }
451    /// ```
452    #[must_use]
453    pub fn from_session(session: &NegotiatedSession, transport: Arc<dyn ClientTransport>) -> Self {
454        Self {
455            session: Arc::new(session.clone()),
456            transport,
457        }
458    }
459
460    /// Connects to `spec`, negotiates, and returns a bound client.
461    ///
462    /// The one-call form, for the common case where the caller has a URL and no
463    /// opinion about revisions. The four steps it replaces — pick the adapter,
464    /// choose what to offer, run the handshake, bind the result — are all
465    /// decisions the crate can make from the spec, and a caller who has to make
466    /// them is doing the crate's work.
467    ///
468    /// Offers every revision this build has wire types for. Narrow that with
469    /// [`connect_to_speaking`](Self::connect_to_speaking) when the server is
470    /// known to be older, or when a build is expected to run against something
471    /// that answers a narrow offer badly.
472    ///
473    /// # Errors
474    ///
475    /// [`ConnectError`] naming the stage that failed. A transport this build
476    /// has no adapter for is [`ConnectError::Unsupported`] rather than a
477    /// handshake error, so "this crate cannot speak that" is never reported as
478    /// "that server would not talk".
479    ///
480    /// # Examples
481    ///
482    /// ```no_run
483    /// # use protocol_droid::application::client::Client;
484    /// # use protocol_droid::domain::TransportSpec;
485    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
486    /// let spec = TransportSpec::new(
487    ///     protocol_droid::domain::TransportKind::StreamableHttp,
488    ///     "http://127.0.0.1:3000/mcp",
489    /// )?;
490    /// let client = Client::connect_to(&spec).await?;
491    /// let tools = client.list_tools().await?;
492    /// # let _ = tools;
493    /// # Ok(())
494    /// # }
495    /// ```
496    pub async fn connect_to(spec: &TransportSpec) -> Result<Self, ConnectError> {
497        Ok(connect::open(spec, &default_versions()?).await?.0)
498    }
499
500    /// Connects to `spec`, offering exactly `versions`.
501    ///
502    /// [`connect_to`](Self::connect_to) for a caller who has an opinion about
503    /// what to offer. The narrowing is deliberate: offering a revision a server
504    /// does not speak gets the *next* one it does, so a set that excludes the
505    /// right revision is refused by servers that would otherwise have worked.
506    ///
507    /// # Errors
508    ///
509    /// As [`connect_to`](Self::connect_to), plus
510    /// [`ConnectError::Config`] when `versions` is empty.
511    pub async fn connect_to_speaking(
512        spec: &TransportSpec,
513        versions: &SupportedVersions,
514    ) -> Result<Self, ConnectError> {
515        Ok(connect::open(spec, versions).await?.0)
516    }
517
518    /// Connects to `spec`, offering `versions` and applying `options`.
519    ///
520    /// For a client that can answer the server: a server asks for sampling,
521    /// elicitation or roots only when the client declared them, so
522    /// [`connect_to`](Self::connect_to), which declares nothing, can never be
523    /// asked. The handler in `options` answers what the server does ask.
524    ///
525    /// # Errors
526    ///
527    /// As [`connect_to_speaking`](Self::connect_to_speaking).
528    pub async fn connect_with(
529        spec: &TransportSpec,
530        versions: &SupportedVersions,
531        options: handler::ConnectOptions,
532    ) -> Result<Self, ConnectError> {
533        Ok(connect::open_with(spec, versions, options).await?.0)
534    }
535
536    /// Connects to an MCP server over stdio by spawning it as a child.
537    ///
538    /// `spec` is the program and (optionally) arguments; `args` is
539    /// appended after the program's own arguments. The function spawns
540    /// the child, drains the child's stderr into
541    /// `Diagnostics::stderr()`, runs the MCP `initialize` handshake,
542    /// and binds the resulting session to a [`Client`].
543    ///
544    /// This is the stdio counterpart of [`connect_to`](Self::connect_to)
545    /// for the HTTP transports. The four steps the consumer would
546    /// otherwise write by hand — spawn a [`StdioClient`], build a
547    /// [`ClientConfig`], run [`handshake`], bind the session with
548    /// [`from_session`](Self::from_session) — are one call here.
549    /// Each of those steps is a decision the consumer should not be
550    /// making for a use case the library already has an answer to:
551    /// which adapter speaks a given spec is a fact about the crate,
552    /// the set of revisions to offer is a matter of what this build
553    /// has wire types for, and the diagnostics channel is the obvious
554    /// one. A consumer who has an opinion on any of those uses the
555    /// four steps directly; both paths are public.
556    ///
557    /// # Errors
558    ///
559    /// Returns [`StdioConnectError`] on any of the four stages. The
560    /// child is killed on failure, so a refused handshake does not
561    /// leave a process behind.
562    ///
563    /// # Example
564    ///
565    /// ```no_run
566    /// use protocol_droid::adapters::secondary::stdio::ChildSpec;
567    /// use protocol_droid::application::client::Client;
568    ///
569    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
570    /// let spec = ChildSpec::new("upstream-mcp-server")?;
571    /// let client = Client::connect_stdio(&spec).await?;
572    /// let tools = client.list_tools().await?;
573    /// # Ok(())
574    /// # }
575    /// ```
576    ///
577    /// [`StdioClient`]: crate::adapters::secondary::stdio::StdioClient
578    /// [`ClientConfig`]: crate::adapters::secondary::stdio::ClientConfig
579    #[cfg(feature = "stdio")]
580    pub async fn connect_stdio(
581        spec: &crate::adapters::secondary::stdio::ChildSpec,
582    ) -> Result<Self, StdioConnectError> {
583        use crate::adapters::primary::stdio::Diagnostics;
584        use crate::adapters::secondary::stdio::StdioClient;
585
586        let diagnostics = Diagnostics::stderr();
587        let transport_config = crate::adapters::secondary::stdio::ClientConfig::default();
588        let transport = StdioClient::spawn(spec, transport_config, diagnostics)
589            .await
590            .map_err(StdioConnectError::Spawn)?;
591        let transport: Arc<dyn ClientTransport> = Arc::new(transport);
592        let versions = default_versions().map_err(StdioConnectError::Versions)?;
593        let config = ClientConfig::speaking(versions.as_slice().to_vec(), TransportKind::Stdio)
594            .map_err(StdioConnectError::Config)?;
595        let session = handshake(transport.as_ref(), &config)
596            .await
597            .map_err(StdioConnectError::Handshake)?;
598        Ok(Self::from_session(&session, transport))
599    }
600
601    /// The revision this client and its server agreed on.
602    #[must_use]
603    pub fn negotiated(&self) -> ProtocolVersion {
604        self.session.version
605    }
606
607    /// The profile that revision produced.
608    #[must_use]
609    pub fn profile(&self) -> FeatureProfile {
610        self.session.profile
611    }
612
613    /// The negotiated session behind this client.
614    ///
615    /// Everything the handshake agreed on, including the parts a caller
616    /// usually wants for reporting rather than for gating: what the server
617    /// called itself and which revision it answered with, what each side
618    /// advertised, which transport this is, and whether the profile is
619    /// recomputed per request with nothing retained.
620    ///
621    /// The accessors above answer the two questions a caller asks before every
622    /// call. This answers the one asked once, usually by a log line or a
623    /// `/healthz` body — and it answers it from the session rather than from a
624    /// second copy of the parts a caller remembered to keep.
625    ///
626    /// Not `const`: the session is behind an `Arc`, and reading through one is
627    /// not a constant expression. A caller who needs the version in a `const`
628    /// should negotiate in a function it can run, not reach for one here.
629    #[must_use]
630    pub fn session(&self) -> &NegotiatedSession {
631        &self.session
632    }
633
634    /// Whether `feature` may be used on this session.
635    #[must_use]
636    pub fn supports(&self, feature: Feature) -> bool {
637        self.session.profile.allows(feature)
638    }
639
640    /// Sends `method` exactly as given, skipping the capability gate.
641    ///
642    /// The typed methods refuse what the server did not advertise, which is
643    /// right for an application and wrong for a tool whose job is to see what a
644    /// server actually does: an inspector, a prober, a conformance client. A
645    /// server may serve a method it did not advertise, and the only way to
646    /// learn that is to ask.
647    ///
648    /// # Errors
649    ///
650    /// [`ClientError::Transport`] as for any request.
651    pub async fn send_unchecked(
652        &self,
653        method: &str,
654        params: &JsonObject,
655    ) -> Result<JsonObject, ClientError> {
656        Ok(self.transport.send(method, params).await?)
657    }
658
659    /// Lists the server's tools.
660    ///
661    /// # Errors
662    ///
663    /// [`ClientError::FeatureUnavailable`] when the session has no
664    /// [`Feature::Tools`], having written nothing. A 2024-11-05 session always
665    /// has one, but a session whose peer advertises nothing still might not,
666    /// and a caller learns which from this rather than from an opaque
667    /// protocol error.
668    pub async fn list_tools(&self) -> Result<JsonObject, ClientError> {
669        self.require(Feature::Tools)?;
670        self.request("tools/list", "{}").await
671    }
672
673    /// Calls `name` with `arguments`.
674    ///
675    /// # Errors
676    ///
677    /// As [`list_tools`](Self::list_tools).
678    pub async fn call_tool(
679        &self,
680        name: &str,
681        arguments: &JsonObject,
682    ) -> Result<JsonObject, ClientError> {
683        self.require(Feature::Tools)?;
684        // `arguments` is a validated `JsonObject`, so re-parsing it cannot
685        // fail. It has to be re-parsed at all: `JsonObject` is opaque text by
686        // design, and nesting one object inside another is the one operation
687        // that needs its structure back. The fallback is the empty object
688        // rather than an error, because a value that arrived here as a
689        // `JsonObject` has already been checked once and a second failure
690        // would report the same malformation from two places.
691        let nested = serde_json::from_str::<serde_json::Value>(arguments.as_str())
692            .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
693        let params = JsonObject::from_value(serde_json::json!({
694            "name": name,
695            "arguments": nested,
696        }))
697        .unwrap_or_default();
698        self.transport
699            .send("tools/call", &params)
700            .await
701            .map_err(ClientError::from)
702    }
703
704    /// Calls `name`, answering any input the server asks for along the way.
705    ///
706    /// See [`call_resolving`](inputs::call_resolving) for the rules.
707    ///
708    /// # Errors
709    ///
710    /// As [`call_tool`](Self::call_tool), plus
711    /// [`ClientError::InputUnresolved`] and [`ClientError::TooManyRounds`].
712    pub async fn call_tool_resolving(
713        &self,
714        name: &str,
715        arguments: &JsonObject,
716        resolve: &(impl inputs::InputResolver + ?Sized),
717    ) -> Result<JsonObject, ClientError> {
718        self.require(Feature::Tools)?;
719        let nested = serde_json::from_str::<serde_json::Value>(arguments.as_str())
720            .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
721        let params =
722            JsonObject::from_value(serde_json::json!({ "name": name, "arguments": nested }))
723                .unwrap_or_default();
724        inputs::call_resolving(self.transport.as_ref(), "tools/call", &params, resolve).await
725    }
726
727    /// Lists the server's resources.
728    ///
729    /// # Errors
730    ///
731    /// As [`list_tools`](Self::list_tools), with
732    /// [`Feature::Resources`](Feature) in place of
733    /// [`Feature::Tools`](Feature).
734    pub async fn list_resources(&self) -> Result<JsonObject, ClientError> {
735        self.require(Feature::Resources)?;
736        self.request("resources/list", "{}").await
737    }
738
739    /// Lists the server's prompts.
740    ///
741    /// # Errors
742    ///
743    /// As [`list_tools`](Self::list_tools), with
744    /// [`Feature::Prompts`](Feature) in place of
745    /// [`Feature::Tools`](Feature).
746    pub async fn list_prompts(&self) -> Result<JsonObject, ClientError> {
747        self.require(Feature::Prompts)?;
748        self.request("prompts/list", "{}").await
749    }
750
751    /// Lists the server's resource templates.
752    ///
753    /// # Errors
754    ///
755    /// As [`list_resources`](Self::list_resources).
756    pub async fn list_resource_templates(&self) -> Result<JsonObject, ClientError> {
757        self.require(Feature::Resources)?;
758        self.request("resources/templates/list", "{}").await
759    }
760
761    /// Reads the resource at `uri`.
762    ///
763    /// # Errors
764    ///
765    /// As [`list_resources`](Self::list_resources).
766    pub async fn read_resource(&self, uri: &str) -> Result<JsonObject, ClientError> {
767        self.require(Feature::Resources)?;
768        let params = JsonObject::from_value(serde_json::json!({ "uri": uri })).unwrap_or_default();
769        Ok(self.transport.send("resources/read", &params).await?)
770    }
771
772    /// Gets the prompt `name`, rendered with `arguments`.
773    ///
774    /// # Errors
775    ///
776    /// As [`list_prompts`](Self::list_prompts).
777    pub async fn get_prompt(
778        &self,
779        name: &str,
780        arguments: &JsonObject,
781    ) -> Result<JsonObject, ClientError> {
782        self.require(Feature::Prompts)?;
783        let nested = serde_json::from_str::<serde_json::Value>(arguments.as_str())
784            .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
785        let params =
786            JsonObject::from_value(serde_json::json!({ "name": name, "arguments": nested }))
787                .unwrap_or_default();
788        Ok(self.transport.send("prompts/get", &params).await?)
789    }
790
791    /// The gate, in one place, called by every typed method.
792    fn require(&self, feature: Feature) -> Result<(), ClientError> {
793        if self.session.profile.allows(feature) {
794            return Ok(());
795        }
796        Err(ClientError::FeatureUnavailable {
797            feature,
798            negotiated: self.session.version,
799        })
800    }
801
802    /// Sends a request whose gate has already been checked.
803    async fn request(&self, method: &str, params: &str) -> Result<JsonObject, ClientError> {
804        let owned = match JsonObject::new(params) {
805            Ok(object) => object,
806            Err(error) => {
807                return Err(ClientError::Transport(TransportError::Unavailable(
808                    format!("{method} was given parameters that are not a JSON object: {error}"),
809                )));
810            }
811        };
812        self.transport
813            .send(method, &owned)
814            .await
815            .map_err(ClientError::from)
816    }
817}
818
819#[cfg(test)]
820mod tests {
821    use std::sync::{Arc, Mutex};
822
823    use super::{Client, ClientConfig, ClientError, ClientTransport, TransportError};
824    use crate::domain::feature::Feature;
825    use crate::domain::ir::{JsonObject, JsonObjectError};
826    use crate::domain::{CapabilitySet, ProtocolVersion, TransportKind};
827
828    /// What a test here can fail with.
829    #[derive(Debug)]
830    enum Failure {
831        /// The test's own complaint.
832        Message(String),
833        /// A fixture was not a JSON object.
834        Fixture(JsonObjectError),
835    }
836
837    impl core::fmt::Display for Failure {
838        fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
839            match self {
840                Self::Message(text) => f.write_str(text),
841                Self::Fixture(error) => write!(f, "bad fixture: {error}"),
842            }
843        }
844    }
845
846    impl std::error::Error for Failure {}
847
848    impl From<String> for Failure {
849        fn from(text: String) -> Self {
850            Self::Message(text)
851        }
852    }
853
854    impl From<&str> for Failure {
855        fn from(text: &str) -> Self {
856            Self::Message(text.to_string())
857        }
858    }
859
860    impl From<JsonObjectError> for Failure {
861        fn from(error: JsonObjectError) -> Self {
862            Self::Fixture(error)
863        }
864    }
865
866    type TestError = Failure;
867
868    /// A transport that records what it was asked and answers with a fixed
869    /// object.
870    struct Recorder {
871        calls: Mutex<Vec<String>>,
872        answer: Option<JsonObject>,
873    }
874
875    impl Recorder {
876        fn answering(answer: &str) -> Result<Self, TestError> {
877            Ok(Self {
878                calls: Mutex::new(Vec::new()),
879                answer: Some(JsonObject::new(answer)?),
880            })
881        }
882
883        fn dead() -> Self {
884            Self {
885                calls: Mutex::new(Vec::new()),
886                answer: None,
887            }
888        }
889
890        fn called(&self) -> Vec<String> {
891            let calls = self
892                .calls
893                .lock()
894                .unwrap_or_else(std::sync::PoisonError::into_inner);
895            calls.clone()
896        }
897    }
898
899    impl ClientTransport for Recorder {
900        fn send(
901            &self,
902            method: &str,
903            _params: &JsonObject,
904        ) -> std::pin::Pin<Box<dyn Future<Output = Result<JsonObject, TransportError>> + Send + '_>>
905        {
906            {
907                let mut calls = self
908                    .calls
909                    .lock()
910                    .unwrap_or_else(std::sync::PoisonError::into_inner);
911                calls.push(method.to_string());
912            }
913            let outcome = self
914                .answer
915                .clone()
916                .ok_or_else(|| TransportError::Unavailable("nothing listening".to_string()));
917            Box::pin(std::future::ready(outcome))
918        }
919
920        /// Records the method and answers nothing, which is the whole
921        /// difference between this and `send`.
922        fn notify(
923            &self,
924            method: &str,
925            _params: &JsonObject,
926        ) -> std::pin::Pin<Box<dyn Future<Output = Result<(), TransportError>> + Send + '_>>
927        {
928            self.called();
929            {
930                let mut calls = self
931                    .calls
932                    .lock()
933                    .unwrap_or_else(std::sync::PoisonError::into_inner);
934                calls.push(method.to_string());
935            }
936            Box::pin(std::future::ready(Ok(())))
937        }
938    }
939
940    /// A client at `version`, with both sides declaring everything that
941    /// revision defines.
942    ///
943    /// `all_for` rather than a hand-listed set, so a change to the capability
944    /// matrix moves this instead of quietly invalidating the premise.
945    fn client_at(version: ProtocolVersion, transport: Arc<Recorder>) -> Client {
946        let caps = CapabilitySet::all_for(version);
947        let config = ClientConfig::new(version, TransportKind::Stdio);
948        Client::connect(config.negotiated_with(version, caps), transport)
949    }
950
951    /// A gated call that cannot run writes nothing and names the feature.
952    ///
953    /// The transport is `dead`, so a call that got through would fail with
954    /// `Transport` — which is what makes this a test of the gate and not of
955    /// the transport. The error variant is the whole assertion.
956    #[tokio::test]
957    async fn a_gated_call_on_a_session_without_it_never_reaches_the_transport()
958    -> Result<(), TestError> {
959        // 2024-11-05 has elicitation only in... it does not have it at all.
960        // Resolving with empty capabilities on both sides is the honest way to
961        // say "this session negotiated nothing optional".
962        let transport = Arc::new(Recorder::dead());
963        let version = ProtocolVersion::V2024_11_05;
964        let config = ClientConfig::new(version, TransportKind::Stdio);
965        let client = Client::connect(
966            config.negotiated_with(version, CapabilitySet::empty()),
967            transport.clone(),
968        );
969
970        let refusal = match client.list_prompts().await {
971            Ok(answer) => {
972                return Err(Failure::from(format!("expected a refusal, got {answer}")));
973            }
974            Err(error) => error,
975        };
976
977        assert_eq!(
978            refusal,
979            ClientError::FeatureUnavailable {
980                feature: Feature::Prompts,
981                negotiated: ProtocolVersion::V2024_11_05,
982            }
983        );
984        assert_eq!(refusal.feature(), Some(Feature::Prompts));
985        assert!(transport.called().is_empty());
986        Ok(())
987    }
988
989    /// An ungated call reaches the transport with the right method name.
990    ///
991    /// The other half of the gate's contract: a test suite of refusals would
992    /// pass just as happily against a client that never sends anything.
993    #[tokio::test]
994    async fn a_permitted_call_reaches_the_transport() -> Result<(), TestError> {
995        let version = ProtocolVersion::V2025_06_18;
996        let transport = Arc::new(Recorder::answering(r#"{"tools":[]}"#)?);
997        let client = client_at(version, transport.clone());
998
999        let answer = match client.list_tools().await {
1000            Ok(answer) => answer,
1001            Err(error) => return Err(Failure::from(error.to_string())),
1002        };
1003
1004        assert_eq!(answer.as_str(), r#"{"tools":[]}"#);
1005        assert_eq!(transport.called(), vec!["tools/list".to_string()]);
1006        Ok(())
1007    }
1008
1009    /// Each typed method sends its own method name.
1010    ///
1011    /// A copy-paste that sent `tools/list` for a resource listing would pass
1012    /// every other test here and break only against a real server, so the
1013    /// names are checked together rather than one call at a time.
1014    #[tokio::test]
1015    async fn each_typed_method_sends_its_own_method() -> Result<(), TestError> {
1016        let version = ProtocolVersion::V2025_06_18;
1017        let transport = Arc::new(Recorder::answering("{}")?);
1018        let client = client_at(version, transport.clone());
1019
1020        for outcome in [
1021            client.list_tools().await.map(|_| ()),
1022            client.list_resources().await.map(|_| ()),
1023            client.list_prompts().await.map(|_| ()),
1024        ] {
1025            if let Err(error) = outcome {
1026                return Err(Failure::from(error.to_string()));
1027            }
1028        }
1029
1030        assert_eq!(
1031            transport.called(),
1032            vec![
1033                "tools/list".to_string(),
1034                "resources/list".to_string(),
1035                "prompts/list".to_string(),
1036            ]
1037        );
1038        Ok(())
1039    }
1040
1041    /// A client that offers no revisions is refused at construction.
1042    ///
1043    /// Before the negotiation, not during it: a client that cannot say what it
1044    /// speaks should not produce a session at all.
1045    #[test]
1046    fn a_client_offering_no_revisions_is_refused() -> Result<(), TestError> {
1047        let empty: Vec<ProtocolVersion> = Vec::new();
1048        let config = ClientConfig::speaking(empty, TransportKind::Stdio);
1049
1050        match config {
1051            Ok(_) => Err(Failure::from("a client offering no revisions was accepted")),
1052            Err(_) => Ok(()),
1053        }
1054    }
1055
1056    /// The profile a client reports is the one the handshake produced.
1057    ///
1058    /// The point of taking the whole `Negotiation` rather than guessing the
1059    /// server's half: a client that invented an empty server capability set
1060    /// would report a profile stricter than the real session, and this pins
1061    /// the difference.
1062    #[test]
1063    fn the_client_reports_the_negotiations_profile() {
1064        let version = ProtocolVersion::V2025_06_18;
1065        let transport = Arc::new(Recorder::dead());
1066        let config = ClientConfig::new(version, TransportKind::Stdio);
1067
1068        let generous = Client::connect(
1069            config.negotiated_with(version, CapabilitySet::all_for(version)),
1070            transport.clone(),
1071        );
1072        let stingy = Client::connect(
1073            config.negotiated_with(version, CapabilitySet::empty()),
1074            transport,
1075        );
1076
1077        assert_eq!(generous.negotiated(), version);
1078        assert!(
1079            generous.supports(Feature::Resources),
1080            "both sides declared everything {version} defines"
1081        );
1082        assert!(
1083            !stingy.supports(Feature::Resources),
1084            "a server that declared nothing offers no resources"
1085        );
1086    }
1087
1088    /// A client built from a session keeps the session's own profile.
1089    ///
1090    /// This is the property `from_session` exists to guarantee, and the reason
1091    /// it is tested rather than assumed: the client's profile is only equal to
1092    /// the session's because `from_session` moves the one across instead of
1093    /// re-deriving it from a copied struct literal. Nothing in the types
1094    /// stops someone from changing that back, and the symptom would be a
1095    /// client that refuses a feature the session it came from allowed.
1096    ///
1097    /// The comparison is against the session's profile read off the session
1098    /// itself. Comparing against another `resolve` call would be a third
1099    /// derivation, which could agree with both by accident and prove nothing
1100    /// about the path being tested.
1101    #[test]
1102    fn a_client_from_a_session_keeps_the_sessions_profile() {
1103        let version = ProtocolVersion::V2025_06_18;
1104        let session = crate::domain::NegotiatedSession::new(
1105            version,
1106            TransportKind::Stdio,
1107            CapabilitySet::all_for(version),
1108            CapabilitySet::all_for(version),
1109        );
1110        let expected = session.profile;
1111        let client = Client::from_session(&session, Arc::new(Recorder::dead()));
1112
1113        assert_eq!(client.negotiated(), version);
1114        assert_eq!(
1115            client.profile(),
1116            expected,
1117            "the client's profile must be the session's, not a second derivation of it"
1118        );
1119        assert!(client.supports(Feature::Resources));
1120    }
1121
1122    /// The half of the handshake a caller reports rather than gates on.
1123    ///
1124    /// `a_client_from_a_session_keeps_the_sessions_profile` covers the two
1125    /// fields the typed methods read, which is why `Client` was able to keep
1126    /// only those two and pass. This one covers the five the profile says
1127    /// nothing about, and it is the test that would have caught a client
1128    /// which answered "which transport is this?" by guessing from the URL.
1129    ///
1130    /// Deliberately built on `with_server_info` rather than on the bare
1131    /// constructor: an `Arc::new(NegotiatedSession::new(..))` would satisfy
1132    /// every version and capability assertion here while silently dropping the
1133    /// peer names, and the symptom a consumer hits is a log line that says
1134    /// "server: unknown" against a server that announced itself twice.
1135    #[test]
1136    fn a_client_reports_what_the_handshake_agreed() {
1137        let version = ProtocolVersion::V2025_06_18;
1138        let caps = CapabilitySet::all_for(version);
1139        let session =
1140            crate::domain::NegotiatedSession::new(version, TransportKind::LegacySse, caps, caps)
1141                .with_client_info(crate::domain::PeerInfo::new("droid-cli", "0.4.1"))
1142                .with_server_info(crate::domain::PeerInfo::new("weather", "2.11.0"));
1143
1144        let client = Client::from_session(&session, Arc::new(Recorder::dead()));
1145        let seen = client.session();
1146
1147        assert_eq!(seen.version, version);
1148        assert_eq!(
1149            seen.transport,
1150            TransportKind::LegacySse,
1151            "the transport comes off the session, not off the URL the caller passed"
1152        );
1153        assert_eq!(seen.client_caps, caps);
1154        assert_eq!(seen.server_caps, caps);
1155        assert_eq!(
1156            seen.server_info.as_ref().map(|i| i.name.as_str()),
1157            Some("weather"),
1158            "a peer that announced itself must still be able to say so"
1159        );
1160        assert_eq!(
1161            seen.client_info.as_ref().map(|i| i.version.as_str()),
1162            Some("0.4.1"),
1163            "our own identity is as reportable as the server's"
1164        );
1165        assert!(
1166            !seen.is_stateless,
1167            "a 2025-06-18 session retains state; only 2026-07-28 does not"
1168        );
1169        assert_eq!(client.profile(), session.profile);
1170    }
1171
1172    /// A connection built from a spec knows which transport it dialled.
1173    ///
1174    /// The narrow assertion, on its own: [`connect`](Client::connect) takes a
1175    /// bare [`Negotiation`] where the caller supplies the four inputs by hand,
1176    /// and it is the one path that can record a transport the caller got
1177    /// wrong. Asserting it here means the two constructors are both covered
1178    /// for the field neither used to keep.
1179    #[test]
1180    fn a_client_built_by_hand_keeps_the_transport_it_was_given() {
1181        let version = ProtocolVersion::V2025_03_26;
1182        let caps = CapabilitySet::all_for(version);
1183        let client = Client::connect(
1184            crate::domain::Negotiation {
1185                version,
1186                client_caps: caps,
1187                server_caps: caps,
1188                transport: TransportKind::Stdio,
1189            },
1190            Arc::new(Recorder::dead()),
1191        );
1192
1193        assert_eq!(client.session().transport, TransportKind::Stdio);
1194        assert_eq!(client.session().client_caps, caps);
1195        assert_eq!(
1196            client.session().server_info,
1197            None,
1198            "a bare Negotiation carries no peer names, and inventing them is worse than none"
1199        );
1200    }
1201
1202    /// The error type for `connect_stdio` is a distinct `enum` from
1203    /// `ConnectError` because the spawn stage is stdio-specific. The
1204    /// variants' `Display` impls name the stage that failed, which is
1205    /// the one bit of information a caller retrying on transient
1206    /// transport faults needs. This test exists to keep the type
1207    /// reachable from the lib's own test target — the
1208    /// `dead_code_pub_in_binary` lint denies unused `pub` items, and
1209    /// the doctest that exercises the function does not name the
1210    /// error type directly (it uses `Box<dyn Error>`).
1211    #[cfg(feature = "stdio")]
1212    #[test]
1213    fn stdio_connect_error_names_the_stage() {
1214        use super::StdioConnectError;
1215        let err = StdioConnectError::Versions(crate::domain::SupportedVersionsError::Empty);
1216        let rendered = format!("{err}");
1217        assert!(
1218            rendered.contains("revision table"),
1219            "Versions variant must name the stage: {rendered}"
1220        );
1221    }
1222}