use std::sync::Arc;
use rmcp::handler::server::ServerHandler;
use rmcp::model::{
CallToolRequestParam, CallToolResult, Content, Implementation, ListToolsResult,
PaginatedRequestParam, ProtocolVersion, ServerCapabilities, ServerInfo, Tool,
ToolsCapability,
};
use rmcp::service::{RequestContext, RoleServer};
use rmcp::{Error as McpError, ServiceExt};
use serde::{Deserialize, Serialize};
use solo_core::{
Confidence, Embedder, EncodingContext, Episode, MemoryId, Tier,
VectorIndex,
};
use solo_storage::{ReaderPool, WriteHandle};
use std::str::FromStr;
#[derive(Clone)]
pub struct SoloMcpServer {
inner: Arc<Inner>,
}
struct Inner {
write: WriteHandle,
pool: ReaderPool,
embedder: Arc<dyn Embedder>,
hnsw: Arc<dyn VectorIndex + Send + Sync>,
user_aliases: Vec<String>,
}
impl SoloMcpServer {
pub fn new(
write: WriteHandle,
pool: ReaderPool,
embedder: Arc<dyn Embedder>,
hnsw: Arc<dyn VectorIndex + Send + Sync>,
) -> Self {
Self::new_with_identity(write, pool, embedder, hnsw, Vec::new())
}
pub fn new_with_identity(
write: WriteHandle,
pool: ReaderPool,
embedder: Arc<dyn Embedder>,
hnsw: Arc<dyn VectorIndex + Send + Sync>,
user_aliases: Vec<String>,
) -> Self {
Self {
inner: Arc::new(Inner {
write,
pool,
embedder,
hnsw,
user_aliases,
}),
}
}
}
pub async fn serve_stdio(server: SoloMcpServer) -> anyhow::Result<()> {
use rmcp::transport::io::stdio;
let (stdin, stdout) = stdio();
let running = server.serve((stdin, stdout)).await?;
running.waiting().await?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RememberArgs {
pub content: String,
#[serde(default)]
pub source_type: Option<String>,
#[serde(default)]
pub source_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecallArgs {
pub query: String,
#[serde(default = "default_limit")]
pub limit: usize,
}
fn default_limit() -> usize {
5
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForgetArgs {
pub memory_id: String,
#[serde(default = "default_forget_reason")]
pub reason: String,
}
fn default_forget_reason() -> String {
"user-initiated via MCP".into()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InspectArgs {
pub memory_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemesArgs {
#[serde(default)]
pub window_days: Option<i64>,
#[serde(default = "default_limit")]
pub limit: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FactsAboutArgs {
pub subject: String,
#[serde(default)]
pub predicate: Option<String>,
#[serde(default)]
pub since_ms: Option<i64>,
#[serde(default)]
pub until_ms: Option<i64>,
#[serde(default = "default_limit")]
pub limit: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContradictionsArgs {
#[serde(default = "default_limit")]
pub limit: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InspectClusterArgs {
pub cluster_id: String,
#[serde(default)]
pub full_content: bool,
}
impl ServerHandler for SoloMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
protocol_version: ProtocolVersion::default(),
capabilities: ServerCapabilities {
tools: Some(ToolsCapability {
list_changed: Some(false),
}),
..Default::default()
},
server_info: Implementation {
name: "solo".into(),
version: env!("CARGO_PKG_VERSION").into(),
},
instructions: Some(
"Solo gives you persistent memory across conversations \
with this user — what they've told you before, the \
people and projects in their life, and where their \
stated beliefs have shifted. Reach for these tools \
whenever the user references something from earlier \
(\"like I mentioned\", \"the project I'm working \
on\", \"my friend Alex\") or asks a question that \
hinges on personal context you don't have in the \
current chat. \
\n\nTools to write or look up specific moments: \
memory_remember (save something worth keeping), \
memory_recall (search past conversations by topic), \
memory_inspect (show one saved item by id), \
memory_forget (delete one saved item). \
\n\nTools for the bigger picture (populated as the \
user uses Solo over time): memory_themes (recent \
topics they've been thinking about), \
memory_facts_about (what you know about a person, \
project, or place — \"what do you know about \
Alex?\"), memory_contradictions (places where the \
user has said two things that disagree — surface \
these before answering), memory_inspect_cluster \
(the raw conversations behind one summary)."
.into(),
),
}
}
async fn list_tools(
&self,
_request: PaginatedRequestParam,
_context: RequestContext<RoleServer>,
) -> std::result::Result<ListToolsResult, McpError> {
Ok(ListToolsResult {
tools: build_tools(),
next_cursor: None,
})
}
async fn call_tool(
&self,
request: CallToolRequestParam,
_context: RequestContext<RoleServer>,
) -> std::result::Result<CallToolResult, McpError> {
let CallToolRequestParam { name, arguments } = request;
let args_value = serde_json::Value::Object(arguments.unwrap_or_default());
self.dispatch_tool(&name, args_value).await
}
}
impl SoloMcpServer {
pub async fn dispatch_tool(
&self,
name: &str,
args_value: serde_json::Value,
) -> std::result::Result<CallToolResult, McpError> {
match name {
"memory_remember" => {
let args: RememberArgs = parse_args(&args_value)?;
self.handle_remember(args).await
}
"memory_recall" => {
let args: RecallArgs = parse_args(&args_value)?;
self.handle_recall(args).await
}
"memory_forget" => {
let args: ForgetArgs = parse_args(&args_value)?;
self.handle_forget(args).await
}
"memory_inspect" => {
let args: InspectArgs = parse_args(&args_value)?;
self.handle_inspect(args).await
}
"memory_themes" => {
let args: ThemesArgs = parse_args(&args_value)?;
self.handle_themes(args).await
}
"memory_facts_about" => {
let args: FactsAboutArgs = parse_args(&args_value)?;
self.handle_facts_about(args).await
}
"memory_contradictions" => {
let args: ContradictionsArgs = parse_args(&args_value)?;
self.handle_contradictions(args).await
}
"memory_inspect_cluster" => {
let args: InspectClusterArgs = parse_args(&args_value)?;
self.handle_inspect_cluster(args).await
}
other => Err(McpError::invalid_params(
format!("unknown tool `{other}`"),
None,
)),
}
}
pub fn dispatch_list_tools(&self) -> Vec<Tool> {
build_tools()
}
}
fn parse_args<T: serde::de::DeserializeOwned>(
v: &serde_json::Value,
) -> std::result::Result<T, McpError> {
serde_json::from_value(v.clone()).map_err(|e| {
McpError::invalid_params(format!("invalid tool arguments: {e}"), None)
})
}
fn solo_to_mcp(e: solo_core::Error) -> McpError {
use solo_core::Error;
match e {
Error::NotFound(msg) => McpError::invalid_params(msg, None),
Error::InvalidInput(msg) => McpError::invalid_params(msg, None),
Error::Conflict(msg) => McpError::invalid_params(msg, None),
other => McpError::internal_error(other.to_string(), None),
}
}
fn build_tools() -> Vec<Tool> {
vec![
Tool::new(
"memory_remember",
"Save something the user has told you — a fact, a \
preference, a name, a date, a context — so you can pick \
it up next conversation. Use whenever the user mentions \
something they'd reasonably expect you to recall later \
(\"I just started at Quotient\", \"my partner is Maya\"). \
Returns the saved item's id.",
json_schema_object(serde_json::json!({
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "The text to remember.",
},
"source_type": {
"type": "string",
"description": "Optional source-type tag (default: \"user_message\").",
},
"source_id": {
"type": "string",
"description": "Optional upstream id for traceability.",
},
},
"required": ["content"],
})),
),
Tool::new(
"memory_recall",
"Search past conversations with this user by topic or \
phrase. Returns up to `limit` of the closest matches, \
best match first. Use when the user references \
something they said before (\"that book I told you \
about\", \"the bug we were debugging last week\"). \
Skips items the user has deleted.",
json_schema_object(serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The query text.",
},
"limit": {
"type": "integer",
"description": "Maximum results (default 5).",
"minimum": 1,
"maximum": 100,
},
},
"required": ["query"],
})),
),
Tool::new(
"memory_forget",
"Delete one saved item by id. Use when the user asks you \
to forget something specific (\"forget that I said \
X\"). The item stops appearing in future recalls. \
Reversible only via backups.",
json_schema_object(serde_json::json!({
"type": "object",
"properties": {
"memory_id": {
"type": "string",
"description": "MemoryId to forget (UUID v7).",
},
"reason": {
"type": "string",
"description": "Optional free-form reason (logged, not yet persisted).",
},
},
"required": ["memory_id"],
})),
),
Tool::new(
"memory_inspect",
"Show the full record for one saved item — when it was \
saved, where it came from, and the full text. Use after \
memory_recall when you want the complete content of a \
specific hit (recall results may be truncated).",
json_schema_object(serde_json::json!({
"type": "object",
"properties": {
"memory_id": {
"type": "string",
"description": "MemoryId to inspect (UUID v7).",
},
},
"required": ["memory_id"],
})),
),
Tool::new(
"memory_themes",
"Recent topics the user has been thinking about. Use to \
orient yourself at the start of a conversation, or when \
the user asks \"what have I been up to\" / \"what was I \
working on last week\". Pass `window_days` to scope \
(e.g. 7 for last week); omit for all-time.",
json_schema_object(serde_json::json!({
"type": "object",
"properties": {
"window_days": {
"type": "integer",
"description": "Optional time window in days. Omit for unfiltered.",
"minimum": 1,
},
"limit": {
"type": "integer",
"description": "Maximum results (default 5).",
"minimum": 1,
"maximum": 100,
},
},
})),
),
Tool::new(
"memory_facts_about",
"Look up what you remember about a person, project, or \
topic — names, dates, preferences, relationships. Use \
when the user asks \"what do you know about Alex?\", \
\"when did I start at Quotient?\", \"who is Maya?\", or \
whenever you need grounded facts about someone or \
something before answering. Subject is required (the \
person/place/thing you're asking about); narrow further \
with `predicate` (\"works_at\", \"lives_in\") or a date \
range. (Backed by subject-predicate-object triples \
distilled from past conversations.)",
json_schema_object(serde_json::json!({
"type": "object",
"properties": {
"subject": {
"type": "string",
"description": "Subject id to query (e.g. 'Sam').",
},
"predicate": {
"type": "string",
"description": "Optional predicate filter (e.g. 'works_at').",
},
"since_ms": {
"type": "integer",
"description": "Optional valid_from_ms lower bound (epoch ms).",
},
"until_ms": {
"type": "integer",
"description": "Optional valid_to_ms upper bound (epoch ms). NULL upper bounds (still-valid facts) pass through.",
},
"limit": {
"type": "integer",
"description": "Maximum results (default 5).",
"minimum": 1,
"maximum": 100,
},
},
"required": ["subject"],
})),
),
Tool::new(
"memory_contradictions",
"Find places where the user's stated beliefs or facts \
disagree across conversations — flag disagreements \
before answering. Use whenever you're about to rely on \
a remembered fact that could have changed (jobs, \
relationships, preferences, opinions); a disagreement \
here means the user has told you both X and not-X over \
time and you should ask which is current instead of \
guessing. Each result shows both conflicting statements \
with the topic.",
json_schema_object(serde_json::json!({
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "Maximum results (default 5).",
"minimum": 1,
"maximum": 100,
},
},
})),
),
Tool::new(
"memory_inspect_cluster",
"Show the raw conversations behind one summary. Returns \
the one-line topic (the LLM-generated summary) and the \
source conversations the topic was built from. Use \
after memory_themes when the user asks \"show me the \
raw context behind this\" or \"why does Solo think \
that about cluster Y\". Source items are truncated to \
200 chars unless `full_content` is set.",
json_schema_object(serde_json::json!({
"type": "object",
"properties": {
"cluster_id": {
"type": "string",
"description": "Cluster id to inspect (from memory_themes hits).",
},
"full_content": {
"type": "boolean",
"description": "If true, episode content is returned verbatim. Default false (truncate to 200 chars + ellipsis).",
},
},
"required": ["cluster_id"],
})),
),
]
}
fn json_schema_object(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
match value {
serde_json::Value::Object(map) => map,
_ => panic!("json_schema_object: input must be an object"),
}
}
pub fn tool_names() -> Vec<&'static str> {
vec![
"memory_remember",
"memory_recall",
"memory_forget",
"memory_inspect",
"memory_themes",
"memory_facts_about",
"memory_contradictions",
"memory_inspect_cluster",
]
}
impl SoloMcpServer {
async fn handle_remember(
&self,
args: RememberArgs,
) -> std::result::Result<CallToolResult, McpError> {
let content = args.content.trim_end().to_string();
if content.is_empty() {
return Err(McpError::invalid_params(
"memory_remember: content must not be empty".to_string(),
None,
));
}
let embedding: solo_core::Embedding = self
.inner
.embedder
.embed(&content)
.await
.map_err(solo_to_mcp)?;
let episode = Episode {
memory_id: MemoryId::new(),
ts_ms: chrono::Utc::now().timestamp_millis(),
source_type: args.source_type.unwrap_or_else(|| "user_message".into()),
source_id: args.source_id,
content,
encoding_context: EncodingContext::default(),
provenance: None,
confidence: Confidence::new(0.9).unwrap(),
strength: 0.5,
salience: 0.5,
tier: Tier::Hot,
};
let mid = self
.inner
.write
.remember(episode, embedding)
.await
.map_err(solo_to_mcp)?;
Ok(CallToolResult::success(vec![Content::text(format!(
"remembered {mid}"
))]))
}
async fn handle_recall(
&self,
args: RecallArgs,
) -> std::result::Result<CallToolResult, McpError> {
let result = solo_query::run_recall(
&self.inner.embedder,
&self.inner.hnsw,
&self.inner.pool,
&args.query,
args.limit,
)
.await
.map_err(solo_to_mcp)?;
if result.hits.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(format!(
"no matches (index has {} vectors)",
result.index_len
))]));
}
let body = serde_json::to_string_pretty(&result.hits).unwrap_or_else(|_| String::new());
Ok(CallToolResult::success(vec![Content::text(body)]))
}
async fn handle_forget(
&self,
args: ForgetArgs,
) -> std::result::Result<CallToolResult, McpError> {
let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
McpError::invalid_params(format!("invalid memory_id: {e}"), None)
})?;
self.inner
.write
.forget(mid, args.reason)
.await
.map_err(solo_to_mcp)?;
Ok(CallToolResult::success(vec![Content::text(format!(
"forgotten {mid}"
))]))
}
async fn handle_inspect(
&self,
args: InspectArgs,
) -> std::result::Result<CallToolResult, McpError> {
let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
McpError::invalid_params(format!("invalid memory_id: {e}"), None)
})?;
let row = solo_query::inspect_one(&self.inner.pool, mid)
.await
.map_err(solo_to_mcp)?;
let body = serde_json::to_string_pretty(&row).unwrap_or_else(|_| String::new());
Ok(CallToolResult::success(vec![Content::text(body)]))
}
async fn handle_themes(
&self,
args: ThemesArgs,
) -> std::result::Result<CallToolResult, McpError> {
let hits = solo_query::themes(
&self.inner.pool,
args.window_days,
args.limit,
)
.await
.map_err(solo_to_mcp)?;
let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
Ok(CallToolResult::success(vec![Content::text(body)]))
}
async fn handle_facts_about(
&self,
args: FactsAboutArgs,
) -> std::result::Result<CallToolResult, McpError> {
if args.subject.trim().is_empty() {
return Err(McpError::invalid_params(
"memory_facts_about: subject must not be empty".to_string(),
None,
));
}
let hits = solo_query::facts_about(
&self.inner.pool,
&args.subject,
&self.inner.user_aliases,
args.predicate.as_deref(),
args.since_ms,
args.until_ms,
args.limit,
)
.await
.map_err(solo_to_mcp)?;
let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
Ok(CallToolResult::success(vec![Content::text(body)]))
}
async fn handle_contradictions(
&self,
args: ContradictionsArgs,
) -> std::result::Result<CallToolResult, McpError> {
let hits = solo_query::contradictions(&self.inner.pool, args.limit)
.await
.map_err(solo_to_mcp)?;
let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
Ok(CallToolResult::success(vec![Content::text(body)]))
}
async fn handle_inspect_cluster(
&self,
args: InspectClusterArgs,
) -> std::result::Result<CallToolResult, McpError> {
if args.cluster_id.trim().is_empty() {
return Err(McpError::invalid_params(
"memory_inspect_cluster: cluster_id must not be empty".to_string(),
None,
));
}
let record = solo_query::inspect_cluster(
&self.inner.pool,
&args.cluster_id,
args.full_content,
)
.await
.map_err(solo_to_mcp)?;
let body = serde_json::to_string_pretty(&record).unwrap_or_else(|_| String::new());
Ok(CallToolResult::success(vec![Content::text(body)]))
}
}
#[cfg(test)]
mod dispatch_tests {
use super::*;
use serde_json::json;
use solo_core::VectorIndex;
use solo_storage::test_support::StubVectorIndex;
use solo_storage::{ReaderPool, StubEmbedder, WriterActor, WriterSpawn};
use std::sync::Arc as StdArc;
struct Harness {
server: SoloMcpServer,
_tmp: tempfile::TempDir,
write_handle_extra: Option<solo_storage::WriteHandle>,
join: Option<std::thread::JoinHandle<()>>,
}
impl Harness {
fn new(runtime: &tokio::runtime::Runtime) -> Self {
let tmp = tempfile::TempDir::new().unwrap();
let dim = 16usize;
let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
let embedder: StdArc<dyn solo_core::Embedder> = StdArc::new(StubEmbedder::new("stub", "v1", dim));
let conn = solo_storage::test_support::open_test_db_at(&tmp.path().join("test.db"));
let WriterSpawn { handle, join } = WriterActor::spawn(conn, hnsw.clone());
let path = tmp.path().join("test.db");
let pool: ReaderPool =
runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
let server = SoloMcpServer::new(handle.clone(), pool, embedder, hnsw);
Harness {
server,
_tmp: tmp,
write_handle_extra: Some(handle),
join: Some(join),
}
}
fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
let join = self.join.take();
let extra = self.write_handle_extra.take();
runtime.block_on(async move {
drop(extra);
drop(self.server);
drop(self._tmp);
if let Some(join) = join {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(join.join());
});
tokio::task::spawn_blocking(move || {
rx.recv_timeout(std::time::Duration::from_secs(5))
})
.await
.expect("blocking task")
.expect("writer thread did not exit within 5s")
.expect("writer thread panicked");
}
});
}
}
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.unwrap()
}
fn first_text(r: &rmcp::model::CallToolResult) -> String {
let first = r.content.first().expect("at least one content item");
let v = serde_json::to_value(first).expect("content serialises");
v.get("text")
.and_then(|t| t.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{v}"))
}
#[test]
fn tools_list_returns_eight_canonical_tools() {
let runtime = rt();
let h = Harness::new(&runtime);
let tools = h.server.dispatch_list_tools();
let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
assert_eq!(
names,
vec![
"memory_remember",
"memory_recall",
"memory_forget",
"memory_inspect",
"memory_themes",
"memory_facts_about",
"memory_contradictions",
"memory_inspect_cluster",
]
);
for t in &tools {
assert!(!t.description.is_empty(), "{} description empty", t.name);
let _schema = t.schema_as_json_value();
}
h.shutdown(&runtime);
}
#[test]
fn themes_returns_json_array_on_empty_db() {
let runtime = rt();
let h = Harness::new(&runtime);
runtime.block_on(async {
let r = h
.server
.dispatch_tool("memory_themes", json!({}))
.await
.expect("themes succeeds");
let text = first_text(&r);
let v: serde_json::Value =
serde_json::from_str(&text).expect("parses as json");
assert!(v.is_array(), "expected array, got: {text}");
assert_eq!(v.as_array().unwrap().len(), 0);
});
h.shutdown(&runtime);
}
#[test]
fn themes_passes_through_window_and_limit_args() {
let runtime = rt();
let h = Harness::new(&runtime);
runtime.block_on(async {
let r = h
.server
.dispatch_tool(
"memory_themes",
json!({ "window_days": 7, "limit": 20 }),
)
.await
.expect("themes with args succeeds");
let text = first_text(&r);
let v: serde_json::Value =
serde_json::from_str(&text).expect("parses as json");
assert!(v.is_array());
});
h.shutdown(&runtime);
}
#[test]
fn facts_about_rejects_empty_subject() {
let runtime = rt();
let h = Harness::new(&runtime);
runtime.block_on(async {
let err = h
.server
.dispatch_tool(
"memory_facts_about",
json!({ "subject": " " }),
)
.await
.expect_err("empty subject must error");
let s = format!("{err:?}");
assert!(
s.to_lowercase().contains("subject")
|| s.to_lowercase().contains("invalid"),
"got: {s}"
);
});
h.shutdown(&runtime);
}
#[test]
fn facts_about_returns_array_for_unknown_subject() {
let runtime = rt();
let h = Harness::new(&runtime);
runtime.block_on(async {
let r = h
.server
.dispatch_tool(
"memory_facts_about",
json!({ "subject": "NobodyKnowsThisSubject" }),
)
.await
.expect("facts_about with unknown subject succeeds");
let text = first_text(&r);
let v: serde_json::Value =
serde_json::from_str(&text).expect("parses as json");
assert_eq!(v.as_array().unwrap().len(), 0);
});
h.shutdown(&runtime);
}
#[test]
fn contradictions_returns_json_array_on_empty_db() {
let runtime = rt();
let h = Harness::new(&runtime);
runtime.block_on(async {
let r = h
.server
.dispatch_tool("memory_contradictions", json!({}))
.await
.expect("contradictions succeeds");
let text = first_text(&r);
let v: serde_json::Value =
serde_json::from_str(&text).expect("parses as json");
assert!(v.is_array());
assert_eq!(v.as_array().unwrap().len(), 0);
});
h.shutdown(&runtime);
}
#[test]
fn remember_then_recall_round_trip() {
let runtime = rt();
let h = Harness::new(&runtime);
runtime.block_on(async {
let r = h
.server
.dispatch_tool("memory_remember", json!({ "content": "the cat sat on the mat" }))
.await
.expect("remember succeeds");
let text = first_text(&r);
assert!(text.starts_with("remembered "), "got: {text}");
let r = h
.server
.dispatch_tool(
"memory_recall",
json!({ "query": "the cat sat on the mat", "limit": 5 }),
)
.await
.expect("recall succeeds");
let text = first_text(&r);
assert!(text.contains("the cat sat on the mat"), "got: {text}");
});
h.shutdown(&runtime);
}
#[test]
fn forget_excludes_row_from_subsequent_recall() {
let runtime = rt();
let h = Harness::new(&runtime);
runtime.block_on(async {
let r = h
.server
.dispatch_tool("memory_remember", json!({ "content": "to be forgotten" }))
.await
.unwrap();
let text = first_text(&r);
let mid = text.strip_prefix("remembered ").unwrap().to_string();
h.server
.dispatch_tool(
"memory_forget",
json!({ "memory_id": mid, "reason": "test" }),
)
.await
.expect("forget succeeds");
let r = h
.server
.dispatch_tool(
"memory_recall",
json!({ "query": "to be forgotten", "limit": 5 }),
)
.await
.unwrap();
let text = first_text(&r);
assert!(
!text.contains(r#""content": "to be forgotten""#),
"forgotten row should be excluded; got: {text}"
);
});
h.shutdown(&runtime);
}
#[test]
fn empty_remember_returns_invalid_params() {
let runtime = rt();
let h = Harness::new(&runtime);
runtime.block_on(async {
let err = h
.server
.dispatch_tool("memory_remember", json!({ "content": "" }))
.await
.unwrap_err();
assert!(format!("{err:?}").contains("must not be empty"));
});
h.shutdown(&runtime);
}
#[test]
fn empty_recall_query_returns_invalid_params() {
let runtime = rt();
let h = Harness::new(&runtime);
runtime.block_on(async {
let err = h
.server
.dispatch_tool("memory_recall", json!({ "query": " " }))
.await
.unwrap_err();
assert!(format!("{err:?}").contains("must not be empty"));
});
h.shutdown(&runtime);
}
#[test]
fn inspect_with_invalid_id_returns_invalid_params() {
let runtime = rt();
let h = Harness::new(&runtime);
runtime.block_on(async {
let err = h
.server
.dispatch_tool("memory_inspect", json!({ "memory_id": "not-a-uuid" }))
.await
.unwrap_err();
assert!(format!("{err:?}").contains("invalid memory_id"));
});
h.shutdown(&runtime);
}
#[test]
fn forget_unknown_id_returns_invalid_params() {
let runtime = rt();
let h = Harness::new(&runtime);
runtime.block_on(async {
let err = h
.server
.dispatch_tool(
"memory_forget",
json!({ "memory_id": "00000000-0000-7000-8000-000000000000" }),
)
.await
.unwrap_err();
assert!(format!("{err:?}").contains("not found"));
});
h.shutdown(&runtime);
}
#[test]
fn unknown_tool_name_returns_invalid_params() {
let runtime = rt();
let h = Harness::new(&runtime);
runtime.block_on(async {
let err = h
.server
.dispatch_tool("memory.summon", json!({}))
.await
.unwrap_err();
assert!(format!("{err:?}").contains("unknown tool"));
});
h.shutdown(&runtime);
}
#[test]
fn tool_names_match_cross_provider_regex() {
fn passes_anthropic(name: &str) -> bool {
let len = name.len();
if !(1..=64).contains(&len) {
return false;
}
name.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
fn passes_openai(name: &str) -> bool {
let len = name.len();
if !(1..=64).contains(&len) {
return false;
}
let mut chars = name.chars();
let first = match chars.next() {
Some(c) => c,
None => return false,
};
if !(first.is_ascii_alphabetic() || first == '_') {
return false;
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
fn passes_gemini(name: &str) -> bool {
let len = name.len();
if !(1..=63).contains(&len) {
return false;
}
let mut chars = name.chars();
let first = match chars.next() {
Some(c) => c,
None => return false,
};
if !(first.is_ascii_alphabetic() || first == '_') {
return false;
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
let tools = build_tools();
assert_eq!(
tools.len(),
8,
"expected 8 tools in v0.5.0 (7 v0.4.x + memory_inspect_cluster)"
);
let tool_name_strings: Vec<String> =
tools.iter().map(|t| t.name.to_string()).collect();
let public_names: Vec<String> =
super::tool_names().iter().map(|s| s.to_string()).collect();
assert_eq!(
tool_name_strings, public_names,
"tool_names() drifted from build_tools() — keep them in sync"
);
for t in tools {
assert!(
passes_anthropic(&t.name),
"tool name {:?} fails Anthropic regex \
^[a-zA-Z0-9_-]{{1,64}}$ — see v0.3 lesson #8",
t.name
);
assert!(
passes_openai(&t.name),
"tool name {:?} fails OpenAI function-calling regex \
^[a-zA-Z_][a-zA-Z0-9_-]*$ (len ≤ 64)",
t.name
);
assert!(
passes_gemini(&t.name),
"tool name {:?} fails Gemini function-calling regex \
^[a-zA-Z_][a-zA-Z0-9_]*$ (len ≤ 63, strict)",
t.name
);
}
}
#[test]
fn tool_descriptions_avoid_internal_jargon() {
const FORBIDDEN: &[&str] = &[
"SPO",
"Steward",
"Steward-flagged",
"LEFT JOIN",
"candidate pair",
"candidate_pair",
"tagged_with",
];
fn contains_case_insensitive(haystack: &str, needle: &str) -> bool {
haystack.to_lowercase().contains(&needle.to_lowercase())
}
for t in build_tools() {
for term in FORBIDDEN {
assert!(
!contains_case_insensitive(&t.description, term),
"tool {:?} description contains forbidden jargon \
{:?} — rewrite in plain English (see v0.5.0 \
Priority 4)",
t.name,
term,
);
}
}
let server_info = harness_server_info();
let instructions = server_info
.instructions
.as_deref()
.expect("get_info() must set instructions");
for term in FORBIDDEN {
assert!(
!contains_case_insensitive(instructions, term),
"get_info().instructions contains forbidden jargon \
{:?} — rewrite in plain English",
term,
);
}
}
fn harness_server_info() -> rmcp::model::ServerInfo {
let runtime = rt();
let h = Harness::new(&runtime);
let info = ServerHandler::get_info(&h.server);
h.shutdown(&runtime);
info
}
#[test]
fn inspect_cluster_unknown_id_returns_invalid_params() {
let runtime = rt();
let h = Harness::new(&runtime);
runtime.block_on(async {
let err = h
.server
.dispatch_tool(
"memory_inspect_cluster",
json!({ "cluster_id": "no-such-cluster" }),
)
.await
.expect_err("unknown cluster must error");
let s = format!("{err:?}");
assert!(
s.contains("no-such-cluster") || s.to_lowercase().contains("not found"),
"expected error to mention the missing cluster id; got: {s}"
);
});
h.shutdown(&runtime);
}
#[test]
fn inspect_cluster_rejects_empty_id() {
let runtime = rt();
let h = Harness::new(&runtime);
runtime.block_on(async {
let err = h
.server
.dispatch_tool(
"memory_inspect_cluster",
json!({ "cluster_id": " " }),
)
.await
.expect_err("blank cluster_id must error");
let s = format!("{err:?}");
assert!(
s.to_lowercase().contains("cluster_id")
|| s.to_lowercase().contains("must not be empty"),
"got: {s}"
);
});
h.shutdown(&runtime);
}
}