Skip to main content

ctx/mcp/
server.rs

1//! MCP Server implementation for ctx.
2
3use std::path::PathBuf;
4use std::sync::Mutex;
5
6use rmcp::model::{
7    CallToolRequestParam, CallToolResult, ErrorCode, Implementation, InitializeRequestParam,
8    InitializeResult, ListToolsResult, PaginatedRequestParam, ProtocolVersion, ServerCapabilities,
9    ServerInfo,
10};
11use rmcp::service::{RequestContext, RoleServer};
12use rmcp::transport::io::stdio;
13use rmcp::{ServerHandler, ServiceExt};
14
15use crate::analytics::Analytics;
16use crate::db::Database;
17use crate::index;
18
19use super::tools;
20
21/// MCP server for ctx code intelligence.
22///
23/// Uses Mutex wrappers to make Database and Analytics thread-safe.
24/// Note: The MCP server is single-threaded per request, so contention is minimal.
25pub struct CtxServer {
26    /// Path to the project root
27    root: PathBuf,
28    /// Database connection (wrapped for thread safety)
29    pub(crate) db: Mutex<Database>,
30    /// Analytics engine (optional, wrapped for thread safety)
31    pub(crate) analytics: Option<Mutex<Analytics>>,
32}
33
34impl CtxServer {
35    /// Create a new CtxServer for the given project root.
36    pub fn new(root: PathBuf) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
37        // Open the database
38        let db = index::open_database(&root)
39            .map_err(|e| format!("Failed to open database: {}. Run 'ctx index' first.", e))?;
40
41        // Try to open analytics (optional)
42        let analytics = Analytics::open(&root).ok().map(Mutex::new);
43
44        Ok(Self {
45            root,
46            db: Mutex::new(db),
47            analytics,
48        })
49    }
50
51    /// Get the project root path.
52    pub fn root(&self) -> &PathBuf {
53        &self.root
54    }
55
56    /// Execute a function with the database.
57    pub fn with_db<F, R>(&self, f: F) -> R
58    where
59        F: FnOnce(&Database) -> R,
60    {
61        let db = self.db.lock().unwrap();
62        f(&db)
63    }
64
65    /// Execute a function with analytics if available.
66    #[allow(dead_code)]
67    pub fn with_analytics<F, R>(&self, f: F) -> Option<R>
68    where
69        F: FnOnce(&Analytics) -> R,
70    {
71        self.analytics.as_ref().map(|a| {
72            let analytics = a.lock().unwrap();
73            f(&analytics)
74        })
75    }
76}
77
78impl ServerHandler for CtxServer {
79    fn get_info(&self) -> ServerInfo {
80        ServerInfo {
81            protocol_version: ProtocolVersion::LATEST,
82            capabilities: ServerCapabilities::builder().enable_tools().build(),
83            server_info: Implementation {
84                name: "ctx".into(),
85                version: env!("CARGO_PKG_VERSION").into(),
86                title: None,
87                icons: None,
88                website_url: None,
89            },
90            instructions: None,
91        }
92    }
93
94    async fn initialize(
95        &self,
96        _request: InitializeRequestParam,
97        _context: RequestContext<RoleServer>,
98    ) -> Result<InitializeResult, rmcp::ErrorData> {
99        Ok(self.get_info())
100    }
101
102    async fn list_tools(
103        &self,
104        _request: Option<PaginatedRequestParam>,
105        _context: RequestContext<RoleServer>,
106    ) -> Result<ListToolsResult, rmcp::ErrorData> {
107        Ok(ListToolsResult {
108            tools: tools::get_all_tools(),
109            next_cursor: None,
110            meta: None,
111        })
112    }
113
114    async fn call_tool(
115        &self,
116        request: CallToolRequestParam,
117        _context: RequestContext<RoleServer>,
118    ) -> Result<CallToolResult, rmcp::ErrorData> {
119        let name = request.name.as_ref();
120        let args = request.arguments.as_ref();
121
122        match name {
123            // Search tools
124            "search_symbols" => tools::search::search_symbols(self, args).await,
125            "get_definition" => tools::search::get_definition(self, args).await,
126            "find_references" => tools::search::find_references(self, args).await,
127
128            // File tools
129            "get_file" => tools::files::get_file(self, args).await,
130            "get_file_tree" => tools::files::get_file_tree(self, args).await,
131
132            // Analysis tools
133            "get_callers" => tools::analysis::get_callers(self, args).await,
134            "get_callees" => tools::analysis::get_callees(self, args).await,
135            "smart_context" => tools::analysis::smart_context(self, args).await,
136
137            _ => Err(rmcp::ErrorData::new(
138                ErrorCode::METHOD_NOT_FOUND,
139                format!("Unknown tool: {}", name),
140                None,
141            )),
142        }
143    }
144}
145
146/// Run the MCP server over stdio.
147pub async fn run_mcp_server(root: PathBuf) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
148    let server = CtxServer::new(root)?;
149
150    // Create stdio transport
151    let transport = stdio();
152
153    // Serve the MCP protocol
154    let service = server.serve(transport).await?;
155
156    // Wait for the service to complete
157    service.waiting().await?;
158
159    Ok(())
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use std::fs;
166    use tempfile::TempDir;
167
168    use crate::index::Indexer;
169
170    /// Create a test database with some symbols for testing.
171    fn setup_test_project() -> (TempDir, PathBuf) {
172        let temp_dir = TempDir::new().unwrap();
173        let root = temp_dir.path().to_path_buf();
174
175        // Create a simple Rust file
176        let src_dir = root.join("src");
177        fs::create_dir_all(&src_dir).unwrap();
178        fs::write(
179            src_dir.join("lib.rs"),
180            r#"
181/// A test function.
182pub fn hello_world() -> String {
183    "Hello, World!".to_string()
184}
185
186/// Another function that calls hello_world.
187pub fn greet() -> String {
188    hello_world()
189}
190"#,
191        )
192        .unwrap();
193
194        // Index the project using Indexer
195        let mut indexer =
196            Indexer::with_config(&root, false, crate::walker::WalkerConfig::default()).unwrap();
197        indexer.index().unwrap();
198
199        (temp_dir, root)
200    }
201
202    #[test]
203    fn test_mcp_server_startup() {
204        let (_temp_dir, root) = setup_test_project();
205
206        // Create server - this should succeed with an indexed database
207        let server =
208            CtxServer::new(root.clone()).expect("Server should start with indexed database");
209
210        // Verify server has correct root
211        assert_eq!(server.root(), &root);
212
213        // Verify we can access the database
214        let symbol_count = server.with_db(|db| db.find_symbols("hello", 10).unwrap().len());
215        assert!(symbol_count > 0, "Should find indexed symbols");
216    }
217
218    #[test]
219    fn test_mcp_server_without_index_fails() {
220        let temp_dir = TempDir::new().unwrap();
221        let root = temp_dir.path().to_path_buf();
222
223        // Create an empty project without indexing
224        fs::create_dir_all(root.join("src")).unwrap();
225        fs::write(root.join("src/lib.rs"), "fn test() {}").unwrap();
226
227        // Server creation should fail without index
228        let result = CtxServer::new(root);
229        assert!(
230            result.is_err(),
231            "Server should fail without indexed database"
232        );
233    }
234
235    #[test]
236    fn test_server_get_info() {
237        let (_temp_dir, root) = setup_test_project();
238        let server = CtxServer::new(root).unwrap();
239
240        let info = server.get_info();
241        assert_eq!(info.server_info.name.as_str(), "ctx");
242        // Version is populated from CARGO_PKG_VERSION
243        assert!(!info.server_info.version.is_empty());
244    }
245}