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 {}