dwara_core/mcp/
mod.rs

1//! Agent-operable administration via MCP (DW-112).
2//!
3//! Expose the admin API as an MCP (Model Context Protocol) server with
4//! tools for route/service/policy CRUD, stats, and analytics queries.
5//! RBAC-scoped tool access -- AI agents operate dwara.
6//!
7//! ## MCP tools
8//!
9//! The MCP server exposes the following tools:
10//!
11//! - `list_routes` -- list all routes in the current config
12//! - `get_route` -- get a single route by name
13//! - `create_route` -- create a new route
14//! - `update_route` -- update an existing route
15//! - `delete_route` -- delete a route
16//! - `list_services` -- list all services
17//! - `get_stats` -- get gateway stats
18//! - `get_health` -- get gateway health
19//! - `get_config` -- dump the current config
20//!
21//! ## RBAC
22//!
23//! Tool access is RBAC-scoped: each tool requires a permission, and
24//! the MCP server checks the agent's permissions before executing a
25//! tool. Out-of-scope operations are denied.
26//!
27//! ## JSON-first
28//!
29//! All tool inputs and outputs are JSON. The CLI also has a
30//! `--json` flag for JSON-first output mode.
31//!
32//! ## Feature gate
33//!
34//! The `mcp` cargo feature must be enabled.
35
36use std::collections::HashMap;
37
38use serde::{Deserialize, Serialize};
39use serde_json::Value;
40
41// ---------------------------------------------------------------------------
42// MCP protocol types
43// ---------------------------------------------------------------------------
44
45/// An MCP tool definition.
46#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
47pub struct ToolDefinition {
48    /// The tool name.
49    pub name: String,
50    /// The tool description.
51    pub description: String,
52    /// The JSON Schema for the tool's input parameters.
53    pub input_schema: Value,
54    /// The permission required to use this tool.
55    pub required_permission: Permission,
56}
57
58/// An MCP tool call request.
59#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
60pub struct ToolCallRequest {
61    /// The tool name.
62    pub name: String,
63    /// The tool arguments (JSON object).
64    pub arguments: Value,
65}
66
67/// An MCP tool call response.
68#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
69pub struct ToolCallResponse {
70    /// Whether the call succeeded.
71    pub success: bool,
72    /// The result (on success) or error message (on failure).
73    pub result: Value,
74    /// The error code (on failure).
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub error_code: Option<String>,
77}
78
79/// A permission required to use a tool.
80#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum Permission {
83    /// Read access (list, get, stats, health, config dump).
84    Read,
85    /// Write access (create, update, delete).
86    Write,
87    /// Admin access (purge cache, reload config).
88    Admin,
89}
90
91/// An agent's identity and permissions.
92#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
93pub struct AgentIdentity {
94    /// The agent's name.
95    pub name: String,
96    /// The agent's permissions.
97    pub permissions: Vec<Permission>,
98}
99
100impl AgentIdentity {
101    /// Check if the agent has a permission.
102    pub fn has_permission(&self, perm: Permission) -> bool {
103        self.permissions.contains(&perm)
104    }
105
106    /// Create an agent with read-only permissions.
107    pub fn read_only(name: &str) -> Self {
108        Self {
109            name: name.to_string(),
110            permissions: vec![Permission::Read],
111        }
112    }
113
114    /// Create an agent with read + write permissions.
115    pub fn read_write(name: &str) -> Self {
116        Self {
117            name: name.to_string(),
118            permissions: vec![Permission::Read, Permission::Write],
119        }
120    }
121
122    /// Create an agent with all permissions.
123    pub fn admin(name: &str) -> Self {
124        Self {
125            name: name.to_string(),
126            permissions: vec![Permission::Read, Permission::Write, Permission::Admin],
127        }
128    }
129}
130
131// ---------------------------------------------------------------------------
132// MCP server
133// ---------------------------------------------------------------------------
134
135/// The MCP server: holds tool definitions and executes tool calls
136/// with RBAC checks.
137pub struct McpServer {
138    tools: HashMap<String, ToolDefinition>,
139}
140
141impl McpServer {
142    /// Create a new MCP server with the standard admin tools.
143    pub fn new() -> Self {
144        let mut tools = HashMap::new();
145
146        for tool in standard_tools() {
147            tools.insert(tool.name.clone(), tool);
148        }
149
150        Self { tools }
151    }
152
153    /// List all available tools.
154    pub fn list_tools(&self) -> Vec<&ToolDefinition> {
155        self.tools.values().collect()
156    }
157
158    /// List tools available to an agent (filtered by permissions).
159    pub fn list_tools_for(&self, agent: &AgentIdentity) -> Vec<&ToolDefinition> {
160        self.tools
161            .values()
162            .filter(|t| agent.has_permission(t.required_permission))
163            .collect()
164    }
165
166    /// Get a tool definition by name.
167    pub fn get_tool(&self, name: &str) -> Option<&ToolDefinition> {
168        self.tools.get(name)
169    }
170
171    /// Execute a tool call with RBAC check.
172    ///
173    /// This is the pure RBAC + dispatch step. The actual tool
174    /// execution (calling the admin API) is done by the caller via
175    /// the `ToolHandler` trait. This method checks permissions and
176    /// returns the appropriate error if the agent is not authorized.
177    pub fn execute(
178        &self,
179        request: &ToolCallRequest,
180        agent: &AgentIdentity,
181        handler: &dyn ToolHandler,
182    ) -> ToolCallResponse {
183        // Find the tool.
184        let Some(tool) = self.tools.get(&request.name) else {
185            return ToolCallResponse {
186                success: false,
187                result: Value::String(format!("unknown tool: {}", request.name)),
188                error_code: Some("unknown_tool".to_string()),
189            };
190        };
191
192        // Check permissions.
193        if !agent.has_permission(tool.required_permission) {
194            return ToolCallResponse {
195                success: false,
196                result: Value::String(format!(
197                    "permission denied: tool '{}' requires {:?}, agent '{}' has {:?}",
198                    tool.name, tool.required_permission, agent.name, agent.permissions
199                )),
200                error_code: Some("permission_denied".to_string()),
201            };
202        }
203
204        // Validate arguments against the tool's input schema.
205        if let Err(err) = validate_arguments(&tool.input_schema, &request.arguments) {
206            return ToolCallResponse {
207                success: false,
208                result: Value::String(format!("invalid arguments: {err}")),
209                error_code: Some("invalid_arguments".to_string()),
210            };
211        }
212
213        // Execute the tool.
214        handler.execute_tool(&request.name, &request.arguments)
215    }
216
217    /// The number of registered tools.
218    pub fn tool_count(&self) -> usize {
219        self.tools.len()
220    }
221}
222
223impl Default for McpServer {
224    fn default() -> Self {
225        Self::new()
226    }
227}
228
229/// A tool handler: the caller implements this to actually execute
230/// tools (calling the admin API, reading config, etc.).
231pub trait ToolHandler {
232    fn execute_tool(&self, name: &str, arguments: &Value) -> ToolCallResponse;
233}
234
235/// A simple tool handler for testing: returns canned responses.
236pub struct MockToolHandler {
237    responses: HashMap<String, Value>,
238}
239
240impl MockToolHandler {
241    pub fn new() -> Self {
242        Self {
243            responses: HashMap::new(),
244        }
245    }
246
247    pub fn with_response(mut self, tool_name: &str, response: Value) -> Self {
248        self.responses.insert(tool_name.to_string(), response);
249        self
250    }
251}
252
253impl Default for MockToolHandler {
254    fn default() -> Self {
255        Self::new()
256    }
257}
258
259impl ToolHandler for MockToolHandler {
260    fn execute_tool(&self, name: &str, _arguments: &Value) -> ToolCallResponse {
261        if let Some(result) = self.responses.get(name) {
262            ToolCallResponse {
263                success: true,
264                result: result.clone(),
265                error_code: None,
266            }
267        } else {
268            ToolCallResponse {
269                success: true,
270                result: Value::String(format!("mock response for {name}")),
271                error_code: None,
272            }
273        }
274    }
275}
276
277// ---------------------------------------------------------------------------
278// Standard admin tools
279// ---------------------------------------------------------------------------
280
281fn standard_tools() -> Vec<ToolDefinition> {
282    vec![
283        ToolDefinition {
284            name: "list_routes".to_string(),
285            description: "List all routes in the current gateway config".to_string(),
286            input_schema: serde_json::json!({
287                "type": "object",
288                "properties": {},
289                "additionalProperties": false
290            }),
291            required_permission: Permission::Read,
292        },
293        ToolDefinition {
294            name: "get_route".to_string(),
295            description: "Get a single route by name".to_string(),
296            input_schema: serde_json::json!({
297                "type": "object",
298                "properties": {
299                    "name": {"type": "string", "description": "The route name"}
300                },
301                "required": ["name"],
302                "additionalProperties": false
303            }),
304            required_permission: Permission::Read,
305        },
306        ToolDefinition {
307            name: "create_route".to_string(),
308            description: "Create a new route".to_string(),
309            input_schema: serde_json::json!({
310                "type": "object",
311                "properties": {
312                    "name": {"type": "string"},
313                    "service": {"type": "string"},
314                    "path": {"type": "string"},
315                    "path_type": {"type": "string", "enum": ["exact", "prefix", "regex"]}
316                },
317                "required": ["name", "service", "path"],
318                "additionalProperties": false
319            }),
320            required_permission: Permission::Write,
321        },
322        ToolDefinition {
323            name: "update_route".to_string(),
324            description: "Update an existing route".to_string(),
325            input_schema: serde_json::json!({
326                "type": "object",
327                "properties": {
328                    "name": {"type": "string"},
329                    "service": {"type": "string"},
330                    "path": {"type": "string"}
331                },
332                "required": ["name"],
333                "additionalProperties": false
334            }),
335            required_permission: Permission::Write,
336        },
337        ToolDefinition {
338            name: "delete_route".to_string(),
339            description: "Delete a route by name".to_string(),
340            input_schema: serde_json::json!({
341                "type": "object",
342                "properties": {
343                    "name": {"type": "string"}
344                },
345                "required": ["name"],
346                "additionalProperties": false
347            }),
348            required_permission: Permission::Write,
349        },
350        ToolDefinition {
351            name: "list_services".to_string(),
352            description: "List all services in the current config".to_string(),
353            input_schema: serde_json::json!({
354                "type": "object",
355                "properties": {},
356                "additionalProperties": false
357            }),
358            required_permission: Permission::Read,
359        },
360        ToolDefinition {
361            name: "get_stats".to_string(),
362            description: "Get gateway stats (counters, histograms)".to_string(),
363            input_schema: serde_json::json!({
364                "type": "object",
365                "properties": {},
366                "additionalProperties": false
367            }),
368            required_permission: Permission::Read,
369        },
370        ToolDefinition {
371            name: "get_health".to_string(),
372            description: "Get gateway health status".to_string(),
373            input_schema: serde_json::json!({
374                "type": "object",
375                "properties": {},
376                "additionalProperties": false
377            }),
378            required_permission: Permission::Read,
379        },
380        ToolDefinition {
381            name: "get_config".to_string(),
382            description: "Dump the current gateway config as YAML".to_string(),
383            input_schema: serde_json::json!({
384                "type": "object",
385                "properties": {},
386                "additionalProperties": false
387            }),
388            required_permission: Permission::Read,
389        },
390        ToolDefinition {
391            name: "purge_cache".to_string(),
392            description: "Purge the cache for a route or all routes".to_string(),
393            input_schema: serde_json::json!({
394                "type": "object",
395                "properties": {
396                    "route": {"type": "string", "description": "The route name (or 'all')"}
397                },
398                "required": ["route"],
399                "additionalProperties": false
400            }),
401            required_permission: Permission::Admin,
402        },
403    ]
404}
405
406// ---------------------------------------------------------------------------
407// Argument validation (simplified JSON Schema)
408// ---------------------------------------------------------------------------
409
410/// Validate arguments against a JSON Schema (simplified: checks
411/// `type`, `required`, `properties`, `additionalProperties`).
412fn validate_arguments(schema: &Value, args: &Value) -> Result<(), String> {
413    let schema_obj = schema.as_object().ok_or("schema must be an object")?;
414
415    // Check type.
416    if let Some(schema_type) = schema_obj.get("type") {
417        if schema_type == "object" {
418            let args_obj = args.as_object().ok_or("arguments must be an object")?;
419
420            // Check required fields.
421            if let Some(required) = schema_obj.get("required").and_then(|r| r.as_array()) {
422                for field in required {
423                    let field_name = field
424                        .as_str()
425                        .ok_or("required field name must be a string")?;
426                    if !args_obj.contains_key(field_name) {
427                        return Err(format!("missing required field: {field_name}"));
428                    }
429                }
430            }
431
432            // Check additionalProperties.
433            let additional_allowed = schema_obj
434                .get("additionalProperties")
435                .map(|v| v.as_bool().unwrap_or(true))
436                .unwrap_or(true);
437
438            if !additional_allowed {
439                if let Some(properties) = schema_obj.get("properties").and_then(|p| p.as_object()) {
440                    for key in args_obj.keys() {
441                        if !properties.contains_key(key) {
442                            return Err(format!("unknown field: {key}"));
443                        }
444                    }
445                }
446            }
447        }
448    }
449
450    Ok(())
451}
452
453// ---------------------------------------------------------------------------
454// AI-11 (#200): SSE transport adapter for the agent-operable admin MCP
455// server. Encodes JSON-RPC responses as SSE frames so the admin MCP
456// server can be served over HTTP with server-sent events.
457// ---------------------------------------------------------------------------
458
459/// AI-11 (#200): Encode a JSON-RPC response as an SSE frame.
460/// Returns a string suitable for writing to an SSE response body
461/// (`data: <payload>\n\n`).
462pub fn encode_sse_response(response: &Value) -> String {
463    format!("data: {response}\n\n")
464}
465
466/// AI-11 (#200): Encode a JSON-RPC error as an SSE frame.
467pub fn encode_sse_error(id: Option<&Value>, code: i32, message: &str) -> String {
468    let error = serde_json::json!({
469        "jsonrpc": "2.0",
470        "id": id,
471        "error": {"code": code, "message": message}
472    });
473    encode_sse_response(&error)
474}
475
476/// AI-11 (#200): The transport type for the admin MCP server.
477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
478pub enum McpTransport {
479    /// JSON-RPC over HTTP (request/response, no streaming).
480    Http,
481    /// JSON-RPC over Server-Sent Events (streaming responses).
482    Sse,
483    /// JSON-RPC over stdio (newline-delimited JSON on stdin/stdout).
484    Stdio,
485}
486
487impl McpTransport {
488    /// The stable name string (for analytics/config storage).
489    pub fn as_str(&self) -> &'static str {
490        match self {
491            McpTransport::Http => "http",
492            McpTransport::Sse => "sse",
493            McpTransport::Stdio => "stdio",
494        }
495    }
496
497    /// Parse a transport name string. Defaults to `Http` for an
498    /// unrecognized or None value.
499    pub fn parse(s: Option<&str>) -> Self {
500        match s {
501            Some("sse") => McpTransport::Sse,
502            Some("stdio") => McpTransport::Stdio,
503            _ => McpTransport::Http,
504        }
505    }
506}