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(¶ms)?;
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}