Skip to main content

semantic_memory_mcp/
http_server.rs

1//! HTTP search server for semantic-memory-mcp.
2//!
3//! A minimal HTTP server that exposes the most-used semantic-memory
4//! operations over a local TCP port. Runs alongside the stdio MCP
5//! transport so the same warm process serves both MCP clients and
6//! HTTP clients (hooks, benchmarks, scripts).
7//!
8//! Endpoints:
9//!   POST /search   {"query": "...", "top_k": 10} -> search results
10//!   POST /stats    {} -> DB stats
11//!   POST /add      {"content": "...", "namespace": "..."} -> fact_id
12//!   GET  /health   -> {"ok": true}
13
14use std::io::{BufRead, BufReader, Read, Write};
15use std::net::TcpListener;
16use tokio::runtime::Handle;
17use tokio::task::block_in_place;
18
19use crate::bridge::MemoryBridge;
20
21/// Call Ollama to rate each result's relevance to the query (1-5) and sort descending.
22/// Returns a new vec with a `rerank_score` field added to each result object.
23fn rerank_results(
24    query: &str,
25    results: &[serde_json::Value],
26    model: &str,
27) -> Vec<serde_json::Value> {
28    let client = reqwest::blocking::Client::new();
29    let mut scored: Vec<(f64, serde_json::Value)> = results
30        .iter()
31        .map(|r| {
32            let content = r.get("content").and_then(|v| v.as_str()).unwrap_or("");
33            let truncated: String = content.chars().take(500).collect();
34            let prompt = format!(
35                "Rate the relevance of this document to the query on a scale of 1-5. Reply with ONLY the number.\nQuery: {query}\nDocument: {truncated}\nRating:"
36            );
37            let body = serde_json::json!({
38                "model": model,
39                "prompt": prompt,
40                "stream": false,
41                "options": {"temperature": 0, "num_predict": 1}
42            });
43            let rating = client
44                .post("http://127.0.0.1:11434/api/generate")
45                .json(&body)
46                .send()
47                .ok()
48                .and_then(|resp| resp.json::<serde_json::Value>().ok())
49                .and_then(|v| {
50                    v.get("response")
51                        .and_then(|r| r.as_str())
52                        .and_then(|s| s.trim().chars().next())
53                        .and_then(|c| c.to_digit(10))
54                        .map(|d| d as f64)
55                })
56                .unwrap_or(1.0);
57            (rating, r.clone())
58        })
59        .collect();
60    scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
61    scored
62        .into_iter()
63        .map(|(score, mut r)| {
64            if let Some(obj) = r.as_object_mut() {
65                obj.insert("rerank_score".to_string(), serde_json::json!(score));
66            }
67            r
68        })
69        .collect()
70}
71
72pub fn start_http_server(port: u16, bridge: MemoryBridge, handle: Handle) {
73    std::thread::spawn(move || {
74        let listener = match TcpListener::bind(("127.0.0.1", port)) {
75            Ok(l) => {
76                eprintln!("HTTP search server listening on 127.0.0.1:{}", port);
77                l
78            }
79            Err(e) => {
80                eprintln!("Failed to bind HTTP port {}: {}", port, e);
81                return;
82            }
83        };
84
85        for stream in listener.incoming() {
86            let stream = match stream {
87                Ok(s) => s,
88                Err(_) => continue,
89            };
90
91            let bridge = bridge.clone();
92            let h = handle.clone();
93            std::thread::spawn(move || {
94                handle_connection(stream, bridge, h);
95            });
96        }
97    });
98}
99
100fn handle_connection(
101    mut stream: std::net::TcpStream,
102    bridge: MemoryBridge,
103    handle: Handle,
104) {
105    let mut reader = BufReader::new(stream.try_clone().expect("clone"));
106    let mut request_line = String::new();
107    if reader.read_line(&mut request_line).is_err() {
108        return;
109    }
110
111    let parts: Vec<&str> = request_line.split_whitespace().collect();
112    if parts.len() < 2 {
113        return;
114    }
115    let method = parts[0];
116    let path = parts[1];
117
118    let mut content_length = 0;
119    loop {
120        let mut header = String::new();
121        if reader.read_line(&mut header).is_err() {
122            return;
123        }
124        if header.trim().is_empty() {
125            break;
126        }
127        if let Some(len_str) = header
128            .strip_prefix("Content-Length:")
129            .or_else(|| header.strip_prefix("content-length:"))
130        {
131            content_length = len_str.trim().parse().unwrap_or(0);
132        }
133    }
134
135    let mut body = vec![0u8; content_length];
136    if content_length > 0 && reader.read_exact(&mut body).is_err() {
137        return;
138    }
139    let body_str = String::from_utf8_lossy(&body);
140
141    let (status, response) = match (method, path) {
142        ("GET", "/health") => (
143            "200 OK",
144            serde_json::json!({"ok": true, "service": "semantic-memory-mcp"}),
145        ),
146        ("POST", "/search") => handle_search(&body_str, &bridge, &handle),
147        ("POST", "/search-routed") => handle_search_routed(&body_str, &bridge, &handle),
148        ("POST", "/rerank") => handle_rerank(&body_str),
149        ("POST", "/stats") => handle_stats(&bridge, &handle),
150        ("POST", "/add") => handle_add_fact(&body_str, &bridge, &handle),
151        ("POST", "/record-outcome") => handle_record_outcome(&body_str),
152        ("GET", "/verify-integrity") => handle_verify_integrity(&bridge, &handle),
153        _ => (
154            "404 Not Found",
155            serde_json::json!({"error": "not found", "path": path}),
156        ),
157    };
158
159    let response_str = serde_json::to_string(&response).unwrap_or_default();
160    let response_bytes = response_str.as_bytes();
161    let http_response = format!(
162        "HTTP/1.1 {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
163        status,
164        response_bytes.len()
165    );
166
167    let _ = stream.write_all(http_response.as_bytes());
168    let _ = stream.write_all(response_bytes);
169    let _ = stream.flush();
170}
171
172fn handle_search(
173    body: &str,
174    bridge: &MemoryBridge,
175    handle: &Handle,
176) -> (&'static str, serde_json::Value) {
177    let params: serde_json::Value = match serde_json::from_str(body) {
178        Ok(v) => v,
179        Err(e) => {
180            return (
181                "400 Bad Request",
182                serde_json::json!({"ok": false, "error": format!("invalid JSON: {e}")}),
183            )
184        }
185    };
186
187    let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
188    let top_k = params.get("top_k").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
189    let namespaces: Option<Vec<String>> = params
190        .get("namespaces")
191        .and_then(|v| serde_json::from_value(v.clone()).ok());
192    let do_rerank = params.get("rerank").and_then(|v| v.as_bool()).unwrap_or(false);
193
194    if query.is_empty() {
195        return (
196            "400 Bad Request",
197            serde_json::json!({"ok": false, "error": "missing 'query' field"}),
198        );
199    }
200
201    let store = &bridge.store;
202    let ns_slice: Option<Vec<&str>> = namespaces
203        .as_ref()
204        .map(|v| v.iter().map(|s| s.as_str()).collect());
205    // Fetch top_k * 2 candidates when reranking so the LLM has a richer pool to sort.
206    let fetch_k = if do_rerank { top_k * 2 } else { top_k };
207    let result = block_in_place(|| {
208        handle.block_on(store.search(query, Some(fetch_k), ns_slice.as_deref(), None))
209    });
210
211    match result {
212        Ok(results) => {
213            let json_results: Vec<serde_json::Value> = results
214                .iter()
215                .map(|r| {
216                    let namespace = match &r.source {
217                        semantic_memory::SearchSource::Fact { namespace, .. } => namespace.clone(),
218                        semantic_memory::SearchSource::Chunk { document_title, .. } => document_title.clone(),
219                        _ => String::new(),
220                    };
221                    serde_json::json!({
222                        "result_id": r.source.result_id(),
223                        "content": r.content,
224                        "score": r.score,
225                        "cosine_similarity": r.cosine_similarity,
226                        "namespace": namespace,
227                    })
228                })
229                .collect();
230
231            let final_results: Vec<serde_json::Value> = if do_rerank && !json_results.is_empty() {
232                rerank_results(query, &json_results, "granite4.1:3b")
233                    .into_iter()
234                    .take(top_k)
235                    .collect()
236            } else {
237                json_results
238            };
239
240            let count = final_results.len();
241            let provenance = serde_json::json!({
242                "stages_fired": {
243                    "bm25": true,
244                    "vector": true,
245                    "late_interaction": false,
246                    "rerank": do_rerank,
247                },
248                "result_count": count,
249                "view": "semantic",
250                "widening_occurred": false,
251                "widening_reason": null,
252                "verification_status": "verified",
253            });
254            (
255                "200 OK",
256                serde_json::json!({
257                    "ok": true,
258                    "query": query,
259                    "top_k": top_k,
260                    "results": final_results,
261                    "count": count,
262                    "reranked": do_rerank,
263                    "provenance": provenance,
264                }),
265            )
266        }
267        Err(e) => (
268            "500 Internal Server Error",
269            serde_json::json!({"ok": false, "error": format!("search error: {e}")}),
270        ),
271    }
272}
273
274/// Handle /search-routed: routing-aware search for complex queries.
275///
276/// Accepts a `query_class` field (A/B/C/D/E) from the Python classifier:
277/// - D (SYNTHESIS): increases top_k to gather more candidates
278/// - C (CONTRADICTION): uses exact search profile
279/// - A/B/E: identical to /search (early return, no overhead)
280fn handle_search_routed(
281    body: &str,
282    bridge: &MemoryBridge,
283    handle: &Handle,
284) -> (&'static str, serde_json::Value) {
285    let params: serde_json::Value = match serde_json::from_str(body) {
286        Ok(v) => v,
287        Err(e) => {
288            return (
289                "400 Bad Request",
290                serde_json::json!({"ok": false, "error": format!("invalid JSON: {e}")}),
291            )
292        }
293    };
294
295    let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
296    let base_top_k = params.get("top_k").and_then(|v| v.as_u64()).unwrap_or(12) as usize;
297    let query_class = params.get("query_class").and_then(|v| v.as_str()).unwrap_or("A");
298    let namespaces: Option<Vec<String>> = params
299        .get("namespaces")
300        .and_then(|v| serde_json::from_value(v.clone()).ok());
301
302    if query.is_empty() {
303        return (
304            "400 Bad Request",
305            serde_json::json!({"ok": false, "error": "missing 'query' field"}),
306        );
307    }
308
309    // Class D (SYNTHESIS): retrieve more candidates to support comprehensive answers
310    let top_k = if query_class == "D" {
311        (base_top_k * 2).min(20)
312    } else {
313        base_top_k
314    };
315
316    let store = &bridge.store;
317    let ns_slice: Option<Vec<&str>> = namespaces
318        .as_ref()
319        .map(|v| v.iter().map(|s| s.as_str()).collect());
320
321    // Class C (CONTRADICTION): use ExactSearch context for higher-fidelity results
322    let result = if query_class == "C" {
323        use semantic_memory::{ExactnessProfile, SearchContext};
324        let mut ctx = SearchContext::default_now();
325        ctx.exactness_profile = ExactnessProfile::PreferExact;
326        block_in_place(|| {
327            handle.block_on(store.search_with_context(
328                query,
329                Some(top_k),
330                ns_slice.as_deref(),
331                None,
332                ctx,
333            ))
334        })
335        .map(|r| r.results)
336    } else {
337        block_in_place(|| {
338            handle.block_on(store.search(query, Some(top_k), ns_slice.as_deref(), None))
339        })
340    };
341
342    match result {
343        Ok(results) => {
344            let json_results: Vec<serde_json::Value> = results
345                .iter()
346                .map(|r| {
347                    let namespace = match &r.source {
348                        semantic_memory::SearchSource::Fact { namespace, .. } => namespace.clone(),
349                        semantic_memory::SearchSource::Chunk { document_title, .. } => {
350                            document_title.clone()
351                        }
352                        _ => String::new(),
353                    };
354                    serde_json::json!({
355                        "result_id": r.source.result_id(),
356                        "content": r.content,
357                        "score": r.score,
358                        "cosine_similarity": r.cosine_similarity,
359                        "namespace": namespace,
360                        "source_type": match &r.source {
361                            semantic_memory::SearchSource::Fact { .. } => "fact",
362                            semantic_memory::SearchSource::Chunk { .. } => "chunk",
363                            semantic_memory::SearchSource::Message { .. } => "message",
364                            _ => "unknown",
365                        },
366                    })
367                })
368                .collect();
369
370            // Query provenance: declare which retrieval stages contributed
371            let provenance = serde_json::json!({
372                "stages_fired": {
373                    "bm25": results.iter().any(|r| r.bm25_rank.is_some()),
374                    "vector": results.iter().any(|r| r.vector_rank.is_some()),
375                    "late_interaction": true,
376                    "discord": false,
377                    "decoder": false,
378                },
379                "result_count": results.len(),
380                "view": "routed",
381                "query_class": query_class,
382                "widening_occurred": false,
383                "widening_reason": null,
384                "verification_status": "verified",
385            });
386
387            (
388                "200 OK",
389                serde_json::json!({
390                    "ok": true,
391                    "query": query,
392                    "top_k": base_top_k,
393                    "results": json_results,
394                    "provenance": provenance,
395                    "query_class": query_class,
396                    "routed": true,
397                }),
398            )
399        }
400        Err(e) => (
401            "500 Internal Server Error",
402            serde_json::json!({"ok": false, "error": format!("search error: {e}")}),
403        ),
404    }
405}
406
407fn handle_stats(
408    bridge: &MemoryBridge,
409    handle: &Handle,
410) -> (&'static str, serde_json::Value) {
411    let store = &bridge.store;
412    let result = block_in_place(|| handle.block_on(store.stats()));
413    match result {
414        Ok(stats) => (
415            "200 OK",
416            serde_json::json!({
417                "ok": true,
418                "facts": stats.total_facts,
419                "documents": stats.total_documents,
420                "chunks": stats.total_chunks,
421                "db_size_mb": (stats.database_size_bytes as f64) / (1024.0 * 1024.0),
422            }),
423        ),
424        Err(e) => (
425            "500 Internal Server Error",
426            serde_json::json!({"ok": false, "error": format!("{e}")}),
427        ),
428    }
429}
430
431fn handle_rerank(body: &str) -> (&'static str, serde_json::Value) {
432    let params: serde_json::Value = match serde_json::from_str(body) {
433        Ok(v) => v,
434        Err(e) => {
435            return (
436                "400 Bad Request",
437                serde_json::json!({"ok": false, "error": format!("invalid JSON: {e}")}),
438            )
439        }
440    };
441
442    let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
443    let model = params
444        .get("model")
445        .and_then(|v| v.as_str())
446        .unwrap_or("granite4.1:3b");
447    let results = match params.get("results").and_then(|v| v.as_array()) {
448        Some(r) => r.clone(),
449        None => {
450            return (
451                "400 Bad Request",
452                serde_json::json!({"ok": false, "error": "missing 'results' array"}),
453            )
454        }
455    };
456
457    if query.is_empty() {
458        return (
459            "400 Bad Request",
460            serde_json::json!({"ok": false, "error": "missing 'query' field"}),
461        );
462    }
463
464    let reranked = rerank_results(query, &results, model);
465    let count = reranked.len();
466    (
467        "200 OK",
468        serde_json::json!({
469            "ok": true,
470            "results": reranked,
471            "count": count,
472        }),
473    )
474}
475
476fn handle_add_fact(
477    body: &str,
478    bridge: &MemoryBridge,
479    handle: &Handle,
480) -> (&'static str, serde_json::Value) {
481    let params: serde_json::Value = match serde_json::from_str(body) {
482        Ok(v) => v,
483        Err(e) => {
484            return (
485                "400 Bad Request",
486                serde_json::json!({"ok": false, "error": format!("invalid JSON: {e}")}),
487            )
488        }
489    };
490
491    let content = params.get("content").and_then(|v| v.as_str()).unwrap_or("");
492    let namespace = params
493        .get("namespace")
494        .and_then(|v| v.as_str())
495        .unwrap_or("general");
496    let source = params.get("source").and_then(|v| v.as_str());
497
498    if content.is_empty() {
499        return (
500            "400 Bad Request",
501            serde_json::json!({"ok": false, "error": "missing 'content' field"}),
502        );
503    }
504
505    let store = &bridge.store;
506    let result =
507        block_in_place(|| handle.block_on(store.add_fact(namespace, content, source, None)));
508
509    match result {
510        Ok(fact_id) => (
511            "200 OK",
512            serde_json::json!({"ok": true, "fact_id": fact_id}),
513        ),
514        Err(e) => (
515            "500 Internal Server Error",
516            serde_json::json!({"ok": false, "error": format!("{e}")}),
517        ),
518    }
519}
520
521/// Handle /record-outcome: record a search outcome for RL routing feedback.
522fn handle_record_outcome(body: &str) -> (&'static str, serde_json::Value) {
523    let params: serde_json::Value = match serde_json::from_str(body) {
524        Ok(v) => v,
525        Err(e) => {
526            return (
527                "400 Bad Request",
528                serde_json::json!({"ok": false, "error": format!("invalid JSON: {e}")}),
529            )
530        }
531    };
532
533    let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
534    let outcome = params.get("outcome").and_then(|v| v.as_str()).unwrap_or("neutral");
535    let query_class = params.get("query_class").and_then(|v| v.as_str()).unwrap_or("A");
536
537    eprintln!(
538        "[record-outcome] query_class={} outcome={} query={:?}",
539        query_class, outcome, &query[..query.len().min(80)]
540    );
541
542    (
543        "200 OK",
544        serde_json::json!({"ok": true, "recorded": true, "outcome": outcome, "query_class": query_class}),
545    )
546}
547
548/// Handle GET /verify-integrity: check DB integrity (WAL checkpoint, FTS index, vector index).
549fn handle_verify_integrity(
550    bridge: &MemoryBridge,
551    handle: &Handle,
552) -> (&'static str, serde_json::Value) {
553    let store = &bridge.store;
554    let stats = block_in_place(|| handle.block_on(store.stats()));
555
556    match stats {
557        Ok(s) => {
558            let facts = s.total_facts;
559            let chunks = s.total_chunks;
560            let docs = s.total_documents;
561            let db_size = s.database_size_bytes;
562
563            let checks = serde_json::json!({
564                "facts_counted": facts > 0,
565                "chunks_present": chunks > 0,
566                "documents_present": docs > 0,
567                "db_size_reasonable": db_size > 1024,
568                "facts_to_chunks_ratio_ok": chunks >= facts,
569            });
570
571            let all_pass = checks.as_object()
572                .map(|m| m.values().all(|v| v.as_bool().unwrap_or(false)))
573                .unwrap_or(false);
574
575            (
576                "200 OK",
577                serde_json::json!({
578                    "ok": true,
579                    "integrity": all_pass,
580                    "checks": checks,
581                    "stats": {
582                        "facts": facts,
583                        "chunks": chunks,
584                        "documents": docs,
585                        "db_size_bytes": db_size,
586                    },
587                    "message": if all_pass { "All integrity checks passed" } else { "Some integrity checks failed" },
588                }),
589            )
590        }
591        Err(e) => (
592            "500 Internal Server Error",
593            serde_json::json!({"ok": false, "integrity": false, "error": format!("stats error: {e}")}),
594        ),
595    }
596}