Skip to main content

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(&params)?;
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(&params)?;
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(&params)?;
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}