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 for ErrorData { fn from(e: ToolError) -> ErrorData { ErrorData::internal_error(e.0, None) } } impl From for ToolError { fn from(e: serde_json::Error) -> Self { ToolError(e.to_string()) } } impl From for ToolError { fn from(s: String) -> Self { ToolError(s) } } // ── Project context // ───────────────────────────────────────────────────────────── struct ProjectCtx { root: PathBuf, cache: Arc, import_path: Option, } // ── 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, } #[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, } #[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, } #[derive(Deserialize, JsonSchema, Default)] struct BacklogInput { /// Project slug. Omit to use the default project. project: Option, /// Filter by status: `Open` | `InProgress` | `Done` | `Cancelled`. status: Option, } #[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, } #[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, // ── update_status fields ── /// Item id (e.g. `bl-001`). Required for `update_status`. id: Option, /// New status: `Open` | `InProgress` | `Done` | `Cancelled`. Required for /// `update_status`. status: Option, // ── add fields ── /// Item title. Required for `add`. title: Option, /// Item kind: `Feature` | `Bug` | `Chore` | `Research`. Required for `add`. kind: Option, /// Item priority: `Critical` | `High` | `Medium` | `Low`. Required for /// `add`. priority: Option, /// Optional detail or acceptance criteria for `add`. detail: Option, } #[derive(Deserialize, JsonSchema, Default)] struct QaListInput { /// Project slug. Omit to use the default project. project: Option, /// Optional substring filter on question text. filter: Option, } #[derive(Deserialize, JsonSchema, Default)] struct QaAddInput { /// The question text. question: String, /// The answer text. May be left empty to fill in later. answer: Option, /// Actor recording this entry. Defaults to `"agent"`. actor: Option, /// Optional tags for categorisation. tags: Option>, /// Optional references to ontology node ids or ADR ids. related: Option>, /// Project slug. Omit to use the default project. project: Option, } #[derive(Deserialize, JsonSchema, Default)] struct ActionListInput { /// Project slug. Omit to use the default project. project: Option, } #[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, /// Icon hint: `"book-open"` | `"refresh"` | `"code"` | `"bolt"`. icon: Option, /// Category: `"docs"` | `"sync"` | `"analysis"` | `"test"`. category: Option, /// Actors who can run this action: `"developer"` | `"agent"` | `"ci"`. actors: Option>, /// Project slug. Omit to use the default project. project: Option, } // ── Server ────────────────────────────────────────────────────────────────────── #[derive(Clone)] pub struct OntoreServer { state: AppState, tool_router: ToolRouter, } impl OntoreServer { pub fn new(state: AppState) -> Self { Self { state, tool_router: Self::tool_router(), } } fn tool_router() -> ToolRouter { ToolRouter::new() .with_async_tool::() .with_async_tool::() .with_async_tool::() .with_async_tool::() .with_async_tool::() .with_async_tool::() .with_async_tool::() .with_async_tool::() .with_async_tool::() .with_async_tool::() .with_async_tool::() .with_async_tool::() .with_async_tool::() .with_async_tool::() .with_async_tool::() .with_async_tool::() .with_async_tool::() .with_async_tool::() .with_async_tool::() } 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")] if let (Some(s), Some(registry)) = (effective.as_deref(), &self.state.registry) { if let Some(ctx) = registry.get(s) { 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 { #[cfg(feature = "ui")] if let Some(ref registry) = self.state.registry { return registry.all().into_iter().map(|p| p.slug.clone()).collect(); } vec![self.state.default_project_name()] } } // ── 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> { Some( "List all available projects. In single-project mode returns the current project name." .into(), ) } fn input_schema() -> Option> { None } fn output_schema() -> Option> { None } } impl AsyncTool for ListProjectsTool { async fn invoke( service: &OntoreServer, _param: NoInput, ) -> Result { 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> { Some( "Free-text search across ontology nodes, ADRs, and reflection modes. Returns ranked \ results with kind, title, and description." .into(), ) } fn output_schema() -> Option> { None } } impl AsyncTool for SearchTool { async fn invoke( service: &OntoreServer, param: SearchInput, ) -> Result { 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> { Some( "Describe a project: README summary, manifest layers, operational modes, default \ mode, and repo kind." .into(), ) } fn output_schema() -> Option> { None } } impl AsyncTool for DescribeProjectTool { async fn invoke( service: &OntoreServer, param: ProjectParam, ) -> Result { 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> { Some( "List all Architecture Decision Records (ADRs) with id, title, status, and constraint \ counts." .into(), ) } fn output_schema() -> Option> { None } } impl AsyncTool for ListAdrsTool { async fn invoke( service: &OntoreServer, param: ProjectParam, ) -> Result { 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 = 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> { Some( "Get full details of an Architecture Decision Record by id or partial filename stem \ (e.g. `adr-001`)." .into(), ) } fn output_schema() -> Option> { None } } impl AsyncTool for GetAdrTool { async fn invoke( service: &OntoreServer, param: GetItemInput, ) -> Result { 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))) } } // ── 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> { Some("List all reflection modes with id, trigger description, and step count.".into()) } fn output_schema() -> Option> { None } } impl AsyncTool for ListModesTool { async fn invoke( service: &OntoreServer, param: ProjectParam, ) -> Result { 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 = 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> { Some( "Get full details of a reflection mode including all steps, preconditions, and \ commands." .into(), ) } fn output_schema() -> Option> { None } } impl AsyncTool for GetModeTool { async fn invoke( service: &OntoreServer, param: GetItemInput, ) -> Result { 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> { Some( "Get project backlog items. Optionally filter by status: Open | InProgress | Done | \ Cancelled." .into(), ) } fn output_schema() -> Option> { None } } impl AsyncTool for GetBacklogTool { async fn invoke( service: &OntoreServer, param: BacklogInput, ) -> Result { 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 = 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> { 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> { None } } impl AsyncTool for GetNodeTool { async fn invoke( service: &OntoreServer, param: GetItemInput, ) -> Result { 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> { 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> { None } } impl AsyncTool for GetTool { async fn invoke( service: &OntoreServer, param: GetInput, ) -> Result { 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> { Some( "List all available ontoref MCP tools with descriptions, parameters, and the current \ active project." .into(), ) } fn output_schema() -> Option> { None } } impl AsyncTool for HelpTool { async fn invoke( service: &OntoreServer, _param: NoInput, ) -> Result { 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}] }, { "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/.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} ] }, ]); 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> { 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> { None } } impl AsyncTool for SetProjectTool { async fn invoke( service: &OntoreServer, param: SetProjectInput, ) -> Result { debug!(tool = "set_project", project = %param.project); // Verify project exists when registry is available. #[cfg(feature = "ui")] if let Some(ref registry) = service.state.registry { if registry.get(¶m.project).is_none() { let available = service.available_projects(); return Err(ToolError(format!( "project '{}' not found; available: {}", param.project, available.join(", ") ))); } } { 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> { 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> { None } } impl AsyncTool for ProjectStatusTool { async fn invoke( service: &OntoreServer, param: ProjectParam, ) -> Result { debug!(tool = "project_status", project = ?param.project); let ctx = service.project_ctx(param.project.as_deref()); // Actors let actor_list: Vec = 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> { 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> { None } } impl AsyncTool for BacklogOpTool { async fn invoke( service: &OntoreServer, param: BacklogOpInput, ) -> Result { 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, ) { 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> { Some( "Extract all hard and soft architectural constraints from all ADRs in the project." .into(), ) } fn output_schema() -> Option> { None } } impl AsyncTool for GetConstraintsTool { async fn invoke( service: &OntoreServer, param: ProjectParam, ) -> Result { 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 = Vec::new(); let mut soft: Vec = 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> { Some( "List Q&A entries stored in reflection/qa.ncl. Optionally filter by question \ substring." .into(), ) } fn output_schema() -> Option> { None } } impl AsyncTool for QaListTool { async fn invoke( service: &OntoreServer, param: QaListInput, ) -> Result { 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 = 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> { 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> { None } } impl AsyncTool for QaAddTool { async fn invoke( service: &OntoreServer, param: QaAddInput, ) -> Result { 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> { 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> { None } } impl AsyncTool for ActionListTool { async fn invoke( service: &OntoreServer, param: ActionListInput, ) -> Result { 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> { Some( concat!( "Create a new reflection mode file in reflection/modes/.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> { None } } impl AsyncTool for ActionAddTool { async fn invoke( service: &OntoreServer, param: ActionAddInput, ) -> Result { 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::(); 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, })) } } fn ncl_str_array(items: &[String]) -> String { if items.is_empty() { return "[]".to_string(); } let inner: Vec = 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. ", "Use `ontoref_search` for queries; then `ontoref_get` with the returned kind+id \ for details. ", "Use `ontoref_status` for a full project dashboard. ", "Use `ontoref_backlog` to add items or update status.", )) } } // ── Entry points // ──────────────────────────────────────────────────────────────── /// 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(()) }