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 field = if method == Method::ResourcesRead {
84        "uri"
85    } else {
86        "name"
87    };
88    let name = value
89        .get("params")
90        .and_then(|params| params.get(field))
91        .and_then(Value::as_str)
92        .ok_or_else(|| LegacyClassificationError::MissingName(method.clone()))?;
93    Ok((method, Some(name.to_owned())))
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn named_methods_only_accept_their_own_target_field() {
102        for (method, required, other) in [
103            ("resources/read", "uri", "name"),
104            ("tools/call", "name", "uri"),
105            ("prompts/get", "name", "uri"),
106        ] {
107            let body =
108                serde_json::json!({"method":method,"params":{required:"expensive",other:"free"}});
109            assert_eq!(
110                classify_body(body.to_string().as_bytes())
111                    .unwrap()
112                    .1
113                    .as_deref(),
114                Some("expensive")
115            );
116            for invalid in [
117                Value::Null,
118                Value::Bool(false),
119                serde_json::json!(123),
120                serde_json::json!([]),
121            ] {
122                let body =
123                    serde_json::json!({"method":method,"params":{required:invalid,other:"free"}});
124                assert!(matches!(
125                    classify_body(body.to_string().as_bytes()),
126                    Err(LegacyClassificationError::MissingName(_))
127                ));
128            }
129            let body = serde_json::json!({"method":method,"params":{other:"free"}});
130            assert!(matches!(
131                classify_body(body.to_string().as_bytes()),
132                Err(LegacyClassificationError::MissingName(_))
133            ));
134        }
135    }
136
137    #[test]
138    fn tools_call_is_priced_on_the_name_in_the_body() {
139        let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/call",
140                        "params":{"name":"check_merchant","arguments":{"domain":"a.com"}}}"#;
141        let (method, name) = classify_body(body).expect("classifies");
142        assert_eq!(method, Method::ToolsCall);
143        assert_eq!(name.as_deref(), Some("check_merchant"));
144    }
145
146    /// The attack the header path has to guard against does not exist here: the name is
147    /// read from the same document the origin executes, so claiming a cheap tool means
148    /// calling the cheap tool.
149    #[test]
150    fn the_body_name_is_the_one_the_origin_will_run() {
151        let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/call",
152                        "params":{"name":"expensive_tool"}}"#;
153        let (_, name) = classify_body(body).expect("classifies");
154        assert_eq!(name.as_deref(), Some("expensive_tool"));
155    }
156
157    #[test]
158    fn resources_read_is_named_by_uri() {
159        let body = br#"{"jsonrpc":"2.0","id":1,"method":"resources/read",
160                        "params":{"uri":"file:///a.txt"}}"#;
161        let (method, name) = classify_body(body).expect("classifies");
162        assert_eq!(method, Method::ResourcesRead);
163        assert_eq!(name.as_deref(), Some("file:///a.txt"));
164    }
165
166    #[test]
167    fn listing_methods_need_no_name() {
168        for raw in ["tools/list", "prompts/list", "resources/list"] {
169            let body = format!(r#"{{"jsonrpc":"2.0","id":1,"method":"{raw}"}}"#);
170            let (method, name) = classify_body(body.as_bytes()).expect("classifies");
171            assert_eq!(method, Method::parse(raw));
172            assert!(name.is_none(), "{raw} should carry no name");
173        }
174    }
175
176    #[test]
177    fn unknown_extension_methods_pass_through() {
178        let body = br#"{"jsonrpc":"2.0","id":1,"method":"vendor/thing"}"#;
179        let (method, _) = classify_body(body).expect("classifies");
180        assert_eq!(method, Method::parse("vendor/thing"));
181    }
182
183    #[test]
184    fn a_name_bearing_method_without_a_name_is_refused() {
185        let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{}}"#;
186        assert!(matches!(
187            classify_body(body),
188            Err(LegacyClassificationError::MissingName(Method::ToolsCall))
189        ));
190    }
191
192    #[test]
193    fn batches_are_refused_rather_than_priced_as_one_call() {
194        let body = br#"[{"jsonrpc":"2.0","id":1,"method":"tools/list"}]"#;
195        assert_eq!(classify_body(body), Err(LegacyClassificationError::Batch));
196    }
197
198    #[test]
199    fn malformed_and_methodless_bodies_are_distinguished() {
200        assert_eq!(
201            classify_body(b"not json"),
202            Err(LegacyClassificationError::Malformed)
203        );
204        assert_eq!(
205            classify_body(br#"{"jsonrpc":"2.0","id":1}"#),
206            Err(LegacyClassificationError::MissingMethod)
207        );
208    }
209}