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", ¶ms)
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", ¶ms, 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", ¶ms).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", ¶ms).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}