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