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::debug;
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: query entity matching plus graph 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 /// Entity network around a center entity.
243 EntityNetwork(post_cortex_core::graph::entity_graph::EntityNetwork),
244 /// Serialized shortest path between two entities.
245 ConnectionPath(String),
246 /// Generic list of entity names.
247 Entities(Vec<String>),
248 /// Per-entity importance analysis.
249 ImportanceAnalysis(Vec<post_cortex_core::graph::entity_graph::EntityAnalysis>),
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. Returns the canonical
280/// service built around it, for transports outside the MCP tools to share.
281pub fn inject_memory_system(system: Arc<ConversationMemorySystem>) -> Arc<MemoryServiceImpl> {
282 debug!("MCP-TOOLS: Injecting external memory system for daemon mode");
283 // Wrap the injected system in a canonical service and store it
284 // alongside the raw handle so write-path callers can pick it up
285 // without reconstructing the Pipeline on every request.
286 let service = Arc::new(MemoryServiceImpl::new(system.clone()));
287 MEMORY_SYSTEM.store(Arc::new(Some(system)));
288 SERVICE.store(Arc::new(Some(service.clone())));
289 debug!("MCP-TOOLS: Memory system injection complete");
290 service
291}
292
293/// Return the cached canonical service, building it from the current
294/// memory system if necessary. Both `update_conversation_context` and
295/// `bulk_update_conversation_context` flow through this — no transport
296/// is allowed to bypass the canonical impl.
297pub async fn get_service() -> Result<Arc<MemoryServiceImpl>> {
298 if let Some(svc) = SERVICE.load().as_ref() {
299 return Ok(svc.clone());
300 }
301 // Cold start: build from the (possibly cold) memory system. If two
302 // callers race here they'll each construct a `MemoryServiceImpl`,
303 // but `rcu` keeps the first one installed and the loser is dropped
304 // when its Arc goes out of scope.
305 let system = get_memory_system().await?;
306 let new_svc = Arc::new(MemoryServiceImpl::new(system));
307 let new_option = Arc::new(Some(new_svc));
308 SERVICE.rcu(|current| {
309 if current.is_none() {
310 new_option.clone()
311 } else {
312 current.clone()
313 }
314 });
315 Ok(SERVICE.load().as_ref().as_ref().unwrap().clone())
316}
317
318/// Create a new memory system from the given configuration.
319pub async fn get_memory_system_with_config(
320 config: SystemConfig,
321) -> Result<ConversationMemorySystem> {
322 ConversationMemorySystem::new(config)
323 .await
324 .map_err(anyhow::Error::msg)
325}
326
327/// Return the global memory system, lazily initialising it if needed.
328pub async fn get_memory_system() -> Result<Arc<ConversationMemorySystem>> {
329 debug!("MCP-TOOLS: get_memory_system() called");
330
331 if let Some(system) = MEMORY_SYSTEM.load().as_ref() {
332 debug!("MCP-TOOLS: Using existing system");
333 return Ok(system.clone());
334 }
335
336 debug!("MCP-TOOLS: System not initialized, proceeding with initialization");
337
338 let data_directory = dirs::home_dir()
339 .unwrap_or_else(|| std::path::PathBuf::from("."))
340 .join(".post-cortex/data")
341 .to_str()
342 .unwrap()
343 .to_string();
344
345 let mut config = SystemConfig {
346 data_directory,
347 ..SystemConfig::default()
348 };
349
350 #[cfg(feature = "embeddings")]
351 {
352 // Model + dimension inherit `SystemConfig::default()` (model2vec),
353 // matching the daemon; BERT models need the opt-in `bert` feature.
354 config.enable_embeddings = true;
355 config.auto_vectorize_on_update = true;
356 config.cross_session_search_enabled = true;
357 debug!("MCP-TOOLS: Embeddings enabled in config");
358 }
359
360 #[cfg(not(feature = "embeddings"))]
361 {
362 debug!("MCP-TOOLS: Embeddings not compiled in");
363 }
364
365 debug!("MCP-TOOLS: About to call ConversationMemorySystem::new()");
366 let system = ConversationMemorySystem::new(config)
367 .await
368 .map_err(anyhow::Error::msg)?;
369 debug!("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 debug!("MCP-TOOLS: Storing newly created system");
377 new_option.clone()
378 } else {
379 debug!("MCP-TOOLS: Another thread already initialized the system, using existing");
380 current.clone()
381 }
382 });
383
384 debug!("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 /// Captured messages the update was drawn from (`message:<seq>`).
499 #[serde(default)]
500 pub derived_from: Vec<String>,
501}
502
503/// Wire shape for an entity carried by an MCP `update_conversation_context`
504/// call. `entity_type` is a lowercase string from the closed set:
505/// `technology`, `concept`, `problem`, `solution`, `decision`,
506/// `code_component`. Unknown values fall back to `concept` server-side.
507#[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)]
508pub struct EntityItem {
509 /// Unique human-readable entity name.
510 pub name: String,
511 /// Lowercase entity-type string (`technology`, `concept`, ...).
512 #[serde(default = "default_entity_type")]
513 pub entity_type: String,
514}
515
516fn default_entity_type() -> String {
517 "concept".to_string()
518}
519
520/// Wire shape for a relation between two named entities. `relation_type`
521/// is a lowercase string from the closed set: `required_by`, `leads_to`,
522/// `related_to`, `conflicts_with`, `depends_on`, `implements`,
523/// `caused_by`, `solves`. Unknown values cause the request to be
524/// rejected with `InvalidArgument`.
525#[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)]
526pub struct RelationItem {
527 /// Source entity name (must match a `name` in the `entities` array).
528 pub from_entity: String,
529 /// Target entity name (must also match an `entities` entry).
530 pub to_entity: String,
531 /// Lowercase relation-type string.
532 pub relation_type: String,
533 /// Short explanation of why this relation exists.
534 pub context: String,
535}
536
537/// Parse a datetime string in RFC 3339, `%Y-%m-%d %H:%M:%S`, or `%Y-%m-%d` format.
538///
539/// Returns 30 days ago when the input is empty.
540pub(crate) fn parse_datetime(date_str: &str) -> Result<chrono::DateTime<chrono::Utc>> {
541 if date_str.is_empty() {
542 return Ok(chrono::Utc::now() - chrono::Duration::days(30));
543 }
544
545 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(date_str) {
546 return Ok(dt.with_timezone(&chrono::Utc));
547 }
548
549 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(date_str, "%Y-%m-%d %H:%M:%S") {
550 return Ok(dt.and_utc());
551 }
552
553 if let Ok(dt) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
554 return dt
555 .and_hms_opt(0, 0, 0)
556 .ok_or_else(|| anyhow::anyhow!("Invalid time components for date: {}", date_str))
557 .map(|dt| dt.and_utc());
558 }
559
560 Err(anyhow::anyhow!("Failed to parse datetime: {}", date_str))
561}