Skip to main content

post_cortex_mcp/
lib.rs

1// Copyright (c) 2025, 2026 Julius ML
2// Licensed under the MIT License. See LICENSE at the workspace root.
3
4//! Model Context Protocol (MCP) tool definitions for post-cortex.
5//!
6//! Pure library — no `rmcp`, `axum`, `tonic`, or transport runtime.
7//! Each public function takes the typed parameters of a single MCP
8//! tool, dispatches into the post-cortex domain layer, and returns an
9//! [`MCPToolResult`].
10//!
11//! The 9 consolidated tool surfaces live as top-level modules: see
12//! [`session`], [`update_context`], [`query`], [`search`], [`analysis`],
13//! [`workspace`], and [`schemas`]. Headline helpers
14//! ([`MCPToolResult`], `MEMORY_SYSTEM`, `get_memory_system`) are
15//! re-exported below.
16//!
17//! ## Phase 6 status
18//!
19//! The crate currently calls into [`post_cortex_memory::ConversationMemorySystem`]
20//! directly via a global `LazyLock<ArcSwap>` singleton. Phase 7 (daemon
21//! extraction) is where the function signatures flip to take
22//! `&dyn post_cortex_core::services::PostCortexService` and the
23//! singleton goes away — at that point this crate's transitive
24//! dependency on `post-cortex-memory` / `post-cortex-storage` drops to
25//! `dev-dependencies` and downstream Rust projects can plug in their
26//! own service impl.
27
28#![forbid(unsafe_code)]
29#![allow(clippy::result_large_err)]
30#![allow(clippy::type_complexity)]
31
32/// Typed error hierarchy for the MCP tool layer.
33pub mod error;
34pub use error::{Error, Result as McpResult};
35
36use anyhow::Result;
37use arc_swap::ArcSwap;
38use post_cortex_core::core::context_update::{CodeReference, ContextUpdate, EntityType};
39use schemars::JsonSchema;
40use serde::{Deserialize, Serialize};
41use std::collections::HashMap;
42use std::sync::{Arc, LazyLock};
43use tracing::info;
44
45use post_cortex_memory::services::MemoryServiceImpl;
46use post_cortex_memory::{ConversationMemorySystem, SystemConfig};
47
48/// Analysis, summaries, insights, and session statistics.
49pub mod analysis;
50/// Structured and keyword-based queries over session context.
51pub mod query;
52/// JSON Schema descriptors for every MCP tool.
53pub mod schemas;
54/// Semantic and embedding-powered search across sessions.
55pub mod search;
56/// Session lifecycle: create, load, checkpoint, list, search, metadata.
57pub mod session;
58/// Helpers for recording context updates (single and bulk).
59pub mod update_context;
60/// Workspace CRUD and session-to-workspace membership.
61pub mod workspace;
62
63pub use analysis::{
64    get_entity_importance_analysis, get_entity_network_view, get_key_decisions, get_key_insights,
65    get_session_statistics, get_structured_summary, get_tool_catalog,
66};
67pub use query::{query_conversation_context, query_conversation_context_with_system};
68pub use schemas::get_all_tool_schemas;
69pub use search::{
70    enable_embeddings, find_related_content, get_vectorization_stats, semantic_search,
71    semantic_search_global, semantic_search_session, vectorize_session,
72};
73pub use session::{
74    create_session_checkpoint, create_session_checkpoint_with_system, list_sessions,
75    list_sessions_with_storage, load_session, load_session_checkpoint,
76    load_session_checkpoint_with_system, load_session_with_system, mark_important, search_sessions,
77    update_session_metadata,
78};
79pub use update_context::{bulk_update_conversation_context, update_conversation_context};
80pub use workspace::{
81    add_session_to_workspace, create_workspace, delete_workspace, get_workspace, list_workspaces,
82    remove_session_from_workspace,
83};
84
85/// Convert a plain string into an `anyhow::Error`.
86fn string_to_anyhow(s: String) -> anyhow::Error {
87    anyhow::Error::msg(s)
88}
89
90/// Typed query descriptors dispatched by `query::query_context`.
91#[derive(Serialize, Deserialize, Debug)]
92pub enum ContextQuery {
93    /// Retrieve context updates created after a given timestamp.
94    GetRecentChanges {
95        /// Earliest timestamp to include.
96        since: chrono::DateTime<chrono::Utc>,
97    },
98    /// Look up code references for a specific file path.
99    FindCodeReferences {
100        /// File path to search references for.
101        file_path: String,
102    },
103    /// Return the current structured summary of the session.
104    GetStructuredSummary,
105    /// Keyword search over stored context updates.
106    SearchUpdates {
107        /// Search query string.
108        query: String,
109    },
110    /// Retrieve decision-type updates, optionally after a timestamp.
111    GetDecisions {
112        /// Optional lower-bound timestamp filter.
113        since: Option<chrono::DateTime<chrono::Utc>>,
114    },
115    /// Return open questions tracked in the session.
116    GetOpenQuestions,
117    /// Return change history, optionally filtered by file path.
118    GetChangeHistory {
119        /// Optional file path to narrow results.
120        file_path: Option<String>,
121    },
122
123    /// Find entity names related to a given entity.
124    FindRelatedEntities {
125        /// Name of the entity to search relations for.
126        entity_name: String,
127    },
128    /// Return full context string for a named entity.
129    GetEntityContext {
130        /// Name of the entity.
131        entity_name: String,
132    },
133    /// List all known entities, optionally filtered by type.
134    GetAllEntities {
135        /// Optional entity-type filter.
136        entity_type: Option<EntityType>,
137    },
138    /// Traverse relationship edges starting from an entity.
139    TraceRelationships {
140        /// Entity name to start traversal from.
141        from_entity: String,
142        /// Maximum graph traversal depth.
143        max_depth: usize,
144    },
145
146    /// Build a sub-graph network centered on an entity.
147    GetEntityNetwork {
148        /// Entity to place at the center of the network.
149        center_entity: String,
150        /// Maximum traversal depth.
151        max_depth: usize,
152    },
153    /// Find the shortest relationship path between two entities.
154    FindConnectionPath {
155        /// Starting entity.
156        from_entity: String,
157        /// Target entity.
158        to_entity: String,
159        /// Maximum path length to explore.
160        max_depth: usize,
161    },
162    /// Return the top-N entities ranked by importance score.
163    GetMostImportantEntities {
164        /// Maximum number of entities to return.
165        limit: usize,
166    },
167    /// Return the most recently mentioned entities.
168    GetRecentlyMentionedEntities {
169        /// Maximum number of entities to return.
170        limit: usize,
171    },
172    /// Perform a full importance analysis across all entities.
173    AnalyzeEntityImportance,
174    /// List entities matching a specific type.
175    FindEntitiesByType {
176        /// Entity type to filter by.
177        entity_type: EntityType,
178    },
179
180    /// Return a hierarchical tree rooted at an entity.
181    GetEntityHierarchy {
182        /// Root entity for the hierarchy.
183        root_entity: String,
184        /// Maximum depth of the hierarchy.
185        max_depth: usize,
186    },
187    /// Detect clusters of closely related entities.
188    FindEntityClusters {
189        /// Minimum number of entities to form a cluster.
190        min_cluster_size: usize,
191    },
192
193    /// Return a timeline of mentions for a specific entity.
194    GetEntityTimeline {
195        /// Entity name to track.
196        entity_name: String,
197        /// Optional start of the time window.
198        start_time: Option<chrono::DateTime<chrono::Utc>>,
199        /// Optional end of the time window.
200        end_time: Option<chrono::DateTime<chrono::Utc>>,
201    },
202    /// Analyse how entity activity trends over a time window.
203    AnalyzeEntityTrends {
204        /// Width of the rolling time window in days.
205        time_window_days: i64,
206    },
207
208    /// Graph-aware retrieval combining semantic search and traversal.
209    AssembleContext {
210        /// Natural-language query for relevance scoring.
211        query: String,
212        /// Maximum approximate token count for the returned context.
213        token_budget: usize,
214    },
215}
216
217/// Typed response payloads returned by `query::query_context`.
218#[derive(Serialize, Deserialize, Debug)]
219pub enum ContextResponse {
220    /// Recent context updates since a given timestamp.
221    RecentChanges(Vec<ContextUpdate>),
222    /// Code references matching a file path.
223    CodeReferences(Vec<CodeReference>),
224    /// Full structured summary of the session.
225    StructuredSummary(post_cortex_core::core::structured_context::StructuredContext),
226    /// Context updates matching a keyword search.
227    SearchResults(Vec<ContextUpdate>),
228    /// Decision-type context updates.
229    Decisions(Vec<ContextUpdate>),
230    /// Open questions tracked in the session.
231    OpenQuestions(Vec<String>),
232    /// Change history for a file or across all files.
233    ChangeHistory(Vec<ContextUpdate>),
234    /// Entity names related to a target entity.
235    RelatedEntities(Vec<String>),
236    /// Human-readable context summary for a single entity.
237    EntityContext(String),
238    /// All entity names, optionally filtered by type.
239    AllEntities(Vec<String>),
240    /// Entity names discovered by relationship traversal.
241    EntityRelationships(Vec<String>),
242    /// Serialized entity network graph.
243    EntityNetwork(String),
244    /// Serialized shortest path between two entities.
245    ConnectionPath(String),
246    /// Generic list of entity names.
247    Entities(Vec<String>),
248    /// Human-readable entity importance analysis.
249    ImportanceAnalysis(String),
250    /// Serialized entity hierarchy tree.
251    EntityHierarchy(String),
252    /// Serialized entity cluster data.
253    EntityClusters(String),
254    /// Serialized entity timeline data.
255    EntityTimeline(String),
256    /// Serialized entity trend analysis.
257    EntityTrends(String),
258    /// Graph-aware assembled context within a token budget.
259    AssembledContext(post_cortex_memory::context_assembly::AssembledContext),
260}
261
262/// Global singleton holding the optional injected memory system.
263static MEMORY_SYSTEM: LazyLock<ArcSwap<Option<Arc<ConversationMemorySystem>>>> =
264    LazyLock::new(|| ArcSwap::new(Arc::new(None)));
265
266/// Global singleton holding the canonical [`MemoryServiceImpl`] derived
267/// from `MEMORY_SYSTEM`. Built lazily on the first call to `get_service`
268/// and replaced whenever a new memory system is injected.
269///
270/// Phase 6 (this commit) wires only `update_conversation_context` and
271/// `bulk_update_conversation_context` through this service. Other MCP
272/// tools still use the raw [`ConversationMemorySystem`] until they're
273/// migrated. Once every tool flows through the service, the singleton
274/// goes away and the function signatures flip to take
275/// `&dyn PostCortexService` (Phase 7).
276static SERVICE: LazyLock<ArcSwap<Option<Arc<MemoryServiceImpl>>>> =
277    LazyLock::new(|| ArcSwap::new(Arc::new(None)));
278
279/// Inject a pre-built memory system for daemon mode.
280pub fn inject_memory_system(system: Arc<ConversationMemorySystem>) {
281    info!("MCP-TOOLS: Injecting external memory system for daemon mode");
282    // Wrap the injected system in a canonical service and store it
283    // alongside the raw handle so write-path callers can pick it up
284    // without reconstructing the Pipeline on every request.
285    let service = Arc::new(MemoryServiceImpl::new(system.clone()));
286    MEMORY_SYSTEM.store(Arc::new(Some(system)));
287    SERVICE.store(Arc::new(Some(service)));
288    info!("MCP-TOOLS: Memory system injection complete");
289}
290
291/// Return the cached canonical service, building it from the current
292/// memory system if necessary. Both `update_conversation_context` and
293/// `bulk_update_conversation_context` flow through this — no transport
294/// is allowed to bypass the canonical impl.
295pub async fn get_service() -> Result<Arc<MemoryServiceImpl>> {
296    if let Some(svc) = SERVICE.load().as_ref() {
297        return Ok(svc.clone());
298    }
299    // Cold start: build from the (possibly cold) memory system. If two
300    // callers race here they'll each construct a `MemoryServiceImpl`,
301    // but `rcu` keeps the first one installed and the loser is dropped
302    // when its Arc goes out of scope.
303    let system = get_memory_system().await?;
304    let new_svc = Arc::new(MemoryServiceImpl::new(system));
305    let new_option = Arc::new(Some(new_svc));
306    SERVICE.rcu(|current| {
307        if current.is_none() {
308            new_option.clone()
309        } else {
310            current.clone()
311        }
312    });
313    Ok(SERVICE.load().as_ref().as_ref().unwrap().clone())
314}
315
316/// Create a new memory system from the given configuration.
317pub async fn get_memory_system_with_config(
318    config: SystemConfig,
319) -> Result<ConversationMemorySystem> {
320    ConversationMemorySystem::new(config)
321        .await
322        .map_err(anyhow::Error::msg)
323}
324
325/// Return the global memory system, lazily initialising it if needed.
326pub async fn get_memory_system() -> Result<Arc<ConversationMemorySystem>> {
327    info!("MCP-TOOLS: get_memory_system() called");
328
329    if let Some(system) = MEMORY_SYSTEM.load().as_ref() {
330        info!("MCP-TOOLS: Using existing system");
331        return Ok(system.clone());
332    }
333
334    info!("MCP-TOOLS: System not initialized, proceeding with initialization");
335
336    let data_directory = dirs::home_dir()
337        .unwrap_or_else(|| std::path::PathBuf::from("."))
338        .join(".post-cortex/data")
339        .to_str()
340        .unwrap()
341        .to_string();
342
343    let mut config = SystemConfig {
344        data_directory,
345        ..SystemConfig::default()
346    };
347
348    #[cfg(feature = "embeddings")]
349    {
350        config.enable_embeddings = true;
351        config.embeddings_model_type = "MultilingualMiniLM".to_string();
352        config.auto_vectorize_on_update = true;
353        config.cross_session_search_enabled = true;
354        info!("MCP-TOOLS: Embeddings enabled in config");
355    }
356
357    #[cfg(not(feature = "embeddings"))]
358    {
359        info!("MCP-TOOLS: Embeddings not compiled in");
360    }
361
362    info!("MCP-TOOLS: About to call ConversationMemorySystem::new()");
363    let system = ConversationMemorySystem::new(config)
364        .await
365        .map_err(anyhow::Error::msg)?;
366    info!("MCP-TOOLS: ConversationMemorySystem created successfully");
367
368    let arc_system = Arc::new(system);
369
370    let new_option = Arc::new(Some(arc_system.clone()));
371    MEMORY_SYSTEM.rcu(|current| {
372        if current.is_none() {
373            info!("MCP-TOOLS: Storing newly created system");
374            new_option.clone()
375        } else {
376            info!("MCP-TOOLS: Another thread already initialized the system, using existing");
377            current.clone()
378        }
379    });
380
381    info!("MCP-TOOLS: System initialization completed");
382
383    Ok(MEMORY_SYSTEM.load().as_ref().as_ref().unwrap().clone())
384}
385
386/// Typed interaction payloads matching MCP `interaction_type` values.
387#[derive(Serialize, Deserialize, Debug)]
388pub enum Interaction {
389    /// A question-answer pair.
390    QA {
391        /// The question asked.
392        question: String,
393        /// The answer provided.
394        answer: String,
395        /// Supplementary detail lines.
396        details: Vec<String>,
397    },
398    /// A code change recorded in the session.
399    CodeChange {
400        /// Path to the changed file.
401        file_path: String,
402        /// Diff or change description.
403        diff: String,
404        /// Supplementary detail lines.
405        details: Vec<String>,
406    },
407    /// A problem that was solved.
408    ProblemSolved {
409        /// Description of the problem.
410        problem: String,
411        /// Description of the solution.
412        solution: String,
413        /// Supplementary detail lines.
414        details: Vec<String>,
415    },
416    /// An architectural or technical decision.
417    DecisionMade {
418        /// The decision that was made.
419        decision: String,
420        /// Rationale behind the decision.
421        rationale: String,
422        /// Supplementary detail lines.
423        details: Vec<String>,
424    },
425    /// A new requirement added to the project.
426    RequirementAdded {
427        /// The requirement text.
428        requirement: String,
429        /// Priority level (e.g. "high", "medium", "low").
430        priority: String,
431        /// Supplementary detail lines.
432        details: Vec<String>,
433    },
434    /// A concept definition recorded for future reference.
435    ConceptDefined {
436        /// Name of the concept.
437        concept: String,
438        /// Definition of the concept.
439        definition: String,
440        /// Supplementary detail lines.
441        details: Vec<String>,
442    },
443}
444
445/// Standardised result envelope for every MCP tool.
446#[derive(Serialize, Deserialize, Debug)]
447pub struct MCPToolResult {
448    /// Whether the tool invocation succeeded.
449    pub success: bool,
450    /// Human-readable status or error message.
451    pub message: String,
452    /// Optional structured payload.
453    pub data: Option<serde_json::Value>,
454}
455
456impl MCPToolResult {
457    /// Build a successful result with an optional JSON payload.
458    pub fn success(message: String, data: Option<serde_json::Value>) -> Self {
459        Self {
460            success: true,
461            message,
462            data,
463        }
464    }
465
466    /// Build an error result with no payload.
467    pub fn error(message: String) -> Self {
468        Self {
469            success: false,
470            message,
471            data: None,
472        }
473    }
474}
475
476/// A single context update item accepted by the bulk-update tool.
477#[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)]
478pub struct ContextUpdateItem {
479    /// Interaction type discriminator (e.g. `"qa"`, `"decision_made"`).
480    pub interaction_type: String,
481    /// Key-value content fields for the interaction.
482    pub content: HashMap<String, String>,
483    /// Named entities mentioned in this update. Required by the canonical
484    /// write path so the entity graph is never silently empty for
485    /// MCP-driven writes.
486    #[serde(default)]
487    pub entities: Vec<EntityItem>,
488    /// Relations between the entities listed above. Both endpoints must
489    /// appear in `entities`; the canonical impl rejects dangling
490    /// references and self-relations.
491    #[serde(default)]
492    pub relations: Vec<RelationItem>,
493    /// Optional code reference attached to the update.
494    pub code_reference: Option<CodeReference>,
495}
496
497/// Wire shape for an entity carried by an MCP `update_conversation_context`
498/// call. `entity_type` is a lowercase string from the closed set:
499/// `technology`, `concept`, `problem`, `solution`, `decision`,
500/// `code_component`. Unknown values fall back to `concept` server-side.
501#[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)]
502pub struct EntityItem {
503    /// Unique human-readable entity name.
504    pub name: String,
505    /// Lowercase entity-type string (`technology`, `concept`, ...).
506    #[serde(default = "default_entity_type")]
507    pub entity_type: String,
508}
509
510fn default_entity_type() -> String {
511    "concept".to_string()
512}
513
514/// Wire shape for a relation between two named entities. `relation_type`
515/// is a lowercase string from the closed set: `required_by`, `leads_to`,
516/// `related_to`, `conflicts_with`, `depends_on`, `implements`,
517/// `caused_by`, `solves`. Unknown values cause the request to be
518/// rejected with `InvalidArgument`.
519#[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)]
520pub struct RelationItem {
521    /// Source entity name (must match a `name` in the `entities` array).
522    pub from_entity: String,
523    /// Target entity name (must also match an `entities` entry).
524    pub to_entity: String,
525    /// Lowercase relation-type string.
526    pub relation_type: String,
527    /// Short explanation of why this relation exists.
528    pub context: String,
529}
530
531/// Parse a datetime string in RFC 3339, `%Y-%m-%d %H:%M:%S`, or `%Y-%m-%d` format.
532///
533/// Returns 30 days ago when the input is empty.
534pub(crate) fn parse_datetime(date_str: &str) -> Result<chrono::DateTime<chrono::Utc>> {
535    if date_str.is_empty() {
536        return Ok(chrono::Utc::now() - chrono::Duration::days(30));
537    }
538
539    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(date_str) {
540        return Ok(dt.with_timezone(&chrono::Utc));
541    }
542
543    if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(date_str, "%Y-%m-%d %H:%M:%S") {
544        return Ok(dt.and_utc());
545    }
546
547    if let Ok(dt) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
548        return dt
549            .and_hms_opt(0, 0, 0)
550            .ok_or_else(|| anyhow::anyhow!("Invalid time components for date: {}", date_str))
551            .map(|dt| dt.and_utc());
552    }
553
554    Err(anyhow::anyhow!("Failed to parse datetime: {}", date_str))
555}