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
192impl McpMethod {
193    /// `true` for methods whose responses may need body rewriting (CSP
194    /// injection in `meta`, widget overlay substitution in `contents`).
195    /// Callers use this to pick buffer-vs-stream strategy pre-forward.
196    ///
197    /// Only the five methods that carry `_meta` / widget payloads return
198    /// `true`. Everything else — initialize, ping, notifications, prompts,
199    /// completion, logging — can safely stream.
200    pub fn needs_response_buffering(&self) -> bool {
201        matches!(
202            self,
203            McpMethod::ToolsList
204                | McpMethod::ToolsCall
205                | McpMethod::ResourcesList
206                | McpMethod::ResourcesTemplatesList
207                | McpMethod::ResourcesRead
208        )
209    }
210}
211
212// MCP method name constants — single source of truth for string matching.
213pub const INITIALIZE: &str = "initialize";
214pub const INITIALIZED: &str = "notifications/initialized";
215pub const PING: &str = "ping";
216pub const TOOLS_LIST: &str = "tools/list";
217pub const TOOLS_CALL: &str = "tools/call";
218pub const RESOURCES_LIST: &str = "resources/list";
219pub const RESOURCES_READ: &str = "resources/read";
220pub const RESOURCES_SUBSCRIBE: &str = "resources/subscribe";
221pub const RESOURCES_UNSUBSCRIBE: &str = "resources/unsubscribe";
222pub const PROMPTS_LIST: &str = "prompts/list";
223pub const PROMPTS_GET: &str = "prompts/get";
224pub const LOGGING_SET_LEVEL: &str = "logging/setLevel";
225pub const COMPLETION_COMPLETE: &str = "completion/complete";
226pub const RESOURCES_TEMPLATES_LIST: &str = "resources/templates/list";
227pub const NOTIFICATIONS_TOOLS_LIST_CHANGED: &str = "notifications/tools/list_changed";
228pub const NOTIFICATIONS_CANCELLED: &str = "notifications/cancelled";
229pub const NOTIFICATIONS_PROGRESS: &str = "notifications/progress";
230
231impl McpMethod {
232    pub fn parse(method: &str) -> Self {
233        match method {
234            INITIALIZE => Self::Initialize,
235            INITIALIZED => Self::Initialized,
236            PING => Self::Ping,
237            TOOLS_LIST => Self::ToolsList,
238            TOOLS_CALL => Self::ToolsCall,
239            RESOURCES_LIST => Self::ResourcesList,
240            RESOURCES_READ => Self::ResourcesRead,
241            RESOURCES_TEMPLATES_LIST => Self::ResourcesTemplatesList,
242            RESOURCES_SUBSCRIBE => Self::ResourcesSubscribe,
243            RESOURCES_UNSUBSCRIBE => Self::ResourcesUnsubscribe,
244            PROMPTS_LIST => Self::PromptsList,
245            PROMPTS_GET => Self::PromptsGet,
246            LOGGING_SET_LEVEL => Self::LoggingSetLevel,
247            COMPLETION_COMPLETE => Self::CompletionComplete,
248            NOTIFICATIONS_TOOLS_LIST_CHANGED => Self::NotificationsToolsListChanged,
249            NOTIFICATIONS_CANCELLED => Self::NotificationsCancelled,
250            NOTIFICATIONS_PROGRESS => Self::NotificationsProgress,
251            m if m.starts_with("notifications/") => Self::Notification(m.to_string()),
252            m => Self::Unknown(m.to_string()),
253        }
254    }
255
256    /// Short label for logging (e.g. "tools/call", "initialize").
257    pub fn as_str(&self) -> &str {
258        match self {
259            Self::Initialize => INITIALIZE,
260            Self::Initialized => INITIALIZED,
261            Self::Ping => PING,
262            Self::ToolsList => TOOLS_LIST,
263            Self::ToolsCall => TOOLS_CALL,
264            Self::ResourcesList => RESOURCES_LIST,
265            Self::ResourcesRead => RESOURCES_READ,
266            Self::ResourcesTemplatesList => RESOURCES_TEMPLATES_LIST,
267            Self::ResourcesSubscribe => RESOURCES_SUBSCRIBE,
268            Self::ResourcesUnsubscribe => RESOURCES_UNSUBSCRIBE,
269            Self::PromptsList => PROMPTS_LIST,
270            Self::PromptsGet => PROMPTS_GET,
271            Self::LoggingSetLevel => LOGGING_SET_LEVEL,
272            Self::CompletionComplete => COMPLETION_COMPLETE,
273            Self::NotificationsToolsListChanged => NOTIFICATIONS_TOOLS_LIST_CHANGED,
274            Self::NotificationsCancelled => NOTIFICATIONS_CANCELLED,
275            Self::NotificationsProgress => NOTIFICATIONS_PROGRESS,
276            Self::Notification(m) => m.as_str(),
277            Self::Unknown(m) => m.as_str(),
278        }
279    }
280}
281
282// ── Parsing ──
283
284/// Parse a JSON-RPC id value.
285fn parse_id(value: &Value) -> Option<JsonRpcId> {
286    match value {
287        Value::Number(n) => n.as_i64().map(JsonRpcId::Number),
288        Value::String(s) => Some(JsonRpcId::String(s.clone())),
289        _ => None,
290    }
291}
292
293/// Parse a JSON-RPC error object.
294fn parse_error(value: &Value) -> Option<JsonRpcError> {
295    let obj = value.as_object()?;
296    Some(JsonRpcError {
297        code: obj.get("code")?.as_i64()?,
298        message: obj.get("message")?.as_str()?.to_string(),
299        data: obj.get("data").cloned(),
300    })
301}
302
303/// Parse a single JSON value as a JSON-RPC 2.0 message.
304/// Returns `None` if it doesn't have `"jsonrpc": "2.0"`.
305pub fn parse_message(value: &Value) -> Option<JsonRpcMessage> {
306    let obj = value.as_object()?;
307
308    // Must be JSON-RPC 2.0
309    if obj.get("jsonrpc")?.as_str()? != "2.0" {
310        return None;
311    }
312
313    let id = obj.get("id").and_then(parse_id);
314    let method = obj.get("method").and_then(|m| m.as_str()).map(String::from);
315    let params = obj.get("params").cloned();
316
317    match (method, id) {
318        // Has method + id → Request
319        (Some(method), Some(id)) => Some(JsonRpcMessage::Request(JsonRpcRequest {
320            id,
321            method,
322            params,
323        })),
324        // Has method, no id → Notification
325        (Some(method), None) => Some(JsonRpcMessage::Notification(JsonRpcNotification {
326            method,
327            params,
328        })),
329        // No method, has id → Response
330        (None, Some(id)) => {
331            let result = obj.get("result").cloned();
332            let error = obj.get("error").and_then(parse_error);
333            Some(JsonRpcMessage::Response(JsonRpcResponse {
334                id,
335                result,
336                error,
337            }))
338        }
339        // No method, no id → invalid
340        (None, None) => None,
341    }
342}
343
344/// Result of parsing a POST body as JSON-RPC 2.0.
345#[derive(Debug)]
346pub struct ParsedBody {
347    pub messages: Vec<JsonRpcMessage>,
348    pub is_batch: bool,
349}
350
351impl ParsedBody {
352    /// Get the method string from the first request or notification.
353    /// Falls back to "unknown" if the batch contains only responses.
354    pub fn method_str(&self) -> &str {
355        self.messages
356            .iter()
357            .find_map(|m| match m {
358                JsonRpcMessage::Request(r) => Some(r.method.as_str()),
359                JsonRpcMessage::Notification(n) => Some(n.method.as_str()),
360                _ => None,
361            })
362            .unwrap_or("unknown")
363    }
364
365    /// Get the MCP method classification from the first request/notification.
366    pub fn mcp_method(&self) -> McpMethod {
367        McpMethod::parse(self.method_str())
368    }
369
370    /// Get the id of the first request (if any).
371    pub fn first_request_id(&self) -> Option<&JsonRpcId> {
372        self.messages.iter().find_map(|m| match m {
373            JsonRpcMessage::Request(r) => Some(&r.id),
374            _ => None,
375        })
376    }
377
378    /// True if every message is a notification (no id, no response expected).
379    pub fn is_notification_only(&self) -> bool {
380        self.messages
381            .iter()
382            .all(|m| matches!(m, JsonRpcMessage::Notification(_)))
383    }
384
385    /// Extract a short detail string for logging:
386    /// - tools/call → tool name (params.name)
387    /// - resources/read → resource URI (params.uri)
388    /// - prompts/get → prompt name (params.name)
389    pub fn detail(&self) -> Option<String> {
390        let params = self.first_params()?;
391        let method = self.mcp_method();
392        match method {
393            McpMethod::ToolsCall => params.get("name")?.as_str().map(String::from),
394            McpMethod::ResourcesRead => params.get("uri")?.as_str().map(String::from),
395            McpMethod::PromptsGet => params.get("name")?.as_str().map(String::from),
396            McpMethod::NotificationsCancelled => {
397                // requestId can be string or number
398                params.get("requestId").map(|v| match v {
399                    Value::String(s) => s.clone(),
400                    Value::Number(n) => n.to_string(),
401                    _ => v.to_string(),
402                })
403            }
404            McpMethod::NotificationsProgress => {
405                // progressToken can be string or number
406                params.get("progressToken").map(|v| match v {
407                    Value::String(s) => s.clone(),
408                    Value::Number(n) => n.to_string(),
409                    _ => v.to_string(),
410                })
411            }
412            _ => None,
413        }
414    }
415
416    /// Get the raw params from the first request/notification.
417    pub fn first_params(&self) -> Option<&Value> {
418        self.messages.iter().find_map(|m| match m {
419            JsonRpcMessage::Request(r) => r.params.as_ref(),
420            JsonRpcMessage::Notification(n) => n.params.as_ref(),
421            _ => None,
422        })
423    }
424}
425
426/// Parse a POST body as JSON-RPC 2.0 — single message or batch.
427/// Returns `None` if the body is not valid JSON-RPC 2.0.
428pub fn parse_body(body: &[u8]) -> Option<ParsedBody> {
429    let value: Value = serde_json::from_slice(body).ok()?;
430
431    if let Some(arr) = value.as_array() {
432        // Batch: array of JSON-RPC messages
433        let messages: Vec<_> = arr.iter().filter_map(parse_message).collect();
434        if messages.is_empty() {
435            return None;
436        }
437        Some(ParsedBody {
438            messages,
439            is_batch: true,
440        })
441    } else {
442        // Single message
443        let msg = parse_message(&value)?;
444        Some(ParsedBody {
445            messages: vec![msg],
446            is_batch: false,
447        })
448    }
449}
450
451// ── Tests ──
452
453#[cfg(test)]
454#[allow(non_snake_case)]
455mod tests {
456    use super::*;
457    use serde_json::json;
458
459    // ── parse_message ──
460
461    #[test]
462    fn parse_message__request() {
463        let val = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "get_weather"}});
464        let msg = parse_message(&val).unwrap();
465        match msg {
466            JsonRpcMessage::Request(r) => {
467                assert_eq!(r.id, JsonRpcId::Number(1));
468                assert_eq!(r.method, "tools/call");
469                assert!(r.params.is_some());
470            }
471            _ => panic!("expected Request"),
472        }
473    }
474
475    #[test]
476    fn parse_message__request_string_id() {
477        let val = json!({"jsonrpc": "2.0", "id": "abc-123", "method": "initialize"});
478        let msg = parse_message(&val).unwrap();
479        match msg {
480            JsonRpcMessage::Request(r) => {
481                assert_eq!(r.id, JsonRpcId::String("abc-123".into()));
482                assert_eq!(r.method, "initialize");
483            }
484            _ => panic!("expected Request"),
485        }
486    }
487
488    #[test]
489    fn parse_message__notification() {
490        let val = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
491        let msg = parse_message(&val).unwrap();
492        match msg {
493            JsonRpcMessage::Notification(n) => {
494                assert_eq!(n.method, "notifications/initialized");
495                assert!(n.params.is_none());
496            }
497            _ => panic!("expected Notification"),
498        }
499    }
500
501    #[test]
502    fn parse_message__response_result() {
503        let val = json!({"jsonrpc": "2.0", "id": 1, "result": {"tools": []}});
504        let msg = parse_message(&val).unwrap();
505        match msg {
506            JsonRpcMessage::Response(r) => {
507                assert_eq!(r.id, JsonRpcId::Number(1));
508                assert!(r.result.is_some());
509                assert!(r.error.is_none());
510            }
511            _ => panic!("expected Response"),
512        }
513    }
514
515    #[test]
516    fn parse_message__response_error() {
517        let val = json!({"jsonrpc": "2.0", "id": 1, "error": {"code": -32601, "message": "Method not found"}});
518        let msg = parse_message(&val).unwrap();
519        match msg {
520            JsonRpcMessage::Response(r) => {
521                assert_eq!(r.id, JsonRpcId::Number(1));
522                assert!(r.result.is_none());
523                let err = r.error.unwrap();
524                assert_eq!(err.code, -32601);
525                assert_eq!(err.message, "Method not found");
526            }
527            _ => panic!("expected Response"),
528        }
529    }
530
531    #[test]
532    fn parse_message__rejects_wrong_version() {
533        let val = json!({"jsonrpc": "1.0", "id": 1, "method": "test"});
534        assert!(parse_message(&val).is_none());
535    }
536
537    #[test]
538    fn parse_message__rejects_missing_jsonrpc() {
539        let val = json!({"id": 1, "method": "test"});
540        assert!(parse_message(&val).is_none());
541    }
542
543    #[test]
544    fn parse_message__rejects_no_method_no_id() {
545        let val = json!({"jsonrpc": "2.0"});
546        assert!(parse_message(&val).is_none());
547    }
548
549    #[test]
550    fn parse_message__rejects_non_object() {
551        let val = json!("hello");
552        assert!(parse_message(&val).is_none());
553    }
554
555    #[test]
556    fn parse_message__rejects_oauth_register() {
557        let val = json!({
558            "client_name": "My App",
559            "redirect_uris": ["https://example.com/callback"],
560            "grant_types": ["authorization_code"]
561        });
562        assert!(parse_message(&val).is_none());
563    }
564
565    // ── parse_body ──
566
567    #[test]
568    fn parse_body__single_request() {
569        let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
570        let parsed = parse_body(body).unwrap();
571        assert!(!parsed.is_batch);
572        assert_eq!(parsed.messages.len(), 1);
573        assert_eq!(parsed.method_str(), "tools/list");
574        assert_eq!(parsed.mcp_method(), McpMethod::ToolsList);
575    }
576
577    #[test]
578    fn parse_body__batch_requests() {
579        let body = br#"[
580            {"jsonrpc":"2.0","id":1,"method":"tools/list"},
581            {"jsonrpc":"2.0","id":2,"method":"resources/list"}
582        ]"#;
583        let parsed = parse_body(body).unwrap();
584        assert!(parsed.is_batch);
585        assert_eq!(parsed.messages.len(), 2);
586        assert_eq!(parsed.method_str(), "tools/list");
587    }
588
589    #[test]
590    fn parse_body__notification_only() {
591        let body = br#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
592        let parsed = parse_body(body).unwrap();
593        assert!(parsed.is_notification_only());
594        assert_eq!(parsed.mcp_method(), McpMethod::Initialized);
595    }
596
597    #[test]
598    fn parse_body__mixed_batch() {
599        let body = br#"[
600            {"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}},
601            {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_weather"}}
602        ]"#;
603        let parsed = parse_body(body).unwrap();
604        assert!(parsed.is_batch);
605        assert!(!parsed.is_notification_only());
606        assert_eq!(parsed.first_request_id(), Some(&JsonRpcId::Number(2)));
607    }
608
609    #[test]
610    fn parse_body__rejects_empty_batch() {
611        let body = b"[]";
612        assert!(parse_body(body).is_none());
613    }
614
615    #[test]
616    fn parse_body__rejects_invalid_json() {
617        assert!(parse_body(b"not json").is_none());
618    }
619
620    #[test]
621    fn parse_body__rejects_non_jsonrpc() {
622        let body = br#"{"grant_type":"client_credentials","client_id":"abc"}"#;
623        assert!(parse_body(body).is_none());
624    }
625
626    #[test]
627    fn parse_body__rejects_batch_of_non_jsonrpc() {
628        let body = br#"[{"foo":"bar"},{"baz":1}]"#;
629        assert!(parse_body(body).is_none());
630    }
631
632    // ── McpMethod ──
633
634    #[test]
635    fn mcp_method__known_methods() {
636        assert_eq!(McpMethod::parse("initialize"), McpMethod::Initialize);
637        assert_eq!(McpMethod::parse("tools/call"), McpMethod::ToolsCall);
638        assert_eq!(McpMethod::parse("tools/list"), McpMethod::ToolsList);
639        assert_eq!(McpMethod::parse("resources/read"), McpMethod::ResourcesRead);
640        assert_eq!(McpMethod::parse("resources/list"), McpMethod::ResourcesList);
641        assert_eq!(
642            McpMethod::parse("resources/templates/list"),
643            McpMethod::ResourcesTemplatesList
644        );
645        assert_eq!(McpMethod::parse("prompts/list"), McpMethod::PromptsList);
646        assert_eq!(McpMethod::parse("prompts/get"), McpMethod::PromptsGet);
647        assert_eq!(McpMethod::parse("ping"), McpMethod::Ping);
648        assert_eq!(
649            McpMethod::parse("logging/setLevel"),
650            McpMethod::LoggingSetLevel
651        );
652        assert_eq!(
653            McpMethod::parse("completion/complete"),
654            McpMethod::CompletionComplete
655        );
656        assert_eq!(
657            McpMethod::parse("notifications/tools/list_changed"),
658            McpMethod::NotificationsToolsListChanged
659        );
660        assert_eq!(
661            McpMethod::parse("notifications/cancelled"),
662            McpMethod::NotificationsCancelled
663        );
664        assert_eq!(
665            McpMethod::parse("notifications/progress"),
666            McpMethod::NotificationsProgress
667        );
668    }
669
670    #[test]
671    fn mcp_method__notifications() {
672        assert_eq!(
673            McpMethod::parse("notifications/initialized"),
674            McpMethod::Initialized
675        );
676        // Known notification variants are parsed to specific enum values
677        assert_eq!(
678            McpMethod::parse("notifications/cancelled"),
679            McpMethod::NotificationsCancelled
680        );
681        assert_eq!(
682            McpMethod::parse("notifications/progress"),
683            McpMethod::NotificationsProgress
684        );
685        assert_eq!(
686            McpMethod::parse("notifications/tools/list_changed"),
687            McpMethod::NotificationsToolsListChanged
688        );
689        // Unknown notifications still fall through to the generic variant
690        assert_eq!(
691            McpMethod::parse("notifications/resources/updated"),
692            McpMethod::Notification("notifications/resources/updated".into())
693        );
694    }
695
696    #[test]
697    fn mcp_method__unknown() {
698        assert_eq!(
699            McpMethod::parse("custom/method"),
700            McpMethod::Unknown("custom/method".into())
701        );
702    }
703
704    #[test]
705    fn mcp_method__as_str_roundtrip() {
706        let methods = [
707            "initialize",
708            "notifications/initialized",
709            "ping",
710            "tools/list",
711            "tools/call",
712            "resources/list",
713            "resources/read",
714            "resources/templates/list",
715            "prompts/list",
716            "prompts/get",
717            "logging/setLevel",
718            "completion/complete",
719            "notifications/tools/list_changed",
720            "notifications/cancelled",
721            "notifications/progress",
722        ];
723        for m in methods {
724            assert_eq!(McpMethod::parse(m).as_str(), m);
725        }
726    }
727
728    // ── JsonRpcId display ──
729
730    #[test]
731    fn jsonrpc_id__display() {
732        assert_eq!(JsonRpcId::Number(42).to_string(), "42");
733        assert_eq!(JsonRpcId::String("abc".into()).to_string(), "abc");
734    }
735
736    // ── ParsedBody helpers ──
737
738    #[test]
739    fn parsed_body__first_params_from_request() {
740        let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo"}}"#;
741        let parsed = parse_body(body).unwrap();
742        let params = parsed.first_params().unwrap();
743        assert_eq!(params["name"], "echo");
744    }
745
746    #[test]
747    fn parsed_body__first_params_none_for_response() {
748        let body = br#"{"jsonrpc":"2.0","id":1,"result":{}}"#;
749        let parsed = parse_body(body).unwrap();
750        assert!(parsed.first_params().is_none());
751    }
752
753    #[test]
754    fn parsed_body__method_str_defaults_to_unknown() {
755        let body = br#"{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}"#;
756        let parsed = parse_body(body).unwrap();
757        assert_eq!(parsed.method_str(), "unknown");
758    }
759
760    // ── ParsedBody::detail ──
761
762    #[test]
763    fn parsed_body__detail_tools_call() {
764        let body =
765            br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_weather"}}"#;
766        let parsed = parse_body(body).unwrap();
767        assert_eq!(parsed.detail().as_deref(), Some("get_weather"));
768    }
769
770    #[test]
771    fn parsed_body__detail_resources_read() {
772        let body = br#"{"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"ui://widget/clock.html"}}"#;
773        let parsed = parse_body(body).unwrap();
774        assert_eq!(parsed.detail().as_deref(), Some("ui://widget/clock.html"));
775    }
776
777    #[test]
778    fn parsed_body__detail_none_for_tools_list() {
779        let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
780        let parsed = parse_body(body).unwrap();
781        assert!(parsed.detail().is_none());
782    }
783
784    #[test]
785    fn parsed_body__detail_notifications_cancelled() {
786        let body = br#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":"req-42","reason":"timeout"}}"#;
787        let parsed = parse_body(body).unwrap();
788        assert_eq!(parsed.detail().as_deref(), Some("req-42"));
789    }
790
791    #[test]
792    fn parsed_body__detail_cancelled_numeric_id() {
793        let body =
794            br#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":7}}"#;
795        let parsed = parse_body(body).unwrap();
796        assert_eq!(parsed.detail().as_deref(), Some("7"));
797    }
798
799    #[test]
800    fn parsed_body__detail_notifications_progress() {
801        let body = br#"{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"tok-1","progress":50,"total":100}}"#;
802        let parsed = parse_body(body).unwrap();
803        assert_eq!(parsed.detail().as_deref(), Some("tok-1"));
804    }
805
806    #[test]
807    fn parsed_body__detail_progress_numeric_token() {
808        let body = br#"{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":99,"progress":10}}"#;
809        let parsed = parse_body(body).unwrap();
810        assert_eq!(parsed.detail().as_deref(), Some("99"));
811    }
812
813    // ── error_code ──
814
815    #[test]
816    fn error_code__labels() {
817        assert_eq!(error_code::label(error_code::PARSE_ERROR), "Parse error");
818        assert_eq!(
819            error_code::label(error_code::METHOD_NOT_FOUND),
820            "Method not found"
821        );
822        assert_eq!(
823            error_code::label(error_code::INVALID_PARAMS),
824            "Invalid params"
825        );
826        assert_eq!(
827            error_code::label(error_code::INTERNAL_ERROR),
828            "Internal error"
829        );
830        assert_eq!(error_code::label(-32000), "Server error");
831        assert_eq!(error_code::label(-32099), "Server error");
832        assert_eq!(error_code::label(42), "Unknown error");
833    }
834
835    // ── error_response ──
836
837    #[test]
838    fn error_response__numeric_id() {
839        let body = error_response(&json!(1), error_code::METHOD_NOT_FOUND, "Method not found");
840        let parsed: Value = serde_json::from_slice(&body).unwrap();
841        assert_eq!(parsed["jsonrpc"], "2.0");
842        assert_eq!(parsed["id"], 1);
843        assert_eq!(parsed["error"]["code"], -32601);
844        assert_eq!(parsed["error"]["message"], "Method not found");
845    }
846
847    #[test]
848    fn error_response__null_id() {
849        let body = error_response(&Value::Null, error_code::PARSE_ERROR, "Parse error");
850        let parsed: Value = serde_json::from_slice(&body).unwrap();
851        assert_eq!(parsed["id"], Value::Null);
852        assert_eq!(parsed["error"]["code"], -32700);
853    }
854
855    // ── extract_error_code ──
856
857    #[test]
858    fn extract_error_code__from_error_response() {
859        let val = json!({"jsonrpc": "2.0", "id": 1, "error": {"code": -32601, "message": "Method not found"}});
860        let (code, msg) = extract_error_code(&val).unwrap();
861        assert_eq!(code, -32601);
862        assert_eq!(msg, "Method not found");
863    }
864
865    #[test]
866    fn extract_error_code__none_for_success() {
867        let val = json!({"jsonrpc": "2.0", "id": 1, "result": {"tools": []}});
868        assert!(extract_error_code(&val).is_none());
869    }
870}