2026-03-13 00:18:14 +00:00
|
|
|
use std::{borrow::Cow, path::PathBuf, sync::Arc};
|
|
|
|
|
|
|
|
|
|
use rmcp::{
|
|
|
|
|
handler::server::{
|
|
|
|
|
router::tool::{AsyncTool, ToolBase},
|
|
|
|
|
tool::ToolRouter,
|
|
|
|
|
},
|
|
|
|
|
model::*,
|
|
|
|
|
tool_handler, ErrorData, ServerHandler, ServiceExt,
|
|
|
|
|
};
|
|
|
|
|
use schemars::JsonSchema;
|
|
|
|
|
use serde::Deserialize;
|
|
|
|
|
use tracing::{debug, warn};
|
|
|
|
|
|
|
|
|
|
use crate::{api::AppState, cache::NclCache};
|
|
|
|
|
|
|
|
|
|
// ── Error ───────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct ToolError(String);
|
|
|
|
|
|
|
|
|
|
impl From<ToolError> for ErrorData {
|
|
|
|
|
fn from(e: ToolError) -> ErrorData {
|
|
|
|
|
ErrorData::internal_error(e.0, None)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl From<serde_json::Error> for ToolError {
|
|
|
|
|
fn from(e: serde_json::Error) -> Self {
|
|
|
|
|
ToolError(e.to_string())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl From<String> for ToolError {
|
|
|
|
|
fn from(s: String) -> Self {
|
|
|
|
|
ToolError(s)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Project context
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct ProjectCtx {
|
|
|
|
|
root: PathBuf,
|
|
|
|
|
cache: Arc<NclCache>,
|
|
|
|
|
import_path: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool parameter types
|
|
|
|
|
// ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, JsonSchema, Default)]
|
|
|
|
|
struct NoInput {}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, JsonSchema, Default)]
|
|
|
|
|
struct ProjectParam {
|
|
|
|
|
/// Project slug. Omit to use the default (single-project) project.
|
|
|
|
|
project: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
---
feat: API catalog surface, protocol v2 tooling, MCP expansion, on+re update
## Summary
Session 2026-03-23. Closes the loop between handler code and discoverability
across all three surfaces (browser, CLI, MCP agent) via compile-time inventory
registration. Adds protocol v2 update tooling, extends MCP from 21 to 29 tools,
and brings the self-description up to date.
## API Catalog Surface (#[onto_api] proc-macro)
- crates/ontoref-derive: new proc-macro crate; `#[onto_api(method, path,
description, auth, actors, params, tags)]` emits `inventory::submit!(ApiRouteEntry{...})`
at link time
- crates/ontoref-daemon/src/api_catalog.rs: `catalog()` — pure fn over
`inventory::iter::<ApiRouteEntry>()`, zero runtime allocation
- GET /api/catalog: returns full annotated HTTP surface as JSON
- templates/pages/api_catalog.html: new page with client-side filtering by
method, auth, path/description; detail panel per route (params table,
feature flag); linked from dashboard card and nav
- UI nav: "API" link (</> icon) added to mobile dropdown and desktop bar
- inventory = "0.3" added to workspace.dependencies (MIT, zero transitive deps)
## Protocol Update Mode
- reflection/modes/update_ontoref.ncl: 9-step DAG (5 detect parallel, 2 update
idempotent, 2 validate, 1 report) — brings any project from protocol v1 to v2
by adding manifest.ncl and connections.ncl if absent, scanning ADRs for
deprecated check_hint, validating with nickel export
- reflection/templates/update-ontology-prompt.md: 8-phase reusable prompt for
agent-driven ontology enrichment (infrastructure → audit → core.ncl →
state.ncl → manifest.ncl → connections.ncl → ADR migration → validation)
## CLI — describe group extensions
- reflection/bin/ontoref.nu: `describe diff [--fmt] [--file]` and
`describe api [--actor] [--tag] [--auth] [--fmt]` registered as canonical
subcommands with log-action; aliases `df` and `da` added; QUICK REFERENCE
and ALIASES sections updated
## MCP — two new tools (21 → 29 total)
- ontoref_api_catalog: filters catalog() output by actor/tag/auth; returns
{ routes, total } — no HTTP roundtrip, calls inventory directly
- ontoref_file_versions: reads ProjectContext.file_versions DashMap per slug;
returns BTreeMap<filename, u64> reload counters
- insert_mcp_ctx: audited and updated from 15 to 28 entries in 6 groups
- HelpTool JSON: 8 new entries (validate_adrs, validate, impact, guides,
bookmark_list, bookmark_add, api_catalog, file_versions)
- ServerHandler::get_info instructions updated to mention new tools
## Web UI — dashboard additions
- Dashboard: "API Catalog" card (9th); "Ontology File Versions" section showing
per-file reload counters from file_versions DashMap
- dashboard_mp: builds BTreeMap<String, u64> from ctx.file_versions and injects
into Tera context
## on+re update
- .ontology/core.ncl: describe-query-layer and adopt-ontoref-tooling descriptions
updated; ontoref-daemon updated ("11 pages", "29 tools", API catalog,
per-file versioning, #[onto_api]); new node api-catalog-surface (Yang/Practice)
with 3 edges; artifact_paths extended across 3 nodes
- .ontology/state.ncl: protocol-maturity blocker updated (protocol v2 complete);
self-description-coverage catalyst updated with session 2026-03-23 additions
- ADR-007: "API Surface Discoverability via #[onto_api] Proc-Macro" — Accepted
## Documentation
- README.md: crates table updated (11 pages, 29 MCP tools, ontoref-derive row);
MCP representative table expanded; API Catalog, Semantic Diff, Per-File
Versioning paragraphs added; update_ontoref onboarding section added
- CHANGELOG.md: [Unreleased] section with 4 change groups
- assets/web/src/index.html: tool counts 19→29 (EN+ES), page counts 12→11
(EN+ES), daemon description paragraph updated with API catalog + #[onto_api]
2026-03-23 00:58:27 +01:00
|
|
|
#[derive(Deserialize, JsonSchema, Default)]
|
|
|
|
|
struct GuidesInput {
|
|
|
|
|
/// Project slug. Omit to use the default project.
|
|
|
|
|
project: Option<String>,
|
|
|
|
|
/// Actor context for policy derivation: developer | agent | ci | admin.
|
|
|
|
|
/// Omit to use the detected actor.
|
|
|
|
|
actor: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, JsonSchema, Default)]
|
|
|
|
|
struct ApiCatalogInput {
|
|
|
|
|
/// Filter by actor: developer | agent | ci | admin. Omit to return all
|
|
|
|
|
/// routes.
|
|
|
|
|
actor: Option<String>,
|
|
|
|
|
/// Filter by tag (e.g. "ontology", "projects", "search"). Omit for all
|
|
|
|
|
/// tags.
|
|
|
|
|
tag: Option<String>,
|
|
|
|
|
/// Filter by auth level: none | viewer | admin. Omit for all auth levels.
|
|
|
|
|
auth: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, JsonSchema, Default)]
|
|
|
|
|
struct FileVersionsInput {
|
|
|
|
|
/// Project slug. Omit to use the default project.
|
|
|
|
|
project: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 00:18:14 +00:00
|
|
|
#[derive(Deserialize, JsonSchema, Default)]
|
|
|
|
|
struct SearchInput {
|
|
|
|
|
/// Full-text search query across ontology nodes, ADRs, and reflection
|
|
|
|
|
/// modes.
|
|
|
|
|
query: String,
|
|
|
|
|
/// Project slug. Omit to use the default project.
|
|
|
|
|
project: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, JsonSchema, Default)]
|
|
|
|
|
struct GetItemInput {
|
|
|
|
|
/// Item id or partial filename stem (e.g. `adr-001`, `plan-mode`).
|
|
|
|
|
id: String,
|
|
|
|
|
/// Project slug. Omit to use the default project.
|
|
|
|
|
project: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, JsonSchema, Default)]
|
|
|
|
|
struct BacklogInput {
|
|
|
|
|
/// Project slug. Omit to use the default project.
|
|
|
|
|
project: Option<String>,
|
|
|
|
|
/// Filter by status: `Open` | `InProgress` | `Done` | `Cancelled`.
|
|
|
|
|
status: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, JsonSchema, Default)]
|
|
|
|
|
struct GetInput {
|
|
|
|
|
/// Item id as returned by `ontoref_search` (e.g. `backend-agnostic-core`,
|
|
|
|
|
/// `adr-001`, `plan-mode`).
|
|
|
|
|
id: String,
|
|
|
|
|
/// Kind of item: `"node"` | `"adr"` | `"mode"`.
|
|
|
|
|
kind: String,
|
|
|
|
|
/// Project slug. Omit to use the current/default project.
|
|
|
|
|
project: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, JsonSchema, Default)]
|
|
|
|
|
struct SetProjectInput {
|
|
|
|
|
/// Project slug to set as the current default for this MCP session.
|
|
|
|
|
project: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, JsonSchema, Default)]
|
|
|
|
|
struct BacklogOpInput {
|
|
|
|
|
/// Operation: `"add"` to create a new item, `"update_status"` to change
|
|
|
|
|
/// status.
|
|
|
|
|
operation: String,
|
|
|
|
|
/// Project slug. Omit to use the current/default project.
|
|
|
|
|
project: Option<String>,
|
|
|
|
|
// ── update_status fields ──
|
|
|
|
|
/// Item id (e.g. `bl-001`). Required for `update_status`.
|
|
|
|
|
id: Option<String>,
|
|
|
|
|
/// New status: `Open` | `InProgress` | `Done` | `Cancelled`. Required for
|
|
|
|
|
/// `update_status`.
|
|
|
|
|
status: Option<String>,
|
|
|
|
|
// ── add fields ──
|
|
|
|
|
/// Item title. Required for `add`.
|
|
|
|
|
title: Option<String>,
|
|
|
|
|
/// Item kind: `Feature` | `Bug` | `Chore` | `Research`. Required for `add`.
|
|
|
|
|
kind: Option<String>,
|
|
|
|
|
/// Item priority: `Critical` | `High` | `Medium` | `Low`. Required for
|
|
|
|
|
/// `add`.
|
|
|
|
|
priority: Option<String>,
|
|
|
|
|
/// Optional detail or acceptance criteria for `add`.
|
|
|
|
|
detail: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, JsonSchema, Default)]
|
|
|
|
|
struct QaListInput {
|
|
|
|
|
/// Project slug. Omit to use the default project.
|
|
|
|
|
project: Option<String>,
|
|
|
|
|
/// Optional substring filter on question text.
|
|
|
|
|
filter: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, JsonSchema, Default)]
|
|
|
|
|
struct QaAddInput {
|
|
|
|
|
/// The question text.
|
|
|
|
|
question: String,
|
|
|
|
|
/// The answer text. May be left empty to fill in later.
|
|
|
|
|
answer: Option<String>,
|
|
|
|
|
/// Actor recording this entry. Defaults to `"agent"`.
|
|
|
|
|
actor: Option<String>,
|
|
|
|
|
/// Optional tags for categorisation.
|
|
|
|
|
tags: Option<Vec<String>>,
|
|
|
|
|
/// Optional references to ontology node ids or ADR ids.
|
|
|
|
|
related: Option<Vec<String>>,
|
|
|
|
|
/// Project slug. Omit to use the default project.
|
|
|
|
|
project: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 01:48:17 +00:00
|
|
|
#[derive(Deserialize, JsonSchema, Default)]
|
|
|
|
|
struct BookmarkListInput {
|
|
|
|
|
/// Project slug. Omit to use the default project.
|
|
|
|
|
project: Option<String>,
|
|
|
|
|
/// Optional substring filter on node_id or title.
|
|
|
|
|
filter: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, JsonSchema, Default)]
|
|
|
|
|
struct BookmarkAddInput {
|
|
|
|
|
/// Ontology node id to bookmark (e.g. `"add-project"`).
|
|
|
|
|
node_id: String,
|
|
|
|
|
/// Kind of the result: `"node"`, `"adr"`, or `"mode"`.
|
|
|
|
|
kind: Option<String>,
|
|
|
|
|
/// Human-readable title of the bookmarked node.
|
|
|
|
|
title: String,
|
|
|
|
|
/// Ontology level: `Axiom`, `Tension`, `Practice`, `Project`. May be empty.
|
|
|
|
|
level: Option<String>,
|
|
|
|
|
/// Search term that produced this result.
|
|
|
|
|
term: Option<String>,
|
|
|
|
|
/// Actor saving the bookmark. Defaults to `"agent"`.
|
|
|
|
|
actor: Option<String>,
|
|
|
|
|
/// Optional tags for categorisation.
|
|
|
|
|
tags: Option<Vec<String>>,
|
|
|
|
|
/// Project slug. Omit to use the default project.
|
|
|
|
|
project: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 00:18:14 +00:00
|
|
|
#[derive(Deserialize, JsonSchema, Default)]
|
|
|
|
|
struct ActionListInput {
|
|
|
|
|
/// Project slug. Omit to use the default project.
|
|
|
|
|
project: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, JsonSchema, Default)]
|
|
|
|
|
struct ActionAddInput {
|
|
|
|
|
/// Short unique slug for the mode file (e.g. `gen-docs`).
|
|
|
|
|
id: String,
|
|
|
|
|
/// Human-readable label shown in the UI.
|
|
|
|
|
label: String,
|
|
|
|
|
/// Natural-language trigger description.
|
|
|
|
|
trigger: String,
|
|
|
|
|
/// Steps as an ordered list of strings.
|
|
|
|
|
steps: Vec<String>,
|
|
|
|
|
/// Icon hint: `"book-open"` | `"refresh"` | `"code"` | `"bolt"`.
|
|
|
|
|
icon: Option<String>,
|
|
|
|
|
/// Category: `"docs"` | `"sync"` | `"analysis"` | `"test"`.
|
|
|
|
|
category: Option<String>,
|
|
|
|
|
/// Actors who can run this action: `"developer"` | `"agent"` | `"ci"`.
|
|
|
|
|
actors: Option<Vec<String>>,
|
|
|
|
|
/// Project slug. Omit to use the default project.
|
|
|
|
|
project: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Server ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[derive(Clone)]
|
|
|
|
|
pub struct OntoreServer {
|
|
|
|
|
state: AppState,
|
|
|
|
|
tool_router: ToolRouter<Self>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl OntoreServer {
|
|
|
|
|
pub fn new(state: AppState) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
state,
|
|
|
|
|
tool_router: Self::tool_router(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn tool_router() -> ToolRouter<Self> {
|
|
|
|
|
ToolRouter::new()
|
|
|
|
|
.with_async_tool::<HelpTool>()
|
|
|
|
|
.with_async_tool::<ListProjectsTool>()
|
|
|
|
|
.with_async_tool::<SetProjectTool>()
|
|
|
|
|
.with_async_tool::<SearchTool>()
|
|
|
|
|
.with_async_tool::<GetTool>()
|
|
|
|
|
.with_async_tool::<DescribeProjectTool>()
|
|
|
|
|
.with_async_tool::<ProjectStatusTool>()
|
|
|
|
|
.with_async_tool::<ListAdrsTool>()
|
|
|
|
|
.with_async_tool::<GetAdrTool>()
|
2026-03-16 01:48:17 +00:00
|
|
|
.with_async_tool::<ListOntologyExtensionsTool>()
|
|
|
|
|
.with_async_tool::<GetOntologyExtensionTool>()
|
2026-03-13 00:18:14 +00:00
|
|
|
.with_async_tool::<ListModesTool>()
|
|
|
|
|
.with_async_tool::<GetModeTool>()
|
|
|
|
|
.with_async_tool::<GetNodeTool>()
|
|
|
|
|
.with_async_tool::<GetBacklogTool>()
|
|
|
|
|
.with_async_tool::<BacklogOpTool>()
|
|
|
|
|
.with_async_tool::<GetConstraintsTool>()
|
|
|
|
|
.with_async_tool::<QaListTool>()
|
|
|
|
|
.with_async_tool::<QaAddTool>()
|
2026-03-16 01:48:17 +00:00
|
|
|
.with_async_tool::<BookmarkListTool>()
|
|
|
|
|
.with_async_tool::<BookmarkAddTool>()
|
2026-03-13 00:18:14 +00:00
|
|
|
.with_async_tool::<ActionListTool>()
|
|
|
|
|
.with_async_tool::<ActionAddTool>()
|
---
feat: API catalog surface, protocol v2 tooling, MCP expansion, on+re update
## Summary
Session 2026-03-23. Closes the loop between handler code and discoverability
across all three surfaces (browser, CLI, MCP agent) via compile-time inventory
registration. Adds protocol v2 update tooling, extends MCP from 21 to 29 tools,
and brings the self-description up to date.
## API Catalog Surface (#[onto_api] proc-macro)
- crates/ontoref-derive: new proc-macro crate; `#[onto_api(method, path,
description, auth, actors, params, tags)]` emits `inventory::submit!(ApiRouteEntry{...})`
at link time
- crates/ontoref-daemon/src/api_catalog.rs: `catalog()` — pure fn over
`inventory::iter::<ApiRouteEntry>()`, zero runtime allocation
- GET /api/catalog: returns full annotated HTTP surface as JSON
- templates/pages/api_catalog.html: new page with client-side filtering by
method, auth, path/description; detail panel per route (params table,
feature flag); linked from dashboard card and nav
- UI nav: "API" link (</> icon) added to mobile dropdown and desktop bar
- inventory = "0.3" added to workspace.dependencies (MIT, zero transitive deps)
## Protocol Update Mode
- reflection/modes/update_ontoref.ncl: 9-step DAG (5 detect parallel, 2 update
idempotent, 2 validate, 1 report) — brings any project from protocol v1 to v2
by adding manifest.ncl and connections.ncl if absent, scanning ADRs for
deprecated check_hint, validating with nickel export
- reflection/templates/update-ontology-prompt.md: 8-phase reusable prompt for
agent-driven ontology enrichment (infrastructure → audit → core.ncl →
state.ncl → manifest.ncl → connections.ncl → ADR migration → validation)
## CLI — describe group extensions
- reflection/bin/ontoref.nu: `describe diff [--fmt] [--file]` and
`describe api [--actor] [--tag] [--auth] [--fmt]` registered as canonical
subcommands with log-action; aliases `df` and `da` added; QUICK REFERENCE
and ALIASES sections updated
## MCP — two new tools (21 → 29 total)
- ontoref_api_catalog: filters catalog() output by actor/tag/auth; returns
{ routes, total } — no HTTP roundtrip, calls inventory directly
- ontoref_file_versions: reads ProjectContext.file_versions DashMap per slug;
returns BTreeMap<filename, u64> reload counters
- insert_mcp_ctx: audited and updated from 15 to 28 entries in 6 groups
- HelpTool JSON: 8 new entries (validate_adrs, validate, impact, guides,
bookmark_list, bookmark_add, api_catalog, file_versions)
- ServerHandler::get_info instructions updated to mention new tools
## Web UI — dashboard additions
- Dashboard: "API Catalog" card (9th); "Ontology File Versions" section showing
per-file reload counters from file_versions DashMap
- dashboard_mp: builds BTreeMap<String, u64> from ctx.file_versions and injects
into Tera context
## on+re update
- .ontology/core.ncl: describe-query-layer and adopt-ontoref-tooling descriptions
updated; ontoref-daemon updated ("11 pages", "29 tools", API catalog,
per-file versioning, #[onto_api]); new node api-catalog-surface (Yang/Practice)
with 3 edges; artifact_paths extended across 3 nodes
- .ontology/state.ncl: protocol-maturity blocker updated (protocol v2 complete);
self-description-coverage catalyst updated with session 2026-03-23 additions
- ADR-007: "API Surface Discoverability via #[onto_api] Proc-Macro" — Accepted
## Documentation
- README.md: crates table updated (11 pages, 29 MCP tools, ontoref-derive row);
MCP representative table expanded; API Catalog, Semantic Diff, Per-File
Versioning paragraphs added; update_ontoref onboarding section added
- CHANGELOG.md: [Unreleased] section with 4 change groups
- assets/web/src/index.html: tool counts 19→29 (EN+ES), page counts 12→11
(EN+ES), daemon description paragraph updated with API catalog + #[onto_api]
2026-03-23 00:58:27 +01:00
|
|
|
.with_async_tool::<ValidateAdrsTool>()
|
|
|
|
|
.with_async_tool::<ValidateProjectTool>()
|
|
|
|
|
.with_async_tool::<ImpactTool>()
|
|
|
|
|
.with_async_tool::<GuidesTool>()
|
|
|
|
|
.with_async_tool::<ApiCatalogTool>()
|
|
|
|
|
.with_async_tool::<FileVersionsTool>()
|
2026-03-13 00:18:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn project_ctx(&self, slug: Option<&str>) -> ProjectCtx {
|
|
|
|
|
// Explicit slug > mcp_current_project > default project.
|
|
|
|
|
let current = self
|
|
|
|
|
.state
|
|
|
|
|
.mcp_current_project
|
|
|
|
|
.read()
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|g| g.clone());
|
|
|
|
|
let effective = slug.map(str::to_string).or(current);
|
|
|
|
|
|
|
|
|
|
#[cfg(feature = "ui")]
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
if let Some(s) = effective.as_deref() {
|
|
|
|
|
if let Some(ctx) = self.state.registry.get(s) {
|
2026-03-13 00:18:14 +00:00
|
|
|
return ProjectCtx {
|
|
|
|
|
root: ctx.root.clone(),
|
|
|
|
|
cache: ctx.cache.clone(),
|
|
|
|
|
import_path: ctx.import_path.clone(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let _ = effective;
|
|
|
|
|
ProjectCtx {
|
|
|
|
|
root: self.state.project_root.clone(),
|
|
|
|
|
cache: self.state.cache.clone(),
|
|
|
|
|
import_path: self.state.nickel_import_path.clone(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn available_projects(&self) -> Vec<String> {
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
self.state
|
|
|
|
|
.registry
|
|
|
|
|
.all()
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|p| p.slug.clone())
|
|
|
|
|
.collect()
|
2026-03-13 00:18:14 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: list_projects
|
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct ListProjectsTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for ListProjectsTool {
|
|
|
|
|
type Parameter = NoInput;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_list_projects".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
"List all available projects. In single-project mode returns the current project name."
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn input_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for ListProjectsTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
_param: NoInput,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "list_projects");
|
|
|
|
|
let projects = service.available_projects();
|
|
|
|
|
Ok(serde_json::json!({ "projects": projects }))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: search
|
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct SearchTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for SearchTool {
|
|
|
|
|
type Parameter = SearchInput;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_search".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
"Free-text search across ontology nodes, ADRs, and reflection modes. Returns ranked \
|
|
|
|
|
results with kind, title, and description."
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for SearchTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: SearchInput,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "search", query = %param.query, project = ?param.project);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
let results = crate::search::search_project(
|
|
|
|
|
&ctx.root,
|
|
|
|
|
&ctx.cache,
|
|
|
|
|
ctx.import_path.as_deref(),
|
|
|
|
|
¶m.query,
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
serde_json::to_value(results).map_err(ToolError::from)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: describe_project
|
|
|
|
|
// ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct DescribeProjectTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for DescribeProjectTool {
|
|
|
|
|
type Parameter = ProjectParam;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_describe".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
"Describe a project: README summary, manifest layers, operational modes, default \
|
|
|
|
|
mode, and repo kind."
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for DescribeProjectTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: ProjectParam,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "describe_project", project = ?param.project);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
let manifest_path = ctx.root.join(".ontology").join("manifest.ncl");
|
|
|
|
|
|
|
|
|
|
let manifest = if manifest_path.exists() {
|
|
|
|
|
match ctx
|
|
|
|
|
.cache
|
|
|
|
|
.export(&manifest_path, ctx.import_path.as_deref())
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok((v, _)) => v,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
warn!(error = %e, "describe_project: manifest export failed");
|
|
|
|
|
serde_json::Value::Null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
serde_json::Value::Null
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let description = crate::ui::handlers::readme_description(&ctx.root);
|
|
|
|
|
Ok(serde_json::json!({
|
|
|
|
|
"root": ctx.root.display().to_string(),
|
|
|
|
|
"description": description,
|
|
|
|
|
"manifest": manifest,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: list_adrs
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct ListAdrsTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for ListAdrsTool {
|
|
|
|
|
type Parameter = ProjectParam;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_list_adrs".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
"List all Architecture Decision Records (ADRs) with id, title, status, and constraint \
|
|
|
|
|
counts."
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for ListAdrsTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: ProjectParam,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "list_adrs", project = ?param.project);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
let adrs_dir = ctx.root.join("adrs");
|
|
|
|
|
|
|
|
|
|
let Ok(entries) = std::fs::read_dir(&adrs_dir) else {
|
|
|
|
|
return Ok(serde_json::json!({ "adrs": [] }));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut adrs: Vec<serde_json::Value> = Vec::new();
|
|
|
|
|
for entry in entries.flatten() {
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let rel = path
|
|
|
|
|
.strip_prefix(&ctx.root)
|
|
|
|
|
.unwrap_or(&path)
|
|
|
|
|
.to_string_lossy()
|
|
|
|
|
.to_string();
|
|
|
|
|
|
|
|
|
|
match ctx.cache.export(&path, ctx.import_path.as_deref()).await {
|
|
|
|
|
Ok((v, _)) => {
|
|
|
|
|
let hard_count = v
|
|
|
|
|
.get("constraints")
|
|
|
|
|
.and_then(|c| c.get("hard"))
|
|
|
|
|
.and_then(|h| h.as_array())
|
|
|
|
|
.map(|a| a.len())
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
let soft_count = v
|
|
|
|
|
.get("constraints")
|
|
|
|
|
.and_then(|c| c.get("soft"))
|
|
|
|
|
.and_then(|s| s.as_array())
|
|
|
|
|
.map(|a| a.len())
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
adrs.push(serde_json::json!({
|
|
|
|
|
"id": v.get("id").and_then(|i| i.as_str()).unwrap_or(""),
|
|
|
|
|
"title": v.get("title").and_then(|t| t.as_str()).unwrap_or(""),
|
|
|
|
|
"status": v.get("status").and_then(|s| s.as_str()).unwrap_or(""),
|
|
|
|
|
"hard_constraints": hard_count,
|
|
|
|
|
"soft_constraints": soft_count,
|
|
|
|
|
"file": rel,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
Err(e) => warn!(path = %rel, error = %e, "list_adrs: export failed"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
adrs.sort_by_key(|v| v["id"].as_str().unwrap_or("").to_string());
|
|
|
|
|
Ok(serde_json::json!({ "adrs": adrs }))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: get_adr
|
|
|
|
|
// ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct GetAdrTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for GetAdrTool {
|
|
|
|
|
type Parameter = GetItemInput;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_get_adr".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
"Get full details of an Architecture Decision Record by id or partial filename stem \
|
|
|
|
|
(e.g. `adr-001`)."
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for GetAdrTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: GetItemInput,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "get_adr", id = %param.id, project = ?param.project);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
let adrs_dir = ctx.root.join("adrs");
|
|
|
|
|
|
|
|
|
|
let entries = std::fs::read_dir(&adrs_dir)
|
|
|
|
|
.map_err(|e| ToolError(format!("adrs directory not found: {e}")))?;
|
|
|
|
|
|
|
|
|
|
for entry in entries.flatten() {
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let stem = path
|
|
|
|
|
.file_stem()
|
|
|
|
|
.and_then(|s| s.to_str())
|
|
|
|
|
.unwrap_or("")
|
|
|
|
|
.to_string();
|
|
|
|
|
if !stem.contains(param.id.as_str()) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
return match ctx.cache.export(&path, ctx.import_path.as_deref()).await {
|
|
|
|
|
Ok((v, _)) => Ok(v),
|
|
|
|
|
Err(e) => Err(ToolError(e.to_string())),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Err(ToolError(format!("ADR '{}' not found", param.id)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 01:48:17 +00:00
|
|
|
// ── Tool: list_ontology_extensions
|
|
|
|
|
// ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct ListOntologyExtensionsTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for ListOntologyExtensionsTool {
|
|
|
|
|
type Parameter = ProjectParam;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_list_ontology_extensions".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
"List extra .ontology/*.ncl files beyond core.ncl, state.ncl, and gate.ncl. These are \
|
|
|
|
|
project-defined domain extensions (e.g. career.ncl, personal.ncl)."
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for ListOntologyExtensionsTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: ProjectParam,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "list_ontology_extensions", project = ?param.project);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
let ontology_dir = ctx.root.join(".ontology");
|
|
|
|
|
|
|
|
|
|
const CORE: &[&str] = &["core.ncl", "state.ncl", "gate.ncl"];
|
|
|
|
|
|
|
|
|
|
let Ok(entries) = std::fs::read_dir(&ontology_dir) else {
|
|
|
|
|
return Ok(serde_json::json!({ "extensions": [] }));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut extensions: Vec<serde_json::Value> = entries
|
|
|
|
|
.flatten()
|
|
|
|
|
.filter_map(|e| {
|
|
|
|
|
let path = e.path();
|
|
|
|
|
if path.extension().and_then(|x| x.to_str()) != Some("ncl") {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let name = path.file_name()?.to_str()?.to_string();
|
|
|
|
|
if CORE.contains(&name.as_str()) {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let stem = path.file_stem()?.to_str()?.to_string();
|
|
|
|
|
Some(serde_json::json!({ "file": name, "id": stem }))
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
extensions.sort_by_key(|v| v["id"].as_str().unwrap_or("").to_string());
|
|
|
|
|
Ok(serde_json::json!({ "extensions": extensions }))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: get_ontology_extension
|
|
|
|
|
// ────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct GetOntologyExtensionTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for GetOntologyExtensionTool {
|
|
|
|
|
type Parameter = GetItemInput;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_get_ontology_extension".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
"Export a project-defined .ontology extension file by stem (e.g. \"career\", \
|
|
|
|
|
\"personal\"). Returns the full exported JSON. Use ontoref_list_ontology_extensions \
|
|
|
|
|
to discover available files."
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for GetOntologyExtensionTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: GetItemInput,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "get_ontology_extension", id = %param.id, project = ?param.project);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
|
|
|
|
|
const CORE: &[&str] = &["core.ncl", "state.ncl", "gate.ncl"];
|
|
|
|
|
let file = if param.id.ends_with(".ncl") {
|
|
|
|
|
param.id.clone()
|
|
|
|
|
} else {
|
|
|
|
|
format!("{}.ncl", param.id)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if file.contains('/') || file.contains("..") || CORE.contains(&file.as_str()) {
|
|
|
|
|
return Err(ToolError(format!(
|
|
|
|
|
"'{}' is a core file — use dedicated tools for core/state/gate",
|
|
|
|
|
param.id
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let path = ctx.root.join(".ontology").join(&file);
|
|
|
|
|
if !path.exists() {
|
|
|
|
|
return Err(ToolError(format!(
|
|
|
|
|
"ontology extension '{}' not found",
|
|
|
|
|
param.id
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx.cache
|
|
|
|
|
.export(&path, ctx.import_path.as_deref())
|
|
|
|
|
.await
|
|
|
|
|
.map(|(v, _)| v)
|
|
|
|
|
.map_err(|e| ToolError(e.to_string()))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 00:18:14 +00:00
|
|
|
// ── Tool: list_modes
|
|
|
|
|
// ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct ListModesTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for ListModesTool {
|
|
|
|
|
type Parameter = ProjectParam;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_list_modes".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some("List all reflection modes with id, trigger description, and step count.".into())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for ListModesTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: ProjectParam,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "list_modes", project = ?param.project);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
let modes_dir = ctx.root.join("reflection").join("modes");
|
|
|
|
|
|
|
|
|
|
let Ok(entries) = std::fs::read_dir(&modes_dir) else {
|
|
|
|
|
return Ok(serde_json::json!({ "modes": [] }));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut modes: Vec<serde_json::Value> = Vec::new();
|
|
|
|
|
for entry in entries.flatten() {
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
match ctx.cache.export(&path, ctx.import_path.as_deref()).await {
|
|
|
|
|
Ok((v, _)) => {
|
|
|
|
|
let step_count = v
|
|
|
|
|
.get("steps")
|
|
|
|
|
.and_then(|s| s.as_array())
|
|
|
|
|
.map(|a| a.len())
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
modes.push(serde_json::json!({
|
|
|
|
|
"id": v.get("id").and_then(|i| i.as_str()).unwrap_or(""),
|
|
|
|
|
"trigger": v.get("trigger").and_then(|t| t.as_str()).unwrap_or(""),
|
|
|
|
|
"steps": step_count,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
let stem = path
|
|
|
|
|
.file_stem()
|
|
|
|
|
.and_then(|s| s.to_str())
|
|
|
|
|
.unwrap_or("")
|
|
|
|
|
.to_string();
|
|
|
|
|
warn!(mode = %stem, error = %e, "list_modes: export failed");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
modes.sort_by_key(|v| v["id"].as_str().unwrap_or("").to_string());
|
|
|
|
|
Ok(serde_json::json!({ "modes": modes }))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: get_mode
|
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct GetModeTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for GetModeTool {
|
|
|
|
|
type Parameter = GetItemInput;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_get_mode".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
"Get full details of a reflection mode including all steps, preconditions, and \
|
|
|
|
|
commands."
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for GetModeTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: GetItemInput,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "get_mode", id = %param.id, project = ?param.project);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
let modes_dir = ctx.root.join("reflection").join("modes");
|
|
|
|
|
|
|
|
|
|
let entries = std::fs::read_dir(&modes_dir)
|
|
|
|
|
.map_err(|_| ToolError("reflection/modes directory not found".to_string()))?;
|
|
|
|
|
|
|
|
|
|
for entry in entries.flatten() {
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let stem = path
|
|
|
|
|
.file_stem()
|
|
|
|
|
.and_then(|s| s.to_str())
|
|
|
|
|
.unwrap_or("")
|
|
|
|
|
.to_string();
|
|
|
|
|
if stem != param.id && !stem.contains(param.id.as_str()) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
return match ctx.cache.export(&path, ctx.import_path.as_deref()).await {
|
|
|
|
|
Ok((v, _)) => Ok(v),
|
|
|
|
|
Err(e) => Err(ToolError(e.to_string())),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Err(ToolError(format!("Mode '{}' not found", param.id)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: get_backlog
|
|
|
|
|
// ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct GetBacklogTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for GetBacklogTool {
|
|
|
|
|
type Parameter = BacklogInput;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_backlog_list".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
"Get project backlog items. Optionally filter by status: Open | InProgress | Done | \
|
|
|
|
|
Cancelled."
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for GetBacklogTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: BacklogInput,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "get_backlog", project = ?param.project, status = ?param.status);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
let backlog_path = ctx.root.join("reflection").join("backlog.ncl");
|
|
|
|
|
|
|
|
|
|
if !backlog_path.exists() {
|
|
|
|
|
return Ok(serde_json::json!({ "items": [] }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let (v, _) = ctx
|
|
|
|
|
.cache
|
|
|
|
|
.export(&backlog_path, ctx.import_path.as_deref())
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| ToolError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let items: Vec<serde_json::Value> = v
|
|
|
|
|
.get("items")
|
|
|
|
|
.and_then(|i| i.as_array())
|
|
|
|
|
.map(|arr| {
|
|
|
|
|
arr.iter()
|
|
|
|
|
.filter(|it| match ¶m.status {
|
|
|
|
|
Some(f) => it
|
|
|
|
|
.get("status")
|
|
|
|
|
.and_then(|s| s.as_str())
|
|
|
|
|
.map(|s| s.eq_ignore_ascii_case(f))
|
|
|
|
|
.unwrap_or(false),
|
|
|
|
|
None => true,
|
|
|
|
|
})
|
|
|
|
|
.cloned()
|
|
|
|
|
.collect()
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
Ok(serde_json::json!({ "items": items }))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: get_node
|
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct GetNodeTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for GetNodeTool {
|
|
|
|
|
type Parameter = GetItemInput;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_get_node".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
concat!(
|
|
|
|
|
"Get full details of an ontology node by id. ",
|
|
|
|
|
"Use after `search` to retrieve complete node data — ",
|
|
|
|
|
"axioms, tensions, practices, pole, and level.",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for GetNodeTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: GetItemInput,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "get_node", id = %param.id, project = ?param.project);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
let core_path = ctx.root.join(".ontology").join("core.ncl");
|
|
|
|
|
|
|
|
|
|
let (json, _) = ctx
|
|
|
|
|
.cache
|
|
|
|
|
.export(&core_path, ctx.import_path.as_deref())
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| ToolError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let nodes = json
|
|
|
|
|
.get("nodes")
|
|
|
|
|
.and_then(|n| n.as_array())
|
|
|
|
|
.ok_or_else(|| ToolError("core.ncl has no nodes array".to_string()))?;
|
|
|
|
|
|
|
|
|
|
let id_lower = param.id.to_lowercase();
|
|
|
|
|
for node in nodes {
|
|
|
|
|
let node_id = node.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
|
|
|
|
let node_name = node.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
|
|
|
if node_id.to_lowercase().contains(&id_lower)
|
|
|
|
|
|| node_name.to_lowercase().contains(&id_lower)
|
|
|
|
|
{
|
|
|
|
|
return Ok(node.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Err(ToolError(format!("node '{}' not found", param.id)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: ontoref_get (unified dispatcher) ───────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct GetTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for GetTool {
|
|
|
|
|
type Parameter = GetInput;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_get".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
concat!(
|
|
|
|
|
"Retrieve full details of any item by id and kind. ",
|
|
|
|
|
"Use the `id` and `kind` fields returned by `ontoref_search`. ",
|
|
|
|
|
"kind: `\"node\"` → ontology node, `\"adr\"` → Architecture Decision Record, ",
|
|
|
|
|
"`\"mode\"` → reflection workflow.",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for GetTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: GetInput,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "ontoref_get", kind = %param.kind, id = %param.id);
|
|
|
|
|
let item = GetItemInput {
|
|
|
|
|
id: param.id,
|
|
|
|
|
project: param.project,
|
|
|
|
|
};
|
|
|
|
|
match param.kind.as_str() {
|
|
|
|
|
"node" => GetNodeTool::invoke(service, item).await,
|
|
|
|
|
"adr" => GetAdrTool::invoke(service, item).await,
|
|
|
|
|
"mode" => GetModeTool::invoke(service, item).await,
|
|
|
|
|
other => Err(ToolError(format!(
|
|
|
|
|
"unknown kind '{other}'; expected node, adr, or mode"
|
|
|
|
|
))),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: ontoref_help
|
|
|
|
|
// ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct HelpTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for HelpTool {
|
|
|
|
|
type Parameter = NoInput;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_help".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
"List all available ontoref MCP tools with descriptions, parameters, and the current \
|
|
|
|
|
active project."
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for HelpTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
_param: NoInput,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "ontoref_help");
|
|
|
|
|
let current_project = service
|
|
|
|
|
.state
|
|
|
|
|
.mcp_current_project
|
|
|
|
|
.read()
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|g| g.clone());
|
|
|
|
|
let available = service.available_projects();
|
|
|
|
|
|
|
|
|
|
let tools = serde_json::json!([
|
|
|
|
|
{ "name": "ontoref_help", "description": "This help. Lists all tools, parameters, and current project.", "params": [] },
|
|
|
|
|
{ "name": "ontoref_list_projects", "description": "List all registered projects.", "params": [] },
|
|
|
|
|
{ "name": "ontoref_set_project", "description": "Set the default project for this session.",
|
|
|
|
|
"params": [{"name": "project", "required": true, "description": "Project slug from ontoref_list_projects"}] },
|
|
|
|
|
{ "name": "ontoref_status", "description": "Full dashboard: actors, notifications, backlog stats, ADR/mode counts, manifest.",
|
|
|
|
|
"params": [{"name": "project", "required": false}] },
|
|
|
|
|
{ "name": "ontoref_search", "description": "Free-text search across nodes, ADRs, and modes. Returns kind+id for use with ontoref_get.",
|
|
|
|
|
"params": [{"name": "query", "required": true}, {"name": "project", "required": false}] },
|
|
|
|
|
{ "name": "ontoref_get", "description": "Retrieve full details of any item by kind+id (from ontoref_search results).",
|
|
|
|
|
"params": [{"name": "id", "required": true}, {"name": "kind", "required": true, "values": ["node", "adr", "mode"]}, {"name": "project", "required": false}] },
|
|
|
|
|
{ "name": "ontoref_describe", "description": "Architecture overview: README summary and manifest.",
|
|
|
|
|
"params": [{"name": "project", "required": false}] },
|
|
|
|
|
{ "name": "ontoref_list_adrs", "description": "List all ADRs with id, title, status, constraint counts.",
|
|
|
|
|
"params": [{"name": "project", "required": false}] },
|
|
|
|
|
{ "name": "ontoref_get_adr", "description": "Full ADR by id or partial stem (e.g. adr-001).",
|
|
|
|
|
"params": [{"name": "id", "required": true}, {"name": "project", "required": false}] },
|
2026-03-16 01:48:17 +00:00
|
|
|
{ "name": "ontoref_list_ontology_extensions", "description": "List extra .ontology/*.ncl files beyond core/state/gate.",
|
|
|
|
|
"params": [{"name": "project", "required": false}] },
|
|
|
|
|
{ "name": "ontoref_get_ontology_extension", "description": "Export a project-defined .ontology extension by stem (e.g. career, personal).",
|
|
|
|
|
"params": [{"name": "id", "required": true}, {"name": "project", "required": false}] },
|
2026-03-13 00:18:14 +00:00
|
|
|
{ "name": "ontoref_list_modes", "description": "List all reflection modes with id, trigger, step count.",
|
|
|
|
|
"params": [{"name": "project", "required": false}] },
|
|
|
|
|
{ "name": "ontoref_get_mode", "description": "Full reflection mode including all steps and preconditions.",
|
|
|
|
|
"params": [{"name": "id", "required": true}, {"name": "project", "required": false}] },
|
|
|
|
|
{ "name": "ontoref_get_node", "description": "Full ontology node by id substring match.",
|
|
|
|
|
"params": [{"name": "id", "required": true}, {"name": "project", "required": false}] },
|
|
|
|
|
{ "name": "ontoref_backlog_list", "description": "List backlog items, optionally filtered by status.",
|
|
|
|
|
"params": [{"name": "project", "required": false}, {"name": "status", "required": false, "values": ["Open", "InProgress", "Done", "Cancelled"]}] },
|
|
|
|
|
{ "name": "ontoref_backlog", "description": "Mutate backlog: add a new item or update an item's status.",
|
|
|
|
|
"params": [
|
|
|
|
|
{"name": "operation", "required": true, "values": ["add", "update_status"]},
|
|
|
|
|
{"name": "project", "required": false},
|
|
|
|
|
{"name": "id", "required": false, "note": "required for update_status"},
|
|
|
|
|
{"name": "status", "required": false, "note": "required for update_status", "values": ["Open", "InProgress", "Done", "Cancelled"]},
|
|
|
|
|
{"name": "title", "required": false, "note": "required for add"},
|
|
|
|
|
{"name": "kind", "required": false, "values": ["Feature", "Bug", "Chore", "Research"]},
|
|
|
|
|
{"name": "priority", "required": false, "values": ["Critical", "High", "Medium", "Low"]},
|
|
|
|
|
{"name": "detail", "required": false}
|
|
|
|
|
] },
|
|
|
|
|
{ "name": "ontoref_constraints", "description": "All hard and soft architectural constraints extracted from all ADRs.",
|
|
|
|
|
"params": [{"name": "project", "required": false}] },
|
|
|
|
|
{ "name": "ontoref_qa_list", "description": "List Q&A entries from reflection/qa.ncl. Optionally filter by question substring.",
|
|
|
|
|
"params": [{"name": "project", "required": false}, {"name": "filter", "required": false}] },
|
|
|
|
|
{ "name": "ontoref_qa_add", "description": "Add a Q&A entry to reflection/qa.ncl (persisted to disk). Use to record architectural knowledge.",
|
|
|
|
|
"params": [
|
|
|
|
|
{"name": "question", "required": true},
|
|
|
|
|
{"name": "answer", "required": false},
|
|
|
|
|
{"name": "actor", "required": false, "default": "agent"},
|
|
|
|
|
{"name": "tags", "required": false, "note": "array of strings"},
|
|
|
|
|
{"name": "related", "required": false, "note": "array of node/ADR ids"},
|
|
|
|
|
{"name": "project", "required": false}
|
|
|
|
|
] },
|
|
|
|
|
{ "name": "ontoref_action_list", "description": "List quick actions from .ontoref/config.ncl quick_actions array.",
|
|
|
|
|
"params": [{"name": "project", "required": false}] },
|
|
|
|
|
{ "name": "ontoref_action_add", "description": "Create a new reflection mode file in reflection/modes/<id>.ncl.",
|
|
|
|
|
"params": [
|
|
|
|
|
{"name": "id", "required": true, "note": "short slug, e.g. gen-docs"},
|
|
|
|
|
{"name": "label", "required": true},
|
|
|
|
|
{"name": "trigger", "required": true},
|
|
|
|
|
{"name": "steps", "required": true, "note": "array of step strings"},
|
|
|
|
|
{"name": "icon", "required": false, "values": ["book-open", "refresh", "code", "bolt"]},
|
|
|
|
|
{"name": "category", "required": false, "values": ["docs", "sync", "analysis", "test"]},
|
|
|
|
|
{"name": "actors", "required": false, "note": "array: developer | agent | ci"},
|
|
|
|
|
{"name": "project", "required": false}
|
|
|
|
|
] },
|
---
feat: API catalog surface, protocol v2 tooling, MCP expansion, on+re update
## Summary
Session 2026-03-23. Closes the loop between handler code and discoverability
across all three surfaces (browser, CLI, MCP agent) via compile-time inventory
registration. Adds protocol v2 update tooling, extends MCP from 21 to 29 tools,
and brings the self-description up to date.
## API Catalog Surface (#[onto_api] proc-macro)
- crates/ontoref-derive: new proc-macro crate; `#[onto_api(method, path,
description, auth, actors, params, tags)]` emits `inventory::submit!(ApiRouteEntry{...})`
at link time
- crates/ontoref-daemon/src/api_catalog.rs: `catalog()` — pure fn over
`inventory::iter::<ApiRouteEntry>()`, zero runtime allocation
- GET /api/catalog: returns full annotated HTTP surface as JSON
- templates/pages/api_catalog.html: new page with client-side filtering by
method, auth, path/description; detail panel per route (params table,
feature flag); linked from dashboard card and nav
- UI nav: "API" link (</> icon) added to mobile dropdown and desktop bar
- inventory = "0.3" added to workspace.dependencies (MIT, zero transitive deps)
## Protocol Update Mode
- reflection/modes/update_ontoref.ncl: 9-step DAG (5 detect parallel, 2 update
idempotent, 2 validate, 1 report) — brings any project from protocol v1 to v2
by adding manifest.ncl and connections.ncl if absent, scanning ADRs for
deprecated check_hint, validating with nickel export
- reflection/templates/update-ontology-prompt.md: 8-phase reusable prompt for
agent-driven ontology enrichment (infrastructure → audit → core.ncl →
state.ncl → manifest.ncl → connections.ncl → ADR migration → validation)
## CLI — describe group extensions
- reflection/bin/ontoref.nu: `describe diff [--fmt] [--file]` and
`describe api [--actor] [--tag] [--auth] [--fmt]` registered as canonical
subcommands with log-action; aliases `df` and `da` added; QUICK REFERENCE
and ALIASES sections updated
## MCP — two new tools (21 → 29 total)
- ontoref_api_catalog: filters catalog() output by actor/tag/auth; returns
{ routes, total } — no HTTP roundtrip, calls inventory directly
- ontoref_file_versions: reads ProjectContext.file_versions DashMap per slug;
returns BTreeMap<filename, u64> reload counters
- insert_mcp_ctx: audited and updated from 15 to 28 entries in 6 groups
- HelpTool JSON: 8 new entries (validate_adrs, validate, impact, guides,
bookmark_list, bookmark_add, api_catalog, file_versions)
- ServerHandler::get_info instructions updated to mention new tools
## Web UI — dashboard additions
- Dashboard: "API Catalog" card (9th); "Ontology File Versions" section showing
per-file reload counters from file_versions DashMap
- dashboard_mp: builds BTreeMap<String, u64> from ctx.file_versions and injects
into Tera context
## on+re update
- .ontology/core.ncl: describe-query-layer and adopt-ontoref-tooling descriptions
updated; ontoref-daemon updated ("11 pages", "29 tools", API catalog,
per-file versioning, #[onto_api]); new node api-catalog-surface (Yang/Practice)
with 3 edges; artifact_paths extended across 3 nodes
- .ontology/state.ncl: protocol-maturity blocker updated (protocol v2 complete);
self-description-coverage catalyst updated with session 2026-03-23 additions
- ADR-007: "API Surface Discoverability via #[onto_api] Proc-Macro" — Accepted
## Documentation
- README.md: crates table updated (11 pages, 29 MCP tools, ontoref-derive row);
MCP representative table expanded; API Catalog, Semantic Diff, Per-File
Versioning paragraphs added; update_ontoref onboarding section added
- CHANGELOG.md: [Unreleased] section with 4 change groups
- assets/web/src/index.html: tool counts 19→29 (EN+ES), page counts 12→11
(EN+ES), daemon description paragraph updated with API catalog + #[onto_api]
2026-03-23 00:58:27 +01:00
|
|
|
{ "name": "ontoref_validate_adrs", "description": "Run typed constraint checks for all ADRs. Returns pass/fail per constraint with detail.",
|
|
|
|
|
"params": [{"name": "project", "required": false}] },
|
|
|
|
|
{ "name": "ontoref_validate", "description": "Run the full project validation suite: ADR constraints, content assets, connections, gate consistency.",
|
|
|
|
|
"params": [{"name": "project", "required": false}] },
|
|
|
|
|
{ "name": "ontoref_impact", "description": "BFS impact graph from a node: what else is affected if this node changes.",
|
|
|
|
|
"params": [
|
|
|
|
|
{"name": "node_id", "required": true},
|
|
|
|
|
{"name": "depth", "required": false, "default": 2},
|
|
|
|
|
{"name": "include_external", "required": false, "note": "traverse cross-project connections"},
|
|
|
|
|
{"name": "project", "required": false}
|
|
|
|
|
] },
|
|
|
|
|
{ "name": "ontoref_guides", "description": "Complete project guide: identity, axioms, practices, constraints, gate state, modes, actor policy, content assets, connections. Canonical entry point for cold-start context.",
|
|
|
|
|
"params": [{"name": "project", "required": false}, {"name": "actor", "required": false, "values": ["developer", "agent", "ci", "admin"]}] },
|
|
|
|
|
{ "name": "ontoref_bookmark_list", "description": "List saved search bookmarks for the project.",
|
|
|
|
|
"params": [{"name": "project", "required": false}, {"name": "filter", "required": false}] },
|
|
|
|
|
{ "name": "ontoref_bookmark_add", "description": "Save a search bookmark (node_id + title + optional tags).",
|
|
|
|
|
"params": [
|
|
|
|
|
{"name": "node_id", "required": true},
|
|
|
|
|
{"name": "title", "required": true},
|
|
|
|
|
{"name": "kind", "required": false, "values": ["node", "adr", "mode"]},
|
|
|
|
|
{"name": "tags", "required": false, "note": "array of strings"},
|
|
|
|
|
{"name": "project", "required": false}
|
|
|
|
|
] },
|
|
|
|
|
{ "name": "ontoref_api_catalog", "description": "Annotated daemon API surface: all HTTP routes with method, path, auth, actors, params, tags.",
|
|
|
|
|
"params": [
|
|
|
|
|
{"name": "actor", "required": false, "values": ["developer", "agent", "ci", "admin"]},
|
|
|
|
|
{"name": "tag", "required": false, "note": "e.g. ontology, projects, search"},
|
|
|
|
|
{"name": "auth", "required": false, "values": ["none", "viewer", "admin"]}
|
|
|
|
|
] },
|
|
|
|
|
{ "name": "ontoref_file_versions", "description": "Per-file reload counters for ontology files. Counter increments on each daemon reload of that file.",
|
|
|
|
|
"params": [{"name": "project", "required": false}] },
|
2026-03-13 00:18:14 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Ok(serde_json::json!({
|
|
|
|
|
"current_project": current_project,
|
|
|
|
|
"available_projects": available,
|
|
|
|
|
"tip": "Run ontoref_set_project to avoid specifying 'project' on every call.",
|
|
|
|
|
"tools": tools,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: set_project ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct SetProjectTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for SetProjectTool {
|
|
|
|
|
type Parameter = SetProjectInput;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_set_project".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
concat!(
|
|
|
|
|
"Set the current default project for this MCP session. ",
|
|
|
|
|
"Once set, all tools that accept an optional `project` field will use it ",
|
|
|
|
|
"automatically without needing to specify it on every call.",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for SetProjectTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: SetProjectInput,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "set_project", project = %param.project);
|
|
|
|
|
// Verify project exists when registry is available.
|
|
|
|
|
#[cfg(feature = "ui")]
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
if service.state.registry.get(¶m.project).is_none() {
|
|
|
|
|
let available = service.available_projects();
|
|
|
|
|
return Err(ToolError(format!(
|
|
|
|
|
"project '{}' not found; available: {}",
|
|
|
|
|
param.project,
|
|
|
|
|
available.join(", ")
|
|
|
|
|
)));
|
2026-03-13 00:18:14 +00:00
|
|
|
}
|
|
|
|
|
{
|
|
|
|
|
let mut guard = service
|
|
|
|
|
.state
|
|
|
|
|
.mcp_current_project
|
|
|
|
|
.write()
|
|
|
|
|
.map_err(|_| ToolError("lock poisoned".to_string()))?;
|
|
|
|
|
*guard = Some(param.project.clone());
|
|
|
|
|
}
|
|
|
|
|
Ok(serde_json::json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"project": param.project,
|
|
|
|
|
"message": format!("Current project set to '{}'", param.project),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: project_status ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct ProjectStatusTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for ProjectStatusTool {
|
|
|
|
|
type Parameter = ProjectParam;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_status".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
concat!(
|
|
|
|
|
"Full dashboard snapshot: actors, notifications, backlog stats ",
|
|
|
|
|
"(open/in-progress/done/cancelled), ADR count, mode count, and manifest.",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for ProjectStatusTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: ProjectParam,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "project_status", project = ?param.project);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
|
|
|
|
|
// Actors
|
|
|
|
|
let actor_list: Vec<serde_json::Value> = service
|
|
|
|
|
.state
|
|
|
|
|
.actors
|
|
|
|
|
.list()
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|(token, view)| {
|
|
|
|
|
serde_json::json!({
|
|
|
|
|
"token": token,
|
|
|
|
|
"actor_type": view.actor_type,
|
|
|
|
|
"pid": view.pid,
|
|
|
|
|
"project": view.project,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
// Notifications — recent count (all projects, last slice)
|
|
|
|
|
let recent_notifications = service.state.notifications.all_recent().len();
|
|
|
|
|
|
|
|
|
|
// Backlog stats
|
|
|
|
|
let backlog_path = ctx.root.join("reflection").join("backlog.ncl");
|
|
|
|
|
let backlog_stats = if backlog_path.exists() {
|
|
|
|
|
match ctx
|
|
|
|
|
.cache
|
|
|
|
|
.export(&backlog_path, ctx.import_path.as_deref())
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok((v, _)) => {
|
|
|
|
|
let items = v
|
|
|
|
|
.get("items")
|
|
|
|
|
.and_then(|i| i.as_array())
|
|
|
|
|
.cloned()
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
let stats = backlog_item_stats(&items);
|
|
|
|
|
serde_json::json!({
|
|
|
|
|
"total": items.len(),
|
|
|
|
|
"open": stats.0,
|
|
|
|
|
"in_progress": stats.1,
|
|
|
|
|
"done": stats.2,
|
|
|
|
|
"cancelled": stats.3,
|
|
|
|
|
"critical": stats.4,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
Err(_) => serde_json::json!({ "error": "backlog export failed" }),
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
serde_json::json!({ "total": 0, "open": 0, "in_progress": 0, "done": 0, "cancelled": 0, "critical": 0 })
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ADR count
|
|
|
|
|
let adr_count = std::fs::read_dir(ctx.root.join("adrs"))
|
|
|
|
|
.map(|rd| {
|
|
|
|
|
rd.flatten()
|
|
|
|
|
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("ncl"))
|
|
|
|
|
.count()
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
|
|
|
|
|
// Mode count
|
|
|
|
|
let mode_count = std::fs::read_dir(ctx.root.join("reflection").join("modes"))
|
|
|
|
|
.map(|rd| {
|
|
|
|
|
rd.flatten()
|
|
|
|
|
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("ncl"))
|
|
|
|
|
.count()
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
|
|
|
|
|
// Manifest
|
|
|
|
|
let manifest_path = ctx.root.join(".ontology").join("manifest.ncl");
|
|
|
|
|
let manifest = if manifest_path.exists() {
|
|
|
|
|
match ctx
|
|
|
|
|
.cache
|
|
|
|
|
.export(&manifest_path, ctx.import_path.as_deref())
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok((v, _)) => v,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
warn!(error = %e, "project_status: manifest export failed");
|
|
|
|
|
serde_json::Value::Null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
serde_json::Value::Null
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Ok(serde_json::json!({
|
|
|
|
|
"project": ctx.root.file_name().and_then(|n| n.to_str()).unwrap_or("unknown"),
|
|
|
|
|
"root": ctx.root.display().to_string(),
|
|
|
|
|
"actors": { "count": actor_list.len(), "list": actor_list },
|
|
|
|
|
"notifications": { "recent": recent_notifications },
|
|
|
|
|
"backlog": backlog_stats,
|
|
|
|
|
"adrs": adr_count,
|
|
|
|
|
"modes": mode_count,
|
|
|
|
|
"manifest": manifest,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: backlog
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct BacklogOpTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for BacklogOpTool {
|
|
|
|
|
type Parameter = BacklogOpInput;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_backlog".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
concat!(
|
|
|
|
|
"Manage the project backlog. ",
|
|
|
|
|
"`operation: \"add\"` — create a new item (required: title, kind, priority; \
|
|
|
|
|
optional: detail). ",
|
|
|
|
|
"`operation: \"update_status\"` — change item status (required: id, status). ",
|
|
|
|
|
"Valid kinds: Feature | Bug | Chore | Research. ",
|
|
|
|
|
"Valid priorities: Critical | High | Medium | Low. ",
|
|
|
|
|
"Valid statuses: Open | InProgress | Done | Cancelled.",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for BacklogOpTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: BacklogOpInput,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "backlog", operation = %param.operation, project = ?param.project);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
let backlog_path = ctx.root.join("reflection").join("backlog.ncl");
|
|
|
|
|
|
|
|
|
|
if !backlog_path.exists() {
|
|
|
|
|
return Err(ToolError(format!(
|
|
|
|
|
"backlog.ncl not found at {}",
|
|
|
|
|
backlog_path.display()
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let today = today_iso();
|
|
|
|
|
|
|
|
|
|
match param.operation.as_str() {
|
|
|
|
|
"update_status" => {
|
|
|
|
|
let id = param
|
|
|
|
|
.id
|
|
|
|
|
.as_deref()
|
|
|
|
|
.ok_or_else(|| ToolError("`id` is required for update_status".to_string()))?;
|
|
|
|
|
let status = param.status.as_deref().ok_or_else(|| {
|
|
|
|
|
ToolError("`status` is required for update_status".to_string())
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
crate::ui::backlog_ncl::update_status(&backlog_path, id, status, &today)
|
|
|
|
|
.map_err(|e| ToolError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
// Invalidate cache so subsequent get_backlog reads the updated file.
|
|
|
|
|
ctx.cache.invalidate_file(&backlog_path);
|
|
|
|
|
|
|
|
|
|
Ok(serde_json::json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"id": id,
|
|
|
|
|
"status": status,
|
|
|
|
|
"updated": today,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
"add" => {
|
|
|
|
|
let title = param
|
|
|
|
|
.title
|
|
|
|
|
.as_deref()
|
|
|
|
|
.ok_or_else(|| ToolError("`title` is required for add".to_string()))?;
|
|
|
|
|
let kind = param.kind.as_deref().unwrap_or("Feature");
|
|
|
|
|
let priority = param.priority.as_deref().unwrap_or("Medium");
|
|
|
|
|
let detail = param.detail.as_deref().unwrap_or("");
|
|
|
|
|
|
|
|
|
|
let new_id = crate::ui::backlog_ncl::add_item(
|
|
|
|
|
&backlog_path,
|
|
|
|
|
title,
|
|
|
|
|
kind,
|
|
|
|
|
priority,
|
|
|
|
|
detail,
|
|
|
|
|
&today,
|
|
|
|
|
)
|
|
|
|
|
.map_err(|e| ToolError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
ctx.cache.invalidate_file(&backlog_path);
|
|
|
|
|
|
|
|
|
|
Ok(serde_json::json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"id": new_id,
|
|
|
|
|
"title": title,
|
|
|
|
|
"kind": kind,
|
|
|
|
|
"priority": priority,
|
|
|
|
|
"status": "Open",
|
|
|
|
|
"created": today,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
other => Err(ToolError(format!(
|
|
|
|
|
"unknown operation '{}'; expected 'add' or 'update_status'",
|
|
|
|
|
other
|
|
|
|
|
))),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns (open, in_progress, done, cancelled, critical) counts.
|
|
|
|
|
fn backlog_item_stats(items: &[serde_json::Value]) -> (usize, usize, usize, usize, usize) {
|
|
|
|
|
let mut open = 0usize;
|
|
|
|
|
let mut in_progress = 0usize;
|
|
|
|
|
let mut done = 0usize;
|
|
|
|
|
let mut cancelled = 0usize;
|
|
|
|
|
let mut critical = 0usize;
|
|
|
|
|
for item in items {
|
|
|
|
|
match item.get("status").and_then(|s| s.as_str()) {
|
|
|
|
|
Some("Open") => open += 1,
|
|
|
|
|
Some("InProgress") => in_progress += 1,
|
|
|
|
|
Some("Done") => done += 1,
|
|
|
|
|
Some("Cancelled") => cancelled += 1,
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
if item.get("priority").and_then(|p| p.as_str()) == Some("Critical") {
|
|
|
|
|
critical += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
(open, in_progress, done, cancelled, critical)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn today_iso() -> String {
|
|
|
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
|
let days = SystemTime::now()
|
|
|
|
|
.duration_since(UNIX_EPOCH)
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.as_secs()
|
|
|
|
|
/ 86400;
|
|
|
|
|
// Hinnant's civil_from_days algorithm (proleptic Gregorian).
|
|
|
|
|
let z = days + 719_468;
|
|
|
|
|
let era = z / 146_097;
|
|
|
|
|
let doe = z - era * 146_097;
|
|
|
|
|
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
|
|
|
|
let y0 = yoe + era * 400;
|
|
|
|
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
|
|
|
let mp = (5 * doy + 2) / 153;
|
|
|
|
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
|
|
|
|
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
|
|
|
|
let y = if m <= 2 { y0 + 1 } else { y0 };
|
|
|
|
|
format!("{y:04}-{m:02}-{d:02}")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn collect_constraints(
|
|
|
|
|
arr: Option<&serde_json::Value>,
|
|
|
|
|
adr_id: &str,
|
|
|
|
|
adr_title: &str,
|
|
|
|
|
out: &mut Vec<serde_json::Value>,
|
|
|
|
|
) {
|
|
|
|
|
let Some(items) = arr.and_then(|v| v.as_array()) else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
for text in items {
|
|
|
|
|
out.push(serde_json::json!({
|
|
|
|
|
"adr": adr_id,
|
|
|
|
|
"adr_title": adr_title,
|
|
|
|
|
"text": text.as_str().unwrap_or(""),
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: get_constraints
|
|
|
|
|
// ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct GetConstraintsTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for GetConstraintsTool {
|
|
|
|
|
type Parameter = ProjectParam;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_constraints".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
"Extract all hard and soft architectural constraints from all ADRs in the project."
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for GetConstraintsTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: ProjectParam,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "get_constraints", project = ?param.project);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
let adrs_dir = ctx.root.join("adrs");
|
|
|
|
|
|
|
|
|
|
let Ok(entries) = std::fs::read_dir(&adrs_dir) else {
|
|
|
|
|
return Ok(serde_json::json!({ "hard": [], "soft": [] }));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut hard: Vec<serde_json::Value> = Vec::new();
|
|
|
|
|
let mut soft: Vec<serde_json::Value> = Vec::new();
|
|
|
|
|
|
|
|
|
|
for entry in entries.flatten() {
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let Ok((v, _)) = ctx.cache.export(&path, ctx.import_path.as_deref()).await else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let adr_id = v
|
|
|
|
|
.get("id")
|
|
|
|
|
.and_then(|i| i.as_str())
|
|
|
|
|
.unwrap_or("")
|
|
|
|
|
.to_string();
|
|
|
|
|
let adr_title = v
|
|
|
|
|
.get("title")
|
|
|
|
|
.and_then(|t| t.as_str())
|
|
|
|
|
.unwrap_or("")
|
|
|
|
|
.to_string();
|
|
|
|
|
let Some(c) = v.get("constraints").and_then(|c| c.as_object()) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
collect_constraints(c.get("hard"), &adr_id, &adr_title, &mut hard);
|
|
|
|
|
collect_constraints(c.get("soft"), &adr_id, &adr_title, &mut soft);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(serde_json::json!({ "hard": hard, "soft": soft }))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: qa_list
|
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct QaListTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for QaListTool {
|
|
|
|
|
type Parameter = QaListInput;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_qa_list".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
"List Q&A entries stored in reflection/qa.ncl. Optionally filter by question \
|
|
|
|
|
substring."
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for QaListTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: QaListInput,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "qa_list", project = ?param.project);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
let qa_path = ctx.root.join("reflection").join("qa.ncl");
|
|
|
|
|
|
|
|
|
|
if !qa_path.exists() {
|
|
|
|
|
return Ok(serde_json::json!({ "entries": [], "count": 0 }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let (json, _) = ctx
|
|
|
|
|
.cache
|
|
|
|
|
.export(&qa_path, ctx.import_path.as_deref())
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| ToolError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let mut entries: Vec<serde_json::Value> = json
|
|
|
|
|
.get("entries")
|
|
|
|
|
.and_then(|v| v.as_array())
|
|
|
|
|
.cloned()
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
if let Some(filter) = param.filter.as_deref() {
|
|
|
|
|
let lc = filter.to_lowercase();
|
|
|
|
|
entries.retain(|e| {
|
|
|
|
|
e.get("question")
|
|
|
|
|
.and_then(|q| q.as_str())
|
|
|
|
|
.map(|q| q.to_lowercase().contains(&lc))
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let count = entries.len();
|
|
|
|
|
Ok(serde_json::json!({ "entries": entries, "count": count }))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: qa_add
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct QaAddTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for QaAddTool {
|
|
|
|
|
type Parameter = QaAddInput;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_qa_add".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
concat!(
|
|
|
|
|
"Add a Q&A entry to reflection/qa.ncl (persisted to disk, git-versioned). ",
|
|
|
|
|
"Use this to record architectural questions and answers the AI discovers during \
|
|
|
|
|
analysis. ",
|
|
|
|
|
"Required: question. Optional: answer, actor, tags (array), related (node/ADR \
|
|
|
|
|
ids).",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for QaAddTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: QaAddInput,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "qa_add", project = ?param.project);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
let qa_path = ctx.root.join("reflection").join("qa.ncl");
|
|
|
|
|
|
|
|
|
|
if !qa_path.exists() {
|
|
|
|
|
return Err(ToolError(format!(
|
|
|
|
|
"qa.ncl not found at {} — create reflection/qa.ncl first",
|
|
|
|
|
qa_path.display()
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let answer = param.answer.as_deref().unwrap_or("");
|
|
|
|
|
let actor = param.actor.as_deref().unwrap_or("agent");
|
|
|
|
|
let tags = param.tags.as_deref().unwrap_or(&[]);
|
|
|
|
|
let related = param.related.as_deref().unwrap_or(&[]);
|
|
|
|
|
let now = today_iso();
|
|
|
|
|
|
|
|
|
|
let id = crate::ui::qa_ncl::add_entry(
|
|
|
|
|
&qa_path,
|
|
|
|
|
¶m.question,
|
|
|
|
|
answer,
|
|
|
|
|
actor,
|
|
|
|
|
&now,
|
|
|
|
|
tags,
|
|
|
|
|
related,
|
|
|
|
|
)
|
|
|
|
|
.map_err(|e| ToolError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
ctx.cache.invalidate_file(&qa_path);
|
|
|
|
|
|
|
|
|
|
Ok(serde_json::json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"id": id,
|
|
|
|
|
"created_at": now,
|
|
|
|
|
"question": param.question,
|
|
|
|
|
"answer": answer,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: action_list
|
|
|
|
|
// ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct ActionListTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for ActionListTool {
|
|
|
|
|
type Parameter = ActionListInput;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_action_list".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
"List quick actions configured in .ontoref/config.ncl (`quick_actions` array). \
|
|
|
|
|
Returns id, label, category, icon, actors, and mode for each action."
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for ActionListTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: ActionListInput,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "action_list", project = ?param.project);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
let config_path = ctx.root.join(".ontoref").join("config.ncl");
|
|
|
|
|
|
|
|
|
|
if !config_path.exists() {
|
|
|
|
|
return Ok(serde_json::json!({ "actions": [], "count": 0 }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let (json, _) = ctx
|
|
|
|
|
.cache
|
|
|
|
|
.export(&config_path, ctx.import_path.as_deref())
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| ToolError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let actions = json
|
|
|
|
|
.get("quick_actions")
|
|
|
|
|
.and_then(|v| v.as_array())
|
|
|
|
|
.cloned()
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
let count = actions.len();
|
|
|
|
|
Ok(serde_json::json!({ "actions": actions, "count": count }))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: action_add
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct ActionAddTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for ActionAddTool {
|
|
|
|
|
type Parameter = ActionAddInput;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_action_add".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
concat!(
|
|
|
|
|
"Create a new reflection mode file in reflection/modes/<id>.ncl. ",
|
|
|
|
|
"The mode is immediately available as a runnable action via the CLI and UI. ",
|
|
|
|
|
"Required: id (slug), label, trigger, steps (array of step descriptions). ",
|
|
|
|
|
"Optional: icon, category, actors.",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for ActionAddTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: ActionAddInput,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "action_add", id = %param.id, project = ?param.project);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
let modes_dir = ctx.root.join("reflection").join("modes");
|
|
|
|
|
|
|
|
|
|
if !modes_dir.exists() {
|
|
|
|
|
return Err(ToolError(format!(
|
|
|
|
|
"reflection/modes/ directory not found at {}",
|
|
|
|
|
modes_dir.display()
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mode_path = modes_dir.join(format!("{}.ncl", param.id));
|
|
|
|
|
if mode_path.exists() {
|
|
|
|
|
return Err(ToolError(format!(
|
|
|
|
|
"mode {} already exists — choose a different id",
|
|
|
|
|
param.id
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let icon = param.icon.as_deref().unwrap_or("bolt");
|
|
|
|
|
let category = param.category.as_deref().unwrap_or("analysis");
|
|
|
|
|
let actors = param.actors.as_deref().unwrap_or(&[]);
|
|
|
|
|
let actors_ncl = ncl_str_array(actors);
|
|
|
|
|
|
|
|
|
|
let steps_ncl = param
|
|
|
|
|
.steps
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|s| format!(" \"{}\",\n", escape_mcp(s)))
|
|
|
|
|
.collect::<String>();
|
|
|
|
|
|
|
|
|
|
let content = format!(
|
|
|
|
|
r#"{{
|
|
|
|
|
id = "{id}",
|
|
|
|
|
label = "{label}",
|
|
|
|
|
trigger = "{trigger}",
|
|
|
|
|
icon = "{icon}",
|
|
|
|
|
category = "{category}",
|
|
|
|
|
actors = {actors},
|
|
|
|
|
steps = [
|
|
|
|
|
{steps} ],
|
|
|
|
|
}}
|
|
|
|
|
"#,
|
|
|
|
|
id = escape_mcp(¶m.id),
|
|
|
|
|
label = escape_mcp(¶m.label),
|
|
|
|
|
trigger = escape_mcp(¶m.trigger),
|
|
|
|
|
icon = icon,
|
|
|
|
|
category = category,
|
|
|
|
|
actors = actors_ncl,
|
|
|
|
|
steps = steps_ncl,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
std::fs::write(&mode_path, content).map_err(|e| ToolError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
// Invalidate any cached export for the modes directory items.
|
|
|
|
|
ctx.cache.invalidate_file(&mode_path);
|
|
|
|
|
|
|
|
|
|
Ok(serde_json::json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"id": param.id,
|
|
|
|
|
"path": mode_path.display().to_string(),
|
|
|
|
|
"label": param.label,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
---
feat: API catalog surface, protocol v2 tooling, MCP expansion, on+re update
## Summary
Session 2026-03-23. Closes the loop between handler code and discoverability
across all three surfaces (browser, CLI, MCP agent) via compile-time inventory
registration. Adds protocol v2 update tooling, extends MCP from 21 to 29 tools,
and brings the self-description up to date.
## API Catalog Surface (#[onto_api] proc-macro)
- crates/ontoref-derive: new proc-macro crate; `#[onto_api(method, path,
description, auth, actors, params, tags)]` emits `inventory::submit!(ApiRouteEntry{...})`
at link time
- crates/ontoref-daemon/src/api_catalog.rs: `catalog()` — pure fn over
`inventory::iter::<ApiRouteEntry>()`, zero runtime allocation
- GET /api/catalog: returns full annotated HTTP surface as JSON
- templates/pages/api_catalog.html: new page with client-side filtering by
method, auth, path/description; detail panel per route (params table,
feature flag); linked from dashboard card and nav
- UI nav: "API" link (</> icon) added to mobile dropdown and desktop bar
- inventory = "0.3" added to workspace.dependencies (MIT, zero transitive deps)
## Protocol Update Mode
- reflection/modes/update_ontoref.ncl: 9-step DAG (5 detect parallel, 2 update
idempotent, 2 validate, 1 report) — brings any project from protocol v1 to v2
by adding manifest.ncl and connections.ncl if absent, scanning ADRs for
deprecated check_hint, validating with nickel export
- reflection/templates/update-ontology-prompt.md: 8-phase reusable prompt for
agent-driven ontology enrichment (infrastructure → audit → core.ncl →
state.ncl → manifest.ncl → connections.ncl → ADR migration → validation)
## CLI — describe group extensions
- reflection/bin/ontoref.nu: `describe diff [--fmt] [--file]` and
`describe api [--actor] [--tag] [--auth] [--fmt]` registered as canonical
subcommands with log-action; aliases `df` and `da` added; QUICK REFERENCE
and ALIASES sections updated
## MCP — two new tools (21 → 29 total)
- ontoref_api_catalog: filters catalog() output by actor/tag/auth; returns
{ routes, total } — no HTTP roundtrip, calls inventory directly
- ontoref_file_versions: reads ProjectContext.file_versions DashMap per slug;
returns BTreeMap<filename, u64> reload counters
- insert_mcp_ctx: audited and updated from 15 to 28 entries in 6 groups
- HelpTool JSON: 8 new entries (validate_adrs, validate, impact, guides,
bookmark_list, bookmark_add, api_catalog, file_versions)
- ServerHandler::get_info instructions updated to mention new tools
## Web UI — dashboard additions
- Dashboard: "API Catalog" card (9th); "Ontology File Versions" section showing
per-file reload counters from file_versions DashMap
- dashboard_mp: builds BTreeMap<String, u64> from ctx.file_versions and injects
into Tera context
## on+re update
- .ontology/core.ncl: describe-query-layer and adopt-ontoref-tooling descriptions
updated; ontoref-daemon updated ("11 pages", "29 tools", API catalog,
per-file versioning, #[onto_api]); new node api-catalog-surface (Yang/Practice)
with 3 edges; artifact_paths extended across 3 nodes
- .ontology/state.ncl: protocol-maturity blocker updated (protocol v2 complete);
self-description-coverage catalyst updated with session 2026-03-23 additions
- ADR-007: "API Surface Discoverability via #[onto_api] Proc-Macro" — Accepted
## Documentation
- README.md: crates table updated (11 pages, 29 MCP tools, ontoref-derive row);
MCP representative table expanded; API Catalog, Semantic Diff, Per-File
Versioning paragraphs added; update_ontoref onboarding section added
- CHANGELOG.md: [Unreleased] section with 4 change groups
- assets/web/src/index.html: tool counts 19→29 (EN+ES), page counts 12→11
(EN+ES), daemon description paragraph updated with API catalog + #[onto_api]
2026-03-23 00:58:27 +01:00
|
|
|
// ── Tool: validate_adrs
|
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct ValidateAdrsTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for ValidateAdrsTool {
|
|
|
|
|
type Parameter = ProjectParam;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_validate_adrs".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
"Run all typed constraint checks from accepted ADRs and return a structured \
|
|
|
|
|
compliance report with per-constraint pass/fail results."
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for ValidateAdrsTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: ProjectParam,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "validate_adrs", project = ?param.project);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
|
|
|
|
|
let output = tokio::process::Command::new("nu")
|
|
|
|
|
.args([
|
|
|
|
|
"--no-config-file",
|
|
|
|
|
"-c",
|
|
|
|
|
"use reflection/modules/validate.nu *; validate check-all --fmt json",
|
|
|
|
|
])
|
|
|
|
|
.current_dir(&ctx.root)
|
|
|
|
|
.output()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| ToolError(format!("spawn failed: {e}")))?;
|
|
|
|
|
|
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
|
serde_json::from_str::<serde_json::Value>(stdout.trim()).map_err(|e| {
|
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
|
|
|
ToolError(format!(
|
|
|
|
|
"invalid JSON from validate: {e}\nstderr: {}",
|
|
|
|
|
stderr.trim()
|
|
|
|
|
))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: impact
|
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, JsonSchema, Default)]
|
|
|
|
|
struct ImpactInput {
|
|
|
|
|
/// Ontology node id to trace impact for (e.g. "dag-formalized").
|
|
|
|
|
node: String,
|
|
|
|
|
/// Project slug. Omit to use the default project.
|
|
|
|
|
project: Option<String>,
|
|
|
|
|
/// Maximum edge hops to follow (default 2, max 5).
|
|
|
|
|
depth: Option<u32>,
|
|
|
|
|
/// When true, follow connections.ncl entries to external projects.
|
|
|
|
|
include_external: Option<bool>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct ImpactTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for ImpactTool {
|
|
|
|
|
type Parameter = ImpactInput;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_impact".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
"Trace the impact graph of an ontology node: which nodes depend on it and which it \
|
|
|
|
|
depends on. Set include_external=true to follow cross-project connections declared \
|
|
|
|
|
in connections.ncl."
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for ImpactTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: ImpactInput,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "impact", node = %param.node, project = ?param.project);
|
|
|
|
|
|
|
|
|
|
let effective_slug = param
|
|
|
|
|
.project
|
|
|
|
|
.clone()
|
|
|
|
|
.unwrap_or_else(|| service.state.registry.primary_slug().to_owned());
|
|
|
|
|
|
|
|
|
|
let fed = crate::federation::FederatedQuery::new(Arc::clone(&service.state.registry));
|
|
|
|
|
let depth = param.depth.unwrap_or(2).min(5);
|
|
|
|
|
let include_external = param.include_external.unwrap_or(false);
|
|
|
|
|
|
|
|
|
|
let impacts = fed
|
|
|
|
|
.impact_graph(&effective_slug, ¶m.node, depth, include_external)
|
|
|
|
|
.await;
|
|
|
|
|
Ok(serde_json::json!({
|
|
|
|
|
"slug": effective_slug,
|
|
|
|
|
"node": param.node,
|
|
|
|
|
"depth": depth,
|
|
|
|
|
"include_external": include_external,
|
|
|
|
|
"impacts": impacts,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: validate_project
|
|
|
|
|
// ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct ValidateProjectTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for ValidateProjectTool {
|
|
|
|
|
type Parameter = ProjectParam;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_validate".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
"Run comprehensive project validation: ADR typed constraints, content asset paths, \
|
|
|
|
|
connection health, practice coverage, and gate/dimension consistency. Returns a \
|
|
|
|
|
structured compliance report. Non-zero exit when any Hard constraint fails."
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for ValidateProjectTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: ProjectParam,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "validate_project", project = ?param.project);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
|
|
|
|
|
// Run the aggregate summary step directly — faster than spawning the full DAG
|
|
|
|
|
// mode.
|
|
|
|
|
let output = tokio::process::Command::new("nu")
|
|
|
|
|
.args([
|
|
|
|
|
"--no-config-file",
|
|
|
|
|
"-c",
|
|
|
|
|
"use reflection/modules/validate.nu *; validate check-all --fmt json",
|
|
|
|
|
])
|
|
|
|
|
.current_dir(&ctx.root)
|
|
|
|
|
.output()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| ToolError(format!("spawn failed: {e}")))?;
|
|
|
|
|
|
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
|
let passed = output.status.success();
|
|
|
|
|
let report =
|
|
|
|
|
serde_json::from_str::<serde_json::Value>(stdout.trim()).unwrap_or_else(|_| {
|
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
|
|
|
serde_json::json!({
|
|
|
|
|
"raw_stdout": stdout.trim(),
|
|
|
|
|
"stderr": stderr.trim(),
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Ok(serde_json::json!({
|
|
|
|
|
"passed": passed,
|
|
|
|
|
"report": report,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: guides
|
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct GuidesTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for GuidesTool {
|
|
|
|
|
type Parameter = GuidesInput;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_guides".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
"Return a complete project guide: identity, axioms, practices, constraints, gate \
|
|
|
|
|
state, available modes, actor-specific policy, language guides, content assets, and \
|
|
|
|
|
connections. Single deterministic JSON response — the canonical entry point for any \
|
|
|
|
|
actor arriving at a project cold."
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for GuidesTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: GuidesInput,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
let actor = param.actor.as_deref().unwrap_or("agent");
|
|
|
|
|
debug!(tool = "guides", project = ?param.project, actor);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
|
|
|
|
|
let nu_cmd = format!(
|
|
|
|
|
"use reflection/modules/describe.nu *; describe guides --actor {} --fmt json",
|
|
|
|
|
actor,
|
|
|
|
|
);
|
|
|
|
|
let output = tokio::process::Command::new("nu")
|
|
|
|
|
.args(["--no-config-file", "-c", &nu_cmd])
|
|
|
|
|
.current_dir(&ctx.root)
|
|
|
|
|
.output()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| ToolError(format!("spawn failed: {e}")))?;
|
|
|
|
|
|
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
|
serde_json::from_str::<serde_json::Value>(stdout.trim()).map_err(|e| {
|
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
|
|
|
ToolError(format!(
|
|
|
|
|
"invalid JSON from describe guides: {e}\nstderr: {}",
|
|
|
|
|
stderr.trim()
|
|
|
|
|
))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: api_catalog
|
|
|
|
|
// ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct ApiCatalogTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for ApiCatalogTool {
|
|
|
|
|
type Parameter = ApiCatalogInput;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_api_catalog".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
"Return the annotated daemon API surface: all HTTP routes with method, path, auth \
|
|
|
|
|
level, allowed actors, parameters, and tags. Filterable by actor, tag, or auth. Use \
|
|
|
|
|
to understand what endpoints are available and how to call them."
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for ApiCatalogTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
_service: &OntoreServer,
|
|
|
|
|
param: ApiCatalogInput,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "api_catalog", actor = ?param.actor, tag = ?param.tag, auth = ?param.auth);
|
|
|
|
|
let routes: Vec<serde_json::Value> = crate::api_catalog::catalog()
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter(|r| {
|
|
|
|
|
let actor_ok = param.actor.as_deref().is_none_or(|a| r.actors.contains(&a));
|
|
|
|
|
let tag_ok = param.tag.as_deref().is_none_or(|t| r.tags.contains(&t));
|
|
|
|
|
let auth_ok = param.auth.as_deref().is_none_or(|a| r.auth == a);
|
|
|
|
|
actor_ok && tag_ok && auth_ok
|
|
|
|
|
})
|
|
|
|
|
.map(|r| {
|
|
|
|
|
let params: Vec<serde_json::Value> = r
|
|
|
|
|
.params
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|p| {
|
|
|
|
|
serde_json::json!({
|
|
|
|
|
"name": p.name,
|
|
|
|
|
"type": p.kind,
|
|
|
|
|
"constraint": p.constraint,
|
|
|
|
|
"description": p.description,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
serde_json::json!({
|
|
|
|
|
"method": r.method,
|
|
|
|
|
"path": r.path,
|
|
|
|
|
"description": r.description,
|
|
|
|
|
"auth": r.auth,
|
|
|
|
|
"actors": r.actors,
|
|
|
|
|
"params": params,
|
|
|
|
|
"tags": r.tags,
|
|
|
|
|
"feature": r.feature,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
let total = routes.len();
|
|
|
|
|
Ok(serde_json::json!({ "routes": routes, "total": total }))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: file_versions
|
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct FileVersionsTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for FileVersionsTool {
|
|
|
|
|
type Parameter = FileVersionsInput;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_file_versions".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
"Return per-file reload counters for the project's ontology files. Each counter \
|
|
|
|
|
increments when the file changes on disk and the daemon reloads it. Use to detect \
|
|
|
|
|
which ontology files have changed since the agent last read them."
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for FileVersionsTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: FileVersionsInput,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "file_versions", project = ?param.project);
|
|
|
|
|
|
|
|
|
|
let current = service
|
|
|
|
|
.state
|
|
|
|
|
.mcp_current_project
|
|
|
|
|
.read()
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|g| g.clone());
|
|
|
|
|
let effective = param.project.or(current);
|
|
|
|
|
|
|
|
|
|
#[cfg(feature = "ui")]
|
|
|
|
|
if let Some(slug) = effective.as_deref() {
|
|
|
|
|
if let Some(ctx) = service.state.registry.get(slug) {
|
|
|
|
|
let files: std::collections::BTreeMap<String, u64> = ctx
|
|
|
|
|
.file_versions
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|r| {
|
|
|
|
|
let name = r
|
|
|
|
|
.key()
|
|
|
|
|
.file_name()
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.to_string_lossy()
|
|
|
|
|
.into_owned();
|
|
|
|
|
(name, *r.value())
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
return Ok(serde_json::json!({ "project": slug, "files": files }));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let _ = effective;
|
|
|
|
|
|
|
|
|
|
let primary = service.state.registry.primary();
|
|
|
|
|
let slug = service.state.registry.primary_slug().to_owned();
|
|
|
|
|
let files: std::collections::BTreeMap<String, u64> = primary
|
|
|
|
|
.file_versions
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|r| {
|
|
|
|
|
let name = r
|
|
|
|
|
.key()
|
|
|
|
|
.file_name()
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.to_string_lossy()
|
|
|
|
|
.into_owned();
|
|
|
|
|
(name, *r.value())
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
Ok(serde_json::json!({ "project": slug, "files": files }))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 00:18:14 +00:00
|
|
|
fn ncl_str_array(items: &[String]) -> String {
|
|
|
|
|
if items.is_empty() {
|
|
|
|
|
return "[]".to_string();
|
|
|
|
|
}
|
|
|
|
|
let inner: Vec<String> = items
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|s| format!("\"{}\"", escape_mcp(s)))
|
|
|
|
|
.collect();
|
|
|
|
|
format!("[{}]", inner.join(", "))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn escape_mcp(s: &str) -> String {
|
|
|
|
|
s.replace('\\', "\\\\").replace('"', "\\\"")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── ServerHandler
|
|
|
|
|
// ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[tool_handler]
|
|
|
|
|
impl ServerHandler for OntoreServer {
|
|
|
|
|
fn get_info(&self) -> ServerInfo {
|
|
|
|
|
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
|
|
|
|
.with_protocol_version(ProtocolVersion::LATEST)
|
|
|
|
|
.with_instructions(concat!(
|
|
|
|
|
"Ontoref semantic knowledge graph. All tools are prefixed `ontoref_`. ",
|
|
|
|
|
"Start with `ontoref_help` to see all tools and the current active project. ",
|
|
|
|
|
"Use `ontoref_set_project` once to avoid repeating `project` on every call. ",
|
---
feat: API catalog surface, protocol v2 tooling, MCP expansion, on+re update
## Summary
Session 2026-03-23. Closes the loop between handler code and discoverability
across all three surfaces (browser, CLI, MCP agent) via compile-time inventory
registration. Adds protocol v2 update tooling, extends MCP from 21 to 29 tools,
and brings the self-description up to date.
## API Catalog Surface (#[onto_api] proc-macro)
- crates/ontoref-derive: new proc-macro crate; `#[onto_api(method, path,
description, auth, actors, params, tags)]` emits `inventory::submit!(ApiRouteEntry{...})`
at link time
- crates/ontoref-daemon/src/api_catalog.rs: `catalog()` — pure fn over
`inventory::iter::<ApiRouteEntry>()`, zero runtime allocation
- GET /api/catalog: returns full annotated HTTP surface as JSON
- templates/pages/api_catalog.html: new page with client-side filtering by
method, auth, path/description; detail panel per route (params table,
feature flag); linked from dashboard card and nav
- UI nav: "API" link (</> icon) added to mobile dropdown and desktop bar
- inventory = "0.3" added to workspace.dependencies (MIT, zero transitive deps)
## Protocol Update Mode
- reflection/modes/update_ontoref.ncl: 9-step DAG (5 detect parallel, 2 update
idempotent, 2 validate, 1 report) — brings any project from protocol v1 to v2
by adding manifest.ncl and connections.ncl if absent, scanning ADRs for
deprecated check_hint, validating with nickel export
- reflection/templates/update-ontology-prompt.md: 8-phase reusable prompt for
agent-driven ontology enrichment (infrastructure → audit → core.ncl →
state.ncl → manifest.ncl → connections.ncl → ADR migration → validation)
## CLI — describe group extensions
- reflection/bin/ontoref.nu: `describe diff [--fmt] [--file]` and
`describe api [--actor] [--tag] [--auth] [--fmt]` registered as canonical
subcommands with log-action; aliases `df` and `da` added; QUICK REFERENCE
and ALIASES sections updated
## MCP — two new tools (21 → 29 total)
- ontoref_api_catalog: filters catalog() output by actor/tag/auth; returns
{ routes, total } — no HTTP roundtrip, calls inventory directly
- ontoref_file_versions: reads ProjectContext.file_versions DashMap per slug;
returns BTreeMap<filename, u64> reload counters
- insert_mcp_ctx: audited and updated from 15 to 28 entries in 6 groups
- HelpTool JSON: 8 new entries (validate_adrs, validate, impact, guides,
bookmark_list, bookmark_add, api_catalog, file_versions)
- ServerHandler::get_info instructions updated to mention new tools
## Web UI — dashboard additions
- Dashboard: "API Catalog" card (9th); "Ontology File Versions" section showing
per-file reload counters from file_versions DashMap
- dashboard_mp: builds BTreeMap<String, u64> from ctx.file_versions and injects
into Tera context
## on+re update
- .ontology/core.ncl: describe-query-layer and adopt-ontoref-tooling descriptions
updated; ontoref-daemon updated ("11 pages", "29 tools", API catalog,
per-file versioning, #[onto_api]); new node api-catalog-surface (Yang/Practice)
with 3 edges; artifact_paths extended across 3 nodes
- .ontology/state.ncl: protocol-maturity blocker updated (protocol v2 complete);
self-description-coverage catalyst updated with session 2026-03-23 additions
- ADR-007: "API Surface Discoverability via #[onto_api] Proc-Macro" — Accepted
## Documentation
- README.md: crates table updated (11 pages, 29 MCP tools, ontoref-derive row);
MCP representative table expanded; API Catalog, Semantic Diff, Per-File
Versioning paragraphs added; update_ontoref onboarding section added
- CHANGELOG.md: [Unreleased] section with 4 change groups
- assets/web/src/index.html: tool counts 19→29 (EN+ES), page counts 12→11
(EN+ES), daemon description paragraph updated with API catalog + #[onto_api]
2026-03-23 00:58:27 +01:00
|
|
|
"Use `ontoref_guides` for full project context on cold start (axioms, practices, \
|
|
|
|
|
gate, actor policy). ",
|
2026-03-13 00:18:14 +00:00
|
|
|
"Use `ontoref_search` for queries; then `ontoref_get` with the returned kind+id \
|
|
|
|
|
for details. ",
|
---
feat: API catalog surface, protocol v2 tooling, MCP expansion, on+re update
## Summary
Session 2026-03-23. Closes the loop between handler code and discoverability
across all three surfaces (browser, CLI, MCP agent) via compile-time inventory
registration. Adds protocol v2 update tooling, extends MCP from 21 to 29 tools,
and brings the self-description up to date.
## API Catalog Surface (#[onto_api] proc-macro)
- crates/ontoref-derive: new proc-macro crate; `#[onto_api(method, path,
description, auth, actors, params, tags)]` emits `inventory::submit!(ApiRouteEntry{...})`
at link time
- crates/ontoref-daemon/src/api_catalog.rs: `catalog()` — pure fn over
`inventory::iter::<ApiRouteEntry>()`, zero runtime allocation
- GET /api/catalog: returns full annotated HTTP surface as JSON
- templates/pages/api_catalog.html: new page with client-side filtering by
method, auth, path/description; detail panel per route (params table,
feature flag); linked from dashboard card and nav
- UI nav: "API" link (</> icon) added to mobile dropdown and desktop bar
- inventory = "0.3" added to workspace.dependencies (MIT, zero transitive deps)
## Protocol Update Mode
- reflection/modes/update_ontoref.ncl: 9-step DAG (5 detect parallel, 2 update
idempotent, 2 validate, 1 report) — brings any project from protocol v1 to v2
by adding manifest.ncl and connections.ncl if absent, scanning ADRs for
deprecated check_hint, validating with nickel export
- reflection/templates/update-ontology-prompt.md: 8-phase reusable prompt for
agent-driven ontology enrichment (infrastructure → audit → core.ncl →
state.ncl → manifest.ncl → connections.ncl → ADR migration → validation)
## CLI — describe group extensions
- reflection/bin/ontoref.nu: `describe diff [--fmt] [--file]` and
`describe api [--actor] [--tag] [--auth] [--fmt]` registered as canonical
subcommands with log-action; aliases `df` and `da` added; QUICK REFERENCE
and ALIASES sections updated
## MCP — two new tools (21 → 29 total)
- ontoref_api_catalog: filters catalog() output by actor/tag/auth; returns
{ routes, total } — no HTTP roundtrip, calls inventory directly
- ontoref_file_versions: reads ProjectContext.file_versions DashMap per slug;
returns BTreeMap<filename, u64> reload counters
- insert_mcp_ctx: audited and updated from 15 to 28 entries in 6 groups
- HelpTool JSON: 8 new entries (validate_adrs, validate, impact, guides,
bookmark_list, bookmark_add, api_catalog, file_versions)
- ServerHandler::get_info instructions updated to mention new tools
## Web UI — dashboard additions
- Dashboard: "API Catalog" card (9th); "Ontology File Versions" section showing
per-file reload counters from file_versions DashMap
- dashboard_mp: builds BTreeMap<String, u64> from ctx.file_versions and injects
into Tera context
## on+re update
- .ontology/core.ncl: describe-query-layer and adopt-ontoref-tooling descriptions
updated; ontoref-daemon updated ("11 pages", "29 tools", API catalog,
per-file versioning, #[onto_api]); new node api-catalog-surface (Yang/Practice)
with 3 edges; artifact_paths extended across 3 nodes
- .ontology/state.ncl: protocol-maturity blocker updated (protocol v2 complete);
self-description-coverage catalyst updated with session 2026-03-23 additions
- ADR-007: "API Surface Discoverability via #[onto_api] Proc-Macro" — Accepted
## Documentation
- README.md: crates table updated (11 pages, 29 MCP tools, ontoref-derive row);
MCP representative table expanded; API Catalog, Semantic Diff, Per-File
Versioning paragraphs added; update_ontoref onboarding section added
- CHANGELOG.md: [Unreleased] section with 4 change groups
- assets/web/src/index.html: tool counts 19→29 (EN+ES), page counts 12→11
(EN+ES), daemon description paragraph updated with API catalog + #[onto_api]
2026-03-23 00:58:27 +01:00
|
|
|
"Use `ontoref_status` for a project dashboard. ",
|
|
|
|
|
"Use `ontoref_api_catalog` to discover daemon endpoints by actor or tag. ",
|
|
|
|
|
"Use `ontoref_file_versions` to detect which ontology files changed since last \
|
|
|
|
|
read. ",
|
|
|
|
|
"Use `ontoref_validate_adrs` or `ontoref_validate` to run architectural \
|
|
|
|
|
constraint checks. ",
|
2026-03-13 00:18:14 +00:00
|
|
|
"Use `ontoref_backlog` to add items or update status.",
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Entry points
|
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-16 01:48:17 +00:00
|
|
|
// ── Tool: bookmark_list
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct BookmarkListTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for BookmarkListTool {
|
|
|
|
|
type Parameter = BookmarkListInput;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_bookmark_list".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
"List search bookmarks stored in reflection/search_bookmarks.ncl. Optionally filter \
|
|
|
|
|
by node_id or title substring."
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for BookmarkListTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: BookmarkListInput,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "bookmark_list", project = ?param.project);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
let bm_path = ctx.root.join("reflection").join("search_bookmarks.ncl");
|
|
|
|
|
|
|
|
|
|
if !bm_path.exists() {
|
|
|
|
|
return Ok(serde_json::json!({ "entries": [], "count": 0 }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let (json, _) = ctx
|
|
|
|
|
.cache
|
|
|
|
|
.export(&bm_path, ctx.import_path.as_deref())
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| ToolError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let mut entries: Vec<serde_json::Value> = json
|
|
|
|
|
.get("entries")
|
|
|
|
|
.and_then(|v| v.as_array())
|
|
|
|
|
.cloned()
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
if let Some(filter) = param.filter.as_deref() {
|
|
|
|
|
let lc = filter.to_lowercase();
|
|
|
|
|
entries.retain(|e| {
|
|
|
|
|
let id_match = e
|
|
|
|
|
.get("node_id")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.map(|s| s.to_lowercase().contains(&lc))
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
let title_match = e
|
|
|
|
|
.get("title")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.map(|s| s.to_lowercase().contains(&lc))
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
id_match || title_match
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let count = entries.len();
|
|
|
|
|
Ok(serde_json::json!({ "entries": entries, "count": count }))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tool: bookmark_add
|
|
|
|
|
// ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct BookmarkAddTool;
|
|
|
|
|
|
|
|
|
|
impl ToolBase for BookmarkAddTool {
|
|
|
|
|
type Parameter = BookmarkAddInput;
|
|
|
|
|
type Output = serde_json::Value;
|
|
|
|
|
type Error = ToolError;
|
|
|
|
|
|
|
|
|
|
fn name() -> Cow<'static, str> {
|
|
|
|
|
"ontoref_bookmark_add".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description() -> Option<Cow<'static, str>> {
|
|
|
|
|
Some(
|
|
|
|
|
concat!(
|
|
|
|
|
"Save a search result as a bookmark in reflection/search_bookmarks.ncl (persisted \
|
|
|
|
|
to disk, git-versioned). ",
|
|
|
|
|
"Use this when the user stars/bookmarks a search result in the CLI or UI. ",
|
|
|
|
|
"Required: node_id, title. Optional: kind, level, term, actor, tags.",
|
|
|
|
|
)
|
|
|
|
|
.into(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn output_schema() -> Option<Arc<JsonObject>> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AsyncTool<OntoreServer> for BookmarkAddTool {
|
|
|
|
|
async fn invoke(
|
|
|
|
|
service: &OntoreServer,
|
|
|
|
|
param: BookmarkAddInput,
|
|
|
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
|
|
|
debug!(tool = "bookmark_add", project = ?param.project, node_id = %param.node_id);
|
|
|
|
|
let ctx = service.project_ctx(param.project.as_deref());
|
|
|
|
|
let bm_path = ctx.root.join("reflection").join("search_bookmarks.ncl");
|
|
|
|
|
|
|
|
|
|
if !bm_path.exists() {
|
|
|
|
|
return Err(ToolError(format!(
|
|
|
|
|
"search_bookmarks.ncl not found at {} — run ontoref setup first",
|
|
|
|
|
bm_path.display()
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let kind = param.kind.as_deref().unwrap_or("node");
|
|
|
|
|
let level = param.level.as_deref().unwrap_or("");
|
|
|
|
|
let term = param.term.as_deref().unwrap_or("");
|
|
|
|
|
let actor = param.actor.as_deref().unwrap_or("agent");
|
|
|
|
|
let tags = param.tags.as_deref().unwrap_or(&[]);
|
|
|
|
|
let now = today_iso();
|
|
|
|
|
|
|
|
|
|
let id = crate::ui::search_bookmarks_ncl::add_entry(
|
|
|
|
|
&bm_path,
|
|
|
|
|
crate::ui::search_bookmarks_ncl::NewBookmark {
|
|
|
|
|
node_id: ¶m.node_id,
|
|
|
|
|
kind,
|
|
|
|
|
title: ¶m.title,
|
|
|
|
|
level,
|
|
|
|
|
term,
|
|
|
|
|
actor,
|
|
|
|
|
created_at: &now,
|
|
|
|
|
tags,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.map_err(|e| ToolError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
ctx.cache.invalidate_file(&bm_path);
|
|
|
|
|
|
|
|
|
|
Ok(serde_json::json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"id": id,
|
|
|
|
|
"created_at": now,
|
|
|
|
|
"node_id": param.node_id,
|
|
|
|
|
"title": param.title,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 00:18:14 +00:00
|
|
|
/// Run the MCP server over stdin/stdout — for use as a `command`-mode MCP
|
|
|
|
|
/// server in Claude Desktop, Cursor, or any stdio-compatible AI client.
|
|
|
|
|
pub async fn serve_stdio(state: AppState) -> anyhow::Result<()> {
|
|
|
|
|
let server = OntoreServer::new(state);
|
|
|
|
|
let ct = server
|
|
|
|
|
.serve(rmcp::transport::stdio())
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("MCP serve error: {e}"))?;
|
|
|
|
|
ct.waiting()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("MCP wait error: {e}"))?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|