Skip to main content

scone/
mcp.rs

1//! MCP server: persistent memory for any MCP agent (spec §8).
2//!
3//! Space-scoped and input-bounded from the first commit — the predecessor
4//! shipped unscoped document access and unbounded inputs, hardened only
5//! fourteen months later (memory/lessons.md L-10, bugs.md P-1/P-4).
6
7use 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    /// The content to remember (1..=100000 chars)
28    pub content: String,
29    /// Space to store into; defaults to the server's space
30    pub space: Option<String>,
31    /// Tags for focused retrieval later (each 1..=64 chars, max 10)
32    pub tags: Option<Vec<String>>,
33}
34
35#[derive(serde::Deserialize, schemars::JsonSchema)]
36pub struct RecallParams {
37    /// Natural-language query (1..=1000 chars)
38    pub query: String,
39    pub space: Option<String>,
40    /// Max items (1..=50)
41    pub limit: Option<usize>,
42    /// Prepend the space's profile (identity facts + recent activity).
43    /// Defaults to true.
44    pub include_profile: Option<bool>,
45    /// Focus recall to episodes carrying ALL of these tags.
46    pub tags: Option<Vec<String>>,
47    /// Evaluate fact validity at this ISO-8601 instant (time travel)
48    pub as_of: Option<String>,
49}
50
51#[derive(serde::Deserialize, schemars::JsonSchema)]
52pub struct FactsAboutParams {
53    /// Entity to look up (person, project, tool …)
54    pub entity: String,
55    pub space: Option<String>,
56}
57
58#[derive(serde::Deserialize, schemars::JsonSchema)]
59pub struct ForgetParams {
60    /// Fact id to close (from memory_recall / memory_facts_about output)
61    pub fact_id: i64,
62    /// Why this fact should be forgotten (recorded, never deleted)
63    pub reason: String,
64    pub space: Option<String>,
65}
66
67#[derive(serde::Deserialize, schemars::JsonSchema)]
68pub struct PendingParams {
69    /// Max episodes to return (1..=20)
70    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    /// 0..=1; defaults to 0.8
80    pub confidence: Option<f32>,
81}
82
83#[derive(serde::Deserialize, schemars::JsonSchema)]
84pub struct StoreFactsParams {
85    /// Episode id from memory_pending
86    pub episode_id: i64,
87    /// Extracted facts (max 50)
88    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    /// Run one closure against the engine in a named space.
109    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    /// Save content to persistent memory. Returns the episode id; duplicate
129    /// content is recognized, not re-stored. When an LLM is configured,
130    /// facts are distilled immediately.
131    #[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    /// Recall relevant memory: temporal facts first, then episodic chunks,
190    /// each with provenance. `as_of` answers what was true at a past time.
191    #[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    /// List what is currently known about one entity (active facts only).
281    #[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    /// List episodes awaiting fact extraction. YOU are the extractor:
314    /// read each episode, distill durable subject/predicate/object facts
315    /// with your own reasoning, then submit them via memory_store_facts.
316    #[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    /// Submit facts you extracted from a pending episode. The engine
346    /// applies contradiction closure and provenance; you only propose.
347    #[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    /// Forget a fact: closes its validity interval with your reason.
382    /// History is preserved; nothing is deleted.
383    #[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}