use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{CallToolResult, Content, ErrorData, ServerCapabilities, ServerInfo};
use rmcp::{tool, tool_handler, tool_router, ServerHandler};
use crate::state::AppState;
fn read_only_denied() -> CallToolResult {
CallToolResult::error(vec![Content::text(
"MCP is read-only: enable write access in Akashi to allow this.",
)])
}
fn filter_vault_map(
map: &mut crate::service::vault_map::VaultMap,
pol: &crate::mcp::policy::McpPolicy,
) {
map.entry_points.retain(|e| !pol.is_hidden(&e.path));
map.orphans.retain(|p| !pol.is_hidden(p));
map.skills.retain(|p| !pol.is_hidden(p));
for g in &mut map.tag_clusters {
g.notes.retain(|n| !pol.is_hidden(n));
}
map.tag_clusters.retain(|g| !g.notes.is_empty());
map.topics.retain(|t| !pol.is_hidden(&t.representative));
for t in &mut map.topics {
t.notes.retain(|n| !pol.is_hidden(n));
t.size = t.notes.len();
}
map.graph.nodes.retain(|n| !pol.is_hidden(&n.id));
map.graph
.edges
.retain(|e| !pol.is_hidden(&e.source) && !pol.is_hidden(&e.target));
if map
.identity
.as_deref()
.map(|id| pol.is_hidden(id))
.unwrap_or(false)
{
map.identity = None;
}
map.totals.orphans = map.orphans.len();
map.totals.clusters = map.topics.len();
}
fn triple_dto_hidden(pol: &crate::mcp::policy::McpPolicy, t: &crate::rest::TripleDto) -> bool {
if pol.is_hidden(&t.subject) {
return true;
}
matches!(&t.object, crate::rest::ValueDto::Node { node } if pol.is_hidden(node))
}
fn binding_hidden(pol: &crate::mcp::policy::McpPolicy, row: &serde_json::Value) -> bool {
row.as_object().is_some_and(|m| {
m.values()
.filter_map(|v| v.as_str())
.any(|s| pol.is_hidden(s))
})
}
#[cfg(feature = "dag")]
fn dag_dto_hidden(pol: &crate::mcp::policy::McpPolicy, d: &crate::rest::dag::DagActionDto) -> bool {
pol.text_references_excluded(&d.payload_summary)
}
#[cfg(feature = "dag")]
#[derive(serde::Deserialize, schemars::JsonSchema)]
pub struct DagHistoryParams {
pub subject: String,
#[serde(default = "default_hist_limit")]
pub limit: usize,
}
#[cfg(feature = "dag")]
fn default_hist_limit() -> usize {
crate::service::dag::DEFAULT_HISTORY_LIMIT
}
#[cfg(feature = "dag")]
#[derive(serde::Deserialize, schemars::JsonSchema)]
pub struct DagActionParams {
pub hash: String,
}
#[cfg(feature = "dag")]
#[derive(serde::Deserialize, schemars::JsonSchema)]
pub struct DagChainParams {
pub author: String,
#[serde(default = "default_hist_limit")]
pub limit: usize,
}
#[derive(Clone)]
pub struct AingleMcp {
pub(crate) state: AppState,
#[allow(dead_code)]
tool_router: ToolRouter<Self>,
}
#[tool_router]
impl AingleMcp {
pub fn new(state: AppState) -> Self {
#[allow(unused_mut)]
let mut router = Self::tool_router();
#[cfg(feature = "dag")]
{
router += Self::dag_tool_router();
}
#[cfg(feature = "sparql")]
{
router += Self::sparql_tool_router();
}
Self {
state,
tool_router: router,
}
}
#[tool(description = "Liveness check; returns 'pong'.")]
async fn aingle_ping(&self) -> String {
"pong".to_string()
}
#[tool(
description = "Ingest a markdown vault or code repo: auto-extracts triples \
(frontmatter, wikilinks, headings, tags), indexes text chunks for \
semantic recall, and records signed provenance. Incremental: unchanged \
files are skipped."
)]
async fn aingle_ingest(
&self,
params: Parameters<IngestParams>,
) -> Result<CallToolResult, ErrorData> {
if !self.state.mcp_policy_snapshot().allows_mutation() {
return Ok(read_only_denied());
}
let Parameters(p) = params;
let resp = crate::service::ingest::ingest_path(&self.state, &p.path, None)
.await
.map_err(super::convert::to_mcp_error)?;
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "Answer-grounding for a question. Returns cited source chunks \
(path:lines) with a signed-provenance anchor and a groundedness signal. \
Answer ONLY from the returned context; if groundedness is not 'grounded', \
say so and do not invent.",
annotations(read_only_hint = true)
)]
async fn aingle_ground(
&self,
params: Parameters<GroundParams>,
) -> Result<CallToolResult, ErrorData> {
let Parameters(p) = params;
let mut g = crate::service::ground::ground(&self.state, &p.question, p.k)
.await
.map_err(super::convert::to_mcp_error)?;
let pol = self.state.mcp_policy_snapshot();
g.answer_context.retain(|c| !pol.is_hidden(&c.source));
let has_visible_source = !g.answer_context.is_empty();
let grounding_ok = !pol.require_grounding || g.groundedness == "grounded";
let answerable = has_visible_source && grounding_ok;
if !answerable {
let instruction = if g.index_stale {
"The semantic index is stale (embeddings are placeholders): tell the \
user to re-index the vault, and do not claim their notes are empty."
} else {
"Insufficient grounded evidence in your notes; say you don't know and \
do not invent facts."
};
let refusal = serde_json::json!({
"groundedness": g.groundedness,
"answerable": false,
"answer_context": [],
"gaps": g.gaps,
"index_stale": g.index_stale,
"instruction": instruction,
});
return Ok(CallToolResult::success(vec![Content::json(refusal)?]));
}
let payload = serde_json::json!({
"groundedness": g.groundedness,
"answerable": true,
"answer_context": g.answer_context,
"gaps": g.gaps,
"index_stale": g.index_stale,
"instruction": g.instruction,
});
Ok(CallToolResult::success(vec![Content::json(payload)?]))
}
#[tool(
description = "Verified backlinks, outgoing links, and unlinked mentions for a note. \
Each backlink includes the source's context line and a signed-provenance anchor \
when available. Use for accurate reverse navigation.",
annotations(read_only_hint = true)
)]
async fn aingle_backlinks(
&self,
params: Parameters<BacklinksParams>,
) -> Result<CallToolResult, ErrorData> {
let Parameters(p) = params;
let mut resp = crate::service::backlinks::backlinks(&self.state, &p.note).await;
let pol = self.state.mcp_policy_snapshot();
resp.backlinks.retain(|b| !pol.is_hidden(&b.path));
resp.outgoing.retain(|path| !pol.is_hidden(path));
resp.unlinked.retain(|path| !pol.is_hidden(path));
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "Verified context bundle for a note: notes that are semantically \
related by meaning (not just by explicit links), each with the matching \
passage as evidence and a signed-provenance anchor when available. Use to \
answer grounded in a note's verified neighborhood without hallucinating.",
annotations(read_only_hint = true)
)]
async fn aingle_note_context(
&self,
params: Parameters<NoteContextParams>,
) -> Result<CallToolResult, ErrorData> {
let Parameters(p) = params;
let mut resp = crate::service::context::note_context_cached(
&self.state,
&p.note,
p.limit.unwrap_or(8),
)
.await;
let pol = self.state.mcp_policy_snapshot();
resp.neighbors.retain(|n| !pol.is_hidden(&n.path));
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "Shortest verified connection between two notes in the vault. \
Returns the chain of typed hops (link or semantic), each with its \
similarity score and signed-provenance anchor when available, so every \
step of the connection can be cited. Use when the user asks how two \
topics, notes, or decisions relate.",
annotations(read_only_hint = true)
)]
async fn aingle_path(
&self,
params: Parameters<PathParams>,
) -> Result<CallToolResult, ErrorData> {
let Parameters(p) = params;
let mut resp =
crate::service::path::find_path(&self.state, &p.from, &p.to, p.max_hops).await;
let pol = self.state.mcp_policy_snapshot();
if resp.found && resp.nodes.iter().any(|n| pol.is_hidden(n)) {
resp.found = false;
resp.nodes.clear();
resp.hops.clear();
resp.note = Some(format!("no connection within {} hops", resp.max_hops));
}
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "List ingested source files with their content hashes (the \
signed provenance registry).",
annotations(read_only_hint = true)
)]
async fn aingle_sources(&self) -> Result<CallToolResult, ErrorData> {
let mut resp = crate::service::ingest::list_sources(&self.state)
.await
.map_err(super::convert::to_mcp_error)?;
let pol = self.state.mcp_policy_snapshot();
resp.retain(|r| !pol.is_hidden(&r.path));
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "Vault map & navigation manual: hub entry-points, semantic topic \
clusters, orphan notes, tag/type indices, and guidance. Call this FIRST to \
navigate a vault accurately, then aingle_ground each claim.",
annotations(read_only_hint = true)
)]
async fn aingle_vault_map(&self) -> Result<CallToolResult, ErrorData> {
let mut resp = crate::service::vault_map::vault_map_cached(&self.state).await;
let pol = self.state.mcp_policy_snapshot();
filter_vault_map(&mut resp, &pol);
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "Query the semantic graph by triple pattern. Omit a field to wildcard it.",
annotations(read_only_hint = true)
)]
async fn aingle_query_pattern(
&self,
params: Parameters<crate::rest::PatternQueryRequest>,
) -> Result<CallToolResult, ErrorData> {
let Parameters(req) = params;
let mut resp = crate::service::query::query_pattern(&self.state, req, None)
.await
.map_err(super::convert::to_mcp_error)?;
let pol = self.state.mcp_policy_snapshot();
resp.matches.retain(|t| !triple_dto_hidden(&pol, t));
resp.total = resp.matches.len();
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "List unique subjects in the semantic graph, optionally filtered by predicate.",
annotations(read_only_hint = true)
)]
async fn aingle_list_subjects(
&self,
params: Parameters<crate::rest::ListSubjectsQuery>,
) -> Result<CallToolResult, ErrorData> {
let Parameters(req) = params;
let mut resp = crate::service::query::list_subjects(&self.state, req, None)
.await
.map_err(super::convert::to_mcp_error)?;
let pol = self.state.mcp_policy_snapshot();
resp.subjects.retain(|s| !pol.is_hidden(s));
resp.total = resp.subjects.len();
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "List unique predicates in the semantic graph, optionally filtered by subject.",
annotations(read_only_hint = true)
)]
async fn aingle_list_predicates(
&self,
params: Parameters<crate::rest::ListPredicatesQuery>,
) -> Result<CallToolResult, ErrorData> {
let Parameters(req) = params;
let resp = crate::service::query::list_predicates(&self.state, req, None)
.await
.map_err(super::convert::to_mcp_error)?;
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "Insert a triple into the semantic graph. Mutates the graph.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = false
)
)]
async fn aingle_create_triple(
&self,
params: Parameters<crate::rest::CreateTripleRequest>,
) -> Result<CallToolResult, ErrorData> {
if !self.state.mcp_policy_snapshot().allows_mutation() {
return Ok(read_only_denied());
}
let Parameters(req) = params;
let dto =
crate::service::triples::create_triple(&self.state, req, None, Some(super::MCP_ORIGIN))
.await
.map_err(super::convert::to_mcp_error)?;
Ok(CallToolResult::success(vec![Content::json(dto)?]))
}
#[tool(
description = "Atomically bulk-insert triples into the semantic graph. Duplicates are skipped silently.",
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true
)
)]
async fn aingle_batch_insert(
&self,
params: Parameters<crate::rest::BatchInsertRequest>,
) -> Result<CallToolResult, ErrorData> {
if !self.state.mcp_policy_snapshot().allows_mutation() {
return Ok(read_only_denied());
}
let Parameters(req) = params;
let resp = crate::service::triples::batch_insert(&self.state, req, None)
.await
.map_err(super::convert::to_mcp_error)?;
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "Fetch a single triple by its hex hash id.",
annotations(read_only_hint = true)
)]
async fn aingle_get_triple(
&self,
params: Parameters<crate::rest::TripleIdRequest>,
) -> Result<CallToolResult, ErrorData> {
let Parameters(req) = params;
let dto = crate::service::triples::get_triple(&self.state, &req.id)
.await
.map_err(super::convert::to_mcp_error)?;
let pol = self.state.mcp_policy_snapshot();
if triple_dto_hidden(&pol, &dto) {
return Err(super::convert::to_mcp_error(crate::error::Error::NotFound(
format!("Triple {} not found", req.id),
)));
}
Ok(CallToolResult::success(vec![Content::json(dto)?]))
}
#[tool(
description = "Delete a triple from the semantic graph by its hex hash id.",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = true
)
)]
async fn aingle_delete_triple(
&self,
params: Parameters<crate::rest::TripleIdRequest>,
) -> Result<CallToolResult, ErrorData> {
if !self.state.mcp_policy_snapshot().allows_mutation() {
return Ok(read_only_denied());
}
let Parameters(req) = params;
crate::service::triples::delete_triple(&self.state, &req.id, None, Some(super::MCP_ORIGIN))
.await
.map_err(super::convert::to_mcp_error)?;
Ok(CallToolResult::success(vec![Content::json(
serde_json::json!({ "deleted": true, "id": req.id }),
)?]))
}
#[tool(
description = "List triples with optional subject/predicate filters and pagination.",
annotations(read_only_hint = true)
)]
async fn aingle_list_triples(
&self,
params: Parameters<crate::rest::ListTriplesQuery>,
) -> Result<CallToolResult, ErrorData> {
let Parameters(req) = params;
let mut resp = crate::service::triples::list_triples(&self.state, req, None)
.await
.map_err(super::convert::to_mcp_error)?;
let pol = self.state.mcp_policy_snapshot();
resp.triples.retain(|t| !triple_dto_hidden(&pol, t));
resp.total = resp.triples.len();
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "Return graph statistics: triple count and related metrics.",
annotations(read_only_hint = true)
)]
async fn aingle_graph_stats(&self) -> Result<CallToolResult, ErrorData> {
let resp = crate::service::stats::graph_stats(&self.state)
.await
.map_err(super::convert::to_mcp_error)?;
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "Verify a cryptographic/ZK proof by ID. Returns valid:false for invalid proofs (not an error).",
annotations(read_only_hint = true)
)]
async fn aingle_verify_proof(
&self,
params: Parameters<crate::rest::VerifyProofByIdRequest>,
) -> Result<CallToolResult, ErrorData> {
let Parameters(req) = params;
let resp = crate::service::proof::verify_proof(&self.state, req)
.await
.map_err(super::convert::to_mcp_error)?;
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "Fetch a stored cryptographic/ZK proof by ID. Errors if the proof does not exist.",
annotations(read_only_hint = true)
)]
async fn aingle_get_proof(
&self,
params: Parameters<crate::rest::GetProofRequest>,
) -> Result<CallToolResult, ErrorData> {
let Parameters(req) = params;
let resp = crate::service::proof::get_proof(&self.state, req)
.await
.map_err(super::convert::to_mcp_error)?;
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "Validate a semantic skill manifest against PoL rules. Returns {valid, errors}; does not mutate.",
annotations(read_only_hint = true)
)]
async fn aingle_validate_skill(
&self,
params: Parameters<crate::rest::ValidateManifestRequest>,
) -> Result<CallToolResult, ErrorData> {
let Parameters(req) = params;
let resp = crate::service::skill::validate_manifest(&self.state, req).await;
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "Create a temporary sandbox namespace for skill testing. Returns {id, namespace}.",
annotations(read_only_hint = false, destructive_hint = false)
)]
async fn aingle_sandbox_create(
&self,
params: Parameters<crate::rest::CreateSandboxRequest>,
) -> Result<CallToolResult, ErrorData> {
if !self.state.mcp_policy_snapshot().allows_mutation() {
return Ok(read_only_denied());
}
let Parameters(req) = params;
let resp = crate::service::skill::create_sandbox(&self.state, req).await;
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "Delete a sandbox namespace by id, removing all triples under it. Unknown id => deleted:false.",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = true
)
)]
async fn aingle_sandbox_delete(
&self,
params: Parameters<crate::rest::DeleteSandboxRequest>,
) -> Result<CallToolResult, ErrorData> {
if !self.state.mcp_policy_snapshot().allows_mutation() {
return Ok(read_only_denied());
}
let Parameters(req) = params;
let resp = crate::service::skill::delete_sandbox(&self.state, &req.id).await;
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "Compute an agent's assertion consistency score (fraction of its assertions that pass PoL validation). Unknown agent => score 0.0.",
annotations(read_only_hint = true)
)]
async fn aingle_agent_consistency(
&self,
params: Parameters<crate::rest::AgentConsistencyRequest>,
) -> Result<CallToolResult, ErrorData> {
let Parameters(req) = params;
let resp =
crate::service::reputation::agent_consistency(&self.state, &req.agent_id, None).await;
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "Batch-verify assertion proofs by (subject, predicate). Returns a per-assertion verified flag; unknown assertions => verified:false (not an error).",
annotations(read_only_hint = true)
)]
async fn aingle_verify_assertions_batch(
&self,
params: Parameters<crate::rest::BatchVerifyAssertionsRequest>,
) -> Result<CallToolResult, ErrorData> {
let Parameters(req) = params;
let resp =
crate::service::reputation::batch_verify_assertions(&self.state, req, None).await;
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "Validate triple(s) against the PoL logic engine. Returns {valid, results, proof_hash}; invalid triples yield valid:false (not an error). Does not mutate.",
annotations(read_only_hint = true)
)]
async fn aingle_validate(
&self,
params: Parameters<crate::rest::ValidateRequest>,
) -> Result<CallToolResult, ErrorData> {
let Parameters(req) = params;
let resp = crate::service::validate::validate_triples(&self.state, req, None)
.await
.map_err(super::convert::to_mcp_error)?;
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
}
#[cfg(feature = "dag")]
#[tool_router(router = dag_tool_router)]
impl AingleMcp {
#[tool(
description = "Return the signed DAG provenance history of a subject (newest first).",
annotations(read_only_hint = true)
)]
async fn aingle_dag_history(
&self,
params: Parameters<DagHistoryParams>,
) -> Result<CallToolResult, ErrorData> {
let Parameters(p) = params;
let pol = self.state.mcp_policy_snapshot();
if pol.is_hidden(&p.subject) {
let empty: Vec<crate::rest::dag::DagActionDto> = Vec::new();
return Ok(CallToolResult::success(vec![Content::json(empty)?]));
}
let mut h = crate::service::dag::history_by_subject(&self.state, &p.subject, p.limit)
.await
.map_err(super::convert::to_mcp_error)?;
h.retain(|a| !dag_dto_hidden(&pol, a));
Ok(CallToolResult::success(vec![Content::json(h)?]))
}
#[tool(
description = "Return the current DAG tip hashes (frontier) and their count.",
annotations(read_only_hint = true)
)]
async fn aingle_dag_tips(&self) -> Result<CallToolResult, ErrorData> {
let resp = crate::service::dag::tips(&self.state)
.await
.map_err(super::convert::to_mcp_error)?;
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "Fetch a single DAG action by its hex hash.",
annotations(read_only_hint = true)
)]
async fn aingle_dag_action(
&self,
params: Parameters<DagActionParams>,
) -> Result<CallToolResult, ErrorData> {
let Parameters(p) = params;
let resp = crate::service::dag::action(&self.state, &p.hash)
.await
.map_err(super::convert::to_mcp_error)?;
let pol = self.state.mcp_policy_snapshot();
if dag_dto_hidden(&pol, &resp) {
return Err(super::convert::to_mcp_error(crate::error::Error::NotFound(
format!("DAG action {} not found", p.hash),
)));
}
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "Return an author's DAG action chain (newest first), up to limit.",
annotations(read_only_hint = true)
)]
async fn aingle_dag_chain(
&self,
params: Parameters<DagChainParams>,
) -> Result<CallToolResult, ErrorData> {
let Parameters(p) = params;
let mut resp = crate::service::dag::chain(&self.state, &p.author, p.limit)
.await
.map_err(super::convert::to_mcp_error)?;
let pol = self.state.mcp_policy_snapshot();
resp.retain(|a| !dag_dto_hidden(&pol, a));
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "Return DAG statistics: action count and tip count.",
annotations(read_only_hint = true)
)]
async fn aingle_dag_stats(&self) -> Result<CallToolResult, ErrorData> {
let resp = crate::service::dag::stats(&self.state)
.await
.map_err(super::convert::to_mcp_error)?;
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
#[tool(
description = "Prune the DAG per a retention policy (keep_all/keep_since/keep_last/keep_depth). Destructive.",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = false
)
)]
async fn aingle_dag_prune(
&self,
params: Parameters<crate::rest::dag::PruneRequest>,
) -> Result<CallToolResult, ErrorData> {
if !self.state.mcp_policy_snapshot().allows_mutation() {
return Ok(read_only_denied());
}
let Parameters(req) = params;
let resp = crate::service::dag::prune(&self.state, req)
.await
.map_err(super::convert::to_mcp_error)?;
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
}
#[cfg(feature = "sparql")]
#[tool_router(router = sparql_tool_router)]
impl AingleMcp {
#[tool(
description = "Execute a SPARQL query (SELECT/CONSTRUCT/ASK) against the semantic graph.",
annotations(read_only_hint = true)
)]
async fn aingle_sparql(
&self,
params: Parameters<crate::sparql::SparqlRequest>,
) -> Result<CallToolResult, ErrorData> {
let Parameters(req) = params;
let query_text = req.query.clone();
let mut resp = crate::service::sparql::execute(&self.state, req)
.await
.map_err(super::convert::to_mcp_error)?;
let pol = self.state.mcp_policy_snapshot();
if !pol.excluded_folders.is_empty() {
if let Some(rows) = resp.bindings.as_mut() {
rows.retain(|row| !binding_hidden(&pol, row));
if resp.triple_count.is_some() {
resp.triple_count = Some(rows.len());
}
}
if resp.boolean.is_some() && pol.text_references_excluded(&query_text) {
return Ok(CallToolResult::error(vec![Content::text(
"SPARQL ASK over an excluded folder is not allowed while folder \
exclusions are active.",
)]));
}
}
Ok(CallToolResult::success(vec![Content::json(resp)?]))
}
}
#[derive(serde::Deserialize, schemars::JsonSchema)]
pub struct IngestParams {
pub path: String,
}
#[derive(serde::Deserialize, schemars::JsonSchema)]
pub struct GroundParams {
pub question: String,
#[serde(default = "default_ground_k")]
pub k: usize,
}
fn default_ground_k() -> usize {
6
}
#[derive(serde::Deserialize, schemars::JsonSchema)]
pub struct BacklinksParams {
pub note: String,
}
#[derive(serde::Deserialize, schemars::JsonSchema)]
pub struct NoteContextParams {
pub note: String,
pub limit: Option<usize>,
}
#[derive(serde::Deserialize, schemars::JsonSchema)]
pub struct PathParams {
pub from: String,
pub to: String,
pub max_hops: Option<usize>,
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for AingleMcp {
fn get_info(&self) -> ServerInfo {
let mut info = ServerInfo::default();
info.capabilities = ServerCapabilities::builder().enable_tools().build();
info.instructions = Some(
"AIngle Córtex MCP server: tools for querying and mutating \
AIngle semantic graphs."
.to_string(),
);
info
}
}
#[cfg(test)]
mod ingest_tools_tests {
use super::*;
#[test]
fn router_exposes_ingest_ground_sources() {
let state = AppState::with_db_path(":memory:", None).unwrap();
let mcp = AingleMcp::new(state);
let names: Vec<String> = mcp
.tool_router
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.collect();
for expected in [
"aingle_ingest",
"aingle_ground",
"aingle_sources",
"aingle_vault_map",
"aingle_backlinks",
"aingle_note_context",
"aingle_path",
] {
assert!(
names.contains(&expected.to_string()),
"missing tool {expected}"
);
}
}
}
#[cfg(test)]
mod policy_enforcement_tests {
use super::*;
use crate::mcp::policy::{McpPolicy, Permission};
fn json_of(result: &CallToolResult) -> serde_json::Value {
let text = result
.content
.first()
.and_then(|c| c.as_text())
.expect("tool result must have a text content block")
.text
.clone();
serde_json::from_str(&text).expect("tool content must be valid JSON")
}
async fn state_with_vault() -> (AppState, tempfile::TempDir) {
let state = AppState::with_db_path(":memory:", None).unwrap();
{
let mut g = state.graph.write().await;
g.enable_dag();
}
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("Personal").join("Finanzas")).unwrap();
std::fs::create_dir_all(dir.path().join("Public")).unwrap();
std::fs::write(
dir.path()
.join("Personal")
.join("Finanzas")
.join("secret.md"),
"# Secreto\n\nMi presupuesto privado y numeros de cuenta.\n",
)
.unwrap();
std::fs::write(
dir.path().join("Public").join("open.md"),
"# Abierto\n\nContenido publico del roadmap del proyecto.\n",
)
.unwrap();
crate::service::ingest::ingest_path(&state, dir.path().to_str().unwrap(), None)
.await
.unwrap();
(state, dir)
}
#[tokio::test]
async fn excluded_folder_hidden_from_sources() {
let (state, _dir) = state_with_vault().await;
state.set_mcp_policy(McpPolicy {
excluded_folders: vec!["Personal/Finanzas".into()],
permission: Permission::ReadOnly,
require_grounding: false,
});
let mcp = AingleMcp::new(state);
let result = mcp.aingle_sources().await.expect("aingle_sources ok");
let paths: Vec<String> = json_of(&result)
.as_array()
.expect("sources is an array")
.iter()
.map(|r| {
r.get("path")
.and_then(|p| p.as_str())
.unwrap_or("")
.replace('\\', "/")
})
.collect();
assert!(
paths.iter().any(|p| p == "Public/open.md"),
"public note must remain visible: {paths:?}"
);
assert!(
!paths.iter().any(|p| p.starts_with("Personal/Finanzas")),
"excluded-folder note must be hidden: {paths:?}"
);
}
async fn excluded_mcp() -> (AingleMcp, tempfile::TempDir) {
let (state, dir) = state_with_vault().await;
state.set_mcp_policy(McpPolicy {
excluded_folders: vec!["Personal/Finanzas".into()],
permission: Permission::ReadOnly,
require_grounding: false,
});
(AingleMcp::new(state), dir)
}
#[tokio::test]
async fn excluded_folder_hidden_from_list_subjects() {
let (mcp, _dir) = excluded_mcp().await;
let req: crate::rest::ListSubjectsQuery =
serde_json::from_value(serde_json::json!({ "limit": 10_000 })).unwrap();
let result = mcp
.aingle_list_subjects(Parameters(req))
.await
.expect("list_subjects ok");
let subjects: Vec<String> = json_of(&result)
.get("subjects")
.and_then(|s| s.as_array())
.expect("subjects array")
.iter()
.map(|v| v.as_str().unwrap_or("").replace('\\', "/"))
.collect();
assert!(
subjects.iter().any(|s| s.contains("Public/open.md")),
"public subject must remain visible: {subjects:?}"
);
assert!(
!subjects.iter().any(|s| s.contains("Personal/Finanzas")),
"excluded subject must be hidden: {subjects:?}"
);
}
#[tokio::test]
async fn excluded_folder_hidden_from_query_pattern() {
let (mcp, _dir) = excluded_mcp().await;
let req: crate::rest::PatternQueryRequest =
serde_json::from_value(serde_json::json!({ "limit": 10_000 })).unwrap();
let result = mcp
.aingle_query_pattern(Parameters(req))
.await
.expect("query_pattern ok");
let payload = json_of(&result);
let dump = payload.to_string().replace('\\', "/");
assert!(
dump.contains("Public/open.md"),
"public triples must remain: {dump}"
);
assert!(
!dump.contains("Personal/Finanzas"),
"excluded-folder triples must be hidden: {dump}"
);
}
#[cfg(feature = "sparql")]
#[tokio::test]
async fn excluded_folder_hidden_from_sparql_select() {
let (mcp, _dir) = excluded_mcp().await;
let req: crate::sparql::SparqlRequest = serde_json::from_value(serde_json::json!({
"query": "SELECT ?s ?p ?o WHERE { ?s ?p ?o }"
}))
.unwrap();
let result = mcp.aingle_sparql(Parameters(req)).await.expect("sparql ok");
let dump = json_of(&result).to_string().replace('\\', "/");
assert!(
!dump.contains("Personal/Finanzas"),
"SPARQL rows must not reference excluded paths: {dump}"
);
}
#[cfg(feature = "dag")]
#[tokio::test]
async fn excluded_folder_hidden_from_dag_history() {
let (mcp, _dir) = excluded_mcp().await;
let params = DagHistoryParams {
subject: "Personal/Finanzas/secret.md".to_string(),
limit: 50,
};
let result = mcp
.aingle_dag_history(Parameters(params))
.await
.expect("dag_history ok");
let payload = json_of(&result);
let rows = payload.as_array().expect("history is an array");
assert!(
rows.is_empty(),
"history of an excluded subject must be empty: {payload}"
);
assert!(
!payload
.to_string()
.replace('\\', "/")
.contains("Personal/Finanzas"),
"dag_history must not leak the excluded path: {payload}"
);
}
#[tokio::test]
async fn mutation_denied_under_read_only_default() {
let state = AppState::with_db_path(":memory:", None).unwrap();
let mcp = AingleMcp::new(state);
let req: crate::rest::CreateTripleRequest = serde_json::from_value(serde_json::json!({
"subject": "http://example.org/a",
"predicate": "http://example.org/knows",
"object": "b",
}))
.unwrap();
let result = mcp
.aingle_create_triple(Parameters(req))
.await
.expect("tool returns a result (not a protocol error)");
assert_eq!(
result.is_error,
Some(true),
"read-only default must deny mutation: {result:?}"
);
}
#[tokio::test]
async fn require_grounding_declines_ungrounded_answers() {
let off_topic = "¿Cuál es la mejor receta de pizza napolitana con mozzarella?";
let (state, _dir) = state_with_vault().await;
state.set_mcp_policy(McpPolicy {
require_grounding: true,
..Default::default()
});
let mcp = AingleMcp::new(state);
let req = GroundParams {
question: off_topic.to_string(),
k: 6,
};
let result = mcp.aingle_ground(Parameters(req)).await.expect("ground ok");
let payload = json_of(&result);
assert_eq!(
payload.get("answerable").and_then(|v| v.as_bool()),
Some(false),
"gated refusal must signal answerable:false: {payload}"
);
let ctx = payload.get("answer_context").and_then(|v| v.as_array());
assert!(
ctx.map(|a| a.is_empty()).unwrap_or(true),
"refusal must omit source chunks so nothing weak can be answered from: {payload}"
);
assert_ne!(
payload.get("groundedness").and_then(|v| v.as_str()),
Some("grounded"),
"an off-topic question must not be grounded: {payload}"
);
let (state, _dir2) = state_with_vault().await;
let mcp = AingleMcp::new(state); let req = GroundParams {
question: off_topic.to_string(),
k: 6,
};
let result = mcp.aingle_ground(Parameters(req)).await.expect("ground ok");
let payload = json_of(&result);
assert_ne!(
payload.get("answerable").and_then(|v| v.as_bool()),
Some(false),
"with the gate off the tool must not refuse: {payload}"
);
assert!(
payload.get("answer_context").is_some(),
"normal shape must still carry answer_context: {payload}"
);
}
#[tokio::test]
async fn all_sources_excluded_makes_unanswerable() {
let state = AppState::with_db_path(":memory:", None).unwrap();
{
let mut g = state.graph.write().await;
g.enable_dag();
}
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("Personal").join("Finanzas")).unwrap();
std::fs::write(
dir.path()
.join("Personal")
.join("Finanzas")
.join("presupuesto.md"),
"# Presupuesto\n\nEl presupuesto mensual de marketing es de 4200 euros.\n",
)
.unwrap();
crate::service::ingest::ingest_path(&state, dir.path().to_str().unwrap(), None)
.await
.unwrap();
state.set_mcp_policy(McpPolicy {
excluded_folders: vec!["Personal/Finanzas".into()],
permission: Permission::ReadOnly,
require_grounding: false,
});
let mcp = AingleMcp::new(state);
let req = GroundParams {
question: "¿Cuál es el presupuesto mensual de marketing?".to_string(),
k: 6,
};
let result = mcp.aingle_ground(Parameters(req)).await.expect("ground ok");
let payload = json_of(&result);
let ctx = payload.get("answer_context").and_then(|v| v.as_array());
assert!(
ctx.map(|a| a.is_empty()).unwrap_or(true),
"all evidence is folder-excluded, so answer_context must be empty: {payload}"
);
assert_eq!(
payload.get("answerable").and_then(|v| v.as_bool()),
Some(false),
"answerable must be false when no visible source remains: {payload}"
);
}
#[cfg(feature = "dag")]
#[tokio::test]
async fn mcp_create_triple_tags_dag_origin_mcp() {
let state = AppState::with_db_path(":memory:", None).unwrap();
{
let mut g = state.graph.write().await;
g.enable_dag();
}
state.set_mcp_policy(McpPolicy {
permission: Permission::ReadWrite,
..Default::default()
});
let mcp = AingleMcp::new(state.clone());
let req: crate::rest::CreateTripleRequest = serde_json::from_value(serde_json::json!({
"subject": "note.md",
"predicate": "links_to",
"object": { "node": "other.md" },
}))
.unwrap();
let result = mcp
.aingle_create_triple(Parameters(req))
.await
.expect("create_triple ok");
assert_ne!(
result.is_error,
Some(true),
"read-write policy must allow the mutation: {result:?}"
);
let graph = state.graph.read().await;
let actions = graph.dag_history_by_subject("note.md", 10).unwrap();
let newest = actions
.first()
.expect("one DAG action recorded for the insert");
assert_eq!(
newest.author.as_name(),
Some(crate::mcp::MCP_ORIGIN),
"MCP-originated create must tag the DAG action author with origin=mcp, got {:?}",
newest.author
);
}
#[tokio::test]
async fn mutation_allowed_under_read_write() {
let state = AppState::with_db_path(":memory:", None).unwrap();
state.set_mcp_policy(McpPolicy {
permission: Permission::ReadWrite,
..Default::default()
});
let mcp = AingleMcp::new(state);
let req: crate::rest::CreateTripleRequest = serde_json::from_value(serde_json::json!({
"subject": "http://example.org/a",
"predicate": "http://example.org/knows",
"object": "b",
}))
.unwrap();
let result = mcp
.aingle_create_triple(Parameters(req))
.await
.expect("tool returns a result");
assert_ne!(
result.is_error,
Some(true),
"read-write policy must allow mutation: {result:?}"
);
}
}