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}