1use serde_json::Value;
5
6#[derive(Debug, Clone, PartialEq)]
8pub struct ToolCallRequest {
9 pub id: Option<Value>,
11 pub tool: String,
13 pub arguments: Value,
15}
16
17#[derive(Debug, thiserror::Error, PartialEq, Eq)]
19#[non_exhaustive]
20pub enum RpcError {
21 #[error("invalid JSON: {0}")]
23 Json(String),
24 #[error("not a JSON-RPC 2.0 request: {0}")]
26 NotJsonRpc(String),
27 #[error("method {0:?} is not tools/call")]
30 NotToolCall(String),
31 #[error("invalid tools/call params: {0}")]
33 BadParams(String),
34 #[error("payload of {0} bytes exceeds the {1}-byte bound")]
36 TooLarge(usize, usize),
37}
38
39pub const MAX_PAYLOAD_BYTES: usize = 4 * 1024 * 1024;
41
42pub const MAX_JSON_DEPTH: usize = 64;
44
45pub fn parse_tool_call(raw: &[u8]) -> Result<ToolCallRequest, RpcError> {
47 if raw.len() > MAX_PAYLOAD_BYTES {
48 return Err(RpcError::TooLarge(raw.len(), MAX_PAYLOAD_BYTES));
49 }
50 let v: Value = serde_json::from_slice(raw).map_err(|e| RpcError::Json(e.to_string()))?;
51 if depth_of(&v, 0) > MAX_JSON_DEPTH {
52 return Err(RpcError::NotJsonRpc("nesting exceeds depth bound".into()));
53 }
54 let obj = v
55 .as_object()
56 .ok_or_else(|| RpcError::NotJsonRpc("root is not an object".into()))?;
57 match obj.get("jsonrpc").and_then(Value::as_str) {
58 Some("2.0") => {}
59 other => {
60 return Err(RpcError::NotJsonRpc(format!(
61 "jsonrpc field is {other:?}, need \"2.0\""
62 )))
63 }
64 }
65 let method = obj
66 .get("method")
67 .and_then(Value::as_str)
68 .ok_or_else(|| RpcError::NotJsonRpc("method missing or not a string".into()))?;
69 if method != "tools/call" {
70 return Err(RpcError::NotToolCall(method.to_owned()));
71 }
72 let params = obj
73 .get("params")
74 .and_then(Value::as_object)
75 .ok_or_else(|| RpcError::BadParams("params missing or not an object".into()))?;
76 let tool = params
77 .get("name")
78 .and_then(Value::as_str)
79 .filter(|s| !s.is_empty())
80 .ok_or_else(|| RpcError::BadParams("params.name missing or empty".into()))?;
81 if av_core::text::contains_bidi_or_zero_width(tool) {
82 return Err(RpcError::BadParams(
83 "params.name carries a bidi/zero-width spoofing character".into(),
84 ));
85 }
86 if tool.chars().any(|c| c.is_control() || c.is_whitespace()) {
93 return Err(RpcError::BadParams(
94 "params.name contains a control character or whitespace".into(),
95 ));
96 }
97 if !tool.is_ascii() {
109 return Err(RpcError::BadParams(
110 "params.name must be ASCII: non-ASCII tool names introduce a Unicode-normalization mismatch \
111 between this proxy's exact-byte matching and downstream MCP servers that fold NFC/NFKC"
112 .into(),
113 ));
114 }
115 if tool.chars().any(|c| c.is_ascii_uppercase()) {
122 return Err(RpcError::BadParams(
123 "params.name must be lowercase: mixed-case tool names bypass policy deny-lists that use \
124 exact-byte matching while downstream MCP servers apply ASCII case folding"
125 .into(),
126 ));
127 }
128 let id = obj.get("id").cloned();
132 match id.as_ref() {
133 None => {}
134 Some(Value::String(_) | Value::Number(_) | Value::Null) => {}
135 Some(_) => {
136 return Err(RpcError::BadParams(
137 "id must be a string, number, or null per JSON-RPC 2.0 Β§4".into(),
138 ));
139 }
140 }
141 if id.is_none() {
146 return Err(RpcError::BadParams(
147 "tools/call requires an id; JSON-RPC notifications are not accepted".into(),
148 ));
149 }
150 let arguments = params
151 .get("arguments")
152 .cloned()
153 .unwrap_or_else(|| Value::Object(Default::default()));
154 if !arguments.is_object() {
155 return Err(RpcError::BadParams("params.arguments must be an object".into()));
156 }
157 Ok(ToolCallRequest {
158 id,
159 tool: tool.to_owned(),
160 arguments,
161 })
162}
163
164fn depth_of(v: &Value, current: usize) -> usize {
165 if current > MAX_JSON_DEPTH {
166 return current; }
168 match v {
169 Value::Array(items) => items
170 .iter()
171 .map(|i| depth_of(i, current + 1))
172 .max()
173 .unwrap_or(current + 1),
174 Value::Object(map) => map
175 .values()
176 .map(|i| depth_of(i, current + 1))
177 .max()
178 .unwrap_or(current + 1),
179 _ => current,
180 }
181}
182
183pub fn authorization_error(id: Option<&Value>, reason: &str) -> Value {
186 serde_json::json!({
187 "jsonrpc": "2.0",
188 "id": id.cloned().unwrap_or(Value::Null),
189 "error": {
190 "code": -32001,
191 "message": "tool call blocked by AgentVisor AI policy",
192 "data": { "reason": reason }
193 }
194 })
195}
196
197#[cfg(test)]
198mod tests {
199 #![allow(
200 clippy::unwrap_used,
201 clippy::expect_used,
202 clippy::panic,
203 clippy::indexing_slicing
204 )]
205
206 use super::*;
207 use proptest::prelude::*;
208 use serde_json::json;
209
210 fn call(tool: &str, args: Value) -> Vec<u8> {
211 serde_json::to_vec(&json!({
212 "jsonrpc": "2.0",
213 "id": 7,
214 "method": "tools/call",
215 "params": { "name": tool, "arguments": args }
216 }))
217 .unwrap()
218 }
219
220 #[test]
221 fn parses_valid_call() {
222 let req = parse_tool_call(&call("db_write", json!({"table": "users"}))).unwrap();
223 assert_eq!(req.tool, "db_write");
224 assert_eq!(req.arguments["table"], "users");
225 assert_eq!(req.id, Some(json!(7)));
226 }
227
228 #[test]
229 fn missing_arguments_defaults_to_empty_object() {
230 let raw = serde_json::to_vec(&json!({
231 "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "t"}
232 }))
233 .unwrap();
234 assert_eq!(parse_tool_call(&raw).unwrap().arguments, json!({}));
235 }
236
237 #[test]
238 fn rejects_malformed_shapes() {
239 assert!(matches!(parse_tool_call(b"not json"), Err(RpcError::Json(_))));
240 assert!(matches!(
241 parse_tool_call(b"[1,2,3]"),
242 Err(RpcError::NotJsonRpc(_))
243 ));
244 assert!(matches!(
245 parse_tool_call(br#"{"jsonrpc":"1.0","method":"tools/call"}"#),
246 Err(RpcError::NotJsonRpc(_))
247 ));
248 assert!(matches!(
249 parse_tool_call(br#"{"jsonrpc":"2.0"}"#),
250 Err(RpcError::NotJsonRpc(_))
251 ));
252 assert!(matches!(
253 parse_tool_call(br#"{"jsonrpc":"2.0","method":"resources/read"}"#),
254 Err(RpcError::NotToolCall(_))
255 ));
256 assert!(matches!(
257 parse_tool_call(br#"{"jsonrpc":"2.0","method":"tools/call"}"#),
258 Err(RpcError::BadParams(_))
259 ));
260 assert!(matches!(
261 parse_tool_call(br#"{"jsonrpc":"2.0","method":"tools/call","params":{"name":""}}"#),
262 Err(RpcError::BadParams(_))
263 ));
264 assert!(matches!(
265 parse_tool_call(
266 br#"{"jsonrpc":"2.0","method":"tools/call","params":{"name":"t","arguments":[1]}}"#
267 ),
268 Err(RpcError::BadParams(_))
269 ));
270 }
271
272 #[test]
273 fn oversized_payload_rejected_before_parse() {
274 let huge = vec![b'x'; MAX_PAYLOAD_BYTES + 1];
275 assert!(matches!(parse_tool_call(&huge), Err(RpcError::TooLarge(..))));
276 }
277
278 #[test]
279 fn max_payload_bytes_is_pinned_at_4_mib() {
280 assert_eq!(MAX_PAYLOAD_BYTES, 4_194_304);
284 }
285
286 #[test]
287 fn deep_nesting_rejected() {
288 let mut nested = String::from("1");
289 for _ in 0..200 {
290 nested = format!("[{nested}]");
291 }
292 let raw = format!(
293 r#"{{"jsonrpc":"2.0","method":"tools/call","params":{{"name":"t","arguments":{{"x":{nested}}}}}}}"#
294 );
295 assert!(parse_tool_call(raw.as_bytes()).is_err());
296 }
297
298 #[test]
299 fn authorization_error_shape() {
300 let e = authorization_error(Some(&json!(9)), "budget exceeded");
301 assert_eq!(e["id"], 9);
302 assert_eq!(e["error"]["code"], -32001);
303 assert_eq!(e["error"]["data"]["reason"], "budget exceeded");
304 }
305
306 proptest! {
307 #![proptest_config(ProptestConfig::with_cases(256))]
308
309 #[test]
311 fn never_panics(bytes in prop::collection::vec(any::<u8>(), 0..2048)) {
312 let _ = parse_tool_call(&bytes);
313 }
314
315 #[test]
317 fn never_panics_on_arbitrary_json(s in "\\PC{0,500}") {
318 let _ = parse_tool_call(s.as_bytes());
319 }
320 }
321
322 #[test]
328 fn tool_name_carrying_a_bidi_or_zero_width_character_is_rejected() {
329 for spoof in [
330 "db_write\u{202E}etirw_bd",
331 "\u{202E}db_write",
332 "db\u{200B}_write",
333 "db_write\u{200E}",
334 "db_write\u{2066}suffix",
335 "db_write\u{FEFF}",
336 ] {
337 let raw = call(spoof, json!({}));
338 match parse_tool_call(&raw) {
339 Err(RpcError::BadParams(reason)) => {
340 assert!(reason.contains("spoofing"), "wrong reason: {reason}");
341 }
342 other => panic!("must reject {spoof:?}, got {other:?}"),
343 }
344 }
345 }
346
347 #[test]
354 fn tool_name_with_control_char_or_whitespace_is_rejected() {
355 for hostile in [
356 "db_write\n",
357 "db_write\r",
358 "db_write\t",
359 "db_write\0",
360 "db_write ",
361 " db_write",
362 "db write",
363 "db_write\u{000B}",
364 "db_write\x7f",
365 ] {
366 let raw = call(hostile, json!({}));
367 match parse_tool_call(&raw) {
368 Err(RpcError::BadParams(reason)) => {
369 assert!(
370 reason.contains("control character or whitespace"),
371 "wrong reason: {reason}",
372 );
373 }
374 other => panic!("must reject {hostile:?}, got {other:?}"),
375 }
376 }
377 }
378
379 #[test]
384 fn tools_call_without_id_is_rejected_as_a_notification() {
385 let raw = serde_json::to_vec(&json!({
386 "jsonrpc": "2.0",
387 "method": "tools/call",
388 "params": {"name": "safe_tool", "arguments": {}}
389 }))
390 .unwrap();
391 match parse_tool_call(&raw) {
392 Err(RpcError::BadParams(reason)) => {
393 assert!(reason.contains("notification"), "wrong reason: {reason}");
394 }
395 other => panic!("notification must be rejected, got {other:?}"),
396 }
397 }
398
399 #[test]
403 fn tools_call_with_structured_id_is_rejected() {
404 for hostile_id in [
405 json!({"nested": true}),
406 json!([1, 2, 3]),
407 json!(true),
408 json!(false),
409 ] {
410 let raw = serde_json::to_vec(&json!({
411 "jsonrpc": "2.0",
412 "id": hostile_id,
413 "method": "tools/call",
414 "params": {"name": "safe_tool", "arguments": {}}
415 }))
416 .unwrap();
417 match parse_tool_call(&raw) {
418 Err(RpcError::BadParams(reason)) => {
419 assert!(reason.contains("JSON-RPC 2.0"), "wrong reason: {reason}");
420 }
421 other => panic!("hostile id {hostile_id:?} must be rejected, got {other:?}"),
422 }
423 }
424 }
425
426 #[test]
428 fn valid_id_shapes_are_accepted() {
429 for good_id in [json!("uuid-123"), json!(42), json!(-7), json!(null)] {
430 let raw = serde_json::to_vec(&json!({
431 "jsonrpc": "2.0",
432 "id": good_id,
433 "method": "tools/call",
434 "params": {"name": "safe_tool", "arguments": {}}
435 }))
436 .unwrap();
437 let parsed = parse_tool_call(&raw).unwrap_or_else(|e| panic!("{good_id:?}: {e:?}"));
438 assert_eq!(parsed.id, Some(good_id));
439 }
440 }
441
442 #[test]
448 fn tools_call_with_uppercase_letters_in_name_is_rejected() {
449 for hostile in ["DB_write", "Delete", "readFile", "PING"] {
450 let raw = serde_json::to_vec(&json!({
451 "jsonrpc": "2.0",
452 "id": "case-attack",
453 "method": "tools/call",
454 "params": {"name": hostile, "arguments": {}}
455 }))
456 .unwrap();
457 let err = parse_tool_call(&raw).unwrap_err();
458 assert!(
459 matches!(err, RpcError::BadParams(ref msg) if msg.contains("lowercase")),
460 "expected lowercase-refusal for {hostile:?}, got {err:?}"
461 );
462 }
463 }
464
465 #[test]
471 fn tools_call_with_non_ascii_bytes_in_name_is_rejected() {
472 for hostile in [
473 "d\u{0301}elete",
474 "de\u{0301}lete",
475 "dΓ©lete",
476 "ππ_π°π«π’ππ",
477 "read_ο½ile",
478 ] {
479 let raw = serde_json::to_vec(&json!({
480 "jsonrpc": "2.0",
481 "id": "unicode-attack",
482 "method": "tools/call",
483 "params": {"name": hostile, "arguments": {}}
484 }))
485 .unwrap();
486 let err = parse_tool_call(&raw).unwrap_err();
487 assert!(
488 matches!(err, RpcError::BadParams(ref msg) if msg.contains("ASCII")),
489 "expected ASCII-only refusal for {hostile:?}, got {err:?}"
490 );
491 }
492 }
493}
494
495#[cfg(test)]
496mod depth_boundary_tests {
497 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
498
499 use super::*;
500
501 #[test]
509 fn json_depth_boundary_is_exact_for_arrays_and_objects() {
510 let build = |inner: String| {
511 format!(
512 r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"t","arguments":{{"k":{inner}}}}}}}"#
513 )
514 };
515 let arrays = |n: usize| format!("{}0{}", "[".repeat(n), "]".repeat(n));
516 let objects = |n: usize| format!("{}0{}", "{\"x\":".repeat(n), "}".repeat(n));
517 for shape in [&arrays as &dyn Fn(usize) -> String, &objects] {
518 let at_limit = build(shape(MAX_JSON_DEPTH - 3));
519 assert!(
520 parse_tool_call(at_limit.as_bytes()).is_ok(),
521 "depth exactly at MAX_JSON_DEPTH must parse"
522 );
523 let past = build(shape(MAX_JSON_DEPTH - 2));
524 let outcome = parse_tool_call(past.as_bytes());
525 assert!(
526 matches!(outcome, Err(RpcError::NotJsonRpc(ref m)) if m.contains("depth")),
527 "one past MAX_JSON_DEPTH must be refused, got {outcome:?}"
528 );
529 }
530 for leaf in ["{}", "[]"] {
534 let wrappers = "{\"x\":".repeat(MAX_JSON_DEPTH - 3);
535 let closers = "}".repeat(MAX_JSON_DEPTH - 3);
536 let past = build(format!("{wrappers}{leaf}{closers}"));
537 let outcome = parse_tool_call(past.as_bytes());
538 assert!(
539 matches!(outcome, Err(RpcError::NotJsonRpc(ref m)) if m.contains("depth")),
540 "empty {leaf} leaf past MAX_JSON_DEPTH must be refused, got {outcome:?}"
541 );
542 }
543 }
544}