Skip to main content

protocol_droid/application/client/
handshake.rs

1//! Establishing a session with a server, over whatever transport.
2//!
3//! Split from [`Client`](crate::application::client::Client) because a client and a session are not
4//! the same thing: the client is what a caller holds, and it cannot be built
5//! until this has run. The crate had both halves and nothing joining them, so
6//! every caller that wanted a connected client either took a transport on trust
7//! or, as `tests/in_process_exchange.rs` did, hand-built a [`Negotiation`] and
8//! asserted against a session that had never been negotiated with anyone.
9//!
10//! ## The two handshakes are not the same shape
11//!
12//! Four revisions answer `initialize`, and its result carries the three fields
13//! that matter: `protocolVersion`, `capabilities`, `serverInfo`.
14//! `2026-07-28` (SEP-2575) has no `initialize` at all — its vendored schema has
15//! no `InitializeRequest` and no `InitializeResult` — and a session is
16//! established by `server/discover` instead, whose result names a *set* of
17//! revisions rather than one, and carries no `serverInfo` at all.
18//!
19//! So "does this revision have a handshake" is the wrong question to ask a
20//! client. [`handshake_defined_in`] answers it, and is still the right question
21//! to ask about `initialize` specifically; what this module acts on is which of
22//! the two a revision has.
23//!
24//! [`Negotiation`]: crate::domain::Negotiation
25
26use serde_json::Value;
27
28use crate::application::client::{ClientConfig, ClientTransport, TransportError};
29use crate::domain::ir::{JsonObject, JsonObjectError};
30use crate::domain::{
31    CLIENT_CAPABILITY_WIRE, CapabilitySet, NegotiatedSession, NegotiationError, PeerInfo,
32    ProtocolVersion, SERVER_CAPABILITY_WIRE, SupportedVersions, handshake_defined_in,
33    negotiate_client,
34};
35
36/// The method that opens a `2026-07-28` session.
37///
38/// The one method in that revision that reports what the server can do, and so
39/// the reason a client at that revision is not guessing its way to a profile.
40pub const DISCOVER: &str = "server/discover";
41
42/// The method that opens every other session this crate speaks.
43pub const INITIALIZE: &str = "initialize";
44
45/// The notification that ends a handshake, on the revisions that have one.
46///
47/// Absent from the `2026-07-28` lifecycle, whose schema has no
48/// `InitializedNotification`, so this is never sent at that revision.
49pub const INITIALIZED: &str = "notifications/initialized";
50
51/// Who this client says it is.
52///
53/// Not optional on the wire: `InitializeRequest` marks `clientInfo` required,
54/// with `name` and `version` both required inside it, so a client with no name
55/// still has to send something. Reporting the real crate beats reporting
56/// nothing — a server that logs what it connected to is then logging a true
57/// thing, and a caller cannot make the field wrong.
58///
59/// A function rather than a `const` because [`PeerInfo`] holds two `String`s.
60/// Two allocations once per handshake is not a cost worth a static to avoid.
61fn self_info() -> PeerInfo {
62    PeerInfo {
63        name: env!("CARGO_PKG_NAME").to_owned(),
64        version: env!("CARGO_PKG_VERSION").to_owned(),
65    }
66}
67
68/// Why a session could not be established.
69#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
70pub enum HandshakeError {
71    /// The client was built offering no revision.
72    ///
73    /// Separate from an unsupported *server* because the two need different
74    /// fixes: one is a misconfigured client, the other is a peer this build
75    /// cannot speak to.
76    #[error("this client was offered no revision, so there is nothing to negotiate")]
77    NothingOffered,
78
79    /// The peer's answer is missing a field the schema marks required, or the
80    /// field holds the wrong shape.
81    ///
82    /// Named by field so the reader knows which part of the peer to look at.
83    /// "Malformed initialize result" sends them looking at the whole message.
84    #[error("the {method} answer has no usable {field}: {detail}")]
85    MalformedResult {
86        /// Which handshake this was.
87        method: &'static str,
88        /// Which required field is wrong.
89        field: &'static str,
90        /// What was there instead.
91        detail: String,
92    },
93
94    /// This build cannot speak any revision the peer offers.
95    #[error(transparent)]
96    Negotiation(#[from] NegotiationError),
97
98    /// The request or the notification could not be delivered.
99    #[error("the handshake did not complete: {0}")]
100    Transport(#[from] TransportError),
101
102    /// A params object this client assembled was not valid JSON.
103    #[error("the {method} request could not be assembled: {source}")]
104    Assembling {
105        /// Which handshake this was.
106        method: &'static str,
107        /// The underlying parse failure.
108        source: JsonObjectError,
109    },
110}
111
112/// Establishes a session over `transport`.
113///
114/// `?Sized` because [`Client`](crate::application::client::Client) holds an `Arc<dyn ClientTransport>`, and a
115/// caller who has one of those could not otherwise use the handshake that
116/// produces the session the `Client` wants. Requiring a concrete transport
117/// here would mean the caller keeps the concrete type alive solely to call
118/// this, then boxes it immediately afterwards — a step with no purpose beyond
119/// satisfying a bound.
120///
121/// Sends [`INITIALIZE`] on every revision that has it and `server/discover` on
122/// `2026-07-28`, then — on the revisions that have one — the [`INITIALIZED`]
123/// notification. The notification is not optional: a server that has not been
124/// told the client is ready will not answer anything else, so a client that
125/// skipped it would look connected and then hang on its first call.
126///
127/// # Errors
128///
129/// [`HandshakeError::Transport`] if the request or the notification could not be
130/// delivered, [`HandshakeError::MalformedResult`] if a required field of the
131/// answer is missing, and [`NegotiationError`] through
132/// [`HandshakeError::Negotiation`] if the two sides have no revision in common.
133pub async fn handshake(
134    transport: &(impl ClientTransport + ?Sized),
135    config: &ClientConfig,
136) -> Result<NegotiatedSession, HandshakeError> {
137    let (preferred, supported) = offer(config).ok_or(HandshakeError::NothingOffered)?;
138
139    // A `2026-07-28` session is established by discovery rather than by an
140    // `initialize` round trip, and its lifecycle has no `initialized`
141    // notification to send at the end. Both facts are read off the vendored
142    // schema rather than written here as a version comparison.
143    if !handshake_defined_in(preferred) {
144        let answer = transport.send(DISCOVER, &empty_params()?).await?;
145        return discover_session(&answer, &supported, config);
146    }
147
148    let answer = transport
149        .send(INITIALIZE, &initialize_params(preferred, config)?)
150        .await?;
151    let session = initialize_session(&answer, preferred, &supported, config)?;
152
153    transport.notify(INITIALIZED, &empty_params()?).await?;
154    Ok(session)
155}
156
157/// The revision this client prefers, and the set it can speak.
158///
159/// `None` for a client that offers nothing, which
160/// [`ClientConfig::speaking`] already refuses to build — so this is a
161/// belt-and-braces answer for a config assembled some other way.
162///
163/// [`ClientConfig::speaking`]: super::ClientConfig::speaking
164#[must_use]
165pub fn offer(config: &ClientConfig) -> Option<(ProtocolVersion, SupportedVersions)> {
166    let supported = SupportedVersions::from_slice(config.versions()).ok()?;
167    let preferred = *config.versions().first()?;
168    Some((preferred, supported))
169}
170
171/// The `initialize` params: the offer, what this client can do, and who it is.
172///
173/// The three required fields of `InitializeRequest`. The capabilities are
174/// rendered from the client's own set rather than passed in as a `Value`, so
175/// there is no way to advertise a capability the profile behind it would
176/// refuse.
177///
178/// # Errors
179///
180/// [`HandshakeError::Assembling`] if the params do not serialise, which for
181/// this input cannot happen — a map of strings and booleans always does.
182pub fn initialize_params(
183    preferred: ProtocolVersion,
184    config: &ClientConfig,
185) -> Result<JsonObject, HandshakeError> {
186    let mut info = serde_json::Map::new();
187    info.insert("name".to_owned(), Value::from(self_info().name));
188    info.insert("version".to_owned(), Value::from(self_info().version));
189
190    let mut params = serde_json::Map::new();
191    params.insert(
192        "protocolVersion".to_owned(),
193        Value::from(preferred.to_string()),
194    );
195    params.insert(
196        "capabilities".to_owned(),
197        Value::Object(client_capabilities(config)),
198    );
199    params.insert("clientInfo".to_owned(), Value::Object(info));
200
201    to_params(INITIALIZE, &Value::Object(params))
202}
203
204/// The empty params a notification and a discovery request both carry.
205fn empty_params() -> Result<JsonObject, HandshakeError> {
206    to_params(DISCOVER, &Value::Object(serde_json::Map::new()))
207}
208
209/// Serialises a params object, naming the handshake it belongs to on failure.
210fn to_params(method: &'static str, value: &Value) -> Result<JsonObject, HandshakeError> {
211    JsonObject::new(value.to_string())
212        .map_err(|source| HandshakeError::Assembling { method, source })
213}
214
215/// Renders the client's own capabilities as a `ClientCapabilities` object.
216///
217/// Walks [`CLIENT_CAPABILITY_WIRE`], the mirror of the table the server's
218/// renderer reads, so a capability is either rendered under the key the schema
219/// gives it or not rendered at all. Deriving the key from `Capability::as_str`
220/// instead would be wrong for every nested capability: `roots.listChanged` is a
221/// dot in a Rust name and two levels in JSON.
222///
223/// [`CLIENT_CAPABILITY_WIRE`]: crate::domain::CLIENT_CAPABILITY_WIRE
224pub(super) fn client_capabilities(config: &ClientConfig) -> serde_json::Map<String, Value> {
225    let mut root = serde_json::Map::new();
226    for (capability, path) in CLIENT_CAPABILITY_WIRE {
227        if config.capabilities().contains(*capability) {
228            insert_path(&mut root, path);
229        }
230    }
231    root
232}
233
234/// Asserts `path` on `root`, creating every object on the way.
235///
236/// A path of one key leaves an **empty object** rather than a boolean, because
237/// a parent capability's presence is the claim and the schema gives it an
238/// object: `ClientCapabilities.roots` is an object, so `"roots": {}` is the
239/// wire form and `"roots": true` is not one any schema admits. A longer path
240/// ends in `true`, because a sub-capability like `listChanged` genuinely is a
241/// boolean.
242fn insert_path(root: &mut serde_json::Map<String, serde_json::Value>, path: &[&str]) {
243    let Some((last, parents)) = path.split_last() else {
244        return;
245    };
246    let mut cursor = root;
247    for key in parents {
248        let entry = cursor
249            .entry((*key).to_string())
250            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
251        let serde_json::Value::Object(parent) = entry else {
252            return;
253        };
254        cursor = parent;
255    }
256    let leaf = if parents.is_empty() {
257        serde_json::Value::Object(serde_json::Map::new())
258    } else {
259        serde_json::Value::Bool(true)
260    };
261    cursor.insert((*last).to_string(), leaf);
262}
263
264/// Reads an `initialize` answer into a session.
265///
266/// The three required fields of `InitializeResult` and nothing else.
267/// `instructions` is ignored rather than carried: it is a human-facing hint with
268/// no client behaviour behind it, and a session that stored it would hand
269/// callers a field nothing gates on.
270///
271/// # Errors
272///
273/// [`HandshakeError::MalformedResult`] naming the field that is wrong, and
274/// [`NegotiationError::UnsupportedServerVersion`] when the answer is a revision
275/// this build cannot speak.
276pub fn initialize_session(
277    answer: &JsonObject,
278    preferred: ProtocolVersion,
279    supported: &SupportedVersions,
280    config: &ClientConfig,
281) -> Result<NegotiatedSession, HandshakeError> {
282    let result = parse(answer);
283    let answered = required_version(&result, INITIALIZE, "protocolVersion")?;
284    let server_caps = server_capabilities(&result, INITIALIZE)?;
285    let server_info = peer_info(&result, "serverInfo");
286
287    let version = negotiate_client(preferred, answered, supported)?;
288    let session = NegotiatedSession::new(
289        version,
290        config.transport(),
291        config.capabilities(),
292        server_caps,
293    )
294    .with_client_info(self_info());
295    Ok(match server_info {
296        Some(info) => session.with_server_info(info),
297        None => session,
298    })
299}
300
301/// Reads a `server/discover` answer into a session.
302///
303/// Different from [`initialize_session`] by construction: the peer names a *set*
304/// of revisions and the client picks, there is no `serverInfo` to read, and the
305/// capabilities arrive under a key the older revisions do not use.
306///
307/// The pick is [`SupportedVersions::best_match`] — the newest revision both
308/// sides speak — rather than the client's own preference. A client that picked
309/// its own preference would ignore a server that had dropped that revision, and
310/// would then be running on a version the server never offered.
311///
312/// # Errors
313///
314/// [`HandshakeError::MalformedResult`] when `supportedVersions` is absent or
315/// holds nothing this build speaks.
316pub fn discover_session(
317    answer: &JsonObject,
318    supported: &SupportedVersions,
319    config: &ClientConfig,
320) -> Result<NegotiatedSession, HandshakeError> {
321    let result = parse(answer);
322    let offered = required_version_set(&result)?;
323    let server_caps = server_capabilities(&result, DISCOVER)?;
324
325    let version = supported.best_match(&offered).ok_or_else(|| {
326        HandshakeError::MalformedResult {
327            method: DISCOVER,
328            field: "supportedVersions",
329            detail: format!(
330                "it offers {offered:?}, none of which this client speaks (it speaks {supported})"
331            ),
332        }
333    })?;
334
335    Ok(NegotiatedSession::new(
336        version,
337        config.transport(),
338        config.capabilities(),
339        server_caps,
340    )
341    .with_client_info(self_info()))
342}
343
344/// Parses an answer, which the port hands over in its serialised form.
345///
346/// Infallible, and that is a fact about [`JsonObject`] rather than a guess:
347/// the type validated the text as a well-formed JSON *object* on the way in, so
348/// there is no text this can be handed that would not parse and nothing to
349/// check for being an object. Re-checking anyway would be a second copy of a
350/// guarantee that already exists, and the day someone relaxes `JsonObject` to
351/// carry any JSON — which the IR has no reason to do, but might — a reader that
352/// has forgotten the check would silently start reading an array as a result.
353fn parse(answer: &JsonObject) -> Value {
354    // The `unwrap`-free read: `from_str` on validated text cannot fail, and a
355    // failure would mean `JsonObject` had stopped promising what its own docs
356    // say. Reported rather than panicked on, because "this can never happen"
357    // is exactly the kind of claim that deserves a path out.
358    serde_json::from_str(answer.as_str()).unwrap_or_else(|_| Value::Object(serde_json::Map::new()))
359}
360
361/// Reads a required `protocolVersion` string.
362fn required_version(
363    result: &Value,
364    method: &'static str,
365    field: &'static str,
366) -> Result<ProtocolVersion, HandshakeError> {
367    let raw = result.get(field).and_then(Value::as_str).ok_or_else(|| {
368        HandshakeError::MalformedResult {
369            method,
370            field,
371            detail: describe(result.get(field)),
372        }
373    })?;
374    raw.parse::<ProtocolVersion>()
375        .map_err(|error| HandshakeError::MalformedResult {
376            method,
377            field,
378            detail: format!("{raw:?} is not a protocol version this build knows: {error}"),
379        })
380}
381
382/// Reads the required `supportedVersions` array of `server/discover`.
383///
384/// An entry this build has never heard of is **skipped** rather than refused,
385/// because the field is a set: one unfamiliar revision does not make the others
386/// unusable, and refusing the whole answer over it would mean a server could
387/// not add a revision without breaking every client that had not heard of it
388/// yet. The revision that is then missing is reported by
389/// [`SupportedVersions::best_match`] returning `None`, which names the whole
390/// set rather than the single entry that went unrecognised.
391fn required_version_set(result: &Value) -> Result<Vec<ProtocolVersion>, HandshakeError> {
392    let raw = result
393        .get("supportedVersions")
394        .and_then(Value::as_array)
395        .ok_or_else(|| HandshakeError::MalformedResult {
396            method: DISCOVER,
397            field: "supportedVersions",
398            detail: describe(result.get("supportedVersions")),
399        })?;
400    Ok(raw
401        .iter()
402        .filter_map(Value::as_str)
403        .filter_map(|text| text.parse::<ProtocolVersion>().ok())
404        .collect())
405}
406
407/// Reads a `capabilities` object into a set.
408///
409/// Walks [`SERVER_CAPABILITY_WIRE`] rather than the keys actually present,
410/// because the table knows which of those keys are capabilities and which are
411/// sub-capabilities of one. A reader that treated every key as a capability
412/// would report `listChanged` as one, and the profile built from that would
413/// then be gating a feature on a key the peer never meant as a claim.
414fn server_capabilities(
415    result: &Value,
416    method: &'static str,
417) -> Result<CapabilitySet, HandshakeError> {
418    let raw = result
419        .get("capabilities")
420        .and_then(Value::as_object)
421        .ok_or_else(|| HandshakeError::MalformedResult {
422            method,
423            field: "capabilities",
424            detail: describe(result.get("capabilities")),
425        })?;
426
427    let mut set = CapabilitySet::empty();
428    for (capability, path) in SERVER_CAPABILITY_WIRE {
429        if read_path(raw, path) {
430            set.insert(*capability);
431        }
432    }
433    Ok(set)
434}
435
436/// Whether `path` is present in `object` as a capability claim.
437///
438/// A one-key path is satisfied by the object's own presence, which is the whole
439/// signal for a parent capability. A longer path needs every segment present
440/// and the last one to be `true`: a `tools` object with `listChanged: false` is
441/// a server that has tools and will not announce changes, which is a different
442/// session from one with no tools at all.
443fn read_path(object: &serde_json::Map<String, Value>, path: &[&str]) -> bool {
444    let Some((last, parents)) = path.split_last() else {
445        return false;
446    };
447    let mut cursor = object;
448    for key in parents {
449        let Some(next) = cursor.get(*key).and_then(Value::as_object) else {
450            return false;
451        };
452        cursor = next;
453    }
454    match cursor.get(*last) {
455        Some(Value::Bool(flag)) => *flag,
456        Some(Value::Object(_)) if parents.is_empty() => true,
457        _ => false,
458    }
459}
460
461/// Reads a `PeerInfo`, or `None` when the revision has no such field.
462fn peer_info(result: &Value, field: &str) -> Option<PeerInfo> {
463    let object = result.get(field)?.as_object()?;
464    Some(PeerInfo {
465        name: object.get("name")?.as_str()?.to_owned(),
466        version: object.get("version")?.as_str()?.to_owned(),
467    })
468}
469
470/// What was in a field that should have held something else.
471fn describe(value: Option<&Value>) -> String {
472    value.map_or_else(
473        || "the field is absent".to_owned(),
474        |value| format!("it holds {value}"),
475    )
476}
477
478#[cfg(test)]
479#[path = "handshake/tests.rs"]
480mod tests;