Skip to main content

ai_memory/mcp/tools/
export_reflection.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! v0.7.0 QW-1 — MCP `memory_export_reflection` handler.
5//!
6//! Renders the markdown / JSON envelope for a single reflection
7//! memory and returns the rendered text plus a suggested filename
8//! the agent can pass to the harness for `Write`-tool invocation.
9//!
10//! # Critical: this handler does NOT write to the filesystem.
11//!
12//! Two reasons:
13//!
14//! 1. **Capability isolation.** The MCP server is gated to the
15//!    Semantic+ tier; the operator pre-authorised "agent reads and
16//!    writes substrate memory" — they did NOT pre-authorise "agent
17//!    writes arbitrary paths under `$HOME`". The CLI surface (which
18//!    runs in the operator's user session) does the disk write.
19//! 2. **Symmetry with `memory_skill_export`.** The L1-5 skill export
20//!    tool follows the same contract: the substrate returns the
21//!    content, the *agent harness* writes the file. The two tools
22//!    must stay structurally aligned so the operator's mental model
23//!    transfers.
24
25use serde_json::{Value, json};
26
27use crate::cli::commands::export_reflections::{self, ExportFormat};
28use crate::db;
29use crate::models::MemoryKind;
30
31/// Wire shape:
32///
33/// ```json
34/// {
35///   "content": "---\nmemory_id: ...\n...",
36///   "suggested_filename": "<namespace-with-slashes>/<id>.md"
37/// }
38/// ```
39///
40/// Errors:
41/// * `memory_id is required` — caller omitted the parameter.
42/// * `memory_id cannot be empty`.
43/// * `memory not found: <id>` — substrate doesn't know this id.
44/// * `memory is not a reflection: <id>` — caller passed an observation.
45/// * `unsupported export format '<x>'` — `format` was neither
46///   `md` nor `json`.
47pub fn handle_export_reflection(
48    conn: &rusqlite::Connection,
49    params: &Value,
50) -> Result<Value, String> {
51    let memory_id = params["memory_id"]
52        .as_str()
53        .ok_or(crate::errors::msg::MEMORY_ID_REQUIRED)?;
54    if memory_id.is_empty() {
55        return Err(crate::errors::msg::MEMORY_ID_EMPTY.to_string());
56    }
57    let format_str = params["format"].as_str().unwrap_or("md");
58    let format = parse_format_for_mcp(format_str)?;
59
60    let mem = db::get(conn, memory_id)
61        .map_err(|e| format!("memory_export_reflection substrate error: {e}"))?
62        .ok_or_else(|| crate::errors::msg::memory_not_found(memory_id))?;
63    if !matches!(mem.memory_kind, MemoryKind::Reflection) {
64        return Err(format!("memory is not a reflection: {memory_id}"));
65    }
66
67    let edges = collect_outbound_reflects_on(conn, memory_id)
68        .map_err(|e| format!("reading reflects_on links: {e}"))?;
69    let attest_level = export_reflections::summarise_attest_level(&edges);
70    let content = export_reflections::render_payload(&mem, &edges, attest_level, format);
71    let suggested = suggested_filename(&mem.namespace, &mem.id, format);
72    Ok(json!({
73        "content": content,
74        "suggested_filename": suggested,
75    }))
76}
77
78/// Local copy of the format parser — kept here so the MCP error
79/// messages can be tuned independently of the CLI's `parse_format`
80/// (which `anyhow::bail`s; MCP convention is plain `String` errors).
81fn parse_format_for_mcp(spec: &str) -> Result<ExportFormat, String> {
82    match spec.to_lowercase().as_str() {
83        "md" | "markdown" => Ok(ExportFormat::Markdown),
84        "json" => Ok(ExportFormat::Json),
85        other => Err(format!(
86            "unsupported export format '{other}' (expected 'md' or 'json')"
87        )),
88    }
89}
90
91/// Same SQL projection the CLI uses, locally re-issued because the
92/// CLI's helper is `pub(crate)` and we want to keep the MCP handler
93/// self-contained for clarity. The two queries are intentionally
94/// byte-identical.
95fn collect_outbound_reflects_on(
96    conn: &rusqlite::Connection,
97    memory_id: &str,
98) -> Result<Vec<export_reflections::ReflectsOnEdge>, anyhow::Error> {
99    let mut stmt = conn.prepare(
100        "SELECT target_id, COALESCE(attest_level, 'unsigned'), created_at \
101         FROM memory_links \
102         WHERE source_id = ?1 AND relation = 'reflects_on' \
103         ORDER BY created_at ASC",
104    )?;
105    let rows = stmt.query_map(rusqlite::params![memory_id], |row| {
106        Ok(export_reflections::ReflectsOnEdge {
107            target_id: row.get(0)?,
108            attest_level: row.get(1)?,
109            created_at: row.get(2)?,
110        })
111    })?;
112    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
113}
114
115/// `<namespace>/<id>.<ext>` — slashes in namespace stay slashes so
116/// the agent can build nested directories under whatever root it
117/// wants.
118fn suggested_filename(namespace: &str, id: &str, format: ExportFormat) -> String {
119    let ns_clean = namespace.trim_matches('/');
120    if ns_clean.is_empty() {
121        format!("{id}.{ext}", ext = format.extension())
122    } else {
123        format!("{ns_clean}/{id}.{ext}", ext = format.extension())
124    }
125}
126
127// --- D1.5 (#986): per-tool McpTool impl for memory_export_reflection ---
128
129use crate::mcp::registry::McpTool;
130use schemars::JsonSchema;
131use serde::Deserialize;
132
133/// v0.7.0 #972 D1.5 (#986) — request body for `memory_export_reflection`.
134#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
135#[allow(dead_code)]
136pub struct ExportReflectionRequest {
137    /// Reflection-kind memory id.
138    pub memory_id: String,
139
140    /// md or json.
141    #[serde(default)]
142    pub format: Option<String>,
143}
144
145/// v0.7.0 #972 D1.5 (#986) — `McpTool` impl for `memory_export_reflection`.
146#[allow(dead_code)]
147pub struct ExportReflectionTool;
148
149impl McpTool for ExportReflectionTool {
150    fn name() -> &'static str {
151        crate::mcp::registry::tool_names::MEMORY_EXPORT_REFLECTION
152    }
153    fn description() -> &'static str {
154        "Render a single reflection memory as markdown or JSON (no filesystem write)."
155    }
156    fn docs() -> &'static str {
157        "QW-1: render reflection + reflects_on provenance as YAML-frontmatter md (default) or JSON envelope. Returns {content, suggested_filename}. No FS write — harness owns disk I/O."
158    }
159    fn input_schema() -> Value {
160        crate::mcp::registry::input_schema_for::<ExportReflectionRequest>()
161    }
162    fn family() -> &'static str {
163        crate::profile::Family::Power.name()
164    }
165}
166
167#[cfg(test)]
168mod d1_5_986_tests {
169    //! D1.5 (#986) — schema parity for `memory_export_reflection`.
170    //! Shared helpers live at [`crate::mcp::parity_test_helpers`].
171    use super::*;
172    use crate::mcp::parity_test_helpers::{
173        assert_descriptions_match, assert_property_set_parity, derived_props_for,
174    };
175
176    #[test]
177    fn export_reflection_parity_986() {
178        let derived = derived_props_for::<ExportReflectionRequest>();
179        assert_property_set_parity("memory_export_reflection", &derived);
180        assert_descriptions_match("memory_export_reflection", &derived);
181    }
182
183    #[test]
184    fn export_reflection_tool_metadata_986() {
185        assert_eq!(ExportReflectionTool::name(), "memory_export_reflection");
186        assert_eq!(ExportReflectionTool::family(), "power");
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::models::{Memory, Tier};
194    use chrono::Utc;
195    use tempfile::TempDir;
196
197    fn fresh_db() -> (rusqlite::Connection, TempDir) {
198        let dir = TempDir::new().unwrap();
199        let path = dir.path().join("ai-memory.db");
200        let conn = db::open(&path).unwrap();
201        (conn, dir)
202    }
203
204    fn make_reflection(ns: &str, depth: i32, agent_id: &str) -> Memory {
205        let now = Utc::now().to_rfc3339();
206        Memory {
207            id: uuid::Uuid::new_v4().to_string(),
208            tier: Tier::Mid,
209            namespace: ns.to_string(),
210            title: "rfl".into(),
211            content: "body".into(),
212            tags: vec![],
213            priority: 5,
214            confidence: 1.0,
215            source: "test".into(),
216            access_count: 0,
217            created_at: now.clone(),
218            updated_at: now,
219            last_accessed_at: None,
220            expires_at: None,
221            metadata: serde_json::json!({"agent_id": agent_id}),
222            reflection_depth: depth,
223            memory_kind: MemoryKind::Reflection,
224            entity_id: None,
225            persona_version: None,
226            citations: Vec::new(),
227            source_uri: None,
228            source_span: None,
229            confidence_source: crate::models::ConfidenceSource::CallerProvided,
230            confidence_signals: None,
231            confidence_decayed_at: None,
232            version: 1,
233        }
234    }
235
236    #[test]
237    fn missing_memory_id_errors() {
238        let (conn, _g) = fresh_db();
239        let err = handle_export_reflection(&conn, &json!({})).unwrap_err();
240        assert!(err.contains("memory_id"));
241    }
242
243    #[test]
244    fn empty_memory_id_errors() {
245        let (conn, _g) = fresh_db();
246        let err = handle_export_reflection(&conn, &json!({"memory_id": ""})).unwrap_err();
247        assert!(err.contains("empty"));
248    }
249
250    #[test]
251    fn unknown_id_errors() {
252        let (conn, _g) = fresh_db();
253        let err = handle_export_reflection(
254            &conn,
255            &json!({"memory_id": "11111111-2222-3333-4444-555555555555"}),
256        )
257        .unwrap_err();
258        assert!(err.contains("not found"));
259    }
260
261    #[test]
262    fn observation_kind_errors() {
263        let (conn, _g) = fresh_db();
264        let mut obs = make_reflection("ns", 0, "ai:test");
265        obs.memory_kind = MemoryKind::Observation;
266        obs.reflection_depth = 0;
267        let id = db::insert(&conn, &obs).unwrap();
268        let err = handle_export_reflection(&conn, &json!({"memory_id": id})).unwrap_err();
269        assert!(err.contains("not a reflection"));
270    }
271
272    #[test]
273    fn unsupported_format_errors() {
274        let (conn, _g) = fresh_db();
275        let rfl = make_reflection("ns", 1, "ai:test");
276        let id = db::insert(&conn, &rfl).unwrap();
277        let err = handle_export_reflection(&conn, &json!({"memory_id": id, "format": "yaml"}))
278            .unwrap_err();
279        assert!(err.contains("unsupported export format"));
280    }
281
282    #[test]
283    fn happy_path_md_returns_content_and_filename() {
284        let (conn, _g) = fresh_db();
285        let rfl = make_reflection("team/alpha", 1, "ai:bot");
286        let id = db::insert(&conn, &rfl).unwrap();
287        let out = handle_export_reflection(&conn, &json!({"memory_id": id})).unwrap();
288        let content = out["content"].as_str().unwrap();
289        assert!(content.starts_with("---\n"));
290        assert!(content.contains(&format!("memory_id: {id}\n")));
291        let fname = out["suggested_filename"].as_str().unwrap();
292        assert_eq!(fname, format!("team/alpha/{id}.md"));
293    }
294
295    #[test]
296    fn happy_path_json_returns_parsable_envelope() {
297        let (conn, _g) = fresh_db();
298        let rfl = make_reflection("ns", 2, "ai:bot");
299        let id = db::insert(&conn, &rfl).unwrap();
300        let out =
301            handle_export_reflection(&conn, &json!({"memory_id": id, "format": "json"})).unwrap();
302        let content = out["content"].as_str().unwrap();
303        let parsed: serde_json::Value = serde_json::from_str(content).unwrap();
304        assert_eq!(parsed["memory_id"].as_str().unwrap(), id);
305        assert_eq!(parsed["namespace"].as_str().unwrap(), "ns");
306        assert_eq!(parsed["reflection_depth"].as_i64().unwrap(), 2);
307        let fname = out["suggested_filename"].as_str().unwrap();
308        assert!(fname.ends_with(".json"));
309    }
310
311    #[test]
312    fn suggested_filename_strips_slashes() {
313        assert_eq!(
314            suggested_filename("/team/alpha/", "abc", ExportFormat::Markdown),
315            "team/alpha/abc.md"
316        );
317        assert_eq!(
318            suggested_filename("", "abc", ExportFormat::Json),
319            "abc.json"
320        );
321    }
322}