Skip to main content

st/mcp/
mod.rs

1//! MCP (Model Context Protocol) server implementation for Smart Tree
2//!
3//! This module provides a JSON-RPC server that exposes Smart Tree's functionality
4//! through the Model Context Protocol, allowing AI assistants to analyze directories.
5
6use crate::compression_manager;
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Value};
10use std::io::{self, BufRead, BufReader, Write};
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14// =============================================================================
15// HEX NUMBER FORMATTING - Token-efficient numeric output for AI contexts
16// =============================================================================
17
18/// Format a number as hex or decimal based on config
19/// In MCP mode, hex is default for token efficiency!
20///
21/// Examples:
22/// - 1000 → "3E8" (hex) or "1000" (decimal)
23/// - 65535 → "FFFF" (hex) or "65535" (decimal)
24/// - 1048576 → "100000" (hex) or "1048576" (decimal) - 1 char saved!
25#[inline]
26pub fn fmt_num(n: usize, hex: bool) -> String {
27    if hex {
28        format!("{:X}", n)
29    } else {
30        n.to_string()
31    }
32}
33
34/// Format a u64 as hex or decimal
35#[inline]
36pub fn fmt_num64(n: u64, hex: bool) -> String {
37    if hex {
38        format!("{:X}", n)
39    } else {
40        n.to_string()
41    }
42}
43
44/// Format a file size with units (always human-readable but hex for raw bytes)
45/// Examples with hex=true:
46/// - 1048576 → "1M" (always uses SI for readability)
47/// - 1234567 → "1.2M"
48pub fn fmt_size(bytes: u64, hex: bool) -> String {
49    if bytes < 1024 {
50        if hex {
51            format!("{}B", fmt_num64(bytes, true))
52        } else {
53            format!("{}B", bytes)
54        }
55    } else if bytes < 1024 * 1024 {
56        format!("{:.1}K", bytes as f64 / 1024.0)
57    } else if bytes < 1024 * 1024 * 1024 {
58        format!("{:.1}M", bytes as f64 / (1024.0 * 1024.0))
59    } else {
60        format!("{:.1}G", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
61    }
62}
63
64/// Format a line number for display (right-aligned, 4 chars)
65#[inline]
66pub fn fmt_line(n: usize, hex: bool) -> String {
67    if hex {
68        format!("{:>4X}", n)
69    } else {
70        format!("{:>4}", n)
71    }
72}
73
74pub mod assistant;
75pub mod cache;
76pub mod consciousness;
77pub mod context_absorber;
78mod context_tools;
79pub mod dashboard_bridge;
80mod enhanced_tool_descriptions;
81mod git_memory_integration;
82mod helpers;
83mod hook_tools;
84mod negotiation;
85pub mod permissions;
86mod proactive_assistant;
87mod prompts;
88mod prompts_enhanced;
89mod resources;
90pub mod session;
91pub mod smart_background_searcher;
92pub mod smart_edit;
93mod smart_edit_diff_viewer;
94pub mod smart_project_detector;
95mod sse;
96mod theme_tools;
97mod tools;
98mod tools_consolidated;
99pub mod tools_consolidated_enhanced;
100pub mod unified_watcher;
101pub mod wave_memory;
102
103use assistant::*;
104use cache::*;
105use consciousness::*;
106use negotiation::*;
107use permissions::*;
108#[allow(unused_imports)]
109use prompts::*;
110#[allow(unused_imports)]
111use prompts_enhanced::*;
112use resources::*;
113use session::*;
114use tools::*;
115
116/// Determines if startup messages should be shown based on environment variables.
117/// MCP protocol requires clean JSON-RPC on stdout - stderr messages can confuse clients.
118///
119/// Default: SILENT (no output) - standard MCP server behavior
120///
121/// To enable debug messages:
122/// - MCP_DEBUG: Set to "1" or "true" to show startup messages
123/// - ST_MCP_VERBOSE: Set to "1" or "true" to show startup messages
124///
125/// Returns true only if explicitly enabled.
126fn should_show_startup_messages() -> bool {
127    use std::env;
128
129    // Only show messages if explicitly enabled
130    if let Ok(val) = env::var("MCP_DEBUG") {
131        if val == "1" || val.to_lowercase() == "true" {
132            return true;
133        }
134    }
135
136    if let Ok(val) = env::var("ST_MCP_VERBOSE") {
137        if val == "1" || val.to_lowercase() == "true" {
138            return true;
139        }
140    }
141
142    // Default: silent (standard MCP server behavior)
143    false
144}
145
146/// MCP server implementation
147pub struct McpServer {
148    context: Arc<McpContext>,
149    consciousness: Arc<tokio::sync::Mutex<ConsciousnessManager>>,
150}
151
152/// Shared context for MCP handlers
153#[derive(Clone)]
154pub struct McpContext {
155    /// Cache for analysis results
156    pub cache: Arc<AnalysisCache>,
157    /// Server configuration
158    pub config: Arc<McpConfig>,
159    /// Permission cache
160    pub permissions: Arc<tokio::sync::Mutex<PermissionCache>>,
161    /// Session manager for compression negotiation
162    pub sessions: Arc<SessionManager>,
163    /// Intelligent assistant for helpful recommendations
164    pub assistant: Arc<McpAssistant>,
165    /// Consciousness persistence manager
166    pub consciousness: Arc<tokio::sync::Mutex<ConsciousnessManager>>,
167    /// Optional bridge to web dashboard for real-time activity visualization
168    pub dashboard_bridge: Option<dashboard_bridge::DashboardBridge>,
169}
170
171/// MCP server configuration
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct McpConfig {
174    /// Enable caching
175    pub cache_enabled: bool,
176    /// Cache TTL in seconds
177    pub cache_ttl: u64,
178    /// Maximum cache size in bytes
179    pub max_cache_size: usize,
180    /// Allowed paths for security
181    pub allowed_paths: Vec<PathBuf>,
182    /// Blocked paths for security
183    pub blocked_paths: Vec<PathBuf>,
184    /// Use consolidated tools (reduces tool count from 50+ to ~15)
185    pub use_consolidated_tools: bool,
186    /// Use hexadecimal for all numbers (saves tokens!)
187    /// Line 1000 → 3E8, size 1048576 → 100000
188    pub hex_numbers: bool,
189}
190
191impl Default for McpConfig {
192    fn default() -> Self {
193        Self {
194            cache_enabled: true,
195            cache_ttl: 300,                    // 5 minutes
196            max_cache_size: 100 * 1024 * 1024, // 100MB
197            allowed_paths: vec![],
198            blocked_paths: vec![
199                PathBuf::from("/etc"),
200                PathBuf::from("/sys"),
201                PathBuf::from("/proc"),
202            ],
203            use_consolidated_tools: true, // Default to consolidated for Cursor compatibility
204            hex_numbers: true,            // Default to hex for token efficiency!
205        }
206    }
207}
208
209/// JSON-RPC request structure
210#[derive(Debug, Deserialize)]
211struct JsonRpcRequest {
212    #[allow(dead_code)]
213    jsonrpc: String,
214    method: String,
215    params: Option<Value>,
216    id: Option<Value>,
217}
218
219/// JSON-RPC response structure
220#[derive(Debug, Serialize)]
221struct JsonRpcResponse {
222    jsonrpc: String,
223    #[serde(skip_serializing_if = "Option::is_none")]
224    result: Option<Value>,
225    #[serde(skip_serializing_if = "Option::is_none")]
226    error: Option<JsonRpcError>,
227    id: Option<Value>,
228}
229
230/// JSON-RPC error structure
231#[derive(Debug, Serialize)]
232struct JsonRpcError {
233    code: i32,
234    message: String,
235    #[serde(skip_serializing_if = "Option::is_none")]
236    data: Option<Value>,
237}
238
239impl McpServer {
240    /// Create a new MCP server
241    pub fn new(config: McpConfig) -> Self {
242        // Use silent constructor - MCP protocol requires clean stdout
243        let consciousness = Arc::new(tokio::sync::Mutex::new(ConsciousnessManager::new_silent()));
244
245        let context = Arc::new(McpContext {
246            cache: Arc::new(AnalysisCache::new(config.cache_ttl)),
247            config: Arc::new(config),
248            permissions: Arc::new(tokio::sync::Mutex::new(PermissionCache::new())),
249            sessions: Arc::new(SessionManager::new()),
250            assistant: Arc::new(McpAssistant::new()),
251            consciousness: consciousness.clone(),
252            dashboard_bridge: None,
253        });
254
255        Self {
256            context,
257            consciousness,
258        }
259    }
260
261    /// Run the MCP server on stdio
262    pub async fn run_stdio(&self) -> Result<()> {
263        let stdin = io::stdin();
264        let stdout = io::stdout();
265        let mut reader = BufReader::new(stdin);
266        let mut stdout = stdout.lock();
267
268        // Restore previous consciousness silently (no output that would break MCP protocol)
269        {
270            let mut consciousness = self.consciousness.lock().await;
271            let _ = consciousness.restore_silent(); // Silent restore, no stderr output
272        }
273
274        // MCP protocol requires clean JSON-RPC on stdout
275        // All debug/info messages go to stderr, only when not in quiet mode
276        // Respects environment variables: MCP_QUIET, NO_STARTUP_MESSAGES, RUST_LOG
277        if should_show_startup_messages() {
278            eprintln!(
279                "<!-- Smart Tree MCP server v{} started -->",
280                env!("CARGO_PKG_VERSION")
281            );
282            eprintln!("<!--   Protocol: MCP v1.0 -->");
283        }
284
285        loop {
286            let mut line = String::new();
287            match reader.read_line(&mut line) {
288                Ok(0) => break, // EOF
289                Ok(_) => {
290                    let line = line.trim();
291                    if line.is_empty() {
292                        continue;
293                    }
294
295                    match self.handle_request(line).await {
296                        Ok(response) => {
297                            // Only write response if it's not empty (notifications return empty)
298                            if !response.is_empty() {
299                                writeln!(stdout, "{}", response)?;
300                                stdout.flush()?;
301                            }
302                        }
303                        Err(e) => {
304                            if should_show_startup_messages() {
305                                eprintln!("Error handling request: {e}");
306                            }
307                            let error_response = json!({
308                                "jsonrpc": "2.0",
309                                "error": {
310                                    "code": -32603,
311                                    "message": e.to_string()
312                                },
313                                "id": null
314                            });
315                            writeln!(stdout, "{}", error_response)?;
316                            stdout.flush()?;
317                        }
318                    }
319                }
320                Err(e) => {
321                    if should_show_startup_messages() {
322                        eprintln!("Error reading input: {e}");
323                    }
324                    break;
325                }
326            }
327        }
328
329        if should_show_startup_messages() {
330            eprintln!("Smart Tree MCP server stopped");
331        }
332        Ok(())
333    }
334
335    /// Handle a single JSON-RPC request
336    async fn handle_request(&self, request_str: &str) -> Result<String> {
337        // Parse JSON-RPC request
338        let request: JsonRpcRequest =
339            serde_json::from_str(request_str).context("Failed to parse JSON-RPC request")?;
340
341        // Check for compression support in every request
342        if let Some(ref params) = request.params {
343            compression_manager::check_client_compression_support(params);
344        }
345
346        // Check if this is a notification (no id field)
347        let is_notification = request.id.is_none();
348
349        // Handle notifications that don't expect responses
350        if is_notification && request.method == "notifications/initialized" {
351            // Just acknowledge receipt, don't send response
352            if should_show_startup_messages() {
353                eprintln!("Received notification: notifications/initialized");
354            }
355            return Ok(String::new()); // Return empty string to skip response
356        }
357
358        // Handle logging/setLevel notification
359        if is_notification && request.method == "logging/setLevel" {
360            // Extract log level from params if provided
361            if should_show_startup_messages() {
362                let level = request
363                    .params
364                    .as_ref()
365                    .and_then(|p| p.get("level"))
366                    .and_then(|v| v.as_str())
367                    .unwrap_or("unspecified");
368                eprintln!("Received logging/setLevel notification: level={}", level);
369            }
370            return Ok(String::new()); // Return empty string to skip response
371        }
372
373        // Route the request
374        let result = match request.method.as_str() {
375            "initialize" => {
376                // Use session-aware initialization if ST_SESSION_AWARE is set
377                if std::env::var("ST_SESSION_AWARE").is_ok() {
378                    handle_session_aware_initialize(request.params, self.context.clone()).await
379                } else {
380                    handle_initialize(request.params, self.context.clone()).await
381                }
382            }
383            "session/negotiate" => {
384                handle_negotiate_session(request.params, self.context.clone()).await
385            }
386            "tools/list" => {
387                if self.context.config.use_consolidated_tools {
388                    handle_consolidated_tools_list(request.params, self.context.clone()).await
389                } else {
390                    handle_tools_list(request.params, self.context.clone()).await
391                }
392            }
393            "tools/call" => {
394                if self.context.config.use_consolidated_tools {
395                    handle_consolidated_tools_call(
396                        request.params.unwrap_or(json!({})),
397                        self.context.clone(),
398                    )
399                    .await
400                } else {
401                    handle_tools_call(request.params.unwrap_or(json!({})), self.context.clone())
402                        .await
403                }
404            }
405            "resources/list" => handle_resources_list(request.params, self.context.clone()).await,
406            "resources/read" => {
407                handle_resources_read(request.params.unwrap_or(json!({})), self.context.clone())
408                    .await
409            }
410            "prompts/list" => {
411                // Use enhanced prompts by default, fall back to legacy if needed
412                prompts_enhanced::handle_prompts_list(request.params, self.context.clone()).await
413            }
414            "prompts/get" => {
415                prompts_enhanced::handle_prompts_get(
416                    request.params.unwrap_or(json!({})),
417                    self.context.clone(),
418                )
419                .await
420            }
421            "notifications/cancelled" => {
422                // This is also a notification but might need handling
423                if is_notification {
424                    if should_show_startup_messages() {
425                        eprintln!("Received notification: notifications/cancelled");
426                    }
427                    return Ok(String::new());
428                }
429                handle_cancelled(request.params, self.context.clone()).await
430            }
431            _ => Err(anyhow::anyhow!("Method not found: {}", request.method)),
432        };
433
434        // Don't send response for notifications (they don't expect responses)
435        if is_notification {
436            // Log unknown notifications for debugging (only if verbose)
437            if result.is_err() && should_show_startup_messages() {
438                eprintln!(
439                    "Received unknown notification: {} (notifications don't return errors)",
440                    request.method
441                );
442            }
443            return Ok(String::new());
444        }
445
446        // Build response for requests only
447        let response = match result {
448            Ok(result) => JsonRpcResponse {
449                jsonrpc: "2.0".to_string(),
450                result: Some(result),
451                error: None,
452                id: request.id,
453            },
454            Err(e) => JsonRpcResponse {
455                jsonrpc: "2.0".to_string(),
456                result: None,
457                error: Some(JsonRpcError {
458                    code: -32603,
459                    message: e.to_string(),
460                    data: None,
461                }),
462                id: request.id,
463            },
464        };
465
466        // Smart compress the response if needed
467        let mut response_value = serde_json::to_value(&response)?;
468        compression_manager::smart_compress_mcp_response(&mut response_value)?;
469
470        Ok(serde_json::to_string(&response_value)?)
471    }
472}
473
474// Handler implementations
475
476async fn handle_initialize(params: Option<Value>, _ctx: Arc<McpContext>) -> Result<Value> {
477    // Check if client supports compression from their request
478    if let Some(params) = params {
479        compression_manager::check_client_compression_support(&params);
480    }
481
482    // Check for updates when MCP tools initialize (non-blocking)
483    let update_info = check_for_mcp_updates().await;
484
485    // Add compression test to response
486    let compression_test = compression_manager::create_compression_test();
487
488    Ok(json!({
489        "protocolVersion": "2025-06-18",
490        "capabilities": {
491            "tools": {
492                "listChanged": false
493            },
494            "resources": {
495                "subscribe": false,
496                "listChanged": false
497            },
498            "prompts": {
499                "listChanged": false
500            },
501            "logging": {}
502        },
503        "serverInfo": {
504            "name": "smart-tree",
505            "version": env!("CARGO_PKG_VERSION"),
506            "vendor": "8b-is",
507            "description": "Smart Tree v5 - NOW WITH COMPRESSION HINTS! 🗜️ Use compress:true for 80% smaller outputs. For massive codebases, use mode:'quantum' for 100x compression!",
508            "homepage": env!("CARGO_PKG_REPOSITORY"),
509            "features": [
510                "quantum-compression",
511                "mcp-optimization",
512                "content-search",
513                "streaming",
514                "caching",
515                "emotional-mode",
516                "auto-compression-hints"
517            ],
518            "compression_hint": "💡 Always add compress:true to analyze tools for optimal context usage!",
519            "update_info": update_info,
520            "compression_test": compression_test
521        }
522    }))
523}
524
525/// Handle MCP notification that a request was cancelled
526///
527/// When an AI assistant cancels a long-running operation, we acknowledge it gracefully.
528/// This helps with cleanup and prevents orphaned operations.
529async fn handle_cancelled(params: Option<Value>, _ctx: Arc<McpContext>) -> Result<Value> {
530    // Extract the request ID that was cancelled (if provided)
531    let request_id = params
532        .as_ref()
533        .and_then(|p| p.get("requestId"))
534        .and_then(|id| id.as_str())
535        .unwrap_or("unknown");
536
537    // Log to stderr for debugging (only if MCP_DEBUG is enabled)
538    if should_show_startup_messages() {
539        eprintln!("[MCP] Request cancelled: {}", request_id);
540    }
541
542    // Acknowledge the cancellation - MCP protocol expects a response
543    Ok(json!({
544        "acknowledged": true,
545        "request_id": request_id,
546        "message": "Request cancellation acknowledged"
547    }))
548}
549
550/// Handle consolidated tools list request
551async fn handle_consolidated_tools_list(
552    _params: Option<Value>,
553    _ctx: Arc<McpContext>,
554) -> Result<Value> {
555    // Use the enhanced tools with tips and examples
556    let tools = tools_consolidated_enhanced::get_enhanced_consolidated_tools();
557
558    // Also include a welcome message for first-time AI assistants
559    let welcome = tools_consolidated_enhanced::get_welcome_message();
560
561    Ok(json!({
562        "tools": tools,
563        "_welcome": welcome
564    }))
565}
566
567/// Handle consolidated tools call request
568async fn handle_consolidated_tools_call(params: Value, ctx: Arc<McpContext>) -> Result<Value> {
569    let tool_name = params["name"]
570        .as_str()
571        .ok_or_else(|| anyhow::anyhow!("Missing tool name"))?;
572    let args = params.get("arguments").cloned();
573
574    // The consolidated tools already return properly formatted responses
575    let result = tools_consolidated_enhanced::dispatch_consolidated_tool(tool_name, args, ctx).await?;
576
577    // Global safeguard: Prevent returning massive context to the AI
578    let stringified = serde_json::to_string(&result)?;
579    if stringified.len() > 50_000 {
580        return Ok(json!({
581            "content": [{
582                "type": "text",
583                "text": format!("⚠️ ERROR: Tool '{}' response was too large to return ({} bytes, max 50,000). The operation succeeded, but returning the data would overwhelm your context window.\n\nPlease use the 'limit' and 'offset' parameters to paginate through the results, or narrow the search parameters.", tool_name, stringified.len())
584            }]
585        }));
586    }
587
588    Ok(result)
589}
590
591/// Check for updates when MCP tools initialize (non-blocking, with timeout)
592async fn check_for_mcp_updates() -> Value {
593    // Skip if disabled or in privacy mode
594    let flags = crate::feature_flags::features();
595    if flags.privacy_mode || flags.disable_external_connections {
596        return json!(null);
597    }
598
599    // Skip if explicitly disabled
600    if std::env::var("SMART_TREE_NO_UPDATE_CHECK").is_ok() {
601        return json!(null);
602    }
603
604    // Get system info for analytics (helps decide what platforms to support)
605    let platform = std::env::consts::OS;
606    let arch = std::env::consts::ARCH;
607    let current_version = env!("CARGO_PKG_VERSION");
608
609    // Use tokio timeout to prevent blocking
610    let timeout_duration = tokio::time::Duration::from_secs(2);
611
612    let result = tokio::time::timeout(timeout_duration, async {
613        // Build request with platform info for analytics
614        let client = reqwest::Client::builder()
615            .timeout(std::time::Duration::from_secs(2))
616            .build()
617            .ok()?;
618
619        let api_url = std::env::var("SMART_TREE_FEEDBACK_API")
620            .unwrap_or_else(|_| "https://f.8b.is".to_string());
621
622        // Include platform info to help understand usage (Windows ARM, etc.)
623        let check_url = format!(
624            "{}/mcp/check?version={}&platform={}&arch={}",
625            api_url, current_version, platform, arch
626        );
627
628        let response = client.get(&check_url).send().await.ok()?;
629
630        if !response.status().is_success() {
631            return None;
632        }
633
634        response.json::<Value>().await.ok()
635    })
636    .await;
637
638    match result {
639        Ok(Some(update_data)) => {
640            // Return update info if available
641            if update_data["update_available"].as_bool().unwrap_or(false) {
642                json!({
643                    "available": true,
644                    "latest_version": update_data["latest_version"],
645                    "new_features": update_data["new_features"],
646                    "message": update_data["message"]
647                })
648            } else {
649                json!({
650                    "available": false,
651                    "message": "You're running the latest version!"
652                })
653            }
654        }
655        _ => json!(null), // Return null if check failed or timed out
656    }
657}
658
659/// Check if a path is allowed based on security configuration
660pub fn is_path_allowed(path: &Path, config: &McpConfig) -> bool {
661    // Check blocked paths first
662    for blocked in &config.blocked_paths {
663        if path.starts_with(blocked) {
664            return false;
665        }
666    }
667
668    // If allowed_paths is empty, allow all non-blocked paths
669    if config.allowed_paths.is_empty() {
670        return true;
671    }
672
673    // Otherwise, check if path is under an allowed path
674    for allowed in &config.allowed_paths {
675        if path.starts_with(allowed) {
676            return true;
677        }
678    }
679
680    false
681}
682
683/// Load MCP configuration from file or use defaults
684pub fn load_config() -> Result<McpConfig> {
685    let config_path = dirs::home_dir()
686        .map(|d| d.join(".st_bumpers").join("mcp-config.toml"))
687        .unwrap_or_else(|| PathBuf::from(".st_bumpers/mcp-config.toml"));
688
689    if config_path.exists() {
690        let config_str =
691            std::fs::read_to_string(&config_path).context("Failed to read MCP config file")?;
692        toml::from_str(&config_str).context("Failed to parse MCP config file")
693    } else {
694        Ok(McpConfig::default())
695    }
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701
702    #[tokio::test]
703    async fn test_logging_setlevel_notification() {
704        let config = McpConfig::default();
705        let server = McpServer::new(config);
706
707        // Test logging/setLevel notification without params
708        let request = r#"{"jsonrpc":"2.0","method":"logging/setLevel"}"#;
709        let response = server.handle_request(request).await.unwrap();
710        assert_eq!(response, "", "Notification should return empty response");
711
712        // Test logging/setLevel notification with level parameter
713        let request_with_level =
714            r#"{"jsonrpc":"2.0","method":"logging/setLevel","params":{"level":"debug"}}"#;
715        let response_with_level = server.handle_request(request_with_level).await.unwrap();
716        assert_eq!(
717            response_with_level, "",
718            "Notification with params should return empty response"
719        );
720    }
721
722    #[tokio::test]
723    async fn test_notifications_initialized() {
724        let config = McpConfig::default();
725        let server = McpServer::new(config);
726
727        // Test notifications/initialized
728        let request = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
729        let response = server.handle_request(request).await.unwrap();
730        assert_eq!(
731            response, "",
732            "notifications/initialized should return empty response"
733        );
734    }
735
736    #[tokio::test]
737    async fn test_unknown_notification() {
738        let config = McpConfig::default();
739        let server = McpServer::new(config);
740
741        // Test unknown notification - should return empty response without error
742        let request = r#"{"jsonrpc":"2.0","method":"notifications/unknown"}"#;
743        let response = server.handle_request(request).await.unwrap();
744        assert_eq!(
745            response, "",
746            "Unknown notification should return empty response"
747        );
748    }
749}