mentedb-mcp 0.5.46

MCP server for MenteDB, the cognition aware database for AI agent memory
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
mod cognitive;
mod consolidation;
mod context;
mod enrichment;
mod graph;
mod handler;
mod hook_support;
mod inference;
mod ingest;
mod memory;
mod process_turn;
mod process_turn_analysis;
mod process_turn_helpers;
mod types;

pub use types::*;

use std::sync::Arc;

use mentedb::MenteDb;
use mentedb_cognitive::trajectory::DecisionState;
use mentedb_cognitive::{CognitiveLlmService, PainSignal, TrajectoryNode};
use mentedb_consolidation::{ArchivalDecision, FactExtractor, ForgetEngine, ForgetRequest};
use mentedb_context::{AssemblyConfig, ContextAssembler, DeltaTracker, OutputFormat, ScoredMemory};
use mentedb_core::edge::EdgeType;
use mentedb_core::memory::{AttributeValue, MemoryType};
use mentedb_core::types::{AgentId, MemoryId};
use mentedb_core::{MemoryEdge, MemoryNode};
use mentedb_embedding::CandleEmbeddingProvider;
use mentedb_embedding::HashEmbeddingProvider;
use mentedb_embedding::provider::EmbeddingProvider;

/// Shares one embedding model instance between the server (which embeds text
/// before storing) and the engine db (whose own embed-and-recall paths, the
/// injection context and action recall the daemon serves, call `embed_text`).
/// Without this the db opens with no embedder, so those engine-side paths
/// embed nothing and recall degrades to keyword or returns empty. One model,
/// two owners, via a cheap Arc delegate.
struct SharedEmbedder(Arc<dyn EmbeddingProvider>);

impl EmbeddingProvider for SharedEmbedder {
    fn embed(&self, text: &str) -> mentedb_core::error::MenteResult<Vec<f32>> {
        self.0.embed(text)
    }
    fn embed_batch(&self, texts: &[&str]) -> mentedb_core::error::MenteResult<Vec<Vec<f32>>> {
        self.0.embed_batch(texts)
    }
    fn dimensions(&self) -> usize {
        self.0.dimensions()
    }
    fn model_name(&self) -> &str {
        self.0.model_name()
    }
}
use mentedb_extraction::{
    ExtractionConfig, ExtractionPipeline, MockExtractionProvider, ProcessedExtractionResult,
};
use mentedb_extraction::{ExtractionLlmJudge, HttpExtractionProvider};
use mentedb_graph::{extract_subgraph, find_contradictions, shortest_path};
use rmcp::ErrorData as McpError;
use rmcp::ServerHandler;
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::*;
use serde_json::json;
use tokio::sync::Mutex;
use uuid::Uuid;

use crate::config::ServerConfig;

/// Parse a decision state string into a DecisionState enum.
pub(crate) fn parse_decision_state(s: &str) -> DecisionState {
    let lower = s.to_lowercase();
    if lower == "investigating" {
        DecisionState::Investigating
    } else if lower == "interrupted" {
        DecisionState::Interrupted
    } else if lower == "completed" {
        DecisionState::Completed
    } else if let Some(choice) = lower.strip_prefix("narrowed_to:") {
        DecisionState::NarrowedTo(choice.trim().to_string())
    } else if let Some(decision) = lower.strip_prefix("decided:") {
        DecisionState::Decided(decision.trim().to_string())
    } else {
        // Fall back to Investigating for unrecognized strings
        DecisionState::Investigating
    }
}

/// MenteDB MCP server state holding the database and cognitive subsystems.
pub struct MenteDbServer {
    db: Arc<MenteDb>,
    embedding_provider: Arc<dyn EmbeddingProvider>,
    delta_tracker: Arc<Mutex<DeltaTracker>>,
    /// LLM-powered cognitive service for contradiction verification,
    /// entity resolution, and topic canonicalization.
    cognitive_llm: Option<Arc<CognitiveLlmService<ExtractionLlmJudge>>>,
    /// True when Candle embedding model failed to load and hash fallback is active.
    using_hash_fallback: bool,
    config: ServerConfig,
    pub tool_router: ToolRouter<Self>,
}

impl MenteDbServer {
    pub fn new(db: MenteDb, config: ServerConfig) -> Self {
        // MENTEDB_FORCE_HASH_EMBEDDINGS skips the Candle model entirely and uses
        // the deterministic hash embedder. It exists for tests and CI: loading
        // (and, on a cold runner, downloading) the local model is slow and
        // flaky, and forcing hash makes the hook e2e suite fast, reproducible,
        // and a direct exercise of the BM25 keyword recall path.
        let (embedding_provider, using_hash_fallback): (Arc<dyn EmbeddingProvider>, bool) =
            if std::env::var("MENTEDB_FORCE_HASH_EMBEDDINGS").is_ok() {
                (
                    Arc::new(HashEmbeddingProvider::new(config.embedding_dim)),
                    true,
                )
            } else {
                match CandleEmbeddingProvider::new() {
                    Ok(provider) => {
                        tracing::info!(
                            model = provider.model_name(),
                            dimensions = provider.dimensions(),
                            "Using local Candle embeddings"
                        );
                        (Arc::new(provider), false)
                    }
                    Err(e) => {
                        tracing::warn!(
                            error = %e,
                            "Failed to load Candle model, falling back to hash embeddings. \
                             Search results will be unreliable."
                        );
                        (
                            Arc::new(HashEmbeddingProvider::new(config.embedding_dim)),
                            true,
                        )
                    }
                }
            };
        let full_tools = config.full_tools;
        let mut tool_router = Self::tool_router_memory()
            + Self::tool_router_graph()
            + Self::tool_router_cognitive()
            + Self::tool_router_consolidation()
            + Self::tool_router_inference()
            + Self::tool_router_ingest()
            + Self::tool_router_process_turn()
            + Self::tool_router_context();

        // Initialize LLM-powered cognitive service if a provider is configured
        let cognitive_llm = if config.llm_provider != "mock" {
            let api_key = config
                .llm_api_key
                .clone()
                .or_else(|| std::env::var("MENTEDB_LLM_API_KEY").ok())
                .or_else(|| std::env::var("OPENAI_API_KEY").ok());

            let extraction_config = match config.llm_provider.as_str() {
                "openai" => {
                    if let Some(key) = api_key {
                        let mut cfg = ExtractionConfig::openai(key);
                        if let Some(model) = &config.llm_model {
                            cfg.model = model.clone();
                        }
                        Some(cfg)
                    } else {
                        tracing::warn!("OpenAI provider configured but no API key found");
                        None
                    }
                }
                "anthropic" => {
                    if let Some(key) = api_key {
                        let mut cfg = ExtractionConfig::anthropic(key);
                        if let Some(model) = &config.llm_model {
                            cfg.model = model.clone();
                        }
                        Some(cfg)
                    } else {
                        tracing::warn!("Anthropic provider configured but no API key found");
                        None
                    }
                }
                "ollama" => {
                    let mut cfg = ExtractionConfig::ollama();
                    if let Some(model) = &config.llm_model {
                        cfg.model = model.clone();
                    }
                    Some(cfg)
                }
                _ => None,
            };

            if let Some(cfg) = extraction_config {
                match HttpExtractionProvider::new(cfg) {
                    Ok(provider) => {
                        let judge = ExtractionLlmJudge::new(provider);
                        tracing::info!(
                            provider = %config.llm_provider,
                            "Cognitive LLM service initialized"
                        );
                        Some(Arc::new(CognitiveLlmService::new(judge)))
                    }
                    Err(e) => {
                        tracing::warn!(error = %e, "Failed to initialize LLM provider");
                        None
                    }
                }
            } else {
                None
            }
        } else {
            tracing::info!("LLM provider is mock, cognitive features using heuristics only");
            None
        };

        // In default mode, only expose essential tools for better agent compliance.
        // All internal tools still run server-side via process_turn.
        if !full_tools {
            let essential = [
                "process_turn",
                "store_memory",
                "search_memories",
                "forget_memory",
            ];
            let all_names: Vec<String> = tool_router
                .list_all()
                .iter()
                .map(|t| t.name.to_string())
                .collect();
            for name in &all_names {
                if !essential.contains(&name.as_str()) {
                    tool_router.remove_route(name);
                }
            }
            tracing::info!(
                exposed = essential.len(),
                hidden = all_names.len() - essential.len(),
                "Slim tool mode: exposing only essential tools"
            );
        }

        // Wire the same embedding model into the db so the engine's own
        // embed-and-recall paths (the daemon's injection context and action
        // recall) embed with the real model instead of the db's empty default.
        let mut db = db;
        db.set_embedder(Box::new(SharedEmbedder(embedding_provider.clone())));

        Self {
            db: Arc::new(db),
            embedding_provider,
            delta_tracker: Arc::new(Mutex::new(DeltaTracker::new())),
            cognitive_llm,
            using_hash_fallback,
            config,
            tool_router,
        }
    }

    /// Get a reference to the database for shutdown handling.
    pub fn db_ref(&self) -> Arc<MenteDb> {
        Arc::clone(&self.db)
    }
}

pub(crate) fn parse_memory_type(s: &str) -> Result<MemoryType, String> {
    match s.to_lowercase().as_str() {
        "episodic" => Ok(MemoryType::Episodic),
        "semantic" => Ok(MemoryType::Semantic),
        "procedural" => Ok(MemoryType::Procedural),
        "anti_pattern" | "antipattern" => Ok(MemoryType::AntiPattern),
        "reasoning" => Ok(MemoryType::Reasoning),
        "correction" => Ok(MemoryType::Correction),
        other => Err(format!("Unknown memory type: {other}")),
    }
}

pub(crate) fn parse_edge_type(s: &str) -> Result<EdgeType, String> {
    match s.to_lowercase().as_str() {
        "caused" => Ok(EdgeType::Caused),
        "before" => Ok(EdgeType::Before),
        "related" | "relates_to" => Ok(EdgeType::Related),
        "contradicts" => Ok(EdgeType::Contradicts),
        "supports" => Ok(EdgeType::Supports),
        "supersedes" | "obsoletes" => Ok(EdgeType::Supersedes),
        "derived" | "requires" => Ok(EdgeType::Derived),
        "part_of" => Ok(EdgeType::PartOf),
        other => Err(format!("Unknown edge type: {other}")),
    }
}

pub(crate) fn parse_uuid(s: &str) -> Result<Uuid, String> {
    Uuid::parse_str(s).map_err(|e| format!("Invalid UUID: {e}"))
}

pub(crate) fn error_result(msg: &str) -> Result<CallToolResult, McpError> {
    Ok(CallToolResult::error(vec![Content::text(msg.to_string())]))
}

/// Translate a low level extraction error into a clear, actionable message
/// instead of leaking a raw provider validation string (e.g. "prompt is
/// required when stop is not true"). Enrichment degrades gracefully, so the
/// message always reassures that stored memories are safe.
pub(crate) fn friendly_extraction_error(e: &mentedb_extraction::ExtractionError) -> String {
    use mentedb_extraction::ExtractionError as E;
    let cause = match e {
        E::AuthError(_) => {
            "the LLM provider rejected the credentials (check llm_api_key or MENTEDB_LLM_API_KEY)"
        }
        E::ModelNotFound(_) => "the configured model was not found (check llm_model)",
        E::ConfigError(_) => "the LLM provider is misconfigured (check llm_provider and llm_model)",
        E::RateLimitExceeded { .. } => {
            "the LLM provider is rate limiting (enrichment will retry later)"
        }
        E::ParseError(_) => "the LLM response could not be parsed",
        E::EmbeddingError(_) => "the embedding provider failed",
        E::ProviderError(_) | E::HttpError(_) => {
            "the LLM provider rejected the request, usually a provider or model mismatch \
             (check llm_provider and llm_model)"
        }
    };
    format!("Memory enrichment is paused: {cause}. Your memories are still saved.")
}

/// Serialize a MemoryNode to a JSON value with all fields.
pub(crate) fn memory_node_to_json(mem: &MemoryNode) -> serde_json::Value {
    let attrs: serde_json::Map<String, serde_json::Value> = mem
        .attributes
        .iter()
        .map(|(k, v)| {
            let val = match v {
                AttributeValue::String(s) => serde_json::Value::String(s.clone()),
                AttributeValue::Integer(i) => json!(*i),
                AttributeValue::Float(f) => json!(*f),
                AttributeValue::Boolean(b) => json!(*b),
                AttributeValue::Bytes(b) => json!(format!("<{} bytes>", b.len())),
            };
            (k.clone(), val)
        })
        .collect();
    json!({
        "id": mem.id.to_string(),
        "agent_id": mem.agent_id.to_string(),
        "content": mem.content,
        "memory_type": format!("{:?}", mem.memory_type),
        "tags": mem.tags,
        "attributes": attrs,
        "created_at": mem.created_at,
        "accessed_at": mem.accessed_at,
        "access_count": mem.access_count,
        "salience": mem.salience,
        "confidence": mem.confidence,
        "space_id": mem.space_id.to_string(),
    })
}

/// Retrieve all memories from the database using direct page_map access.
///
/// Use only when every memory is needed (consolidation, decay, archival, GDPR).
/// For similarity-based retrieval, prefer `recall_similar` / `recall_hybrid_at`.
pub(crate) fn recall_all_memories(db: &MenteDb) -> Vec<ScoredMemory> {
    db.memory_ids()
        .into_iter()
        .filter_map(|id| {
            db.get_memory(id)
                .ok()
                .map(|memory| ScoredMemory { memory, score: 1.0 })
        })
        .collect()
}

/// Resolve HNSW result IDs into full `ScoredMemory` objects.
pub(crate) fn resolve_memory_ids(db: &MenteDb, ids: &[(MemoryId, f32)]) -> Vec<ScoredMemory> {
    ids.iter()
        .filter_map(|(id, score)| {
            db.get_memory(*id).ok().map(|memory| ScoredMemory {
                memory,
                score: *score,
            })
        })
        .collect()
}

/// Find a specific memory by UUID using direct page_map lookup.
pub(crate) fn find_memory_by_id(
    db: &MenteDb,
    target_id: Uuid,
) -> Result<Option<ScoredMemory>, String> {
    match db.get_memory(MemoryId(target_id)) {
        Ok(memory) => Ok(Some(ScoredMemory { memory, score: 1.0 })),
        Err(_) => Ok(None),
    }
}

/// Store extraction results (accepted + contradictions) into the database.
pub(crate) fn store_extraction_results(
    result: &ProcessedExtractionResult,
    db: &MenteDb,
    embedding_provider: &dyn EmbeddingProvider,
    agent_id: Uuid,
) -> Result<Vec<String>, McpError> {
    let mut stored_ids = Vec::new();

    for memory in &result.to_store {
        let mem_type = mentedb_extraction::map_extraction_type_to_memory_type(&memory.memory_type);
        let embedding = embedding_provider
            .embed(&memory.content)
            .map_err(|e| McpError::internal_error(format!("Embedding failed: {e}"), None))?;
        let mut node = MemoryNode::new(
            AgentId(agent_id),
            mem_type,
            memory.content.clone(),
            embedding,
        );
        node.tags = memory.tags.clone();
        let id = node.id;
        if let Err(e) = db.store(node) {
            tracing::error!(error = %e, "failed to store extracted memory");
            continue;
        }
        stored_ids.push(id.to_string());
    }

    for (memory, _findings) in &result.contradictions {
        let mem_type = mentedb_extraction::map_extraction_type_to_memory_type(&memory.memory_type);
        let embedding = embedding_provider
            .embed(&memory.content)
            .map_err(|e| McpError::internal_error(format!("Embedding failed: {e}"), None))?;
        let mut node = MemoryNode::new(
            AgentId(agent_id),
            mem_type,
            memory.content.clone(),
            embedding,
        );
        node.tags = memory.tags.clone();
        node.tags.push("has_contradiction".to_string());
        let id = node.id;
        if let Err(e) = db.store(node) {
            tracing::error!(error = %e, "failed to store contradicting memory");
            continue;
        }
        stored_ids.push(id.to_string());
    }

    Ok(stored_ids)
}