Skip to main content

cortex_mcp/
server.rs

1//! `CortexServer` — the `rmcp` 1.7 `ServerHandler` that exposes Cortex's tool
2//! surface over a spec-compliant MCP 2025-06-18 stdio transport.
3//!
4//! This module replaces the hand-rolled JSON-RPC dispatcher in
5//! [`crate::serve`]. The legacy dispatcher treated the request `method`
6//! field as a tool name (e.g. `"cortex_memory_note"`) and therefore failed
7//! Claude Code's MCP client, which speaks the canonical
8//! `initialize` / `tools/list` / `tools/call` handshake. The handshake itself
9//! is now handled by `rmcp`; tool dispatch routes through the existing
10//! [`ToolRegistry`] so every [`crate::ToolHandler`] implementation — and
11//! every per-tool unit test — keeps working unchanged.
12//!
13//! ## Wire mapping
14//!
15//! Every `#[tool]` method below accepts `Parameters<serde_json::Value>` and
16//! delegates to [`CortexServer::dispatch`], which looks up the static tool
17//! name in the registry and returns either `Json<Value>` (success) or an
18//! [`McpError`]. The mapping from [`ToolError`] to [`McpError`] is:
19//!
20//! | [`ToolError`]                    | [`McpError`] constructor      |
21//! |----------------------------------|-------------------------------|
22//! | `InvalidParams(msg)`             | `invalid_params(msg, None)`   |
23//! | `PolicyRejected(msg)`            | `invalid_params(msg, None)`   |
24//! | `SizeLimitExceeded(msg)`         | `invalid_params(msg, None)`   |
25//! | `Internal(msg)`                  | `internal_error(msg, None)`   |
26//!
27//! `PolicyRejected` maps to `invalid_params` because every policy rejection
28//! today is "the caller's input was refused by an authority gate" — there is
29//! no separate rmcp variant for that case and the JSON-RPC `-32602` semantics
30//! ("the parameters are invalid for this method as configured") are correct.
31//!
32//! ## Why a registry passthrough instead of typed Params per tool
33//!
34//! Cordance's `rmcp` integration uses one `JsonSchema`-derived params struct
35//! per tool, which gives Claude Code's UI a rich schema for argument
36//! prompting. Cortex's 18 tools all currently validate their own raw
37//! `serde_json::Value` payload in `ToolHandler::call`. Re-deriving 18 typed
38//! params structs would duplicate that validation surface and risk drift
39//! between the rmcp schema and the runtime validator. The passthrough
40//! preserves the existing single source of truth at the cost of presenting
41//! "any JSON object" as the schema in `tools/list`. The handshake itself
42//! still works, which is the bug this module was written to fix.
43
44use std::sync::Arc;
45
46use rmcp::handler::server::wrapper::{Json, Parameters};
47use rmcp::model::{Implementation, InitializeResult, ProtocolVersion, ServerCapabilities};
48use rmcp::transport::stdio;
49use rmcp::{ErrorData as McpError, ServerHandler, ServiceExt, tool, tool_handler, tool_router};
50use serde_json::{Map, Value};
51
52use crate::tool_handler::ToolError;
53use crate::tool_registry::ToolRegistry;
54
55/// JSON object accepted as the argument map for every Cortex MCP tool.
56///
57/// rmcp 1.7's `Parameters<T>` enforces that `T: JsonSchema` derives a schema
58/// whose root has `"type": "object"` (MCP 2025-06-18 §6 — tool argument schemas
59/// MUST be objects). Naked `serde_json::Value` derives the "any" schema (no
60/// `type` field) and rmcp panics at `tools/list` registration time. schemars
61/// 1.x's impl of `JsonSchema for serde_json::Map<String, Value>` produces a
62/// proper `{ "type": "object", "additionalProperties": true }` schema, which
63/// satisfies the rmcp/MCP contract while preserving the "any JSON object"
64/// wire shape every existing [`crate::ToolHandler`] already validates.
65type ToolArgs = Map<String, Value>;
66
67/// JSON object returned as the response body for every Cortex MCP tool.
68///
69/// Mirrors [`ToolArgs`] on the response side — the rmcp output-schema check
70/// requires `"type": "object"` at the root, which `Map<String, Value>`
71/// produces directly. Every existing Cortex tool already wraps its response
72/// in `serde_json::json!({...})`, so the invariant holds at the call site;
73/// [`CortexServer::dispatch`] errors out with a clear message if a future
74/// tool ever returns a non-object value.
75type ToolResultBody = Map<String, Value>;
76
77/// Cortex MCP server.
78///
79/// Holds an `Arc<ToolRegistry>` so the struct is cheap to clone. `rmcp`
80/// requires `ServerHandler: Clone` for its router.
81#[derive(Clone)]
82pub struct CortexServer {
83    registry: Arc<ToolRegistry>,
84}
85
86impl std::fmt::Debug for CortexServer {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.debug_struct("CortexServer")
89            .field("registry", &self.registry)
90            .finish()
91    }
92}
93
94impl CortexServer {
95    /// Construct over a fully-wired registry. The registry must already
96    /// contain every tool the server will expose; rmcp's `tools/list` reply
97    /// is generated from the static `#[tool(...)]` attributes on this impl
98    /// and not from the registry contents, so a tool advertised here but not
99    /// registered will return `internal_error` at call time.
100    #[must_use]
101    pub fn new(registry: Arc<ToolRegistry>) -> Self {
102        Self { registry }
103    }
104
105    /// Route a tool call through the underlying [`ToolRegistry`].
106    ///
107    /// `name` is a compile-time tool name supplied by each `#[tool]` shim
108    /// below; `params` is the peer-supplied arguments object. The registry
109    /// returns `None` only if the name was never registered (a wiring bug
110    /// that surfaces as `internal_error`). A successful tool response is
111    /// asserted to be a JSON object — every current Cortex tool returns
112    /// `serde_json::json!({...})`; a non-object value indicates a programming
113    /// error in the tool and is reported as `internal_error` rather than
114    /// silently coerced.
115    fn dispatch(
116        &self,
117        name: &'static str,
118        params: ToolArgs,
119    ) -> Result<Json<ToolResultBody>, McpError> {
120        let raw_params = Value::Object(params);
121        match self.registry.dispatch(name, raw_params) {
122            Some(Ok(Value::Object(body))) => Ok(Json(body)),
123            Some(Ok(other)) => {
124                tracing::error!(
125                    tool = name,
126                    value_kind = value_kind(&other),
127                    "mcp: tool returned non-object value (violates output schema invariant)"
128                );
129                Err(McpError::internal_error(
130                    format!(
131                        "tool '{name}' returned non-object value (kind: {})",
132                        value_kind(&other)
133                    ),
134                    None,
135                ))
136            }
137            Some(Err(err)) => Err(tool_error_to_mcp(name, err)),
138            None => {
139                tracing::error!(
140                    tool = name,
141                    "mcp: tool advertised by #[tool] but not registered in ToolRegistry"
142                );
143                Err(McpError::internal_error(
144                    format!("tool '{name}' not registered"),
145                    None,
146                ))
147            }
148        }
149    }
150}
151
152fn value_kind(v: &Value) -> &'static str {
153    match v {
154        Value::Null => "null",
155        Value::Bool(_) => "bool",
156        Value::Number(_) => "number",
157        Value::String(_) => "string",
158        Value::Array(_) => "array",
159        Value::Object(_) => "object",
160    }
161}
162
163fn tool_error_to_mcp(tool_name: &str, err: ToolError) -> McpError {
164    match err {
165        ToolError::InvalidParams(msg) => McpError::invalid_params(msg, None),
166        ToolError::PolicyRejected(msg) => McpError::invalid_params(msg, None),
167        ToolError::SizeLimitExceeded(msg) => McpError::invalid_params(msg, None),
168        ToolError::Internal(msg) => {
169            tracing::warn!(tool = tool_name, error = %msg, "mcp: tool internal error");
170            McpError::internal_error(msg, None)
171        }
172    }
173}
174
175#[tool_router]
176impl CortexServer {
177    // ── Session tier (read-only, no confirmation token) ─────────────────
178
179    #[tool(
180        name = "cortex_search",
181        description = "Find active memories matching the query. FTS5 by default; \
182                       set `semantic: true` for Ollama-embedding similarity search. \
183                       Returns top-K with relevance scores."
184    )]
185    async fn cortex_search(
186        &self,
187        Parameters(p): Parameters<ToolArgs>,
188    ) -> Result<Json<ToolResultBody>, McpError> {
189        self.dispatch("cortex_search", p)
190    }
191
192    #[tool(
193        name = "cortex_context",
194        description = "Build a context pack for the current session. Optionally include \
195                       doctrine snippets and filter by tag, domain, or query."
196    )]
197    async fn cortex_context(
198        &self,
199        Parameters(p): Parameters<ToolArgs>,
200    ) -> Result<Json<ToolResultBody>, McpError> {
201        self.dispatch("cortex_context", p)
202    }
203
204    #[tool(
205        name = "cortex_memory_health",
206        description = "Return aggregate counts for active and quarantined memories: \
207                       total, stale (>30 days old), unvalidated, and quarantined."
208    )]
209    async fn cortex_memory_health(
210        &self,
211        Parameters(p): Parameters<ToolArgs>,
212    ) -> Result<Json<ToolResultBody>, McpError> {
213        self.dispatch("cortex_memory_health", p)
214    }
215
216    #[tool(
217        name = "cortex_config",
218        description = "Return the active LLM and embedding backend configuration \
219                       (Ollama / OpenAI-compat / Claude HTTP) loaded from cortex.toml."
220    )]
221    async fn cortex_config(
222        &self,
223        Parameters(p): Parameters<ToolArgs>,
224    ) -> Result<Json<ToolResultBody>, McpError> {
225        self.dispatch("cortex_config", p)
226    }
227
228    #[tool(
229        name = "cortex_suggest",
230        description = "Server-initiated memory suggestions for the current focus. \
231                       Ranks by FTS5 match + salience; never mutates state."
232    )]
233    async fn cortex_suggest(
234        &self,
235        Parameters(p): Parameters<ToolArgs>,
236    ) -> Result<Json<ToolResultBody>, McpError> {
237        self.dispatch("cortex_suggest", p)
238    }
239
240    // ── Supervised tier (executes + logs; no confirmation token) ────────
241
242    #[tool(
243        name = "cortex_memory_list",
244        description = "Browse active memories with optional tag/domain/status filters \
245                       and a paging cursor. Read-only."
246    )]
247    async fn cortex_memory_list(
248        &self,
249        Parameters(p): Parameters<ToolArgs>,
250    ) -> Result<Json<ToolResultBody>, McpError> {
251        self.dispatch("cortex_memory_list", p)
252    }
253
254    #[tool(
255        name = "cortex_memory_outcome",
256        description = "Mark a specific memory as `helpful` or `not_helpful` for outcome \
257                       tracking. Logs a structured outcome record (ADR 0020 §6)."
258    )]
259    async fn cortex_memory_outcome(
260        &self,
261        Parameters(p): Parameters<ToolArgs>,
262    ) -> Result<Json<ToolResultBody>, McpError> {
263        self.dispatch("cortex_memory_outcome", p)
264    }
265
266    #[tool(
267        name = "cortex_decay_status",
268        description = "Inspect the decay job queue: pending evictions and the next \
269                       scheduled decay window."
270    )]
271    async fn cortex_decay_status(
272        &self,
273        Parameters(p): Parameters<ToolArgs>,
274    ) -> Result<Json<ToolResultBody>, McpError> {
275        self.dispatch("cortex_decay_status", p)
276    }
277
278    #[tool(
279        name = "cortex_doctor",
280        description = "Run health checks on the store, event log, and configured \
281                       backends. Stores the result for `cortex doctor` to read back."
282    )]
283    async fn cortex_doctor(
284        &self,
285        Parameters(p): Parameters<ToolArgs>,
286    ) -> Result<Json<ToolResultBody>, McpError> {
287        self.dispatch("cortex_doctor", p)
288    }
289
290    #[tool(
291        name = "cortex_audit_verify",
292        description = "Verify the JSONL audit log's hash chain end-to-end. Returns \
293                       pass/fail and the first divergence offset on failure."
294    )]
295    async fn cortex_audit_verify(
296        &self,
297        Parameters(p): Parameters<ToolArgs>,
298    ) -> Result<Json<ToolResultBody>, McpError> {
299        self.dispatch("cortex_audit_verify", p)
300    }
301
302    #[tool(
303        name = "cortex_reflect",
304        description = "Run a reflection pass over a session trace (optionally with a \
305                       live LLM via `live_reflect: true`). Returns memory candidates."
306    )]
307    async fn cortex_reflect(
308        &self,
309        Parameters(p): Parameters<ToolArgs>,
310    ) -> Result<Json<ToolResultBody>, McpError> {
311        self.dispatch("cortex_reflect", p)
312    }
313
314    #[tool(
315        name = "cortex_models_list",
316        description = "List models available to the configured backends: pulled Ollama \
317                       tags and the compile-time Claude allowlist."
318    )]
319    async fn cortex_models_list(
320        &self,
321        Parameters(p): Parameters<ToolArgs>,
322    ) -> Result<Json<ToolResultBody>, McpError> {
323        self.dispatch("cortex_models_list", p)
324    }
325
326    #[tool(
327        name = "cortex_memory_embed",
328        description = "Enrich pending memory rows with Ollama embeddings. Idempotent; \
329                       `preview: true` reports the candidate set without writing."
330    )]
331    async fn cortex_memory_embed(
332        &self,
333        Parameters(p): Parameters<ToolArgs>,
334    ) -> Result<Json<ToolResultBody>, McpError> {
335        self.dispatch("cortex_memory_embed", p)
336    }
337
338    #[tool(
339        name = "cortex_memory_note",
340        description = "Store an operator-attested fact directly as an active memory. \
341                       Bypasses the reflection pipeline. Required: `claim` (non-empty)."
342    )]
343    async fn cortex_memory_note(
344        &self,
345        Parameters(p): Parameters<ToolArgs>,
346    ) -> Result<Json<ToolResultBody>, McpError> {
347        self.dispatch("cortex_memory_note", p)
348    }
349
350    #[tool(
351        name = "cortex_session_close",
352        description = "Index the current session's events into pending memories. \
353                       Use `live_reflect: true` for an LLM pass; otherwise heuristic only."
354    )]
355    async fn cortex_session_close(
356        &self,
357        Parameters(p): Parameters<ToolArgs>,
358    ) -> Result<Json<ToolResultBody>, McpError> {
359        self.dispatch("cortex_session_close", p)
360    }
361
362    // ── Confirmed tier (operator token from stderr) ─────────────────────
363
364    #[tool(
365        name = "cortex_memory_accept",
366        description = "Promote a specific pending memory candidate to active. Requires \
367                       the operator confirmation token printed to stderr (ADR 0047)."
368    )]
369    async fn cortex_memory_accept(
370        &self,
371        Parameters(p): Parameters<ToolArgs>,
372    ) -> Result<Json<ToolResultBody>, McpError> {
373        self.dispatch("cortex_memory_accept", p)
374    }
375
376    #[tool(
377        name = "cortex_admit_axiom",
378        description = "Admit a pinned-authority axiom into the ledger. Requires the \
379                       operator confirmation token (ADR 0026 §4)."
380    )]
381    async fn cortex_admit_axiom(
382        &self,
383        Parameters(p): Parameters<ToolArgs>,
384    ) -> Result<Json<ToolResultBody>, McpError> {
385        self.dispatch("cortex_admit_axiom", p)
386    }
387
388    #[tool(
389        name = "cortex_session_commit",
390        description = "Activate the current session's pending_mcp_commit memories. \
391                       Requires the operator confirmation token printed to stderr \
392                       at server startup (ADR 0047 §3)."
393    )]
394    async fn cortex_session_commit(
395        &self,
396        Parameters(p): Parameters<ToolArgs>,
397    ) -> Result<Json<ToolResultBody>, McpError> {
398        self.dispatch("cortex_session_commit", p)
399    }
400}
401
402#[tool_handler]
403impl ServerHandler for CortexServer {
404    fn get_info(&self) -> rmcp::model::ServerInfo {
405        let capabilities = ServerCapabilities::builder().enable_tools().build();
406        let server_info = Implementation::new(
407            "cortex".to_string(),
408            env!("CARGO_PKG_VERSION").to_string(),
409        );
410        let instructions = "Cortex MCP server. Memory mutations and session commits \
411                            require an operator-issued confirmation token printed to \
412                            stderr at startup (ADR 0047). Paste the token when prompted \
413                            for `cortex_session_commit` or `cortex_memory_accept`. \
414                            Sensitivity-gated context can be requested via \
415                            `cortex_context` and `cortex_search`.";
416        InitializeResult::new(capabilities)
417            .with_protocol_version(ProtocolVersion::V_2025_06_18)
418            .with_server_info(server_info)
419            .with_instructions(instructions)
420    }
421}
422
423/// Drive the stdio MCP loop until the peer closes stdin (EOF) or an OS
424/// signal terminates the process.
425///
426/// This is the rmcp-based replacement for [`crate::serve::run_stdio_server`].
427/// The caller is responsible for building a `tokio` runtime (rmcp's
428/// `transport-io` feature uses the async stdin/stdout primitives).
429///
430/// # Errors
431///
432/// Returns an error when rmcp fails to bind the stdio transport or when the
433/// loop exits abnormally. Clean EOF returns `Ok(())`.
434pub async fn serve_stdio(server: CortexServer) -> Result<(), McpError> {
435    tracing::info!("cortex mcp: rmcp 1.7 stdio server starting");
436    let service = server.serve(stdio()).await.map_err(|e| {
437        McpError::internal_error(format!("serve_stdio init failed: {e}"), None)
438    })?;
439    service.waiting().await.map_err(|e| {
440        McpError::internal_error(format!("serve_stdio loop failed: {e}"), None)
441    })?;
442    tracing::info!("cortex mcp: rmcp stdio server shutdown (EOF)");
443    Ok(())
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use crate::tool_handler::{GateId, ToolHandler};
450
451    struct EchoTool;
452    impl ToolHandler for EchoTool {
453        fn name(&self) -> &'static str {
454            "cortex_search"
455        }
456        fn gate_set(&self) -> &'static [GateId] {
457            &[GateId::FtsRead]
458        }
459        fn call(&self, params: Value) -> Result<Value, ToolError> {
460            Ok(params)
461        }
462    }
463
464    fn server_with_echo() -> CortexServer {
465        let mut registry = ToolRegistry::new();
466        registry.register(Box::new(EchoTool));
467        CortexServer::new(Arc::new(registry))
468    }
469
470    fn args(v: Value) -> ToolArgs {
471        match v {
472            Value::Object(fields) => fields,
473            _ => panic!("test fixture must pass an object"),
474        }
475    }
476
477    #[test]
478    fn dispatch_returns_registry_value_on_success() {
479        let server = server_with_echo();
480        let result = server
481            .dispatch("cortex_search", args(serde_json::json!({"q": "hello"})))
482            .expect("registered tool dispatches");
483        let Json(body) = result;
484        assert_eq!(body.get("q"), Some(&Value::String("hello".into())));
485    }
486
487    #[test]
488    fn dispatch_unregistered_tool_returns_internal_error() {
489        // `rmcp::Json` deliberately does not implement `Debug` so the
490        // `.expect_err(_)` shortcut isn't available; match the result by hand.
491        let server = server_with_echo();
492        let result = server.dispatch("cortex_missing", ToolArgs::new());
493        match result {
494            Ok(_) => panic!("unregistered tool must error"),
495            Err(err) => assert!(
496                err.message.contains("cortex_missing"),
497                "error must name the missing tool: {}",
498                err.message
499            ),
500        }
501    }
502
503    #[test]
504    fn dispatch_non_object_tool_value_returns_internal_error() {
505        // A tool that returns a non-object Value is a programming error;
506        // `dispatch` must surface that as `internal_error` rather than
507        // silently coercing — the output JSON Schema is `type: object`.
508        struct NonObjectTool;
509        impl ToolHandler for NonObjectTool {
510            fn name(&self) -> &'static str {
511                "cortex_search"
512            }
513            fn gate_set(&self) -> &'static [GateId] {
514                &[GateId::FtsRead]
515            }
516            fn call(&self, _params: Value) -> Result<Value, ToolError> {
517                Ok(Value::String("not an object".into()))
518            }
519        }
520        let mut registry = ToolRegistry::new();
521        registry.register(Box::new(NonObjectTool));
522        let server = CortexServer::new(Arc::new(registry));
523        let result = server.dispatch("cortex_search", ToolArgs::new());
524        match result {
525            Ok(_) => panic!("non-object tool value must error"),
526            Err(err) => {
527                assert_eq!(err.code, rmcp::model::ErrorCode::INTERNAL_ERROR);
528                assert!(
529                    err.message.contains("non-object"),
530                    "error must say non-object: {}",
531                    err.message
532                );
533            }
534        }
535    }
536
537    #[test]
538    fn tool_error_invalid_params_maps_to_invalid_params() {
539        let err = tool_error_to_mcp("t", ToolError::InvalidParams("bad".into()));
540        assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
541    }
542
543    #[test]
544    fn tool_error_policy_rejected_maps_to_invalid_params() {
545        let err = tool_error_to_mcp("t", ToolError::PolicyRejected("nope".into()));
546        assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
547    }
548
549    #[test]
550    fn tool_error_internal_maps_to_internal_error() {
551        let err = tool_error_to_mcp("t", ToolError::Internal("kaboom".into()));
552        assert_eq!(err.code, rmcp::model::ErrorCode::INTERNAL_ERROR);
553    }
554}