Skip to main content

ai_memory/mcp/tools/
share.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! MCP `memory_share` handler — minimal v0.8-pulled-forward implementation
5//! for issues #224 (Phase 3 Memory Sharing & Sync RFC) and #311 (targeted
6//! point-to-point memory share).
7//!
8//! Per operator directive `28860423-d12c-4959-bc8b-8fa9a94a33d9` (2026-05-18)
9//! the v0.8.0 Phase 3 RFC is pulled forward into v0.7.0 as a minimum-viable
10//! correct fix. This handler implements the MVP slice:
11//!
12//! 1. Accept `source_memory_id` + `target_agent_id`.
13//! 2. Look up the source memory.
14//! 3. Insert a copy into the target agent's shared namespace
15//!    `_shared/<from_agent_id>→<to_agent_id>/`.
16//! 4. Preserve provenance via metadata (`shared_from_memory_id`,
17//!    `shared_from_agent_id`, `shared_at`).
18//!
19//! Out of scope for this MVP (deferred to v0.8 Phase 3 full delivery):
20//! - CRDT-lite per-field merge rules (#224 design table)
21//! - Bi-directional sync, conflict resolution, vector clocks
22//! - Federation wire-level distribution (still local-DB only here)
23//! - Receiver-side accept/reject workflow
24//!
25//! Regression test: `share_copies_memory_into_shared_namespace`.
26
27use crate::mcp::param_names;
28use crate::models::field_names;
29use crate::{models::Memory, storage as db, validate};
30use serde_json::{Value, json};
31
32/// Build the destination namespace for a shared memory.
33///
34/// Format: `_shared/<from>→<to>/`. The arrow is U+2192 (single
35/// glyph) so the namespace token is one segment — namespace validation
36/// permits it because `validate_namespace` allows non-ASCII tokens
37/// (see `src/validate.rs`).
38#[must_use]
39#[allow(dead_code)]
40pub fn shared_namespace(from_agent_id: &str, to_agent_id: &str) -> String {
41    format!("_shared/{from_agent_id}\u{2192}{to_agent_id}/")
42}
43
44/// MCP `memory_share` — copy a memory into the target agent's shared
45/// namespace.
46///
47/// Returns a JSON object:
48/// ```json
49/// {
50///   "shared_memory_id": "<new uuid>",
51///   "source_memory_id": "<input>",
52///   "target_namespace": "_shared/<from>→<to>/",
53///   "target_agent_id": "<input>",
54///   "from_agent_id": "<derived>"
55/// }
56/// ```
57#[allow(dead_code)]
58pub fn handle_share(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
59    let source_memory_id = params[param_names::SOURCE_MEMORY_ID]
60        .as_str()
61        .ok_or("source_memory_id is required")?;
62    let target_agent_id = params[param_names::TARGET_AGENT_ID]
63        .as_str()
64        .ok_or("target_agent_id is required")?;
65
66    validate::validate_id(source_memory_id).map_err(|e| e.to_string())?;
67    validate::validate_agent_id(target_agent_id).map_err(|e| e.to_string())?;
68
69    let source = db::resolve_id(conn, source_memory_id)
70        .map_err(|e| e.to_string())?
71        .ok_or_else(|| format!("source memory {source_memory_id} not found"))?;
72
73    // Derive the from_agent_id from the source memory's metadata; fall back
74    // to `unknown` if absent.
75    let from_agent_id = source
76        .metadata
77        .get(param_names::AGENT_ID)
78        .and_then(Value::as_str)
79        .unwrap_or("unknown")
80        .to_string();
81
82    let target_namespace = shared_namespace(&from_agent_id, target_agent_id);
83    let now = chrono::Utc::now().to_rfc3339();
84
85    // Merge provenance into metadata; preserve the source's metadata
86    // (no information loss) but stamp the share-event fields.
87    let mut metadata = source.metadata.clone();
88    if let Some(obj) = metadata.as_object_mut() {
89        obj.insert("shared_from_memory_id".into(), json!(source.id.clone()));
90        obj.insert("shared_from_agent_id".into(), json!(from_agent_id.clone()));
91        obj.insert("shared_to_agent_id".into(), json!(target_agent_id));
92        obj.insert("shared_at".into(), json!(now.clone()));
93        // The shared copy is authored BY the receiving agent for write-auth
94        // purposes; the original author is preserved in
95        // `shared_from_agent_id`.
96        obj.insert("agent_id".into(), json!(target_agent_id));
97    }
98
99    let shared_id = uuid::Uuid::new_v4().to_string();
100    let shared = Memory {
101        id: shared_id.clone(),
102        tier: source.tier,
103        namespace: target_namespace.clone(),
104        title: source.title.clone(),
105        content: source.content.clone(),
106        tags: source.tags.clone(),
107        priority: source.priority,
108        confidence: source.confidence,
109        source: "shared".to_string(),
110        access_count: 0,
111        created_at: now.clone(),
112        updated_at: now,
113        last_accessed_at: None,
114        expires_at: None,
115        metadata,
116        reflection_depth: source.reflection_depth,
117        memory_kind: source.memory_kind,
118        entity_id: source.entity_id.clone(),
119        persona_version: source.persona_version,
120        citations: source.citations.clone(),
121        source_uri: source.source_uri.clone(),
122        source_span: source.source_span.clone(),
123        confidence_source: source.confidence_source,
124        confidence_signals: source.confidence_signals.clone(),
125        confidence_decayed_at: source.confidence_decayed_at.clone(),
126        // v45 schema (Gap-1 optimistic concurrency, issue #884) — fresh
127        // share row starts at version 1.
128        version: 1,
129    };
130
131    db::insert(conn, &shared).map_err(|e| e.to_string())?;
132
133    Ok(json!({
134        "shared_memory_id": shared_id,
135        (field_names::SOURCE_MEMORY_ID): source_memory_id,
136        (field_names::TARGET_NAMESPACE): target_namespace,
137        (field_names::TARGET_AGENT_ID): target_agent_id,
138        (field_names::FROM_AGENT_ID): from_agent_id,
139    }))
140}
141
142// --- D1.5 (#986): per-tool McpTool impl for memory_share ---
143
144use crate::mcp::registry::McpTool;
145use schemars::JsonSchema;
146use serde::Deserialize;
147
148/// v0.7.0 #972 D1.5 (#986) — request body for `memory_share`.
149#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
150#[allow(dead_code)]
151pub struct ShareRequest {
152    /// Memory id (full UUID or unique prefix) to share.
153    pub source_memory_id: String,
154
155    /// Recipient agent id; must satisfy validate_agent_id.
156    pub target_agent_id: String,
157}
158
159/// v0.7.0 #972 D1.5 (#986) — `McpTool` impl for `memory_share`.
160#[allow(dead_code)]
161pub struct ShareTool;
162
163impl McpTool for ShareTool {
164    fn name() -> &'static str {
165        crate::mcp::registry::tool_names::MEMORY_SHARE
166    }
167    fn description() -> &'static str {
168        "Share a memory with another agent (copy into _shared/<from>→<to>/)."
169    }
170    fn docs() -> &'static str {
171        "#224/#311 MVP: point-to-point copy into `_shared/<from>→<to>/` with provenance."
172    }
173    fn input_schema() -> Value {
174        crate::mcp::registry::input_schema_for::<ShareRequest>()
175    }
176    fn family() -> &'static str {
177        crate::profile::Family::Power.name()
178    }
179}
180
181#[cfg(test)]
182mod d1_5_986_tests {
183    //! D1.5 (#986) — schema parity for `memory_share`.
184    //! Shared helpers live at [`crate::mcp::parity_test_helpers`].
185    use super::*;
186    use crate::mcp::parity_test_helpers::{
187        assert_descriptions_match, assert_property_set_parity, derived_props_for,
188    };
189
190    #[test]
191    fn share_parity_986() {
192        let derived = derived_props_for::<ShareRequest>();
193        assert_property_set_parity("memory_share", &derived);
194        assert_descriptions_match("memory_share", &derived);
195    }
196
197    #[test]
198    fn share_tool_metadata_986() {
199        assert_eq!(ShareTool::name(), "memory_share");
200        assert_eq!(ShareTool::family(), "power");
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::models::{Memory, Tier};
208
209    fn fresh_conn() -> rusqlite::Connection {
210        db::open(std::path::Path::new(":memory:")).expect("open in-memory db")
211    }
212
213    fn make_mem(title: &str, namespace: &str, agent_id: &str) -> Memory {
214        let now = chrono::Utc::now().to_rfc3339();
215        Memory {
216            id: uuid::Uuid::new_v4().to_string(),
217            tier: Tier::Mid,
218            namespace: namespace.to_string(),
219            title: title.to_string(),
220            content: format!("content for {title}"),
221            tags: vec!["share-test".to_string()],
222            priority: 5,
223            confidence: 1.0,
224            source: "test".to_string(),
225            access_count: 0,
226            created_at: now.clone(),
227            updated_at: now,
228            last_accessed_at: None,
229            expires_at: None,
230            metadata: json!({"agent_id": agent_id}),
231            reflection_depth: 0,
232            memory_kind: crate::models::MemoryKind::Observation,
233            entity_id: None,
234            persona_version: None,
235            citations: Vec::new(),
236            source_uri: None,
237            source_span: None,
238            confidence_source: crate::models::ConfidenceSource::CallerProvided,
239            confidence_signals: None,
240            confidence_decayed_at: None,
241            version: 1,
242        }
243    }
244
245    #[test]
246    fn share_copies_memory_into_shared_namespace() {
247        let conn = fresh_conn();
248        let src = make_mem("source memo", "alice/notes", "ai:alice");
249        let src_id = db::insert(&conn, &src).expect("insert source");
250
251        let params = json!({
252            "source_memory_id": src_id.clone(),
253            "target_agent_id": "ai:bob",
254        });
255        let resp = handle_share(&conn, &params).expect("share ok");
256
257        let new_id = resp["shared_memory_id"]
258            .as_str()
259            .expect("shared_memory_id present");
260        assert_ne!(new_id, src_id, "shared copy must have new id");
261        assert_eq!(resp["target_agent_id"], "ai:bob");
262        assert_eq!(resp["from_agent_id"], "ai:alice");
263        assert_eq!(resp["target_namespace"], "_shared/ai:alice\u{2192}ai:bob/");
264
265        // Pull the shared row back and verify provenance + content fidelity.
266        let copy = db::resolve_id(&conn, new_id)
267            .expect("resolve")
268            .expect("shared copy present");
269        assert_eq!(copy.title, src.title);
270        assert_eq!(copy.content, src.content);
271        assert_eq!(copy.namespace, "_shared/ai:alice\u{2192}ai:bob/");
272        assert_eq!(copy.source, "shared");
273        assert_eq!(
274            copy.metadata["shared_from_memory_id"].as_str(),
275            Some(src_id.as_str())
276        );
277        assert_eq!(
278            copy.metadata["shared_from_agent_id"].as_str(),
279            Some("ai:alice")
280        );
281        assert_eq!(copy.metadata["shared_to_agent_id"].as_str(), Some("ai:bob"));
282        assert_eq!(copy.metadata["agent_id"].as_str(), Some("ai:bob"));
283    }
284
285    #[test]
286    fn share_rejects_missing_source() {
287        let conn = fresh_conn();
288        let nonexistent = uuid::Uuid::new_v4().to_string();
289        let params = json!({
290            "source_memory_id": nonexistent,
291            "target_agent_id": "ai:bob",
292        });
293        let err = handle_share(&conn, &params).expect_err("must fail");
294        assert!(err.contains("not found"), "got: {err}");
295    }
296
297    #[test]
298    fn share_rejects_missing_params() {
299        let conn = fresh_conn();
300        let r1 = handle_share(&conn, &json!({"target_agent_id": "ai:bob"}));
301        assert!(r1.is_err());
302        let r2 = handle_share(
303            &conn,
304            &json!({"source_memory_id": uuid::Uuid::new_v4().to_string()}),
305        );
306        assert!(r2.is_err());
307    }
308}