Skip to main content

stygian_plugin/mcp/
handler.rs

1//! MCP request dispatcher and protocol handler.
2//!
3//! Implements the full MCP protocol including initialize, notifications,
4//! and tool dispatch.
5
6use crate::config::{Config, SUPPORTED_PROTOCOL_VERSIONS};
7use crate::mcp::McpPluginServer;
8use serde_json::{Value, json};
9use std::sync::Arc;
10
11/// Dispatches incoming JSON-RPC 2.0 MCP requests to appropriate handlers.
12pub struct McpRequestHandler {
13    server: Arc<McpPluginServer>,
14    config: Config,
15}
16
17impl McpRequestHandler {
18    /// Create a new request handler
19    #[must_use]
20    pub const fn new(server: Arc<McpPluginServer>, config: Config) -> Self {
21        Self { server, config }
22    }
23
24    /// Handle an incoming MCP request.
25    ///
26    /// Returns `Some(response)` for all requests except notifications (id field missing),
27    /// which return `None`.
28    pub async fn handle(&self, req: &Value) -> Option<Value> {
29        // Check if this is a well-formed notification (jsonrpc="2.0", has method, id field missing)
30        let is_notification = is_jsonrpc_notification(req);
31        // Clone id to avoid Send trait issues across await points (serde_json::Value contains Cell)
32        let id = req.get("id").cloned().unwrap_or(Value::Null);
33
34        // Validate JSON-RPC 2.0 structure
35        if req.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
36            return Some(error_response(
37                &id,
38                -32600,
39                "Invalid request: expected jsonrpc='2.0'",
40            ));
41        }
42
43        let Some(method) = req.get("method").and_then(Value::as_str) else {
44            return Some(error_response(
45                &id,
46                -32600,
47                "Invalid request: missing string 'method'",
48            ));
49        };
50
51        // Dispatch to appropriate handler
52        let response = match method {
53            "initialize" => self.handle_initialize(&id, req),
54            "initialized" | "notifications/initialized" | "ping" => ok_response(&id, &json!({})),
55            "tools/list" => self.handle_tools_list(&id),
56            "tools/call" => self.handle_tools_call(&id, req).await,
57            other => error_response(&id, -32601, &format!("Method not found: {other}")),
58        };
59
60        // Notifications don't get responses
61        if is_notification {
62            None
63        } else {
64            Some(response)
65        }
66    }
67
68    /// Handle the initialize request
69    fn handle_initialize(&self, id: &Value, req: &Value) -> Value {
70        let requested_version = req
71            .get("params")
72            .and_then(|p| p.get("protocolVersion"))
73            .and_then(Value::as_str);
74
75        let protocol_version = match requested_version {
76            Some(v) if SUPPORTED_PROTOCOL_VERSIONS.contains(&v) => v,
77            Some(v) => {
78                return error_response(
79                    id,
80                    -32602,
81                    &format!(
82                        "Unsupported protocolVersion: {v}. Supported: {}",
83                        SUPPORTED_PROTOCOL_VERSIONS.join(", ")
84                    ),
85                );
86            }
87            None => SUPPORTED_PROTOCOL_VERSIONS
88                .first()
89                .copied()
90                .unwrap_or("2024-11-05"),
91        };
92
93        ok_response(
94            id,
95            &json!({
96                "protocolVersion": protocol_version,
97                "capabilities": {
98                    "tools": { "listChanged": false }
99                },
100                "serverInfo": {
101                    "name": self.config.server_name,
102                    "version": env!("CARGO_PKG_VERSION")
103                }
104            }),
105        )
106    }
107
108    /// Handle tools/list request
109    fn handle_tools_list(&self, id: &Value) -> Value {
110        let tools = self.server.tools_list();
111        ok_response(id, &json!({ "tools": tools }))
112    }
113
114    /// Handle tools/call request
115    async fn handle_tools_call(&self, id: &Value, req: &Value) -> Value {
116        let Some(params) = req.get("params") else {
117            return error_response(id, -32602, "Missing 'params'");
118        };
119
120        let Some(name) = params.get("name").and_then(Value::as_str) else {
121            return error_response(id, -32602, "Missing tool 'name'");
122        };
123        let name = name.to_string();
124
125        let args = params.get("arguments").cloned().unwrap_or(Value::Null);
126
127        // Call the server's tool handler
128        let result = self.server.handle_tool_call(&name, &args).await;
129
130        // Return wrapped in MCP response format
131        ok_response(id, &result)
132    }
133}
134
135// ─── Response Helpers ───────────────────────────────────────────────────────
136
137/// Construct a successful JSON-RPC response
138fn ok_response(id: &Value, result: &Value) -> Value {
139    json!({ "jsonrpc": "2.0", "id": id, "result": result })
140}
141
142/// Construct an error JSON-RPC response
143fn error_response(id: &Value, code: i32, message: &str) -> Value {
144    json!({
145        "jsonrpc": "2.0",
146        "id": id,
147        "error": { "code": code, "message": message }
148    })
149}
150
151/// Check if a request is a valid JSON-RPC notification (no response required)
152fn is_jsonrpc_notification(req: &Value) -> bool {
153    req.is_object()
154        && req.get("jsonrpc").and_then(Value::as_str) == Some("2.0")
155        && req.get("id").is_none()
156        && req.get("method").and_then(Value::as_str).is_some()
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn test_is_jsonrpc_notification() {
165        // Valid notification: jsonrpc 2.0, method, no id
166        let notif = json!({
167            "jsonrpc": "2.0",
168            "method": "ping"
169        });
170        assert!(is_jsonrpc_notification(&notif));
171
172        // Not a notification: has id
173        let request = json!({
174            "jsonrpc": "2.0",
175            "id": 1,
176            "method": "ping"
177        });
178        assert!(!is_jsonrpc_notification(&request));
179
180        // Not a notification: missing jsonrpc
181        let bad = json!({ "method": "ping" });
182        assert!(!is_jsonrpc_notification(&bad));
183    }
184
185    #[test]
186    fn test_ok_response() {
187        let resp = ok_response(&json!(1), &json!({"status": "ok"}));
188        assert_eq!(resp.get("jsonrpc").and_then(Value::as_str), Some("2.0"));
189        assert_eq!(resp.get("id").and_then(Value::as_u64), Some(1));
190        assert_eq!(
191            resp.pointer("/result/status").and_then(Value::as_str),
192            Some("ok")
193        );
194    }
195
196    #[test]
197    fn test_error_response() {
198        let resp = error_response(&json!(2), -32601, "Not found");
199        assert_eq!(resp.get("jsonrpc").and_then(Value::as_str), Some("2.0"));
200        assert_eq!(resp.get("id").and_then(Value::as_u64), Some(2));
201        assert_eq!(
202            resp.pointer("/error/code").and_then(Value::as_i64),
203            Some(-32601)
204        );
205        assert_eq!(
206            resp.pointer("/error/message").and_then(Value::as_str),
207            Some("Not found")
208        );
209    }
210}