Skip to main content

mcplease/
client.rs

1//! Transport-agnostic client protocol — the mirror of [`handle_request`].
2//!
3//! [`handle_request`] answers a decoded request without doing any I/O, which
4//! leaves a server transport responsible for framing alone. This module is the
5//! same split from the other side: [`ClientProtocol`] *builds* requests and
6//! decides what to do with each message that arrives, without reading or
7//! writing anything. A transport is again only framing:
8//!
9//! ```ignore
10//! let request = protocol.request("tools/list", None);
11//! transport.write(&JsonRpcMessage::Request(request.clone()))?;
12//! loop {
13//!     match protocol.on_message(transport.read()?) {
14//!         Reaction::Result { id, result } if id == request.id => break result,
15//!         Reaction::Reply(response) => transport.write(&response.into())?,
16//!         _ => continue,
17//!     }
18//! }
19//! ```
20//!
21//! That loop is identical whether the frames are newline-delimited JSON over a
22//! subprocess's pipes or events on a streamable-HTTP SSE stream, which is why
23//! there is no transport trait here and no sync/async split: the part that
24//! differs between transports is the part a transport was always going to
25//! write, and the part that does not differ needs neither I/O nor a runtime.
26//!
27//! Nothing here knows about connections, headers, authorization, retries, or
28//! process lifetimes. Those are the transport's, and stay the transport's.
29//!
30//! [`handle_request`]: crate::handle_request
31
32use crate::types::{
33    CacheScope, ClientCapabilities, DiscoverResult, Implementation, InitializeRequestParams,
34    InitializeResult, JsonRpcError, JsonRpcMessage, JsonRpcNotification, JsonRpcRequest,
35    JsonRpcResponse, LATEST_HANDSHAKE_PROTOCOL_VERSION, LATEST_PROTOCOL_VERSION, RequestId,
36    SUPPORTED_PROTOCOL_VERSIONS, ServerCapabilities, meta_keys,
37};
38use serde_json::{Map, Value, json};
39
40/// A client's half of the protocol: request construction, `_meta` stamping,
41/// and the disposition of every message that arrives.
42///
43/// Holds no connection, so one of these can outlive a reconnect, and a client
44/// with several connections can hold one per connection or share a single
45/// counter — the ids it allocates are unique per instance.
46#[derive(Debug, Clone)]
47pub struct ClientProtocol {
48    client_info: Implementation,
49    capabilities: ClientCapabilities,
50    protocol_version: Option<String>,
51    next_id: i64,
52}
53
54impl ClientProtocol {
55    /// A client that declares no optional capabilities. A server MUST NOT ask
56    /// such a client for elicitation or sampling.
57    pub fn new(client_info: Implementation) -> Self {
58        Self {
59            client_info,
60            capabilities: ClientCapabilities::default(),
61            protocol_version: None,
62            next_id: 0,
63        }
64    }
65
66    /// Declare what this client supports. Capabilities are per-request in
67    /// `2026-07-28` — the spec forbids a server from inferring them from
68    /// earlier requests — so they are stamped onto every request, not
69    /// announced once.
70    pub fn with_capabilities(mut self, capabilities: ClientCapabilities) -> Self {
71        self.capabilities = capabilities;
72        self
73    }
74
75    /// The negotiated revision, once [`Negotiation`] has settled it.
76    pub fn protocol_version(&self) -> Option<&str> {
77        self.protocol_version.as_deref()
78    }
79
80    /// The context a server on any revision will reconstruct from this
81    /// client's requests — the client-side counterpart of
82    /// [`RequestContext::from_params`](crate::types::RequestContext::from_params).
83    pub fn request_context(&self) -> crate::types::RequestContext {
84        crate::types::RequestContext {
85            protocol_version: Some(self.declared_version().to_string()),
86            client_info: Some(self.client_info.clone()),
87            client_capabilities: self.capabilities.clone(),
88        }
89    }
90
91    /// The version to declare on a request: the negotiated one, or — before
92    /// negotiation settles, when a `2026-07-28` server must already be told
93    /// something — the newest revision these types model.
94    fn declared_version(&self) -> &str {
95        self.protocol_version
96            .as_deref()
97            .unwrap_or(LATEST_PROTOCOL_VERSION)
98    }
99
100    /// The `_meta` every request carries: protocol version, client identity,
101    /// and capabilities.
102    ///
103    /// `2026-07-28` requires this on every request; earlier revisions carry
104    /// none of it and ignore unknown `_meta` keys, so it is stamped
105    /// unconditionally rather than switched on the negotiated revision. The
106    /// one exception is `initialize`, whose params carry the same information
107    /// as ordinary fields — see [`Negotiation`].
108    fn meta(&self) -> Map<String, Value> {
109        json!({
110            meta_keys::PROTOCOL_VERSION: self.declared_version(),
111            meta_keys::CLIENT_INFO: self.client_info,
112            meta_keys::CLIENT_CAPABILITIES: self.capabilities,
113        })
114        .as_object()
115        .cloned()
116        .unwrap_or_default()
117    }
118
119    /// Allocate an id and build a request, stamping the client `_meta` into
120    /// its params. Params that are not an object are left alone — there is
121    /// nowhere to put `_meta` — which the spec's own request shapes never do.
122    pub fn request(&mut self, method: impl Into<String>, params: Option<Value>) -> JsonRpcRequest {
123        self.next_id += 1;
124        let params = match params {
125            Some(Value::Object(mut params)) => {
126                params.insert("_meta".into(), Value::Object(self.meta()));
127                Some(Value::Object(params))
128            }
129            None => Some(json!({ "_meta": self.meta() })),
130            other => other,
131        };
132        JsonRpcRequest::new(RequestId::Integer(self.next_id), method, params)
133    }
134
135    /// A one-way message. Notifications carry no `_meta`: there is no result
136    /// to correlate and no per-request context for a server to act on.
137    pub fn notification(
138        &self,
139        method: impl Into<String>,
140        params: Option<Value>,
141    ) -> JsonRpcNotification {
142        JsonRpcNotification::new(method, params)
143    }
144
145    /// `tools/list`, optionally continuing a paginated listing.
146    pub fn tools_list(&mut self, cursor: Option<&str>) -> JsonRpcRequest {
147        let params = cursor.map(|cursor| json!({ "cursor": cursor }));
148        self.request("tools/list", params)
149    }
150
151    /// `tools/call`, where `arguments` is the tool's input object.
152    ///
153    /// This builds the first attempt only. `2026-07-28`'s multi round-trip
154    /// retry — re-issuing with `inputResponses` and the server's opaque
155    /// `requestState` after an
156    /// [`InputRequiredResult`](crate::types::InputRequiredResult) — is not
157    /// built here: this crate's serve loop never issues one, so the retry
158    /// shape would ship untested against any real server. A client that meets
159    /// one can recognize it (see
160    /// [`ToolCallOutcome`](crate::types::ToolCallOutcome)) and build the retry
161    /// with [`request`](Self::request).
162    pub fn tools_call(&mut self, name: &str, arguments: &Value) -> JsonRpcRequest {
163        self.request(
164            "tools/call",
165            Some(json!({ "name": name, "arguments": arguments })),
166        )
167    }
168
169    /// What a transport should do with a message it just read.
170    ///
171    /// Takes `&mut self` because a future revision may have the client learn
172    /// from server traffic; today it mutates nothing.
173    pub fn on_message(&mut self, message: JsonRpcMessage) -> Reaction {
174        match message {
175            JsonRpcMessage::Response(response) => match response.id.clone() {
176                Some(id) => Reaction::Result {
177                    id,
178                    result: response.into_result(),
179                },
180                // A response the server could not attribute to a request.
181                // There is nothing to correlate it with.
182                None => {
183                    log::debug!("dropping a response with no id");
184                    Reaction::Ignore
185                }
186            },
187            JsonRpcMessage::Request(request) => {
188                let response = if request.method == "ping" {
189                    JsonRpcResponse::success(request.id, json!({}))
190                } else {
191                    // Every server-initiated method belongs to a capability
192                    // this client did not declare, so the honest answer is
193                    // that the method is not implemented here.
194                    log::debug!("declining server-initiated {}", request.method);
195                    JsonRpcResponse::error(
196                        request.id,
197                        JsonRpcError::method_not_found(&request.method),
198                    )
199                };
200                Reaction::Reply(response)
201            }
202            JsonRpcMessage::Notification(notification) => {
203                log::debug!("ignoring server notification {}", notification.method);
204                Reaction::Ignore
205            }
206        }
207    }
208}
209
210/// What a transport should do with a message [`ClientProtocol::on_message`]
211/// just classified.
212#[derive(Debug, Clone)]
213pub enum Reaction {
214    /// A response arrived. The transport compares `id` against the requests it
215    /// has outstanding: its own, or one it abandoned and should drop.
216    Result {
217        id: RequestId,
218        result: Result<Value, JsonRpcError>,
219    },
220    /// A reply the transport should send if it has a channel to send it on.
221    /// Advisory: a transport reading a per-request SSE stream has no way to
222    /// answer on that stream and may drop it.
223    Reply(JsonRpcResponse),
224    /// Nothing to do.
225    Ignore,
226}
227
228/// What negotiation learned about a server.
229#[derive(Debug, Clone)]
230pub struct Negotiated {
231    /// The revision both sides will speak.
232    pub protocol_version: String,
233    pub capabilities: ServerCapabilities,
234    /// Present when the server identified itself; `server/discover` does not
235    /// carry an identity, so a stateless negotiation leaves this `None` until
236    /// a result's `_meta` supplies one.
237    pub server_info: Option<Implementation>,
238    /// Guidance the server offers about how to use it.
239    pub instructions: Option<String>,
240    /// How long a `tools/list` result may be considered fresh, and whether a
241    /// shared cache may serve it across authorization contexts. Only
242    /// `server/discover` reports these.
243    pub tools_ttl_ms: Option<u64>,
244    pub tools_cache_scope: Option<CacheScope>,
245    /// True when the server was reached through the `initialize` handshake
246    /// rather than `server/discover`, and is therefore keeping session state.
247    pub stateful: bool,
248}
249
250impl Negotiated {
251    /// The confirmation a stateful server is waiting for. Send it before the
252    /// first ordinary request; a stateless server needs nothing, and this is
253    /// `None`.
254    pub fn initialized_notification(&self) -> Option<JsonRpcNotification> {
255        self.stateful
256            .then(|| JsonRpcNotification::new("notifications/initialized", None))
257    }
258}
259
260/// The `server/discover` → `initialize` fallback, as a state machine that
261/// performs no I/O.
262///
263/// `2026-07-28` replaced the handshake with a stateless `server/discover`
264/// probe, and made that probe the backward-compatibility test: a server on an
265/// earlier revision answers it with method-not-found, which is how a client
266/// learns to fall back. Deployed servers overwhelmingly still want the
267/// handshake, so every client needs both paths and the rule for choosing.
268///
269/// The driver is the same shape as the message loop:
270///
271/// ```ignore
272/// let (mut negotiation, mut request) = Negotiation::start(&mut protocol);
273/// let negotiated = loop {
274///     let result = transport.round_trip(request).await;
275///     match negotiation.on_result(&mut protocol, result)? {
276///         Step::Send(next) => request = next,
277///         Step::Done(negotiated) => break *negotiated,
278///     }
279/// };
280/// if let Some(confirm) = negotiated.initialized_notification() {
281///     transport.notify(confirm).await?;
282/// }
283/// ```
284#[derive(Debug, Clone)]
285pub struct Negotiation {
286    /// The error `server/discover` came back with, kept so that a failing
287    /// `initialize` can report both halves rather than only the second.
288    discover_error: Option<JsonRpcError>,
289    stage: Stage,
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293enum Stage {
294    Discovering,
295    Initializing,
296}
297
298/// One move in a [`Negotiation`].
299#[derive(Debug, Clone)]
300pub enum Step {
301    /// Send this request and feed its result back to
302    /// [`Negotiation::on_result`].
303    Send(JsonRpcRequest),
304    /// Negotiation settled.
305    Done(Box<Negotiated>),
306}
307
308impl Negotiation {
309    /// Begin with the `server/discover` probe.
310    pub fn start(protocol: &mut ClientProtocol) -> (Self, JsonRpcRequest) {
311        let request = protocol.request("server/discover", None);
312        (
313            Self {
314                discover_error: None,
315                stage: Stage::Discovering,
316            },
317            request,
318        )
319    }
320
321    /// Take the result of the request this negotiation last handed out.
322    ///
323    /// A `server/discover` that comes back as *any* JSON-RPC error falls back
324    /// to `initialize`. The spec describes method-not-found as the signal, but
325    /// a server that rejects an unknown method some other way is equally a
326    /// server that does not speak `2026-07-28`, and the discarded error is
327    /// carried into the failure message if `initialize` fails too.
328    pub fn on_result(
329        &mut self,
330        protocol: &mut ClientProtocol,
331        result: Result<Value, JsonRpcError>,
332    ) -> Result<Step, NegotiationError> {
333        match (self.stage, result) {
334            (Stage::Discovering, Ok(value)) => {
335                let discovered: DiscoverResult = serde_json::from_value(value)
336                    .map_err(|e| NegotiationError::Malformed("server/discover", e.to_string()))?;
337                let versions: Vec<&str> = discovered
338                    .supported_versions
339                    .iter()
340                    .map(String::as_str)
341                    .collect();
342                // Newest revision both sides support. The server's own
343                // ordering is not authoritative.
344                let protocol_version = SUPPORTED_PROTOCOL_VERSIONS
345                    .iter()
346                    .find(|supported| versions.contains(supported))
347                    .ok_or_else(|| {
348                        NegotiationError::NoSharedVersion(discovered.supported_versions.clone())
349                    })?
350                    .to_string();
351                protocol.protocol_version = Some(protocol_version.clone());
352                Ok(Step::Done(Box::new(Negotiated {
353                    protocol_version,
354                    capabilities: discovered.capabilities,
355                    server_info: server_info_from_meta(discovered.meta.as_ref()),
356                    instructions: discovered.instructions,
357                    tools_ttl_ms: discovered.ttl_ms,
358                    tools_cache_scope: discovered.cache_scope,
359                    stateful: false,
360                })))
361            }
362            (Stage::Discovering, Err(error)) => {
363                log::debug!("server/discover was declined ({error}); falling back to initialize");
364                self.discover_error = Some(error);
365                self.stage = Stage::Initializing;
366                let params = InitializeRequestParams {
367                    protocol_version: LATEST_HANDSHAKE_PROTOCOL_VERSION.into(),
368                    capabilities: protocol.capabilities.clone(),
369                    client_info: protocol.client_info.clone(),
370                };
371                let params = serde_json::to_value(params)
372                    .map_err(|e| NegotiationError::Malformed("initialize", e.to_string()))?;
373                // `initialize` carries version, identity, and capabilities as
374                // ordinary params, so it is the one request built without the
375                // `_meta` stamp — the two would be redundant, and a server on
376                // an older revision reads only the params.
377                protocol.next_id += 1;
378                Ok(Step::Send(JsonRpcRequest::new(
379                    RequestId::Integer(protocol.next_id),
380                    "initialize",
381                    Some(params),
382                )))
383            }
384            (Stage::Initializing, Ok(value)) => {
385                let initialized: InitializeResult = serde_json::from_value(value)
386                    .map_err(|e| NegotiationError::Malformed("initialize", e.to_string()))?;
387                if !SUPPORTED_PROTOCOL_VERSIONS.contains(&initialized.protocol_version.as_str()) {
388                    return Err(NegotiationError::NoSharedVersion(vec![
389                        initialized.protocol_version,
390                    ]));
391                }
392                protocol.protocol_version = Some(initialized.protocol_version.clone());
393                Ok(Step::Done(Box::new(Negotiated {
394                    protocol_version: initialized.protocol_version,
395                    capabilities: initialized.capabilities,
396                    server_info: Some(initialized.server_info),
397                    instructions: initialized.instructions,
398                    tools_ttl_ms: None,
399                    tools_cache_scope: None,
400                    stateful: true,
401                })))
402            }
403            (Stage::Initializing, Err(error)) => {
404                Err(NegotiationError::Declined(Box::new(Declined {
405                    discover: self.discover_error.clone(),
406                    initialize: error,
407                })))
408            }
409        }
410    }
411}
412
413/// The identity a server stamps into every result's `_meta`.
414fn server_info_from_meta(meta: Option<&Map<String, Value>>) -> Option<Implementation> {
415    meta?
416        .get(meta_keys::SERVER_INFO)
417        .cloned()
418        .and_then(|value| serde_json::from_value(value).ok())
419}
420
421/// Why a [`Negotiation`] could not settle.
422#[derive(Debug, Clone)]
423pub enum NegotiationError {
424    /// A result did not parse as the shape its method promises.
425    Malformed(&'static str, String),
426    /// No revision this client models appears in what the server offers.
427    NoSharedVersion(Vec<String>),
428    /// The server refused both entry points. Boxed to keep the error — which
429    /// rides in every negotiation `Result` — small.
430    Declined(Box<Declined>),
431}
432
433/// What each entry point answered when neither worked.
434#[derive(Debug, Clone)]
435pub struct Declined {
436    /// Absent only if `server/discover` was never reached.
437    pub discover: Option<JsonRpcError>,
438    pub initialize: JsonRpcError,
439}
440
441impl std::fmt::Display for NegotiationError {
442    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
443        match self {
444            Self::Malformed(method, error) => write!(f, "unparseable {method} result: {error}"),
445            Self::NoSharedVersion(offered) => write!(
446                f,
447                "no shared protocol version: server offers [{}], this client speaks [{}]",
448                offered.join(", "),
449                SUPPORTED_PROTOCOL_VERSIONS.join(", ")
450            ),
451            Self::Declined(declined) => match &declined.discover {
452                Some(discover) => write!(
453                    f,
454                    "server declined both entry points: server/discover: {discover}; initialize: \
455                     {}",
456                    declined.initialize
457                ),
458                None => write!(f, "server declined initialize: {}", declined.initialize),
459            },
460        }
461    }
462}
463
464impl std::error::Error for NegotiationError {}