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 Some(cfg) => match cfg.allowlist_decision(agent_id, family_name) {
688 AllowlistDecision::Disabled | AllowlistDecision::Allow => true,
689 AllowlistDecision::Deny => false,
690 },
691 None => true,
692 };
693 for name in fam.tool_names() {
694 entries.push(ToolEntry {
695 name: (*name).to_string(),
696 family: family_name.to_string(),
697 loaded,
698 callable_now: loaded && allowed,
699 // v0.7.0 issue #803 — per-tool worked examples.
700 examples: tool_examples(name),
701 });
702 }
703 }
704
705 // Always-on bootstraps not in a normal family walk.
706 for name in ALWAYS_ON_TOOLS {
707 if !entries.iter().any(|e| e.name == *name) {
708 entries.push(ToolEntry {
709 name: (*name).to_string(),
710 family: "always_on".to_string(),
711 loaded: true,
712 callable_now: true,
713 examples: tool_examples(name),
714 });
715 }
716 }
717
718 entries
719}
720
721/// v0.7.0 issue #803 — per-tool worked example catalog.
722///
723/// Returns 0-2 [`crate::config::ToolExample`] entries for a given
724/// tool name. Only a curated subset of high-leverage tools carry
725/// examples; the rest return empty, which `skip_serializing_if`
726/// drops from the wire so the payload stays compact.
727#[must_use]
728pub fn tool_examples(name: &str) -> Vec<crate::config::ToolExample> {
729 use crate::config::ToolExample;
730 use crate::mcp::registry::tool_names as tn;
731 use crate::models::Tier;
732 use serde_json::json;
733 let ex = |call: serde_json::Value, desc: &str| ToolExample {
734 call,
735 description: desc.to_string(),
736 };
737 match name {
738 tn::MEMORY_STORE => vec![ex(
739 // #1644 — the success envelope is {id, tier, title,
740 // namespace, agent_id} (store/mod.rs success echo), NOT
741 // the previously-claimed {id, status}.
742 json!({"title": "design", "content": "wt-1 atomisation", "tier": Tier::Long.as_str(), "namespace": "ai-memory"}),
743 "Persists a long-tier memory; returns {id, tier, title, namespace, agent_id}.",
744 )],
745 tn::MEMORY_RECALL => vec![ex(
746 // #1606 — the MCP wire param is `context` (the `query` alias
747 // ladder is HTTP-only); the example stays byte-equal to a
748 // valid call per the #1325 discipline, pinned by
749 // `recall_example_payload_parses_1606`.
750 json!({"context": "atomisation gates", "namespace": "ai-memory", "limit": 5}),
751 "Hybrid FTS+semantic recall; returns top-K ranked memories.",
752 )],
753 tn::MEMORY_SEARCH => vec![ex(
754 json!({"query": "L1-6 governance", "limit": 10}),
755 "FTS5 keyword search across namespaces.",
756 )],
757 tn::MEMORY_LINK => vec![ex(
758 // #1644 — parser fields are `source_id`/`target_id`
759 // (`handle_link`), NOT `from_id`/`to_id`; the success
760 // envelope carries no `link_id`.
761 json!({"source_id": "<uuid-a>", "target_id": "<uuid-b>", "relation": "derives_from"}),
762 "Signed directional edge; returns {linked, source_id, target_id, relation, invalidation_notified, attest_level}.",
763 )],
764 tn::MEMORY_REFLECT => vec![ex(
765 // v0.7.0 #1325 — example payload is byte-equal to a valid call.
766 // Canonical parser field is `source_ids` (NOT `memory_ids`);
767 // `depth` is an optional caller-asserted cap that MUST equal
768 // max(source_depths)+1 or the call refuses with
769 // CALLER_DEPTH_MISMATCH. For depth-0 sources, the substrate
770 // computes reflection_depth=1, which the example asserts.
771 json!({
772 "source_ids": ["<uuid-1>", "<uuid-2>"],
773 "title": "Reflection over alpha + beta",
774 "content": "Synthesis of the two source memories.",
775 "depth": 1,
776 }),
777 "Curator synthesises a Reflection; returns {id, reflection_depth, reflects_on, namespace}.",
778 )],
779 tn::MEMORY_PERSONA_GENERATE => vec![
780 ex(
781 json!({"entity_id": "alice", "namespace": "team/alpha"}),
782 "Single-namespace scope.",
783 ),
784 ex(
785 json!({"entity_id": "alice"}),
786 "#848 cross-namespace; persona lands in 'global'.",
787 ),
788 ],
789 tn::MEMORY_CONSOLIDATE => vec![ex(
790 // #1644 — the parser contract is `ids[]` + `title`
791 // (`handle_consolidate`); there is no namespace-sweep
792 // form and no `into_namespace`/`limit` params.
793 json!({"ids": ["<uuid-a>", "<uuid-b>"], "title": "Distilled summary", "namespace": "team/alpha"}),
794 "Curator distils the listed memories into one consolidated memory.",
795 )],
796 tn::MEMORY_ATOMISE => vec![ex(
797 json!({"memory_id": "<long-uuid>", "max_atom_tokens": 200}),
798 "WT-1 decomposition; archives parent.",
799 )],
800 tn::MEMORY_FIND_PATHS => vec![ex(
801 // #1644 — parser fields are `source_id`/`target_id`
802 // (`handle_find_paths`), NOT `from_id`/`to_id`.
803 json!({"source_id": "<uuid-a>", "target_id": "<uuid-b>", "max_depth": 4}),
804 "BFS over KG; returns path arrays of memory ids.",
805 )],
806 tn::MEMORY_KG_QUERY => vec![ex(
807 // #1644 — the parser requires `source_id` and reads
808 // `max_depth` (`handle_kg_query`); the previously-shipped
809 // `start_id`/`relation`/`direction`/`depth` params are
810 // never read by the handler.
811 json!({"source_id": "<uuid>", "max_depth": 2}),
812 "Typed KG walk; returns nodes+edges.",
813 )],
814 tn::MEMORY_EXPORT_REFLECTION => vec![ex(
815 json!({"memory_id": "<reflection-uuid>", "format": "md"}),
816 "QW-1 export; returns {content, suggested_filename}.",
817 )],
818 tn::MEMORY_SMART_LOAD => vec![ex(
819 // #1644 sweep — the handler reads `intent`/`namespace`/`k`
820 // only; the previously-shipped `include_schema` was inert.
821 json!({"intent": "inspect the knowledge graph", "k": 10}),
822 "B2 intent routing.",
823 )],
824 tn::MEMORY_LOAD_FAMILY => vec![ex(
825 // #1644 sweep — the handler reads `family`/`namespace`/`k`
826 // only; the previously-shipped `include_schema` was inert.
827 json!({"family": "graph", "k": 10}),
828 "B1 explicit family load.",
829 )],
830 tn::MEMORY_SESSION_START => vec![ex(
831 // #1644 — the handler reads only `namespace` + `limit`
832 // (`handle_session_start`); the previously-shipped `topic`
833 // param was inert.
834 json!({"namespace": "ai-memory", "limit": 10}),
835 "SessionStart bootstrap; returns memories+persona+rules.",
836 )],
837 tn::MEMORY_VERIFY => vec![
838 // #1644 — memory_verify re-verifies LINK signatures, not
839 // memory rows: `handle_verify` accepts either a composite
840 // `link_id` or the explicit `source_id`+`target_id`
841 // triple; `memory_id` is never read. The truthful return
842 // key is `signature_verified` (not `verified`).
843 ex(
844 json!({"source_id": "<uuid-a>", "target_id": "<uuid-b>", "relation": "derives_from"}),
845 "H4 on-demand link-signature re-verify; returns {signature_verified, attest_level, signed_by, signed_at}.",
846 ),
847 ex(
848 json!({"link_id": "<uuid-a>--derives_from--><uuid-b>"}),
849 "Composite link_id form of the same re-verify call.",
850 ),
851 ],
852 tn::MEMORY_NOTIFY => vec![ex(
853 // #1644 — `handle_notify` requires `target_agent_id` +
854 // `title`, and `payload` is a STRING (the message body);
855 // the previously-shipped `event_type`/`ttl_seconds`
856 // params (and the JSON-object payload) are never read.
857 json!({"target_agent_id": "ai:claude@host-a", "title": "deploy completed", "payload": "prod deploy finished green"}),
858 "Write a message to the target agent's inbox; read via memory_inbox.",
859 )],
860 // v0.7.0 #1327 — canonical example for `memory_skill_register`.
861 // Parameter name is `folder_path` (NOT `skill_folder`); the
862 // example payload is BYTE-EQUAL to what `handle_skill_register`
863 // parses (see `src/mcp/tools/skill_register.rs:254-279`).
864 // `inline_skill` is the alternative form for callers without a
865 // filesystem path. Either field is required (not both).
866 tn::MEMORY_SKILL_REGISTER => vec![
867 ex(
868 json!({"folder_path": "/path/to/skill-dir"}),
869 "Register a SKILL.md folder (optional resources/ sub-dir); returns {id, digest, signed}.",
870 ),
871 ex(
872 json!({"inline_skill": "---\nnamespace: example\nname: demo\ndescription: A demo skill.\n---\n\nBody.\n"}),
873 "Register a SKILL.md from inline text (no filesystem dependency).",
874 ),
875 ],
876 _ => Vec::new(),
877 }
878}
879
880/// v0.7.0 A4 — compute the optional `agent_permitted_families` field
881/// for a v3 capabilities response.
882///
883/// Returns:
884/// - `Some(Vec<...>)` (possibly empty) when `[mcp.allowlist]` is
885/// configured AND an `agent_id` was provided. The vector lists the
886/// canonical family names the agent is permitted to access (per the
887/// `Family::all()` registration order).
888/// - `None` when the allowlist is disabled (no table, empty table, or
889/// `mcp_config = None`) OR when no `agent_id` was provided.
890/// `serde(skip_serializing_if = "Option::is_none")` on the field
891/// means a `None` value drops the field from the wire entirely so
892/// v2-shaped consumers don't see drift from A4 alone.
893///
894/// The wildcard pattern `"*"` participates in the per-family
895/// allowlist_decision call — this matches the existing v0.6.4-008
896/// resolution semantics, so a `"*" = ["core"]` row grants every agent
897/// access to `core` even when their explicit row is missing.
898#[must_use]
899pub fn build_agent_permitted_families(
900 mcp_config: Option<&crate::config::McpConfig>,
901 agent_id: Option<&str>,
902) -> Option<Vec<String>> {
903 use crate::config::AllowlistDecision;
904 use crate::profile::Family;
905
906 // A4 spec: omit the field when allowlist disabled OR no agent_id.
907 let cfg = mcp_config?;
908 let aid = agent_id?;
909 let table = cfg.allowlist.as_ref()?;
910 if table.is_empty() {
911 // Allowlist Disabled (per the v0.6.4-008 contract): omit.
912 return None;
913 }
914
915 let permitted: Vec<String> = Family::all()
916 .iter()
917 .filter(|fam| {
918 matches!(
919 cfg.allowlist_decision(Some(aid), fam.name()),
920 AllowlistDecision::Allow
921 )
922 })
923 .map(|fam| fam.name().to_string())
924 .collect();
925
926 Some(permitted)
927}
928
929/// Return a stable label for a profile's summary string. Named profiles
930/// (core/graph/admin/power/full) use their canonical name; custom
931/// profiles use the comma-joined family list (matches the
932/// `--profile core,graph,archive` CLI form).
933fn profile_summary_label(profile: &crate::profile::Profile) -> String {
934 use crate::profile::Profile;
935 if *profile == Profile::full() {
936 "full".to_string()
937 } else if *profile == Profile::core() {
938 "core".to_string()
939 } else if *profile == Profile::graph() {
940 "graph".to_string()
941 } else if *profile == Profile::admin() {
942 "admin".to_string()
943 } else if *profile == Profile::power() {
944 "power".to_string()
945 } else {
946 profile
947 .families()
948 .iter()
949 .map(|f| f.name())
950 .collect::<Vec<_>>()
951 .join(",")
952 }
953}
954
955/// Round-2 F13 — derive the runtime-effective tier label from the
956/// presence of the LLM, embedder, and reranker handles. Mirrors the
957/// boot banner string emitted by `serve_mcp` so the
958/// `memory_capabilities` response and the daemon log agree on what
959/// the daemon is actually doing — independent of `tier_config.tier`,
960/// which only reflects the configured (build-time) tier and can lag
961/// the runtime when an embedder/LLM fails to load.
962#[must_use]
963pub fn effective_tier_label(has_llm: bool, has_embedder: bool, has_reranker: bool) -> &'static str {
964 if has_llm && has_embedder && has_reranker {
965 "autonomous"
966 } else if has_llm && has_embedder {
967 "smart"
968 } else if has_embedder {
969 "semantic"
970 } else {
971 "keyword"
972 }
973}
974
975/// Round-2 F13 — overlay per-tool `inputSchema` and/or `docstring`
976/// onto the top-level `tools[]` array of a v2/v3 capabilities
977/// response. Called on the no-family path when `include_schema=true`
978/// and/or `verbose=true` is set on the top-level
979/// `memory_capabilities` invocation. Without an overlay, those
980/// flags were inert at the top level (only the family drilldown
981/// honoured them).
982///
983/// `include_schema=true` — inject the canonical
984/// `crate::mcp::tool_definitions()[name].inputSchema` for every tool entry.
985/// `verbose=true` — inject `docstring` (sourced from the long-form
986/// `docs` field on `crate::mcp::tool_definitions()`).
987///
988/// Tools that aren't currently loaded under the active profile (i.e.
989/// `loaded=false` in the v3 `tools[]`) get the same overlay so a
990/// caller can decide whether to drill in via
991/// `memory_load_family`/`memory_smart_load`.
992pub fn overlay_tool_payloads(
993 obj: &mut serde_json::Map<String, Value>,
994 _profile: &crate::profile::Profile,
995 include_schema: bool,
996 verbose: bool,
997) {
998 if !include_schema && !verbose {
999 return;
1000 }
1001
1002 // Build a name → (docs, inputSchema) lookup from the canonical
1003 // tool catalog. Done once per call; cheap (~50 entries).
1004 //
1005 // v0.7.0 #1059 (Agent-4 F5) — when `verbose=false` the caller is
1006 // asking for the trimmed wire shape. Pre-#1059 this function
1007 // injected the FULL unstripped schemars `inputSchema` regardless
1008 // of the verbose flag — including schemars-only metadata
1009 // (top-level `description`, `$schema`, `title`, nested
1010 // `definitions.*.description`, per-property `description`,
1011 // `default: null`) that the bare `tools/list` payload strips via
1012 // `strip_docs_from_tools`. The asymmetric gate meant a caller
1013 // sending `include_schema=true, verbose=false` got a noisier
1014 // payload than the bare `tools/list` they would have received
1015 // with no overlay.
1016 //
1017 // Post-#1059 the lookup runs through `strip_docs_from_tools`
1018 // when `verbose=false` so the overlay matches the bare wire
1019 // contract. When `verbose=true` the caller is explicitly asking
1020 // for the prose surface — preserve the un-stripped schemas.
1021 let defs = if verbose {
1022 crate::mcp::tool_definitions()
1023 } else {
1024 let mut defs = crate::mcp::tool_definitions();
1025 if let Some(arr) = defs.get_mut("tools").and_then(Value::as_array_mut) {
1026 crate::mcp::registry::strip_docs_from_tools(arr);
1027 }
1028 defs
1029 };
1030 let lookup: std::collections::HashMap<String, (Option<Value>, Option<Value>)> = defs
1031 .get("tools")
1032 .and_then(Value::as_array)
1033 .map(|tools| {
1034 tools
1035 .iter()
1036 .filter_map(|t| {
1037 let name = t
1038 .get(param_names::NAME)
1039 .and_then(Value::as_str)?
1040 .to_string();
1041 let docs = t.get("docs").cloned();
1042 let schema = t.get("inputSchema").cloned();
1043 Some((name, (docs, schema)))
1044 })
1045 .collect()
1046 })
1047 .unwrap_or_default();
1048
1049 // The v3 response carries a top-level `tools` array of
1050 // `ToolEntry` objects; the v2 response does not. For v2 callers
1051 // passing include_schema/verbose, synthesize a parallel
1052 // `tool_payloads` array so the overlay is still discoverable
1053 // without disturbing the v2 wire shape.
1054 if let Some(tools) = obj.get_mut("tools").and_then(Value::as_array_mut) {
1055 for tool in tools.iter_mut() {
1056 let Some(tool_obj) = tool.as_object_mut() else {
1057 continue;
1058 };
1059 let Some(name) = tool_obj.get(param_names::NAME).and_then(Value::as_str) else {
1060 continue;
1061 };
1062 let Some((docs, schema)) = lookup.get(name) else {
1063 continue;
1064 };
1065 if include_schema && let Some(s) = schema {
1066 tool_obj.insert("inputSchema".to_string(), s.clone());
1067 }
1068 if verbose && let Some(d) = docs {
1069 tool_obj.insert("docstring".to_string(), d.clone());
1070 }
1071 }
1072 } else {
1073 // v2 path — no `tools` field exists. Synthesize a flat
1074 // `tool_payloads` array so the overlay is still on the wire.
1075 let payloads: Vec<Value> = lookup
1076 .iter()
1077 .map(|(name, (docs, schema))| {
1078 let mut entry = serde_json::Map::new();
1079 entry.insert("name".to_string(), Value::String(name.clone()));
1080 if include_schema && let Some(s) = schema {
1081 entry.insert("inputSchema".to_string(), s.clone());
1082 }
1083 if verbose && let Some(d) = docs {
1084 entry.insert("docstring".to_string(), d.clone());
1085 }
1086 Value::Object(entry)
1087 })
1088 .collect();
1089 obj.insert("tool_payloads".to_string(), Value::Array(payloads));
1090 }
1091}
1092
1093/// Compute the live `recall_mode_active` tag from the configured tier
1094/// and the runtime embedder-loaded signal. P1 honesty patch.
1095///
1096/// - Tier configured no embedder (keyword tier) → `Disabled`.
1097/// - Tier configured an embedder and it loaded → `Hybrid`.
1098/// - Tier configured an embedder but it did not load → `Degraded`.
1099/// - (Reserved) `KeywordOnly` is returned only when the daemon has an
1100/// embedder configured but the operator explicitly disabled hybrid
1101/// blending — not possible in v0.6.3.1, so unreachable today.
1102fn compute_recall_mode(
1103 tier_config: &TierConfig,
1104 embedder_loaded: bool,
1105) -> crate::config::RecallMode {
1106 use crate::config::RecallMode;
1107 if tier_config.embedding_model.is_none() {
1108 RecallMode::Disabled
1109 } else if embedder_loaded {
1110 RecallMode::Hybrid
1111 } else {
1112 RecallMode::Degraded
1113 }
1114}
1115
1116#[cfg(test)]
1117mod example_validity_1606_tests {
1118 //! #1606 — capabilities examples must stay byte-equal to valid
1119 //! calls (the #1325 discipline). The pre-#1606 `memory_recall`
1120 //! example advertised `{"query": ...}`, a payload the MCP wire
1121 //! parser refuses with "context is required" (the `query` alias
1122 //! ladder is HTTP-only).
1123
1124 #[test]
1125 fn recall_example_payload_parses_1606() {
1126 let examples = super::tool_examples(crate::mcp::registry::tool_names::MEMORY_RECALL);
1127 assert!(
1128 !examples.is_empty(),
1129 "memory_recall must carry a worked example (#803)"
1130 );
1131 for example in &examples {
1132 crate::models::RecallRequest::from_mcp_params(&example.call).unwrap_or_else(|e| {
1133 panic!(
1134 "memory_recall capabilities example must be byte-equal to a \
1135 valid MCP call (#1606/#1325); parser said: {e}"
1136 )
1137 });
1138 }
1139 }
1140}
1141
1142#[cfg(test)]
1143mod example_validity_1644_tests {
1144 //! #1644 — class-closing generalization of
1145 //! `recall_example_payload_parses_1606` (above) and
1146 //! `tests/issue_1327_skill_register_docstring_example.rs`: EVERY
1147 //! payload in the `tool_examples()` worked-example catalog must
1148 //! round-trip through its tool's actual parser / param-extraction
1149 //! layer (the #1325 byte-valid discipline).
1150 //!
1151 //! **Parse-level validation suffices here (documented per #1644):**
1152 //! most handlers need a live DB / LLM / embedder to execute
1153 //! end-to-end, but the failure class this closes — wrong param
1154 //! names (`from_id` vs `source_id`), wrong param types (object
1155 //! `payload` vs string), and inert params the handler never reads
1156 //! (`topic`, `ttl_seconds`) — is fully visible at the serde +
1157 //! schema layer. Each example deserializes through the SAME
1158 //! request struct whose schemars derive is the tool's wire
1159 //! `inputSchema`, and (because #1052 keeps the structs permissive
1160 //! to unknown fields) every example key is additionally checked
1161 //! against the declared `inputSchema.properties` set so an inert
1162 //! param cannot hide behind serde leniency.
1163
1164 use serde_json::Value;
1165
1166 /// Every tool name in the FULL catalog (all registered tools +
1167 /// the always-on bootstraps) that carries at least one worked
1168 /// example. Enumerated from the catalog — not hand-listed — so a
1169 /// future example added for ANY tool cannot dodge this test.
1170 fn example_bearing_tools() -> Vec<String> {
1171 let defs = crate::mcp::tool_definitions();
1172 let mut names: Vec<String> = defs["tools"]
1173 .as_array()
1174 .expect("tool_definitions must emit `tools` array")
1175 .iter()
1176 .filter_map(|t| t.get("name").and_then(Value::as_str))
1177 .map(str::to_string)
1178 .collect();
1179 for name in crate::profile::ALWAYS_ON_TOOLS {
1180 if !names.iter().any(|n| n == name) {
1181 names.push((*name).to_string());
1182 }
1183 }
1184 names.retain(|n| !super::tool_examples(n).is_empty());
1185 assert!(
1186 !names.is_empty(),
1187 "the #803 worked-example catalog must not be empty"
1188 );
1189 names
1190 }
1191
1192 /// Deserialize `call` through `T` — the same request struct whose
1193 /// schemars derive is the tool's wire `inputSchema` — panicking
1194 /// with the tool name on refusal.
1195 fn parses_as<T: serde::de::DeserializeOwned>(name: &str, call: &Value) {
1196 if let Err(e) = serde_json::from_value::<T>(call.clone()) {
1197 panic!(
1198 "{name} capabilities example must be byte-equal to a valid \
1199 MCP call (#1644/#1325); parser said: {e}"
1200 );
1201 }
1202 }
1203
1204 /// Round-trip every worked example through its tool's canonical
1205 /// parser. The fallthrough arm panics, so a tool that GAINS a
1206 /// worked example without a parser pin here fails this test —
1207 /// that is the class guard.
1208 #[test]
1209 fn issue_1644_every_example_round_trips_through_its_parser() {
1210 use crate::mcp::registry::tool_names as tn;
1211 for name in example_bearing_tools() {
1212 for example in &super::tool_examples(&name) {
1213 let call = &example.call;
1214 match name.as_str() {
1215 x if x == tn::MEMORY_STORE => {
1216 parses_as::<crate::mcp::store::StoreRequest>(&name, call);
1217 }
1218 x if x == tn::MEMORY_RECALL => {
1219 // The one real no-DB parser entry point — same
1220 // contract as `recall_example_payload_parses_1606`.
1221 crate::models::RecallRequest::from_mcp_params(call).unwrap_or_else(|e| {
1222 panic!("memory_recall example must parse (#1644): {e}")
1223 });
1224 }
1225 x if x == tn::MEMORY_SEARCH => {
1226 parses_as::<crate::mcp::search::SearchRequest>(&name, call);
1227 }
1228 x if x == tn::MEMORY_LINK => {
1229 parses_as::<crate::mcp::link::LinkRequest>(&name, call);
1230 }
1231 x if x == tn::MEMORY_REFLECT => {
1232 parses_as::<crate::mcp::reflect::ReflectRequest>(&name, call);
1233 }
1234 x if x == tn::MEMORY_PERSONA_GENERATE => {
1235 parses_as::<crate::mcp::persona::PersonaGenerateRequest>(&name, call);
1236 }
1237 x if x == tn::MEMORY_CONSOLIDATE => {
1238 parses_as::<crate::mcp::consolidate::ConsolidateRequest>(&name, call);
1239 }
1240 x if x == tn::MEMORY_ATOMISE => {
1241 parses_as::<crate::mcp::atomise::AtomiseRequest>(&name, call);
1242 }
1243 x if x == tn::MEMORY_FIND_PATHS => {
1244 parses_as::<crate::mcp::find_paths::FindPathsRequest>(&name, call);
1245 }
1246 x if x == tn::MEMORY_KG_QUERY => {
1247 parses_as::<crate::mcp::kg_query::KgQueryRequest>(&name, call);
1248 }
1249 x if x == tn::MEMORY_EXPORT_REFLECTION => {
1250 parses_as::<crate::mcp::export_reflection::ExportReflectionRequest>(
1251 &name, call,
1252 );
1253 }
1254 x if x == tn::MEMORY_SMART_LOAD => {
1255 parses_as::<crate::mcp::load_family::SmartLoadRequest>(&name, call);
1256 }
1257 x if x == tn::MEMORY_LOAD_FAMILY => {
1258 parses_as::<crate::mcp::load_family::LoadFamilyRequest>(&name, call);
1259 }
1260 x if x == tn::MEMORY_SESSION_START => {
1261 parses_as::<crate::mcp::session_start::SessionStartRequest>(&name, call);
1262 }
1263 x if x == tn::MEMORY_VERIFY => {
1264 parses_as::<crate::mcp::verify::VerifyRequest>(&name, call);
1265 // Mirror `handle_verify`'s param extraction:
1266 // link_id OR source_id+target_id is required,
1267 // and a composite link_id must satisfy
1268 // `parse_link_id`.
1269 let obj = call.as_object().expect("verify example is an object");
1270 if let Some(lid) = obj.get("link_id").and_then(Value::as_str) {
1271 assert!(
1272 crate::mcp::link::parse_link_id(lid).is_some(),
1273 "memory_verify link_id example must satisfy parse_link_id (#1644): {lid}"
1274 );
1275 } else {
1276 assert!(
1277 obj.contains_key("source_id") && obj.contains_key("target_id"),
1278 "memory_verify example must carry link_id or source_id+target_id (#1644)"
1279 );
1280 }
1281 }
1282 x if x == tn::MEMORY_NOTIFY => {
1283 parses_as::<crate::mcp::notify::NotifyRequest>(&name, call);
1284 }
1285 x if x == tn::MEMORY_SKILL_REGISTER => {
1286 parses_as::<crate::mcp::skill_register::SkillRegisterRequest>(&name, call);
1287 }
1288 other => panic!(
1289 "tool `{other}` carries worked examples but has no parser \
1290 round-trip arm in this test — add one so the example \
1291 stays byte-valid (#1644 class guard)"
1292 ),
1293 }
1294 }
1295 }
1296 }
1297
1298 /// Every key on every example must be a declared
1299 /// `inputSchema.properties` entry, and every schema-`required`
1300 /// property must be present on the example. This is the
1301 /// inert-param guard: #1052 keeps request structs permissive to
1302 /// unknown fields, so serde alone cannot catch a `topic`-class
1303 /// param the handler never reads.
1304 #[test]
1305 fn issue_1644_example_keys_match_declared_schema() {
1306 let defs = crate::mcp::tool_definitions();
1307 let tools = defs["tools"]
1308 .as_array()
1309 .expect("tool_definitions must emit `tools` array");
1310 for name in example_bearing_tools() {
1311 let tool = tools
1312 .iter()
1313 .find(|t| t.get("name").and_then(Value::as_str) == Some(name.as_str()))
1314 .unwrap_or_else(|| panic!("`{name}` must be in the tool catalog"));
1315 let props: std::collections::BTreeSet<&str> = tool
1316 .pointer("/inputSchema/properties")
1317 .and_then(Value::as_object)
1318 .unwrap_or_else(|| panic!("`{name}` must carry inputSchema.properties"))
1319 .keys()
1320 .map(String::as_str)
1321 .collect();
1322 let required: Vec<&str> = tool
1323 .pointer("/inputSchema/required")
1324 .and_then(Value::as_array)
1325 .map(|a| a.iter().filter_map(Value::as_str).collect())
1326 .unwrap_or_default();
1327 for (idx, example) in super::tool_examples(&name).iter().enumerate() {
1328 let obj = example
1329 .call
1330 .as_object()
1331 .unwrap_or_else(|| panic!("{name} example {idx} call must be an object"));
1332 for key in obj.keys() {
1333 assert!(
1334 props.contains(key.as_str()),
1335 "{name} example {idx}: key `{key}` is not a declared \
1336 inputSchema property — the handler never reads it \
1337 (#1644 inert-param class); declared: {props:?}"
1338 );
1339 }
1340 // memory_verify's either/or gate is checked in the
1341 // round-trip test; its schema declares no `required`.
1342 for req in &required {
1343 assert!(
1344 obj.contains_key(*req),
1345 "{name} example {idx}: missing required property `{req}` (#1644)"
1346 );
1347 }
1348 }
1349 }
1350 }
1351
1352 /// Pin the two #1644 return-shape corrections so the false claims
1353 /// cannot regress.
1354 #[test]
1355 fn issue_1644_return_shape_claims_are_truthful() {
1356 use crate::mcp::registry::tool_names as tn;
1357 let store = super::tool_examples(tn::MEMORY_STORE);
1358 assert!(
1359 store[0]
1360 .description
1361 .contains("{id, tier, title, namespace, agent_id}"),
1362 "memory_store example must claim the real success envelope (#1644)"
1363 );
1364 assert!(
1365 !store[0].description.contains("{id, status}"),
1366 "memory_store example must not claim the fictitious {{id, status}} shape (#1644)"
1367 );
1368 let link = super::tool_examples(tn::MEMORY_LINK);
1369 assert!(
1370 !link[0].description.contains("link_id"),
1371 "memory_link's success envelope carries no link_id (#1644)"
1372 );
1373 assert!(
1374 link[0].description.contains("invalidation_notified")
1375 && link[0].description.contains("attest_level"),
1376 "memory_link example must claim the real success envelope (#1644)"
1377 );
1378 let verify = super::tool_examples(tn::MEMORY_VERIFY);
1379 assert!(
1380 verify[0].description.contains("signature_verified"),
1381 "memory_verify's truthful return key is signature_verified (#1644)"
1382 );
1383 }
1384}
1385
1386#[cfg(test)]
1387mod d1_2_983_tests {
1388 //! D1.2 (#983) — parity contract between the schemars-derived
1389 //! `memory_capabilities` schema and the legacy hand-coded entry in
1390 //! [`crate::mcp::registry::tool_definitions`]. Run via
1391 //! `cargo test --lib d1_2_983`.
1392 //!
1393 //! Allowed diffs (documented + asserted-tolerated):
1394 //!
1395 //! 1. `type`: legacy `"string"` / `"boolean"`; schemars
1396 //! `["string","null"]` / `["boolean","null"]` because Rust
1397 //! `Option<T>` round-trips through nullable JSON. Wire clients
1398 //! consume the same shape.
1399 //! 2. `default`: legacy carries typed defaults (`"v2"` /
1400 //! `false`); schemars emits `null` for every `Option<T>`. The
1401 //! handler's runtime `unwrap_or_*` calls supply the v0.7.0 A5
1402 //! defaults (V3 for `accept`, `false` for booleans), so the
1403 //! wire-level None reaches the same code path.
1404 //! 3. `enum`: legacy carries `["v1","v2"]` for `accept` (stale —
1405 //! the runtime has supported V3 since A5) and a curated
1406 //! family list. The D1.1 PoC intentionally drops these to fix
1407 //! the schema/runtime drift (see CapabilitiesRequest doc).
1408 //! A future enum-tightening pass can reintroduce them via
1409 //! typed enum structs + `#[schemars(with = "...")]`.
1410 //! 4. `additionalProperties: false`: schemars emits it (from
1411 //! is a tightening — strictly safer for clients.
1412 //!
1413 //! Match-exactly contracts:
1414 //!
1415 //! - Property names: every property in the legacy entry MUST be
1416 //! present in the schemars-derived schema; vice versa.
1417 //! - Per-property `description`: byte-equal.
1418 //! - Base `type: "object"`.
1419 //! - No spurious top-level keys (e.g. legacy never had `required`;
1420 //! schemars omits it for all-Option<T> requests).
1421
1422 use super::*;
1423 use serde_json::Value;
1424
1425 /// Resolve the schemars-derived `properties` object regardless of
1426 /// whether schemars emits it directly or under a `$ref`-resolved
1427 /// `definitions/.../properties` path. schemars 0.8 emits direct;
1428 /// 1.0 may relocate; this helper insulates downstream tests.
1429 fn derived_properties() -> serde_json::Map<String, Value> {
1430 let schema = CapabilitiesTool::input_schema();
1431 if let Some(props) = schema.get("properties").and_then(Value::as_object) {
1432 return props.clone();
1433 }
1434 if let Some(props) = schema
1435 .pointer("/definitions/CapabilitiesRequest/properties")
1436 .and_then(Value::as_object)
1437 {
1438 return props.clone();
1439 }
1440 panic!("schemars schema must emit properties at a known path; got {schema:#}")
1441 }
1442
1443 /// Pull the legacy hand-coded `memory_capabilities` entry's
1444 /// `inputSchema.properties` map out of
1445 /// [`crate::mcp::registry::tool_definitions`]. This is the
1446 /// source-of-truth we're migrating away from in D1.6 (#987).
1447 fn legacy_properties() -> serde_json::Map<String, Value> {
1448 let defs = crate::mcp::registry::tool_definitions();
1449 let tools = defs
1450 .get("tools")
1451 .and_then(Value::as_array)
1452 .expect("tool_definitions must emit `tools` array");
1453 let cap = tools
1454 .iter()
1455 .find(|t| t.get("name").and_then(Value::as_str) == Some("memory_capabilities"))
1456 .expect("memory_capabilities must be in the legacy tool catalog");
1457 cap.pointer("/inputSchema/properties")
1458 .and_then(Value::as_object)
1459 .expect("memory_capabilities.inputSchema.properties must be an object")
1460 .clone()
1461 }
1462
1463 #[test]
1464 fn capabilities_parity_property_set_983() {
1465 let legacy = legacy_properties();
1466 let derived = derived_properties();
1467 let legacy_keys: std::collections::BTreeSet<&str> =
1468 legacy.keys().map(String::as_str).collect();
1469 let derived_keys: std::collections::BTreeSet<&str> =
1470 derived.keys().map(String::as_str).collect();
1471 assert_eq!(
1472 legacy_keys,
1473 derived_keys,
1474 "schemars-derived schema must cover every legacy property; missing/extra: {:?}",
1475 legacy_keys
1476 .symmetric_difference(&derived_keys)
1477 .collect::<Vec<_>>()
1478 );
1479 }
1480
1481 #[test]
1482 fn no_reranker_handle_flips_cross_encoder_flag_1647() {
1483 // #1647 — a surface with no live reranker handle (the HTTP
1484 // daemon) must not advertise the tier preset's
1485 // cross_encoder_reranking; pre-fix the envelope was
1486 // self-contradictory (flag true beside reranker_active "off").
1487 let tier_config = crate::config::FeatureTier::Autonomous.config();
1488 let caps = build_capabilities_overlay(
1489 &tier_config,
1490 &crate::config::ResolvedModels::default(),
1491 None,
1492 false,
1493 None,
1494 );
1495 assert!(
1496 !caps.features.cross_encoder_reranking,
1497 "#1647: no handle ⇒ flag false"
1498 );
1499 assert_eq!(caps.features.reranker_active, RerankerMode::Off);
1500 }
1501
1502 #[test]
1503 fn capabilities_parity_descriptions_983() {
1504 let legacy = legacy_properties();
1505 let derived = derived_properties();
1506 for (name, legacy_prop) in &legacy {
1507 let legacy_desc = legacy_prop.get("description").and_then(Value::as_str);
1508 let derived_desc = derived
1509 .get(name)
1510 .and_then(|p| p.get("description"))
1511 .and_then(Value::as_str);
1512 // Legacy property may not have a description (rare); only
1513 // assert when it does.
1514 if let Some(want) = legacy_desc {
1515 assert_eq!(
1516 derived_desc,
1517 Some(want),
1518 "property `{name}`: legacy description must match the schemars-derived one byte-for-byte"
1519 );
1520 }
1521 }
1522 }
1523
1524 #[test]
1525 fn capabilities_parity_top_level_object_983() {
1526 let schema = CapabilitiesTool::input_schema();
1527 assert_eq!(
1528 schema.get("type").and_then(Value::as_str),
1529 Some("object"),
1530 "top-level type must be `object`"
1531 );
1532 }
1533
1534 #[test]
1535 fn capabilities_parity_no_required_fields_983() {
1536 let schema = CapabilitiesTool::input_schema();
1537 let required = schema.get("required");
1538 // Legacy entry doesn't carry `required`; schemars also omits
1539 // when every field is `Option<T>`. Either absent or empty
1540 // array is acceptable; a non-empty array is a regression.
1541 if let Some(arr) = required.and_then(Value::as_array) {
1542 assert!(
1543 arr.is_empty(),
1544 "schemars-derived schema must not require any field; got {arr:?}"
1545 );
1546 }
1547 }
1548
1549 #[test]
1550 fn capabilities_parity_allowed_diffs_documented_983() {
1551 // Sanity-asserts the explicit allowed-diffs catalog. If the
1552 // schemars output structurally drifts away from the
1553 // documented set, this test pins the regression.
1554 let derived = derived_properties();
1555 // Each Option<T> property must have a nullable type AND a
1556 // null default. Both are byproducts of the Option<T> wrap.
1557 for name in &["accept", "family", "include_schema", "verbose"] {
1558 let prop = derived
1559 .get(*name)
1560 .unwrap_or_else(|| panic!("derived property `{name}` missing"));
1561 let type_value = prop.get("type").expect("each property has `type`");
1562 // Type is an array containing both the concrete type and "null".
1563 let arr = type_value
1564 .as_array()
1565 .unwrap_or_else(|| panic!("`{name}.type` must be an array (Option<T> nullable)"));
1566 assert!(
1567 arr.iter().any(|v| v.as_str() == Some("null")),
1568 "`{name}.type` must include `\"null\"` (Option<T> derive)"
1569 );
1570 assert_eq!(
1571 prop.get("default"),
1572 Some(&Value::Null),
1573 "`{name}.default` must be `null` (Option<T>::None)"
1574 );
1575 }
1576 }
1577}