Skip to main content

post_cortex_daemon/daemon/mcp_service/
mod.rs

1// Copyright (c) 2025, 2026 Julius ML
2// MIT License
3
4//! Post-Cortex MCP Service — consolidated 9-tool API.
5//!
6//! Tools:
7//! 1. session                       — create/list/load/search/update_metadata/delete sessions
8//! 2. update_conversation_context   — single or bulk updates
9//! 3. semantic_search               — unified search with scope (structured payload)
10//! 4. get_structured_summary        — summary with optional sections
11//! 5. query_conversation_context    — flexible queries
12//! 6. manage_workspace              — workspace operations
13//! 7. assemble_context              — graph-aware retrieval (semantic + traversal + impact)
14//! 8. manage_entity                 — delete entities / context updates from a session
15//! 9. admin                         — daemon health, vectorization, checkpoints
16//!
17//! The `#[tool_router]`-decorated impl block must stay in a single file so the
18//! rmcp macro can see every `#[tool]` method at once. Tool method bodies are
19//! delegated to per-tool submodules to keep this file readable.
20
21use crate::daemon::coerce::CoercionError;
22use post_cortex_mcp::MCPToolResult;
23use post_cortex_memory::ConversationMemorySystem;
24use rmcp::{
25    RoleServer, ServerHandler,
26    handler::server::router::tool::ToolRouter,
27    handler::server::wrapper::Parameters,
28    model::{
29        CallToolRequestParam, CallToolResult, Content, ErrorData as McpError, ListToolsResult,
30        PaginatedRequestParam, *,
31    },
32    service::RequestContext,
33    tool, tool_router,
34};
35use std::sync::Arc;
36use tracing::info;
37
38mod admin;
39mod assembly;
40mod entity;
41mod query;
42mod requests;
43mod search;
44mod session;
45mod summary;
46mod update_context;
47mod workspace;
48
49// Request types are public so external code (e.g. integration tests) can
50// build them, mirroring the original monolith's surface.
51pub use requests::{
52    AdminRequest, AssembleContextRequest, ContextUpdateItem, GetStructuredSummaryRequest,
53    ManageEntityRequest, ManageWorkspaceRequest, QueryConversationContextRequest,
54    SemanticSearchRequest, SessionRequest, UpdateConversationContextRequest,
55};
56
57/// Post-Cortex MCP Service
58#[derive(Clone)]
59pub struct PostCortexService {
60    memory_system: Arc<ConversationMemorySystem>,
61    tool_router: ToolRouter<PostCortexService>,
62}
63
64// =============================================================================
65// Helper Functions (shared across tool submodules)
66// =============================================================================
67
68/// Emit an MCPToolResult through CallToolResult, preserving structured data.
69///
70/// When `result.data` is present, the structured JSON payload is appended as a
71/// second `Content::text` item so MCP clients keep access to scores, snippets
72/// and entity tags instead of the human-readable summary alone.
73pub(crate) fn mcp_result_to_call_result(result: MCPToolResult) -> CallToolResult {
74    let mut contents = vec![Content::text(result.message)];
75    if let Some(data) = result.data {
76        contents.push(Content::text(format!(
77            "\n\nResults:\n{}",
78            serde_json::to_string_pretty(&data).unwrap_or_default()
79        )));
80    }
81    CallToolResult::success(contents)
82}
83
84/// Check that a session exists in storage; used by dry_run paths so we don't
85/// approve an update for a session that never existed.
86pub(crate) async fn check_session_exists_for_dry_run(
87    memory_system: &Arc<ConversationMemorySystem>,
88    session_id: uuid::Uuid,
89) -> Result<(), McpError> {
90    match memory_system.storage_actor.load_session(session_id).await {
91        Ok(Some(_)) => Ok(()),
92        Ok(None) => Err(CoercionError::new(
93            "Session not found",
94            std::io::Error::new(std::io::ErrorKind::NotFound, "session does not exist"),
95            Some(serde_json::Value::String(session_id.to_string())),
96        )
97        .with_parameter_path("session_id".to_string())
98        .with_hint("Create a session first using the 'session' tool with action='create'")
99        .to_mcp_error()),
100        Err(e) => Err(McpError::internal_error(
101            format!("Failed to check session existence: {}", e),
102            None,
103        )),
104    }
105}
106
107/// Parse `(date_from, date_to)` into a UTC range. Both must be supplied; partial
108/// pairs return an MCP error. `(None, None)` returns `Ok(None)`.
109pub(crate) fn parse_date_range(
110    date_from: Option<String>,
111    date_to: Option<String>,
112) -> Result<Option<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)>, McpError> {
113    match (date_from, date_to) {
114        (Some(from_str), Some(to_str)) => {
115            let from = chrono::DateTime::parse_from_rfc3339(&from_str)
116                .map_err(|_| {
117                    McpError::invalid_params(
118                        format!(
119                            "Invalid date_from format: '{}'. Expected RFC3339 format (e.g., 2024-01-01T00:00:00Z)",
120                            from_str
121                        ),
122                        Some(serde_json::Value::String("date_from".to_string())),
123                    )
124                })?
125                .with_timezone(&chrono::Utc);
126            let to = chrono::DateTime::parse_from_rfc3339(&to_str)
127                .map_err(|_| {
128                    McpError::invalid_params(
129                        format!(
130                            "Invalid date_to format: '{}'. Expected RFC3339 format (e.g., 2024-12-31T23:59:59Z)",
131                            to_str
132                        ),
133                        Some(serde_json::Value::String("date_to".to_string())),
134                    )
135                })?
136                .with_timezone(&chrono::Utc);
137            Ok(Some((from, to)))
138        }
139        (Some(_), None) | (None, Some(_)) => Err(McpError::invalid_params(
140            "Both date_from and date_to must be provided together".to_string(),
141            Some(serde_json::Value::String("date_from,date_to".to_string())),
142        )),
143        (None, None) => Ok(None),
144    }
145}
146
147// =============================================================================
148// Tool dispatch — every method here is a thin call into a per-tool submodule.
149// =============================================================================
150
151#[tool_router]
152impl PostCortexService {
153    /// Create a new MCP service backed by the given memory system.
154    pub fn new(memory_system: Arc<ConversationMemorySystem>) -> Self {
155        info!("Initializing Post-Cortex MCP Service (9 consolidated tools)");
156        Self {
157            memory_system,
158            tool_router: Self::tool_router(),
159        }
160    }
161
162    #[tool(
163        description = "Manage sessions. Actions: create, list, load, search, update_metadata, delete.",
164        input_schema = rmcp::handler::server::common::schema_for_type::<SessionRequest>()
165    )]
166    async fn session(
167        &self,
168        params: Parameters<serde_json::Value>,
169    ) -> Result<CallToolResult, McpError> {
170        session::handle(&self.memory_system, params).await
171    }
172
173    #[tool(
174        description = "Add context updates to conversation. Supports single update or bulk mode with updates array.",
175        input_schema = rmcp::handler::server::common::schema_for_type::<UpdateConversationContextRequest>()
176    )]
177    async fn update_conversation_context(
178        &self,
179        params: Parameters<serde_json::Value>,
180    ) -> Result<CallToolResult, McpError> {
181        update_context::handle(&self.memory_system, params).await
182    }
183
184    #[tool(
185        description = "Universal semantic search. Scope: session (requires scope_id), workspace (requires scope_id), or global (default).",
186        input_schema = rmcp::handler::server::common::schema_for_type::<SemanticSearchRequest>()
187    )]
188    async fn semantic_search(
189        &self,
190        params: Parameters<serde_json::Value>,
191    ) -> Result<CallToolResult, McpError> {
192        search::handle(&self.memory_system, params).await
193    }
194
195    #[tool(
196        description = "Get session summary. Use 'include' to specify sections: decisions, insights, entities, or all (default).",
197        input_schema = rmcp::handler::server::common::schema_for_type::<GetStructuredSummaryRequest>()
198    )]
199    async fn get_structured_summary(
200        &self,
201        params: Parameters<serde_json::Value>,
202    ) -> Result<CallToolResult, McpError> {
203        summary::handle(&self.memory_system, params).await
204    }
205
206    #[tool(
207        description = "Query session data. Types include: find_related_entities, get_entity_context, search_updates, entity_importance, entity_network, find_related_content, key_decisions, key_insights, session_statistics, structured_summary, decisions, open_questions, assemble_context.",
208        input_schema = rmcp::handler::server::common::schema_for_type::<QueryConversationContextRequest>()
209    )]
210    async fn query_conversation_context(
211        &self,
212        params: Parameters<serde_json::Value>,
213    ) -> Result<CallToolResult, McpError> {
214        query::handle(&self.memory_system, params).await
215    }
216
217    #[tool(
218        description = "Manage workspaces. Actions: create, list, get, delete, add_session, remove_session",
219        input_schema = rmcp::handler::server::common::schema_for_type::<ManageWorkspaceRequest>()
220    )]
221    async fn manage_workspace(
222        &self,
223        params: Parameters<serde_json::Value>,
224    ) -> Result<CallToolResult, McpError> {
225        workspace::handle(&self.memory_system, params).await
226    }
227
228    #[tool(
229        description = "Graph-aware context assembly: blends semantic search with entity-graph traversal and impact analysis. Returns ranked items, entity neighbourhood, dependency impact, and an LLM-ready formatted block. Scope: session or workspace.",
230        input_schema = rmcp::handler::server::common::schema_for_type::<AssembleContextRequest>()
231    )]
232    async fn assemble_context(
233        &self,
234        params: Parameters<serde_json::Value>,
235    ) -> Result<CallToolResult, McpError> {
236        assembly::handle(&self.memory_system, params).await
237    }
238
239    #[tool(
240        description = "Manage entities and context entries in a session. Actions: delete (remove entity + cascade typed edges; requires entity_name), delete_update (remove a single context entry by entry_id).",
241        input_schema = rmcp::handler::server::common::schema_for_type::<ManageEntityRequest>()
242    )]
243    async fn manage_entity(
244        &self,
245        params: Parameters<serde_json::Value>,
246    ) -> Result<CallToolResult, McpError> {
247        entity::handle(&self.memory_system, params).await
248    }
249
250    #[tool(
251        description = "Daemon administration. Actions: health, vectorize_session (session_id), vectorize_stats, create_checkpoint (session_id).",
252        input_schema = rmcp::handler::server::common::schema_for_type::<AdminRequest>()
253    )]
254    async fn admin(
255        &self,
256        params: Parameters<serde_json::Value>,
257    ) -> Result<CallToolResult, McpError> {
258        admin::handle(&self.memory_system, params).await
259    }
260}
261
262// NOTE: We implement ServerHandler manually instead of using #[tool_handler] macro
263// to strip $schema from tool input schemas for broader MCP client compatibility.
264// Many clients (Cursor, Windsurf, Continue.dev) don't support JSON Schema draft/2020-12.
265impl ServerHandler for PostCortexService {
266    fn get_info(&self) -> ServerInfo {
267        ServerInfo {
268            protocol_version: ProtocolVersion::V_2024_11_05,
269            capabilities: ServerCapabilities::builder().enable_tools().build(),
270            server_info: Implementation::from_build_env(),
271            instructions: Some(
272                "Post-Cortex: Intelligent conversation memory system with 9 consolidated MCP tools. \
273                 Tools: session, update_conversation_context, semantic_search, get_structured_summary, \
274                 query_conversation_context, manage_workspace, assemble_context, manage_entity, admin. \
275                 All use shared RocksDB for centralized knowledge management.".to_string()
276            ),
277        }
278    }
279
280    async fn initialize(
281        &self,
282        _request: InitializeRequestParam,
283        context: RequestContext<RoleServer>,
284    ) -> Result<InitializeResult, McpError> {
285        if let Some(parts) = context.extensions.get::<axum::http::request::Parts>() {
286            info!("Client initialized from {}", parts.uri);
287        }
288        Ok(self.get_info())
289    }
290
291    // Custom list_tools that strips $schema from input schemas for client compatibility
292    async fn list_tools(
293        &self,
294        _request: Option<PaginatedRequestParam>,
295        _context: RequestContext<RoleServer>,
296    ) -> Result<ListToolsResult, McpError> {
297        let tools = self.tool_router.list_all();
298
299        // Strip $schema from each tool's input_schema for broader client compatibility
300        // Clone and modify since input_schema is Arc<JsonObject>
301        let tools: Vec<Tool> = tools
302            .into_iter()
303            .map(|mut tool| {
304                let mut schema = (*tool.input_schema).clone();
305                schema.remove("$schema");
306                // Also strip from $defs if present
307                if let Some(defs) = schema.get_mut("$defs")
308                    && let Some(defs_obj) = defs.as_object_mut()
309                {
310                    for (_, def) in defs_obj.iter_mut() {
311                        if let Some(def_obj) = def.as_object_mut() {
312                            def_obj.remove("$schema");
313                        }
314                    }
315                }
316                tool.input_schema = std::sync::Arc::new(schema);
317                tool
318            })
319            .collect();
320
321        Ok(ListToolsResult {
322            tools,
323            meta: None,
324            next_cursor: None,
325        })
326    }
327
328    // Route tool calls to the tool router
329    async fn call_tool(
330        &self,
331        request: CallToolRequestParam,
332        context: RequestContext<RoleServer>,
333    ) -> Result<CallToolResult, McpError> {
334        let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
335        self.tool_router.call(tcc).await
336    }
337}