Skip to main content

mcp_usage_core/
legacy.rs

1//! Classifying a request from its body, for clients older than the mirrored headers.
2//!
3//! Mirrored headers ([`crate::version::HEADER_VALIDATION_SINCE`]) let the meter price a
4//! request without reading its body, but only clients on that revision send them. Every
5//! earlier client - and, as of this writing, the default configuration of MCP Inspector -
6//! sends none, so a header-only meter refuses the entire installed base.
7//!
8//! Reading the body instead is not the unsafe fallback [`crate::version`] warns about.
9//! That warning is about believing *headers* on a revision where nothing obliges the
10//! origin to check them against the body: there, a client can call an expensive tool
11//! while claiming a cheap one. The body carries no such ambiguity. It is the exact
12//! document the origin will execute, so a client cannot misdeclare its call without
13//! also changing what it invokes.
14//!
15//! What this costs is throughput, not correctness: one bounded JSON parse per legacy
16//! request. Requests that arrive with trusted headers never reach this module.
17
18use crate::Method;
19use serde_json::Value;
20use std::fmt;
21
22/// Why a legacy request could not be classified from its body.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum LegacyClassificationError {
25    /// The body was not JSON.
26    Malformed,
27    /// A JSON-RPC batch. Several calls in one request cannot be priced as one, and
28    /// batching was removed from the protocol in 2025-06-18.
29    Batch,
30    /// No usable `method` string.
31    MissingMethod,
32    /// A name-bearing method arrived without `params.name` or `params.uri`.
33    MissingName(Method),
34}
35
36impl fmt::Display for LegacyClassificationError {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            Self::Malformed => {
40                f.write_str("request body is not JSON, and this client sent no trusted MCP headers")
41            }
42            Self::Batch => f.write_str(
43                "JSON-RPC batches cannot be metered as a single call; send one request per call",
44            ),
45            Self::MissingMethod => f.write_str("request body has no JSON-RPC \"method\""),
46            Self::MissingName(method) => write!(
47                f,
48                "{} requires params.name or params.uri in the body",
49                method.as_str()
50            ),
51        }
52    }
53}
54
55impl std::error::Error for LegacyClassificationError {}
56
57/// Derive the method and name of a legacy request from its JSON-RPC body.
58///
59/// `name` mirrors what `Mcp-Name` would carry: `params.name` for `tools/call` and
60/// `prompts/get`, `params.uri` for `resources/read`.
61///
62/// # Errors
63///
64/// Returns [`LegacyClassificationError`] for malformed JSON, batches, a missing method,
65/// or a name-bearing method with no name in its params.
66pub fn classify_body(body: &[u8]) -> Result<(Method, Option<String>), LegacyClassificationError> {
67    let value: Value =
68        serde_json::from_slice(body).map_err(|_| LegacyClassificationError::Malformed)?;
69    if value.is_array() {
70        return Err(LegacyClassificationError::Batch);
71    }
72    let method = value
73        .get("method")
74        .and_then(Value::as_str)
75        .ok_or(LegacyClassificationError::MissingMethod)?;
76    let method = Method::parse(method);
77
78    if !method.carries_name() {
79        return Ok((method, None));
80    }
81    // resources/read identifies its target by uri; the other two by name. Mcp-Name
82    // mirrors whichever the method uses, so the fallback has to do the same.
83    let name = value
84        .get("params")
85        .and_then(|params| {
86            params
87                .get("name")
88                .or_else(|| params.get("uri"))
89                .and_then(Value::as_str)
90        })
91        .ok_or_else(|| LegacyClassificationError::MissingName(method.clone()))?;
92    Ok((method, Some(name.to_owned())))
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn tools_call_is_priced_on_the_name_in_the_body() {
101        let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/call",
102                        "params":{"name":"check_merchant","arguments":{"domain":"a.com"}}}"#;
103        let (method, name) = classify_body(body).expect("classifies");
104        assert_eq!(method, Method::ToolsCall);
105        assert_eq!(name.as_deref(), Some("check_merchant"));
106    }
107
108    /// The attack the header path has to guard against does not exist here: the name is
109    /// read from the same document the origin executes, so claiming a cheap tool means
110    /// calling the cheap tool.
111    #[test]
112    fn the_body_name_is_the_one_the_origin_will_run() {
113        let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/call",
114                        "params":{"name":"expensive_tool"}}"#;
115        let (_, name) = classify_body(body).expect("classifies");
116        assert_eq!(name.as_deref(), Some("expensive_tool"));
117    }
118
119    #[test]
120    fn resources_read_is_named_by_uri() {
121        let body = br#"{"jsonrpc":"2.0","id":1,"method":"resources/read",
122                        "params":{"uri":"file:///a.txt"}}"#;
123        let (method, name) = classify_body(body).expect("classifies");
124        assert_eq!(method, Method::ResourcesRead);
125        assert_eq!(name.as_deref(), Some("file:///a.txt"));
126    }
127
128    #[test]
129    fn listing_methods_need_no_name() {
130        for raw in ["tools/list", "prompts/list", "resources/list"] {
131            let body = format!(r#"{{"jsonrpc":"2.0","id":1,"method":"{raw}"}}"#);
132            let (method, name) = classify_body(body.as_bytes()).expect("classifies");
133            assert_eq!(method, Method::parse(raw));
134            assert!(name.is_none(), "{raw} should carry no name");
135        }
136    }
137
138    #[test]
139    fn unknown_extension_methods_pass_through() {
140        let body = br#"{"jsonrpc":"2.0","id":1,"method":"vendor/thing"}"#;
141        let (method, _) = classify_body(body).expect("classifies");
142        assert_eq!(method, Method::parse("vendor/thing"));
143    }
144
145    #[test]
146    fn a_name_bearing_method_without_a_name_is_refused() {
147        let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{}}"#;
148        assert!(matches!(
149            classify_body(body),
150            Err(LegacyClassificationError::MissingName(Method::ToolsCall))
151        ));
152    }
153
154    #[test]
155    fn batches_are_refused_rather_than_priced_as_one_call() {
156        let body = br#"[{"jsonrpc":"2.0","id":1,"method":"tools/list"}]"#;
157        assert_eq!(classify_body(body), Err(LegacyClassificationError::Batch));
158    }
159
160    #[test]
161    fn malformed_and_methodless_bodies_are_distinguished() {
162        assert_eq!(
163            classify_body(b"not json"),
164            Err(LegacyClassificationError::Malformed)
165        );
166        assert_eq!(
167            classify_body(br#"{"jsonrpc":"2.0","id":1}"#),
168            Err(LegacyClassificationError::MissingMethod)
169        );
170    }
171}