use std::str::FromStr;
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::{Json, Parameters};
use rmcp::model::{Implementation, ServerCapabilities, ServerInfo};
use rmcp::{tool, tool_handler, tool_router, ErrorData, ServerHandler};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use topodb::{
Db, Direction, EdgeId, NodeId, Op, PropValue, Props, Scope, ScopeSet, TopoError, TraversalQuery,
};
use crate::config::{
scope_label, Config, ENTITY_LABEL, ENTITY_NAME_PROP, MEMORY_CONTENT_PROP, MEMORY_LABEL,
};
use topodb_json as convert;
#[derive(Clone)]
pub struct TopoServer {
db: Db,
default_scope: Scope,
default_scopes: ScopeSet,
db_path: String,
tool_router: ToolRouter<Self>,
}
impl TopoServer {
pub fn new(db: Db, config: &Config) -> Self {
let default_scopes = convert::scope_to_scope_set(config.default_scope);
Self {
db,
default_scope: config.default_scope,
default_scopes,
db_path: config.db_path.display().to_string(),
tool_router: Self::tool_router(),
}
}
fn resolve_scopes(&self, scope: Option<&str>) -> Result<ScopeSet, ErrorData> {
match scope {
None => Ok(self.default_scopes.clone()),
Some(_) => {
let resolved = convert::resolve_scope(scope, self.default_scope)
.map_err(|e| ErrorData::invalid_params(e, None))?;
Ok(convert::scope_to_scope_set(resolved))
}
}
}
fn resolve_scope(&self, scope: Option<&str>) -> Result<Scope, ErrorData> {
convert::resolve_scope(scope, self.default_scope)
.map_err(|e| ErrorData::invalid_params(e, None))
}
fn submit_write(&self, ops: Vec<Op>) -> Result<(), ErrorData> {
self.db.submit(ops).map(|_| ()).map_err(|e| match e {
TopoError::Rejected(msg) => ErrorData::invalid_params(msg, None),
other => ErrorData::internal_error(other.to_string(), None),
})
}
}
fn parse_node_id(id: &str) -> Result<NodeId, ErrorData> {
NodeId::from_str(id)
.map_err(|e| ErrorData::invalid_params(format!("invalid node id {id:?}: {e}"), None))
}
#[derive(Debug, Serialize, JsonSchema)]
struct DbInfo {
path: String,
current_seq: u64,
default_scope: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GetNodeParams {
id: String,
#[serde(default)]
scope: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct GetNodeResult {
found: bool,
#[serde(skip_serializing_if = "Option::is_none")]
node: Option<Value>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct FindByPropParams {
label: String,
prop: String,
value: Value,
#[serde(default)]
scope: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct FindByPropResult {
nodes: Vec<Value>,
}
fn default_search_k() -> usize {
10
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SearchMemoriesParams {
query: String,
#[serde(default = "default_search_k")]
k: usize,
#[serde(default)]
scope: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct SearchHit {
node: Value,
score: f32,
}
#[derive(Debug, Serialize, JsonSchema)]
struct SearchMemoriesResult {
hits: Vec<SearchHit>,
}
#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
enum DirectionParam {
Out,
In,
#[default]
Both,
}
impl From<DirectionParam> for Direction {
fn from(d: DirectionParam) -> Self {
match d {
DirectionParam::Out => Direction::Out,
DirectionParam::In => Direction::In,
DirectionParam::Both => Direction::Both,
}
}
}
fn default_max_hops() -> u8 {
2
}
#[derive(Debug, Deserialize, JsonSchema)]
struct TraverseParams {
seed_id: String,
#[serde(default = "default_max_hops")]
max_hops: u8,
#[serde(default)]
direction: DirectionParam,
#[serde(default)]
edge_types: Option<Vec<String>>,
#[serde(default)]
scope: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct TraverseResult {
subgraph: Value,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct AccessStatsParams {
id: String,
#[serde(default)]
scope: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct AccessStatsResult {
found: bool,
#[serde(skip_serializing_if = "Option::is_none")]
access_count: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
last_accessed_at: Option<i64>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GetChangesParams {
since_seq: u64,
}
#[derive(Debug, Serialize, JsonSchema)]
struct ChangeEventJson {
seq: u64,
op: Value,
}
#[derive(Debug, Serialize, JsonSchema)]
struct GetChangesResult {
ops: Vec<ChangeEventJson>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct CreateMemoryParams {
content: String,
#[serde(default)]
props: Option<Value>,
#[serde(default)]
scope: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct CreateEntityParams {
name: String,
#[serde(default)]
props: Option<Value>,
#[serde(default)]
scope: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct CreateResult {
id: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct LinkParams {
from_id: String,
to_id: String,
edge_type: String,
#[serde(default)]
props: Option<Value>,
#[serde(default)]
valid_from: Option<i64>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct LinkResult {
id: String,
}
#[tool_router]
impl TopoServer {
#[tool(
description = "Report the open database's path, current op-log sequence number, and the default scope applied to tool calls that omit `scope`. Call this first to confirm the server is wired to the expected database, and to obtain current_seq as the anchor for get_changes."
)]
fn db_info(&self) -> Result<Json<DbInfo>, ErrorData> {
let current_seq = self
.db
.current_seq()
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
Ok(Json(DbInfo {
path: self.db_path.clone(),
current_seq,
default_scope: scope_label(&self.default_scope),
}))
}
#[tool(
description = "Fetch one node by its ULID. Call this when you already have a node id (from a previous search, traverse, or create) and need its current label and properties."
)]
fn get_node(
&self,
Parameters(p): Parameters<GetNodeParams>,
) -> Result<Json<GetNodeResult>, ErrorData> {
let id = parse_node_id(&p.id)?;
let scopes = self.resolve_scopes(p.scope.as_deref())?;
match self.db.node(&scopes, id) {
Some(n) => {
let node =
convert::node_to_json(&n).map_err(|e| ErrorData::internal_error(e, None))?;
Ok(Json(GetNodeResult {
found: true,
node: Some(node),
}))
}
None => Ok(Json(GetNodeResult {
found: false,
node: None,
})),
}
}
#[tool(
description = "Exact-match lookup on an equality-indexed property (e.g. an Entity's name). Call this to resolve a known identifier to a node — NOT for fuzzy or full-text search; use search_memories for that. Errors if (label, prop) is not declared in the index spec."
)]
fn find_by_prop(
&self,
Parameters(p): Parameters<FindByPropParams>,
) -> Result<Json<FindByPropResult>, ErrorData> {
let value = convert::json_to_prop_value(&p.value)
.map_err(|e| ErrorData::invalid_params(e, None))?;
let scopes = self.resolve_scopes(p.scope.as_deref())?;
let hits = self
.db
.nodes_by_prop(&scopes, &p.label, &p.prop, &value)
.map_err(|e| ErrorData::invalid_params(e.to_string(), None))?;
let nodes = hits
.iter()
.map(convert::node_to_json)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| ErrorData::internal_error(e, None))?;
Ok(Json(FindByPropResult { nodes }))
}
#[tool(
description = "Full-text BM25 search over indexed text properties. Call this when looking for memories relevant to a topic or phrase. Returns up to k nodes ranked by relevance with scores."
)]
fn search_memories(
&self,
Parameters(p): Parameters<SearchMemoriesParams>,
) -> Result<Json<SearchMemoriesResult>, ErrorData> {
let scopes = self.resolve_scopes(p.scope.as_deref())?;
let hits = self
.db
.search_text(&scopes, &p.query, p.k)
.map_err(|e| match e {
TopoError::Rejected(_) => ErrorData::invalid_params(e.to_string(), None),
other => ErrorData::internal_error(other.to_string(), None),
})?;
let hits = hits
.iter()
.map(|(n, score)| {
convert::node_to_json(n).map(|node| SearchHit {
node,
score: *score,
})
})
.collect::<Result<Vec<_>, _>>()
.map_err(|e| ErrorData::internal_error(e, None))?;
Ok(Json(SearchMemoriesResult { hits }))
}
#[tool(
description = "Walk the graph outward from a seed node, following edges up to max_hops. Call this to gather the context AROUND something you already found — related entities, linked memories. Returns the subgraph (nodes + edges)."
)]
fn traverse(
&self,
Parameters(p): Parameters<TraverseParams>,
) -> Result<Json<TraverseResult>, ErrorData> {
let seed = parse_node_id(&p.seed_id)?;
let scopes = self.resolve_scopes(p.scope.as_deref())?;
let query = TraversalQuery {
scopes,
seeds: vec![seed],
max_hops: p.max_hops,
edge_types: p
.edge_types
.map(|v| v.into_iter().map(Into::into).collect()),
direction: p.direction.into(),
as_of: None,
};
let sg = self
.db
.traverse(&query)
.map_err(|e| ErrorData::invalid_params(e.to_string(), None))?;
let subgraph =
convert::subgraph_to_json(&sg).map_err(|e| ErrorData::internal_error(e, None))?;
Ok(Json(TraverseResult { subgraph }))
}
#[tool(
description = "Read a node's access statistics (count, last-accessed timestamp). Call this when deciding what to consolidate or forget — e.g. finding stale memories. Reading stats does not itself count as an access."
)]
fn access_stats(
&self,
Parameters(p): Parameters<AccessStatsParams>,
) -> Result<Json<AccessStatsResult>, ErrorData> {
let id = parse_node_id(&p.id)?;
let scopes = self.resolve_scopes(p.scope.as_deref())?;
let stats = self
.db
.access_stats(&scopes, id)
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
Ok(Json(match stats {
Some(s) => AccessStatsResult {
found: true,
access_count: Some(s.access_count),
last_accessed_at: Some(s.last_accessed_at),
},
None => AccessStatsResult {
found: false,
access_count: None,
last_accessed_at: None,
},
}))
}
#[tool(
description = "Replay the operation log from a sequence number (inclusive). Host-level primitive for consolidation/sync — the ONE unscoped read; the log spans all scopes. Returns ops with their seq numbers; on Compacted errors, re-anchor from current state. The db_info tool reports current_seq."
)]
fn get_changes(
&self,
Parameters(p): Parameters<GetChangesParams>,
) -> Result<Json<GetChangesResult>, ErrorData> {
let events = self.db.ops_since(p.since_seq).map_err(|e| match e {
TopoError::Compacted { .. } => ErrorData::invalid_params(e.to_string(), None),
other => ErrorData::internal_error(other.to_string(), None),
})?;
let ops = events
.into_iter()
.map(|ev| {
serde_json::to_value(ev.op.as_ref())
.map(|op| ChangeEventJson { seq: ev.seq, op })
.map_err(|e| e.to_string())
})
.collect::<Result<Vec<_>, _>>()
.map_err(|e| ErrorData::internal_error(e, None))?;
Ok(Json(GetChangesResult { ops }))
}
#[tool(
description = "Store a new memory. Call this when the user or task produces information worth remembering later. content becomes the full-text-searchable body; props holds structured metadata (strings/numbers/bools). Returns the new node's id — keep it if you plan to link this memory to entities."
)]
fn create_memory(
&self,
Parameters(p): Parameters<CreateMemoryParams>,
) -> Result<Json<CreateResult>, ErrorData> {
let props = convert::merge_required_prop(
MEMORY_CONTENT_PROP,
PropValue::Str(p.content),
p.props.as_ref(),
)
.map_err(|e| ErrorData::invalid_params(e, None))?;
let scope = self.resolve_scope(p.scope.as_deref())?;
let id = NodeId::new();
self.submit_write(vec![Op::CreateNode {
id,
scope,
label: MEMORY_LABEL.into(),
props,
}])?;
Ok(Json(CreateResult { id: id.to_string() }))
}
#[tool(
description = "Create an entity node (person, project, concept). Call this the FIRST time something is mentioned that memories should attach to; use find_by_prop first to check it doesn't already exist. name is equality-indexed for exact lookup."
)]
fn create_entity(
&self,
Parameters(p): Parameters<CreateEntityParams>,
) -> Result<Json<CreateResult>, ErrorData> {
let props = convert::merge_required_prop(
ENTITY_NAME_PROP,
PropValue::Str(p.name),
p.props.as_ref(),
)
.map_err(|e| ErrorData::invalid_params(e, None))?;
let scope = self.resolve_scope(p.scope.as_deref())?;
let id = NodeId::new();
self.submit_write(vec![Op::CreateNode {
id,
scope,
label: ENTITY_LABEL.into(),
props,
}])?;
Ok(Json(CreateResult { id: id.to_string() }))
}
#[tool(
description = "Create a typed, time-aware edge between two existing nodes. Call this to connect a memory to the entities it concerns, or entities to each other (e.g. 'works_on'). edge_type is free-form but be consistent — traverse can filter by it. Returns the edge id. Errors if either node doesn't exist."
)]
fn link(&self, Parameters(p): Parameters<LinkParams>) -> Result<Json<LinkResult>, ErrorData> {
let from = parse_node_id(&p.from_id)?;
let to = parse_node_id(&p.to_id)?;
let props = match &p.props {
Some(v) => convert::json_to_props(v).map_err(|e| ErrorData::invalid_params(e, None))?,
None => Props::new(),
};
let scope = self.resolve_scope(None)?;
let id = EdgeId::new();
self.submit_write(vec![Op::CreateEdge {
id,
scope,
ty: p.edge_type.into(),
from,
to,
props,
valid_from: p.valid_from,
}])?;
Ok(Json(LinkResult { id: id.to_string() }))
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for TopoServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new(
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION"),
))
.with_instructions(
"TopoDB agent-memory engine exposed over MCP: a temporal property graph with \
scoped recall. Tool calls that omit `scope` use the server's configured default \
scope (see db_info). Start with db_info to confirm wiring.",
)
}
}