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