trusty-memory 0.28.0

MCP server (stdio + Unix socket) for trusty-memory
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
//! JSON-RPC 2.0 request/response envelopes and the transport-agnostic
//! dispatcher that routes a parsed method name onto the underlying
//! [`AppState`] handlers.
//!
//! Why: The daemon used to expose its tool surface exclusively through
//! axum-bound REST routes (browser UI, hook CLIs) and a broken stdio MCP
//! mode whose redb opens collided with the running HTTP daemon's
//! exclusive locks. The fix was to keep the daemon as the single
//! redb-owning process and expose its surface through a transport-agnostic
//! dispatcher any listener could drive.
//!
//! There is now exactly one listener (#6286, ADR-0032): the hardened Unix
//! socket in [`crate::transport::uds`], which mounts this function whole as its
//! `RpcFallback` rather than restating the table below. The stdio bridge
//! forwards to that same socket. `POST /rpc` — the axum route this module was
//! first written for — is gone with the rest of the HTTP surface, and the
//! "Unix domain socket for low-overhead local clients" this comment used to
//! anticipate is no longer a plan but the whole of it.
//! What:
//!   - [`JsonRpcRequest`] / [`JsonRpcResponse`] / [`JsonRpcError`]: the
//!     envelope types matching the JSON-RPC 2.0 spec
//!     (`{"jsonrpc": "2.0", "id": ..., "method": ..., "params": ...}` →
//!     `{"jsonrpc": "2.0", "id": ..., "result": ...}` or `error`).
//!   - [`dispatch`]: async function that consumes a request and an
//!     `AppState` and returns the response. It routes `initialize`,
//!     `notifications/initialized`, `ping`, `rpc.discover`, `tools/list`,
//!     `tools/call`, `palace_*`, `memory_*`, `kg_*`, `add_alias`,
//!     `discover_aliases`, `get_prompt_context`, `list_prompt_facts`,
//!     `remove_prompt_fact`, `memory_send_message`, and `hook_fired` to the
//!     existing handlers in [`crate::tools`] / `crate::lib`. Unknown
//!     methods return `-32601 Method not found`.
//!
//! Test: see `dispatch_palace_list_returns_empty_array_initially`,
//!     `dispatch_unknown_method_returns_method_not_found`,
//!     `dispatch_hook_fired_emits_activity`, and the transport-level
//!     integration tests in `transport::http` and `transport::uds`.

use crate::{ActivitySource, AppState, DaemonEvent, HookType, InjectionKind};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use trusty_mcp::initialize_response;

/// JSON-RPC 2.0 standard error codes used by [`dispatch`].
///
/// Why: the spec assigns specific integers to specific failure modes
/// (parse errors, invalid request, method not found, invalid params,
/// internal error). Centralising them as constants prevents drift
/// between transports.
/// Test: `dispatch_unknown_method_returns_method_not_found` asserts
/// the value of `METHOD_NOT_FOUND`.
pub mod error_codes {
    /// Invalid JSON was received (used by transport parsers, not
    /// [`super::dispatch`] which only sees already-parsed values).
    pub const PARSE_ERROR: i32 = -32700;
    /// The JSON sent is not a valid Request object.
    pub const INVALID_REQUEST: i32 = -32600;
    /// The method does not exist / is not available.
    pub const METHOD_NOT_FOUND: i32 = -32601;
    /// Invalid method parameter(s).
    pub const INVALID_PARAMS: i32 = -32602;
    /// Internal JSON-RPC error.
    pub const INTERNAL_ERROR: i32 = -32603;
}

/// JSON-RPC 2.0 request envelope.
///
/// Why: every transport (HTTP `POST /rpc`, UDS NDJSON, future stdio
/// without the bridge) speaks the same envelope shape, so the type lives
/// here and is reused by every parser.
/// What: serde-deserialised from the JSON wire format. `id` is optional
/// (notifications omit it); `params` is optional and defaults to
/// `Value::Null` on the dispatch path.
/// Test: `jsonrpc_request_round_trip`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcRequest {
    /// Protocol version. Always `"2.0"` for spec-compliant clients;
    /// `dispatch` does not enforce this so legacy clients that omit it
    /// still work.
    #[serde(default)]
    pub jsonrpc: Option<String>,
    /// Request identifier. Notifications (id absent or null) MUST NOT
    /// receive a response.
    #[serde(default)]
    pub id: Option<Value>,
    /// Method name (e.g. `palace_list`, `memory_recall`, `hook_fired`).
    pub method: String,
    /// Method arguments. Defaults to `Null` when omitted.
    #[serde(default)]
    pub params: Option<Value>,
}

/// JSON-RPC 2.0 response envelope.
///
/// Why: matches the spec so any compliant client (jsonrpc-client-rs,
/// jayson, hand-rolled `nc`-piped JSON) can talk to the daemon.
/// What: exactly one of `result` or `error` is present. `id` echoes the
/// request id (or is `Null` for parse errors that could not extract one).
/// Test: see helpers below — `ok()` / `err()` / `from_anyhow()`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcResponse {
    /// Always `"2.0"` for spec-compliant responses. Stored as
    /// owned `String` (rather than `&'static str`) so the type can be
    /// deserialised — borrowed-str fields would force every parsed
    /// response to share the lifetime of its source buffer.
    pub jsonrpc: String,
    pub id: Value,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub result: Option<Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<JsonRpcError>,
}

/// JSON-RPC 2.0 error object.
///
/// Why: standardised error shape lets transports surface the same
/// failure modes (parse error, method not found, invalid params, etc.)
/// without inventing per-transport encodings.
/// What: `code` follows the spec; `message` is human-readable; `data`
/// is optional structured detail.
/// Test: `dispatch_unknown_method_returns_method_not_found`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcError {
    pub code: i32,
    pub message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub data: Option<Value>,
}

impl JsonRpcResponse {
    /// Build a success response with the given result payload.
    ///
    /// Why: every dispatcher arm calls this on its happy path, so the
    /// envelope shape lives in one place.
    /// What: sets `jsonrpc = "2.0"`, `id` to the supplied value, and
    /// `result` to the payload.
    /// Test: covered by every successful dispatch test.
    pub fn ok(id: Value, result: Value) -> Self {
        Self {
            jsonrpc: "2.0".to_string(),
            id,
            result: Some(result),
            error: None,
        }
    }

    /// Build an error response.
    ///
    /// Why: every dispatcher error path calls this so wire format stays
    /// consistent across transports.
    /// What: sets `jsonrpc = "2.0"`, `id`, and the error code + message.
    /// Test: `dispatch_unknown_method_returns_method_not_found`.
    pub fn err(id: Value, code: i32, message: impl Into<String>) -> Self {
        Self {
            jsonrpc: "2.0".to_string(),
            id,
            result: None,
            error: Some(JsonRpcError {
                code,
                message: message.into(),
                data: None,
            }),
        }
    }

    /// Convert an `anyhow::Error` into a JSON-RPC internal-error response.
    ///
    /// Why: tool dispatch returns `anyhow::Result` (the existing
    /// `dispatch_tool` contract); the alternate `{:#}` format walks
    /// the full `Caused by:` chain so the wire surfaces actionable
    /// detail instead of just the outermost context.
    /// What: code = `INTERNAL_ERROR`, message = `format!("{e:#}")`.
    /// Test: covered indirectly when a tool call fails.
    pub fn from_anyhow(id: Value, e: anyhow::Error) -> Self {
        Self::err(id, error_codes::INTERNAL_ERROR, format!("{e:#}"))
    }
}

/// Methods that map directly onto [`crate::tools::dispatch_tool`].
///
/// Why: most of the surface (every `memory_*`, `palace_*`, `kg_*` tool)
/// is already implemented inside `dispatch_tool` with full validation
/// and creator-attribution wiring. Listing them here lets [`dispatch`]
/// forward in one match arm without duplicating the per-tool argument
/// parsing.
/// What: a sorted, exhaustive list of every method name routed through
/// `dispatch_tool`. Adding a tool requires extending this constant.
///
/// #6397: #149 wrote this constant and only targeted fixes ever extended it, so
/// every tool added afterwards that nobody patched in by hand was absent for no
/// reason — the room, wing, task, and dream surfaces, `kg_list_subjects`,
/// `kg_retract_triple`, and `palace_update`. They are forwarded here. What
/// stayed off the raw path, and why, is `tests::UNFORWARDED_TOOLS`.
///
/// Test: `dispatch_palace_list_returns_empty_array_initially` confirms
/// the forwarding works end-to-end;
/// `every_registered_tool_is_forwarded_or_listed_unforwarded` holds this
/// constant exhaustive over the tool registry.
const TOOL_METHODS: &[&str] = &[
    "add_alias",
    "console_metrics",
    "discover_aliases",
    "dream_consolidate_room",
    "get_prompt_context",
    "kg_assert",
    "kg_bootstrap",
    "kg_gaps",
    // #4776: the subject census that makes `kg_query` — forwarded since #149 —
    // usable without already knowing an exact subject string.
    "kg_list_subjects",
    "kg_query",
    "kg_retract_triple",
    "list_prompt_facts",
    "memory_forget",
    "memory_list",
    "memory_note",
    "memory_recall",
    "memory_recall_all",
    "memory_recall_deep",
    "memory_remember",
    "memory_send_message",
    "palace_compact",
    "palace_create",
    "palace_dream",
    // #5044: `tm memory import --refresh` calls `palace_verify_embedded` by raw
    // name and got -32601 — #6328 registered both tools but not here.
    "palace_embed_sweep",
    "palace_info",
    "palace_list",
    "palace_reembed",
    "palace_unalias",
    "palace_update",
    "palace_verify_embedded",
    "remove_prompt_fact",
    "room_create",
    "room_list",
    "room_rename",
    "task_add",
    "task_complete",
    "task_list",
    "wing_create",
    "wing_list",
    "wing_rename",
];

/// The protocol arms [`dispatch`] answers before it reaches [`TOOL_METHODS`].
///
/// Listed rather than only matched so [`method_names`] can report the whole
/// surface. `notifications/cancelled` shares an arm with
/// `notifications/initialized` and is counted separately here because a caller
/// may send either.
const PROTOCOL_METHODS: &[&str] = &[
    "initialize",
    "notifications/initialized",
    "notifications/cancelled",
    "ping",
    "rpc.discover",
    "tools/list",
    "tools/call",
    "hook_fired",
];

/// Every method name [`dispatch`] routes.
///
/// Why: the UDS router mounts this dispatcher as a catch-all, so
/// `RpcRouter::method_names` reports only the folded methods and cannot see
/// these — which is the point of the seam and also the reason nothing else
/// could say how large the other half is. `memory.health` and the start-up log
/// print this count, which turns "the fallback is mounted" from an assumption
/// into something an operator reads.
///
/// It counts NAMES this function routes, not tools reachable: the whole tool
/// registry sits behind the single `tools/call` envelope and is enumerated by
/// `tools/list`, whether or not [`TOOL_METHODS`] also spells it out.
///
/// Test: `method_names_covers_every_dispatched_arm`.
pub fn method_names() -> Vec<&'static str> {
    let mut names: Vec<&'static str> = PROTOCOL_METHODS.to_vec();
    names.extend_from_slice(TOOL_METHODS);
    names.sort_unstable();
    names
}

/// Hook-event payload (mirrors [`crate::hook_emit::HookEventPayload`]).
///
/// Why: parsed inline so the dispatcher does not need a special case
/// for hook events that already lives in the HTTP layer. Keeping the
/// shape identical to the HTTP `POST /api/v1/activity/hook` payload
/// lets the hook CLI helpers send the same JSON body to either
/// transport.
/// What: serde-deserialised from `params` on a `hook_fired` request.
/// Test: `dispatch_hook_fired_emits_activity`.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct HookFiredParams {
    #[serde(default)]
    palace_id: Option<String>,
    #[serde(default)]
    palace_name: Option<String>,
    hook_type: HookType,
    injection_kind: InjectionKind,
    #[serde(default)]
    injection_length: u64,
    #[serde(default)]
    trigger_prompt_excerpt: String,
    #[serde(default)]
    duration_ms: u64,
}

/// Dispatch a parsed JSON-RPC request against the daemon's shared state.
///
/// Why: this is the single transport-agnostic seam. HTTP, UDS, and any
/// future transport (gRPC, named pipes on Windows) all funnel through
/// this function. Concentrating the routing here means new tools
/// automatically light up on every transport.
/// What: routes by `req.method`. `initialize` returns the MCP capability
/// handshake required by Claude Code before it will mark the server as
/// connected. `notifications/initialized` and `notifications/cancelled`
/// are client-to-server notifications (no response per MCP spec). `ping`
/// returns `{}`. `rpc.discover` returns the OpenRPC document. `tools/list`
/// returns the MCP tool definitions. `tools/call` extracts `name` +
/// `arguments` from `params` (matching the MCP convention). Any method
/// listed in [`TOOL_METHODS`] is forwarded directly to
/// [`crate::tools::dispatch_tool`] with `params` as the argument object.
/// `hook_fired` emits a [`DaemonEvent::HookFired`] (the JSON-RPC equivalent
/// of `POST /api/v1/activity/hook`). Unknown methods return
/// [`error_codes::METHOD_NOT_FOUND`].
/// Test: see `dispatch_*` tests in this module, especially
/// `dispatch_initialize_returns_capabilities`.
pub async fn dispatch(state: &AppState, req: JsonRpcRequest) -> JsonRpcResponse {
    let id = req.id.clone().unwrap_or(Value::Null);
    let params = req.params.clone().unwrap_or(Value::Null);

    // Built-in protocol methods first — these never touch tool surface.
    match req.method.as_str() {
        // MCP lifecycle: Claude Code sends `initialize` first; without a
        // valid response the client marks the server as failed and refuses
        // to call any tools. `notifications/initialized` is a client
        // notification confirming it finished setup — per spec we MUST NOT
        // send a response (handled by the `is_notification` guard in the
        // UDS handler, but we also explicitly return Null here for safety).
        "initialize" => {
            let extra = state
                .default_palace
                .as_deref()
                .map(|p| json!({"default_palace": p}));
            let result = initialize_response("trusty-memory", &state.version, extra);
            return JsonRpcResponse::ok(id, result);
        }
        "notifications/initialized" | "notifications/cancelled" => {
            // Notifications must not receive a response (MCP spec §4.1).
            // Returning Null here is safe: the UDS handler suppresses the
            // response for any request whose id is absent or Null.
            return JsonRpcResponse::ok(Value::Null, Value::Null);
        }
        "ping" => return JsonRpcResponse::ok(id, json!({})),
        "rpc.discover" => {
            let result = crate::openrpc::build_discover_response(
                &state.version,
                state.default_palace.is_some(),
            );
            return JsonRpcResponse::ok(id, result);
        }
        "tools/list" => {
            let result = crate::tools::tool_definitions_with(state.default_palace.is_some());
            return JsonRpcResponse::ok(id, result);
        }
        "tools/call" => {
            // MCP-style `tools/call` request: params.name + params.arguments.
            let name = params
                .get("name")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            let args = params.get("arguments").cloned().unwrap_or(Value::Null);
            return match crate::tools::dispatch_tool(state, &name, args).await {
                Ok(content) => {
                    // MCP convention: wrap the tool result in a content[0].text
                    // block so MCP clients can render it as plain text.
                    let text = match &content {
                        Value::String(s) => s.clone(),
                        other => other.to_string(),
                    };
                    JsonRpcResponse::ok(id, json!({"content": [{"type": "text", "text": text}]}))
                }
                Err(e) => JsonRpcResponse::from_anyhow(id, e),
            };
        }
        "hook_fired" => return dispatch_hook_fired(state, id, params),
        _ => {}
    }

    // Direct tool dispatch: every entry in TOOL_METHODS is forwarded
    // straight into `tools::dispatch_tool` with `params` as the args
    // object. Lets a CLI / curl client call `palace_list` directly
    // without wrapping it in a `tools/call` envelope.
    if TOOL_METHODS.contains(&req.method.as_str()) {
        return match crate::tools::dispatch_tool(state, &req.method, params).await {
            Ok(result) => JsonRpcResponse::ok(id, result),
            Err(e) => JsonRpcResponse::from_anyhow(id, e),
        };
    }

    JsonRpcResponse::err(
        id,
        error_codes::METHOD_NOT_FOUND,
        format!("Method not found: {}", req.method),
    )
}

/// Handle the `hook_fired` method.
///
/// Why: previously the hook ingest path was a bespoke HTTP route
/// (`POST /api/v1/activity/hook`); promoting it to a first-class JSON-RPC
/// method lets the same flow work over UDS (preferred for local hook
/// CLIs because it avoids the TCP-handshake overhead) and HTTP `POST
/// /rpc`. The HTTP route is kept for backwards compatibility.
/// What: parses the params into a [`HookFiredParams`], constructs a
/// [`DaemonEvent::HookFired`] with `source = ActivitySource::Hook`, and
/// emits it via `state.emit`. Returns `{"status": "ok"}` on success or
/// an invalid-params error on bad input.
/// Test: `dispatch_hook_fired_emits_activity`.
fn dispatch_hook_fired(state: &AppState, id: Value, params: Value) -> JsonRpcResponse {
    let parsed: HookFiredParams = match serde_json::from_value(params) {
        Ok(p) => p,
        Err(e) => {
            return JsonRpcResponse::err(
                id,
                error_codes::INVALID_PARAMS,
                format!("hook_fired: invalid params: {e}"),
            );
        }
    };
    state.emit(DaemonEvent::HookFired {
        palace_id: parsed.palace_id,
        palace_name: parsed.palace_name,
        hook_type: parsed.hook_type,
        injection_kind: parsed.injection_kind,
        injection_length: parsed.injection_length,
        trigger_prompt_excerpt: parsed.trigger_prompt_excerpt,
        timestamp: chrono::Utc::now(),
        duration_ms: parsed.duration_ms,
        source: ActivitySource::Hook,
    });
    JsonRpcResponse::ok(id, json!({"status": "ok"}))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::AppState;
    use serde_json::json;

    /// Build an `AppState` rooted at a fresh tempdir; the tempdir is
    /// leaked so it outlives the test process (tests are short).
    fn test_state() -> AppState {
        let tmp = tempfile::tempdir().expect("tempdir");
        let root = tmp.path().to_path_buf();
        std::mem::forget(tmp);
        AppState::new(root)
    }

    /// Why: round-trips a request through serde to confirm the envelope
    /// shape matches the JSON-RPC 2.0 spec — `jsonrpc`, `id`, `method`,
    /// `params` all surface as expected.
    /// Test: serialise, deserialise, assert equality.
    #[test]
    fn jsonrpc_request_round_trip() {
        let req = JsonRpcRequest {
            jsonrpc: Some("2.0".to_string()),
            id: Some(json!(1)),
            method: "palace_list".to_string(),
            params: Some(json!({})),
        };
        let s = serde_json::to_string(&req).unwrap();
        let back: JsonRpcRequest = serde_json::from_str(&s).unwrap();
        assert_eq!(back.method, "palace_list");
        assert_eq!(back.id, Some(json!(1)));
    }

    /// Why: dispatching `palace_list` against a fresh `AppState` must
    /// return an empty array — the registry has no palaces yet but the
    /// call itself must succeed (not 404). Confirms the dispatcher
    /// reaches `tools::dispatch_tool`.
    /// What: build a state, dispatch, assert the response is `result`
    /// (not `error`) and the payload is an array.
    /// Test: this test.
    #[tokio::test]
    async fn dispatch_palace_list_returns_empty_array_initially() {
        let state = test_state();
        let req = JsonRpcRequest {
            jsonrpc: Some("2.0".to_string()),
            id: Some(json!(1)),
            method: "palace_list".to_string(),
            params: Some(json!({})),
        };
        let resp = dispatch(&state, req).await;
        assert!(resp.error.is_none(), "expected ok, got {:?}", resp.error);
        let result = resp.result.expect("result");
        // `palace_list` returns `{"palaces": [...]}`.
        let palaces = result["palaces"]
            .as_array()
            .expect("result.palaces must be an array");
        assert!(palaces.is_empty(), "fresh state must list zero palaces");
    }

    /// Why: `method_names` is the only report of how large the surface behind
    /// the UDS router's fallback is, and nothing else can see it —
    /// `RpcRouter::method_names` covers the folded methods only. A name that
    /// `dispatch` routes but this function omits would be invisible to
    /// `memory.health`, to the start-up log, and to any consumer counting the
    /// surface. This walks the protocol arms and asserts each one is claimed.
    /// What: dispatches every listed protocol name against a fresh state and
    /// asserts none answers `METHOD_NOT_FOUND` — which is what a name the match
    /// does not handle would return.
    /// Test: itself.
    #[tokio::test]
    async fn method_names_covers_every_dispatched_arm() {
        let state = test_state();
        for method in PROTOCOL_METHODS {
            let req = JsonRpcRequest {
                jsonrpc: Some("2.0".to_string()),
                id: Some(json!(1)),
                // `hook_fired` is the one arm that validates its params, and it
                // is reached whether or not they are valid — an invalid-params
                // answer still proves the name is routed.
                method: (*method).to_string(),
                params: Some(json!({})),
            };
            let resp = dispatch(&state, req).await;
            if let Some(error) = resp.error {
                assert_ne!(
                    error.code,
                    error_codes::METHOD_NOT_FOUND,
                    "{method} is listed in PROTOCOL_METHODS but dispatch does not route it"
                );
            }
        }

        let names = method_names();
        assert!(
            names.windows(2).all(|w| w[0] <= w[1]),
            "method_names must be sorted so two reads compare equal: {names:?}"
        );
        assert_eq!(
            names.len(),
            PROTOCOL_METHODS.len() + TOOL_METHODS.len(),
            "every protocol arm and every tool method, and nothing twice"
        );
    }

    /// Registry tools the raw-JSON-RPC path does not forward, and why not.
    ///
    /// Why: `TOOL_METHODS` is an allowlist, so a tool added to the registry and
    /// nowhere else is unreachable by raw name with nothing saying so — #5044,
    /// where `tm memory import --refresh` got -32601 for
    /// `palace_verify_embedded` while `tools/call` reached the same tool. Paired
    /// with `TOOL_METHODS` this list is exhaustive over the registry, so a new
    /// tool has to be entered in one of the two.
    ///
    /// What: names `tools/list` publishes and `TOOL_METHODS` omits. #6397
    /// classified all twenty-three rows this list once held: fourteen carried no
    /// reason beyond `TOOL_METHODS` never being extended and are now forwarded,
    /// and every row left here states why it stays. A row is a reason, not a
    /// record that the omission was noticed.
    ///
    /// A row is about the SPELLING of the surface, never about authority:
    /// `tools/call` already reaches every registered tool over the same socket,
    /// so forwarding a name adds a second way to say it and no new capability.
    ///
    /// Test: `every_registered_tool_is_forwarded_or_listed_unforwarded`,
    /// `dispatch_refuses_the_deliberately_unforwarded_tools_by_raw_name`.
    const UNFORWARDED_TOOLS: &[&str] = &[
        "chat_asset_capabilities",
        "chat_asset_put",
        "chat_asset_get",
        // Contract-locked to the `tools/call` envelope.
        // `uds_consumer_contract.rs`'s
        // `shared_client_reaches_a_chat_tool_through_the_tools_call_envelope`
        // asserts a direct `chat_session_create` answers METHOD_NOT_FOUND, and
        // both consumers send the envelope: trusty-agents' `persona_memory` and
        // trusty-code's `memory_sink`. Forwarding any of these fails that test.
        "chat_session_add_turn",
        "chat_session_create",
        "chat_session_delete",
        "chat_session_get",
        "chat_session_list",
        "chat_session_recall",
        "chat_turn_append",
        // #6397, held for the owner: whole-palace teardown. Its one caller,
        // trusty-console's `delete_palace_on_socket`, sends `tools/call` and
        // documents the bare name as unrouted in three doc comments. Open
        // question: does deleting a palace want a second, shorter spelling?
        "palace_delete",
        // #6397, held for the owner: not a memory operation — `upgrade`
        // downloads a build, installs it, and restarts the daemon mid-
        // connection. trusty-console's search proxy refuses the sibling
        // daemon's `upgrade` as an unmapped path (`search_uds/map.rs`). Open
        // question: should a raw-RPC name restart the daemon?
        "upgrade",
    ];

    /// Why: #5044. #6328 registered `palace_verify_embedded` and
    /// `palace_embed_sweep` in `tools/list` but left `TOOL_METHODS` alone, so
    /// every raw-name caller of either got `Method not found (-32601)` from a
    /// daemon whose `tools/call` path served them fine. Nothing compared the two
    /// surfaces, so the drift only showed up in a live run. This compares them.
    /// What: partitions every name `tool_definitions` publishes across
    /// `TOOL_METHODS` and `UNFORWARDED_TOOLS`, and fails on a name in neither,
    /// a name in both, or a row in either naming a tool the registry no longer
    /// has.
    /// Test: this is the test.
    #[test]
    fn every_registered_tool_is_forwarded_or_listed_unforwarded() {
        let defs = crate::tools::tool_definitions();
        let registered: Vec<&str> = defs["tools"]
            .as_array()
            .expect("tools/list payload carries a `tools` array")
            .iter()
            .map(|tool| tool["name"].as_str().expect("every tool has a name"))
            .collect();
        assert!(
            !registered.is_empty(),
            "an empty registry would make this test vacuous"
        );

        let unclassified: Vec<&&str> = registered
            .iter()
            .filter(|name| !TOOL_METHODS.contains(*name) && !UNFORWARDED_TOOLS.contains(*name))
            .collect();
        assert!(
            unclassified.is_empty(),
            "registered but in neither list, so raw-name callers get -32601 with \
             nothing saying so (#5044): {unclassified:?} — add each to TOOL_METHODS \
             to forward it, or to UNFORWARDED_TOOLS to record that it is not"
        );

        let both: Vec<&&str> = TOOL_METHODS
            .iter()
            .filter(|name| UNFORWARDED_TOOLS.contains(*name))
            .collect();
        assert!(
            both.is_empty(),
            "listed as forwarded and unforwarded: {both:?}"
        );

        let stale: Vec<&&str> = TOOL_METHODS
            .iter()
            .chain(UNFORWARDED_TOOLS)
            .filter(|name| !registered.contains(*name))
            .collect();
        assert!(
            stale.is_empty(),
            "named here but absent from the registry, so the row can no longer \
             be dispatched: {stale:?}"
        );
    }

    /// Why: #5044's live symptom, at the seam that produced it —
    /// `trusty_common::memory_rpc::call_memory_tool_at` sends the bare tool name
    /// as the JSON-RPC method, which is the path `tm memory import --refresh`
    /// takes. `every_registered_tool_is_forwarded_or_listed_unforwarded` guards
    /// the constant; this proves the two names actually reach `dispatch_tool`.
    /// What: dispatches both by raw name against a fresh state and asserts
    /// neither answers `METHOD_NOT_FOUND`. An argument-level error still proves
    /// the routing, exactly as in `method_names_covers_every_dispatched_arm`.
    /// Test: this is the test.
    #[tokio::test]
    async fn dispatch_forwards_the_embed_audit_tools_by_raw_name() {
        let state = test_state();
        for method in ["palace_verify_embedded", "palace_embed_sweep"] {
            let req = JsonRpcRequest {
                jsonrpc: Some("2.0".to_string()),
                id: Some(json!(1)),
                method: method.to_string(),
                params: Some(json!({})),
            };
            let resp = dispatch(&state, req).await;
            if let Some(error) = resp.error {
                assert_ne!(
                    error.code,
                    error_codes::METHOD_NOT_FOUND,
                    "{method} is registered in tools/list but the raw-name path \
                     refuses it: {}",
                    error.message
                );
            }
        }
    }

    /// Why: #6397 moved fourteen names out of `UNFORWARDED_TOOLS` and into
    /// `TOOL_METHODS`. The partition test guards the constant's SHAPE and would
    /// pass just as well if `dispatch` never reached `dispatch_tool` for any of
    /// them — #5044's symptom was exactly a name `tools/list` published and the
    /// raw path refused.
    /// What: dispatches each newly-forwarded name by raw name against a fresh
    /// state and asserts none answers `METHOD_NOT_FOUND`. Empty params make each
    /// handler fail its own palace or argument resolution first, so the routing
    /// is proven without any of them writing.
    /// Test: this is the test.
    #[tokio::test]
    async fn dispatch_forwards_the_reclassified_tools_by_raw_name() {
        let state = test_state();
        for method in [
            "dream_consolidate_room",
            "kg_list_subjects",
            "kg_retract_triple",
            "palace_dream",
            "palace_update",
            "room_create",
            "room_list",
            "room_rename",
            "task_add",
            "task_complete",
            "task_list",
            "wing_create",
            "wing_list",
            "wing_rename",
        ] {
            let req = JsonRpcRequest {
                jsonrpc: Some("2.0".to_string()),
                id: Some(json!(1)),
                method: method.to_string(),
                params: Some(json!({})),
            };
            let resp = dispatch(&state, req).await;
            if let Some(error) = resp.error {
                assert_ne!(
                    error.code,
                    error_codes::METHOD_NOT_FOUND,
                    "{method} is in TOOL_METHODS but the raw-name path refuses \
                     it: {}",
                    error.message
                );
            }
        }
    }

    /// Why: the two rows #6397 left in `UNFORWARDED_TOOLS` for the owner are a
    /// decision, and a decision nothing asserts is indistinguishable from the
    /// drift the issue set out to close. trusty-console's `memory_rpc` module
    /// and `delete_palace_on_socket` both document `palace_delete` as unrouted
    /// by bare name and send `tools/call` because of it, in a crate with no
    /// Cargo edge on this one.
    /// What: dispatches both by raw name and asserts each answers
    /// `METHOD_NOT_FOUND`. Forwarding either one has to update this test, which
    /// is where the open question is written down. `chat_*` is covered instead
    /// by `uds_consumer_contract.rs` over a real socket.
    /// Test: this is the test.
    #[tokio::test]
    async fn dispatch_refuses_the_deliberately_unforwarded_tools_by_raw_name() {
        let state = test_state();
        for method in ["palace_delete", "upgrade"] {
            let req = JsonRpcRequest {
                jsonrpc: Some("2.0".to_string()),
                id: Some(json!(1)),
                method: method.to_string(),
                params: Some(json!({})),
            };
            let resp = dispatch(&state, req).await;
            let error = resp
                .error
                .unwrap_or_else(|| panic!("{method} must not be dispatchable by raw name"));
            assert_eq!(
                error.code,
                error_codes::METHOD_NOT_FOUND,
                "{method} is held off the raw path on purpose (#6397); \
                 forwarding it is an owner decision, not a drift fix"
            );
        }
    }

    /// Why: spec compliance — unknown methods must return -32601.
    #[tokio::test]
    async fn dispatch_unknown_method_returns_method_not_found() {
        let state = test_state();
        let req = JsonRpcRequest {
            jsonrpc: Some("2.0".to_string()),
            id: Some(json!(7)),
            method: "definitely_not_a_real_method".to_string(),
            params: None,
        };
        let resp = dispatch(&state, req).await;
        assert!(resp.result.is_none());
        let err = resp.error.expect("error");
        assert_eq!(err.code, error_codes::METHOD_NOT_FOUND);
        assert!(err.message.contains("definitely_not_a_real_method"));
    }

    /// Why: `initialize` is the first method Claude Code sends over the UDS/
    /// bridge path. Without a valid response the MCP client marks the server
    /// as failed. This confirms the dispatcher routes `initialize` to
    /// `trusty_mcp::initialize_response` and returns the MCP
    /// capability shape Claude Code expects.
    /// What: dispatch an `initialize` request, assert the result carries
    /// `protocolVersion`, `capabilities.tools`, and `serverInfo.name`.
    /// Test: this test.
    #[tokio::test]
    async fn dispatch_initialize_returns_capabilities() {
        let state = test_state();
        let req = JsonRpcRequest {
            jsonrpc: Some("2.0".to_string()),
            id: Some(json!(1)),
            method: "initialize".to_string(),
            params: Some(json!({
                "protocolVersion": "2024-11-05",
                "capabilities": {},
                "clientInfo": {"name": "test", "version": "1.0"}
            })),
        };
        let resp = dispatch(&state, req).await;
        assert!(
            resp.error.is_none(),
            "initialize must not error: {:?}",
            resp.error
        );
        let result = resp.result.expect("result");
        assert_eq!(
            result["protocolVersion"], "2024-11-05",
            "must echo the negotiated protocol version"
        );
        assert!(
            result["capabilities"]["tools"].is_object(),
            "must advertise tools capability"
        );
        assert_eq!(
            result["serverInfo"]["name"], "trusty-memory",
            "serverInfo.name must be trusty-memory"
        );
    }

    /// Why: ping is a protocol-level keepalive; must return `{}` and
    /// echo the id.
    #[tokio::test]
    async fn dispatch_ping_returns_empty_object() {
        let state = test_state();
        let req = JsonRpcRequest {
            jsonrpc: Some("2.0".to_string()),
            id: Some(json!(42)),
            method: "ping".to_string(),
            params: None,
        };
        let resp = dispatch(&state, req).await;
        assert_eq!(resp.id, json!(42));
        assert_eq!(resp.result, Some(json!({})));
    }

    /// Why: `tools/list` must work via the new dispatcher (parity with
    /// the existing stdio MCP handler).
    #[tokio::test]
    async fn dispatch_tools_list_returns_tool_array() {
        let state = test_state();
        let req = JsonRpcRequest {
            jsonrpc: Some("2.0".to_string()),
            id: Some(json!(2)),
            method: "tools/list".to_string(),
            params: None,
        };
        let resp = dispatch(&state, req).await;
        let result = resp.result.expect("result");
        let tools = result["tools"].as_array().expect("tools array");
        assert!(!tools.is_empty());
    }

    /// Why: PR #144's `POST /api/v1/activity/hook` route is replaced by
    /// the `hook_fired` JSON-RPC method. A successful dispatch must
    /// append a row to the activity log (the persistence side-effect
    /// observable from outside the dispatcher).
    /// What: dispatch one `hook_fired` request, then count the activity
    /// log rows.
    /// Test: this test.
    #[tokio::test]
    async fn dispatch_hook_fired_emits_activity() {
        let state = test_state();
        let req = JsonRpcRequest {
            jsonrpc: Some("2.0".to_string()),
            id: Some(json!(3)),
            method: "hook_fired".to_string(),
            params: Some(json!({
                "palace_id": "p",
                "palace_name": "p",
                "hook_type": "UserPromptSubmit",
                "injection_kind": "prompt-context",
                "injection_length": 100,
                "trigger_prompt_excerpt": "test",
                "duration_ms": 5,
            })),
        };
        let resp = dispatch(&state, req).await;
        assert!(resp.error.is_none(), "expected ok, got {:?}", resp.error);
        // Issue #232: `emit` now fire-and-forgets the redb append on the
        // blocking pool; flush before observing the persisted count.
        state.flush_activity_writes().await;
        let count = state.activity_log.count().unwrap();
        assert_eq!(count, 1, "hook_fired must persist one activity row");
    }

    /// Why: malformed `hook_fired` params must return invalid-params
    /// rather than panicking or emitting a half-formed event.
    #[tokio::test]
    async fn dispatch_hook_fired_invalid_params_errors() {
        let state = test_state();
        let req = JsonRpcRequest {
            jsonrpc: Some("2.0".to_string()),
            id: Some(json!(4)),
            method: "hook_fired".to_string(),
            params: Some(json!({"wrong": "shape"})),
        };
        let resp = dispatch(&state, req).await;
        let err = resp.error.expect("error");
        assert_eq!(err.code, error_codes::INVALID_PARAMS);
    }
}