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}