Skip to main content

mcpr_protocol/
lib.rs

1/// JSON-RPC 2.0 parsing and MCP method classification.
2///
3/// Provides typed structs for JSON-RPC 2.0 messages and MCP-specific
4/// method detection. Used by the proxy to classify incoming requests
5/// and log which MCP function is being called.
6use serde_json::Value;
7
8// ── JSON-RPC 2.0 types ──
9
10/// A parsed JSON-RPC 2.0 id (number or string).
11#[derive(Debug, Clone, PartialEq)]
12pub enum JsonRpcId {
13    Number(i64),
14    String(String),
15}
16
17impl std::fmt::Display for JsonRpcId {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        match self {
20            Self::Number(n) => write!(f, "{n}"),
21            Self::String(s) => write!(f, "{s}"),
22        }
23    }
24}
25
26/// A classified JSON-RPC 2.0 message.
27#[derive(Debug)]
28pub enum JsonRpcMessage {
29    /// Has `method` + `id` → expects a response.
30    Request(JsonRpcRequest),
31    /// Has `method`, no `id` → fire-and-forget.
32    Notification(JsonRpcNotification),
33    /// Has `id` + `result`/`error` → reply to a prior request.
34    Response(JsonRpcResponse),
35}
36
37#[derive(Debug)]
38pub struct JsonRpcRequest {
39    pub id: JsonRpcId,
40    pub method: String,
41    pub params: Option<Value>,
42}
43
44#[derive(Debug)]
45pub struct JsonRpcNotification {
46    pub method: String,
47    pub params: Option<Value>,
48}
49
50#[derive(Debug)]
51pub struct JsonRpcResponse {
52    pub id: JsonRpcId,
53    pub result: Option<Value>,
54    pub error: Option<JsonRpcError>,
55}
56
57#[derive(Debug)]
58pub struct JsonRpcError {
59    pub code: i64,
60    pub message: String,
61    pub data: Option<Value>,
62}
63
64// ── JSON-RPC 2.0 error codes (spec: https://www.jsonrpc.org/specification#error_object) ──
65
66pub mod error_code {
67    /// Invalid JSON was received.
68    pub const PARSE_ERROR: i64 = -32700;
69    /// The JSON sent is not a valid Request object.
70    pub const INVALID_REQUEST: i64 = -32600;
71    /// The method does not exist / is not available.
72    pub const METHOD_NOT_FOUND: i64 = -32601;
73    /// Invalid method parameter(s).
74    pub const INVALID_PARAMS: i64 = -32602;
75    /// Internal JSON-RPC error.
76    pub const INTERNAL_ERROR: i64 = -32603;
77
78    /// Short label for known error codes.
79    pub fn label(code: i64) -> &'static str {
80        match code {
81            PARSE_ERROR => "Parse error",
82            INVALID_REQUEST => "Invalid request",
83            METHOD_NOT_FOUND => "Method not found",
84            INVALID_PARAMS => "Invalid params",
85            INTERNAL_ERROR => "Internal error",
86            -32099..=-32000 => "Server error",
87            _ => "Unknown error",
88        }
89    }
90}
91
92// ── JSON-RPC error response builder ──
93
94/// Build a JSON-RPC 2.0 error response as bytes.
95/// `id` can be a request id or `Value::Null` if the request id couldn't be parsed.
96pub fn error_response(id: &Value, code: i64, message: &str) -> Vec<u8> {
97    let resp = serde_json::json!({
98        "jsonrpc": "2.0",
99        "id": id,
100        "error": {
101            "code": code,
102            "message": message,
103        }
104    });
105    serde_json::to_vec(&resp).unwrap_or_default()
106}
107
108/// Extract the JSON-RPC error code from a response body (if it's an error response).
109pub fn extract_error_code(body: &Value) -> Option<(i64, &str)> {
110    let err = body.get("error")?;
111    let code = err.get("code")?.as_i64()?;
112    let message = err.get("message")?.as_str()?;
113    Some((code, message))
114}
115
116// ── MCP method classification ──
117
118/// Known MCP methods — lets the proxy know exactly what function is being called.
119#[derive(Debug, Clone, PartialEq)]
120pub enum McpMethod {
121    Initialize,
122    Initialized,
123    Ping,
124    ToolsList,
125    ToolsCall,
126    ResourcesList,
127    ResourcesRead,
128    ResourcesSubscribe,
129    ResourcesUnsubscribe,
130    PromptsList,
131    PromptsGet,
132    LoggingSetLevel,
133    CompletionComplete,
134    /// Any `notifications/*` we don't have a specific variant for.
135    Notification(String),
136    /// Anything else.
137    Unknown(String),
138}
139
140// MCP method name constants — single source of truth for string matching.
141pub const INITIALIZE: &str = "initialize";
142pub const INITIALIZED: &str = "notifications/initialized";
143pub const PING: &str = "ping";
144pub const TOOLS_LIST: &str = "tools/list";
145pub const TOOLS_CALL: &str = "tools/call";
146pub const RESOURCES_LIST: &str = "resources/list";
147pub const RESOURCES_READ: &str = "resources/read";
148pub const RESOURCES_SUBSCRIBE: &str = "resources/subscribe";
149pub const RESOURCES_UNSUBSCRIBE: &str = "resources/unsubscribe";
150pub const PROMPTS_LIST: &str = "prompts/list";
151pub const PROMPTS_GET: &str = "prompts/get";
152pub const LOGGING_SET_LEVEL: &str = "logging/setLevel";
153pub const COMPLETION_COMPLETE: &str = "completion/complete";
154
155impl McpMethod {
156    pub fn parse(method: &str) -> Self {
157        match method {
158            INITIALIZE => Self::Initialize,
159            INITIALIZED => Self::Initialized,
160            PING => Self::Ping,
161            TOOLS_LIST => Self::ToolsList,
162            TOOLS_CALL => Self::ToolsCall,
163            RESOURCES_LIST => Self::ResourcesList,
164            RESOURCES_READ => Self::ResourcesRead,
165            RESOURCES_SUBSCRIBE => Self::ResourcesSubscribe,
166            RESOURCES_UNSUBSCRIBE => Self::ResourcesUnsubscribe,
167            PROMPTS_LIST => Self::PromptsList,
168            PROMPTS_GET => Self::PromptsGet,
169            LOGGING_SET_LEVEL => Self::LoggingSetLevel,
170            COMPLETION_COMPLETE => Self::CompletionComplete,
171            m if m.starts_with("notifications/") => Self::Notification(m.to_string()),
172            m => Self::Unknown(m.to_string()),
173        }
174    }
175
176    /// Short label for logging (e.g. "tools/call", "initialize").
177    pub fn as_str(&self) -> &str {
178        match self {
179            Self::Initialize => INITIALIZE,
180            Self::Initialized => INITIALIZED,
181            Self::Ping => PING,
182            Self::ToolsList => TOOLS_LIST,
183            Self::ToolsCall => TOOLS_CALL,
184            Self::ResourcesList => RESOURCES_LIST,
185            Self::ResourcesRead => RESOURCES_READ,
186            Self::ResourcesSubscribe => RESOURCES_SUBSCRIBE,
187            Self::ResourcesUnsubscribe => RESOURCES_UNSUBSCRIBE,
188            Self::PromptsList => PROMPTS_LIST,
189            Self::PromptsGet => PROMPTS_GET,
190            Self::LoggingSetLevel => LOGGING_SET_LEVEL,
191            Self::CompletionComplete => COMPLETION_COMPLETE,
192            Self::Notification(m) => m.as_str(),
193            Self::Unknown(m) => m.as_str(),
194        }
195    }
196}
197
198// ── Parsing ──
199
200/// Parse a JSON-RPC id value.
201fn parse_id(value: &Value) -> Option<JsonRpcId> {
202    match value {
203        Value::Number(n) => n.as_i64().map(JsonRpcId::Number),
204        Value::String(s) => Some(JsonRpcId::String(s.clone())),
205        _ => None,
206    }
207}
208
209/// Parse a JSON-RPC error object.
210fn parse_error(value: &Value) -> Option<JsonRpcError> {
211    let obj = value.as_object()?;
212    Some(JsonRpcError {
213        code: obj.get("code")?.as_i64()?,
214        message: obj.get("message")?.as_str()?.to_string(),
215        data: obj.get("data").cloned(),
216    })
217}
218
219/// Parse a single JSON value as a JSON-RPC 2.0 message.
220/// Returns `None` if it doesn't have `"jsonrpc": "2.0"`.
221pub fn parse_message(value: &Value) -> Option<JsonRpcMessage> {
222    let obj = value.as_object()?;
223
224    // Must be JSON-RPC 2.0
225    if obj.get("jsonrpc")?.as_str()? != "2.0" {
226        return None;
227    }
228
229    let id = obj.get("id").and_then(parse_id);
230    let method = obj.get("method").and_then(|m| m.as_str()).map(String::from);
231    let params = obj.get("params").cloned();
232
233    match (method, id) {
234        // Has method + id → Request
235        (Some(method), Some(id)) => Some(JsonRpcMessage::Request(JsonRpcRequest {
236            id,
237            method,
238            params,
239        })),
240        // Has method, no id → Notification
241        (Some(method), None) => Some(JsonRpcMessage::Notification(JsonRpcNotification {
242            method,
243            params,
244        })),
245        // No method, has id → Response
246        (None, Some(id)) => {
247            let result = obj.get("result").cloned();
248            let error = obj.get("error").and_then(parse_error);
249            Some(JsonRpcMessage::Response(JsonRpcResponse {
250                id,
251                result,
252                error,
253            }))
254        }
255        // No method, no id → invalid
256        (None, None) => None,
257    }
258}
259
260/// Result of parsing a POST body as JSON-RPC 2.0.
261#[derive(Debug)]
262pub struct ParsedBody {
263    pub messages: Vec<JsonRpcMessage>,
264    pub is_batch: bool,
265}
266
267impl ParsedBody {
268    /// Get the method string from the first request or notification.
269    /// Falls back to "unknown" if the batch contains only responses.
270    pub fn method_str(&self) -> &str {
271        self.messages
272            .iter()
273            .find_map(|m| match m {
274                JsonRpcMessage::Request(r) => Some(r.method.as_str()),
275                JsonRpcMessage::Notification(n) => Some(n.method.as_str()),
276                _ => None,
277            })
278            .unwrap_or("unknown")
279    }
280
281    /// Get the MCP method classification from the first request/notification.
282    pub fn mcp_method(&self) -> McpMethod {
283        McpMethod::parse(self.method_str())
284    }
285
286    /// Get the id of the first request (if any).
287    pub fn first_request_id(&self) -> Option<&JsonRpcId> {
288        self.messages.iter().find_map(|m| match m {
289            JsonRpcMessage::Request(r) => Some(&r.id),
290            _ => None,
291        })
292    }
293
294    /// True if every message is a notification (no id, no response expected).
295    pub fn is_notification_only(&self) -> bool {
296        self.messages
297            .iter()
298            .all(|m| matches!(m, JsonRpcMessage::Notification(_)))
299    }
300
301    /// Extract a short detail string for logging:
302    /// - tools/call → tool name (params.name)
303    /// - resources/read → resource URI (params.uri)
304    /// - prompts/get → prompt name (params.name)
305    pub fn detail(&self) -> Option<String> {
306        let params = self.first_params()?;
307        let method = self.mcp_method();
308        match method {
309            McpMethod::ToolsCall => params.get("name")?.as_str().map(String::from),
310            McpMethod::ResourcesRead => params.get("uri")?.as_str().map(String::from),
311            McpMethod::PromptsGet => params.get("name")?.as_str().map(String::from),
312            _ => None,
313        }
314    }
315
316    /// Get the raw params from the first request/notification.
317    pub fn first_params(&self) -> Option<&Value> {
318        self.messages.iter().find_map(|m| match m {
319            JsonRpcMessage::Request(r) => r.params.as_ref(),
320            JsonRpcMessage::Notification(n) => n.params.as_ref(),
321            _ => None,
322        })
323    }
324}
325
326/// Parse a POST body as JSON-RPC 2.0 — single message or batch.
327/// Returns `None` if the body is not valid JSON-RPC 2.0.
328pub fn parse_body(body: &[u8]) -> Option<ParsedBody> {
329    let value: Value = serde_json::from_slice(body).ok()?;
330
331    if let Some(arr) = value.as_array() {
332        // Batch: array of JSON-RPC messages
333        let messages: Vec<_> = arr.iter().filter_map(parse_message).collect();
334        if messages.is_empty() {
335            return None;
336        }
337        Some(ParsedBody {
338            messages,
339            is_batch: true,
340        })
341    } else {
342        // Single message
343        let msg = parse_message(&value)?;
344        Some(ParsedBody {
345            messages: vec![msg],
346            is_batch: false,
347        })
348    }
349}
350
351// ── Tests ──
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use serde_json::json;
357
358    // ── parse_message ──
359
360    #[test]
361    fn parse_request() {
362        let val = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "get_weather"}});
363        let msg = parse_message(&val).unwrap();
364        match msg {
365            JsonRpcMessage::Request(r) => {
366                assert_eq!(r.id, JsonRpcId::Number(1));
367                assert_eq!(r.method, "tools/call");
368                assert!(r.params.is_some());
369            }
370            _ => panic!("expected Request"),
371        }
372    }
373
374    #[test]
375    fn parse_request_string_id() {
376        let val = json!({"jsonrpc": "2.0", "id": "abc-123", "method": "initialize"});
377        let msg = parse_message(&val).unwrap();
378        match msg {
379            JsonRpcMessage::Request(r) => {
380                assert_eq!(r.id, JsonRpcId::String("abc-123".into()));
381                assert_eq!(r.method, "initialize");
382            }
383            _ => panic!("expected Request"),
384        }
385    }
386
387    #[test]
388    fn parse_notification() {
389        let val = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
390        let msg = parse_message(&val).unwrap();
391        match msg {
392            JsonRpcMessage::Notification(n) => {
393                assert_eq!(n.method, "notifications/initialized");
394                assert!(n.params.is_none());
395            }
396            _ => panic!("expected Notification"),
397        }
398    }
399
400    #[test]
401    fn parse_response_result() {
402        let val = json!({"jsonrpc": "2.0", "id": 1, "result": {"tools": []}});
403        let msg = parse_message(&val).unwrap();
404        match msg {
405            JsonRpcMessage::Response(r) => {
406                assert_eq!(r.id, JsonRpcId::Number(1));
407                assert!(r.result.is_some());
408                assert!(r.error.is_none());
409            }
410            _ => panic!("expected Response"),
411        }
412    }
413
414    #[test]
415    fn parse_response_error() {
416        let val = json!({"jsonrpc": "2.0", "id": 1, "error": {"code": -32601, "message": "Method not found"}});
417        let msg = parse_message(&val).unwrap();
418        match msg {
419            JsonRpcMessage::Response(r) => {
420                assert_eq!(r.id, JsonRpcId::Number(1));
421                assert!(r.result.is_none());
422                let err = r.error.unwrap();
423                assert_eq!(err.code, -32601);
424                assert_eq!(err.message, "Method not found");
425            }
426            _ => panic!("expected Response"),
427        }
428    }
429
430    #[test]
431    fn reject_wrong_jsonrpc_version() {
432        let val = json!({"jsonrpc": "1.0", "id": 1, "method": "test"});
433        assert!(parse_message(&val).is_none());
434    }
435
436    #[test]
437    fn reject_missing_jsonrpc() {
438        let val = json!({"id": 1, "method": "test"});
439        assert!(parse_message(&val).is_none());
440    }
441
442    #[test]
443    fn reject_no_method_no_id() {
444        let val = json!({"jsonrpc": "2.0"});
445        assert!(parse_message(&val).is_none());
446    }
447
448    #[test]
449    fn reject_non_object() {
450        let val = json!("hello");
451        assert!(parse_message(&val).is_none());
452    }
453
454    #[test]
455    fn reject_oauth_register_body() {
456        // OAuth dynamic client registration — valid JSON, but not JSON-RPC
457        let val = json!({
458            "client_name": "My App",
459            "redirect_uris": ["https://example.com/callback"],
460            "grant_types": ["authorization_code"]
461        });
462        assert!(parse_message(&val).is_none());
463    }
464
465    // ── parse_body ──
466
467    #[test]
468    fn parse_single_request() {
469        let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
470        let parsed = parse_body(body).unwrap();
471        assert!(!parsed.is_batch);
472        assert_eq!(parsed.messages.len(), 1);
473        assert_eq!(parsed.method_str(), "tools/list");
474        assert_eq!(parsed.mcp_method(), McpMethod::ToolsList);
475    }
476
477    #[test]
478    fn parse_batch_requests() {
479        let body = br#"[
480            {"jsonrpc":"2.0","id":1,"method":"tools/list"},
481            {"jsonrpc":"2.0","id":2,"method":"resources/list"}
482        ]"#;
483        let parsed = parse_body(body).unwrap();
484        assert!(parsed.is_batch);
485        assert_eq!(parsed.messages.len(), 2);
486        // method_str returns first request's method
487        assert_eq!(parsed.method_str(), "tools/list");
488    }
489
490    #[test]
491    fn parse_notification_only() {
492        let body = br#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
493        let parsed = parse_body(body).unwrap();
494        assert!(parsed.is_notification_only());
495        assert_eq!(parsed.mcp_method(), McpMethod::Initialized);
496    }
497
498    #[test]
499    fn parse_mixed_batch() {
500        let body = br#"[
501            {"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}},
502            {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_weather"}}
503        ]"#;
504        let parsed = parse_body(body).unwrap();
505        assert!(parsed.is_batch);
506        assert!(!parsed.is_notification_only());
507        // first_request_id skips the notification, finds the request
508        assert_eq!(parsed.first_request_id(), Some(&JsonRpcId::Number(2)));
509    }
510
511    #[test]
512    fn reject_empty_batch() {
513        let body = b"[]";
514        assert!(parse_body(body).is_none());
515    }
516
517    #[test]
518    fn reject_invalid_json() {
519        assert!(parse_body(b"not json").is_none());
520    }
521
522    #[test]
523    fn reject_non_jsonrpc_json() {
524        // Valid JSON but not JSON-RPC — e.g. an OAuth token request body
525        let body = br#"{"grant_type":"client_credentials","client_id":"abc"}"#;
526        assert!(parse_body(body).is_none());
527    }
528
529    #[test]
530    fn reject_batch_of_non_jsonrpc() {
531        let body = br#"[{"foo":"bar"},{"baz":1}]"#;
532        assert!(parse_body(body).is_none());
533    }
534
535    // ── McpMethod ──
536
537    #[test]
538    fn mcp_method_known() {
539        assert_eq!(McpMethod::parse("initialize"), McpMethod::Initialize);
540        assert_eq!(McpMethod::parse("tools/call"), McpMethod::ToolsCall);
541        assert_eq!(McpMethod::parse("tools/list"), McpMethod::ToolsList);
542        assert_eq!(McpMethod::parse("resources/read"), McpMethod::ResourcesRead);
543        assert_eq!(McpMethod::parse("resources/list"), McpMethod::ResourcesList);
544        assert_eq!(McpMethod::parse("prompts/list"), McpMethod::PromptsList);
545        assert_eq!(McpMethod::parse("prompts/get"), McpMethod::PromptsGet);
546        assert_eq!(McpMethod::parse("ping"), McpMethod::Ping);
547        assert_eq!(
548            McpMethod::parse("logging/setLevel"),
549            McpMethod::LoggingSetLevel
550        );
551        assert_eq!(
552            McpMethod::parse("completion/complete"),
553            McpMethod::CompletionComplete
554        );
555    }
556
557    #[test]
558    fn mcp_method_notification() {
559        assert_eq!(
560            McpMethod::parse("notifications/initialized"),
561            McpMethod::Initialized
562        );
563        assert_eq!(
564            McpMethod::parse("notifications/cancelled"),
565            McpMethod::Notification("notifications/cancelled".into())
566        );
567        assert_eq!(
568            McpMethod::parse("notifications/resources/updated"),
569            McpMethod::Notification("notifications/resources/updated".into())
570        );
571    }
572
573    #[test]
574    fn mcp_method_unknown() {
575        assert_eq!(
576            McpMethod::parse("custom/method"),
577            McpMethod::Unknown("custom/method".into())
578        );
579    }
580
581    #[test]
582    fn mcp_method_as_str_roundtrip() {
583        let methods = [
584            "initialize",
585            "notifications/initialized",
586            "ping",
587            "tools/list",
588            "tools/call",
589            "resources/list",
590            "resources/read",
591            "prompts/list",
592            "prompts/get",
593            "logging/setLevel",
594            "completion/complete",
595        ];
596        for m in methods {
597            assert_eq!(McpMethod::parse(m).as_str(), m);
598        }
599    }
600
601    // ── JsonRpcId display ──
602
603    #[test]
604    fn id_display() {
605        assert_eq!(JsonRpcId::Number(42).to_string(), "42");
606        assert_eq!(JsonRpcId::String("abc".into()).to_string(), "abc");
607    }
608
609    // ── ParsedBody helpers ──
610
611    #[test]
612    fn first_params_from_request() {
613        let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo"}}"#;
614        let parsed = parse_body(body).unwrap();
615        let params = parsed.first_params().unwrap();
616        assert_eq!(params["name"], "echo");
617    }
618
619    #[test]
620    fn first_params_none_for_response() {
621        let body = br#"{"jsonrpc":"2.0","id":1,"result":{}}"#;
622        let parsed = parse_body(body).unwrap();
623        assert!(parsed.first_params().is_none());
624    }
625
626    #[test]
627    fn method_str_defaults_to_unknown_for_responses() {
628        let body = br#"{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}"#;
629        let parsed = parse_body(body).unwrap();
630        assert_eq!(parsed.method_str(), "unknown");
631    }
632
633    // ── ParsedBody::detail ──
634
635    #[test]
636    fn detail_tools_call() {
637        let body =
638            br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_weather"}}"#;
639        let parsed = parse_body(body).unwrap();
640        assert_eq!(parsed.detail().as_deref(), Some("get_weather"));
641    }
642
643    #[test]
644    fn detail_resources_read() {
645        let body = br#"{"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"ui://widget/clock.html"}}"#;
646        let parsed = parse_body(body).unwrap();
647        assert_eq!(parsed.detail().as_deref(), Some("ui://widget/clock.html"));
648    }
649
650    #[test]
651    fn detail_none_for_tools_list() {
652        let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
653        let parsed = parse_body(body).unwrap();
654        assert!(parsed.detail().is_none());
655    }
656
657    // ── error_code ──
658
659    #[test]
660    fn error_code_labels() {
661        assert_eq!(error_code::label(error_code::PARSE_ERROR), "Parse error");
662        assert_eq!(
663            error_code::label(error_code::METHOD_NOT_FOUND),
664            "Method not found"
665        );
666        assert_eq!(
667            error_code::label(error_code::INVALID_PARAMS),
668            "Invalid params"
669        );
670        assert_eq!(
671            error_code::label(error_code::INTERNAL_ERROR),
672            "Internal error"
673        );
674        assert_eq!(error_code::label(-32000), "Server error");
675        assert_eq!(error_code::label(-32099), "Server error");
676        assert_eq!(error_code::label(42), "Unknown error");
677    }
678
679    // ── error_response ──
680
681    #[test]
682    fn error_response_with_numeric_id() {
683        let body = error_response(&json!(1), error_code::METHOD_NOT_FOUND, "Method not found");
684        let parsed: Value = serde_json::from_slice(&body).unwrap();
685        assert_eq!(parsed["jsonrpc"], "2.0");
686        assert_eq!(parsed["id"], 1);
687        assert_eq!(parsed["error"]["code"], -32601);
688        assert_eq!(parsed["error"]["message"], "Method not found");
689    }
690
691    #[test]
692    fn error_response_with_null_id() {
693        let body = error_response(&Value::Null, error_code::PARSE_ERROR, "Parse error");
694        let parsed: Value = serde_json::from_slice(&body).unwrap();
695        assert_eq!(parsed["id"], Value::Null);
696        assert_eq!(parsed["error"]["code"], -32700);
697    }
698
699    // ── extract_error_code ──
700
701    #[test]
702    fn extract_error_from_response() {
703        let val = json!({"jsonrpc": "2.0", "id": 1, "error": {"code": -32601, "message": "Method not found"}});
704        let (code, msg) = extract_error_code(&val).unwrap();
705        assert_eq!(code, -32601);
706        assert_eq!(msg, "Method not found");
707    }
708
709    #[test]
710    fn extract_no_error_from_success() {
711        let val = json!({"jsonrpc": "2.0", "id": 1, "result": {"tools": []}});
712        assert!(extract_error_code(&val).is_none());
713    }
714}