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