use std::sync::Arc;
use mant_engine::QueryViewResult;
use mant_loader::LoadPolicy;
use mant_protocol::{
CatalogQuery, DocumentCatalog, QueryRequest, ScopeQueryRequest, ScopeQueryResponse,
};
use tokio::{sync::Semaphore, task};
#[derive(Debug, Clone)]
pub(super) struct QueryService {
gate: Arc<Semaphore>,
}
impl QueryService {
pub(super) fn new() -> Self {
Self {
gate: Arc::new(Semaphore::new(4)),
}
}
pub(super) async fn query(&self, request: QueryRequest) -> Result<QueryViewResult, String> {
let permit = Arc::clone(&self.gate)
.acquire_owned()
.await
.map_err(|_| "MCP query service is shutting down".to_owned())?;
task::spawn_blocking(move || {
let _permit = permit;
mant_engine::execute_query(&request, LoadPolicy::default()).map_err(query_error_for_mcp)
})
.await
.map_err(|_| "MCP query worker failed".to_owned())?
}
pub(super) async fn query_scope(
&self,
request: ScopeQueryRequest,
) -> Result<ScopeQueryResponse, String> {
let permit = Arc::clone(&self.gate)
.acquire_owned()
.await
.map_err(|_| "MCP query service is shutting down".to_owned())?;
task::spawn_blocking(move || {
let _permit = permit;
mant_engine::execute_scope_query(&request).map_err(scope_error_for_mcp)
})
.await
.map_err(|_| "MCP scope-query worker failed".to_owned())?
}
pub(super) async fn discover(&self, query: CatalogQuery) -> Result<DocumentCatalog, String> {
let permit = Arc::clone(&self.gate)
.acquire_owned()
.await
.map_err(|_| "MCP query service is shutting down".to_owned())?;
task::spawn_blocking(move || {
let _permit = permit;
mant_loader::discover_documents(&query).map_err(discovery_error_for_mcp)
})
.await
.map_err(|_| "MCP document discovery worker failed".to_owned())?
}
}
fn discovery_error_for_mcp(_error: String) -> String {
"registered document discovery failed".to_owned()
}
fn scope_error_for_mcp(error: mant_engine::ScopeQueryError) -> String {
use mant_engine::ScopeQueryError;
match error {
ScopeQueryError::Load(mant_loader::ScopeLoadError::NoResolvedDocuments { .. })
| ScopeQueryError::Execution(mant_query::ScopeExecutionError::NoReadableDocuments {
..
}) => "none of the requested documents could be resolved".to_owned(),
other => other.to_string(),
}
}
pub(super) fn query_error_for_mcp(error: mant_engine::QueryExecutionError) -> String {
use mant_engine::{QueryError, QueryExecutionError};
use mant_loader::{LoadError, ManualLoadError};
use mant_query::ProjectionError;
fn manual_error_for_mcp(error: &ManualLoadError) -> String {
match error {
ManualLoadError::NotFound { name, .. } => format!("manual '{name}' was not found"),
ManualLoadError::Parse { name, .. } => format!("could not parse manual '{name}'"),
ManualLoadError::Empty { name, .. } => {
format!("manual '{name}' contained no readable sections")
}
}
}
let QueryExecutionError::Query(error) = error else {
return match error {
QueryExecutionError::Projection(ProjectionError::UnknownSelector {
document,
selector,
}) => format!(
"document '{document}' has no outline node '{selector}'; call mant_outline with entries.kind=all for available selectors"
),
other => other.to_string(),
};
};
let QueryError::Load(error) = error else {
return error.to_string();
};
match error {
LoadError::NativeBackendUnavailable { tldr_topic } => tldr_topic.map_or_else(
|| "native manual loading is unavailable in this build".to_owned(),
|topic| format!("native manual loading is unavailable in this build; a tldr entry is available for '{topic}'"),
),
LoadError::Markdown { .. } => {
"could not load or parse the selected Markdown document".to_owned()
}
LoadError::EmptyMarkdown { .. } => {
"the selected Markdown document has no readable content".to_owned()
}
LoadError::Registry { .. } => "registered document discovery failed".to_owned(),
LoadError::Manual(error) => manual_error_for_mcp(&error),
LoadError::ManualWithTldr { error, topic } => format!(
"{}; a tldr entry is available for '{topic}'",
manual_error_for_mcp(&error)
),
LoadError::Tldr { topic, .. } => {
format!("could not load the tldr entry for '{topic}'")
}
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::{discovery_error_for_mcp, scope_error_for_mcp};
#[test]
fn unavailable_native_backend_has_actionable_path_free_guidance() {
let error =
mant_engine::QueryError::Load(mant_loader::LoadError::NativeBackendUnavailable {
tldr_topic: Some("tool".to_owned()),
});
assert_eq!(
super::query_error_for_mcp(mant_engine::QueryExecutionError::Query(error)),
"native manual loading is unavailable in this build; a tldr entry is available for 'tool'"
);
}
#[test]
fn scope_loading_errors_redact_host_paths_but_preserve_usage_guidance() {
use mant_engine::ScopeQueryError;
use mant_loader::ScopeLoadError;
use mant_query::ScopeExecutionError;
assert_eq!(
scope_error_for_mcp(ScopeQueryError::Load(ScopeLoadError::NoResolvedDocuments {
reasons: vec!["/home/demo/private/source.md: permission denied".into()],
})),
"none of the requested documents could be resolved"
);
assert_eq!(
scope_error_for_mcp(ScopeQueryError::Execution(
ScopeExecutionError::NoReadableDocuments {
reasons: vec!["/home/demo/private/source.md: no readable body".into()],
},
)),
"none of the requested documents could be resolved"
);
assert_eq!(
scope_error_for_mcp(ScopeQueryError::Load(ScopeLoadError::EmptyScope)),
"at least one document is required"
);
}
#[test]
fn discovery_errors_never_expose_configuration_paths_or_source_lines() {
let error = discovery_error_for_mcp(
"/home/demo/.config/mant/sources.toml:4: invalid table\nrepo = [".to_owned(),
);
assert_eq!(error, "registered document discovery failed");
assert!(!error.contains("/home/demo"));
assert!(!error.contains("repo ="));
}
}