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