ontoref-code/crates/ontoref-daemon/src/mcp/mod.rs

1772 lines
61 KiB
Rust
Raw Normal View History

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>,
}
#[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>,
}
#[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>()
.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>()
.with_async_tool::<ActionListTool>()
.with_async_tool::<ActionAddTool>()
}
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) = 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> {
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(),
&param.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)))
}
}
// ── 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 &param.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}] },
{ "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}
] },
]);
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")]
if service.state.registry.get(&param.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,
&param.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(&param.id),
label = escape_mcp(&param.label),
trigger = escape_mcp(&param.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<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. ",
"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(())
}