use rmcp::handler::server::tool::ToolCallContext;
use rmcp::{tool, ServerHandler};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use exocortex_cache::LocalCache;
use exocortex_ops::VisibilityContext;
use crate::eof_drain::InFlightCalls;
use crate::tools::end_session::{EdgeHintInput, EndSessionArgs, EndSessionTool, MemoryDraftInput};
use crate::wal;
#[derive(Clone)]
pub struct ExocortexMcp {
org: smol_str::SmolStr,
cache: Arc<LocalCache>,
vc: VisibilityContext,
end_session: Option<Arc<EndSessionTool>>,
wal: Option<Arc<wal::Wal>>,
ontology: Arc<exocortex_kernel::Ontology>,
process_session_id: String,
in_flight: Arc<InFlightCalls>,
}
impl ExocortexMcp {
pub fn new(
org: smol_str::SmolStr,
cache: Arc<LocalCache>,
vc: VisibilityContext,
ontology: Arc<exocortex_kernel::Ontology>,
) -> Self {
Self {
org,
cache,
vc,
end_session: None,
wal: None,
ontology,
process_session_id: uuid::Uuid::now_v7().simple().to_string(),
in_flight: InFlightCalls::new(),
}
}
pub fn with_end_session(mut self, tool: Arc<EndSessionTool>) -> Self {
self.end_session = Some(tool);
self
}
pub fn with_offline_wal(mut self, wal: Arc<wal::Wal>) -> Self {
self.wal = Some(wal);
self
}
pub fn process_session_id(&self) -> &str {
&self.process_session_id
}
pub fn in_flight_calls(&self) -> Arc<InFlightCalls> {
Arc::clone(&self.in_flight)
}
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct SearchMemoriesInput {
pub query: String,
#[serde(default = "default_limit")]
pub limit: u32,
}
fn default_limit() -> u32 {
20
}
impl ExocortexMcp {
fn registry_ctx(&self) -> std::sync::Arc<exocortex_ops::OpContext> {
std::sync::Arc::new(exocortex_ops::OpContext {
visibility_ctx: self.vc.clone(),
audit_admin: true,
storage: std::sync::Arc::new(crate::no_backend::NoBackendStorage),
cache: self.cache.clone(),
deadline: chrono::Utc::now() + chrono::Duration::seconds(30),
ontology: Some(self.ontology.clone()),
ingest_preflight: None,
})
}
#[tool(
name = "exocortex.search_memories",
description = "Search the exocortex graph: ranked memories matching a free-text query over titles and tags."
)]
pub async fn search_memories(
&self,
#[tool(param)] query: String,
#[tool(param)] limit: Option<u32>,
) -> Result<String, String> {
let entry = exocortex_ops::entries()
.into_iter()
.find(|e| e.mcp_tool_name == "exocortex.search_memories")
.expect("search_memories registered");
let out = (entry.handler)(
entry,
&self.registry_ctx(),
serde_json::to_value(exocortex_ops::operations::SearchInput {
query,
limit: limit.unwrap_or(20),
})
.map_err(|e| e.to_string())?,
)
.await
.map_err(|e| e.to_string())?;
let version = self.cache.version(&self.org);
let mut v = serde_json::to_value(&out).map_err(|e| e.to_string())?;
if let serde_json::Value::Object(map) = &mut v {
map.insert(
"snapshot_version".into(),
serde_json::json!({
"local_lsn": version.map(|x| x.local_lsn).unwrap_or(0),
"backend_lsn": version.map(|x| x.backend_lsn).unwrap_or(0),
}),
);
}
serde_json::to_string(&v).map_err(|e| e.to_string())
}
#[tool(
name = "exocortex.get_memory",
description = "Fetch one memory by hex id from the local org graph."
)]
pub async fn get_memory(&self, #[tool(param)] id: String) -> Result<String, String> {
let entry = exocortex_ops::entries()
.into_iter()
.find(|e| e.mcp_tool_name == "exocortex.get_memory")
.expect("get_memory registered");
let out = (entry.handler)(
entry,
&self.registry_ctx(),
serde_json::to_value(exocortex_ops::operations::GetMemoryInput { id })
.map_err(|e| e.to_string())?,
)
.await
.map_err(|e| e.to_string())?;
serde_json::to_string(&out).map_err(|e| e.to_string())
}
#[tool(
name = "exocortex.find_related",
description = "Bounded k-hop neighborhood of a memory (hex id anchor, depth <= 4)."
)]
pub async fn find_related(
&self,
#[tool(param)] anchor: String,
#[tool(param)] k: Option<u8>,
) -> Result<String, String> {
let entry = exocortex_ops::entries()
.into_iter()
.find(|e| e.mcp_tool_name == "exocortex.find_related")
.expect("find_related registered");
let out = (entry.handler)(
entry,
&self.registry_ctx(),
serde_json::to_value(exocortex_ops::operations::FindRelatedInput {
anchor,
k: k.unwrap_or(2),
})
.map_err(|e| e.to_string())?,
)
.await
.map_err(|e| e.to_string())?;
serde_json::to_string(&out).map_err(|e| e.to_string())
}
#[tool(
name = "exocortex.end_session",
description = "Submit a session wrapup: 1-5 memory drafts plus optional edges (by draft_key within the batch, or to_memory_id for an existing memory). session_id is optional — the client stamps its conversation id."
)]
pub async fn end_session(
&self,
#[tool(param)] session_id: Option<String>,
#[tool(param)] project_id: String,
#[tool(param)] team_id: Option<String>,
#[tool(param)] memories: Vec<MemoryDraftInput>,
#[tool(param)] edges: Vec<EdgeHintInput>,
) -> Result<String, String> {
let args = EndSessionArgs {
session_id: Some(session_id.unwrap_or_else(|| self.process_session_id.clone())),
project_id,
team_id,
memories,
edges,
};
if let Some(tool) = &self.end_session {
let ack = tool.handle(args).await.map_err(|e| e.to_string())?;
return serde_json::to_string(&ack).map_err(|e| e.to_string());
}
if let Some(wal) = &self.wal {
return self
.end_session_offline(wal, &self.ontology, args)
.map_err(|e| e.to_string());
}
Err(json_error("not-connected", "end_session requires --backend (gRPC submit) or the offline WAL; neither is configured"))
}
fn end_session_offline(
&self,
wal: &wal::Wal,
ontology: &exocortex_kernel::Ontology,
args: EndSessionArgs,
) -> Result<String, String> {
use exocortex_kernel::{MemoryDraft, MemoryId};
let session_id = args.session_id.clone().unwrap_or_default();
if session_id.is_empty() {
return Err(json_error("invalid-params", "session_id required"));
}
if args.memories.is_empty() || args.memories.len() > 5 {
return Err(json_error("invalid-params", "memories: expected 1..=5"));
}
let now = chrono::Utc::now();
let batch_id = crate::drain::content_batch_id(&session_id, &args.memories, &args.edges);
let draft_keys: Vec<String> = args.memories.iter().map(|m| m.draft_key.clone()).collect();
let tags: Vec<Vec<String>> = args.memories.iter().map(|m| m.tags.clone()).collect();
let mut ids: Vec<(String, MemoryId)> = Vec::with_capacity(args.memories.len());
let mut drafts: Vec<MemoryDraft> = Vec::with_capacity(args.memories.len());
for m in args.memories {
let memory_type = ontology.memory_type_id(&m.memory_type).ok_or_else(|| {
json_error(
"unknown-memory-type",
format!("unknown memory type `{}`", m.memory_type),
)
})?;
let visibility = match m.visibility.to_lowercase().as_str() {
"private" => exocortex_kernel::Visibility::Private,
"project" => exocortex_kernel::Visibility::Project,
"team" => exocortex_kernel::Visibility::Team,
"org" => exocortex_kernel::Visibility::Org,
other => {
return Err(json_error(
"invalid-params",
format!("unknown visibility `{other}`"),
))
}
};
let context = exocortex_kernel::MemoryContext {
timestamp: now,
project_id: Some(args.project_id.clone().into()),
project_path: None,
team_id: args
.team_id
.as_deref()
.filter(|id| !id.is_empty())
.map(Into::into),
tenant_id: Some(self.vc.org_id.clone()),
session_id: Some(session_id.clone().into()),
user_id: Some(self.vc.user_id.clone()),
created_by: None,
files_involved: Default::default(),
languages: Default::default(),
frameworks: Default::default(),
technologies: Default::default(),
git_commit: None,
git_branch: None,
working_directory: None,
entities: Default::default(),
additional_metadata: serde_json::Value::Null,
};
let probe = MemoryDraft {
memory_type,
title: m.title.clone().into(),
content: m.content.clone(),
summary: None,
visibility,
context: context.clone(),
edge_hints: Default::default(),
external_key: None,
};
if let Err(e) = exocortex_kernel::validator::validate_draft(
ontology,
&probe,
exocortex_kernel::validator::SourceCeiling {
source: "offline-wal",
ceiling: exocortex_kernel::Visibility::Org,
},
) {
return Err(json_error("validation", e.to_string()));
}
let id = MemoryId::new_v7();
let draft = MemoryDraft {
memory_type,
title: m.title.into(),
content: m.content,
summary: None,
visibility,
context,
edge_hints: Default::default(),
external_key: None,
};
ids.push((m.draft_key, id));
drafts.push(draft);
}
for e in &args.edges {
let Some(src) = ids.iter().position(|(k, _)| *k == e.from_draft_key) else {
return Err(json_error(
"invalid-params",
format!("edge references unknown draft_key `{}`", e.from_draft_key),
));
};
let to = if !e.to_memory_id.is_empty() {
let Some(to) = MemoryId::parse_hex(&e.to_memory_id) else {
return Err(json_error(
"invalid-params",
format!(
"to_memory_id `{}` is not a 32-hex memory id",
e.to_memory_id
),
));
};
to
} else {
let Some((_, to)) = ids.iter().find(|(k, _)| *k == e.to_draft_key) else {
return Err(json_error(
"invalid-params",
format!("edge references unknown draft_key `{}`", e.to_draft_key),
));
};
*to
};
let Some(kind) = ontology.kind_id(&e.kind) else {
return Err(json_error(
"unknown-kind",
format!("unknown relationship kind `{}`", e.kind),
));
};
drafts[src].edge_hints.push(exocortex_kernel::EdgeHint {
kind,
to,
strength: if e.strength == 0.0 {
None
} else {
Some(e.strength)
},
confidence: None,
});
}
let memory_ids: Vec<MemoryId> = ids.into_iter().map(|(_, id)| id).collect();
let local_lsn = wal
.append_batch_full_idempotent(
&session_id,
drafts,
memory_ids,
batch_id,
draft_keys,
tags,
)
.map_err(|e| json_error("wal-error", e.to_string()))?;
match wal.entry(local_lsn) {
Ok(Some(entry)) => {
let rows = crate::materialize::materialize_entry(
&self.ontology,
&self.org,
&entry,
&|id| {
self.cache
.get_memory(&self.org, id, &self.vc)
.map(|m| (m.memory_type, m.visibility))
},
);
if !rows.dropped_edges.is_empty() {
tracing::warn!(?rows.dropped_edges, "offline edges not served");
}
self.cache
.apply_local(&self.org, &rows.memories, &rows.edges, local_lsn);
}
Ok(None) => {
tracing::warn!("wal entry {local_lsn} unreadable after append; advancing LSN only");
self.cache.advance_local_lsn(&self.vc.org_id, local_lsn);
}
Err(error) => return Err(json_error("wal-corrupt", error.to_string())),
}
#[derive(Serialize)]
struct OfflineAck {
local_lsns: Vec<u64>,
sync_pending: bool,
}
serde_json::to_string(&OfflineAck {
local_lsns: vec![local_lsn],
sync_pending: true,
})
.map_err(|e| e.to_string())
}
#[tool(
name = "exocortex.preflight_wrapup",
description = "Validate a proposed wrapup batch without writing: the same rules end_session enforces, answered locally with correction hints and an unverified list of server-only checks."
)]
pub async fn preflight_wrapup(
&self,
#[tool(param)] _project_id: String,
#[tool(param)] memories: Vec<MemoryDraftInput>,
#[tool(param)] edges: Vec<EdgeHintInput>,
) -> Result<String, String> {
let cache = self.cache.clone();
let org = self.org.to_string();
let vc = self.vc.clone();
let result = crate::preflight::validate_batch(&self.ontology, &memories, &edges, |id| {
let id = exocortex_kernel::MemoryId::parse_hex(id)?;
cache.get_memory(&org, &id, &vc).map(|m| m.memory_type)
});
serde_json::to_string(&result).map_err(|e| e.to_string())
}
#[tool(
name = "exocortex.playbook_version",
description = "Report the compiled Agent Playbook version and the content hashes of the playbook and the CLAUDE.md/AGENTS.md instruction block."
)]
pub async fn playbook_version(&self) -> Result<String, String> {
#[derive(Serialize)]
struct VersionReport {
version: String,
playbook_hash: String,
block_hash: String,
}
serde_json::to_string(&VersionReport {
version: crate::playbook::PLAYBOOK_VERSION.into(),
playbook_hash: crate::playbook::playbook_hash(),
block_hash: crate::playbook::block_hash(),
})
.map_err(|e| e.to_string())
}
}
fn self_contained(tool: rmcp::model::Tool) -> rmcp::model::Tool {
let mut schema = tool.input_schema.as_ref().clone();
let defs = match schema.remove("definitions") {
Some(serde_json::Value::Object(defs)) => defs,
_ => return tool,
};
let mut root = serde_json::Value::Object(schema);
inline_schema_refs(&mut root, &defs, &mut Vec::new());
match root {
serde_json::Value::Object(map) => rmcp::model::Tool {
name: tool.name,
description: tool.description,
input_schema: Arc::new(map),
},
_ => tool,
}
}
fn inline_schema_refs(
value: &mut serde_json::Value,
defs: &serde_json::Map<String, serde_json::Value>,
expanding: &mut Vec<String>,
) {
match value {
serde_json::Value::Object(map) => {
if let Some(serde_json::Value::String(reference)) = map.get("$ref") {
if let Some(name) = reference.strip_prefix("#/definitions/") {
if !expanding.iter().any(|n| n == name) {
if let Some(definition) = defs.get(name) {
let mut definition = definition.clone();
expanding.push(name.to_string());
inline_schema_refs(&mut definition, defs, expanding);
expanding.pop();
*value = definition;
return;
}
}
return;
}
}
for child in map.values_mut() {
inline_schema_refs(child, defs, expanding);
}
}
serde_json::Value::Array(items) => {
for item in items.iter_mut() {
inline_schema_refs(item, defs, expanding);
}
}
_ => {}
}
}
fn json_error(error: &str, message: impl std::fmt::Display) -> String {
serde_json::json!({ "error": error, "message": message.to_string() }).to_string()
}
impl ServerHandler for ExocortexMcp {
fn get_info(&self) -> rmcp::model::ServerInfo {
rmcp::model::ServerInfo {
capabilities: rmcp::model::ServerCapabilities {
tools: Some(rmcp::model::ToolsCapability { list_changed: None }),
..Default::default()
},
server_info: rmcp::model::Implementation {
name: "exocortex-mcp-client".into(),
version: env!("CARGO_PKG_VERSION").into(),
},
instructions: Some("Exocortex typed memory graph. Read with exocortex.search_memories / exocortex.find_related. To write, submit with exocortex.end_session (1-5 typed memories, ≤200-char titles, edges by draft_key or memory id) — it validates locally and explains any rejection. exocortex.preflight_wrapup checks a batch without writing.".into()),
..Default::default()
}
}
async fn list_tools(
&self,
_pagination: rmcp::model::PaginatedRequestParam,
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<rmcp::model::ListToolsResult, rmcp::Error> {
let mut tools = vec![
Self::search_memories_tool_attr(),
Self::get_memory_tool_attr(),
Self::find_related_tool_attr(),
Self::end_session_tool_attr(),
Self::preflight_wrapup_tool_attr(),
Self::playbook_version_tool_attr(),
];
for entry in exocortex_ops::entries() {
let dispatchable = matches!(
entry.mcp_tool_name,
"exocortex.search_memories" | "exocortex.get_memory" | "exocortex.find_related"
);
if dispatchable && !tools.iter().any(|t| t.name == entry.mcp_tool_name) {
if let Ok(t) = serde_json::from_value::<rmcp::model::Tool>(serde_json::json!({
"name": entry.mcp_tool_name,
"description": entry.name,
})) {
tools.push(t);
}
}
}
Ok(rmcp::model::ListToolsResult {
next_cursor: None,
tools: tools.into_iter().map(self_contained).collect(),
})
}
async fn call_tool(
&self,
request: rmcp::model::CallToolRequestParam,
context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<rmcp::model::CallToolResult, rmcp::Error> {
let _in_flight = self.in_flight.guard();
let tcc = ToolCallContext::new(self, request, context);
match tcc.name() {
"exocortex.search_memories" => Self::search_memories_tool_call(tcc).await,
"exocortex.get_memory" => Self::get_memory_tool_call(tcc).await,
"exocortex.find_related" => Self::find_related_tool_call(tcc).await,
"exocortex.end_session" => Self::end_session_tool_call(tcc).await,
"exocortex.preflight_wrapup" => Self::preflight_wrapup_tool_call(tcc).await,
"exocortex.playbook_version" => Self::playbook_version_tool_call(tcc).await,
_other => Err(rmcp::Error::invalid_params(
"method not found (backend-only operations are served over HTTP)",
None,
)),
}
}
}
#[cfg(test)]
mod schema_inline_tests {
use super::inline_schema_refs;
use serde_json::json;
fn object_map(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
value.as_object().expect("object").clone()
}
#[test]
fn refs_resolve_to_their_definitions_depth_first() {
let defs = object_map(json!({
"Draft": {
"type": "object",
"properties": {
"memory_type": { "type": "string" },
"inner": { "$ref": "#/definitions/Inner" }
}
},
"Inner": { "type": "string" }
}));
let mut schema = json!({
"type": "object",
"properties": {
"memories": {
"type": "array",
"items": { "$ref": "#/definitions/Draft" }
}
}
});
inline_schema_refs(&mut schema, &defs, &mut Vec::new());
assert_eq!(
schema["properties"]["memories"]["items"]["properties"]["memory_type"],
json!({ "type": "string" }),
"one hop resolves: {schema}"
);
assert_eq!(
schema["properties"]["memories"]["items"]["properties"]["inner"],
json!({ "type": "string" }),
"nested refs resolve depth-first: {schema}"
);
assert!(
!schema.to_string().contains("$ref"),
"no refs remain: {schema}"
);
}
#[test]
fn cyclic_and_foreign_refs_are_left_verbatim() {
let defs = object_map(json!({
"A": { "type": "object", "properties": { "b": { "$ref": "#/definitions/A" } } }
}));
let mut cyclic = json!({ "$ref": "#/definitions/A" });
inline_schema_refs(&mut cyclic, &defs, &mut Vec::new());
assert_eq!(
cyclic["properties"]["b"],
json!({ "$ref": "#/definitions/A" }),
"cycle stops expanding: {cyclic}"
);
let mut foreign = json!({ "$ref": "https://example.com/other" });
inline_schema_refs(&mut foreign, &defs, &mut Vec::new());
assert_eq!(foreign, json!({ "$ref": "https://example.com/other" }));
}
}