1use std::sync::Mutex;
8
9use rmcp::handler::server::wrapper::Parameters;
10use rmcp::model::{CallToolResult, ContentBlock, ErrorData};
11use rmcp::{ServerHandler, tool, tool_handler, tool_router};
12use scone_core::{Engine, IngestInput, IngestOutcome, RecallOpts, auth};
13
14const MAX_CONTENT: usize = 100_000;
15const MAX_QUERY: usize = 1_000;
16const MAX_ENTITY: usize = 200;
17const MAX_REASON: usize = 500;
18const MAX_LIMIT: usize = 50;
19
20pub struct SconeMcp {
21 engine: Mutex<Engine>,
22 default_space: String,
23}
24
25#[derive(serde::Deserialize, schemars::JsonSchema)]
26pub struct StoreParams {
27 pub content: String,
29 pub space: Option<String>,
31 pub tags: Option<Vec<String>>,
33}
34
35#[derive(serde::Deserialize, schemars::JsonSchema)]
36pub struct RecallParams {
37 pub query: String,
39 pub space: Option<String>,
40 pub limit: Option<usize>,
42 pub include_profile: Option<bool>,
45 pub tags: Option<Vec<String>>,
47 pub as_of: Option<String>,
49}
50
51#[derive(serde::Deserialize, schemars::JsonSchema)]
52pub struct FactsAboutParams {
53 pub entity: String,
55 pub space: Option<String>,
56}
57
58#[derive(serde::Deserialize, schemars::JsonSchema)]
59pub struct ForgetParams {
60 pub fact_id: i64,
62 pub reason: String,
64 pub space: Option<String>,
65}
66
67fn tool_error(msg: impl Into<String>) -> CallToolResult {
68 CallToolResult::error(vec![ContentBlock::text(msg.into())])
69}
70
71fn ok_text(msg: impl Into<String>) -> CallToolResult {
72 CallToolResult::success(vec![ContentBlock::text(msg.into())])
73}
74
75impl SconeMcp {
76 pub fn new(engine: Engine, default_space: &str) -> Self {
77 Self {
78 engine: Mutex::new(engine),
79 default_space: default_space.to_owned(),
80 }
81 }
82
83 fn with_space<T>(
85 &self,
86 space_override: &Option<String>,
87 f: impl FnOnce(&mut Engine, &auth::ScopedSpace) -> scone_core::Result<T>,
88 ) -> Result<T, String> {
89 let name = space_override
90 .clone()
91 .unwrap_or_else(|| self.default_space.clone());
92 let mut engine = self
93 .engine
94 .lock()
95 .map_err(|_| "engine lock poisoned".to_owned())?;
96 let space = auth::resolve(&mut engine, &name, true).map_err(|e| e.to_string())?;
97 f(&mut engine, &space).map_err(|e| e.to_string())
98 }
99}
100
101#[tool_router]
102impl SconeMcp {
103 #[tool(name = "memory_store")]
107 async fn memory_store(
108 &self,
109 Parameters(p): Parameters<StoreParams>,
110 ) -> Result<CallToolResult, ErrorData> {
111 if p.content.is_empty() || p.content.len() > MAX_CONTENT {
112 return Ok(tool_error(format!(
113 "content must be 1..={MAX_CONTENT} bytes, got {}",
114 p.content.len()
115 )));
116 }
117 let tags = p.tags.clone().unwrap_or_default();
118 if tags.len() > 10 {
119 return Ok(tool_error("at most 10 tags per store"));
120 }
121 let result = self.with_space(&p.space, |engine, space| {
122 let outcome = engine.ingest(
123 space,
124 IngestInput::Note {
125 text: p.content.clone(),
126 },
127 )?;
128 let episode_id = match &outcome {
129 IngestOutcome::Ingested { episode_id, .. }
130 | IngestOutcome::Deduplicated { episode_id } => *episode_id,
131 };
132 if !tags.is_empty() {
133 let refs: Vec<&str> = tags.iter().map(String::as_str).collect();
134 engine.tag_episode(space, episode_id, &refs)?;
135 }
136 let lane = if engine.has_llm() {
137 let r = engine.distill(space, 10)?;
138 format!(
139 "facts: +{} added, {} closed{}",
140 r.facts_added,
141 r.facts_closed,
142 if r.failed > 0 {
143 format!(", {} failed (recorded for retry)", r.failed)
144 } else {
145 String::new()
146 }
147 )
148 } else {
149 "semantic lane paused (no LLM configured); episodic memory stored".to_owned()
150 };
151 Ok((outcome, lane))
152 });
153 Ok(match result {
154 Ok((IngestOutcome::Ingested { episode_id, chunks }, lane)) => ok_text(format!(
155 "stored episode {episode_id} ({chunks} chunks). {lane}"
156 )),
157 Ok((IngestOutcome::Deduplicated { episode_id }, _)) => ok_text(format!(
158 "already stored as episode {episode_id} (deduplicated)"
159 )),
160 Err(e) => tool_error(e),
161 })
162 }
163
164 #[tool(name = "memory_recall")]
167 async fn memory_recall(
168 &self,
169 Parameters(p): Parameters<RecallParams>,
170 ) -> Result<CallToolResult, ErrorData> {
171 if p.query.is_empty() || p.query.len() > MAX_QUERY {
172 return Ok(tool_error(format!(
173 "query must be 1..={MAX_QUERY} chars, got {}",
174 p.query.len()
175 )));
176 }
177 let limit = p.limit.unwrap_or(10).clamp(1, MAX_LIMIT);
178 let include_profile = p.include_profile.unwrap_or(true);
179 let result = self.with_space(&p.space, |engine, space| {
180 let profile = if include_profile {
181 Some(engine.profile(space, 5)?)
182 } else {
183 None
184 };
185 let pack = engine.recall(
186 space,
187 &p.query,
188 &RecallOpts {
189 limit,
190 budget_bytes: None,
191 as_of: p.as_of.clone(),
192 expand_neighbors: true,
193 tags: p.tags.clone().unwrap_or_default(),
194 },
195 )?;
196 Ok((profile, pack))
197 });
198 Ok(match result {
199 Ok((profile, pack)) => {
200 let mut out = String::new();
201 if let Some(profile) = profile {
202 if !profile.static_facts.is_empty() {
203 out.push_str(
204 "## Profile
205",
206 );
207 for f in &profile.static_facts {
208 out.push_str(&format!(
209 "- {} {} {} (conf {:.2})
210",
211 f.subject, f.predicate, f.object, f.confidence
212 ));
213 }
214 }
215 if !profile.dynamic.is_empty() {
216 out.push_str(
217 "## Recent activity
218",
219 );
220 for d in &profile.dynamic {
221 out.push_str(&format!(
222 "- {}
223",
224 d.replace('\n', " ")
225 ));
226 }
227 }
228 }
229 for f in &pack.facts {
230 out.push_str(&format!(
231 "fact [{}] {} {} {} (conf {:.2}, {})\n",
232 f.fact_id, f.subject, f.predicate, f.object, f.confidence, f.status
233 ));
234 }
235 for item in &pack.items {
236 out.push_str(&format!(
237 "memory [episode {}] {}\n",
238 item.episode_id, item.text
239 ));
240 }
241 for d in &pack.degraded {
242 out.push_str(&format!("degraded: {d}\n"));
243 }
244 if out.is_empty() {
245 out.push_str("no matching memory");
246 }
247 ok_text(out)
248 }
249 Err(e) => tool_error(e),
250 })
251 }
252
253 #[tool(name = "memory_facts_about")]
255 async fn memory_facts_about(
256 &self,
257 Parameters(p): Parameters<FactsAboutParams>,
258 ) -> Result<CallToolResult, ErrorData> {
259 if p.entity.is_empty() || p.entity.len() > MAX_ENTITY {
260 return Ok(tool_error(format!(
261 "entity must be 1..={MAX_ENTITY} chars, got {}",
262 p.entity.len()
263 )));
264 }
265 let result = self.with_space(&p.space, |engine, space| {
266 engine.facts_about(space, &p.entity)
267 });
268 Ok(match result {
269 Ok(facts) if facts.is_empty() => ok_text(format!("no facts about {}", p.entity)),
270 Ok(facts) => ok_text(
271 facts
272 .iter()
273 .map(|f| {
274 format!(
275 "fact [{}] {} {} {} (conf {:.2}, since {})",
276 f.fact_id, f.subject, f.predicate, f.object, f.confidence, f.valid_from
277 )
278 })
279 .collect::<Vec<_>>()
280 .join("\n"),
281 ),
282 Err(e) => tool_error(e),
283 })
284 }
285
286 #[tool(name = "memory_forget")]
289 async fn memory_forget(
290 &self,
291 Parameters(p): Parameters<ForgetParams>,
292 ) -> Result<CallToolResult, ErrorData> {
293 if p.reason.is_empty() || p.reason.len() > MAX_REASON {
294 return Ok(tool_error(format!(
295 "reason must be 1..={MAX_REASON} chars, got {}",
296 p.reason.len()
297 )));
298 }
299 let result = self.with_space(&p.space, |engine, space| {
300 engine.facts_close(space, p.fact_id, &p.reason)
301 });
302 Ok(match result {
303 Ok(()) => ok_text(format!("closed fact {}: {}", p.fact_id, p.reason)),
304 Err(e) => tool_error(e),
305 })
306 }
307}
308
309#[tool_handler]
310impl ServerHandler for SconeMcp {
311 fn get_info(&self) -> rmcp::model::ServerInfo {
312 let mut info = rmcp::model::ServerInfo::default();
313 info.server_info.name = "scone".into();
314 info.server_info.title = Some("Scone memory engine".into());
315 info.server_info.version = env!("CARGO_PKG_VERSION").into();
316 info.instructions = Some(
317 "Persistent memory for this agent. Call memory_recall at task start; \
318 memory_store for durable observations; memory_facts_about before acting \
319 on an entity; memory_forget when the user retracts something."
320 .into(),
321 );
322 info
323 }
324}