1use crate::Method;
19use serde_json::Value;
20use std::fmt;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum LegacyClassificationError {
25 Malformed,
27 Batch,
30 MissingMethod,
32 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
57pub 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 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 #[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}