1use 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
55type ToolArgs = Map<String, Value>;
66
67type ToolResultBody = Map<String, Value>;
76
77#[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 #[must_use]
101 pub fn new(registry: Arc<ToolRegistry>) -> Self {
102 Self { registry }
103 }
104
105 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 #[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 #[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 #[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
423pub 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 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 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}