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