ontoref/adrs/adr-015-mcp-tool-inventory-auto-derive.ncl

89 lines
8.7 KiB
Text
Raw Normal View History

feat: #[onto_mcp_tool] catalog, OCI credential vault layer, validate ADR-018 mode hierarchy ontoref-derive: #[onto_mcp_tool] attribute macro registers MCP tool unit-structs in the catalog at link time via inventory::submit!; annotated item is emitted unchanged, ToolBase/AsyncTool impls stay on the struct. All 34 tools migrated from manual wiring (net +5: ontoref_list_projects, ontoref_search, ontoref_describe, ontoref_list_ontology_extensions, ontoref_get_ontology_extension). validate modes (ADR-018): reads level_hierarchy from workflow.ncl and checks every .ncl mode for level declared, strategy declared, delegate chain coherent, compose extends valid. mode resolve <id> shows which hierarchy level handles a mode and why. --self-test generates synthetic fixtures in a temp dir for CI smoke-testing. validate run-cargo: two-step Cargo.toml resolution — workspace layout first (crates/<check.crate>/Cargo.toml), single-crate fallback by package name or repo basename. Lets the same ADR constraint shape apply to workspace and single-crate repos. ontology/schemas/manifest.ncl: registry_topology_type contract — multi-registry coordination, push targets, participant scopes, per-namespace capability. reflection/requirements/base.ncl: oras ≥1.2.0, cosign ≥2.0.0, sops ≥3.9.0, age ≥1.1.0, restic declared as Hard/Soft requirements with version_min, check_cmd, and install_hint (ADR-017 toolchain surface). ADR-019: per-file recipient routing for tenant isolation without multi-vault. Schema additions: sops.recipient_groups + sops.recipient_rules in ontoref-project.ncl. secrets-bootstrap generates .sops.yaml from project.ncl in declarative mode. Three new secrets-audit checks: recipient-routing-coherent, recipient-routing-coverage, no-multi-vault. Adoption templates: single-team/, multi-tenant/, agent-first/. Integration templates: domain-producer/, mode-producer/, mode-consumer/. UI: project_picker surfaces registry badge (⟳ participant) and vault badge (⛁ vault_id · N, green=declarative / amber=legacy) per project card. Expanded panel adds collapsible Registry section with namespace, endpoint, and push/pull capability. manage.html gains Runtime Services card — MCP and GraphQL toggleable without restart via HTMX POST /ui/manage/services/{service}/toggle. describe.nu: capabilities JSON includes registry_topology and vault_state per project. sync.nu: drift check extended to detect //! absence on newly registered crates. qa.ncl: six entries — credential-vault-best-practice (layered data-flow diagram), credential-vault-templates (paths A/B/C), credential-vault-troubleshooting (15 named errors), integration-what-and-why (ADR-042 OCI federation), integration-how-to-implement, integration-troubleshooting. on+re: core.ncl + manifest.ncl updated to reflect OCI, MCP, and mode-hierarchy nodes. Deleted stale presentation assets (2026-02 slides + voice notes).
2026-05-12 04:46:15 +01:00
let d = import "adr-defaults.ncl" in
d.make_adr {
id = "adr-015",
title = "MCP Tool Catalog via #[onto_mcp_tool] Proc-Macro + Inventory",
status = 'Accepted,
date = "2026-04-26",
context = "ontoref-daemon exposes 33 MCP tools via the rmcp ToolRouter in crates/ontoref-daemon/src/mcp/mod.rs. The authoritative tool implementation lives in each tool struct's `ToolBase`/`AsyncTool` impls (name, description, schema). However, the agent-facing tool catalog returned by `ontoref_help` was a hand-typed JSON literal (~100 lines, mod.rs:1129-1226) duplicating every tool's name, description, and parameter shape. This is the same failure mode ADR-007 documents as the original motivation for `#[onto_api]`: the previous-session bug where `insert_mcp_ctx` listed 15 tools while the router had 27. ADR-007 fixed that drift for the HTTP API surface (inventory-collected `ApiRouteEntry`) but did not extend the pattern to MCP. As of 2026-04-26 the manifest claim 'daemon exposes 33 MCP tools' is also a hand-maintained string in `.ontology/manifest.ncl`. With the rate of MCP tool additions through 2026-Q1 (qa, bookmarks, actions, config, ontology extensions all added in separate sessions), drift was becoming inevitable.",
decision = "Every MCP tool struct in ontoref-daemon must carry `#[onto_mcp_tool(name, description, category, params)]`. The proc-macro (in `crates/ontoref-derive`) emits `inventory::submit!(ontoref_ontology::McpToolEntry{...})` at link time and leaves the annotated struct unchanged — the existing `ToolBase` and `AsyncTool` impls are untouched. A new pure function `ontoref_daemon::mcp::catalog()` walks `inventory::iter::<McpToolEntry>()`, sorts by name, and returns `Vec<&'static McpToolEntry>`. `HelpTool::invoke` now serializes `catalog()` instead of holding a hand-typed JSON literal. `McpToolEntry` lives in `ontoref-ontology` next to `ApiRouteEntry` and reuses `ApiParam` for parameter metadata — both are protocol surfaces, the type is generic. `tool_router()`'s compile-time `with_async_tool::<T>()` list is left as-is; the Rust type system requires the type list at compile time and a separate macro architecture would be needed to derive it from inventory (out of scope for this ADR).",
rationale = [
{
claim = "Same drift class as ADR-007 — same fix pattern",
detail = "ADR-007 cites the insert_mcp_ctx (15 listed) vs router (27 actual) drift as proof that hand-maintained registries decay. The HelpTool JSON literal had identical mechanics: every new tool struct required two coordinated edits (with_async_tool registration + JSON entry) with no compiler enforcement that they stayed in sync. Applying the same inventory linker pattern that fixed it for HTTP routes closes the equivalent gap for MCP.",
},
{
claim = "Co-location of annotation and implementation",
detail = "`#[onto_mcp_tool]` sits directly on the tool struct, immediately above its `ToolBase` impl. Adding a tool that compiles but has no inventory entry requires actively skipping the attribute — a much higher-friction error than forgetting to update a JSON list 100 lines away in the same file. Removing a tool struct removes its inventory entry automatically.",
},
{
claim = "Zero new dependencies",
detail = "ontoref-derive and inventory are already workspace dependencies (introduced by ADR-007). McpToolEntry mirrors ApiRouteEntry's shape and reuses ApiParam — no schema duplication. The change is additive: existing #[onto_api] macros are unaffected.",
},
{
claim = "Preserves runtime cost guarantees",
detail = "inventory::iter walks a linker-built linked list — no HashMap, no Arc, no allocation. catalog() is a pure function returning &'static references. HelpTool's per-call cost is unchanged (still O(n) over the tool list, no cache invalidation, no daemon state required).",
},
],
consequences = {
positive = [
"Adding a tool struct without #[onto_mcp_tool] makes the tool invisible to ontoref_help — drift is detectable on first agent invocation",
"Removing a tool struct removes its catalog entry automatically; no orphaned help entries",
"Manifest claim '33 MCP tools' is verifiable at runtime via catalog().len() rather than asserted by hand",
"Future tool tiering (essential vs extended, mentioned but deferred from this ADR) becomes a single-field addition to McpToolEntry",
"Same proc-macro infrastructure as #[onto_api] — no new linker semantics, no platform-specific concerns introduced",
],
negative = [
"Per-tool params are stringly-typed in the `params = \"name:type:constraint:desc; ...\"` attribute argument — a typo in `required` vs `optional` is not caught at compile time (mirrors the same limitation in #[onto_api])",
"tool_router()'s with_async_tool::<T>() list still requires manual maintenance — the inventory does not eliminate it. A drift between router registrations and inventory entries is possible (caught only at runtime by the regression test, not at compile time)",
"33 tool structs require a one-time annotation pass — non-trivial diff size, but mechanical and reviewable",
],
},
alternatives_considered = [
{
option = "Generate the help JSON from ToolBase::name() + description() directly via reflection",
why_rejected = "ToolBase exposes name and description but not parameter metadata in a structured form (input_schema returns a JsonObject blob, not the per-param required/values/note shape that ontoref_help emits). Walking the JSON schema and reverse-engineering the per-param hints is brittle. The inventory entry lets us declare the agent-facing param documentation in a stable, typed shape next to the ToolBase impl.",
},
{
option = "Replace tool_router()'s with_async_tool::<T>() list with macro-driven iteration over inventory",
why_rejected = "rmcp's ToolRouter requires the tool type at compile time (generic associated types over each tool's Parameter/Output types). Driving registration from inventory entries — which are runtime values of `&'static McpToolEntry` — would require either a build.rs that emits the registration list or a macro that takes the full type list as input. Either is feasible but is a larger architectural change with no immediate reliability win beyond what the inventory already provides for the help/catalog surface. Deferred.",
},
{
option = "Skip the macro and write an `inventory::submit!` block manually next to each tool",
why_rejected = "Eliminates the macro infrastructure but loses the validation that #[onto_mcp_tool] applies (key spelling check, param string parsing reuse). Manual blocks also bypass the file!() capture for source-file traceability that ApiRouteEntry already uses.",
},
],
constraints = [
{
id = "onto-mcp-tool-on-all-tools",
claim = "Every MCP tool struct in ontoref-daemon must carry #[onto_mcp_tool(...)]",
scope = "ontoref-daemon (crates/ontoref-daemon/src/mcp/mod.rs)",
severity = 'Hard,
check = { tag = 'Grep, pattern = "#\\[onto_mcp_tool", paths = ["crates/ontoref-daemon/src/mcp/mod.rs"], must_be_empty = false },
rationale = "catalog() is only as complete as the set of annotated tool structs. Unannotated tools are invisible to agents calling ontoref_help — equivalent to undocumented tools and a re-introduction of the ADR-007 drift class.",
},
{
id = "mcp-catalog-router-parity",
claim = "The number of #[onto_mcp_tool] annotations must equal the number of with_async_tool::<T>() calls in tool_router()",
scope = "ontoref-daemon (crates/ontoref-daemon/src/mcp/mod.rs)",
severity = 'Soft,
check = { tag = 'NuCmd, command = "let a = (open crates/ontoref-daemon/src/mcp/mod.rs | lines | where ($it | str starts-with '#[ontoref_derive::onto_mcp_tool') | length); let b = (open crates/ontoref-daemon/src/mcp/mod.rs | lines | where ($it | str contains 'with_async_tool::<') | length); if $a != $b { error make {msg: $'annotation count ($a) != router registration count ($b)'} } else { 'ok' }" },
rationale = "Without compile-time enforcement, a tool could be registered with the router but lack the annotation (or vice versa). This soft constraint catches the divergence at validate time. Future work (deferred alternative above) can lift this to compile time.",
},
],
related_adrs = ["adr-007", "adr-001"],
ontology_check = {
decision_string = "Use #[onto_mcp_tool] proc-macro + inventory linker registration as the single source of truth for the MCP tool catalog; HelpTool renders from catalog() instead of a hand-typed JSON literal",
invariants_at_risk = ["protocol-not-runtime"],
verdict = 'Safe,
},
}