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
67#[derive(serde::Deserialize, schemars::JsonSchema)]
68pub struct PendingParams {
69 pub limit: Option<usize>,
71 pub space: Option<String>,
72}
73
74#[derive(serde::Deserialize, schemars::JsonSchema)]
75pub struct SubmittedFact {
76 pub subject: String,
77 pub predicate: String,
78 pub object: String,
79 pub confidence: Option<f32>,
81}
82
83#[derive(serde::Deserialize, schemars::JsonSchema)]
84pub struct StoreFactsParams {
85 pub episode_id: i64,
87 pub facts: Vec<SubmittedFact>,
89 pub space: Option<String>,
90}
91
92fn tool_error(msg: impl Into<String>) -> CallToolResult {
93 CallToolResult::error(vec![ContentBlock::text(msg.into())])
94}
95
96fn ok_text(msg: impl Into<String>) -> CallToolResult {
97 CallToolResult::success(vec![ContentBlock::text(msg.into())])
98}
99
100impl SconeMcp {
101 pub fn new(engine: Engine, default_space: &str) -> Self {
102 Self {
103 engine: Mutex::new(engine),
104 default_space: default_space.to_owned(),
105 }
106 }
107
108 fn with_space<T>(
110 &self,
111 space_override: &Option<String>,
112 f: impl FnOnce(&mut Engine, &auth::ScopedSpace) -> scone_core::Result<T>,
113 ) -> Result<T, String> {
114 let name = space_override
115 .clone()
116 .unwrap_or_else(|| self.default_space.clone());
117 let mut engine = self
118 .engine
119 .lock()
120 .map_err(|_| "engine lock poisoned".to_owned())?;
121 let space = auth::resolve(&mut engine, &name, true).map_err(|e| e.to_string())?;
122 f(&mut engine, &space).map_err(|e| e.to_string())
123 }
124}
125
126#[tool_router]
127impl SconeMcp {
128 #[tool(name = "memory_store")]
132 async fn memory_store(
133 &self,
134 Parameters(p): Parameters<StoreParams>,
135 ) -> Result<CallToolResult, ErrorData> {
136 if p.content.is_empty() || p.content.len() > MAX_CONTENT {
137 return Ok(tool_error(format!(
138 "content must be 1..={MAX_CONTENT} bytes, got {}",
139 p.content.len()
140 )));
141 }
142 let tags = p.tags.clone().unwrap_or_default();
143 if tags.len() > 10 {
144 return Ok(tool_error("at most 10 tags per store"));
145 }
146 let result = self.with_space(&p.space, |engine, space| {
147 let outcome = engine.ingest(
148 space,
149 IngestInput::Note {
150 text: p.content.clone(),
151 },
152 )?;
153 let episode_id = match &outcome {
154 IngestOutcome::Ingested { episode_id, .. }
155 | IngestOutcome::Deduplicated { episode_id } => *episode_id,
156 };
157 if !tags.is_empty() {
158 let refs: Vec<&str> = tags.iter().map(String::as_str).collect();
159 engine.tag_episode(space, episode_id, &refs)?;
160 }
161 let lane = if engine.has_llm() {
162 let r = engine.distill(space, 10)?;
163 format!(
164 "facts: +{} added, {} closed{}",
165 r.facts_added,
166 r.facts_closed,
167 if r.failed > 0 {
168 format!(", {} failed (recorded for retry)", r.failed)
169 } else {
170 String::new()
171 }
172 )
173 } else {
174 "semantic lane paused (no LLM configured); episodic memory stored".to_owned()
175 };
176 Ok((outcome, lane))
177 });
178 Ok(match result {
179 Ok((IngestOutcome::Ingested { episode_id, chunks }, lane)) => ok_text(format!(
180 "stored episode {episode_id} ({chunks} chunks). {lane}"
181 )),
182 Ok((IngestOutcome::Deduplicated { episode_id }, _)) => ok_text(format!(
183 "already stored as episode {episode_id} (deduplicated)"
184 )),
185 Err(e) => tool_error(e),
186 })
187 }
188
189 #[tool(name = "memory_recall")]
192 async fn memory_recall(
193 &self,
194 Parameters(p): Parameters<RecallParams>,
195 ) -> Result<CallToolResult, ErrorData> {
196 if p.query.is_empty() || p.query.len() > MAX_QUERY {
197 return Ok(tool_error(format!(
198 "query must be 1..={MAX_QUERY} chars, got {}",
199 p.query.len()
200 )));
201 }
202 let limit = p.limit.unwrap_or(10).clamp(1, MAX_LIMIT);
203 let include_profile = p.include_profile.unwrap_or(true);
204 let result = self.with_space(&p.space, |engine, space| {
205 let profile = if include_profile {
206 Some(engine.profile(space, 5)?)
207 } else {
208 None
209 };
210 let pack = engine.recall(
211 space,
212 &p.query,
213 &RecallOpts {
214 limit,
215 budget_bytes: None,
216 as_of: p.as_of.clone(),
217 expand_neighbors: true,
218 tags: p.tags.clone().unwrap_or_default(),
219 },
220 )?;
221 Ok((profile, pack))
222 });
223 Ok(match result {
224 Ok((profile, pack)) => {
225 let mut out = String::new();
226 if let Some(profile) = profile {
227 if !profile.static_facts.is_empty() {
228 out.push_str(
229 "## Profile
230",
231 );
232 for f in &profile.static_facts {
233 out.push_str(&format!(
234 "- {} {} {} (conf {:.2})
235",
236 f.subject, f.predicate, f.object, f.confidence
237 ));
238 }
239 }
240 if !profile.dynamic.is_empty() {
241 out.push_str(
242 "## Recent activity
243",
244 );
245 for d in &profile.dynamic {
246 out.push_str(&format!(
247 "- {}
248",
249 d.replace('\n', " ")
250 ));
251 }
252 }
253 }
254 for f in &pack.facts {
255 out.push_str(&format!(
256 "fact [{}] {} {} {} (conf {:.2}, {})\n",
257 f.fact_id, f.subject, f.predicate, f.object, f.confidence, f.status
258 ));
259 }
260 for item in &pack.items {
261 out.push_str(&format!(
262 "memory [{} | episode {}] {}\n",
263 item.day(),
264 item.episode_id,
265 item.text
266 ));
267 }
268 for d in &pack.degraded {
269 out.push_str(&format!("degraded: {d}\n"));
270 }
271 if out.is_empty() {
272 out.push_str("no matching memory");
273 }
274 ok_text(out)
275 }
276 Err(e) => tool_error(e),
277 })
278 }
279
280 #[tool(name = "memory_facts_about")]
282 async fn memory_facts_about(
283 &self,
284 Parameters(p): Parameters<FactsAboutParams>,
285 ) -> Result<CallToolResult, ErrorData> {
286 if p.entity.is_empty() || p.entity.len() > MAX_ENTITY {
287 return Ok(tool_error(format!(
288 "entity must be 1..={MAX_ENTITY} chars, got {}",
289 p.entity.len()
290 )));
291 }
292 let result = self.with_space(&p.space, |engine, space| {
293 engine.facts_about(space, &p.entity)
294 });
295 Ok(match result {
296 Ok(facts) if facts.is_empty() => ok_text(format!("no facts about {}", p.entity)),
297 Ok(facts) => ok_text(
298 facts
299 .iter()
300 .map(|f| {
301 format!(
302 "fact [{}] {} {} {} (conf {:.2}, since {})",
303 f.fact_id, f.subject, f.predicate, f.object, f.confidence, f.valid_from
304 )
305 })
306 .collect::<Vec<_>>()
307 .join("\n"),
308 ),
309 Err(e) => tool_error(e),
310 })
311 }
312
313 #[tool(name = "memory_pending")]
317 async fn memory_pending(
318 &self,
319 Parameters(p): Parameters<PendingParams>,
320 ) -> Result<CallToolResult, ErrorData> {
321 let limit = p.limit.unwrap_or(5).clamp(1, 20);
322 let result = self.with_space(&p.space, |engine, space| {
323 engine.pending_episodes(space, limit)
324 });
325 Ok(match result {
326 Ok(rows) if rows.is_empty() => ok_text("nothing pending: memory is fully distilled"),
327 Ok(rows) => {
328 let mut out = String::from(
329 "Episodes awaiting fact extraction (submit via memory_store_facts):
330",
331 );
332 for (id, content, created_at) in rows {
333 out.push_str(&format!(
334 "--- episode {id} ({created_at})
335{content}
336"
337 ));
338 }
339 ok_text(out)
340 }
341 Err(e) => tool_error(e),
342 })
343 }
344
345 #[tool(name = "memory_store_facts")]
348 async fn memory_store_facts(
349 &self,
350 Parameters(p): Parameters<StoreFactsParams>,
351 ) -> Result<CallToolResult, ErrorData> {
352 if p.facts.len() > 50 {
353 return Ok(tool_error("at most 50 facts per submission"));
354 }
355 let facts: Vec<scone_core::llm::ExtractedFact> = p
356 .facts
357 .iter()
358 .map(|f| scone_core::llm::ExtractedFact {
359 subject: f.subject.clone(),
360 predicate: f.predicate.clone(),
361 object: f.object.clone(),
362 confidence: f.confidence.unwrap_or(0.8).clamp(0.0, 1.0),
363 })
364 .collect();
365 let result = self.with_space(&p.space, |engine, space| {
366 engine.complete_distillation(space, p.episode_id, &facts)
367 });
368 Ok(match result {
369 Ok(report) => ok_text(format!(
370 "episode {} distilled: {} fact{} added, {} closed, {} deduplicated",
371 p.episode_id,
372 report.added,
373 if report.added == 1 { "" } else { "s" },
374 report.closed,
375 report.deduplicated,
376 )),
377 Err(e) => tool_error(e),
378 })
379 }
380
381 #[tool(name = "memory_forget")]
384 async fn memory_forget(
385 &self,
386 Parameters(p): Parameters<ForgetParams>,
387 ) -> Result<CallToolResult, ErrorData> {
388 if p.reason.is_empty() || p.reason.len() > MAX_REASON {
389 return Ok(tool_error(format!(
390 "reason must be 1..={MAX_REASON} chars, got {}",
391 p.reason.len()
392 )));
393 }
394 let result = self.with_space(&p.space, |engine, space| {
395 engine.facts_close(space, p.fact_id, &p.reason)
396 });
397 Ok(match result {
398 Ok(()) => ok_text(format!("closed fact {}: {}", p.fact_id, p.reason)),
399 Err(e) => tool_error(e),
400 })
401 }
402}
403
404#[tool_handler]
405impl ServerHandler for SconeMcp {
406 fn get_info(&self) -> rmcp::model::ServerInfo {
407 let mut info = rmcp::model::ServerInfo::default();
408 info.server_info.name = "scone".into();
409 info.server_info.title = Some("Scone memory engine".into());
410 info.server_info.version = env!("CARGO_PKG_VERSION").into();
411 info.instructions = Some(
412 "Persistent memory for this agent. Call memory_recall at task start; \
413 memory_store for durable observations; memory_facts_about before acting \
414 on an entity; memory_forget when the user retracts something. \
415 Periodically (session start or idle), call memory_pending and distill \
416 the returned episodes into subject/predicate/object facts with your own \
417 reasoning, submitting via memory_store_facts; you are the extraction \
418 model and no API key is needed."
419 .into(),
420 );
421 info
422 }
423}