Skip to main content

ai_memory/mcp/tools/
capabilities.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! MCP `memory_capabilities` handlers, CapabilitiesAccept, and capability-summary helpers.
5
6use crate::config::{RerankerMode, ResolvedModels, TierConfig};
7use crate::db;
8use crate::mcp::param_names;
9use crate::mcp::registry::McpTool;
10use crate::reranker::BatchedReranker;
11use schemars::JsonSchema;
12use serde::Deserialize;
13use serde_json::Value;
14
15// --- D1.1 (#982) PoC: per-tool descriptor for `memory_capabilities` ---
16
17/// v0.7.0 #972 D1.1 (#982) — per-tool request body for
18/// `memory_capabilities`. Source of truth for the wire schema; the
19/// schemars-derived shape replaces the hand-coded entry in
20/// [`crate::mcp::registry::tool_definitions`].
21///
22/// **Fix as a side effect of D1.1:** the legacy hand-coded schema
23/// reported `accept: enum ["v1","v2"]` (default `"v2"`), but
24/// [`CapabilitiesAccept`] has been `V1`/`V2`/`V3` since the v0.7.0 A5
25/// release (with `V3` as the actual default). The schemars derive
26/// from this struct will surface `accept` as an optional string
27/// (no enum constraint at this layer — the runtime
28/// [`CapabilitiesAccept::parse`] tolerates any input and falls back
29/// to V3). That removes the schema/runtime drift without forcing
30/// breaking-change semantics on existing v1/v2-pinned clients.
31#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
32#[allow(dead_code)] // D1.1 PoC: struct is the schemars source; handler still parses Value directly until D1.3.
33pub struct CapabilitiesRequest {
34    /// Schema version. v3 default (A5); v2/v1 legacy.
35    #[serde(default)]
36    pub accept: Option<String>,
37
38    // The accepted value set (`core` / `lifecycle` / `graph` /
39    // `governance` / `power` / `meta` / `archive` / `other`) lives in
40    // the long-form `docs` field on `CapabilitiesTool` so the wire
41    // `description` here stays byte-identical to the legacy hand-coded
42    // entry for D1.2 (#983) parity. Schemars 0.8 derives `description`
43    // from the WHOLE doc comment (concatenated with `\n\n`), so any
44    // prose beyond the first sentence would break the parity test.
45    /// Drill into one family.
46    #[serde(default)]
47    pub family: Option<String>,
48
49    /// Return full tool schemas. Requires family.
50    #[serde(default)]
51    pub include_schema: Option<bool>,
52
53    /// C2/C4: preserve docs + every optional inputSchema property.
54    #[serde(default)]
55    pub verbose: Option<bool>,
56}
57
58/// v0.7.0 #972 D1.1 (#982) — zero-sized type implementing [`McpTool`]
59/// for `memory_capabilities`. The trait impl returns the
60/// schemars-derived input_schema; downstream D1.6 (#987) will collapse
61/// the giant `tool_definitions` macro to iterate over `McpTool` impls
62/// like this one. The `dead_code` allow comes off in D1.6 when the
63/// type is registered into `registered_tools()`.
64#[allow(dead_code)]
65pub struct CapabilitiesTool;
66
67impl McpTool for CapabilitiesTool {
68    fn name() -> &'static str {
69        crate::mcp::registry::tool_names::MEMORY_CAPABILITIES
70    }
71
72    fn description() -> &'static str {
73        "Discover runtime capabilities; family=<name> drills in."
74    }
75
76    fn docs() -> &'static str {
77        "Caps-v3: tier, profile, summary, callable_now, agent_permitted_families, harness detection. \
78         family+include_schema drills one family. verbose=true restores full schema. \
79         NOTE per #864: `family` here = MCP tool-family (8 groups: \
80         core/lifecycle/graph/governance/power/meta/archive/other), NOT memory_kind taxonomy."
81    }
82
83    fn input_schema() -> Value {
84        // Use schemars 0.8's `schema_for!` to derive the schema from the
85        // `CapabilitiesRequest` struct, then convert to `serde_json::Value`.
86        crate::mcp::registry::input_schema_for::<CapabilitiesRequest>()
87    }
88
89    fn family() -> &'static str {
90        crate::profile::Family::Meta.name()
91    }
92}
93
94#[cfg(test)]
95mod d1_1_982_tests {
96    use super::*;
97
98    #[test]
99    fn capabilities_tool_metadata_982() {
100        assert_eq!(CapabilitiesTool::name(), "memory_capabilities");
101        assert_eq!(CapabilitiesTool::family(), "meta");
102        assert!(CapabilitiesTool::description().contains("capabilities"));
103        assert!(CapabilitiesTool::docs().contains("family"));
104    }
105
106    #[test]
107    fn capabilities_input_schema_has_expected_fields_982() {
108        let schema = CapabilitiesTool::input_schema();
109        // schemars 0.8 emits the schema under either top-level
110        // `properties` or under `$ref`-resolved nesting, depending on
111        // version. Probe both shapes to stay version-tolerant.
112        let direct = schema.get("properties").and_then(Value::as_object);
113        let nested = schema
114            .pointer("/definitions/CapabilitiesRequest/properties")
115            .and_then(Value::as_object);
116        let props = direct
117            .or(nested)
118            .expect("schemars must emit properties under direct or definitions path");
119        for field in &["accept", "family", "include_schema", "verbose"] {
120            assert!(
121                props.contains_key(*field),
122                "schemars-derived schema must include `{field}` (got keys: {:?})",
123                props.keys().collect::<Vec<_>>()
124            );
125        }
126    }
127
128    #[test]
129    fn capabilities_request_deserializes_empty_982() {
130        let parsed: CapabilitiesRequest = serde_json::from_value(serde_json::json!({})).unwrap();
131        assert!(parsed.accept.is_none());
132        assert!(parsed.family.is_none());
133        assert!(parsed.include_schema.is_none());
134        assert!(parsed.verbose.is_none());
135    }
136
137    #[test]
138    fn capabilities_request_deserializes_full_982() {
139        let parsed: CapabilitiesRequest = serde_json::from_value(serde_json::json!({
140            "accept": "v3",
141            "family": "core",
142            "include_schema": true,
143            "verbose": false
144        }))
145        .unwrap();
146        assert_eq!(parsed.accept.as_deref(), Some("v3"));
147        assert_eq!(parsed.family.as_deref(), Some("core"));
148        assert_eq!(parsed.include_schema, Some(true));
149        assert_eq!(parsed.verbose, Some(false));
150    }
151}
152
153/// Capabilities schema selector (v0.6.3.1 P1 honesty patch; extended
154/// through v0.7.0 A1–A5).
155///
156/// HTTP callers send `Accept-Capabilities: v1`/`v2`/`v3` to request a
157/// shape; MCP callers pass `accept: "v1"`/`"v2"`/`"v3"` to
158/// `memory_capabilities`. **As of v0.7.0 A5, the default is v3.** v2
159/// stays supported indefinitely for backward compat — clients that
160/// pin v2 explicitly continue to get the v2 shape unchanged.
161///
162/// v3 carries pre-computed calibration fields stacked from the A1–A4
163/// increments (top-level `summary` from A1; `to_describe_to_user`
164/// from A2; per-tool `tools[].callable_now` from A3;
165/// `agent_permitted_families` from A4). v3 is **additive** over v2 —
166/// no v2 fields are removed or retyped — so v0.6.4 SDK clients
167/// reading v3 by name still resolve every field they used to. The
168/// `schema_version` discriminator does change from `"2"` to `"3"`,
169/// which is why clients that strict-equality-asserted on it must
170/// either relax that or pin `accept="v2"` explicitly.
171///
172/// v3 requires the live `Profile` (and optionally `McpConfig` +
173/// `agent_id`) for the new pre-computed fields, so callers that opt
174/// in must reach for [`handle_capabilities_with_conn_v3`] instead of
175/// the v1/v2 entry point.
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum CapabilitiesAccept {
178    V1,
179    V2,
180    /// v0.7.0 A1–A4 — additive on top of v2: `summary`,
181    /// `to_describe_to_user`, per-tool `tools[].callable_now`,
182    /// optional `agent_permitted_families`. **Default since A5.**
183    V3,
184}
185
186impl CapabilitiesAccept {
187    /// Parse the wire value sent by the client. Unknown / missing
188    /// values fall back to v3 (the default since v0.7.0 A5).
189    /// Whitespace and case insensitive. Explicit `"v2"`/`"2"` still
190    /// returns `V2`; explicit `"v1"`/`"1"` still returns `V1`.
191    #[must_use]
192    pub fn parse(s: &str) -> Self {
193        match s.trim().to_ascii_lowercase().as_str() {
194            "v1" | "1" => Self::V1,
195            "v2" | "2" => Self::V2,
196            // v0.7.0 A5 — unknown / missing default flips from V2 → V3.
197            // Explicit `"v2"` above keeps the v2 wire shape for clients
198            // that pin it; everyone else gets v3 (additive over v2).
199            _ => Self::V3,
200        }
201    }
202}
203
204/// v0.6.3 (capabilities schema v2 / P1 honesty patch): the canonical
205/// capabilities entry point.
206///
207/// **Live overlays.** When the wrapper has access to the corresponding
208/// runtime handle, it overlays:
209/// - `features.embedder_loaded` from `embedder_loaded`,
210/// - `features.recall_mode_active` from `embedder_loaded` (loaded ⇒
211///   `Hybrid`; not loaded but configured ⇒ `KeywordOnly`; configured
212///   but failed ⇒ `Degraded`; tier == keyword ⇒ `Disabled`),
213/// - `features.reranker_active` from the `CrossEncoder` enum variant
214///   (`Neural` / `LexicalFallback` / `Off`),
215/// - `features.cross_encoder_reranking` flips to `false` when the
216///   neural reranker fell back to lexical (the v1 honesty fix #93),
217/// - `models.cross_encoder` annotated with `lexical-fallback` when the
218///   neural download failed.
219///
220/// **Live DB counts.** When `conn` is `Some`, the dynamic blocks
221/// (`permissions.active_rules`, `hooks.registered_count`,
222/// `approval.pending_requests`) are populated from live counts. DB
223/// errors are non-fatal — the report falls back to zero-state so a
224/// transient blip cannot 500 the capabilities endpoint.
225///
226/// **Schema selection.** `accept` controls the wire shape. As of
227/// v0.7.0 A5 the default is `V3` (#1645); `V2` and `V1` remain
228/// negotiable for pinned clients (`V1` projects the v2 report down to
229/// the legacy shape — see [`Capabilities::to_v1`]).
230pub fn handle_capabilities_with_conn(
231    tier_config: &TierConfig,
232    resolved_models: &ResolvedModels,
233    reranker: Option<&BatchedReranker>,
234    embedder_loaded: bool,
235    conn: Option<&rusqlite::Connection>,
236    accept: CapabilitiesAccept,
237) -> Result<Value, String> {
238    let caps = build_capabilities_overlay(
239        tier_config,
240        resolved_models,
241        reranker,
242        embedder_loaded,
243        conn,
244    );
245
246    // --- Schema selection ---
247    match accept {
248        CapabilitiesAccept::V2 => serde_json::to_value(caps).map_err(|e| e.to_string()),
249        CapabilitiesAccept::V1 => serde_json::to_value(caps.to_v1()).map_err(|e| e.to_string()),
250        CapabilitiesAccept::V3 => Err(
251            "capabilities v3 requires profile context — call handle_capabilities_with_conn_v3"
252                .to_string(),
253        ),
254    }
255}
256
257/// v0.7.0 A1 — the v3-shaped capabilities entry point.
258///
259/// Same overlay logic as [`handle_capabilities_with_conn`] (factored
260/// into [`build_capabilities_overlay`]); additionally computes the
261/// top-level `summary` string from the live `profile` state so the
262/// LLM gets a pre-computed, plain-language description of its
263/// operational tool surface (loaded count, total count, the three
264/// named recovery paths for unloaded families).
265///
266/// HTTP callers reach this path through `Accept-Capabilities: v3`;
267/// MCP callers via `accept: "v3"`. The HTTP wire-up is deferred until
268/// A5 (which flips the default and threads the profile through
269/// `AppState`); A1 lights up the MCP dispatch path only.
270pub fn handle_capabilities_with_conn_v3(
271    tier_config: &TierConfig,
272    resolved_models: &ResolvedModels,
273    reranker: Option<&BatchedReranker>,
274    embedder_loaded: bool,
275    conn: Option<&rusqlite::Connection>,
276    profile: &crate::profile::Profile,
277    mcp_config: Option<&crate::config::McpConfig>,
278    agent_id: Option<&str>,
279    // v0.7.0 B4 — the harness detected from `initialize.clientInfo.name`
280    // at MCP handshake time. `None` when no handshake has happened
281    // (HTTP callers, or a malformed MCP session that issued
282    // `memory_capabilities` before `initialize`); the resulting
283    // `your_harness_supports_deferred_registration` field is omitted
284    // from the wire via `skip_serializing_if = Option::is_none`.
285    harness: Option<&crate::harness::Harness>,
286) -> Result<Value, String> {
287    let caps = build_capabilities_overlay(
288        tier_config,
289        resolved_models,
290        reranker,
291        embedder_loaded,
292        conn,
293    );
294    let summary = build_capabilities_summary(profile);
295    let describe = build_capabilities_describe_to_user(profile);
296    let tools = build_capabilities_tools(profile, mcp_config, agent_id);
297    let permitted = build_agent_permitted_families(mcp_config, agent_id);
298    // B4 — present only when we know the harness; otherwise omit so
299    // unaware callers and HTTP callers see no schema drift.
300    let deferred = harness.map(crate::harness::Harness::supports_deferred_registration);
301    let mut value = serde_json::to_value(caps.to_v3(summary, describe, tools, permitted, deferred))
302        .map_err(|e| e.to_string())?;
303    // v0.7.0 (issue #691) — substrate-level agent-action rules engine
304    // surface. Stamps two top-level keys onto the `governance` object
305    // in the v3 capabilities payload. Operator UI can inspect these
306    // without inferring from tool registration order.
307    //
308    // `agent_action_check` is the honest enforcement label:
309    //   "substrate-authoritative-for-internal-ops" — substrate
310    //   gates are mechanical at the K9 write path; agent-external
311    //   ops are harness-mediated (PreToolUse hook calls
312    //   memory_check_agent_action).
313    //
314    // `rules_immutable_seed` reflects the seed-rules-at-enabled=0
315    // posture per design revision 2026-05-13.
316    if let Some(obj) = value.as_object_mut() {
317        let gov = obj
318            .entry("governance".to_string())
319            .or_insert_with(|| serde_json::json!({}));
320        if let Some(gov_obj) = gov.as_object_mut() {
321            gov_obj.insert(
322                "agent_action_check".to_string(),
323                serde_json::Value::String("substrate-authoritative-for-internal-ops".to_string()),
324            );
325            gov_obj.insert(
326                "rules_immutable_seed".to_string(),
327                serde_json::Value::Bool(true),
328            );
329        }
330    }
331    Ok(value)
332}
333
334/// Build the runtime-overlaid [`Capabilities`] document. Shared between
335/// the v1/v2 entry point [`handle_capabilities_with_conn`] and the v3
336/// entry point [`handle_capabilities_with_conn_v3`] so the overlay
337/// logic stays single-sourced.
338fn build_capabilities_overlay(
339    tier_config: &TierConfig,
340    resolved_models: &ResolvedModels,
341    reranker: Option<&BatchedReranker>,
342    embedder_loaded: bool,
343    conn: Option<&rusqlite::Connection>,
344) -> crate::config::Capabilities {
345    // v0.7.x (#1168) — build the report from the operator-resolved
346    // models triple. The boot banner already routes the same triple
347    // through `app_config.resolve_models()`; the capabilities surface
348    // now matches it so `memory_capabilities.models.*` reflects what
349    // the live LLM / embedder / reranker were wired to, not the
350    // compiled tier preset.
351    let mut caps = tier_config.capabilities_with_resolved(resolved_models);
352
353    // --- Reranker live state (P1) ---
354    caps.features.reranker_active = match reranker {
355        Some(ce) if ce.is_neural() => RerankerMode::Neural,
356        Some(_) => {
357            // Lexical fallback — neural download or load failed.
358            caps.features.cross_encoder_reranking = false;
359            caps.models.cross_encoder = "lexical-fallback (neural download failed)".to_string();
360            RerankerMode::LexicalFallback
361        }
362        None => {
363            // #1647 — no reranker handle on THIS surface (the HTTP
364            // daemon never wires one; its recall path performs no
365            // cross-encoder pass), so the flag must not advertise the
366            // tier preset. Same live-truth posture as the #93
367            // LexicalFallback flip above: the envelope was previously
368            // self-contradictory (cross_encoder_reranking=true beside
369            // reranker_active="off") on autonomous-tier HTTP daemons.
370            caps.features.cross_encoder_reranking = false;
371            RerankerMode::Off
372        }
373    };
374
375    // --- Reflection-aware boost live state (v0.7.0 L2-8) ---
376    if let Some(ce) = reranker {
377        caps.features.reflection_boost =
378            crate::config::ReflectionBoostReport::from(ce.reflection_boost());
379    }
380
381    // --- Embedder live state (P1, S18) ---
382    caps.features.embedder_loaded = embedder_loaded;
383    caps.features.recall_mode_active = compute_recall_mode(tier_config, embedder_loaded);
384
385    // --- HNSW eviction surface (P3, G2) ---
386    caps.hnsw.evictions_total = crate::hnsw::index_evictions_total();
387    caps.hnsw.evicted_recently = crate::hnsw::evicted_recently(60);
388
389    // v0.7-polish SEC-15 / COR-11 (issue #780) — mirror the
390    // process-wide auto-export spawn-failure counter onto the
391    // capabilities surface so operators see otherwise-silent
392    // detached-worker failures without scraping /metrics directly.
393    caps.hooks.auto_export_spawn_failed_total = crate::metrics::auto_export_spawn_failed_count();
394
395    // --- Live DB-count overlays ---
396    if let Some(c) = conn {
397        if let Ok(n) = db::count_active_governance_rules(c) {
398            caps.permissions.active_rules = n;
399        }
400        // v0.7.0 K5 — populate `permissions.rule_summary` with a
401        // one-line summary per active governance policy, sorted lex by
402        // namespace. The DB layer returns the rows already sorted, so
403        // the format pass preserves order. Failure is silent (best-
404        // effort): a malformed policy must not take down the whole
405        // capabilities response. `Vec::is_empty` + `skip_serializing_if`
406        // means an unconfigured deployment sees the field omitted from
407        // the wire entirely (matching the v0.6.3.1 honesty disclosure
408        // that the field was previously dropped because no per-rule
409        // serializer existed).
410        if let Ok(rules) = db::list_active_governance_policies(c) {
411            caps.permissions.rule_summary = rules
412                .into_iter()
413                .map(|(ns, p)| format_rule_summary(&ns, &p))
414                .collect();
415        }
416        if let Ok(n) = db::count_subscriptions(c) {
417            caps.hooks.registered_count = n;
418        }
419        if let Ok(n) = db::count_pending_actions_by_status(c, "pending") {
420            caps.approval.pending_requests = n;
421        }
422        // v0.7.0 Cluster-C SEC-3 (issue #767) — surface the deferred-
423        // audit drainer's DLQ depth. Best-effort: a missing table
424        // (pre-v40 DB) or transient lock falls through to 0 so the
425        // capabilities response always succeeds.
426        if let Ok(n) = crate::governance::deferred_audit::dlq_size(c) {
427            caps.approval.deferred_audit_dlq_size = n;
428        }
429
430        // v0.7.0 #1324 — transcripts substrate live overlay.
431        //
432        // Pre-#1324 the capabilities surface advertised
433        // `planned: true, enabled: false` for transcripts even though
434        // the v0.7.0 substrate ships the full zstd-3 BLOB store + the
435        // `memory_replay` MCP tool + the lifecycle sweep. Operators
436        // hitting `memory_replay` against a reflection chain reasonably
437        // expected non-empty output and instead received an empty
438        // array, because the substrate does not auto-link transcripts
439        // — that requires the operator to wire the R5 reference
440        // `pre_store` hook (`tools/transcript-extractor/`).
441        //
442        // The overlay below flips `enabled: true` when at least one
443        // row exists in `memory_transcripts` (the operator wired the
444        // hook + rows are flowing) and surfaces a non-zero
445        // `total_count` / `total_size_mb` so the operator can audit
446        // capacity without scraping the DB directly. A missing table
447        // (pre-v21 DB) or transient lock falls through to the
448        // pre-overlay defaults (count = 0, enabled = false) so the
449        // capabilities response always succeeds.
450        if let Ok((count, bytes)) = c.query_row(
451            "SELECT COUNT(*), COALESCE(SUM(compressed_size), 0) FROM memory_transcripts",
452            [],
453            |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)),
454        ) {
455            #[allow(clippy::cast_sign_loss)]
456            let count_usize = count.max(0) as usize;
457            #[allow(clippy::cast_sign_loss)]
458            let bytes_u64 = bytes.max(0) as u64;
459            caps.transcripts.total_count = count_usize;
460            caps.transcripts.total_size_mb = bytes_u64 / (1024 * 1024);
461            if count_usize > 0 {
462                caps.transcripts.status.enabled = true;
463            }
464        }
465    }
466
467    caps
468}
469
470/// v0.7.0 K5 — format a single [`GovernancePolicy`] as a one-line
471/// human-readable summary, prefixed with the namespace it governs.
472///
473/// Output shape:
474/// ```text
475/// "alphaone/eng — write=approve, promote=any, delete=owner, approver=human, inherit=true"
476/// ```
477///
478/// The `approver` rendering follows the [`ApproverType`] discriminator
479/// tag (`human` / `agent:<id>` / `consensus:<n>`) so an operator can tell
480/// apart a `Human` policy from a `Consensus(3)` policy without fanning
481/// out to `memory_namespace_get_standard`. `inherit` is rendered as a
482/// boolean string so the line stays scan-friendly.
483///
484/// Public so the capabilities-v3 integration tests (track A, K5) can
485/// pin the exact wire shape without re-implementing the formatter.
486#[must_use]
487pub fn format_rule_summary(namespace: &str, policy: &crate::models::GovernancePolicy) -> String {
488    use crate::models::ApproverType;
489    // #880 — `approver` / `write` / `promote` / `delete` / `inherit`
490    // live on `policy.core` after the governance decomposition.
491    let approver = match &policy.core.approver {
492        ApproverType::Human => "human".to_string(),
493        ApproverType::Agent(id) => format!("agent:{id}"),
494        ApproverType::Consensus(n) => format!("consensus:{n}"),
495    };
496    format!(
497        "{namespace} — write={write}, promote={promote}, delete={delete}, approver={approver}, inherit={inherit}",
498        write = policy.core.write.as_str(),
499        promote = policy.core.promote.as_str(),
500        delete = policy.core.delete.as_str(),
501        inherit = policy.core.inherit,
502    )
503}
504
505/// v0.7.0 A1 — build the capabilities-v3 `summary` string from the live
506/// `Profile` state.
507///
508/// The summary names: how many tools are advertised in `tools/list`
509/// under the active profile vs how many exist in total, and the three
510/// recovery paths an LLM can take to reach unloaded tools (`--profile`
511/// CLI flag, [`memory_load_family`](#) — landing in B1, and
512/// [`memory_smart_load`](#) — landing in B2).
513///
514/// The result is a single plain-language string, intentionally written
515/// for an LLM to repeat verbatim when an end-user asks "what tools do
516/// you have?" — see the A2 increment for the explicit
517/// `to_describe_to_user` field.
518#[must_use]
519pub fn build_capabilities_summary(profile: &crate::profile::Profile) -> String {
520    use crate::profile::{ALWAYS_ON_TOOLS, Family};
521
522    // Round-2 F13 — substantive memory-tool count, EXCLUDING the
523    // always-on bootstrap (`memory_capabilities`). Reconciles with
524    // `build_capabilities_describe_to_user`'s "{n_loaded} memory
525    // tool{s}" phrasing so the summary number agrees with the
526    // user-facing sentence — at v0.7.0 both report 73 for
527    // `--profile full` (73 callable memory tools + the always-on
528    // `memory_capabilities` bootstrap = 74 advertised entries, which
529    // matches `Profile::full().expected_tool_count()` and
530    // `crate::mcp::registry::tool_names::ALL.len()`). The F13 pin
531    // guards against the off-by-one where the summary count would
532    // collide with the advertised-entries count; see issue #862 for
533    // the canonical 73/74 disambiguation.
534    let total: usize = Family::all()
535        .iter()
536        .map(|f| f.expected_tool_count())
537        .sum::<usize>()
538        .saturating_sub(ALWAYS_ON_TOOLS.len());
539
540    // Visible memory tools = profile-loaded family tools, minus any
541    // always-on bootstrap that lives in a family the profile loads
542    // (otherwise `memory_capabilities` would be double-counted for
543    // profiles that load `Meta`). The bootstrap still appears in
544    // `tools/list` — it just isn't a "memory tool" in the user-facing
545    // sense.
546    let from_families: usize = profile.expected_tool_count();
547    let always_on_in_loaded_family: usize = ALWAYS_ON_TOOLS
548        .iter()
549        .filter(|name| Family::for_tool(name).is_some_and(|f| profile.includes(f)))
550        .count();
551    let visible = from_families.saturating_sub(always_on_in_loaded_family);
552    let unloaded = total.saturating_sub(visible);
553    let label = profile_summary_label(profile);
554
555    format!(
556        "{visible} of {total} memory tools are advertised in tools/list under the current \
557         profile ({label}). The other {unloaded} are listed in this manifest but NOT directly \
558         callable. To use any unloaded tool, choose one of: \
559         (a) restart the server with --profile <family> or --profile full, \
560         (b) call memory_load_family(family=<name>) — preferred, \
561         (c) call memory_smart_load(intent='<plain language>') — easiest, \
562         (d) call the tool by name and recover from JSON-RPC -32601."
563    )
564}
565
566/// v0.7.0 A2 — build the capabilities-v3 `to_describe_to_user` string.
567///
568/// This is the canonical plain-language sentence the LLM should repeat
569/// (verbatim) when an end-user asks "what tools do you have?". It
570/// names how many tools are loaded right now, lists the first few by
571/// short name (without the `memory_` prefix, since the prefix is MCP
572/// jargon a user doesn't care about), reports how many are unloaded,
573/// and gives an end-user-friendly recovery hint ("I can load them on
574/// demand, or you can restart the server with a different profile").
575///
576/// Tone constraint (per A2 spec): NO MCP jargon. No mention of
577/// `tools/list`, `JSON-RPC`, or `--profile <family>`. Reads like a
578/// normal sentence a person would write.
579///
580/// The always-on bootstrap (`memory_capabilities`) is intentionally
581/// excluded from the loaded-tool preview — to a user, it's plumbing,
582/// not a feature.
583#[must_use]
584pub fn build_capabilities_describe_to_user(profile: &crate::profile::Profile) -> String {
585    use crate::profile::Family;
586
587    // Loaded vs unloaded by family membership. The always-on bootstrap
588    // sits in `Family::Meta`; under e.g. `--profile core` Meta isn't
589    // loaded, so `memory_capabilities` would normally count as
590    // unloaded. We strip it from BOTH sides — the user-facing sentence
591    // talks about the substantive tool surface, not the
592    // runtime-discovery bootstrap.
593    let loaded_tools: Vec<&'static str> = Family::all()
594        .iter()
595        .filter(|f| profile.includes(**f))
596        .flat_map(|f| f.tool_names().iter().copied())
597        .filter(|name| !crate::profile::ALWAYS_ON_TOOLS.contains(name))
598        .collect();
599    let unloaded_tools: Vec<&'static str> = Family::all()
600        .iter()
601        .filter(|f| !profile.includes(**f))
602        .flat_map(|f| f.tool_names().iter().copied())
603        .filter(|name| !crate::profile::ALWAYS_ON_TOOLS.contains(name))
604        .collect();
605
606    let n_loaded = loaded_tools.len();
607    let n_unloaded = unloaded_tools.len();
608
609    // Preview the first 5 loaded tools by short name (strip the
610    // `memory_` prefix). Five matches the canonical example in the
611    // A2 NHI prompt and lines up with the size of the smallest
612    // (`core`) profile so the preview is a complete enumeration there.
613    let preview_loaded = loaded_tools
614        .iter()
615        .take(5)
616        .map(|name| short_tool_name(name))
617        .collect::<Vec<_>>()
618        .join(", ");
619    let loaded_more_marker = if n_loaded > 5 { ", ..." } else { "" };
620
621    if n_unloaded == 0 {
622        format!(
623            "I can directly use all {n_loaded} memory tools right now \
624             ({preview_loaded}{loaded_more_marker}). Nothing more to load — \
625             the full memory surface is already active."
626        )
627    } else {
628        // Preview 4 unloaded tool names — the canonical example uses 4
629        // (link, kg_query, consolidate, delete) followed by ", etc.".
630        let preview_unloaded = unloaded_tools
631            .iter()
632            .take(4)
633            .map(|name| short_tool_name(name))
634            .collect::<Vec<_>>()
635            .join(", ");
636        let plural_loaded = if n_loaded == 1 { "" } else { "s" };
637        format!(
638            "I can directly use {n_loaded} memory tool{plural_loaded} right now \
639             ({preview_loaded}{loaded_more_marker}). {n_unloaded} more \
640             ({preview_unloaded}, etc.) are available on demand — I can load them \
641             if you ask for something that needs them, or you can restart the \
642             server with a different profile."
643        )
644    }
645}
646
647/// Strip the `memory_` prefix from a tool name for end-user-facing
648/// previews. v0.7.0 A2 — the prefix is MCP jargon; a user doesn't care
649/// that every tool name starts with the same five characters.
650fn short_tool_name(name: &'static str) -> &'static str {
651    name.strip_prefix("memory_").unwrap_or(name)
652}
653
654/// v0.7.0 A3 — build the per-tool array carried in the
655/// capabilities-v3 `tools` field.
656///
657/// Each entry's `loaded` mirrors `Profile::loads(name)`. Each entry's
658/// `callable_now` is `loaded && agent_can_call(agent_id, family)` —
659/// when the `[mcp.allowlist]` is disabled (no table or empty), the
660/// allowlist gate is `Disabled` and the AND collapses to just
661/// `loaded`. When the allowlist is active and the requesting agent
662/// has no entry granting the tool's family, `callable_now == false`
663/// even though `loaded == true`.
664///
665/// The order of the returned vector matches `crate::mcp::tool_definitions()`'s
666/// registration walk so a sequential reader gets a stable
667/// presentation matching the order in `tools/list`.
668#[must_use]
669pub fn build_capabilities_tools(
670    profile: &crate::profile::Profile,
671    mcp_config: Option<&crate::config::McpConfig>,
672    agent_id: Option<&str>,
673) -> Vec<crate::config::ToolEntry> {
674    use crate::config::{AllowlistDecision, ToolEntry};
675    use crate::profile::{ALWAYS_ON_TOOLS, Family};
676
677    let mut entries: Vec<ToolEntry> = Vec::with_capacity(50);
678
679    for fam in Family::all() {
680        let family_name = fam.name();
681        let loaded = profile.includes(*fam);
682        // Whether THIS agent can call tools in this family — disabled
683        // allowlist falls through to `loaded`. When the allowlist is
684        // configured but denies the family, callable_now collapses to
685        // false regardless of loaded.
686        let allowed = match mcp_config {
687            // #1673/n13 — when there is no resolved caller agent_id (the HTTP
688            // capabilities surface passes None), the per-agent allowlist cannot
689            // make an honest decision: `allowlist_decision(None, ..)` coerces
690            // `aid=""` -> wildcard-or-Deny, which mis-reports callable_now
691            // (allowlisted callers would see false, non-allowlisted true).
692            // Report callable_now from `loaded` alone for an unknown caller.
693            // The MCP surface always resolves a concrete agent_id, so per-agent
694            // gating is unchanged there; ENFORCING the allowlist on the HTTP
695            // surface (which has its own auth model) is tracked for v0.8 (#1695).
696            Some(_) if agent_id.is_none() => true,
697            Some(cfg) => match cfg.allowlist_decision(agent_id, family_name) {
698                AllowlistDecision::Disabled | AllowlistDecision::Allow => true,
699                AllowlistDecision::Deny => false,
700            },
701            None => true,
702        };
703        for name in fam.tool_names() {
704            entries.push(ToolEntry {
705                name: (*name).to_string(),
706                family: family_name.to_string(),
707                loaded,
708                callable_now: loaded && allowed,
709                // v0.7.0 issue #803 — per-tool worked examples.
710                examples: tool_examples(name),
711            });
712        }
713    }
714
715    // Always-on bootstraps not in a normal family walk.
716    for name in ALWAYS_ON_TOOLS {
717        if !entries.iter().any(|e| e.name == *name) {
718            entries.push(ToolEntry {
719                name: (*name).to_string(),
720                family: "always_on".to_string(),
721                loaded: true,
722                callable_now: true,
723                examples: tool_examples(name),
724            });
725        }
726    }
727
728    entries
729}
730
731/// v0.7.0 issue #803 — per-tool worked example catalog.
732///
733/// Returns 0-2 [`crate::config::ToolExample`] entries for a given
734/// tool name. Only a curated subset of high-leverage tools carry
735/// examples; the rest return empty, which `skip_serializing_if`
736/// drops from the wire so the payload stays compact.
737#[must_use]
738pub fn tool_examples(name: &str) -> Vec<crate::config::ToolExample> {
739    use crate::config::ToolExample;
740    use crate::mcp::registry::tool_names as tn;
741    use crate::models::Tier;
742    use serde_json::json;
743    let ex = |call: serde_json::Value, desc: &str| ToolExample {
744        call,
745        description: desc.to_string(),
746    };
747    match name {
748        tn::MEMORY_STORE => vec![ex(
749            // #1644 — the success envelope is {id, tier, title,
750            // namespace, agent_id} (store/mod.rs success echo), NOT
751            // the previously-claimed {id, status}.
752            json!({"title": "design", "content": "wt-1 atomisation", "tier": Tier::Long.as_str(), "namespace": "ai-memory"}),
753            "Persists a long-tier memory; returns {id, tier, title, namespace, agent_id}.",
754        )],
755        tn::MEMORY_RECALL => vec![ex(
756            // #1606 — the MCP wire param is `context` (the `query` alias
757            // ladder is HTTP-only); the example stays byte-equal to a
758            // valid call per the #1325 discipline, pinned by
759            // `recall_example_payload_parses_1606`.
760            json!({"context": "atomisation gates", "namespace": "ai-memory", "limit": 5}),
761            "Hybrid FTS+semantic recall; returns top-K ranked memories.",
762        )],
763        tn::MEMORY_SEARCH => vec![ex(
764            json!({"query": "L1-6 governance", "limit": 10}),
765            "FTS5 keyword search across namespaces.",
766        )],
767        tn::MEMORY_LINK => vec![ex(
768            // #1644 — parser fields are `source_id`/`target_id`
769            // (`handle_link`), NOT `from_id`/`to_id`; the success
770            // envelope carries no `link_id`.
771            json!({"source_id": "<uuid-a>", "target_id": "<uuid-b>", "relation": "derives_from"}),
772            "Signed directional edge; returns {linked, source_id, target_id, relation, invalidation_notified, attest_level}.",
773        )],
774        tn::MEMORY_REFLECT => vec![ex(
775            // v0.7.0 #1325 — example payload is byte-equal to a valid call.
776            // Canonical parser field is `source_ids` (NOT `memory_ids`);
777            // `depth` is an optional caller-asserted cap that MUST equal
778            // max(source_depths)+1 or the call refuses with
779            // CALLER_DEPTH_MISMATCH. For depth-0 sources, the substrate
780            // computes reflection_depth=1, which the example asserts.
781            json!({
782                "source_ids": ["<uuid-1>", "<uuid-2>"],
783                "title": "Reflection over alpha + beta",
784                "content": "Synthesis of the two source memories.",
785                "depth": 1,
786            }),
787            "Curator synthesises a Reflection; returns {id, reflection_depth, reflects_on, namespace}.",
788        )],
789        tn::MEMORY_PERSONA_GENERATE => vec![
790            ex(
791                json!({"entity_id": "alice", "namespace": "team/alpha"}),
792                "Single-namespace scope.",
793            ),
794            ex(
795                json!({"entity_id": "alice"}),
796                "#848 cross-namespace; persona lands in 'global'.",
797            ),
798        ],
799        tn::MEMORY_CONSOLIDATE => vec![ex(
800            // #1644 — the parser contract is `ids[]` + `title`
801            // (`handle_consolidate`); there is no namespace-sweep
802            // form and no `into_namespace`/`limit` params.
803            json!({"ids": ["<uuid-a>", "<uuid-b>"], "title": "Distilled summary", "namespace": "team/alpha"}),
804            "Curator distils the listed memories into one consolidated memory.",
805        )],
806        tn::MEMORY_ATOMISE => vec![ex(
807            json!({"memory_id": "<long-uuid>", "max_atom_tokens": 200}),
808            "WT-1 decomposition; archives parent.",
809        )],
810        tn::MEMORY_FIND_PATHS => vec![ex(
811            // #1644 — parser fields are `source_id`/`target_id`
812            // (`handle_find_paths`), NOT `from_id`/`to_id`.
813            json!({"source_id": "<uuid-a>", "target_id": "<uuid-b>", "max_depth": 4}),
814            "BFS over KG; returns path arrays of memory ids.",
815        )],
816        tn::MEMORY_KG_QUERY => vec![ex(
817            // #1644 — the parser requires `source_id` and reads
818            // `max_depth` (`handle_kg_query`); the previously-shipped
819            // `start_id`/`relation`/`direction`/`depth` params are
820            // never read by the handler.
821            json!({"source_id": "<uuid>", "max_depth": 2}),
822            "Typed KG walk; returns nodes+edges.",
823        )],
824        tn::MEMORY_EXPORT_REFLECTION => vec![ex(
825            json!({"memory_id": "<reflection-uuid>", "format": "md"}),
826            "QW-1 export; returns {content, suggested_filename}.",
827        )],
828        tn::MEMORY_SMART_LOAD => vec![ex(
829            // #1644 sweep — the handler reads `intent`/`namespace`/`k`
830            // only; the previously-shipped `include_schema` was inert.
831            json!({"intent": "inspect the knowledge graph", "k": 10}),
832            "B2 intent routing.",
833        )],
834        tn::MEMORY_LOAD_FAMILY => vec![ex(
835            // #1644 sweep — the handler reads `family`/`namespace`/`k`
836            // only; the previously-shipped `include_schema` was inert.
837            json!({"family": "graph", "k": 10}),
838            "B1 explicit family load.",
839        )],
840        tn::MEMORY_SESSION_START => vec![ex(
841            // #1644 — the handler reads only `namespace` + `limit`
842            // (`handle_session_start`); the previously-shipped `topic`
843            // param was inert.
844            json!({"namespace": "ai-memory", "limit": 10}),
845            "SessionStart bootstrap; returns memories+persona+rules.",
846        )],
847        tn::MEMORY_VERIFY => vec![
848            // #1644 — memory_verify re-verifies LINK signatures, not
849            // memory rows: `handle_verify` accepts either a composite
850            // `link_id` or the explicit `source_id`+`target_id`
851            // triple; `memory_id` is never read. The truthful return
852            // key is `signature_verified` (not `verified`).
853            ex(
854                json!({"source_id": "<uuid-a>", "target_id": "<uuid-b>", "relation": "derives_from"}),
855                "H4 on-demand link-signature re-verify; returns {signature_verified, attest_level, signed_by, signed_at}.",
856            ),
857            ex(
858                json!({"link_id": "<uuid-a>--derives_from--><uuid-b>"}),
859                "Composite link_id form of the same re-verify call.",
860            ),
861        ],
862        tn::MEMORY_NOTIFY => vec![ex(
863            // #1644 — `handle_notify` requires `target_agent_id` +
864            // `title`, and `payload` is a STRING (the message body);
865            // the previously-shipped `event_type`/`ttl_seconds`
866            // params (and the JSON-object payload) are never read.
867            json!({"target_agent_id": "ai:claude@host-a", "title": "deploy completed", "payload": "prod deploy finished green"}),
868            "Write a message to the target agent's inbox; read via memory_inbox.",
869        )],
870        // v0.7.0 #1327 — canonical example for `memory_skill_register`.
871        // Parameter name is `folder_path` (NOT `skill_folder`); the
872        // example payload is BYTE-EQUAL to what `handle_skill_register`
873        // parses (see `src/mcp/tools/skill_register.rs:254-279`).
874        // `inline_skill` is the alternative form for callers without a
875        // filesystem path. Either field is required (not both).
876        tn::MEMORY_SKILL_REGISTER => vec![
877            ex(
878                json!({"folder_path": "/path/to/skill-dir"}),
879                "Register a SKILL.md folder (optional resources/ sub-dir); returns {id, digest, signed}.",
880            ),
881            ex(
882                json!({"inline_skill": "---\nnamespace: example\nname: demo\ndescription: A demo skill.\n---\n\nBody.\n"}),
883                "Register a SKILL.md from inline text (no filesystem dependency).",
884            ),
885        ],
886        _ => Vec::new(),
887    }
888}
889
890/// v0.7.0 A4 — compute the optional `agent_permitted_families` field
891/// for a v3 capabilities response.
892///
893/// Returns:
894/// - `Some(Vec<...>)` (possibly empty) when `[mcp.allowlist]` is
895///   configured AND an `agent_id` was provided. The vector lists the
896///   canonical family names the agent is permitted to access (per the
897///   `Family::all()` registration order).
898/// - `None` when the allowlist is disabled (no table, empty table, or
899///   `mcp_config = None`) OR when no `agent_id` was provided.
900///   `serde(skip_serializing_if = "Option::is_none")` on the field
901///   means a `None` value drops the field from the wire entirely so
902///   v2-shaped consumers don't see drift from A4 alone.
903///
904/// The wildcard pattern `"*"` participates in the per-family
905/// allowlist_decision call — this matches the existing v0.6.4-008
906/// resolution semantics, so a `"*" = ["core"]` row grants every agent
907/// access to `core` even when their explicit row is missing.
908#[must_use]
909pub fn build_agent_permitted_families(
910    mcp_config: Option<&crate::config::McpConfig>,
911    agent_id: Option<&str>,
912) -> Option<Vec<String>> {
913    use crate::config::AllowlistDecision;
914    use crate::profile::Family;
915
916    // A4 spec: omit the field when allowlist disabled OR no agent_id.
917    let cfg = mcp_config?;
918    let aid = agent_id?;
919    let table = cfg.allowlist.as_ref()?;
920    if table.is_empty() {
921        // Allowlist Disabled (per the v0.6.4-008 contract): omit.
922        return None;
923    }
924
925    let permitted: Vec<String> = Family::all()
926        .iter()
927        .filter(|fam| {
928            matches!(
929                cfg.allowlist_decision(Some(aid), fam.name()),
930                AllowlistDecision::Allow
931            )
932        })
933        .map(|fam| fam.name().to_string())
934        .collect();
935
936    Some(permitted)
937}
938
939/// Return a stable label for a profile's summary string. Named profiles
940/// (core/graph/admin/power/full) use their canonical name; custom
941/// profiles use the comma-joined family list (matches the
942/// `--profile core,graph,archive` CLI form).
943fn profile_summary_label(profile: &crate::profile::Profile) -> String {
944    use crate::profile::Profile;
945    if *profile == Profile::full() {
946        "full".to_string()
947    } else if *profile == Profile::core() {
948        "core".to_string()
949    } else if *profile == Profile::graph() {
950        "graph".to_string()
951    } else if *profile == Profile::admin() {
952        "admin".to_string()
953    } else if *profile == Profile::power() {
954        "power".to_string()
955    } else {
956        profile
957            .families()
958            .iter()
959            .map(|f| f.name())
960            .collect::<Vec<_>>()
961            .join(",")
962    }
963}
964
965/// Round-2 F13 — derive the runtime-effective tier label from the
966/// presence of the LLM, embedder, and reranker handles. Mirrors the
967/// boot banner string emitted by `serve_mcp` so the
968/// `memory_capabilities` response and the daemon log agree on what
969/// the daemon is actually doing — independent of `tier_config.tier`,
970/// which only reflects the configured (build-time) tier and can lag
971/// the runtime when an embedder/LLM fails to load.
972#[must_use]
973pub fn effective_tier_label(has_llm: bool, has_embedder: bool, has_reranker: bool) -> &'static str {
974    if has_llm && has_embedder && has_reranker {
975        "autonomous"
976    } else if has_llm && has_embedder {
977        "smart"
978    } else if has_embedder {
979        "semantic"
980    } else {
981        "keyword"
982    }
983}
984
985/// Round-2 F13 — overlay per-tool `inputSchema` and/or `docstring`
986/// onto the top-level `tools[]` array of a v2/v3 capabilities
987/// response. Called on the no-family path when `include_schema=true`
988/// and/or `verbose=true` is set on the top-level
989/// `memory_capabilities` invocation. Without an overlay, those
990/// flags were inert at the top level (only the family drilldown
991/// honoured them).
992///
993/// `include_schema=true` — inject the canonical
994/// `crate::mcp::tool_definitions()[name].inputSchema` for every tool entry.
995/// `verbose=true` — inject `docstring` (sourced from the long-form
996/// `docs` field on `crate::mcp::tool_definitions()`).
997///
998/// Tools that aren't currently loaded under the active profile (i.e.
999/// `loaded=false` in the v3 `tools[]`) get the same overlay so a
1000/// caller can decide whether to drill in via
1001/// `memory_load_family`/`memory_smart_load`.
1002pub fn overlay_tool_payloads(
1003    obj: &mut serde_json::Map<String, Value>,
1004    _profile: &crate::profile::Profile,
1005    include_schema: bool,
1006    verbose: bool,
1007) {
1008    if !include_schema && !verbose {
1009        return;
1010    }
1011
1012    // Build a name → (docs, inputSchema) lookup from the canonical
1013    // tool catalog. Done once per call; cheap (~50 entries).
1014    //
1015    // v0.7.0 #1059 (Agent-4 F5) — when `verbose=false` the caller is
1016    // asking for the trimmed wire shape. Pre-#1059 this function
1017    // injected the FULL unstripped schemars `inputSchema` regardless
1018    // of the verbose flag — including schemars-only metadata
1019    // (top-level `description`, `$schema`, `title`, nested
1020    // `definitions.*.description`, per-property `description`,
1021    // `default: null`) that the bare `tools/list` payload strips via
1022    // `strip_docs_from_tools`. The asymmetric gate meant a caller
1023    // sending `include_schema=true, verbose=false` got a noisier
1024    // payload than the bare `tools/list` they would have received
1025    // with no overlay.
1026    //
1027    // Post-#1059 the lookup runs through `strip_docs_from_tools`
1028    // when `verbose=false` so the overlay matches the bare wire
1029    // contract. When `verbose=true` the caller is explicitly asking
1030    // for the prose surface — preserve the un-stripped schemas.
1031    let defs = if verbose {
1032        crate::mcp::tool_definitions()
1033    } else {
1034        let mut defs = crate::mcp::tool_definitions();
1035        if let Some(arr) = defs.get_mut("tools").and_then(Value::as_array_mut) {
1036            crate::mcp::registry::strip_docs_from_tools(arr);
1037        }
1038        defs
1039    };
1040    let lookup: std::collections::HashMap<String, (Option<Value>, Option<Value>)> = defs
1041        .get("tools")
1042        .and_then(Value::as_array)
1043        .map(|tools| {
1044            tools
1045                .iter()
1046                .filter_map(|t| {
1047                    let name = t
1048                        .get(param_names::NAME)
1049                        .and_then(Value::as_str)?
1050                        .to_string();
1051                    let docs = t.get("docs").cloned();
1052                    let schema = t.get("inputSchema").cloned();
1053                    Some((name, (docs, schema)))
1054                })
1055                .collect()
1056        })
1057        .unwrap_or_default();
1058
1059    // The v3 response carries a top-level `tools` array of
1060    // `ToolEntry` objects; the v2 response does not. For v2 callers
1061    // passing include_schema/verbose, synthesize a parallel
1062    // `tool_payloads` array so the overlay is still discoverable
1063    // without disturbing the v2 wire shape.
1064    if let Some(tools) = obj.get_mut("tools").and_then(Value::as_array_mut) {
1065        for tool in tools.iter_mut() {
1066            let Some(tool_obj) = tool.as_object_mut() else {
1067                continue;
1068            };
1069            let Some(name) = tool_obj.get(param_names::NAME).and_then(Value::as_str) else {
1070                continue;
1071            };
1072            let Some((docs, schema)) = lookup.get(name) else {
1073                continue;
1074            };
1075            if include_schema && let Some(s) = schema {
1076                tool_obj.insert("inputSchema".to_string(), s.clone());
1077            }
1078            if verbose && let Some(d) = docs {
1079                tool_obj.insert("docstring".to_string(), d.clone());
1080            }
1081        }
1082    } else {
1083        // v2 path — no `tools` field exists. Synthesize a flat
1084        // `tool_payloads` array so the overlay is still on the wire.
1085        let payloads: Vec<Value> = lookup
1086            .iter()
1087            .map(|(name, (docs, schema))| {
1088                let mut entry = serde_json::Map::new();
1089                entry.insert("name".to_string(), Value::String(name.clone()));
1090                if include_schema && let Some(s) = schema {
1091                    entry.insert("inputSchema".to_string(), s.clone());
1092                }
1093                if verbose && let Some(d) = docs {
1094                    entry.insert("docstring".to_string(), d.clone());
1095                }
1096                Value::Object(entry)
1097            })
1098            .collect();
1099        obj.insert("tool_payloads".to_string(), Value::Array(payloads));
1100    }
1101}
1102
1103/// Compute the live `recall_mode_active` tag from the configured tier
1104/// and the runtime embedder-loaded signal. P1 honesty patch.
1105///
1106/// - Tier configured no embedder (keyword tier) → `Disabled`.
1107/// - Tier configured an embedder and it loaded → `Hybrid`.
1108/// - Tier configured an embedder but it did not load → `Degraded`.
1109/// - (Reserved) `KeywordOnly` is returned only when the daemon has an
1110///   embedder configured but the operator explicitly disabled hybrid
1111///   blending — not possible in v0.6.3.1, so unreachable today.
1112fn compute_recall_mode(
1113    tier_config: &TierConfig,
1114    embedder_loaded: bool,
1115) -> crate::config::RecallMode {
1116    use crate::config::RecallMode;
1117    if tier_config.embedding_model.is_none() {
1118        RecallMode::Disabled
1119    } else if embedder_loaded {
1120        RecallMode::Hybrid
1121    } else {
1122        RecallMode::Degraded
1123    }
1124}
1125
1126#[cfg(test)]
1127mod example_validity_1606_tests {
1128    //! #1606 — capabilities examples must stay byte-equal to valid
1129    //! calls (the #1325 discipline). The pre-#1606 `memory_recall`
1130    //! example advertised `{"query": ...}`, a payload the MCP wire
1131    //! parser refuses with "context is required" (the `query` alias
1132    //! ladder is HTTP-only).
1133
1134    #[test]
1135    fn recall_example_payload_parses_1606() {
1136        let examples = super::tool_examples(crate::mcp::registry::tool_names::MEMORY_RECALL);
1137        assert!(
1138            !examples.is_empty(),
1139            "memory_recall must carry a worked example (#803)"
1140        );
1141        for example in &examples {
1142            crate::models::RecallRequest::from_mcp_params(&example.call).unwrap_or_else(|e| {
1143                panic!(
1144                    "memory_recall capabilities example must be byte-equal to a \
1145                     valid MCP call (#1606/#1325); parser said: {e}"
1146                )
1147            });
1148        }
1149    }
1150}
1151
1152#[cfg(test)]
1153mod example_validity_1644_tests {
1154    //! #1644 — class-closing generalization of
1155    //! `recall_example_payload_parses_1606` (above) and
1156    //! `tests/issue_1327_skill_register_docstring_example.rs`: EVERY
1157    //! payload in the `tool_examples()` worked-example catalog must
1158    //! round-trip through its tool's actual parser / param-extraction
1159    //! layer (the #1325 byte-valid discipline).
1160    //!
1161    //! **Parse-level validation suffices here (documented per #1644):**
1162    //! most handlers need a live DB / LLM / embedder to execute
1163    //! end-to-end, but the failure class this closes — wrong param
1164    //! names (`from_id` vs `source_id`), wrong param types (object
1165    //! `payload` vs string), and inert params the handler never reads
1166    //! (`topic`, `ttl_seconds`) — is fully visible at the serde +
1167    //! schema layer. Each example deserializes through the SAME
1168    //! request struct whose schemars derive is the tool's wire
1169    //! `inputSchema`, and (because #1052 keeps the structs permissive
1170    //! to unknown fields) every example key is additionally checked
1171    //! against the declared `inputSchema.properties` set so an inert
1172    //! param cannot hide behind serde leniency.
1173
1174    use serde_json::Value;
1175
1176    /// Every tool name in the FULL catalog (all registered tools +
1177    /// the always-on bootstraps) that carries at least one worked
1178    /// example. Enumerated from the catalog — not hand-listed — so a
1179    /// future example added for ANY tool cannot dodge this test.
1180    fn example_bearing_tools() -> Vec<String> {
1181        let defs = crate::mcp::tool_definitions();
1182        let mut names: Vec<String> = defs["tools"]
1183            .as_array()
1184            .expect("tool_definitions must emit `tools` array")
1185            .iter()
1186            .filter_map(|t| t.get("name").and_then(Value::as_str))
1187            .map(str::to_string)
1188            .collect();
1189        for name in crate::profile::ALWAYS_ON_TOOLS {
1190            if !names.iter().any(|n| n == name) {
1191                names.push((*name).to_string());
1192            }
1193        }
1194        names.retain(|n| !super::tool_examples(n).is_empty());
1195        assert!(
1196            !names.is_empty(),
1197            "the #803 worked-example catalog must not be empty"
1198        );
1199        names
1200    }
1201
1202    /// Deserialize `call` through `T` — the same request struct whose
1203    /// schemars derive is the tool's wire `inputSchema` — panicking
1204    /// with the tool name on refusal.
1205    fn parses_as<T: serde::de::DeserializeOwned>(name: &str, call: &Value) {
1206        if let Err(e) = serde_json::from_value::<T>(call.clone()) {
1207            panic!(
1208                "{name} capabilities example must be byte-equal to a valid \
1209                 MCP call (#1644/#1325); parser said: {e}"
1210            );
1211        }
1212    }
1213
1214    /// Round-trip every worked example through its tool's canonical
1215    /// parser. The fallthrough arm panics, so a tool that GAINS a
1216    /// worked example without a parser pin here fails this test —
1217    /// that is the class guard.
1218    #[test]
1219    fn issue_1644_every_example_round_trips_through_its_parser() {
1220        use crate::mcp::registry::tool_names as tn;
1221        for name in example_bearing_tools() {
1222            for example in &super::tool_examples(&name) {
1223                let call = &example.call;
1224                match name.as_str() {
1225                    x if x == tn::MEMORY_STORE => {
1226                        parses_as::<crate::mcp::store::StoreRequest>(&name, call);
1227                    }
1228                    x if x == tn::MEMORY_RECALL => {
1229                        // The one real no-DB parser entry point — same
1230                        // contract as `recall_example_payload_parses_1606`.
1231                        crate::models::RecallRequest::from_mcp_params(call).unwrap_or_else(|e| {
1232                            panic!("memory_recall example must parse (#1644): {e}")
1233                        });
1234                    }
1235                    x if x == tn::MEMORY_SEARCH => {
1236                        parses_as::<crate::mcp::search::SearchRequest>(&name, call);
1237                    }
1238                    x if x == tn::MEMORY_LINK => {
1239                        parses_as::<crate::mcp::link::LinkRequest>(&name, call);
1240                    }
1241                    x if x == tn::MEMORY_REFLECT => {
1242                        parses_as::<crate::mcp::reflect::ReflectRequest>(&name, call);
1243                    }
1244                    x if x == tn::MEMORY_PERSONA_GENERATE => {
1245                        parses_as::<crate::mcp::persona::PersonaGenerateRequest>(&name, call);
1246                    }
1247                    x if x == tn::MEMORY_CONSOLIDATE => {
1248                        parses_as::<crate::mcp::consolidate::ConsolidateRequest>(&name, call);
1249                    }
1250                    x if x == tn::MEMORY_ATOMISE => {
1251                        parses_as::<crate::mcp::atomise::AtomiseRequest>(&name, call);
1252                    }
1253                    x if x == tn::MEMORY_FIND_PATHS => {
1254                        parses_as::<crate::mcp::find_paths::FindPathsRequest>(&name, call);
1255                    }
1256                    x if x == tn::MEMORY_KG_QUERY => {
1257                        parses_as::<crate::mcp::kg_query::KgQueryRequest>(&name, call);
1258                    }
1259                    x if x == tn::MEMORY_EXPORT_REFLECTION => {
1260                        parses_as::<crate::mcp::export_reflection::ExportReflectionRequest>(
1261                            &name, call,
1262                        );
1263                    }
1264                    x if x == tn::MEMORY_SMART_LOAD => {
1265                        parses_as::<crate::mcp::load_family::SmartLoadRequest>(&name, call);
1266                    }
1267                    x if x == tn::MEMORY_LOAD_FAMILY => {
1268                        parses_as::<crate::mcp::load_family::LoadFamilyRequest>(&name, call);
1269                    }
1270                    x if x == tn::MEMORY_SESSION_START => {
1271                        parses_as::<crate::mcp::session_start::SessionStartRequest>(&name, call);
1272                    }
1273                    x if x == tn::MEMORY_VERIFY => {
1274                        parses_as::<crate::mcp::verify::VerifyRequest>(&name, call);
1275                        // Mirror `handle_verify`'s param extraction:
1276                        // link_id OR source_id+target_id is required,
1277                        // and a composite link_id must satisfy
1278                        // `parse_link_id`.
1279                        let obj = call.as_object().expect("verify example is an object");
1280                        if let Some(lid) = obj.get("link_id").and_then(Value::as_str) {
1281                            assert!(
1282                                crate::mcp::link::parse_link_id(lid).is_some(),
1283                                "memory_verify link_id example must satisfy parse_link_id (#1644): {lid}"
1284                            );
1285                        } else {
1286                            assert!(
1287                                obj.contains_key("source_id") && obj.contains_key("target_id"),
1288                                "memory_verify example must carry link_id or source_id+target_id (#1644)"
1289                            );
1290                        }
1291                    }
1292                    x if x == tn::MEMORY_NOTIFY => {
1293                        parses_as::<crate::mcp::notify::NotifyRequest>(&name, call);
1294                    }
1295                    x if x == tn::MEMORY_SKILL_REGISTER => {
1296                        parses_as::<crate::mcp::skill_register::SkillRegisterRequest>(&name, call);
1297                    }
1298                    other => panic!(
1299                        "tool `{other}` carries worked examples but has no parser \
1300                         round-trip arm in this test — add one so the example \
1301                         stays byte-valid (#1644 class guard)"
1302                    ),
1303                }
1304            }
1305        }
1306    }
1307
1308    /// Every key on every example must be a declared
1309    /// `inputSchema.properties` entry, and every schema-`required`
1310    /// property must be present on the example. This is the
1311    /// inert-param guard: #1052 keeps request structs permissive to
1312    /// unknown fields, so serde alone cannot catch a `topic`-class
1313    /// param the handler never reads.
1314    #[test]
1315    fn issue_1644_example_keys_match_declared_schema() {
1316        let defs = crate::mcp::tool_definitions();
1317        let tools = defs["tools"]
1318            .as_array()
1319            .expect("tool_definitions must emit `tools` array");
1320        for name in example_bearing_tools() {
1321            let tool = tools
1322                .iter()
1323                .find(|t| t.get("name").and_then(Value::as_str) == Some(name.as_str()))
1324                .unwrap_or_else(|| panic!("`{name}` must be in the tool catalog"));
1325            let props: std::collections::BTreeSet<&str> = tool
1326                .pointer("/inputSchema/properties")
1327                .and_then(Value::as_object)
1328                .unwrap_or_else(|| panic!("`{name}` must carry inputSchema.properties"))
1329                .keys()
1330                .map(String::as_str)
1331                .collect();
1332            let required: Vec<&str> = tool
1333                .pointer("/inputSchema/required")
1334                .and_then(Value::as_array)
1335                .map(|a| a.iter().filter_map(Value::as_str).collect())
1336                .unwrap_or_default();
1337            for (idx, example) in super::tool_examples(&name).iter().enumerate() {
1338                let obj = example
1339                    .call
1340                    .as_object()
1341                    .unwrap_or_else(|| panic!("{name} example {idx} call must be an object"));
1342                for key in obj.keys() {
1343                    assert!(
1344                        props.contains(key.as_str()),
1345                        "{name} example {idx}: key `{key}` is not a declared \
1346                         inputSchema property — the handler never reads it \
1347                         (#1644 inert-param class); declared: {props:?}"
1348                    );
1349                }
1350                // memory_verify's either/or gate is checked in the
1351                // round-trip test; its schema declares no `required`.
1352                for req in &required {
1353                    assert!(
1354                        obj.contains_key(*req),
1355                        "{name} example {idx}: missing required property `{req}` (#1644)"
1356                    );
1357                }
1358            }
1359        }
1360    }
1361
1362    /// Pin the two #1644 return-shape corrections so the false claims
1363    /// cannot regress.
1364    #[test]
1365    fn issue_1644_return_shape_claims_are_truthful() {
1366        use crate::mcp::registry::tool_names as tn;
1367        let store = super::tool_examples(tn::MEMORY_STORE);
1368        assert!(
1369            store[0]
1370                .description
1371                .contains("{id, tier, title, namespace, agent_id}"),
1372            "memory_store example must claim the real success envelope (#1644)"
1373        );
1374        assert!(
1375            !store[0].description.contains("{id, status}"),
1376            "memory_store example must not claim the fictitious {{id, status}} shape (#1644)"
1377        );
1378        let link = super::tool_examples(tn::MEMORY_LINK);
1379        assert!(
1380            !link[0].description.contains("link_id"),
1381            "memory_link's success envelope carries no link_id (#1644)"
1382        );
1383        assert!(
1384            link[0].description.contains("invalidation_notified")
1385                && link[0].description.contains("attest_level"),
1386            "memory_link example must claim the real success envelope (#1644)"
1387        );
1388        let verify = super::tool_examples(tn::MEMORY_VERIFY);
1389        assert!(
1390            verify[0].description.contains("signature_verified"),
1391            "memory_verify's truthful return key is signature_verified (#1644)"
1392        );
1393    }
1394}
1395
1396#[cfg(test)]
1397mod d1_2_983_tests {
1398    //! D1.2 (#983) — parity contract between the schemars-derived
1399    //! `memory_capabilities` schema and the legacy hand-coded entry in
1400    //! [`crate::mcp::registry::tool_definitions`]. Run via
1401    //! `cargo test --lib d1_2_983`.
1402    //!
1403    //! Allowed diffs (documented + asserted-tolerated):
1404    //!
1405    //! 1. `type`: legacy `"string"` / `"boolean"`; schemars
1406    //!    `["string","null"]` / `["boolean","null"]` because Rust
1407    //!    `Option<T>` round-trips through nullable JSON. Wire clients
1408    //!    consume the same shape.
1409    //! 2. `default`: legacy carries typed defaults (`"v2"` /
1410    //!    `false`); schemars emits `null` for every `Option<T>`. The
1411    //!    handler's runtime `unwrap_or_*` calls supply the v0.7.0 A5
1412    //!    defaults (V3 for `accept`, `false` for booleans), so the
1413    //!    wire-level None reaches the same code path.
1414    //! 3. `enum`: legacy carries `["v1","v2"]` for `accept` (stale —
1415    //!    the runtime has supported V3 since A5) and a curated
1416    //!    family list. The D1.1 PoC intentionally drops these to fix
1417    //!    the schema/runtime drift (see CapabilitiesRequest doc).
1418    //!    A future enum-tightening pass can reintroduce them via
1419    //!    typed enum structs + `#[schemars(with = "...")]`.
1420    //! 4. `additionalProperties: false`: schemars emits it (from
1421    //!    is a tightening — strictly safer for clients.
1422    //!
1423    //! Match-exactly contracts:
1424    //!
1425    //! - Property names: every property in the legacy entry MUST be
1426    //!   present in the schemars-derived schema; vice versa.
1427    //! - Per-property `description`: byte-equal.
1428    //! - Base `type: "object"`.
1429    //! - No spurious top-level keys (e.g. legacy never had `required`;
1430    //!   schemars omits it for all-Option<T> requests).
1431
1432    use super::*;
1433    use serde_json::Value;
1434
1435    /// Resolve the schemars-derived `properties` object regardless of
1436    /// whether schemars emits it directly or under a `$ref`-resolved
1437    /// `definitions/.../properties` path. schemars 0.8 emits direct;
1438    /// 1.0 may relocate; this helper insulates downstream tests.
1439    fn derived_properties() -> serde_json::Map<String, Value> {
1440        let schema = CapabilitiesTool::input_schema();
1441        if let Some(props) = schema.get("properties").and_then(Value::as_object) {
1442            return props.clone();
1443        }
1444        if let Some(props) = schema
1445            .pointer("/definitions/CapabilitiesRequest/properties")
1446            .and_then(Value::as_object)
1447        {
1448            return props.clone();
1449        }
1450        panic!("schemars schema must emit properties at a known path; got {schema:#}")
1451    }
1452
1453    /// Pull the legacy hand-coded `memory_capabilities` entry's
1454    /// `inputSchema.properties` map out of
1455    /// [`crate::mcp::registry::tool_definitions`]. This is the
1456    /// source-of-truth we're migrating away from in D1.6 (#987).
1457    fn legacy_properties() -> serde_json::Map<String, Value> {
1458        let defs = crate::mcp::registry::tool_definitions();
1459        let tools = defs
1460            .get("tools")
1461            .and_then(Value::as_array)
1462            .expect("tool_definitions must emit `tools` array");
1463        let cap = tools
1464            .iter()
1465            .find(|t| t.get("name").and_then(Value::as_str) == Some("memory_capabilities"))
1466            .expect("memory_capabilities must be in the legacy tool catalog");
1467        cap.pointer("/inputSchema/properties")
1468            .and_then(Value::as_object)
1469            .expect("memory_capabilities.inputSchema.properties must be an object")
1470            .clone()
1471    }
1472
1473    #[test]
1474    fn capabilities_parity_property_set_983() {
1475        let legacy = legacy_properties();
1476        let derived = derived_properties();
1477        let legacy_keys: std::collections::BTreeSet<&str> =
1478            legacy.keys().map(String::as_str).collect();
1479        let derived_keys: std::collections::BTreeSet<&str> =
1480            derived.keys().map(String::as_str).collect();
1481        assert_eq!(
1482            legacy_keys,
1483            derived_keys,
1484            "schemars-derived schema must cover every legacy property; missing/extra: {:?}",
1485            legacy_keys
1486                .symmetric_difference(&derived_keys)
1487                .collect::<Vec<_>>()
1488        );
1489    }
1490
1491    #[test]
1492    fn no_reranker_handle_flips_cross_encoder_flag_1647() {
1493        // #1647 — a surface with no live reranker handle (the HTTP
1494        // daemon) must not advertise the tier preset's
1495        // cross_encoder_reranking; pre-fix the envelope was
1496        // self-contradictory (flag true beside reranker_active "off").
1497        let tier_config = crate::config::FeatureTier::Autonomous.config();
1498        let caps = build_capabilities_overlay(
1499            &tier_config,
1500            &crate::config::ResolvedModels::default(),
1501            None,
1502            false,
1503            None,
1504        );
1505        assert!(
1506            !caps.features.cross_encoder_reranking,
1507            "#1647: no handle ⇒ flag false"
1508        );
1509        assert_eq!(caps.features.reranker_active, RerankerMode::Off);
1510    }
1511
1512    #[test]
1513    fn capabilities_parity_descriptions_983() {
1514        let legacy = legacy_properties();
1515        let derived = derived_properties();
1516        for (name, legacy_prop) in &legacy {
1517            let legacy_desc = legacy_prop.get("description").and_then(Value::as_str);
1518            let derived_desc = derived
1519                .get(name)
1520                .and_then(|p| p.get("description"))
1521                .and_then(Value::as_str);
1522            // Legacy property may not have a description (rare); only
1523            // assert when it does.
1524            if let Some(want) = legacy_desc {
1525                assert_eq!(
1526                    derived_desc,
1527                    Some(want),
1528                    "property `{name}`: legacy description must match the schemars-derived one byte-for-byte"
1529                );
1530            }
1531        }
1532    }
1533
1534    #[test]
1535    fn capabilities_parity_top_level_object_983() {
1536        let schema = CapabilitiesTool::input_schema();
1537        assert_eq!(
1538            schema.get("type").and_then(Value::as_str),
1539            Some("object"),
1540            "top-level type must be `object`"
1541        );
1542    }
1543
1544    #[test]
1545    fn capabilities_parity_no_required_fields_983() {
1546        let schema = CapabilitiesTool::input_schema();
1547        let required = schema.get("required");
1548        // Legacy entry doesn't carry `required`; schemars also omits
1549        // when every field is `Option<T>`. Either absent or empty
1550        // array is acceptable; a non-empty array is a regression.
1551        if let Some(arr) = required.and_then(Value::as_array) {
1552            assert!(
1553                arr.is_empty(),
1554                "schemars-derived schema must not require any field; got {arr:?}"
1555            );
1556        }
1557    }
1558
1559    #[test]
1560    fn capabilities_parity_allowed_diffs_documented_983() {
1561        // Sanity-asserts the explicit allowed-diffs catalog. If the
1562        // schemars output structurally drifts away from the
1563        // documented set, this test pins the regression.
1564        let derived = derived_properties();
1565        // Each Option<T> property must have a nullable type AND a
1566        // null default. Both are byproducts of the Option<T> wrap.
1567        for name in &["accept", "family", "include_schema", "verbose"] {
1568            let prop = derived
1569                .get(*name)
1570                .unwrap_or_else(|| panic!("derived property `{name}` missing"));
1571            let type_value = prop.get("type").expect("each property has `type`");
1572            // Type is an array containing both the concrete type and "null".
1573            let arr = type_value
1574                .as_array()
1575                .unwrap_or_else(|| panic!("`{name}.type` must be an array (Option<T> nullable)"));
1576            assert!(
1577                arr.iter().any(|v| v.as_str() == Some("null")),
1578                "`{name}.type` must include `\"null\"` (Option<T> derive)"
1579            );
1580            assert_eq!(
1581                prop.get("default"),
1582                Some(&Value::Null),
1583                "`{name}.default` must be `null` (Option<T>::None)"
1584            );
1585        }
1586    }
1587}