Skip to main content

st/web_dashboard/
mcp_http.rs

1//! HTTP MCP Endpoints for Smart Tree Daemon
2//!
3//! Provides MCP (Model Context Protocol) over HTTP instead of stdio.
4//! This enables multiple AIs to connect simultaneously and share context
5//! through the daemon's central brain.
6//!
7//! Endpoints:
8//! - GET  /mcp/sse - SSE endpoint for MCP (Claude Code compatible)
9//! - POST /mcp/message - Send JSON-RPC message (for SSE clients)
10//! - POST /mcp/initialize - Initialize MCP session
11//! - GET  /mcp/tools/list - List available tools
12//! - POST /mcp/tools/call - Execute a tool
13//! - GET  /mcp/resources/list - List resources
14//! - GET  /mcp/prompts/list - List prompts
15//!
16//! ~ The Custodian watches all operations through here ~
17
18use axum::{
19    extract::State,
20    http::StatusCode,
21    response::{
22        sse::{Event, KeepAlive, Sse},
23        IntoResponse,
24    },
25    Json,
26};
27use futures::stream::{self, Stream};
28use serde::{Deserialize, Serialize};
29use serde_json::{json, Value};
30use std::convert::Infallible;
31use std::sync::Arc;
32use tokio::sync::RwLock;
33
34use crate::mcp::{McpConfig, McpContext};
35use crate::mcp::consciousness::ConsciousnessManager;
36
37/// Shared MCP context for HTTP handlers
38pub type SharedMcpContext = Arc<RwLock<Option<Arc<McpContext>>>>;
39
40/// Create a new shared MCP context
41pub fn create_mcp_context() -> SharedMcpContext {
42    Arc::new(RwLock::new(None))
43}
44
45/// Initialize MCP context lazily on first request
46async fn ensure_mcp_context(state: &SharedMcpContext) -> Arc<McpContext> {
47    let read_guard = state.read().await;
48    if let Some(ctx) = read_guard.as_ref() {
49        return ctx.clone();
50    }
51    drop(read_guard);
52
53    // Create new context
54    let config = McpConfig::default();
55    let consciousness = Arc::new(tokio::sync::Mutex::new(ConsciousnessManager::new_silent()));
56
57    let ctx = Arc::new(McpContext {
58        cache: Arc::new(crate::mcp::cache::AnalysisCache::new(config.cache_ttl)),
59        config: Arc::new(config),
60        permissions: Arc::new(tokio::sync::Mutex::new(crate::mcp::permissions::PermissionCache::new())),
61        sessions: Arc::new(crate::mcp::session::SessionManager::new()),
62        assistant: Arc::new(crate::mcp::assistant::McpAssistant::new()),
63        consciousness,
64        dashboard_bridge: None,
65    });
66
67    let mut write_guard = state.write().await;
68    *write_guard = Some(ctx.clone());
69    ctx
70}
71
72// =============================================================================
73// REQUEST/RESPONSE TYPES
74// =============================================================================
75
76#[derive(Debug, Deserialize)]
77pub struct McpInitializeRequest {
78    #[serde(default)]
79    pub client_info: Option<ClientInfo>,
80}
81
82#[derive(Debug, Deserialize)]
83pub struct ClientInfo {
84    pub name: Option<String>,
85    pub version: Option<String>,
86}
87
88#[derive(Debug, Serialize)]
89pub struct McpInitializeResponse {
90    pub protocol_version: String,
91    pub server_info: ServerInfo,
92    pub capabilities: Capabilities,
93}
94
95#[derive(Debug, Serialize)]
96pub struct ServerInfo {
97    pub name: String,
98    pub version: String,
99    pub description: String,
100}
101
102#[derive(Debug, Serialize)]
103pub struct Capabilities {
104    pub tools: ToolCapabilities,
105    pub resources: ResourceCapabilities,
106    pub prompts: PromptCapabilities,
107}
108
109#[derive(Debug, Serialize)]
110pub struct ToolCapabilities {
111    pub list_changed: bool,
112}
113
114#[derive(Debug, Serialize)]
115pub struct ResourceCapabilities {
116    pub subscribe: bool,
117    pub list_changed: bool,
118}
119
120#[derive(Debug, Serialize)]
121pub struct PromptCapabilities {
122    pub list_changed: bool,
123}
124
125#[derive(Debug, Deserialize)]
126pub struct ToolCallRequest {
127    pub name: String,
128    #[serde(default)]
129    pub arguments: Option<Value>,
130}
131
132// =============================================================================
133// HANDLERS
134// =============================================================================
135
136/// POST /mcp/initialize - Initialize MCP session
137pub async fn mcp_initialize(
138    State(state): State<SharedMcpContext>,
139    Json(req): Json<McpInitializeRequest>,
140) -> impl IntoResponse {
141    let _ctx = ensure_mcp_context(&state).await;
142
143    // Log the connecting client
144    if let Some(client) = &req.client_info {
145        tracing::info!(
146            "MCP client connected: {} v{}",
147            client.name.as_deref().unwrap_or("unknown"),
148            client.version.as_deref().unwrap_or("?")
149        );
150    }
151
152    Json(McpInitializeResponse {
153        protocol_version: "2025-06-18".to_string(),
154        server_info: ServerInfo {
155            name: "smart-tree".to_string(),
156            version: env!("CARGO_PKG_VERSION").to_string(),
157            description: "Smart Tree Daemon - HTTP MCP with The Custodian watching".to_string(),
158        },
159        capabilities: Capabilities {
160            tools: ToolCapabilities { list_changed: false },
161            resources: ResourceCapabilities { subscribe: false, list_changed: false },
162            prompts: PromptCapabilities { list_changed: false },
163        },
164    })
165}
166
167/// GET /mcp/tools/list - List available MCP tools
168pub async fn mcp_tools_list(
169    State(state): State<SharedMcpContext>,
170) -> impl IntoResponse {
171    let _ctx = ensure_mcp_context(&state).await;
172
173    // Get the enhanced consolidated tools
174    let tools = crate::mcp::tools_consolidated_enhanced::get_enhanced_consolidated_tools();
175    let welcome = crate::mcp::tools_consolidated_enhanced::get_welcome_message();
176
177    Json(json!({
178        "tools": tools,
179        "_welcome": welcome,
180        "_custodian": "🧹 The Custodian is watching. All operations are monitored for your protection."
181    }))
182}
183
184/// POST /mcp/tools/call - Execute an MCP tool
185pub async fn mcp_tools_call(
186    State(state): State<SharedMcpContext>,
187    Json(req): Json<ToolCallRequest>,
188) -> impl IntoResponse {
189    let ctx = ensure_mcp_context(&state).await;
190
191    // === THE CUSTODIAN CHECKPOINT ===
192    // Before executing any tool, The Custodian evaluates the operation
193    let custodian_alert = evaluate_operation(&req.name, &req.arguments);
194    if let Some(alert) = &custodian_alert {
195        tracing::warn!("🧹 Custodian Alert: {}", alert);
196    }
197
198    // Dispatch to the consolidated tool handler
199    let result = crate::mcp::tools_consolidated_enhanced::dispatch_consolidated_tool(
200        &req.name,
201        req.arguments,
202        ctx,
203    ).await;
204
205    match result {
206        Ok(mut value) => {
207            // Include Custodian alert in successful responses if present
208            if let Some(alert) = custodian_alert {
209                if let Some(obj) = value.as_object_mut() {
210                    obj.insert("_custodian_alert".to_string(), json!(alert));
211                }
212            }
213            (StatusCode::OK, Json(value))
214        }
215        Err(e) => (
216            StatusCode::INTERNAL_SERVER_ERROR,
217            Json(json!({
218                "error": {
219                    "code": -32603,
220                    "message": e.to_string()
221                }
222            }))
223        )
224    }
225}
226
227/// GET /mcp/resources/list - List available resources
228pub async fn mcp_resources_list(
229    State(_state): State<SharedMcpContext>,
230) -> impl IntoResponse {
231    // For now, return empty - resources are mostly file-based
232    Json(json!({
233        "resources": []
234    }))
235}
236
237/// GET /mcp/prompts/list - List available prompts
238pub async fn mcp_prompts_list(
239    State(_state): State<SharedMcpContext>,
240) -> impl IntoResponse {
241    Json(json!({
242        "prompts": [
243            {
244                "name": "project-overview",
245                "description": "Get a comprehensive overview of a project",
246                "arguments": [
247                    {
248                        "name": "path",
249                        "description": "Path to the project",
250                        "required": false
251                    }
252                ]
253            },
254            {
255                "name": "code-review",
256                "description": "Review code changes in a file or directory",
257                "arguments": [
258                    {
259                        "name": "path",
260                        "description": "Path to review",
261                        "required": true
262                    }
263                ]
264            }
265        ]
266    }))
267}
268
269// =============================================================================
270// THE CUSTODIAN - Operation Evaluation
271// =============================================================================
272
273/// Evaluate an operation for suspicious patterns
274/// Returns an alert message if something looks concerning
275fn evaluate_operation(tool_name: &str, args: &Option<Value>) -> Option<String> {
276    // Patterns that The Custodian watches for:
277
278    // 1. External data transmission
279    if let Some(args) = args {
280        let args_str = args.to_string().to_lowercase();
281
282        // IPFS/IPNS gateways - code leaving the machine
283        if args_str.contains("ipfs") || args_str.contains("ipns")
284            || args_str.contains("dweb.link") || args_str.contains("w3s.link") {
285            return Some(format!(
286                "🧹 Custodian Notice: Operation '{}' references IPFS/IPNS. \
287                 Data may be transmitted to external decentralized storage. \
288                 Verify this is intentional.",
289                tool_name
290            ));
291        }
292
293        // Sensitive file patterns
294        if args_str.contains(".env") || args_str.contains("credentials")
295            || args_str.contains("secret") || args_str.contains(".ssh")
296            || args_str.contains("private_key") {
297            return Some(format!(
298                "🧹 Custodian Notice: Operation '{}' involves potentially sensitive files. \
299                 Please verify this access is authorized.",
300                tool_name
301            ));
302        }
303
304        // External URLs in write operations
305        if (tool_name.contains("write") || tool_name.contains("edit"))
306            && (args_str.contains("http://") || args_str.contains("https://")) {
307            return Some(format!(
308                "🧹 Custodian Notice: Write operation '{}' contains external URLs. \
309                 Verify the destination is trusted.",
310                tool_name
311            ));
312        }
313    }
314
315    // 2. Known risky tool patterns
316    if tool_name == "smart_edit" || tool_name == "write_file" {
317        // Log all write operations for audit
318        tracing::debug!("🧹 Custodian: Recording write operation - {}", tool_name);
319    }
320
321    None
322}
323
324// =============================================================================
325// SSE ENDPOINT (Claude Code Compatible)
326// =============================================================================
327
328/// GET /mcp/sse - Server-Sent Events endpoint for MCP
329///
330/// This implements the MCP SSE transport protocol:
331/// 1. Client connects here
332/// 2. Server sends an "endpoint" event with the message POST URL
333/// 3. Client POSTs JSON-RPC messages to that URL
334/// 4. Server streams responses back here
335pub async fn mcp_sse_handler(
336    State(_state): State<SharedMcpContext>,
337) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
338    // Generate a unique session ID
339    let session_id = uuid::Uuid::new_v4().to_string();
340
341    // Create the initial events stream
342    // Note: endpoint must be absolute URL for Claude Code compatibility
343    let events = stream::iter(vec![
344        // Send the endpoint event as required by MCP SSE protocol
345        Ok(Event::default()
346            .event("endpoint")
347            .data(format!("http://localhost:28428/mcp/message?session_id={}", session_id))),
348        // Send a welcome message
349        Ok(Event::default()
350            .event("message")
351            .data(serde_json::to_string(&json!({
352                "jsonrpc": "2.0",
353                "method": "notifications/initialized",
354                "params": {
355                    "_custodian": "🧹 The Custodian is watching. Welcome to Smart Tree MCP!",
356                    "serverInfo": {
357                        "name": "smart-tree",
358                        "version": env!("CARGO_PKG_VERSION")
359                    }
360                }
361            })).unwrap_or_default())),
362    ]);
363
364    Sse::new(events).keep_alive(KeepAlive::default())
365}
366
367/// POST /mcp/message - Receive JSON-RPC messages from SSE clients
368pub async fn mcp_message_handler(
369    State(state): State<SharedMcpContext>,
370    Json(request): Json<Value>,
371) -> impl IntoResponse {
372    let ctx = ensure_mcp_context(&state).await;
373
374    // Parse JSON-RPC request
375    let method = request["method"].as_str().unwrap_or("");
376    let id = request.get("id").cloned();
377    let params = request.get("params").cloned();
378
379    // === THE CUSTODIAN CHECKPOINT ===
380    if let Some(name) = request["params"]["name"].as_str() {
381        if let Some(alert) = evaluate_operation(name, &params) {
382            tracing::warn!("🧹 Custodian Alert: {}", alert);
383        }
384    }
385
386    // Route to appropriate handler
387    let result = match method {
388        "initialize" => {
389            json!({
390                "protocolVersion": "2024-11-05",
391                "serverInfo": {
392                    "name": "smart-tree",
393                    "version": env!("CARGO_PKG_VERSION")
394                },
395                "capabilities": {
396                    "tools": { "listChanged": true },
397                    "resources": { "listChanged": true },
398                    "prompts": { "listChanged": true }
399                },
400                "_custodian": "🧹 The Custodian is watching all operations."
401            })
402        }
403        "tools/list" => {
404            let tools = crate::mcp::tools_consolidated_enhanced::get_enhanced_consolidated_tools();
405            json!({ "tools": tools })
406        }
407        "tools/call" => {
408            let tool_name = request["params"]["name"].as_str().unwrap_or("");
409            let arguments = request["params"]["arguments"].clone();
410
411            match crate::mcp::tools_consolidated_enhanced::dispatch_consolidated_tool(
412                tool_name,
413                Some(arguments),
414                ctx,
415            ).await {
416                Ok(result) => result,
417                Err(e) => json!({
418                    "isError": true,
419                    "content": [{ "type": "text", "text": e.to_string() }]
420                })
421            }
422        }
423        "resources/list" => json!({ "resources": [] }),
424        "prompts/list" => json!({ "prompts": [] }),
425        _ => json!({
426            "error": {
427                "code": -32601,
428                "message": format!("Method not found: {}", method)
429            }
430        })
431    };
432
433    // Build JSON-RPC response
434    let response = if let Some(id) = id {
435        json!({
436            "jsonrpc": "2.0",
437            "id": id,
438            "result": result
439        })
440    } else {
441        // Notification - no response needed
442        return (StatusCode::NO_CONTENT, Json(json!({})));
443    };
444
445    (StatusCode::OK, Json(response))
446}
447
448// =============================================================================
449// ROUTER SETUP
450// =============================================================================
451
452use axum::{routing::{get, post}, Router};
453
454/// Create the MCP HTTP router
455pub fn mcp_router(state: SharedMcpContext) -> Router {
456    Router::new()
457        // SSE endpoint (Claude Code compatible)
458        .route("/sse", get(mcp_sse_handler))
459        .route("/message", post(mcp_message_handler))
460        // Legacy REST endpoints (direct HTTP)
461        .route("/initialize", post(mcp_initialize))
462        .route("/tools/list", get(mcp_tools_list))
463        .route("/tools/call", post(mcp_tools_call))
464        .route("/resources/list", get(mcp_resources_list))
465        .route("/prompts/list", get(mcp_prompts_list))
466        .with_state(state)
467}