pub mod cite_check;
pub mod cli;
mod command_provider;
pub mod discover_compat;
pub mod document;
pub mod evidence;
pub mod http_transport;
pub mod lean;
mod ocr_evidence;
pub mod ocr_vlm;
pub mod outline;
mod page_selection;
pub mod pdf_compare;
pub mod pdf_evidence;
pub mod pro;
pub mod read_pdf;
mod region_analysis_evidence;
pub mod schema;
pub mod search;
pub mod setup;
pub mod source_access;
pub mod tool_routes;
pub mod video_evidence;
pub mod video_request;
mod visual_evidence;
use rmcp::{
handler::server::router::tool::ToolRouter,
handler::server::tool::ToolCallContext,
handler::server::wrapper::Parameters,
model::{
CacheScope, CallToolResponse, CustomRequest, CustomResult, Implementation, ListToolsResult,
MetaObject, PaginatedRequestParams, ProtocolVersion, ResultType, ServerCapabilities,
ServerInfo,
},
service::{RequestContext, RoleServer},
tool, tool_handler, tool_router, ErrorData, ServerHandler,
};
use crate::schema::{
ComparePdfArgs, InspectArgs, InspectOperation, OutlineArgs, PdfEvidenceArgs,
PdfEvidenceOperation, ReadArgs, ReadPdfArgs, SearchArgs, SearchPdfArgs,
};
use crate::source_access::SourceAccessPolicy;
use serde_json::Value;
pub const SERVER_NAME: &str = "anymd";
/// The product version: the workspace version, set with `bun scripts/set-version.ts`.
pub const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const SERVER_INFO_META_KEY: &str = "io.modelcontextprotocol/serverInfo";
pub const SERVER_INSTRUCTIONS: &str =
"Local document reader for agents. read turns any file, URL, or directory listing into clean \
Markdown (PDF, Office, EPUB, HTML, CSV, images, media) with page/slide/sheet markers and a cursor \
for long documents. outline returns a document tree; read node fetches a section. search finds text across files and directories with page locators and node title paths. inspect \
renders, crops, OCRs, diffs, or returns structured JSON for PDFs. No cloud API key is required.";
fn omit_absent_optional_fields(value: Value) -> Value {
match value {
Value::Object(object) => Value::Object(
object
.into_iter()
.filter_map(|(key, value)| {
(!value.is_null()).then(|| (key, omit_absent_optional_fields(value)))
})
.collect(),
),
Value::Array(values) => Value::Array(
values
.into_iter()
.map(omit_absent_optional_fields)
.collect(),
),
other => other,
}
}
/// Formats defined by the JSON Schema specification (draft 2020-12). schemars
/// annotates Rust integer/float types with non-standard formats ("uint32",
/// "uint64", "double") that spec-strict client validators (AJV strict mode,
/// Zod, ...) reject or log as unknown on every tool call.
fn is_standard_schema_format(format: &str) -> bool {
matches!(
format,
"date-time"
| "date"
| "time"
| "duration"
| "email"
| "idn-email"
| "hostname"
| "idn-hostname"
| "ipv4"
| "ipv6"
| "uri"
| "uri-reference"
| "iri"
| "iri-reference"
| "uri-template"
| "json-pointer"
| "relative-json-pointer"
| "regex"
| "uuid"
)
}
fn sanitize_schema_formats(object: &mut serde_json::Map<String, Value>) {
if matches!(object.get("format"), Some(Value::String(format)) if !is_standard_schema_format(format))
{
object.remove("format");
}
for value in object.values_mut() {
match value {
Value::Object(child) => sanitize_schema_formats(child),
Value::Array(items) => {
for item in items {
if let Value::Object(child) = item {
sanitize_schema_formats(child);
}
}
}
_ => {}
}
}
}
fn sanitized_tools(router: &ToolRouter<PdfReaderMcp>) -> Vec<rmcp::model::Tool> {
let mut tools = router.list_all();
for tool in &mut tools {
sanitize_schema_formats(std::sync::Arc::make_mut(&mut tool.input_schema));
if let Some(output_schema) = &mut tool.output_schema {
sanitize_schema_formats(std::sync::Arc::make_mut(output_schema));
}
}
tools
}
#[derive(Clone)]
pub struct PdfReaderMcp {
pub tool_router: ToolRouter<Self>,
source_access: SourceAccessPolicy,
pro_active: fn() -> bool,
}
impl PdfReaderMcp {
/// Replace the licence check (tests inject a fixed answer).
pub fn with_pro_check(mut self, pro_active: fn() -> bool) -> Self {
self.pro_active = pro_active;
self
}
/// Gate for Pro-only operations: `Some(result)` is the normal tool result
/// that carries the ProRequired message; nothing has been opened or run.
fn pro_gate(&self, feature: &str) -> Option<rmcp::model::CallToolResult> {
pro::require_pro_with(feature, (self.pro_active)())
.err()
.map(|required| pro::required_result(&required))
}
pub fn new() -> Self {
Self::with_source_access(SourceAccessPolicy::unrestricted())
}
pub fn with_source_access(source_access: SourceAccessPolicy) -> Self {
Self {
tool_router: Self::tool_router(),
source_access,
pro_active: || pro::current_license().is_some(),
}
}
}
impl Default for PdfReaderMcp {
fn default() -> Self {
Self::new()
}
}
fn server_result_meta(implementation: &Implementation) -> MetaObject {
let mut meta = MetaObject::new();
meta.insert(
SERVER_INFO_META_KEY.to_string(),
serde_json::to_value(implementation).expect("Implementation serialization cannot fail"),
);
meta
}
fn uses_2026_envelope(context: &RequestContext<RoleServer>) -> bool {
context
.protocol_version()
.is_some_and(|version| version >= ProtocolVersion::V_2026_07_28)
}
#[tool_router]
impl PdfReaderMcp {
#[tool(
description = "Read any document as clean Markdown: PDF, Word (DOCX), PowerPoint (PPTX), Excel (XLSX/XLS/ODS), CSV, EPUB, HTML or a web URL, Markdown/text, images (metadata + OCR), audio/video (metadata, chapters, subtitles). Pages/slides/sheets carry <!-- page N --> style markers for citation. Long documents stop at max_tokens (default 20000) and end with a cursor to continue; choose pages with pages: \"1-5,8\". A directory returns its readable files."
)]
pub async fn read(
&self,
Parameters(args): Parameters<ReadArgs>,
) -> Result<rmcp::model::CallToolResult, ErrorData> {
args.validate()
.map_err(|message| ErrorData::invalid_params(message, None))?;
if args.timeline.is_some() {
if let Some(result) = self.pro_gate("Video timeline") {
return Ok(result);
}
}
let policy = self.source_access.clone();
tokio::task::spawn_blocking(move || lean::read(&args, &policy))
.await
.map_err(|error| {
ErrorData::internal_error(format!("read worker failed: {error}"), None)
})?
}
#[tool(
description = "Search documents for text: one file, many files, whole directories (recursive, .gitignore aware), or URLs, across every format read supports. Returns each hit as file + page/slide/sheet + a snippet with the match in bold. mode auto (default) finds the exact phrase and falls back to BM25-ranked passages when there is none; literal or ranked force one. Narrow directories with glob, e.g. \"*.pdf\"."
)]
pub async fn search(
&self,
Parameters(args): Parameters<SearchArgs>,
) -> Result<rmcp::model::CallToolResult, ErrorData> {
args.validate()
.map_err(|message| ErrorData::invalid_params(message, None))?;
let policy = self.source_access.clone();
tokio::task::spawn_blocking(move || lean::search(&args, &policy))
.await
.map_err(|error| {
ErrorData::internal_error(format!("search worker failed: {error}"), None)
})?
}
#[tool(
description = "Return a document heading tree with stable node ids, title paths, page/slide/chapter ranges and Markdown byte ranges. PDF bookmarks or detected headings, Word headings, PowerPoint slides, EPUB chapters and headings, HTML and Markdown. format json (default) or tree. Pass a node id to read to fetch that section."
)]
pub async fn outline(
&self,
Parameters(args): Parameters<OutlineArgs>,
) -> Result<rmcp::model::CallToolResult, ErrorData> {
if args.timeline.is_some() {
if let Some(result) = self.pro_gate("Video timeline") {
return Ok(result);
}
}
let policy = self.source_access.clone();
tokio::task::spawn_blocking(move || outline::tool(&args, &policy))
.await
.map_err(|error| {
ErrorData::internal_error(format!("outline worker failed: {error}"), None)
})?
}
#[tool(
description = "Document and media evidence when Markdown is not enough. video_timeline (bounded scene/cue timeline) and render_frame (actual decoded frames), and cite_check (quote/location support, not semantic truth), are anymd Pro; PDF operations: inspect (page facts, metadata), render_page (PNG images), extract_regions (crop bounding boxes), ocr_pages / analyze_regions (configured OCR or vision provider), structure (JSON with document map, elements, geometry; profile quality|research adds trust and accessibility reports), compare (page-level diff of sources[0] vs sources[1])."
)]
pub async fn inspect(
&self,
Parameters(args): Parameters<InspectArgs>,
) -> Result<rmcp::model::CallToolResult, ErrorData> {
self.run_inspect(args).await
}
}
/// Legacy tool names, still callable (not listed) for one major version.
impl PdfReaderMcp {
pub async fn read_pdf(
&self,
Parameters(mut args): Parameters<ReadPdfArgs>,
) -> Result<rmcp::model::CallToolResult, ErrorData> {
args.validate()
.map_err(|message| ErrorData::invalid_params(message, None))?;
self.source_access
.admit_pdf_sources(&mut args.sources)
.map_err(|message| ErrorData::invalid_params(message, None))?;
if !lean::read_wants_legacy(&args) {
let policy = self.source_access.clone();
return tokio::task::spawn_blocking(move || lean::read_pdf(&args, &policy))
.await
.map_err(|error| {
ErrorData::internal_error(format!("read_pdf worker failed: {error}"), None)
})?;
}
args.max_tokens = None;
args.cursor = None;
let provider_operation = args.include_ocr_text_layer == Some(true);
let value = serde_json::to_value(args)
.map(omit_absent_optional_fields)
.map_err(|error| {
ErrorData::invalid_params(format!("Failed to encode read_pdf args: {error}"), None)
})?;
if provider_operation {
tokio::task::spawn_blocking(move || read_pdf::read_pdf(value))
.await
.map_err(|error| {
ErrorData::internal_error(format!("read_pdf worker failed: {error}"), None)
})?
} else {
read_pdf::read_pdf(value)
}
}
pub async fn pdf_compare(
&self,
Parameters(args): Parameters<ComparePdfArgs>,
) -> Result<rmcp::model::CallToolResult, ErrorData> {
args.validate()
.map_err(|message| ErrorData::invalid_params(message, None))?;
let value = serde_json::to_value(args).map_err(|error| {
ErrorData::invalid_params(format!("Failed to encode pdf_compare args: {error}"), None)
})?;
pdf_compare::pdf_compare(value)
}
pub async fn search_pdf(
&self,
Parameters(mut args): Parameters<SearchPdfArgs>,
) -> Result<rmcp::model::CallToolResult, ErrorData> {
args.validate()
.map_err(|message| ErrorData::invalid_params(message, None))?;
self.source_access
.admit_pdf_sources(&mut args.sources)
.map_err(|message| ErrorData::invalid_params(message, None))?;
if !lean::search_wants_legacy(&args) {
let policy = self.source_access.clone();
return tokio::task::spawn_blocking(move || lean::search_pdf(&args, &policy))
.await
.map_err(|error| {
ErrorData::internal_error(format!("search_pdf worker failed: {error}"), None)
})?;
}
args.detail = None;
let provider_operation = args.include_ocr_text_layer == Some(true);
let value = serde_json::to_value(args)
.map(omit_absent_optional_fields)
.map_err(|error| {
ErrorData::invalid_params(
format!("Failed to encode search_pdf args: {error}"),
None,
)
})?;
if provider_operation {
tokio::task::spawn_blocking(move || search::search_pdf(value))
.await
.map_err(|error| {
ErrorData::internal_error(format!("search_pdf worker failed: {error}"), None)
})?
} else {
search::search_pdf(value)
}
}
pub async fn pdf_evidence(
&self,
Parameters(mut args): Parameters<PdfEvidenceArgs>,
) -> Result<rmcp::model::CallToolResult, ErrorData> {
args.validate()
.map_err(|message| ErrorData::invalid_params(message, None))?;
self.source_access
.admit_evidence_sources(&mut args.sources)
.map_err(|message| ErrorData::invalid_params(message, None))?;
let provider_operation = matches!(
args.operation,
PdfEvidenceOperation::OcrPages | PdfEvidenceOperation::AnalyzeRegions
);
let value = serde_json::to_value(args)
.map(omit_absent_optional_fields)
.map_err(|error| {
ErrorData::invalid_params(
format!("Failed to encode pdf_evidence args: {error}"),
None,
)
})?;
if provider_operation {
tokio::task::spawn_blocking(move || pdf_evidence::pdf_evidence(value))
.await
.map_err(|error| {
ErrorData::internal_error(format!("Provider worker failed: {error}"), None)
})?
} else {
pdf_evidence::pdf_evidence(value)
}
}
}
/// Legacy tool names that stay callable (unlisted) for one major version.
pub const LEGACY_TOOL_NAMES: &[&str] = &["read_pdf", "search_pdf", "pdf_evidence", "pdf_compare"];
fn legacy_args<T: serde::de::DeserializeOwned>(
request: &rmcp::model::CallToolRequestParams,
) -> Result<T, ErrorData> {
let value = Value::Object(request.arguments.clone().unwrap_or_default());
serde_json::from_value(value).map_err(|error| {
ErrorData::invalid_params(
format!("Invalid arguments for {}: {error}", request.name),
None,
)
})
}
impl PdfReaderMcp {
async fn run_inspect(
&self,
args: InspectArgs,
) -> Result<rmcp::model::CallToolResult, ErrorData> {
let pro_feature = match args.operation {
InspectOperation::VideoTimeline => Some("Video timeline"),
InspectOperation::RenderFrame => Some("Video frame rendering"),
InspectOperation::CiteCheck => Some("Cite-check"),
_ => None,
};
if let Some(result) = pro_feature.and_then(|feature| self.pro_gate(feature)) {
return Ok(result);
}
if matches!(
args.operation,
InspectOperation::VideoTimeline | InspectOperation::RenderFrame
) {
let policy = self.source_access.clone();
return tokio::task::spawn_blocking(move || {
crate::video_request::inspect(args, &policy)
})
.await
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
}
if args.timeline.is_some()
|| args.timestamps_ms.is_some()
|| (args.expected_source_sha256.is_some()
&& !matches!(args.operation, InspectOperation::CiteCheck))
|| args.transcript.is_some()
{
return Err(ErrorData::invalid_params(
"video options require a video operation",
None,
));
}
match args.operation {
InspectOperation::CiteCheck => {
crate::cite_check::inspect(args, self.source_access.clone()).await
}
InspectOperation::Compare => {
let paths: Vec<String> = args
.sources
.iter()
.filter_map(|source| source.path.clone())
.collect();
let [before, after] = paths.as_slice() else {
return Err(ErrorData::invalid_params(
"compare needs exactly two local PDF sources: [before, after].",
None,
));
};
let before = self
.source_access
.admit_path(before)
.map_err(|message| ErrorData::invalid_params(message, None))?;
let after = self
.source_access
.admit_path(after)
.map_err(|message| ErrorData::invalid_params(message, None))?;
self.pdf_compare(Parameters(ComparePdfArgs {
before,
after,
max_file_bytes: None,
context_chars: None,
}))
.await
}
InspectOperation::Structure => {
let profile = args.profile.clone().unwrap_or_else(|| "fast".into());
if profile.eq_ignore_ascii_case("markdown") {
return Err(ErrorData::invalid_params(
"structure returns JSON; use read for Markdown.",
None,
));
}
let sources: Vec<_> = args
.sources
.iter()
.map(|source| source.as_pdf_source())
.collect();
let mut result = self
.read_pdf(Parameters(ReadPdfArgs {
sources: sources.clone(),
profile: Some(profile),
..Default::default()
}))
.await?;
let policy = self.source_access.clone();
tokio::task::spawn_blocking(move || {
document::attach_structure_images(&mut result, &sources, &policy);
result
})
.await
.map_err(|error| {
ErrorData::internal_error(format!("structure worker failed: {error}"), None)
})
}
operation => {
let operation = match operation {
InspectOperation::Inspect => PdfEvidenceOperation::Inspect,
InspectOperation::RenderPage => PdfEvidenceOperation::RenderPage,
InspectOperation::ExtractRegions => PdfEvidenceOperation::ExtractRegions,
InspectOperation::OcrPages => PdfEvidenceOperation::OcrPages,
InspectOperation::AnalyzeRegions => PdfEvidenceOperation::AnalyzeRegions,
InspectOperation::CiteCheck
| InspectOperation::Structure
| InspectOperation::Compare
| InspectOperation::VideoTimeline
| InspectOperation::RenderFrame => unreachable!(),
};
self.pdf_evidence(Parameters(PdfEvidenceArgs {
operation,
sources: args.sources,
sample_pages: args.sample_pages,
include_metadata: args.include_metadata,
scale: args.scale,
max_pages: args.max_pages,
max_regions: args.max_regions,
max_pixels_per_page: args.max_pixels_per_page,
include_image: args.include_image,
timeout_ms: args.timeout_ms,
max_output_chars: args.max_output_chars,
languages: args.languages,
ocr: args.ocr,
}))
.await
}
}
}
async fn call_legacy(
&self,
request: &rmcp::model::CallToolRequestParams,
) -> Option<Result<rmcp::model::CallToolResult, ErrorData>> {
Some(match request.name.as_ref() {
"read_pdf" => match legacy_args(request) {
Ok(args) => self.read_pdf(Parameters(args)).await,
Err(error) => Err(error),
},
"search_pdf" => match legacy_args(request) {
Ok(args) => self.search_pdf(Parameters(args)).await,
Err(error) => Err(error),
},
"pdf_evidence" => match legacy_args(request) {
Ok(args) => self.pdf_evidence(Parameters(args)).await,
Err(error) => Err(error),
},
"pdf_compare" => match legacy_args(request) {
Ok(args) => self.pdf_compare(Parameters(args)).await,
Err(error) => Err(error),
},
_ => return None,
})
}
}
#[tool_handler]
impl ServerHandler for PdfReaderMcp {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(
Implementation::new(SERVER_NAME, SERVER_VERSION)
.with_description(
"@sylphx/anymd sole-Rust MCP server (native binary; no TypeScript PDF runtime)",
)
.with_website_url("https://sylphxai.github.io/anymd/"),
)
.with_instructions(SERVER_INSTRUCTIONS)
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, ErrorData> {
let supports_2026 = uses_2026_envelope(&context);
let result = ListToolsResult {
result_type: Some(ResultType::COMPLETE),
tools: sanitized_tools(&self.tool_router),
meta: supports_2026.then(|| server_result_meta(&self.get_info().server_info)),
next_cursor: None,
ttl_ms: supports_2026.then_some(0),
cache_scope: supports_2026.then_some(CacheScope::Public),
};
Ok(result)
}
async fn call_tool(
&self,
request: rmcp::model::CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, ErrorData> {
let supports_2026 = uses_2026_envelope(&context);
let mut response = match self.call_legacy(&request).await {
Some(result) => CallToolResponse::Complete(result?),
None => {
let tool_context = ToolCallContext::new(self, request, context);
self.tool_router.call(tool_context).await?
}
};
if supports_2026 {
if let CallToolResponse::Complete(ref mut result) = response {
result.meta = Some(server_result_meta(&self.get_info().server_info));
}
}
Ok(response)
}
/// Post-init `server/discover` (SEP-2575). Pre-init is handled by
/// [`discover_compat::DiscoverAwareTransport`].
fn on_custom_request(
&self,
request: CustomRequest,
_context: RequestContext<RoleServer>,
) -> impl std::future::Future<Output = Result<CustomResult, ErrorData>> + Send + '_ {
async move {
if request.method == discover_compat::SERVER_DISCOVER_METHOD {
return Ok(CustomResult::new(discover_compat::discover_result_value(
&self.get_info(),
)));
}
Err(ErrorData::new(
rmcp::model::ErrorCode::METHOD_NOT_FOUND,
request.method,
None,
))
}
}
}
#[cfg(test)]
mod tests {
use super::{
is_standard_schema_format, sanitize_schema_formats, sanitized_tools, PdfReaderMcp,
};
use rmcp::handler::server::wrapper::Parameters;
use serde_json::Value;
use std::path::PathBuf;
fn locked() -> PdfReaderMcp {
PdfReaderMcp::new().with_pro_check(|| false)
}
fn unlocked() -> PdfReaderMcp {
PdfReaderMcp::new().with_pro_check(|| true)
}
fn result_text(result: &rmcp::model::CallToolResult) -> String {
serde_json::to_string(&result.content).expect("content serializes")
}
const MISSING: &str = "/nonexistent/anymd-pro-gate-probe";
fn inspect_args(operation: &str, extra: Value) -> crate::schema::InspectArgs {
let mut value = serde_json::json!({
"operation": operation,
"sources": [{"path": MISSING}],
});
value
.as_object_mut()
.unwrap()
.extend(extra.as_object().unwrap().clone());
serde_json::from_value(value).expect("valid inspect args")
}
#[tokio::test]
async fn pro_operations_without_a_licence_return_the_message_and_do_no_work() {
let video = serde_json::json!({"timeline": {"end_ms": 1000}, "timestamps_ms": [0]});
let cite = serde_json::json!({
"citations": [{"quote": "a", "page": 1, "bounding_box": {"left": 0, "bottom": 0, "right": 1, "top": 1}}]
});
for (operation, feature, extra) in [
("video_timeline", "Video timeline", video.clone()),
("render_frame", "Video frame rendering", video),
("cite_check", "Cite-check", cite),
] {
let result = locked()
.inspect(Parameters(inspect_args(operation, extra)))
.await
.expect("an unlicensed Pro call is a normal result, not a protocol error");
assert_ne!(result.is_error, Some(true), "{operation}");
assert_eq!(
result_text(&result),
format!(
"[{{\"type\":\"text\",\"text\":\"{feature} is part of anymd Pro. Learn more and get it: https://sylphxai.github.io/anymd/pro\"}}]"
),
"{operation}"
);
}
}
#[tokio::test]
async fn timeline_option_on_read_and_outline_is_gated_without_a_licence() {
let read: crate::schema::ReadArgs = serde_json::from_value(
serde_json::json!({"source": MISSING, "timeline": {"end_ms": 1000}}),
)
.expect("valid read args");
let result = locked()
.read(Parameters(read))
.await
.expect("normal result");
assert_ne!(result.is_error, Some(true));
assert!(result_text(&result).contains("is part of anymd Pro"));
let outline: crate::schema::OutlineArgs = serde_json::from_value(
serde_json::json!({"source": MISSING, "timeline": {"end_ms": 1000}}),
)
.expect("valid outline args");
let result = locked()
.outline(Parameters(outline))
.await
.expect("normal result");
assert!(result_text(&result).contains("is part of anymd Pro"));
}
#[tokio::test]
async fn licensed_pro_operations_proceed_past_the_gate() {
let cite = serde_json::json!({
"citations": [{"quote": "a", "page": 1, "bounding_box": {"left": 0, "bottom": 0, "right": 1, "top": 1}}]
});
let video = serde_json::json!({"timeline": {"end_ms": 1000}, "timestamps_ms": [0]});
for (operation, extra) in [("cite_check", cite), ("video_timeline", video)] {
let outcome = unlocked()
.inspect(Parameters(inspect_args(operation, extra)))
.await;
let text = match &outcome {
Ok(result) => result_text(result),
Err(error) => error.message.to_string(),
};
assert!(
!text.contains("is part of anymd Pro"),
"{operation}: {text}"
);
}
}
#[tokio::test]
async fn free_operations_never_hit_the_pro_gate() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("note.md");
std::fs::write(&path, "# Title\n\nhello world\n").expect("write");
let server = locked();
let read: crate::schema::ReadArgs =
serde_json::from_value(serde_json::json!({"source": path})).unwrap();
let read = server.read(Parameters(read)).await.expect("read");
assert!(result_text(&read).contains("hello world"));
let outline: crate::schema::OutlineArgs =
serde_json::from_value(serde_json::json!({"source": path})).unwrap();
let outline = server.outline(Parameters(outline)).await.expect("outline");
assert!(result_text(&outline).contains("Title"));
let search: crate::schema::SearchArgs =
serde_json::from_value(serde_json::json!({"query": "hello", "sources": [path]}))
.unwrap();
let search = server.search(Parameters(search)).await.expect("search");
assert!(!result_text(&search).contains("is part of anymd Pro"));
for operation in ["inspect", "structure", "render_page"] {
let outcome = server
.inspect(Parameters(inspect_args(operation, serde_json::json!({}))))
.await;
let text = match &outcome {
Ok(result) => result_text(result),
Err(error) => error.message.to_string(),
};
assert!(!text.contains("is part of anymd Pro"), "{operation}");
}
}
#[test]
fn exposes_four_obvious_tools_and_keeps_legacy_names_callable() {
let tools = PdfReaderMcp::new().tool_router.list_all();
let mut names: Vec<_> = tools.iter().map(|tool| tool.name.to_string()).collect();
names.sort();
assert_eq!(names, ["inspect", "outline", "read", "search"]);
for legacy in super::LEGACY_TOOL_NAMES {
assert!(
!names.contains(&legacy.to_string()),
"{legacy} must not be listed"
);
}
}
#[test]
fn tools_list_exposes_typed_object_schemas_not_empty_value() {
let tools = PdfReaderMcp::new().tool_router.list_all();
for tool in tools {
let schema_value = serde_json::to_value(&tool.input_schema).expect("schema json");
assert_eq!(
schema_value.get("type").and_then(|v| v.as_str()),
Some("object"),
"tool {} schema type must be object, got {schema_value}",
tool.name
);
let props = schema_value
.get("properties")
.and_then(|v| v.as_object())
.expect("properties object");
let required: &[&str] = match tool.name.as_ref() {
"read" => &["source", "pages", "max_tokens", "cursor", "node"],
"outline" => &["source", "format"],
"search" => &["query", "sources", "mode"],
"inspect" => &["operation", "sources"],
other => panic!("unexpected tool {other}"),
};
for key in required {
assert!(
props.contains_key(*key),
"tool {} must document {key}",
tool.name
);
}
}
}
#[test]
fn tools_list_strips_non_standard_schema_formats() {
// Raw router schemas carry schemars annotations (uint32, uint64,
// double) that spec-strict client validators report as unknown
// formats; the tools/list surface must not advertise them.
let raw = serde_json::to_value(&PdfReaderMcp::new().tool_router.list_all()[0].input_schema)
.expect("raw schema json");
let raw_formats = schema_format_values(&raw);
assert!(
raw_formats.iter().any(|format| format == "uint32"),
"precondition: raw schemars schema must contain a uint32 format, got {raw_formats:?}"
);
let tools = sanitized_tools(&PdfReaderMcp::new().tool_router);
for tool in tools {
let schema = serde_json::to_value(&tool.input_schema).expect("schema json");
let formats = schema_format_values(&schema);
for format in &formats {
assert!(
is_standard_schema_format(format),
"tool {} advertises non-standard format {format}",
tool.name
);
}
// Bounds are the contract; losing the format annotation must not
// lose the numeric limits that back it.
let raw_tool = serde_json::to_value(
&PdfReaderMcp::new()
.tool_router
.list_all()
.into_iter()
.find(|raw_tool| raw_tool.name == tool.name)
.expect("raw tool")
.input_schema,
)
.expect("raw schema json");
assert_eq!(
schema_numeric_bounds(&schema),
schema_numeric_bounds(&raw_tool),
"tool {} numeric bounds changed during sanitization",
tool.name
);
}
}
#[test]
fn sanitize_schema_formats_keeps_standard_formats_and_bounds() {
let mut object = serde_json::json!({
"type": ["null", "integer"],
"format": "uint32",
"minimum": 1000,
"maximum": 1000000,
"standard": { "format": "uuid" },
"nested": { "format": "double" },
"list": [{ "format": "date-time" }, { "format": "uint64" }]
})
.as_object()
.expect("object")
.clone();
sanitize_schema_formats(&mut object);
let value = Value::Object(object);
assert!(value.get("format").is_none());
assert_eq!(value["minimum"], 1000);
assert_eq!(value["maximum"], 1000000);
assert_eq!(value["standard"]["format"], "uuid");
assert!(value["nested"].get("format").is_none());
assert_eq!(value["list"][0]["format"], "date-time");
assert!(value["list"][1].get("format").is_none());
}
fn schema_format_values(value: &Value) -> Vec<String> {
let mut formats = Vec::new();
match value {
Value::Object(object) => {
if let Some(Value::String(format)) = object.get("format") {
formats.push(format.clone());
}
for child in object.values() {
formats.extend(schema_format_values(child));
}
}
Value::Array(items) => {
for item in items {
formats.extend(schema_format_values(item));
}
}
_ => {}
}
formats
}
fn schema_numeric_bounds(value: &Value) -> Vec<(String, f64)> {
let mut bounds = Vec::new();
match value {
Value::Object(object) => {
for key in ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum"] {
if let Some(number) = object.get(key).and_then(|value| value.as_f64()) {
bounds.push((key.to_string(), number));
}
}
for child in object.values() {
bounds.extend(schema_numeric_bounds(child));
}
}
Value::Array(items) => {
for item in items {
bounds.extend(schema_numeric_bounds(item));
}
}
_ => {}
}
bounds
}
#[tokio::test]
async fn read_pdf_contains_malformed_cff_custom_encoding_panic() {
// Regression for SylphxAI/citra#660: the CFF Custom encoding
// panic must cross the MCP boundary as ErrorData, not kill the native
// server process or leave the tool call timing out.
let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(
"../../test/fixtures/differential/v3014-bug660-cff-custom-encoding-short-charset-v1.pdf",
);
let read = serde_json::from_value(serde_json::json!({
"sources": [{"path": fixture}],
"include_full_text": true,
}))
.expect("structurally valid read args");
let error = PdfReaderMcp::new()
.read_pdf(Parameters(read))
.await
.expect_err("read_pdf must return a structured MCP error");
assert!(error.message.contains("malformed font encoding"));
}
#[tokio::test]
async fn read_pdf_reports_malformed_inline_image_as_error() {
// Regression for SylphxAI/citra#675: a malformed inline
// image (missing `/CS` without `/IM`) panics inside lopdf 0.42's
// content parser. It must cross the MCP boundary as ErrorData
// naming the page, not kill the native server or time out the call.
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("inline-malformed.pdf");
let content = b"BI /W 1 /H 1 /BPC 1 ID \x00 EI\n";
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << >> >>"
.to_string(),
format!(
"<< /Length {} >>\nstream\n{}\nendstream",
content.len(),
String::from_utf8_lossy(content)
),
];
let mut pdf = b"%PDF-1.4\n".to_vec();
let mut offsets = Vec::new();
for (index, object) in objects.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", index + 1).as_bytes());
}
let xref_offset = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
pdf.extend_from_slice(b"0000000000 65535 f \n");
for offset in offsets {
pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n",
objects.len() + 1
)
.as_bytes(),
);
std::fs::write(&path, pdf).expect("write PDF");
let read = serde_json::from_value(serde_json::json!({
"sources": [{"path": path}],
"include_full_text": true,
}))
.expect("structurally valid read args");
let error = PdfReaderMcp::new()
.read_pdf(Parameters(read))
.await
.expect_err("read_pdf must return a structured MCP error");
assert!(
error.message.contains("invalid content stream (page 1)"),
"unexpected message: {}",
error.message
);
}
#[tokio::test]
async fn read_pdf_survives_wellformed_inline_image() {
// The #675 reproducer itself must read cleanly through the tool.
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("inline-ok.pdf");
let content = b"BI /W 1 /H 1 /IM true /BPC 1 ID \x00 EI\n";
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << >> >>"
.to_string(),
format!(
"<< /Length {} >>\nstream\n{}\nendstream",
content.len(),
String::from_utf8_lossy(content)
),
];
let mut pdf = b"%PDF-1.4\n".to_vec();
let mut offsets = Vec::new();
for (index, object) in objects.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", index + 1).as_bytes());
}
let xref_offset = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
pdf.extend_from_slice(b"0000000000 65535 f \n");
for offset in offsets {
pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n",
objects.len() + 1
)
.as_bytes(),
);
std::fs::write(&path, pdf).expect("write PDF");
let read = serde_json::from_value(serde_json::json!({
"sources": [{"path": path}],
"include_full_text": true,
}))
.expect("structurally valid read args");
let result = PdfReaderMcp::new()
.read_pdf(Parameters(read))
.await
.expect("read_pdf must complete on an inline-image PDF");
assert!(
!result.content.is_empty(),
"expected a structured read_pdf result"
);
}
#[tokio::test]
async fn search_pdf_handles_malformed_cid_cmap_fixture_without_aborting() {
// Regression for SylphxAI/citra#608: a pdfTeX ToUnicode CMap
// using 1-byte beginbfrange destinations (like <C5> <D6> <C5>) made the
// upstream adobe-cmap-parser panic with "bad length of hexstring",
// aborting the whole MCP server. The tool entrypoint must return a
// structured result instead.
let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../test/fixtures/differential/v3014-bug608-cid-bfrange-odd-v1.pdf");
let server = PdfReaderMcp::new();
let search = serde_json::from_value(serde_json::json!({
"sources": [{"path": fixture}],
"query": "a"
}))
.expect("structurally valid search args");
let result = server
.search_pdf(Parameters(search))
.await
.expect("search_pdf must complete on a malformed CMap PDF");
assert!(
!result.content.is_empty(),
"expected a structured search_pdf result"
);
let read = serde_json::from_value(serde_json::json!({
"sources": [{"path": fixture}]
}))
.expect("structurally valid read args");
let result = server
.read_pdf(Parameters(read))
.await
.expect("read_pdf must complete on a malformed CMap PDF");
assert!(
!result.content.is_empty(),
"expected a structured read_pdf result"
);
}
#[tokio::test]
async fn tool_entrypoints_reject_values_outside_v3_0_14_runtime_bounds() {
let server = PdfReaderMcp::new();
let read = serde_json::from_value(serde_json::json!({
"sources": [{"path": "sample.pdf"}],
"sample_pages": 21
}))
.expect("structurally valid read args");
assert!(server.read_pdf(Parameters(read)).await.is_err());
let search = serde_json::from_value(serde_json::json!({
"sources": [{"path": "sample.pdf"}],
"query": "needle",
"context_chars": 1001
}))
.expect("structurally valid search args");
assert!(server.search_pdf(Parameters(search)).await.is_err());
let evidence = serde_json::from_value(serde_json::json!({
"operation": "inspect",
"sources": [{"path": "sample.pdf"}],
"scale": 4.01
}))
.expect("structurally valid evidence args");
assert!(server.pdf_evidence(Parameters(evidence)).await.is_err());
}
#[tokio::test]
async fn all_tool_entrypoints_reject_paths_outside_the_native_allowlist() {
let temp = tempfile::tempdir().expect("tempdir");
let allowed = temp.path().join("allowed");
std::fs::create_dir(&allowed).expect("allowed root");
let outside = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../test/fixtures/sample.pdf")
.canonicalize()
.expect("outside fixture");
let policy = crate::source_access::SourceAccessPolicy::restricted_for_test(
temp.path().to_path_buf(),
&allowed,
)
.expect("restricted policy");
let server = PdfReaderMcp::with_source_access(policy);
let read = serde_json::from_value(serde_json::json!({
"sources": [{"path": outside}]
}))
.expect("read args");
let read_error = server
.read_pdf(Parameters(read))
.await
.expect_err("read_pdf must reject outside path");
assert!(read_error.message.contains("Access denied"));
let search = serde_json::from_value(serde_json::json!({
"sources": [{"path": outside}],
"query": "needle"
}))
.expect("search args");
let search_error = server
.search_pdf(Parameters(search))
.await
.expect_err("search_pdf must reject outside path");
assert!(search_error.message.contains("Access denied"));
let evidence = serde_json::from_value(serde_json::json!({
"operation": "inspect",
"sources": [{"path": outside}]
}))
.expect("evidence args");
let evidence_error = server
.pdf_evidence(Parameters(evidence))
.await
.expect_err("pdf_evidence must reject outside path");
assert!(evidence_error.message.contains("Access denied"));
}
}