Skip to main content

protocol_droid/application/mcp/
initialize.rs

1//! The `initialize` exchange, as both a server and a bridge read it.
2//!
3//! This is shared because two callers genuinely need it and neither owns it.
4//! A server answers a client's handshake. A bridge is the server on its
5//! downstream leg, so it reads the same request and answers the same way —
6//! with the difference that the capabilities it advertises are the
7//! _intersection_ of what the client asked for and what the upstream server
8//! agreed to, rather than whatever the local process can do.
9//!
10//! Putting the parsing here rather than in either caller is what keeps that
11//! difference from becoming a second reading of the wire format. The
12//! capability tables in [`crate::domain`] map a Rust name to a JSON path, and
13//! two copies of that mapping would be two definitions of what a client is
14//! allowed to say.
15//!
16//! # What is not here
17//!
18//! Choosing a revision is not in this module. Reading an offer is a wire
19//! format question; agreeing on a version is a negotiation question, and the
20//! two answer to different inputs — see
21//! [`agree`](crate::domain::negotiate_server) for the server and
22//! the bridge's downstream handler for the bridge.
23
24use crate::domain::feature::FeatureProfile;
25use crate::domain::ir::JsonObject;
26use crate::domain::{
27    CLIENT_CAPABILITY_WIRE, CapabilitySet, ProtocolVersion, SERVER_CAPABILITY_WIRE,
28};
29
30/// What a client said in its `initialize`.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Offer {
33    /// The revision the client asked for.
34    ///
35    /// An [`Offer`], not a choice: the client naming a revision this build has
36    /// never heard of is normal and is answered with a counter-offer, not a
37    /// refusal.
38    pub version: ProtocolVersion,
39    /// What the client said it supported, read through the capability tables.
40    pub capabilities: CapabilitySet,
41    /// The client's own name, which is echoed nowhere but is worth keeping for
42    /// an operator reading a report.
43    pub name: String,
44}
45
46/// Why an `initialize` could not be read.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
48#[non_exhaustive]
49pub enum InitializeError {
50    /// The request named nothing usable.
51    ///
52    /// One variant for every failure rather than one per field, because no
53    /// caller can act on the difference: every one of them is answered to the
54    /// peer as an invalid request, and splitting them would produce five
55    /// variants that are always mapped to the same thing.
56    #[error("initialize params were missing or malformed")]
57    Malformed,
58}
59
60/// Reads an `initialize` request body.
61///
62/// # Errors
63///
64/// [`InitializeError::Malformed`] when the body is not an object, when
65/// `protocolVersion` is absent, is not a string, or does not parse as a
66/// protocol version; when `capabilities` is absent or is not an object; or
67/// when `clientInfo` is absent, is not an object, or has no `name`.
68///
69/// `clientInfo.version` is the one required field not enforced. It is
70/// free-form, nothing in this crate's answer depends on it, and refusing a
71/// handshake over a missing version string would buy a stricter reading of one
72/// schema at the cost of every client that omits it.
73///
74/// # Examples
75///
76/// ```
77/// use protocol_droid::application::mcp::initialize::read_offer;
78/// use protocol_droid::domain::ir::JsonObject;
79///
80/// let params = JsonObject::new(
81///     r#"{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe"}}"#,
82/// )?;
83/// let offer = read_offer(&params)?;
84/// assert_eq!(offer.name, "probe");
85/// # Ok::<(), Box<dyn std::error::Error>>(())
86/// ```
87pub fn read_offer(params: &JsonObject) -> Result<Offer, InitializeError> {
88    let parsed: serde_json::Value =
89        serde_json::from_str(params.as_str()).map_err(|_| InitializeError::Malformed)?;
90    let object = parsed.as_object().ok_or(InitializeError::Malformed)?;
91
92    let offered = object
93        .get("protocolVersion")
94        .and_then(serde_json::Value::as_str)
95        .ok_or(InitializeError::Malformed)?
96        .parse::<ProtocolVersion>()
97        .map_err(|_| InitializeError::Malformed)?;
98
99    let capabilities = object
100        .get("capabilities")
101        .and_then(serde_json::Value::as_object)
102        .ok_or(InitializeError::Malformed)?;
103
104    let client = object
105        .get("clientInfo")
106        .filter(|value| value.is_object())
107        .ok_or(InitializeError::Malformed)?;
108    let name = client
109        .get("name")
110        .and_then(serde_json::Value::as_str)
111        .ok_or(InitializeError::Malformed)?;
112
113    Ok(Offer {
114        version: offered,
115        capabilities: client_capabilities(capabilities),
116        name: name.to_owned(),
117    })
118}
119
120/// Reads a `ClientCapabilities` object as a [`CapabilitySet`].
121///
122/// Public because SEP-2575's per-request `_meta` carries the same object in a
123/// different message, and reading it twice with two tables would be two
124/// definitions of what a client is allowed to say. See
125/// [`crate::application::mcp::request_meta`].
126///
127/// The inverse of the client's own renderer, and table-driven for the same
128/// reason: [`Capability::as_str`] is a dot in a Rust name and two levels in
129/// JSON, so a capability advertised under the wrong key is one this cannot see.
130/// A peer that sent nothing is not an error — it advertised nothing, and an
131/// empty set is what that means.
132///
133/// An unrecognised key is ignored rather than refused. The specification's
134/// extensibility point is `experimental`, and a client built against a later
135/// revision may name capabilities this build has never heard of; refusing the
136/// handshake over one would make every future client unconnectable.
137///
138/// [`Capability::as_str`]: crate::domain::Capability::as_str
139pub fn client_capabilities(object: &serde_json::Map<String, serde_json::Value>) -> CapabilitySet {
140    let mut set = CapabilitySet::empty();
141    for (capability, path) in CLIENT_CAPABILITY_WIRE {
142        if path_present(object, path) {
143            set.insert(*capability);
144        }
145    }
146    set
147}
148
149/// Whether every key on `path` is present, as an object where the schema gives
150/// one and a terminal key at the end.
151///
152/// The mirror of the client's `insert_path`: a parent capability's presence is
153/// the claim, and the schema gives `roots` an object rather than a boolean, so
154/// `roots: {}` is the wire form and `roots: true` is not.
155fn path_present(root: &serde_json::Map<String, serde_json::Value>, path: &[&str]) -> bool {
156    let Some((last, parents)) = path.split_last() else {
157        return false;
158    };
159    let mut cursor = root;
160    for key in parents {
161        let Some(next) = cursor.get(*key).and_then(serde_json::Value::as_object) else {
162            return false;
163        };
164        cursor = next;
165    }
166    cursor.contains_key(*last)
167}
168
169/// Asserts `path` on `root`, creating every object on the way.
170///
171/// A path of one key leaves an **empty object** rather than a boolean, because
172/// a parent capability's presence is the claim and the schema gives it an
173/// object: `ClientCapabilities.roots` is an object, so `"roots": {}` is the
174/// wire form and `"roots": true` is not one any schema admits. A longer path
175/// ends in `true`, because a sub-capability like `listChanged` genuinely is a
176/// boolean.
177fn insert_path(root: &mut serde_json::Map<String, serde_json::Value>, path: &[&str]) {
178    let Some((last, parents)) = path.split_last() else {
179        return;
180    };
181    let mut cursor = root;
182    for key in parents {
183        let entry = cursor
184            .entry((*key).to_string())
185            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
186        let serde_json::Value::Object(parent) = entry else {
187            return;
188        };
189        cursor = parent;
190    }
191    let leaf = if parents.is_empty() {
192        serde_json::Value::Object(serde_json::Map::new())
193    } else {
194        serde_json::Value::Bool(true)
195    };
196    cursor.insert((*last).to_string(), leaf);
197}
198
199/// Renders a profile's server capabilities as a `ServerCapabilities` object.
200///
201/// Reads [`FeatureProfile::advertised_by_server`] rather than being handed a
202/// set, so there is no way to reach this function with a capability set that
203/// did not come from the profile. That is the drift this exists to prevent: a
204/// caller that passed in its own set would be free to advertise a flag the
205/// gates behind it would refuse, and the peer would have been told otherwise
206/// at connect time.
207///
208/// A bridge passes the intersection here, which is the whole point of computing
209/// one: the object a client reads is the set of things the bridge can actually
210/// do behind it.
211///
212/// A capability in the derived set with no row in [`SERVER_CAPABILITY_WIRE`]
213/// is not emitted. Two are in that position and neither is reachable from a
214/// server: the client-side ones, which [`advertised_by_server`](crate::domain::feature::FeatureProfile::advertised_by_server) cannot produce
215/// because their feature rows are `Side::Client`, and
216/// `Capability::Extensions`, which only `2026-07-28` defines — a revision a
217/// caller has already refused before getting here, because that revision has
218/// no `InitializeResult` to put it in.
219#[must_use]
220pub fn server_capabilities_json(profile: FeatureProfile) -> serde_json::Value {
221    let advertised = profile.advertised_by_server();
222    let mut root = serde_json::Map::new();
223
224    for (capability, path) in SERVER_CAPABILITY_WIRE {
225        if !advertised.contains(*capability) {
226            continue;
227        }
228        insert_path(&mut root, path);
229    }
230
231    serde_json::Value::Object(root)
232}
233
234#[cfg(test)]
235mod tests {
236    use super::{InitializeError, read_offer, server_capabilities_json};
237    use crate::domain::ir::JsonObject;
238    use crate::domain::{Capability, CapabilitySet, ProtocolVersion};
239
240    /// What a test here can fail with.
241    #[derive(Debug)]
242    enum Failure {
243        /// The fixture was not a JSON object.
244        Fixture(crate::domain::ir::JsonObjectError),
245        /// The test's own complaint.
246        Message(String),
247    }
248
249    impl core::fmt::Display for Failure {
250        fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
251            match self {
252                Self::Fixture(error) => write!(f, "bad fixture: {error}"),
253                Self::Message(text) => f.write_str(text),
254            }
255        }
256    }
257
258    impl std::error::Error for Failure {}
259
260    impl From<crate::domain::ir::JsonObjectError> for Failure {
261        fn from(error: crate::domain::ir::JsonObjectError) -> Self {
262            Self::Fixture(error)
263        }
264    }
265
266    impl From<String> for Failure {
267        fn from(text: String) -> Self {
268            Self::Message(text)
269        }
270    }
271
272    fn params(text: &str) -> Result<JsonObject, Failure> {
273        JsonObject::new(text).map_err(Failure::from)
274    }
275
276    #[test]
277    fn reads_a_minimal_offer() -> Result<(), Failure> {
278        let body = params(
279            r#"{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe"}}"#,
280        )?;
281        let offer = read_offer(&body)
282            .map_err(|_| Failure::from("a minimal offer was refused".to_string()))?;
283
284        assert_eq!(offer.version, ProtocolVersion::V2025_06_18);
285        assert_eq!(offer.capabilities, CapabilitySet::empty());
286        assert_eq!(offer.name, "probe");
287        Ok(())
288    }
289
290    /// An unknown revision is an offer, not a refusal. The client is the only
291    /// thing that can speak it, so refusing here would make every future
292    /// client unconnectable — the answer is a counter-offer.
293    #[test]
294    fn an_unknown_revision_is_still_read() -> Result<(), Failure> {
295        let body = params(
296            r#"{"protocolVersion":"2099-01-01","capabilities":{},"clientInfo":{"name":"future"}}"#,
297        )?;
298        let offer = read_offer(&body)
299            .map_err(|_| Failure::from("a future revision was refused".to_string()))?;
300        assert!(matches!(offer.version, ProtocolVersion::Unknown(_)));
301        Ok(())
302    }
303
304    #[test]
305    fn capabilities_are_read_through_the_tables() -> Result<(), Failure> {
306        let body = params(
307            r#"{"protocolVersion":"2025-06-18","capabilities":{"roots":{}},"clientInfo":{"name":"r"}}"#,
308        )?;
309        let offer =
310            read_offer(&body).map_err(|_| Failure::from("roots were refused".to_string()))?;
311        assert!(
312            offer.capabilities.contains(Capability::Roots),
313            "an empty roots object is the wire form for the capability being present"
314        );
315        Ok(())
316    }
317
318    /// Every malformed case collapses to one error, so the table checks that
319    /// none of them is accidentally accepted rather than that each has its own
320    /// variant.
321    #[test]
322    fn malformed_bodies_are_all_refused() -> Result<(), Failure> {
323        let cases = [
324            r#"{"capabilities":{},"clientInfo":{"name":"x"}}"#,
325            r#"{"protocolVersion":7,"capabilities":{},"clientInfo":{"name":"x"}}"#,
326            r#"{"protocolVersion":"not-a-date","capabilities":{},"clientInfo":{"name":"x"}}"#,
327            r#"{"protocolVersion":"2025-06-18","clientInfo":{"name":"x"}}"#,
328            r#"{"protocolVersion":"2025-06-18","capabilities":true,"clientInfo":{"name":"x"}}"#,
329            r#"{"protocolVersion":"2025-06-18","capabilities":{}}"#,
330            r#"{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{}}"#,
331            r#"{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":3}}"#,
332        ];
333
334        // A body that is not a JSON object cannot appear in this table: the
335        // reader takes a `&JsonObject`, so `params: "[]"` was already refused
336        // by whoever parsed it. Testing it here would assert that a type
337        // holds, which is what the signature is for.
338
339        for (index, case) in cases.iter().enumerate() {
340            let body = params(case)?;
341            if read_offer(&body) != Err(InitializeError::Malformed) {
342                return Err(Failure::from(format!("case {index} was accepted: {case}")));
343            }
344        }
345        Ok(())
346    }
347
348    /// `clientInfo.version` is free-form and nothing here depends on it, so a
349    /// client that omits it connects. This is a deliberate leniency and would
350    /// silently tighten if someone added the field to the required set.
351    #[test]
352    fn a_client_without_a_version_string_is_accepted() -> Result<(), Failure> {
353        let body = params(
354            r#"{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"x"}}"#,
355        )?;
356        read_offer(&body)
357            .map_err(|_| Failure::from("a nameless version was refused".to_string()))?;
358        Ok(())
359    }
360
361    /// The writer emits only what the profile derived.
362    ///
363    /// There is no round-trip here to test: the reader above reads a
364    /// _client_ capabilities object and the writer renders a _server_ one, so
365    /// they walk different tables in opposite directions and are not
366    /// inverses. What matters is that the writer cannot drift from the gates —
367    /// a capability the profile refused must not appear, or the peer was told
368    /// otherwise at connect time.
369    #[test]
370    fn the_writer_advertises_only_what_the_profile_allows() -> Result<(), Failure> {
371        let version = ProtocolVersion::V2025_06_18;
372        let everything = crate::domain::feature::resolve(crate::domain::Negotiation {
373            version,
374            client_caps: CapabilitySet::all_for(version),
375            server_caps: CapabilitySet::all_for(version),
376            transport: crate::domain::TransportKind::Stdio,
377        });
378        let nothing = crate::domain::feature::resolve(crate::domain::Negotiation {
379            version,
380            client_caps: CapabilitySet::all_for(version),
381            server_caps: CapabilitySet::empty(),
382            transport: crate::domain::TransportKind::Stdio,
383        });
384
385        let serde_json::Value::Object(generous) = server_capabilities_json(everything) else {
386            return Err(Failure::from(
387                "tools should render as an object".to_string(),
388            ));
389        };
390        let serde_json::Value::Object(stingy) = server_capabilities_json(nothing) else {
391            return Err(Failure::from(
392                "tools should render as an object".to_string(),
393            ));
394        };
395
396        assert!(
397            stingy.is_empty(),
398            "a server that claimed nothing may claim nothing"
399        );
400        assert!(
401            !generous.is_empty(),
402            "a server claiming everything must claim something"
403        );
404        Ok(())
405    }
406
407    /// A single-key capability path renders as an empty object, not `true`.
408    ///
409    /// `experimental` is the case: the schema gives it an object, so
410    /// `"experimental": true` is not a wire form any schema admits, and a
411    /// client reading it with the same table this crate uses would not see the
412    /// capability.
413    #[test]
414    fn a_parent_capability_renders_as_an_object() -> Result<(), Failure> {
415        let version = ProtocolVersion::V2025_06_18;
416        let profile = crate::domain::feature::resolve(crate::domain::Negotiation {
417            version,
418            client_caps: CapabilitySet::all_for(version),
419            server_caps: CapabilitySet::all_for(version),
420            transport: crate::domain::TransportKind::Stdio,
421        });
422        let serde_json::Value::Object(object) = server_capabilities_json(profile) else {
423            return Err(Failure::from(
424                "server_capabilities_json always renders an object".to_string(),
425            ));
426        };
427
428        match object.get("experimental") {
429            Some(experimental) => assert!(
430                experimental.is_object(),
431                "experimental must be an object; `true` is not a form the schema admits"
432            ),
433            None => Err(Failure::from(
434                "experimental was in the profile but not in the wire form".to_string(),
435            ))?,
436        }
437        Ok(())
438    }
439}