use std::{
io,
pin::Pin,
sync::Arc,
task::{Context, Poll},
};
use mant_ast::{
ExcerptSelection, OutlineDetail, QueryBundle, QueryExcerpt, QueryInput, QueryOutline,
QueryRequest, QueryView, SearchCase, SearchQuery, SearchScope, SearchSyntax,
default_search_limit,
};
use rmcp::{
Json, ServerHandler, ServiceExt,
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{Implementation, ServerCapabilities, ServerInfo},
tool, tool_handler, tool_router,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tokio::{
io::{AsyncRead, ReadBuf},
sync::Semaphore,
task,
};
const MAX_MCP_LINE_BYTES: usize = 8 * 1024 * 1024;
pub(super) async fn run_stdio() -> u8 {
let transport = (
LineBoundedReader::new(tokio::io::stdin(), MAX_MCP_LINE_BYTES),
tokio::io::stdout(),
);
let Ok(service) = MantMcpServer::new().serve(transport).await else {
return 1;
};
match service.waiting().await {
Ok(_) => 0,
Err(_) => 1,
}
}
struct LineBoundedReader<R> {
inner: R,
max_line: usize,
since_newline: usize,
tripped: bool,
}
impl<R> LineBoundedReader<R> {
fn new(inner: R, max_line: usize) -> Self {
Self {
inner,
max_line,
since_newline: 0,
tripped: false,
}
}
}
impl<R: AsyncRead + Unpin> AsyncRead for LineBoundedReader<R> {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
if self.tripped {
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::InvalidData,
"MCP request line exceeded the maximum allowed length",
)));
}
let start = buf.filled().len();
let poll = Pin::new(&mut self.inner).poll_read(cx, buf);
if let Poll::Ready(Ok(())) = &poll {
let new = &buf.filled()[start..];
match new.iter().rposition(|&byte| byte == b'\n') {
Some(last_newline) => self.since_newline = new.len() - last_newline - 1,
None => self.since_newline += new.len(),
}
self.tripped = self.since_newline > self.max_line;
}
poll
}
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct DocumentSelector {
name: String,
section: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct OutlineParams {
#[serde(flatten)]
selector: DocumentSelector,
detail: Option<OutlineDetail>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct GetParams {
#[serde(flatten)]
selector: DocumentSelector,
#[schemars(length(min = 1))]
#[serde(deserialize_with = "lenient_nodes")]
nodes: Vec<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ExplainParams {
#[serde(flatten)]
selector: DocumentSelector,
entry: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct SearchParams {
#[serde(flatten)]
selector: DocumentSelector,
#[schemars(length(min = 1, max = 4096))]
pattern: String,
syntax: Option<SearchSyntax>,
case: Option<SearchCase>,
scope: Option<SearchScope>,
#[serde(default, deserialize_with = "lenient_scalar")]
word: Option<bool>,
#[schemars(range(max = 100))]
#[serde(default, deserialize_with = "lenient_scalar", alias = "context_lines")]
context_lines: Option<u16>,
#[schemars(range(min = 1, max = 10000))]
#[serde(default, deserialize_with = "lenient_scalar")]
limit: Option<u32>,
#[serde(default, deserialize_with = "lenient_scalar")]
offset: Option<u32>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct DocumentListParams {
query: Option<String>,
kind: Option<DocumentKindFilter>,
section: Option<String>,
#[schemars(range(min = 1, max = 1000))]
#[serde(default, deserialize_with = "lenient_scalar")]
limit: Option<u32>,
#[serde(default, deserialize_with = "lenient_scalar")]
offset: Option<u32>,
}
#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
enum DocumentKindFilter {
Markdown,
Manual,
}
#[derive(Debug, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
struct DocumentCatalog {
total: u32,
returned: u32,
offset: u32,
truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
next_offset: Option<u32>,
documents: Vec<DocumentSummary>,
}
#[derive(Debug, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
struct DocumentSummary {
name: String,
kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
section: Option<String>,
path: String,
origin: String,
}
fn lenient_scalar<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::de::DeserializeOwned + std::str::FromStr,
T::Err: std::fmt::Display,
{
use serde::de::Error as _;
let value = serde_json::Value::deserialize(deserializer)?;
match value {
serde_json::Value::Null => Ok(None),
serde_json::Value::String(text) => text
.trim()
.to_ascii_lowercase()
.parse()
.map(Some)
.map_err(|error| D::Error::custom(format!("cannot parse {text:?}: {error}"))),
other => serde_json::from_value(other)
.map(Some)
.map_err(D::Error::custom),
}
}
const NODES_HINT: &str = r#"nodes must be an array of outline selectors such as ["2","1/o1"]"#;
fn lenient_nodes<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
let value = serde_json::Value::deserialize(deserializer)?;
let value = match value {
serde_json::Value::String(text) => match serde_json::from_str(&text) {
Ok(parsed @ serde_json::Value::Array(_)) => parsed,
_ => return Ok(vec![text]),
},
other => other,
};
serde_json::from_value(value)
.map_err(|error| D::Error::custom(format!("{error}; {NODES_HINT}")))
}
#[derive(Debug, Clone)]
struct MantMcpServer {
tool_router: ToolRouter<Self>,
query_gate: Arc<Semaphore>,
}
impl MantMcpServer {
fn new() -> Self {
Self {
tool_router: Self::tool_router(),
query_gate: Arc::new(Semaphore::new(1)),
}
}
async fn query(&self, request: QueryRequest) -> Result<QueryBundle, String> {
let permit = Arc::clone(&self.query_gate)
.acquire_owned()
.await
.map_err(|_| "MCP query service is shutting down".to_owned())?;
task::spawn_blocking(move || {
let _permit = permit;
mant_core::query(&request).map_err(|error| error.to_string())
})
.await
.map_err(|error| format!("MCP query worker failed: {error}"))?
}
}
#[tool_router(router = tool_router)]
impl MantMcpServer {
#[tool(
name = "mant_documents_list",
annotations(
title = "ManT local documents",
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn documents_list(
&self,
parameters: Parameters<DocumentListParams>,
) -> Result<Json<DocumentCatalog>, String> {
let parameters = validate_document_list(parameters.0)?;
let documents = task::spawn_blocking(mant_core::list_available_documents)
.await
.map_err(|error| format!("MCP document discovery worker failed: {error}"))?;
Ok(Json(build_document_catalog(documents, ¶meters)))
}
#[tool(
name = "mant_document_outline",
annotations(
title = "ManT document outline",
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn document_outline(
&self,
parameters: Parameters<OutlineParams>,
) -> Result<Json<QueryOutline>, String> {
let parameters = parameters.0;
let detail = parameters.detail.unwrap_or(OutlineDetail::Options);
let request = request_for(parameters.selector, QueryView::Outline { detail })?;
let query = self.query(request).await?;
let outline = mant_core::build_outline_with_detail(&query, detail)
.map_err(|error| error.to_string())?;
Ok(Json(outline))
}
#[tool(
name = "mant_document_get",
annotations(
title = "ManT selected document content",
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn document_get(
&self,
parameters: Parameters<GetParams>,
) -> Result<Json<QueryExcerpt>, String> {
let parameters = parameters.0;
validate_nodes(¶meters.nodes)?;
let request = request_for(
parameters.selector,
QueryView::Excerpt {
nodes: parameters.nodes.clone(),
},
)?;
let query = self.query(request).await?;
let mut excerpt = mant_core::select_excerpt(&query, ¶meters.nodes)
.map_err(|error| error.to_string())?;
discard_lowering_diagnostics(&mut excerpt);
Ok(Json(excerpt))
}
#[tool(
name = "mant_document_explain",
annotations(
title = "ManT option explanation",
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn document_explain(
&self,
parameters: Parameters<ExplainParams>,
) -> Result<Json<QueryExcerpt>, String> {
let parameters = parameters.0;
let entry = non_empty(¶meters.entry, "entry")?;
let request = request_for(
parameters.selector,
QueryView::Excerpt {
nodes: vec![entry.clone()],
},
)?;
let query = self.query(request).await?;
let mut excerpt =
mant_core::select_excerpt(&query, &[entry]).map_err(|error| error.to_string())?;
if matches!(
excerpt.selections.as_slice(),
[ExcerptSelection::DocumentEntry { .. }]
) {
discard_lowering_diagnostics(&mut excerpt);
Ok(Json(excerpt))
} else {
Err("entry does not resolve to one option, command, or environment variable".to_owned())
}
}
#[tool(
name = "mant_document_search",
annotations(
title = "ManT document search",
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn document_search(
&self,
parameters: Parameters<SearchParams>,
) -> Result<Json<mant_ast::QuerySearch>, String> {
let parameters = parameters.0;
let search = SearchQuery {
pattern: non_empty(¶meters.pattern, "pattern")?,
syntax: parameters.syntax.unwrap_or_default(),
case: parameters.case.unwrap_or_default(),
scope: parameters.scope.unwrap_or_default(),
word: parameters.word.unwrap_or(false),
context_lines: parameters.context_lines.unwrap_or(0),
limit: parameters.limit.unwrap_or_else(default_search_limit),
offset: parameters.offset.unwrap_or(0),
};
mant_core::validate_search_query(&search).map_err(|error| error.to_string())?;
let request = request_for(
parameters.selector,
QueryView::Search {
pattern: search.pattern.clone(),
syntax: search.syntax,
case: search.case,
scope: search.scope,
word: search.word,
context_lines: search.context_lines,
limit: search.limit,
offset: search.offset,
},
)?;
let query = self.query(request).await?;
let result = mant_core::search_query(&query, &search).map_err(|error| error.to_string())?;
Ok(Json(result))
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for MantMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new("mant", env!("CARGO_PKG_VERSION")))
.with_instructions(
"Query registered Markdown documents and local manual pages by name. Use mant_documents_list for paginated discovery, then mant_document_outline before selecting IDs, paths, or aliases with mant_document_get or mant_document_explain.",
)
}
}
fn validate_document_list(
mut parameters: DocumentListParams,
) -> Result<DocumentListParams, String> {
parameters.query = parameters
.query
.map(|query| query.trim().to_owned())
.filter(|query| !query.is_empty());
parameters.section = parameters
.section
.map(|section| non_empty(§ion, "section"))
.transpose()?;
let limit = parameters.limit.unwrap_or(100);
if !(1..=1000).contains(&limit) {
return Err("limit must be between 1 and 1000".to_owned());
}
parameters.limit = Some(limit);
parameters.offset = Some(parameters.offset.unwrap_or(0));
Ok(parameters)
}
fn build_document_catalog(
documents: Vec<mant_core::AvailableDocument>,
parameters: &DocumentListParams,
) -> DocumentCatalog {
let query = parameters.query.as_ref().map(|query| query.to_lowercase());
let filtered = documents
.into_iter()
.filter(|document| {
query
.as_ref()
.is_none_or(|query| document.name.to_lowercase().contains(query))
&& parameters.kind.is_none_or(|kind| match kind {
DocumentKindFilter::Markdown => {
document.kind == mant_core::AvailableDocumentKind::Markdown
}
DocumentKindFilter::Manual => {
document.kind == mant_core::AvailableDocumentKind::Manual
}
})
&& parameters.section.as_ref().is_none_or(|section| {
document
.section
.as_ref()
.is_some_and(|value| value == section)
})
})
.collect::<Vec<_>>();
let total = filtered.len();
let offset = usize::try_from(parameters.offset.unwrap_or(0)).unwrap_or(usize::MAX);
let limit = usize::try_from(parameters.limit.unwrap_or(100)).unwrap_or(100);
let start = offset.min(total);
let end = start.saturating_add(limit).min(total);
let documents = filtered[start..end]
.iter()
.map(|document| DocumentSummary {
name: document.name.clone(),
kind: match document.kind {
mant_core::AvailableDocumentKind::Markdown => "markdown",
mant_core::AvailableDocumentKind::Manual => "manual",
}
.to_owned(),
section: document.section.clone(),
path: document.path.to_string_lossy().into_owned(),
origin: match document.origin {
mant_core::AvailableDocumentOrigin::User => "user",
mant_core::AvailableDocumentOrigin::System => "system",
mant_core::AvailableDocumentOrigin::ManualPath => "manual-path",
}
.to_owned(),
})
.collect::<Vec<_>>();
DocumentCatalog {
total: u32::try_from(total).unwrap_or(u32::MAX),
returned: u32::try_from(documents.len()).unwrap_or(u32::MAX),
offset: u32::try_from(start).unwrap_or(u32::MAX),
truncated: end < total,
next_offset: (end < total).then(|| u32::try_from(end).unwrap_or(u32::MAX)),
documents,
}
}
fn discard_lowering_diagnostics(excerpt: &mut QueryExcerpt) {
excerpt.diagnostics.clear();
}
fn request_for(selector: DocumentSelector, view: QueryView) -> Result<QueryRequest, String> {
let input = QueryInput::Document {
name: non_empty(&selector.name, "name")?,
section: selector
.section
.map(|section| non_empty(§ion, "section"))
.transpose()?,
};
Ok(QueryRequest {
schema: mant_ast::RequestSchema::V4,
input,
view,
})
}
fn validate_nodes(nodes: &[String]) -> Result<(), String> {
if nodes.is_empty() {
return Err("at least one outline node is required".to_owned());
}
if nodes.iter().any(|node| node.trim().is_empty()) {
return Err("outline node must not be empty".to_owned());
}
Ok(())
}
fn non_empty(value: &str, field: &str) -> Result<String, String> {
let value = value.trim();
if value.is_empty() {
Err(format!("{field} must not be empty"))
} else {
Ok(value.to_owned())
}
}
#[cfg(test)]
mod tests {
use std::{io, path::PathBuf};
use mant_core::{AvailableDocument, AvailableDocumentKind, AvailableDocumentOrigin};
use serde_json::json;
use super::{
DocumentKindFilter, DocumentListParams, GetParams, MantMcpServer, OutlineParams,
SearchParams, build_document_catalog, validate_document_list,
};
#[test]
fn publishes_only_the_read_only_document_tools_with_generated_schemas() {
let server = MantMcpServer::new();
let tools = server.tool_router.list_all();
let mut names = tools
.iter()
.map(|tool| tool.name.as_ref())
.collect::<Vec<_>>();
names.sort_unstable();
assert_eq!(
names,
[
"mant_document_explain",
"mant_document_get",
"mant_document_outline",
"mant_document_search",
"mant_documents_list",
]
);
for tool in tools {
assert!(tool.input_schema.contains_key("properties"));
assert!(tool.output_schema.is_some());
let annotations = tool.annotations.expect("read-only annotation");
assert_eq!(annotations.read_only_hint, Some(true));
assert_eq!(annotations.destructive_hint, Some(false));
assert_eq!(annotations.open_world_hint, Some(false));
}
}
#[test]
fn document_tools_publish_a_name_without_an_arbitrary_path_target() {
let server = MantMcpServer::new();
let tools = server.tool_router.list_all();
let outline = tools
.iter()
.find(|tool| tool.name == "mant_document_outline")
.expect("outline tool");
let schema = serde_json::to_value(&outline.input_schema).expect("schema JSON");
assert_eq!(schema["properties"]["name"]["type"], "string");
assert!(schema["properties"]["section"].is_object());
assert!(schema["properties"].get("target").is_none());
assert!(!schema.to_string().contains("markdown-file"));
}
#[test]
fn a_name_and_optional_manual_section_deserialize_directly() {
let parameters: OutlineParams = serde_json::from_value(json!({
"name": "printf",
"section": "3"
}))
.expect("name selector");
assert_eq!(parameters.selector.name, "printf");
assert_eq!(parameters.selector.section.as_deref(), Some("3"));
}
#[test]
fn arbitrary_markdown_paths_are_not_mcp_inputs() {
let error = serde_json::from_value::<OutlineParams>(json!({
"target": {"kind": "markdown-file", "path": "README.md"}
}))
.expect_err("path target must be rejected");
assert!(error.to_string().contains("name"));
}
#[test]
fn document_catalog_filters_and_paginates_both_source_families() {
let parameters = validate_document_list(DocumentListParams {
query: Some("PRINT".to_owned()),
kind: Some(DocumentKindFilter::Manual),
section: None,
limit: Some(1),
offset: Some(1),
})
.expect("catalog parameters");
let catalog = build_document_catalog(
vec![
AvailableDocument {
name: "printf".to_owned(),
kind: AvailableDocumentKind::Markdown,
section: None,
path: PathBuf::from("/data/mant/printf.md"),
origin: AvailableDocumentOrigin::User,
},
AvailableDocument {
name: "printf".to_owned(),
kind: AvailableDocumentKind::Manual,
section: Some("1".to_owned()),
path: PathBuf::from("/usr/share/man/man1/printf.1.gz"),
origin: AvailableDocumentOrigin::ManualPath,
},
AvailableDocument {
name: "printf".to_owned(),
kind: AvailableDocumentKind::Manual,
section: Some("3".to_owned()),
path: PathBuf::from("/usr/share/man/man3/printf.3.gz"),
origin: AvailableDocumentOrigin::ManualPath,
},
],
¶meters,
);
assert_eq!(catalog.total, 2);
assert_eq!(catalog.returned, 1);
assert_eq!(catalog.offset, 1);
assert!(!catalog.truncated);
assert_eq!(catalog.documents[0].section.as_deref(), Some("3"));
assert_eq!(catalog.documents[0].origin, "manual-path");
}
#[test]
fn stringified_search_scalars_and_snake_case_context_still_deserialize() {
let parameters: SearchParams = serde_json::from_value(json!({
"name": "ls",
"pattern": "sort",
"word": "True",
"context_lines": "2",
"limit": "10",
"offset": 5,
}))
.expect("lenient search parameters");
assert_eq!(parameters.word, Some(true));
assert_eq!(parameters.context_lines, Some(2));
assert_eq!(parameters.limit, Some(10));
assert_eq!(parameters.offset, Some(5));
}
#[test]
fn unparsable_search_scalars_still_fail() {
let error = serde_json::from_value::<SearchParams>(json!({
"name": "ls",
"pattern": "sort",
"limit": "ten",
}))
.expect_err("invalid limit");
assert!(error.to_string().contains(r#"cannot parse "ten""#));
}
#[test]
fn node_selectors_accept_arrays_bare_strings_and_stringified_arrays() {
for (nodes, expected) in [
(json!(["2", "1/o1"]), vec!["2", "1/o1"]),
(json!("2"), vec!["2"]),
(json!("[\"2\", \"1/o1\"]"), vec!["2", "1/o1"]),
] {
let parameters: GetParams = serde_json::from_value(json!({
"name": "ls",
"nodes": nodes,
}))
.expect("lenient nodes");
assert_eq!(parameters.nodes, expected);
}
}
#[test]
fn malformed_node_selectors_report_a_correct_example() {
let error = serde_json::from_value::<GetParams>(json!({
"name": "ls",
"nodes": "[1, 2]",
}))
.expect_err("non-string selectors");
assert!(
error.to_string().contains(r#"["2","1/o1"]"#),
"missing example in: {error}"
);
}
#[test]
fn excerpts_discard_all_lowering_diagnostics() {
use mant_ast::{Diagnostic, DiagnosticLevel, ExcerptSchema, QueryExcerpt};
let diagnostic = |level| Diagnostic {
level,
code: None,
message: "finding".to_owned(),
source: None,
};
let mut excerpt = QueryExcerpt {
schema: ExcerptSchema::V4,
label: "demo".to_owned(),
producer: None,
source: None,
meta: None,
diagnostics: vec![
diagnostic(DiagnosticLevel::Style),
diagnostic(DiagnosticLevel::Warning),
diagnostic(DiagnosticLevel::Error),
diagnostic(DiagnosticLevel::Unsupported),
],
selections: Vec::new(),
};
super::discard_lowering_diagnostics(&mut excerpt);
assert!(excerpt.diagnostics.is_empty());
}
fn read_to_end(source: &'static [u8], max_line: usize) -> io::Result<Vec<u8>> {
use tokio::io::AsyncReadExt;
let runtime = tokio::runtime::Builder::new_current_thread()
.build()
.expect("current-thread runtime");
runtime.block_on(async move {
let mut reader = super::LineBoundedReader::new(source, max_line);
let mut collected = Vec::new();
reader.read_to_end(&mut collected).await?;
Ok(collected)
})
}
#[test]
fn line_bounded_reader_passes_lines_within_the_limit() {
let source: &[u8] = b"short line\nnext\n";
let collected = read_to_end(source, 32).expect("read within limit");
assert_eq!(collected, source);
}
#[test]
fn line_bounded_reader_rejects_a_line_over_the_limit() {
let error = read_to_end(b"aaaaaaaaaaaaaaaaaaaa", 8).expect_err("oversized line must fail");
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn line_bounded_reader_resets_its_count_on_each_newline() {
let source: &[u8] = b"aaaa\nbbbb\ncccc\n";
let collected = read_to_end(source, 5).expect("newlines reset the counter");
assert_eq!(collected, source);
}
}