Skip to main content

mcpr_core/protocol/
mod.rs

1//! # mcpr-protocol
2//!
3//! MCP specification layer: JSON-RPC 2.0 parsing, MCP method classification,
4//! and session lifecycle management.
5//!
6//! This crate is the foundation of the mcpr workspace. It contains everything
7//! related to understanding the MCP protocol itself, with zero coupling to
8//! HTTP frameworks or proxy logic.
9//!
10//! ## Responsibilities
11//!
12//! - **JSON-RPC 2.0 parsing** (`lib.rs`): Parse and classify JSON-RPC 2.0
13//!   messages (requests, notifications, responses). Provides `ParsedBody` for
14//!   batch-aware parsing and `McpMethod` for typed MCP method discrimination.
15//!
16//! - **MCP method constants**: Single source of truth for MCP method strings
17//!   (`initialize`, `tools/call`, `resources/read`, etc.).
18//!
19//! - **Error handling**: JSON-RPC error codes, error response builders, and
20//!   error extraction from response bodies.
21//!
22//! - **Session management** (`session` module): MCP session state machine
23//!   (`Created -> Initialized -> Active -> Closed`), `SessionStore` trait for
24//!   pluggable storage backends, and `MemorySessionStore` for in-memory use.
25//!
26//! - **Schema capture** (`schema` module): Types and logic for passively
27//!   capturing MCP server schemas from discovery responses. Includes pagination
28//!   detection/merging, schema diffing (detect added/removed/modified tools,
29//!   resources, prompts), and schema method classification.
30//!
31//! ## Module Structure
32//!
33//! ```text
34//! mcpr-protocol/src/
35//! +-- lib.rs          # JSON-RPC 2.0 types, parsing, MCP method classification
36//! +-- schema.rs       # Schema capture types, pagination merge, diff logic
37//! +-- session.rs      # Session state, SessionStore trait, MemorySessionStore
38//! ```
39//!
40//! ## Dependencies
41//!
42//! Minimal: `serde`, `serde_json`, `chrono`, `dashmap`. No HTTP framework deps.
43
44pub mod schema;
45pub mod session;
46
47use serde_json::Value;
48
49// ── JSON-RPC 2.0 types ──
50
51/// A parsed JSON-RPC 2.0 id (number or string).
52#[derive(Debug, Clone, PartialEq)]
53pub enum JsonRpcId {
54    Number(i64),
55    String(String),
56}
57
58impl std::fmt::Display for JsonRpcId {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            Self::Number(n) => write!(f, "{n}"),
62            Self::String(s) => write!(f, "{s}"),
63        }
64    }
65}
66
67/// A classified JSON-RPC 2.0 message.
68#[derive(Debug)]
69pub enum JsonRpcMessage {
70    /// Has `method` + `id` → expects a response.
71    Request(JsonRpcRequest),
72    /// Has `method`, no `id` → fire-and-forget.
73    Notification(JsonRpcNotification),
74    /// Has `id` + `result`/`error` → reply to a prior request.
75    Response(JsonRpcResponse),
76}
77
78#[derive(Debug)]
79pub struct JsonRpcRequest {
80    pub id: JsonRpcId,
81    pub method: String,
82    pub params: Option<Value>,
83}
84
85#[derive(Debug)]
86pub struct JsonRpcNotification {
87    pub method: String,
88    pub params: Option<Value>,
89}
90
91#[derive(Debug)]
92pub struct JsonRpcResponse {
93    pub id: JsonRpcId,
94    pub result: Option<Value>,
95    pub error: Option<JsonRpcError>,
96}
97
98#[derive(Debug)]
99pub struct JsonRpcError {
100    pub code: i64,
101    pub message: String,
102    pub data: Option<Value>,
103}
104
105// ── JSON-RPC 2.0 error codes (spec: https://www.jsonrpc.org/specification#error_object) ──
106
107pub mod error_code {
108    /// Invalid JSON was received.
109    pub const PARSE_ERROR: i64 = -32700;
110    /// The JSON sent is not a valid Request object.
111    pub const INVALID_REQUEST: i64 = -32600;
112    /// The method does not exist / is not available.
113    pub const METHOD_NOT_FOUND: i64 = -32601;
114    /// Invalid method parameter(s).
115    pub const INVALID_PARAMS: i64 = -32602;
116    /// Internal JSON-RPC error.
117    pub const INTERNAL_ERROR: i64 = -32603;
118
119    /// Short label for known error codes.
120    pub fn label(code: i64) -> &'static str {
121        match code {
122            PARSE_ERROR => "Parse error",
123            INVALID_REQUEST => "Invalid request",
124            METHOD_NOT_FOUND => "Method not found",
125            INVALID_PARAMS => "Invalid params",
126            INTERNAL_ERROR => "Internal error",
127            -32099..=-32000 => "Server error",
128            _ => "Unknown error",
129        }
130    }
131}
132
133// ── JSON-RPC error response builder ──
134
135/// Build a JSON-RPC 2.0 error response as bytes.
136/// `id` can be a request id or `Value::Null` if the request id couldn't be parsed.
137pub fn error_response(id: &Value, code: i64, message: &str) -> Vec<u8> {
138    let resp = serde_json::json!({
139        "jsonrpc": "2.0",
140        "id": id,
141        "error": {
142            "code": code,
143            "message": message,
144        }
145    });
146    serde_json::to_vec(&resp).unwrap_or_default()
147}
148
149/// Extract the JSON-RPC error code from a response body (if it's an error response).
150pub fn extract_error_code(body: &Value) -> Option<(i64, &str)> {
151    let err = body.get("error")?;
152    let code = err.get("code")?.as_i64()?;
153    let message = err.get("message")?.as_str()?;
154    Some((code, message))
155}
156
157// ── MCP method classification ──
158
159/// Known MCP methods — lets the proxy know exactly what function is being called.
160#[derive(Debug, Clone, PartialEq)]
161pub enum McpMethod {
162    Initialize,
163    Initialized,
164    Ping,
165    ToolsList,
166    ToolsCall,
167    ResourcesList,
168    ResourcesRead,
169    ResourcesTemplatesList,
170    ResourcesSubscribe,
171    ResourcesUnsubscribe,
172    PromptsList,
173    PromptsGet,
174    LoggingSetLevel,
175    CompletionComplete,
176    NotificationsToolsListChanged,
177    NotificationsCancelled,
178    NotificationsProgress,
179    /// Any `notifications/*` we don't have a specific variant for.
180    Notification(String),
181    /// Anything else.
182    Unknown(String),
183}
184
185// MCP method name constants — single source of truth for string matching.
186pub const INITIALIZE: &str = "initialize";
187pub const INITIALIZED: &str = "notifications/initialized";
188pub const PING: &str = "ping";
189pub const TOOLS_LIST: &str = "tools/list";
190pub const TOOLS_CALL: &str = "tools/call";
191pub const RESOURCES_LIST: &str = "resources/list";
192pub const RESOURCES_READ: &str = "resources/read";
193pub const RESOURCES_SUBSCRIBE: &str = "resources/subscribe";
194pub const RESOURCES_UNSUBSCRIBE: &str = "resources/unsubscribe";
195pub const PROMPTS_LIST: &str = "prompts/list";
196pub const PROMPTS_GET: &str = "prompts/get";
197pub const LOGGING_SET_LEVEL: &str = "logging/setLevel";
198pub const COMPLETION_COMPLETE: &str = "completion/complete";
199pub const RESOURCES_TEMPLATES_LIST: &str = "resources/templates/list";
200pub const NOTIFICATIONS_TOOLS_LIST_CHANGED: &str = "notifications/tools/list_changed";
201pub const NOTIFICATIONS_CANCELLED: &str = "notifications/cancelled";
202pub const NOTIFICATIONS_PROGRESS: &str = "notifications/progress";
203
204impl McpMethod {
205    pub fn parse(method: &str) -> Self {
206        match method {
207            INITIALIZE => Self::Initialize,
208            INITIALIZED => Self::Initialized,
209            PING => Self::Ping,
210            TOOLS_LIST => Self::ToolsList,
211            TOOLS_CALL => Self::ToolsCall,
212            RESOURCES_LIST => Self::ResourcesList,
213            RESOURCES_READ => Self::ResourcesRead,
214            RESOURCES_TEMPLATES_LIST => Self::ResourcesTemplatesList,
215            RESOURCES_SUBSCRIBE => Self::ResourcesSubscribe,
216            RESOURCES_UNSUBSCRIBE => Self::ResourcesUnsubscribe,
217            PROMPTS_LIST => Self::PromptsList,
218            PROMPTS_GET => Self::PromptsGet,
219            LOGGING_SET_LEVEL => Self::LoggingSetLevel,
220            COMPLETION_COMPLETE => Self::CompletionComplete,
221            NOTIFICATIONS_TOOLS_LIST_CHANGED => Self::NotificationsToolsListChanged,
222            NOTIFICATIONS_CANCELLED => Self::NotificationsCancelled,
223            NOTIFICATIONS_PROGRESS => Self::NotificationsProgress,
224            m if m.starts_with("notifications/") => Self::Notification(m.to_string()),
225            m => Self::Unknown(m.to_string()),
226        }
227    }
228
229    /// Short label for logging (e.g. "tools/call", "initialize").
230    pub fn as_str(&self) -> &str {
231        match self {
232            Self::Initialize => INITIALIZE,
233            Self::Initialized => INITIALIZED,
234            Self::Ping => PING,
235            Self::ToolsList => TOOLS_LIST,
236            Self::ToolsCall => TOOLS_CALL,
237            Self::ResourcesList => RESOURCES_LIST,
238            Self::ResourcesRead => RESOURCES_READ,
239            Self::ResourcesTemplatesList => RESOURCES_TEMPLATES_LIST,
240            Self::ResourcesSubscribe => RESOURCES_SUBSCRIBE,
241            Self::ResourcesUnsubscribe => RESOURCES_UNSUBSCRIBE,
242            Self::PromptsList => PROMPTS_LIST,
243            Self::PromptsGet => PROMPTS_GET,
244            Self::LoggingSetLevel => LOGGING_SET_LEVEL,
245            Self::CompletionComplete => COMPLETION_COMPLETE,
246            Self::NotificationsToolsListChanged => NOTIFICATIONS_TOOLS_LIST_CHANGED,
247            Self::NotificationsCancelled => NOTIFICATIONS_CANCELLED,
248            Self::NotificationsProgress => NOTIFICATIONS_PROGRESS,
249            Self::Notification(m) => m.as_str(),
250            Self::Unknown(m) => m.as_str(),
251        }
252    }
253}
254
255// ── Parsing ──
256
257/// Parse a JSON-RPC id value.
258fn parse_id(value: &Value) -> Option<JsonRpcId> {
259    match value {
260        Value::Number(n) => n.as_i64().map(JsonRpcId::Number),
261        Value::String(s) => Some(JsonRpcId::String(s.clone())),
262        _ => None,
263    }
264}
265
266/// Parse a JSON-RPC error object.
267fn parse_error(value: &Value) -> Option<JsonRpcError> {
268    let obj = value.as_object()?;
269    Some(JsonRpcError {
270        code: obj.get("code")?.as_i64()?,
271        message: obj.get("message")?.as_str()?.to_string(),
272        data: obj.get("data").cloned(),
273    })
274}
275
276/// Parse a single JSON value as a JSON-RPC 2.0 message.
277/// Returns `None` if it doesn't have `"jsonrpc": "2.0"`.
278pub fn parse_message(value: &Value) -> Option<JsonRpcMessage> {
279    let obj = value.as_object()?;
280
281    // Must be JSON-RPC 2.0
282    if obj.get("jsonrpc")?.as_str()? != "2.0" {
283        return None;
284    }
285
286    let id = obj.get("id").and_then(parse_id);
287    let method = obj.get("method").and_then(|m| m.as_str()).map(String::from);
288    let params = obj.get("params").cloned();
289
290    match (method, id) {
291        // Has method + id → Request
292        (Some(method), Some(id)) => Some(JsonRpcMessage::Request(JsonRpcRequest {
293            id,
294            method,
295            params,
296        })),
297        // Has method, no id → Notification
298        (Some(method), None) => Some(JsonRpcMessage::Notification(JsonRpcNotification {
299            method,
300            params,
301        })),
302        // No method, has id → Response
303        (None, Some(id)) => {
304            let result = obj.get("result").cloned();
305            let error = obj.get("error").and_then(parse_error);
306            Some(JsonRpcMessage::Response(JsonRpcResponse {
307                id,
308                result,
309                error,
310            }))
311        }
312        // No method, no id → invalid
313        (None, None) => None,
314    }
315}
316
317/// Result of parsing a POST body as JSON-RPC 2.0.
318#[derive(Debug)]
319pub struct ParsedBody {
320    pub messages: Vec<JsonRpcMessage>,
321    pub is_batch: bool,
322}
323
324impl ParsedBody {
325    /// Get the method string from the first request or notification.
326    /// Falls back to "unknown" if the batch contains only responses.
327    pub fn method_str(&self) -> &str {
328        self.messages
329            .iter()
330            .find_map(|m| match m {
331                JsonRpcMessage::Request(r) => Some(r.method.as_str()),
332                JsonRpcMessage::Notification(n) => Some(n.method.as_str()),
333                _ => None,
334            })
335            .unwrap_or("unknown")
336    }
337
338    /// Get the MCP method classification from the first request/notification.
339    pub fn mcp_method(&self) -> McpMethod {
340        McpMethod::parse(self.method_str())
341    }
342
343    /// Get the id of the first request (if any).
344    pub fn first_request_id(&self) -> Option<&JsonRpcId> {
345        self.messages.iter().find_map(|m| match m {
346            JsonRpcMessage::Request(r) => Some(&r.id),
347            _ => None,
348        })
349    }
350
351    /// True if every message is a notification (no id, no response expected).
352    pub fn is_notification_only(&self) -> bool {
353        self.messages
354            .iter()
355            .all(|m| matches!(m, JsonRpcMessage::Notification(_)))
356    }
357
358    /// Extract a short detail string for logging:
359    /// - tools/call → tool name (params.name)
360    /// - resources/read → resource URI (params.uri)
361    /// - prompts/get → prompt name (params.name)
362    pub fn detail(&self) -> Option<String> {
363        let params = self.first_params()?;
364        let method = self.mcp_method();
365        match method {
366            McpMethod::ToolsCall => params.get("name")?.as_str().map(String::from),
367            McpMethod::ResourcesRead => params.get("uri")?.as_str().map(String::from),
368            McpMethod::PromptsGet => params.get("name")?.as_str().map(String::from),
369            McpMethod::NotificationsCancelled => {
370                // requestId can be string or number
371                params.get("requestId").map(|v| match v {
372                    Value::String(s) => s.clone(),
373                    Value::Number(n) => n.to_string(),
374                    _ => v.to_string(),
375                })
376            }
377            McpMethod::NotificationsProgress => {
378                // progressToken can be string or number
379                params.get("progressToken").map(|v| match v {
380                    Value::String(s) => s.clone(),
381                    Value::Number(n) => n.to_string(),
382                    _ => v.to_string(),
383                })
384            }
385            _ => None,
386        }
387    }
388
389    /// Get the raw params from the first request/notification.
390    pub fn first_params(&self) -> Option<&Value> {
391        self.messages.iter().find_map(|m| match m {
392            JsonRpcMessage::Request(r) => r.params.as_ref(),
393            JsonRpcMessage::Notification(n) => n.params.as_ref(),
394            _ => None,
395        })
396    }
397}
398
399/// Parse a POST body as JSON-RPC 2.0 — single message or batch.
400/// Returns `None` if the body is not valid JSON-RPC 2.0.
401pub fn parse_body(body: &[u8]) -> Option<ParsedBody> {
402    let value: Value = serde_json::from_slice(body).ok()?;
403
404    if let Some(arr) = value.as_array() {
405        // Batch: array of JSON-RPC messages
406        let messages: Vec<_> = arr.iter().filter_map(parse_message).collect();
407        if messages.is_empty() {
408            return None;
409        }
410        Some(ParsedBody {
411            messages,
412            is_batch: true,
413        })
414    } else {
415        // Single message
416        let msg = parse_message(&value)?;
417        Some(ParsedBody {
418            messages: vec![msg],
419            is_batch: false,
420        })
421    }
422}
423
424// ── Tests ──
425
426#[cfg(test)]
427#[allow(non_snake_case)]
428mod tests {
429    use super::*;
430    use serde_json::json;
431
432    // ── parse_message ──
433
434    #[test]
435    fn parse_message__request() {
436        let val = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "get_weather"}});
437        let msg = parse_message(&val).unwrap();
438        match msg {
439            JsonRpcMessage::Request(r) => {
440                assert_eq!(r.id, JsonRpcId::Number(1));
441                assert_eq!(r.method, "tools/call");
442                assert!(r.params.is_some());
443            }
444            _ => panic!("expected Request"),
445        }
446    }
447
448    #[test]
449    fn parse_message__request_string_id() {
450        let val = json!({"jsonrpc": "2.0", "id": "abc-123", "method": "initialize"});
451        let msg = parse_message(&val).unwrap();
452        match msg {
453            JsonRpcMessage::Request(r) => {
454                assert_eq!(r.id, JsonRpcId::String("abc-123".into()));
455                assert_eq!(r.method, "initialize");
456            }
457            _ => panic!("expected Request"),
458        }
459    }
460
461    #[test]
462    fn parse_message__notification() {
463        let val = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
464        let msg = parse_message(&val).unwrap();
465        match msg {
466            JsonRpcMessage::Notification(n) => {
467                assert_eq!(n.method, "notifications/initialized");
468                assert!(n.params.is_none());
469            }
470            _ => panic!("expected Notification"),
471        }
472    }
473
474    #[test]
475    fn parse_message__response_result() {
476        let val = json!({"jsonrpc": "2.0", "id": 1, "result": {"tools": []}});
477        let msg = parse_message(&val).unwrap();
478        match msg {
479            JsonRpcMessage::Response(r) => {
480                assert_eq!(r.id, JsonRpcId::Number(1));
481                assert!(r.result.is_some());
482                assert!(r.error.is_none());
483            }
484            _ => panic!("expected Response"),
485        }
486    }
487
488    #[test]
489    fn parse_message__response_error() {
490        let val = json!({"jsonrpc": "2.0", "id": 1, "error": {"code": -32601, "message": "Method not found"}});
491        let msg = parse_message(&val).unwrap();
492        match msg {
493            JsonRpcMessage::Response(r) => {
494                assert_eq!(r.id, JsonRpcId::Number(1));
495                assert!(r.result.is_none());
496                let err = r.error.unwrap();
497                assert_eq!(err.code, -32601);
498                assert_eq!(err.message, "Method not found");
499            }
500            _ => panic!("expected Response"),
501        }
502    }
503
504    #[test]
505    fn parse_message__rejects_wrong_version() {
506        let val = json!({"jsonrpc": "1.0", "id": 1, "method": "test"});
507        assert!(parse_message(&val).is_none());
508    }
509
510    #[test]
511    fn parse_message__rejects_missing_jsonrpc() {
512        let val = json!({"id": 1, "method": "test"});
513        assert!(parse_message(&val).is_none());
514    }
515
516    #[test]
517    fn parse_message__rejects_no_method_no_id() {
518        let val = json!({"jsonrpc": "2.0"});
519        assert!(parse_message(&val).is_none());
520    }
521
522    #[test]
523    fn parse_message__rejects_non_object() {
524        let val = json!("hello");
525        assert!(parse_message(&val).is_none());
526    }
527
528    #[test]
529    fn parse_message__rejects_oauth_register() {
530        let val = json!({
531            "client_name": "My App",
532            "redirect_uris": ["https://example.com/callback"],
533            "grant_types": ["authorization_code"]
534        });
535        assert!(parse_message(&val).is_none());
536    }
537
538    // ── parse_body ──
539
540    #[test]
541    fn parse_body__single_request() {
542        let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
543        let parsed = parse_body(body).unwrap();
544        assert!(!parsed.is_batch);
545        assert_eq!(parsed.messages.len(), 1);
546        assert_eq!(parsed.method_str(), "tools/list");
547        assert_eq!(parsed.mcp_method(), McpMethod::ToolsList);
548    }
549
550    #[test]
551    fn parse_body__batch_requests() {
552        let body = br#"[
553            {"jsonrpc":"2.0","id":1,"method":"tools/list"},
554            {"jsonrpc":"2.0","id":2,"method":"resources/list"}
555        ]"#;
556        let parsed = parse_body(body).unwrap();
557        assert!(parsed.is_batch);
558        assert_eq!(parsed.messages.len(), 2);
559        assert_eq!(parsed.method_str(), "tools/list");
560    }
561
562    #[test]
563    fn parse_body__notification_only() {
564        let body = br#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
565        let parsed = parse_body(body).unwrap();
566        assert!(parsed.is_notification_only());
567        assert_eq!(parsed.mcp_method(), McpMethod::Initialized);
568    }
569
570    #[test]
571    fn parse_body__mixed_batch() {
572        let body = br#"[
573            {"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}},
574            {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_weather"}}
575        ]"#;
576        let parsed = parse_body(body).unwrap();
577        assert!(parsed.is_batch);
578        assert!(!parsed.is_notification_only());
579        assert_eq!(parsed.first_request_id(), Some(&JsonRpcId::Number(2)));
580    }
581
582    #[test]
583    fn parse_body__rejects_empty_batch() {
584        let body = b"[]";
585        assert!(parse_body(body).is_none());
586    }
587
588    #[test]
589    fn parse_body__rejects_invalid_json() {
590        assert!(parse_body(b"not json").is_none());
591    }
592
593    #[test]
594    fn parse_body__rejects_non_jsonrpc() {
595        let body = br#"{"grant_type":"client_credentials","client_id":"abc"}"#;
596        assert!(parse_body(body).is_none());
597    }
598
599    #[test]
600    fn parse_body__rejects_batch_of_non_jsonrpc() {
601        let body = br#"[{"foo":"bar"},{"baz":1}]"#;
602        assert!(parse_body(body).is_none());
603    }
604
605    // ── McpMethod ──
606
607    #[test]
608    fn mcp_method__known_methods() {
609        assert_eq!(McpMethod::parse("initialize"), McpMethod::Initialize);
610        assert_eq!(McpMethod::parse("tools/call"), McpMethod::ToolsCall);
611        assert_eq!(McpMethod::parse("tools/list"), McpMethod::ToolsList);
612        assert_eq!(McpMethod::parse("resources/read"), McpMethod::ResourcesRead);
613        assert_eq!(McpMethod::parse("resources/list"), McpMethod::ResourcesList);
614        assert_eq!(
615            McpMethod::parse("resources/templates/list"),
616            McpMethod::ResourcesTemplatesList
617        );
618        assert_eq!(McpMethod::parse("prompts/list"), McpMethod::PromptsList);
619        assert_eq!(McpMethod::parse("prompts/get"), McpMethod::PromptsGet);
620        assert_eq!(McpMethod::parse("ping"), McpMethod::Ping);
621        assert_eq!(
622            McpMethod::parse("logging/setLevel"),
623            McpMethod::LoggingSetLevel
624        );
625        assert_eq!(
626            McpMethod::parse("completion/complete"),
627            McpMethod::CompletionComplete
628        );
629        assert_eq!(
630            McpMethod::parse("notifications/tools/list_changed"),
631            McpMethod::NotificationsToolsListChanged
632        );
633        assert_eq!(
634            McpMethod::parse("notifications/cancelled"),
635            McpMethod::NotificationsCancelled
636        );
637        assert_eq!(
638            McpMethod::parse("notifications/progress"),
639            McpMethod::NotificationsProgress
640        );
641    }
642
643    #[test]
644    fn mcp_method__notifications() {
645        assert_eq!(
646            McpMethod::parse("notifications/initialized"),
647            McpMethod::Initialized
648        );
649        // Known notification variants are parsed to specific enum values
650        assert_eq!(
651            McpMethod::parse("notifications/cancelled"),
652            McpMethod::NotificationsCancelled
653        );
654        assert_eq!(
655            McpMethod::parse("notifications/progress"),
656            McpMethod::NotificationsProgress
657        );
658        assert_eq!(
659            McpMethod::parse("notifications/tools/list_changed"),
660            McpMethod::NotificationsToolsListChanged
661        );
662        // Unknown notifications still fall through to the generic variant
663        assert_eq!(
664            McpMethod::parse("notifications/resources/updated"),
665            McpMethod::Notification("notifications/resources/updated".into())
666        );
667    }
668
669    #[test]
670    fn mcp_method__unknown() {
671        assert_eq!(
672            McpMethod::parse("custom/method"),
673            McpMethod::Unknown("custom/method".into())
674        );
675    }
676
677    #[test]
678    fn mcp_method__as_str_roundtrip() {
679        let methods = [
680            "initialize",
681            "notifications/initialized",
682            "ping",
683            "tools/list",
684            "tools/call",
685            "resources/list",
686            "resources/read",
687            "resources/templates/list",
688            "prompts/list",
689            "prompts/get",
690            "logging/setLevel",
691            "completion/complete",
692            "notifications/tools/list_changed",
693            "notifications/cancelled",
694            "notifications/progress",
695        ];
696        for m in methods {
697            assert_eq!(McpMethod::parse(m).as_str(), m);
698        }
699    }
700
701    // ── JsonRpcId display ──
702
703    #[test]
704    fn jsonrpc_id__display() {
705        assert_eq!(JsonRpcId::Number(42).to_string(), "42");
706        assert_eq!(JsonRpcId::String("abc".into()).to_string(), "abc");
707    }
708
709    // ── ParsedBody helpers ──
710
711    #[test]
712    fn parsed_body__first_params_from_request() {
713        let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo"}}"#;
714        let parsed = parse_body(body).unwrap();
715        let params = parsed.first_params().unwrap();
716        assert_eq!(params["name"], "echo");
717    }
718
719    #[test]
720    fn parsed_body__first_params_none_for_response() {
721        let body = br#"{"jsonrpc":"2.0","id":1,"result":{}}"#;
722        let parsed = parse_body(body).unwrap();
723        assert!(parsed.first_params().is_none());
724    }
725
726    #[test]
727    fn parsed_body__method_str_defaults_to_unknown() {
728        let body = br#"{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}"#;
729        let parsed = parse_body(body).unwrap();
730        assert_eq!(parsed.method_str(), "unknown");
731    }
732
733    // ── ParsedBody::detail ──
734
735    #[test]
736    fn parsed_body__detail_tools_call() {
737        let body =
738            br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_weather"}}"#;
739        let parsed = parse_body(body).unwrap();
740        assert_eq!(parsed.detail().as_deref(), Some("get_weather"));
741    }
742
743    #[test]
744    fn parsed_body__detail_resources_read() {
745        let body = br#"{"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"ui://widget/clock.html"}}"#;
746        let parsed = parse_body(body).unwrap();
747        assert_eq!(parsed.detail().as_deref(), Some("ui://widget/clock.html"));
748    }
749
750    #[test]
751    fn parsed_body__detail_none_for_tools_list() {
752        let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
753        let parsed = parse_body(body).unwrap();
754        assert!(parsed.detail().is_none());
755    }
756
757    #[test]
758    fn parsed_body__detail_notifications_cancelled() {
759        let body = br#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":"req-42","reason":"timeout"}}"#;
760        let parsed = parse_body(body).unwrap();
761        assert_eq!(parsed.detail().as_deref(), Some("req-42"));
762    }
763
764    #[test]
765    fn parsed_body__detail_cancelled_numeric_id() {
766        let body =
767            br#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":7}}"#;
768        let parsed = parse_body(body).unwrap();
769        assert_eq!(parsed.detail().as_deref(), Some("7"));
770    }
771
772    #[test]
773    fn parsed_body__detail_notifications_progress() {
774        let body = br#"{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"tok-1","progress":50,"total":100}}"#;
775        let parsed = parse_body(body).unwrap();
776        assert_eq!(parsed.detail().as_deref(), Some("tok-1"));
777    }
778
779    #[test]
780    fn parsed_body__detail_progress_numeric_token() {
781        let body = br#"{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":99,"progress":10}}"#;
782        let parsed = parse_body(body).unwrap();
783        assert_eq!(parsed.detail().as_deref(), Some("99"));
784    }
785
786    // ── error_code ──
787
788    #[test]
789    fn error_code__labels() {
790        assert_eq!(error_code::label(error_code::PARSE_ERROR), "Parse error");
791        assert_eq!(
792            error_code::label(error_code::METHOD_NOT_FOUND),
793            "Method not found"
794        );
795        assert_eq!(
796            error_code::label(error_code::INVALID_PARAMS),
797            "Invalid params"
798        );
799        assert_eq!(
800            error_code::label(error_code::INTERNAL_ERROR),
801            "Internal error"
802        );
803        assert_eq!(error_code::label(-32000), "Server error");
804        assert_eq!(error_code::label(-32099), "Server error");
805        assert_eq!(error_code::label(42), "Unknown error");
806    }
807
808    // ── error_response ──
809
810    #[test]
811    fn error_response__numeric_id() {
812        let body = error_response(&json!(1), error_code::METHOD_NOT_FOUND, "Method not found");
813        let parsed: Value = serde_json::from_slice(&body).unwrap();
814        assert_eq!(parsed["jsonrpc"], "2.0");
815        assert_eq!(parsed["id"], 1);
816        assert_eq!(parsed["error"]["code"], -32601);
817        assert_eq!(parsed["error"]["message"], "Method not found");
818    }
819
820    #[test]
821    fn error_response__null_id() {
822        let body = error_response(&Value::Null, error_code::PARSE_ERROR, "Parse error");
823        let parsed: Value = serde_json::from_slice(&body).unwrap();
824        assert_eq!(parsed["id"], Value::Null);
825        assert_eq!(parsed["error"]["code"], -32700);
826    }
827
828    // ── extract_error_code ──
829
830    #[test]
831    fn extract_error_code__from_error_response() {
832        let val = json!({"jsonrpc": "2.0", "id": 1, "error": {"code": -32601, "message": "Method not found"}});
833        let (code, msg) = extract_error_code(&val).unwrap();
834        assert_eq!(code, -32601);
835        assert_eq!(msg, "Method not found");
836    }
837
838    #[test]
839    fn extract_error_code__none_for_success() {
840        let val = json!({"jsonrpc": "2.0", "id": 1, "result": {"tools": []}});
841        assert!(extract_error_code(&val).is_none());
842    }
843}