protocol_droid/application/mcp/request_meta.rs
1//! The per-request `_meta` block SEP-2575 introduced at **2026-07-28**.
2//!
3//! # Why the handshake moved into `_meta`
4//!
5//! Through `2025-11-25` a client's capabilities and revision arrive once, in
6//! `initialize`, and every request after that rides the session they opened.
7//! `2026-07-28` has no handshake: the schema makes `RequestParams._meta`
8//! required on every request, and requires two keys in it —
9//! `io.modelcontextprotocol/protocolVersion` and
10//! `io.modelcontextprotocol/clientCapabilities`. A stateless peer repeats
11//! them per request because there is no session to remember them on.
12//!
13//! The spec is explicit that the server must not carry them forward:
14//!
15//! > Capabilities are declared per-request rather than once at initialization;
16//! > Servers MUST NOT infer capabilities from prior requests.
17//!
18//! That sentence is the reason this is a value object parsed from one body
19//! rather than a field on the session: reading a request against the previous
20//! request's capabilities is exactly the inference the SEP forbids, and a
21//! design where that is reachable is a design where it will happen.
22//!
23//! # Why the parser lives beside `initialize`
24//!
25//! `initialize::read_offer` reads the same two facts off a different member
26//! of a different message, and both go through the same
27//! [`client_capabilities`] table. A second copy of that table would be a
28//! second definition of what a client is allowed to say.
29//!
30//! [`client_capabilities`]: crate::application::mcp::initialize::read_offer
31
32use crate::domain::ir::{JsonObject, LogLevel};
33use crate::domain::session::ProgressToken;
34use crate::domain::{CapabilitySet, ProtocolVersion};
35
36/// The key naming the revision this request is written against.
37pub const PROTOCOL_VERSION_KEY: &str = "io.modelcontextprotocol/protocolVersion";
38
39/// The key naming what the client supports, for this request.
40pub const CLIENT_CAPABILITIES_KEY: &str = "io.modelcontextprotocol/clientCapabilities";
41
42/// The key naming the client software, which the SEP makes a SHOULD.
43pub const CLIENT_INFO_KEY: &str = "io.modelcontextprotocol/clientInfo";
44
45/// The key opting a request in to `notifications/message` at a level.
46///
47/// SEP-2575 replaced `logging/setLevel` with it: a request that omits the key
48/// receives no log records at all.
49pub const LOG_LEVEL_KEY: &str = "io.modelcontextprotocol/logLevel";
50
51/// What one request said about itself.
52///
53/// `capabilities` and `version` are not optional because the schema does not
54/// make them so: both are in `RequestMetaObject`'s `required`. A request that
55/// omits either is refused by [`RequestMeta::read`] rather than served with a
56/// default, because a default here would be the server inventing a
57/// capability the client never claimed.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct RequestMeta {
60 /// The revision this request is written against.
61 pub version: ProtocolVersion,
62 /// What the client supports, read through the shared capability tables.
63 pub capabilities: CapabilitySet,
64 /// The client's self-reported name, when it sent one.
65 ///
66 /// A SHOULD in the SEP and self-reported by the definition: nothing in
67 /// this crate's answers depends on it, and a server that trusted it would
68 /// be trusting a string the schema calls unverified.
69 pub client_name: Option<String>,
70 /// The level this request opted in to log records at, or `None` for none.
71 pub log_level: Option<LogLevel>,
72}
73
74/// Why a `_meta` block could not be read.
75///
76/// Split by cause because the three faults have three different answers, and
77/// one error type would force every caller to re-derive which it had:
78///
79/// - [`Absent`](Self::Absent) is a pre-2026-07-28 request, or one from a
80/// client that negotiated an older revision. It is not a fault at all.
81/// - [`MissingVersion`](Self::MissingVersion) and
82/// [`MissingCapabilities`](Self::MissingCapabilities) are SEP-2575's own
83/// `-32602 Invalid params` cases, and the suite checks them separately.
84/// - [`MalformedVersion`](Self::MalformedVersion) is the same fault with a
85/// different cause: the key was there and its value was not a revision. The
86/// spec answers that with
87/// [`UnsupportedProtocolVersionError`](Self::MalformedVersion) — an
88/// *unsupported version*, not a malformed request — so it cannot collapse
89/// into the missing case.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
91pub enum RequestMetaError {
92 /// No `_meta` at all, which is the shape of every pre-2026-07-28 request.
93 #[error("the request carries no `_meta` block")]
94 Absent,
95
96 /// `_meta` is present and is not a JSON object.
97 #[error("`_meta` is not a JSON object")]
98 Malformed,
99
100 /// `_meta` names no `io.modelcontextprotocol/protocolVersion`.
101 #[error("`_meta` is missing `{key}`", key = PROTOCOL_VERSION_KEY)]
102 MissingVersion,
103
104 /// The named version is not one this crate knows.
105 #[error("`{PROTOCOL_VERSION_KEY}` is not a protocol version this build implements")]
106 MalformedVersion,
107
108 /// `_meta` names no `io.modelcontextprotocol/clientCapabilities`.
109 #[error("`_meta` is missing `{key}`", key = CLIENT_CAPABILITIES_KEY)]
110 MissingCapabilities,
111
112 /// `io.modelcontextprotocol/logLevel` is present and is not a level the
113 /// specification names.
114 #[error("`{LOG_LEVEL_KEY}` is not a logging level")]
115 MalformedLogLevel,
116
117 /// The capabilities value is present and is not a JSON object.
118 #[error("`{CLIENT_CAPABILITIES_KEY}` is not a JSON object")]
119 MalformedCapabilities,
120}
121
122impl RequestMeta {
123 /// Reads the block off a request's `params`.
124 ///
125 /// # Errors
126 ///
127 /// [`RequestMetaError`] for each of the faults above, distinguished as
128 /// documented on the type.
129 ///
130 /// # Examples
131 ///
132 /// ```
133 /// use protocol_droid::application::mcp::request_meta::{RequestMeta, RequestMetaError};
134 /// use protocol_droid::domain::ir::JsonObject;
135 /// use protocol_droid::domain::{Capability, ProtocolVersion};
136 ///
137 /// // One line, because the keys are long and rustfmt would otherwise
138 /// // break the literal across lines — and `JsonObject` validates on
139 /// // construction, so a newline inside the JSON is a parse error.
140 /// let params = JsonObject::new(
141 /// r#"{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"roots":{}},"io.modelcontextprotocol/clientInfo":{"name":"probe","version":"1"}}}"#,
142 /// )?;
143 ///
144 /// let meta = RequestMeta::read(¶ms)?;
145 /// assert_eq!(meta.version, ProtocolVersion::V2026_07_28);
146 /// assert!(meta.capabilities.contains(Capability::Roots));
147 /// assert_eq!(meta.client_name.as_deref(), Some("probe"));
148 ///
149 /// // A request with no `_meta` is an older revision, not a fault.
150 /// let older = JsonObject::new(r#"{"method":"tools/list"}"#)?;
151 /// assert_eq!(RequestMeta::read(&older).err(), Some(RequestMetaError::Absent));
152 /// # Ok::<(), Box<dyn std::error::Error>>(())
153 /// ```
154 pub fn read(params: &JsonObject) -> Result<Self, RequestMetaError> {
155 let parsed: serde_json::Value =
156 serde_json::from_str(params.as_str()).map_err(|_| RequestMetaError::Malformed)?;
157 let object = parsed.as_object().ok_or(RequestMetaError::Malformed)?;
158 let meta = object
159 .get("_meta")
160 .ok_or(RequestMetaError::Absent)?
161 .as_object()
162 .ok_or(RequestMetaError::Malformed)?;
163
164 let version = meta
165 .get(PROTOCOL_VERSION_KEY)
166 .ok_or(RequestMetaError::MissingVersion)?
167 .as_str()
168 .ok_or(RequestMetaError::MalformedVersion)?
169 .parse::<ProtocolVersion>()
170 .map_err(|_| RequestMetaError::MalformedVersion)?;
171
172 let capabilities = meta
173 .get(CLIENT_CAPABILITIES_KEY)
174 .ok_or(RequestMetaError::MissingCapabilities)?
175 .as_object()
176 .ok_or(RequestMetaError::MalformedCapabilities)?;
177
178 let log_level = match meta.get(LOG_LEVEL_KEY) {
179 None => None,
180 Some(level) => Some(
181 level
182 .as_str()
183 .and_then(|text| text.parse::<LogLevel>().ok())
184 .ok_or(RequestMetaError::MalformedLogLevel)?,
185 ),
186 };
187
188 Ok(Self {
189 version,
190 log_level,
191 capabilities: client_capabilities(capabilities),
192 client_name: meta
193 .get(CLIENT_INFO_KEY)
194 .and_then(serde_json::Value::as_object)
195 .and_then(|info| info.get("name"))
196 .and_then(serde_json::Value::as_str)
197 .map(ToOwned::to_owned),
198 })
199 }
200}
201
202/// The revision a request's `_meta` names, exactly as the client wrote it.
203///
204/// Separate from [`RequestMeta::read`] because the two callers that need it
205/// need it *when that read fails*: a version this build cannot parse is still
206/// a version the client asked for, and SEP-2575 answers it with
207/// `UnsupportedProtocolVersionError` echoing `data.requested` — which has to be
208/// the client's own spelling. The HTTP transport also compares it byte for
209/// byte with the `MCP-Protocol-Version` header, which the schema says it MUST
210/// match.
211#[must_use]
212pub fn version_text(params: &JsonObject) -> Option<String> {
213 let parsed: serde_json::Value = serde_json::from_str(params.as_str()).ok()?;
214 parsed
215 .get("_meta")?
216 .get(PROTOCOL_VERSION_KEY)?
217 .as_str()
218 .map(ToOwned::to_owned)
219}
220
221/// The progress token a request opened, if it opened one.
222///
223/// `params._meta.progressToken` on every revision that has `_meta`, and the
224/// same member on `2024-11-05` and `2025-03-26`, whose schemas spell the
225/// request-level `_meta` the same way. Optional in the strongest sense: a
226/// request without one is valid and simply gets no progress.
227#[must_use]
228pub fn progress_token(params: &JsonObject) -> Option<ProgressToken> {
229 let parsed: serde_json::Value = serde_json::from_str(params.as_str()).ok()?;
230 ProgressToken::from_json(parsed.get("_meta")?.get("progressToken")?)
231}
232
233/// Reads a `ClientCapabilities` object as a [`CapabilitySet`].
234///
235/// The same table `read_offer` uses, reached through the same function: the
236/// two messages say the same thing about the client in different members, and
237/// a second table here would be a second answer to "what may a client say".
238fn client_capabilities(object: &serde_json::Map<String, serde_json::Value>) -> CapabilitySet {
239 crate::application::mcp::initialize::client_capabilities(object)
240}
241
242/// Whether this body is a SEP-2575 stateless request.
243///
244/// The cheapest possible answer to the question the router asks, and it is
245/// deliberately cheap: this runs on every sessionless POST, and a full
246/// [`RequestMeta::read`] would parse the whole body only to be discarded by a
247/// caller that turns out to have a session header after all.
248///
249/// The presence of the version key *is* the test, because it is the only
250/// member that can make a request self-describing: capabilities alone would
251/// not say which revision to gate the answer on.
252#[must_use]
253pub fn declares_version(params: &serde_json::Value) -> bool {
254 params
255 .get("_meta")
256 .and_then(serde_json::Value::as_object)
257 .is_some_and(|meta| meta.contains_key(PROTOCOL_VERSION_KEY))
258}
259
260#[cfg(test)]
261mod tests {
262 use crate::application::mcp::request_meta::{RequestMeta, RequestMetaError};
263 use crate::domain::ir::JsonObject;
264 use crate::domain::{Capability, CapabilitySet, ProtocolVersion};
265
266 /// What a test here can fail with.
267 #[derive(Debug)]
268 enum Failure {
269 /// The test's own complaint.
270 Message(String),
271 /// A fixture was not a JSON object.
272 Fixture(crate::domain::ir::JsonObjectError),
273 }
274
275 impl core::fmt::Display for Failure {
276 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
277 match self {
278 Self::Message(text) => f.write_str(text),
279 Self::Fixture(error) => write!(f, "bad fixture: {error}"),
280 }
281 }
282 }
283
284 impl std::error::Error for Failure {}
285
286 impl From<String> for Failure {
287 fn from(text: String) -> Self {
288 Self::Message(text)
289 }
290 }
291
292 impl From<crate::domain::ir::JsonObjectError> for Failure {
293 fn from(error: crate::domain::ir::JsonObjectError) -> Self {
294 Self::Fixture(error)
295 }
296 }
297
298 impl From<RequestMetaError> for Failure {
299 fn from(error: RequestMetaError) -> Self {
300 Self::Message(error.to_string())
301 }
302 }
303
304 type TestError = Failure;
305
306 /// Both required keys, read back as the revision and the capability set.
307 ///
308 /// Asserting on the whole `Result` rather than on a parsed value followed
309 /// by an unwrap: on failure this prints the reader's own account of what
310 /// it refused, which is what a failing schema change wants.
311 #[test]
312 fn both_required_keys_are_read() -> Result<(), TestError> {
313 let params = JsonObject::new(
314 r#"{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"sampling":{},"roots":{}}}}"#,
315 )?;
316 let meta = RequestMeta::read(¶ms)?;
317
318 assert_eq!(meta.version, ProtocolVersion::V2026_07_28);
319 assert!(meta.capabilities.contains(Capability::Sampling));
320 assert!(meta.capabilities.contains(Capability::Roots));
321 assert_eq!(meta.client_name, None);
322 Ok(())
323 }
324
325 /// An unrecognised capability is ignored, not refused.
326 ///
327 /// The same extensibility rule `read_offer` follows: a client built
328 /// against a later revision names capabilities this build has never heard
329 /// of, and refusing the request over one would make every future client
330 /// unconnectable.
331 #[test]
332 fn an_unknown_capability_is_ignored() -> Result<(), TestError> {
333 let params = JsonObject::new(
334 r#"{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"somethingNew":{}}}}"#,
335 )?;
336 let meta = RequestMeta::read(¶ms)?;
337
338 assert_eq!(meta.capabilities, CapabilitySet::empty());
339 Ok(())
340 }
341
342 /// Each missing key is its own fault, because each is its own `-32602`.
343 #[test]
344 fn each_missing_required_key_is_distinguished() -> Result<(), TestError> {
345 let no_meta = JsonObject::new("{}")?;
346 assert_eq!(
347 RequestMeta::read(&no_meta).err(),
348 Some(RequestMetaError::Absent),
349 "a request with no _meta is an older revision, not a fault",
350 );
351
352 let no_version =
353 JsonObject::new(r#"{"_meta":{"io.modelcontextprotocol/clientCapabilities":{}}}"#)?;
354 assert_eq!(
355 RequestMeta::read(&no_version).err(),
356 Some(RequestMetaError::MissingVersion),
357 );
358
359 let no_capabilities = JsonObject::new(
360 r#"{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}"#,
361 )?;
362 assert_eq!(
363 RequestMeta::read(&no_capabilities).err(),
364 Some(RequestMetaError::MissingCapabilities),
365 );
366
367 let bad_version = JsonObject::new(
368 r#"{"_meta":{"io.modelcontextprotocol/protocolVersion":"not-a-version","io.modelcontextprotocol/clientCapabilities":{}}}"#,
369 )?;
370 assert_eq!(
371 RequestMeta::read(&bad_version).err(),
372 Some(RequestMetaError::MalformedVersion),
373 "a named-but-unknown version is an unsupported version, not a missing key",
374 );
375
376 let bad_capabilities = JsonObject::new(
377 r#"{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":"yes"}}"#,
378 )?;
379 assert_eq!(
380 RequestMeta::read(&bad_capabilities).err(),
381 Some(RequestMetaError::MalformedCapabilities),
382 );
383 Ok(())
384 }
385
386 /// `clientInfo` is a SHOULD: absent is fine, present is read, malformed is
387 /// ignored rather than refused.
388 ///
389 /// The last one matters: nothing this crate answers depends on the name,
390 /// so refusing a request over a `clientInfo` that is not an object would
391 /// be stricter than the spec for a field the spec calls unverified.
392 #[test]
393 fn client_info_is_read_when_present_and_ignored_when_not() -> Result<(), TestError> {
394 let named = JsonObject::new(
395 r#"{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{},"io.modelcontextprotocol/clientInfo":{"name":"probe","version":"1"}}}"#,
396 )?;
397 assert_eq!(
398 RequestMeta::read(&named)?.client_name.as_deref(),
399 Some("probe"),
400 );
401
402 let malformed = JsonObject::new(
403 r#"{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{},"io.modelcontextprotocol/clientInfo":"probe"}}"#,
404 )?;
405 assert_eq!(RequestMeta::read(&malformed)?.client_name, None);
406 Ok(())
407 }
408}