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}
32
33#[derive(serde::Deserialize, schemars::JsonSchema)]
34pub struct RecallParams {
35    /// Natural-language query (1..=1000 chars)
36    pub query: String,
37    pub space: Option<String>,
38    /// Max items (1..=50)
39    pub limit: Option<usize>,
40    /// Prepend the space's profile (identity facts + recent activity).
41    /// Defaults to true.
42    pub include_profile: Option<bool>,
43    /// Evaluate fact validity at this ISO-8601 instant (time travel)
44    pub as_of: Option<String>,
45}
46
47#[derive(serde::Deserialize, schemars::JsonSchema)]
48pub struct FactsAboutParams {
49    /// Entity to look up (person, project, tool …)
50    pub entity: String,
51    pub space: Option<String>,
52}
53
54#[derive(serde::Deserialize, schemars::JsonSchema)]
55pub struct ForgetParams {
56    /// Fact id to close (from memory_recall / memory_facts_about output)
57    pub fact_id: i64,
58    /// Why this fact should be forgotten (recorded, never deleted)
59    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    /// Run one closure against the engine in a named space.
80    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    /// Save content to persistent memory. Returns the episode id; duplicate
100    /// content is recognized, not re-stored. When an LLM is configured,
101    /// facts are distilled immediately.
102    #[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    /// Recall relevant memory: temporal facts first, then episodic chunks,
149    /// each with provenance. `as_of` answers what was true at a past time.
150    #[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    /// List what is currently known about one entity (active facts only).
236    #[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    /// Forget a fact: closes its validity interval with your reason.
269    /// History is preserved; nothing is deleted.
270    #[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}