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