Skip to main content

trusty_memory/
lib.rs

1//! MCP server (stdio + HTTP/SSE) for trusty-memory.
2//!
3//! Why: Claude Code and other MCP-aware clients integrate with trusty-memory
4//! through the standardized Model Context Protocol; we expose memory + KG
5//! tools so they can be called by name.
6//! What: Provides `run_stdio` (JSON-RPC 2.0 over stdin/stdout) and `run_http`
7//! (axum HTTP/SSE stub), plus an `AppState` that carries the shared
8//! `PalaceRegistry`, on-disk data root, and a lazily-initialized embedder.
9//! Test: `cargo test -p trusty-memory-mcp` validates handshake + dispatch.
10
11use anyhow::Result;
12use serde_json::{json, Value};
13use std::path::PathBuf;
14use std::sync::Arc;
15use tokio::sync::{broadcast, OnceCell};
16use tracing::info;
17use trusty_common::mcp::{error_codes, initialize_response, Request, Response};
18use trusty_common::memory_core::embed::FastEmbedder;
19use trusty_common::memory_core::store::ChatSessionStore;
20use trusty_common::memory_core::PalaceRegistry;
21use trusty_common::ChatProvider;
22
23pub mod commands;
24pub mod openrpc;
25pub mod service;
26pub mod tools;
27pub mod web;
28
29pub use service::MemoryMcpService;
30pub use tools::MemoryMcpServer;
31
32/// Live daemon events broadcast to connected SSE subscribers.
33///
34/// Why: The dashboard needs push-driven updates so palace creation, drawer
35/// add/delete, dream cycles, and aggregate status changes are visible without
36/// polling. A single broadcast channel fans out to every connected browser.
37/// What: Tagged enum serialized as `{"type": "...", ...fields}` over SSE.
38/// Test: `web::tests::sse_stream_emits_events` subscribes, triggers a
39/// mutation, and asserts the frame arrives.
40#[derive(Clone, Debug, serde::Serialize)]
41#[serde(tag = "type", rename_all = "snake_case")]
42pub enum DaemonEvent {
43    PalaceCreated {
44        id: String,
45        name: String,
46    },
47    DrawerAdded {
48        palace_id: String,
49        drawer_count: usize,
50    },
51    DrawerDeleted {
52        palace_id: String,
53        drawer_count: usize,
54    },
55    DreamCompleted {
56        palace_id: Option<String>,
57        merged: usize,
58        pruned: usize,
59        compacted: usize,
60        closets_updated: usize,
61        duration_ms: u64,
62    },
63    StatusChanged {
64        total_drawers: usize,
65        total_vectors: usize,
66        total_kg_triples: usize,
67    },
68}
69
70/// Shared application state passed to every request handler.
71///
72/// Why: The stdio loop and HTTP server need the same handles to the registry,
73/// data root, and embedder so MCP tools can perform real reads/writes against
74/// the live trusty-memory core. The embedder is heavy (loads ONNX weights) so
75/// we hold it behind a `OnceCell` and initialize lazily on first use.
76/// What: `Clone`-able via `Arc` fields. The registry / data root are eager;
77/// `embedder` is `Arc<OnceCell<Arc<FastEmbedder>>>` so concurrent first-use
78/// races resolve to a single shared instance.
79/// Test: `app_state_default_constructs` confirms construction without panic.
80#[derive(Clone)]
81pub struct AppState {
82    pub version: String,
83    pub registry: Arc<PalaceRegistry>,
84    pub data_root: PathBuf,
85    pub embedder: Arc<OnceCell<Arc<FastEmbedder>>>,
86    /// Optional default palace applied to MCP tool calls when the caller
87    /// omits the `palace` argument. Set via `trusty-memory serve --palace`.
88    pub default_palace: Option<String>,
89    /// Active chat provider selected at startup. `None` means no upstream is
90    /// configured (no Ollama detected and no OpenRouter key) — callers must
91    /// degrade gracefully (chat endpoint returns 412).
92    pub chat_provider: Arc<OnceCell<Option<Arc<dyn ChatProvider>>>>,
93    /// Per-palace chat-session stores, opened lazily so cold-start cost is
94    /// paid only when chat-history endpoints are hit.
95    pub session_stores: Arc<dashmap::DashMap<String, Arc<ChatSessionStore>>>,
96    /// Broadcast sender for live `DaemonEvent` pushes to SSE subscribers.
97    ///
98    /// Why: Lets mutating handlers emit events that any connected dashboard
99    /// receives instantly. Cap of 128 buffers transient slow readers; if a
100    /// receiver lags it gets `RecvError::Lagged` and we emit a `lag` frame.
101    pub events: Arc<broadcast::Sender<DaemonEvent>>,
102}
103
104impl AppState {
105    /// Construct an `AppState` rooted at the given on-disk data directory.
106    ///
107    /// Why: The CLI (`serve`) and integration tests need to point the MCP
108    /// server at different roots — production at `dirs::data_dir`, tests at a
109    /// `tempfile::tempdir()`.
110    /// What: Builds an empty `PalaceRegistry`, captures the version, and
111    /// allocates an empty `OnceCell` for the embedder. `default_palace` is
112    /// `None`; use `with_default_palace` to set it.
113    /// Test: `tools::tests::dispatch_palace_create_persists` constructs an
114    /// AppState pointed at a tempdir and round-trips a palace through it.
115    pub fn new(data_root: PathBuf) -> Self {
116        let (events_tx, _) = broadcast::channel::<DaemonEvent>(128);
117        Self {
118            version: env!("CARGO_PKG_VERSION").to_string(),
119            registry: Arc::new(PalaceRegistry::new()),
120            data_root,
121            embedder: Arc::new(OnceCell::new()),
122            default_palace: None,
123            chat_provider: Arc::new(OnceCell::new()),
124            session_stores: Arc::new(dashmap::DashMap::new()),
125            events: Arc::new(events_tx),
126        }
127    }
128
129    /// Send a `DaemonEvent` to all connected SSE subscribers.
130    ///
131    /// Why: Mutating handlers call this after a successful write so the
132    /// dashboard can update without polling. The send is best-effort —
133    /// `broadcast::Sender::send` returns `Err` only when there are no live
134    /// receivers, which is fine (no listeners == no work to do).
135    /// What: Drops the result, so callers don't need to care whether anyone
136    /// is listening.
137    /// Test: `web::tests::sse_stream_receives_palace_created` confirms a
138    /// subscriber observes the emitted event.
139    pub fn emit(&self, event: DaemonEvent) {
140        let _ = self.events.send(event);
141    }
142
143    /// Open (or return cached) the chat-session store for a palace.
144    ///
145    /// Why: Chat session persistence lives in a dedicated SQLite file under
146    /// the palace's data dir (`chat_sessions.db`) so it doesn't intermingle
147    /// with the KG's transactional load. The store is cheap to clone via
148    /// `Arc` but the underlying r2d2 pool should be reused, so cache by id.
149    /// What: Creates the palace data dir if missing, opens (or reuses) a
150    /// `ChatSessionStore` and stashes an `Arc` in the DashMap.
151    /// Test: Indirectly via the session HTTP handlers in `web::tests`.
152    pub fn session_store(&self, palace_id: &str) -> Result<Arc<ChatSessionStore>> {
153        if let Some(entry) = self.session_stores.get(palace_id) {
154            return Ok(entry.clone());
155        }
156        let dir = self.data_root.join(palace_id);
157        std::fs::create_dir_all(&dir)
158            .map_err(|e| anyhow::anyhow!("create palace dir {}: {e}", dir.display()))?;
159        let store = Arc::new(ChatSessionStore::open(&dir.join("chat_sessions.db"))?);
160        self.session_stores
161            .insert(palace_id.to_string(), store.clone());
162        Ok(store)
163    }
164
165    /// Builder-style setter for the default palace name.
166    ///
167    /// Why: `serve --palace <name>` wants to bind every tool call to a
168    /// project-scoped namespace without forcing every MCP request to repeat
169    /// the palace argument.
170    /// What: Returns `self` with `default_palace = Some(name)`.
171    /// Test: `default_palace_used_when_arg_omitted` covers the resolution
172    /// path; this setter is exercised there.
173    pub fn with_default_palace(mut self, name: Option<String>) -> Self {
174        self.default_palace = name;
175        self
176    }
177
178    /// Resolve (or initialize) the shared embedder.
179    ///
180    /// Why: FastEmbedder load is expensive — we share one instance across all
181    /// tool calls; the `OnceCell` ensures concurrent first-use races collapse
182    /// to a single load.
183    /// What: Returns `Arc<FastEmbedder>` on success. Errors propagate from the
184    /// underlying ONNX load.
185    /// Test: Indirectly via `dispatch_remember_then_recall`.
186    /// Resolve the active chat provider, auto-detecting on first call.
187    ///
188    /// Why: Provider selection depends on filesystem-loaded config plus a
189    /// network probe (Ollama liveness), so it must be lazily initialised at
190    /// runtime. Caching the choice in a `OnceCell` keeps it stable across
191    /// concurrent requests without re-probing on every chat call.
192    /// What: On first use loads `~/.trusty-memory/config.toml`, prefers an
193    /// auto-detected Ollama instance (when `local_model.enabled`), and falls
194    /// back to OpenRouter when an API key is set. Returns `Ok(None)` when
195    /// neither is available so the caller can emit a 412.
196    /// Test: `web::tests::providers_endpoint_returns_payload` covers the
197    /// detection path indirectly through `/api/v1/chat/providers`.
198    pub async fn chat_provider(&self) -> Option<Arc<dyn ChatProvider>> {
199        self.chat_provider
200            .get_or_init(|| async {
201                let cfg = crate::web::load_user_config().unwrap_or_default();
202                if cfg.local_model.enabled {
203                    if let Some(mut p) =
204                        trusty_common::auto_detect_local_provider(&cfg.local_model.base_url).await
205                    {
206                        // auto_detect returns an empty model id; callers must
207                        // set the configured model name themselves.
208                        p.model = cfg.local_model.model.clone();
209                        return Some(Arc::new(p) as Arc<dyn ChatProvider>);
210                    }
211                }
212                if !cfg.openrouter_api_key.is_empty() {
213                    return Some(Arc::new(trusty_common::OpenRouterProvider::new(
214                        cfg.openrouter_api_key,
215                        cfg.openrouter_model,
216                    )) as Arc<dyn ChatProvider>);
217                }
218                None
219            })
220            .await
221            .clone()
222    }
223
224    pub async fn embedder(&self) -> Result<Arc<FastEmbedder>> {
225        let cell = self.embedder.clone();
226        let embedder = cell
227            .get_or_try_init(|| async {
228                let e = FastEmbedder::new().await?;
229                Ok::<Arc<FastEmbedder>, anyhow::Error>(Arc::new(e))
230            })
231            .await?
232            .clone();
233        Ok(embedder)
234    }
235}
236
237impl std::fmt::Debug for AppState {
238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        f.debug_struct("AppState")
240            .field("version", &self.version)
241            .field("data_root", &self.data_root)
242            .field("registry_len", &self.registry.len())
243            .finish()
244    }
245}
246
247/// Handle a single MCP JSON-RPC message and produce its response.
248///
249/// Why: Pulled out of the stdio loop so unit tests can drive every method
250/// without touching real stdin/stdout.
251/// What: Routes `initialize`, `tools/list`, `tools/call`, `ping`, and the
252/// `notifications/initialized` notification (which returns `Value::Null`).
253/// Test: See unit tests below — initialize/list/call all return expected
254/// JSON-RPC envelopes; notifications return `Null` (no response written).
255pub async fn handle_message(state: &AppState, msg: Value) -> Value {
256    let id = msg.get("id").cloned().unwrap_or(Value::Null);
257    let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or("");
258
259    match method {
260        "initialize" => {
261            let extra = state
262                .default_palace
263                .as_ref()
264                .map(|dp| json!({ "default_palace": dp }));
265            let result = initialize_response("trusty-memory", &state.version, extra);
266            json!({
267                "jsonrpc": "2.0",
268                "id": id,
269                "result": result,
270            })
271        }
272        // Notifications must NOT receive a response.
273        "notifications/initialized" | "notifications/cancelled" => Value::Null,
274        "tools/list" => json!({
275            "jsonrpc": "2.0",
276            "id": id,
277            "result": tools::tool_definitions_with(state.default_palace.is_some())
278        }),
279        // OpenRPC 1.3.2 discovery — see `openrpc.rs`. Returns the full
280        // service description so orchestrators (open-mpm, etc.) can
281        // introspect every tool and its required `memory.read`/`memory.write`
282        // scope without bespoke per-server adapters.
283        "rpc.discover" => json!({
284            "jsonrpc": "2.0",
285            "id": id,
286            "result": openrpc::build_discover_response(
287                &state.version,
288                state.default_palace.is_some(),
289            ),
290        }),
291        "tools/call" => {
292            let params = msg.get("params").cloned().unwrap_or_default();
293            let tool_name = params
294                .get("name")
295                .and_then(|n| n.as_str())
296                .unwrap_or("")
297                .to_string();
298            let args = params.get("arguments").cloned().unwrap_or_default();
299            match tools::dispatch_tool(state, &tool_name, args).await {
300                Ok(content) => json!({
301                    "jsonrpc": "2.0",
302                    "id": id,
303                    "result": {
304                        "content": [{"type": "text", "text": content.to_string()}]
305                    }
306                }),
307                Err(e) => json!({
308                    "jsonrpc": "2.0",
309                    "id": id,
310                    "error": {"code": -32603, "message": e.to_string()}
311                }),
312            }
313        }
314        "ping" => json!({"jsonrpc": "2.0", "id": id, "result": {}}),
315        _ => json!({
316            "jsonrpc": "2.0",
317            "id": id,
318            "error": {
319                "code": -32601,
320                "message": format!("Method not found: {method}")
321            }
322        }),
323    }
324}
325
326/// Run the MCP stdio JSON-RPC 2.0 server loop.
327///
328/// Why: Claude Code launches MCP servers as child processes and speaks
329/// JSON-RPC over stdin/stdout — this is the primary integration path.
330/// What: Delegates to `trusty_mcp_core::run_stdio_loop`, adapting each
331/// shared `Request` back into the JSON `Value` shape `handle_message`
332/// expects, and translating the returned `Value` into a `Response`.
333/// Notifications (where `handle_message` returns `Value::Null`) become
334/// suppressed responses so the loop emits nothing on the wire.
335/// Test: `handle_message` covers protocol behaviour in unit tests.
336pub async fn run_stdio(state: AppState) -> Result<()> {
337    info!("trusty-memory MCP stdio server starting");
338    let state = Arc::new(state);
339    trusty_common::mcp::run_stdio_loop(move |req: Request| {
340        let state = state.clone();
341        async move {
342            // Re-serialise the Request into the JSON shape handle_message expects.
343            // (handle_message predates the shared types and reads loose Values.)
344            let msg = json!({
345                "jsonrpc": req.jsonrpc.unwrap_or_else(|| "2.0".to_string()),
346                "id": req.id.clone().unwrap_or(Value::Null),
347                "method": req.method,
348                "params": req.params.unwrap_or(Value::Null),
349            });
350            let resp_value = handle_message(&state, msg).await;
351            // handle_message returns Value::Null for notifications.
352            if resp_value.is_null() {
353                return Response::suppressed();
354            }
355            // Otherwise it returns the full JSON-RPC envelope as a Value;
356            // re-encode into the shared Response struct so the loop can serialise.
357            let id = resp_value.get("id").cloned();
358            if let Some(result) = resp_value.get("result").cloned() {
359                Response::ok(id, result)
360            } else if let Some(err) = resp_value.get("error") {
361                let code =
362                    err.get("code")
363                        .and_then(|c| c.as_i64())
364                        .unwrap_or(error_codes::INTERNAL_ERROR as i64) as i32;
365                let message = err
366                    .get("message")
367                    .and_then(|m| m.as_str())
368                    .unwrap_or("internal error")
369                    .to_string();
370                Response::err(id, code, message)
371            } else {
372                Response::err(
373                    id,
374                    error_codes::INTERNAL_ERROR,
375                    "malformed handler response",
376                )
377            }
378        }
379    })
380    .await
381}
382
383/// Run the optional HTTP/SSE + web admin server.
384///
385/// Why: A long-running daemon mode lets non-stdio clients (browsers, curl,
386/// future remote agents) hit `/health`, the `/api/v1/*` REST surface, and the
387/// embedded admin SPA.
388/// What: axum router built from `web::router()` plus a `/sse` stub for the
389/// existing MCP-over-SSE clients. Caller provides a pre-bound listener so
390/// port auto-detection lives at the call site.
391/// Test: `cargo test -p trusty-memory-mcp web::tests` exercises the router
392/// shape; manual: `curl http://127.0.0.1:<port>/health` returns `ok`.
393pub async fn run_http_on(state: AppState, listener: tokio::net::TcpListener) -> Result<()> {
394    use axum::routing::get;
395
396    let app = web::router()
397        .route("/sse", get(sse_handler))
398        .with_state(state);
399
400    let local = listener.local_addr().ok();
401    if let Some(a) = local {
402        info!("HTTP server listening on http://{a}");
403        eprintln!("HTTP server listening on http://{a}");
404    }
405    axum::serve(listener, app).await?;
406    Ok(())
407}
408
409/// Convenience: bind `addr` and serve via [`run_http_on`].
410pub async fn run_http(state: AppState, addr: std::net::SocketAddr) -> Result<()> {
411    let listener = tokio::net::TcpListener::bind(addr).await?;
412    run_http_on(state, listener).await
413}
414
415/// Live SSE event stream — pushes `DaemonEvent` frames to dashboard clients.
416///
417/// Why: The dashboard subscribes once and reacts to live pushes (palace
418/// created, drawer added/deleted, dream completed, status changed) instead of
419/// polling `/api/v1/*` endpoints.
420/// What: Subscribes to `state.events`, emits an initial `connected` frame,
421/// then forwards every `DaemonEvent` as `data: <json>\n\n`. Lagged
422/// subscribers receive a `lag` frame indicating skipped events; channel
423/// closure ends the stream.
424/// Test: `web::tests::sse_stream_emits_palace_created` (covers subscribe +
425/// emit + receive); manual: `curl -N http://.../sse`.
426pub(crate) async fn sse_handler(
427    axum::extract::State(state): axum::extract::State<AppState>,
428) -> impl axum::response::IntoResponse {
429    use futures::StreamExt;
430    use tokio_stream::wrappers::BroadcastStream;
431
432    let rx = state.events.subscribe();
433    let initial = futures::stream::once(async {
434        Ok::<axum::body::Bytes, std::io::Error>(axum::body::Bytes::from(
435            "data: {\"type\":\"connected\"}\n\n",
436        ))
437    });
438    let events = BroadcastStream::new(rx).map(|res| {
439        let frame = match res {
440            Ok(event) => match serde_json::to_string(&event) {
441                Ok(json) => format!("data: {json}\n\n"),
442                Err(e) => format!("data: {{\"type\":\"error\",\"message\":\"{e}\"}}\n\n"),
443            },
444            Err(tokio_stream::wrappers::errors::BroadcastStreamRecvError::Lagged(n)) => {
445                format!("data: {{\"type\":\"lag\",\"skipped\":{n}}}\n\n")
446            }
447        };
448        Ok::<axum::body::Bytes, std::io::Error>(axum::body::Bytes::from(frame))
449    });
450    let stream = initial.chain(events);
451
452    axum::response::Response::builder()
453        .header("Content-Type", "text/event-stream")
454        .header("Cache-Control", "no-cache")
455        .header("X-Accel-Buffering", "no")
456        .body(axum::body::Body::from_stream(stream))
457        .expect("valid SSE response")
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    fn test_state() -> AppState {
465        let tmp = tempfile::tempdir().expect("tempdir");
466        let root = tmp.path().to_path_buf();
467        // Leak the tempdir so it lives for the test process; tests are short.
468        std::mem::forget(tmp);
469        AppState::new(root)
470    }
471
472    #[tokio::test]
473    async fn initialize_returns_protocol_version_and_capabilities() {
474        let state = test_state();
475        let req = json!({
476            "jsonrpc": "2.0",
477            "id": 1,
478            "method": "initialize",
479            "params": {
480                "protocolVersion": "2024-11-05",
481                "capabilities": {},
482                "clientInfo": {"name": "test", "version": "0"}
483            }
484        });
485        let resp = handle_message(&state, req).await;
486        assert_eq!(resp["jsonrpc"], "2.0");
487        assert_eq!(resp["id"], 1);
488        assert_eq!(resp["result"]["protocolVersion"], "2024-11-05");
489        assert!(resp["result"]["capabilities"]["tools"].is_object());
490        assert_eq!(resp["result"]["serverInfo"]["name"], "trusty-memory");
491    }
492
493    #[tokio::test]
494    async fn initialized_notification_returns_null() {
495        let state = test_state();
496        let req = json!({
497            "jsonrpc": "2.0",
498            "method": "notifications/initialized",
499            "params": {}
500        });
501        let resp = handle_message(&state, req).await;
502        assert!(resp.is_null());
503    }
504
505    #[tokio::test]
506    async fn tools_list_returns_all_tools() {
507        let state = test_state();
508        let req = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"});
509        let resp = handle_message(&state, req).await;
510        let tools = resp["result"]["tools"].as_array().expect("tools array");
511        assert_eq!(tools.len(), 12);
512    }
513
514    #[tokio::test]
515    async fn unknown_method_returns_error() {
516        let state = test_state();
517        let req = json!({"jsonrpc": "2.0", "id": 4, "method": "wat"});
518        let resp = handle_message(&state, req).await;
519        assert_eq!(resp["error"]["code"], -32601);
520    }
521
522    #[tokio::test]
523    async fn ping_returns_empty_result() {
524        let state = test_state();
525        let req = json!({"jsonrpc": "2.0", "id": 5, "method": "ping"});
526        let resp = handle_message(&state, req).await;
527        assert!(resp["result"].is_object());
528    }
529
530    #[tokio::test]
531    async fn app_state_default_constructs() {
532        let s = test_state();
533        assert!(!s.version.is_empty());
534        assert!(s.registry.is_empty());
535        assert!(s.default_palace.is_none());
536    }
537
538    /// Why: Issue #26 — when `serve --palace <name>` is set, the MCP server
539    /// must (a) report the default in the `initialize` `serverInfo`, (b)
540    /// drop `palace` from the required schema in `tools/list`, and (c) let
541    /// `tools/call` use the default when the caller omits `palace`.
542    /// Test: Construct an AppState with a default palace, create that palace
543    /// on disk via the registry, then call `memory_remember` without a
544    /// `palace` argument and confirm it resolves to the default.
545    #[tokio::test]
546    async fn default_palace_used_when_arg_omitted() {
547        let tmp = tempfile::tempdir().expect("tempdir");
548        let root = tmp.path().to_path_buf();
549
550        // Pre-create the default palace so remember has somewhere to land.
551        let registry = trusty_common::memory_core::PalaceRegistry::new();
552        let palace = trusty_common::memory_core::Palace {
553            id: trusty_common::memory_core::PalaceId::new("default-pal"),
554            name: "default-pal".to_string(),
555            description: None,
556            created_at: chrono::Utc::now(),
557            data_dir: root.join("default-pal"),
558        };
559        registry
560            .create_palace(&root, palace)
561            .expect("create_palace");
562
563        let state = AppState::new(root).with_default_palace(Some("default-pal".to_string()));
564
565        // (a) initialize advertises the default.
566        let init = handle_message(
567            &state,
568            json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"}),
569        )
570        .await;
571        assert_eq!(
572            init["result"]["serverInfo"]["default_palace"], "default-pal",
573            "initialize must echo default_palace in serverInfo"
574        );
575
576        // (b) tools/list drops `palace` from required when default is set.
577        let list = handle_message(
578            &state,
579            json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}),
580        )
581        .await;
582        let tools = list["result"]["tools"].as_array().expect("tools array");
583        let remember = tools
584            .iter()
585            .find(|t| t["name"] == "memory_remember")
586            .expect("memory_remember tool");
587        let required: Vec<&str> = remember["inputSchema"]["required"]
588            .as_array()
589            .expect("required array")
590            .iter()
591            .filter_map(|v| v.as_str())
592            .collect();
593        assert!(
594            !required.contains(&"palace"),
595            "palace must not be required when default is configured; got {required:?}"
596        );
597        assert!(required.contains(&"text"));
598
599        // (c) tools/call resolves the default when arg is omitted.
600        let call = handle_message(
601            &state,
602            json!({
603                "jsonrpc": "2.0",
604                "id": 3,
605                "method": "tools/call",
606                "params": {
607                    "name": "memory_remember",
608                    "arguments": {"text": "default-palace test memory"},
609                },
610            }),
611        )
612        .await;
613        // Successful dispatch returns `result.content[0].text` JSON.
614        let text = call["result"]["content"][0]["text"]
615            .as_str()
616            .unwrap_or_else(|| panic!("expected success result, got {call}"));
617        let parsed: Value = serde_json::from_str(text).expect("parse content json");
618        assert_eq!(parsed["palace"], "default-pal");
619        assert_eq!(parsed["status"], "stored");
620        assert!(parsed["drawer_id"].as_str().is_some());
621    }
622
623    /// Why: When no default is set, `tools/call` for a palace-bound tool
624    /// without a `palace` argument should error helpfully rather than panic.
625    #[tokio::test]
626    async fn missing_palace_without_default_errors() {
627        let state = test_state();
628        let resp = handle_message(
629            &state,
630            json!({
631                "jsonrpc": "2.0",
632                "id": 7,
633                "method": "tools/call",
634                "params": {
635                    "name": "memory_recall",
636                    "arguments": {"query": "anything"},
637                },
638            }),
639        )
640        .await;
641        assert_eq!(resp["error"]["code"], -32603);
642        let msg = resp["error"]["message"].as_str().unwrap_or("");
643        assert!(
644            msg.contains("missing 'palace'"),
645            "expected helpful error, got: {msg}"
646        );
647    }
648
649    /// Why: initialize without a default palace must omit `default_palace`
650    /// from `serverInfo` so clients can detect the unbound mode.
651    #[tokio::test]
652    async fn initialize_without_default_palace_omits_field() {
653        let state = test_state();
654        let init = handle_message(
655            &state,
656            json!({"jsonrpc": "2.0", "id": 1, "method": "initialize"}),
657        )
658        .await;
659        assert!(init["result"]["serverInfo"]["default_palace"].is_null());
660    }
661}