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
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    /// Run one closure against the engine in a named space.
84    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    /// Save content to persistent memory. Returns the episode id; duplicate
104    /// content is recognized, not re-stored. When an LLM is configured,
105    /// facts are distilled immediately.
106    #[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    /// Recall relevant memory: temporal facts first, then episodic chunks,
165    /// each with provenance. `as_of` answers what was true at a past time.
166    #[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    /// List what is currently known about one entity (active facts only).
254    #[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    /// Forget a fact: closes its validity interval with your reason.
287    /// History is preserved; nothing is deleted.
288    #[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}