misukisu commited on
Commit
65d01d4
·
verified ·
1 Parent(s): c0141d6

Create mcp.rs

Browse files
Files changed (1) hide show
  1. src/mcp.rs +361 -0
src/mcp.rs ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::indexer::TantivyEngine;
2
+ use crate::parser::{DocumentParser, ParsedDocument};
3
+ use anyhow::Result;
4
+ use axum::{
5
+ extract::State,
6
+ response::sse::{Event, KeepAlive, Sse},
7
+ response::IntoResponse,
8
+ routing::{get, post},
9
+ Json, Router,
10
+ };
11
+ use futures::stream::Stream;
12
+ use rayon::prelude::*;
13
+ use serde::{Deserialize, Serialize};
14
+ use serde_json::{json, Value};
15
+ use std::convert::Infallible;
16
+ use std::path::Path;
17
+ use std::sync::Arc;
18
+ use std::time::Duration;
19
+ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
20
+ use tracing::{error, info};
21
+ use walkdir::WalkDir;
22
+
23
+ #[derive(Serialize, Deserialize, Debug, Clone)]
24
+ pub struct JsonRpcRequest {
25
+ pub jsonrpc: String,
26
+ pub id: Option<Value>,
27
+ pub method: String,
28
+ pub params: Option<Value>,
29
+ }
30
+
31
+ #[derive(Serialize, Deserialize, Debug, Clone)]
32
+ pub struct JsonRpcResponse {
33
+ pub jsonrpc: String,
34
+ #[serde(skip_serializing_if = "Option::is_none")]
35
+ pub id: Option<Value>,
36
+ #[serde(skip_serializing_if = "Option::is_none")]
37
+ pub result: Option<Value>,
38
+ #[serde(skip_serializing_if = "Option::is_none")]
39
+ pub error: Option<JsonRpcError>,
40
+ }
41
+
42
+ #[derive(Serialize, Deserialize, Debug, Clone)]
43
+ pub struct JsonRpcError {
44
+ pub code: i64,
45
+ pub message: String,
46
+ #[serde(skip_serializing_if = "Option::is_none")]
47
+ pub data: Option<Value>,
48
+ }
49
+
50
+ pub struct McpServer {
51
+ engine: Arc<TantivyEngine>,
52
+ }
53
+
54
+ impl McpServer {
55
+ pub fn new(engine: Arc<TantivyEngine>) -> Self {
56
+ Self { engine }
57
+ }
58
+
59
+ pub async fn handle_request(&self, req: JsonRpcRequest) -> JsonRpcResponse {
60
+ let req_id = req.id.clone();
61
+
62
+ match req.method.as_str() {
63
+ "initialize" => {
64
+ let init_result = json!({
65
+ "protocolVersion": "2024-11-05",
66
+ "capabilities": {
67
+ "tools": {}
68
+ },
69
+ "serverInfo": {
70
+ "name": "rust-mcp-search",
71
+ "version": "0.1.0"
72
+ }
73
+ });
74
+ Self::success_response(req_id, init_result)
75
+ }
76
+ "notifications/initialized" | "ping" => {
77
+ Self::success_response(req_id, json!({}))
78
+ }
79
+ "tools/list" => {
80
+ let tools = json!({
81
+ "tools": [
82
+ {
83
+ "name": "parse_and_index",
84
+ "description": "Recursively scans a directory and indexes plain text, Markdown, JSON, PDF, CSV, and DOCX files in parallel using Rayon and Tantivy.",
85
+ "inputSchema": {
86
+ "type": "object",
87
+ "properties": {
88
+ "directory_path": {
89
+ "type": "string",
90
+ "description": "Absolute or relative directory path to index"
91
+ },
92
+ "collection_name": {
93
+ "type": "string",
94
+ "description": "Collection namespace identifier (default: 'default')"
95
+ }
96
+ },
97
+ "required": ["directory_path"]
98
+ }
99
+ },
100
+ {
101
+ "name": "search_documents",
102
+ "description": "Full-text BM25 search over indexed documents with highlighted snippets and relevance scores.",
103
+ "inputSchema": {
104
+ "type": "object",
105
+ "properties": {
106
+ "query": {
107
+ "type": "string",
108
+ "description": "Tantivy BM25 search query"
109
+ },
110
+ "collection_name": {
111
+ "type": "string",
112
+ "description": "Filter results by collection"
113
+ },
114
+ "limit": {
115
+ "type": "integer",
116
+ "description": "Max hits to return (default: 10)"
117
+ }
118
+ },
119
+ "required": ["query"]
120
+ }
121
+ },
122
+ {
123
+ "name": "extract_document_text",
124
+ "description": "High-speed raw text extraction from a single file (TXT, MD, PDF, DOCX, CSV, JSON).",
125
+ "inputSchema": {
126
+ "type": "object",
127
+ "properties": {
128
+ "file_path": {
129
+ "type": "string",
130
+ "description": "Path to document"
131
+ }
132
+ },
133
+ "required": ["file_path"]
134
+ }
135
+ },
136
+ {
137
+ "name": "get_index_stats",
138
+ "description": "Returns indexed document counts, segment counts, active collections, and memory/storage status.",
139
+ "inputSchema": {
140
+ "type": "object",
141
+ "properties": {}
142
+ }
143
+ }
144
+ ]
145
+ });
146
+ Self::success_response(req_id, tools)
147
+ }
148
+ "tools/call" => {
149
+ let params = req.params.unwrap_or_default();
150
+ let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
151
+ let args = params.get("arguments").cloned().unwrap_or(json!({}));
152
+
153
+ match self.dispatch_tool(tool_name, args).await {
154
+ Ok(tool_output) => Self::success_response(
155
+ req_id,
156
+ json!({
157
+ "content": [
158
+ {
159
+ "type": "text",
160
+ "text": tool_output
161
+ }
162
+ ],
163
+ "isError": false
164
+ }),
165
+ ),
166
+ Err(e) => Self::success_response(
167
+ req_id,
168
+ json!({
169
+ "content": [
170
+ {
171
+ "type": "text",
172
+ "text": format!("Error executing tool '{}': {}", tool_name, e)
173
+ }
174
+ ],
175
+ "isError": true
176
+ }),
177
+ ),
178
+ }
179
+ }
180
+ _ => Self::error_response(req_id, -32601, format!("Method '{}' not found", req.method)),
181
+ }
182
+ }
183
+
184
+ async fn dispatch_tool(&self, name: &str, args: Value) -> Result<String> {
185
+ match name {
186
+ "parse_and_index" => {
187
+ let dir = args
188
+ .get("directory_path")
189
+ .and_then(|v| v.as_str())
190
+ .ok_or_else(|| anyhow::anyhow!("Missing 'directory_path' argument"))?;
191
+ let collection = args
192
+ .get("collection_name")
193
+ .and_then(|v| v.as_str())
194
+ .unwrap_or("default");
195
+
196
+ let dir_path = Path::new(dir).to_path_buf();
197
+ if !dir_path.exists() {
198
+ return Err(anyhow::anyhow!("Directory does not exist: {:?}", dir_path));
199
+ }
200
+
201
+ // 1. Collect all valid candidate files
202
+ let paths: Vec<_> = WalkDir::new(&dir_path)
203
+ .into_iter()
204
+ .filter_map(|e| e.ok())
205
+ .filter(|e| e.file_type().is_file())
206
+ .map(|e| e.into_path())
207
+ .collect();
208
+
209
+ let total_found = paths.len();
210
+
211
+ // 2. Parallel ingestion with Rayon
212
+ let parsed_docs: Vec<ParsedDocument> = paths
213
+ .par_iter()
214
+ .filter_map(|path| match DocumentParser::parse_file(path) {
215
+ Ok(doc) => Some(doc),
216
+ Err(err) => {
217
+ tracing::warn!("Skipping {:?}: {}", path, err);
218
+ None
219
+ }
220
+ })
221
+ .collect();
222
+
223
+ let parsed_count = parsed_docs.len();
224
+
225
+ // 3. Batch commit to Tantivy engine
226
+ let indexed_count = self.engine.add_documents(collection, parsed_docs).await?;
227
+
228
+ Ok(json!({
229
+ "status": "success",
230
+ "collection": collection,
231
+ "scanned_files": total_found,
232
+ "parsed_documents": parsed_count,
233
+ "indexed_documents": indexed_count,
234
+ "source_path": dir
235
+ })
236
+ .to_string())
237
+ }
238
+ "search_documents" => {
239
+ let query = args
240
+ .get("query")
241
+ .and_then(|v| v.as_str())
242
+ .ok_or_else(|| anyhow::anyhow!("Missing 'query' argument"))?;
243
+ let collection = args.get("collection_name").and_then(|v| v.as_str());
244
+ let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
245
+
246
+ let hits = self.engine.search(query, collection, limit)?;
247
+ Ok(serde_json::to_string_pretty(&hits)?)
248
+ }
249
+ "extract_document_text" => {
250
+ let path_str = args
251
+ .get("file_path")
252
+ .and_then(|v| v.as_str())
253
+ .ok_or_else(|| anyhow::anyhow!("Missing 'file_path' argument"))?;
254
+
255
+ let doc = DocumentParser::parse_file(path_str)?;
256
+ Ok(json!({
257
+ "path": doc.path,
258
+ "title": doc.title,
259
+ "extension": doc.extension,
260
+ "size_bytes": doc.size_bytes,
261
+ "content": doc.content
262
+ })
263
+ .to_string())
264
+ }
265
+ "get_index_stats" => {
266
+ let stats = self.engine.get_stats()?;
267
+ Ok(serde_json::to_string_pretty(&stats)?)
268
+ }
269
+ unknown => Err(anyhow::anyhow!("Unknown tool name: {}", unknown)),
270
+ }
271
+ }
272
+
273
+ fn success_response(id: Option<Value>, result: Value) -> JsonRpcResponse {
274
+ JsonRpcResponse {
275
+ jsonrpc: "2.0".to_string(),
276
+ id,
277
+ result: Some(result),
278
+ error: None,
279
+ }
280
+ }
281
+
282
+ fn error_response(id: Option<Value>, code: i64, message: String) -> JsonRpcResponse {
283
+ JsonRpcResponse {
284
+ jsonrpc: "2.0".to_string(),
285
+ id,
286
+ result: None,
287
+ error: Some(JsonRpcError {
288
+ code,
289
+ message,
290
+ data: None,
291
+ }),
292
+ }
293
+ }
294
+
295
+ /// Runs the server loop over standard I/O (STDIO) transport
296
+ pub async fn run_stdio(self: Arc<Self>) -> Result<()> {
297
+ let stdin = tokio::io::stdin();
298
+ let mut reader = BufReader::new(stdin).lines();
299
+ let mut stdout = tokio::io::stdout();
300
+
301
+ while let Some(line) = reader.next_line().await? {
302
+ if line.trim().is_empty() {
303
+ continue;
304
+ }
305
+
306
+ match serde_json::from_str::<JsonRpcRequest>(&line) {
307
+ Ok(request) => {
308
+ let response = self.handle_request(request).await;
309
+ let out_json = serde_json::to_string(&response)?;
310
+ stdout.write_all(out_json.as_bytes()).await?;
311
+ stdout.write_all(b"\n").await?;
312
+ stdout.flush().await?;
313
+ }
314
+ Err(e) => {
315
+ let err_resp = Self::error_response(None, -32700, format!("Parse error: {}", e));
316
+ let out_json = serde_json::to_string(&err_resp)?;
317
+ stdout.write_all(out_json.as_bytes()).await?;
318
+ stdout.write_all(b"\n").await?;
319
+ stdout.flush().await?;
320
+ }
321
+ }
322
+ }
323
+ Ok(())
324
+ }
325
+
326
+ /// Runs the server over Axum SSE/HTTP transport
327
+ pub async fn run_http(self: Arc<Self>, host: &str, port: u16) -> Result<()> {
328
+ let app = Router::new()
329
+ .route("/rpc", post(handle_http_rpc))
330
+ .route("/sse", get(handle_sse))
331
+ .route("/health", get(|| async { "healthy" }))
332
+ .layer(tower_http::cors::CorsLayer::permissive())
333
+ .with_state(self);
334
+
335
+ let addr = format!("{}:{}", host, port);
336
+ info!("MCP HTTP/SSE server listening on http://{}", addr);
337
+ let listener = tokio::net::TcpListener::bind(&addr).await?;
338
+ axum::serve(listener, app).await?;
339
+ Ok(())
340
+ }
341
+ }
342
+
343
+ async fn handle_http_rpc(
344
+ State(server): State<Arc<McpServer>>,
345
+ Json(payload): Json<JsonRpcRequest>,
346
+ ) -> Json<JsonRpcResponse> {
347
+ let resp = server.handle_request(payload).await;
348
+ Json(resp)
349
+ }
350
+
351
+ async fn handle_sse(
352
+ State(_server): State<Arc<McpServer>>,
353
+ ) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
354
+ let stream = tokio_stream::iter(vec![
355
+ Ok(Event::default().event("endpoint").data("/rpc"))
356
+ ])
357
+ .chain(tokio_stream::wrappers::IntervalStream::new(tokio::time::interval(Duration::from_secs(15)))
358
+ .map(|_| Ok(Event::default().comment("keep-alive"))));
359
+
360
+ Sse::new(stream).keep_alive(KeepAlive::default())
361
+ }