1use 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
21pub struct CtxServer {
26 root: PathBuf,
28 pub(crate) db: Mutex<Database>,
30 pub(crate) analytics: Option<Mutex<Analytics>>,
32}
33
34impl CtxServer {
35 pub fn new(root: PathBuf) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
37 let db = index::open_database(&root)
39 .map_err(|e| format!("Failed to open database: {}. Run 'ctx index' first.", e))?;
40
41 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 pub fn root(&self) -> &PathBuf {
53 &self.root
54 }
55
56 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 #[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_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 "get_file" => tools::files::get_file(self, args).await,
130 "get_file_tree" => tools::files::get_file_tree(self, args).await,
131
132 "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
146pub async fn run_mcp_server(root: PathBuf) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
148 let server = CtxServer::new(root)?;
149
150 let transport = stdio();
152
153 let service = server.serve(transport).await?;
155
156 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 fn setup_test_project() -> (TempDir, PathBuf) {
172 let temp_dir = TempDir::new().unwrap();
173 let root = temp_dir.path().to_path_buf();
174
175 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 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 let server =
208 CtxServer::new(root.clone()).expect("Server should start with indexed database");
209
210 assert_eq!(server.root(), &root);
212
213 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 fs::create_dir_all(root.join("src")).unwrap();
225 fs::write(root.join("src/lib.rs"), "fn test() {}").unwrap();
226
227 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 assert!(!info.server_info.version.is_empty());
244 }
245}