use std::path::{Component, Path, PathBuf};
use std::process::ExitCode;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use docling::{ConvertOptions, DocumentConverter, ImageMode, InputFormat, SourceDocument};
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{Implementation, ProgressNotificationParam, ServerCapabilities, ServerConfig};
use rmcp::service::RequestContext;
use rmcp::{tool, tool_handler, tool_router, RoleServer, ServerHandler, ServiceExt};
use serde::Deserialize;
const HELP: &str = "\
docling-rs mcp — a Model Context Protocol server over the docling.rs converter
usage: docling-rs mcp [--root DIR]... [--allow-url-fetch] [--concurrency N]
docling-rs mcp --http [--host HOST] [--port PORT] [--allow-remote]
[--bearer-token TOKEN] [...]
Tools: convert_to_markdown(uri) (MarkItDown's MCP tool, same contract),
convert(uri, to, images, options), list_input_formats().
--root DIR directory local paths and file: URIs may read from;
repeatable (default: the working directory)
--allow-url-fetch convert http(s) URLs, remote <img src> and a
request-supplied vlm_endpoint (outbound requests from
this machine; private and loopback targets stay refused
unless DOCLING_RS_ALLOW_PRIVATE_IP_FETCH=1)
--concurrency N conversions running at once (default 2)
--http serve Streamable HTTP at /mcp instead of stdio
--host HOST bind address for --http (default 127.0.0.1)
--port PORT port for --http (default 3001)
--allow-remote let --http bind a non-loopback address
--bearer-token T require `Authorization: Bearer T` on --http
(default: DOCLING_RS_MCP_TOKEN when set)
Claude Desktop (claude_desktop_config.json):
{\"mcpServers\": {\"docling\": {\"command\": \"docling-rs\",
\"args\": [\"mcp\", \"--root\", \"/Users/me/Documents\"]}}}";
struct Root {
given: PathBuf,
canonical: PathBuf,
}
impl Root {
fn new(dir: &str) -> Result<Self, String> {
let canonical = std::fs::canonicalize(dir).map_err(|e| format!("--root {dir}: {e}"))?;
if !canonical.is_dir() {
return Err(format!("--root {dir}: not a directory"));
}
let given = std::path::absolute(dir).map_err(|e| format!("--root {dir}: {e}"))?;
Ok(Self {
given: normalize(&given),
canonical,
})
}
}
struct State {
roots: Vec<Root>,
allow_url_fetch: bool,
fetch_policy: docling::FetchPolicy,
permits: Arc<tokio::sync::Semaphore>,
warned_text_layer: AtomicBool,
warm: docling::WarmPipeline,
}
struct HttpConfig {
host: String,
port: u16,
allow_remote: bool,
token: Option<String>,
}
pub fn run(args: Vec<String>) -> ExitCode {
let mut roots: Vec<String> = Vec::new();
let mut allow_url_fetch = false;
let mut concurrency: usize = 2;
let mut http = false;
let mut host = "127.0.0.1".to_string();
let mut port: u16 = 3001;
let mut allow_remote = false;
let mut token = docling_core::env::nonempty("DOCLING_RS_MCP_TOKEN");
let mut http_flag: Option<&'static str> = None;
let mut it = args.into_iter();
while let Some(arg) = it.next() {
match arg.as_str() {
"--help" | "-h" => {
println!("{HELP}");
return ExitCode::SUCCESS;
}
"--root" => match it.next() {
Some(v) if !v.is_empty() => roots.push(v),
_ => return usage("--root needs a directory"),
},
"--allow-url-fetch" => allow_url_fetch = true,
"--concurrency" => match it.next().and_then(|v| v.parse().ok()) {
Some(n) if n >= 1 => concurrency = n,
_ => return usage("--concurrency needs a positive integer"),
},
"--http" => http = true,
"--host" => match it.next() {
Some(v) if !v.is_empty() => {
host = v;
http_flag = Some("--host");
}
_ => return usage("--host needs an address"),
},
"--port" => match it.next().and_then(|v| v.parse().ok()) {
Some(p) => {
port = p;
http_flag = Some("--port");
}
None => return usage("--port needs a port number"),
},
"--allow-remote" => {
allow_remote = true;
http_flag = Some("--allow-remote");
}
"--bearer-token" => match it.next() {
Some(v) if !v.is_empty() => {
token = Some(v);
http_flag = Some("--bearer-token");
}
_ => return usage("--bearer-token needs a token"),
},
other => return usage(&format!("unknown argument '{other}'")),
}
}
if let (false, Some(flag)) = (http, http_flag) {
return usage(&format!("{flag} applies to --http only"));
}
if roots.is_empty() {
roots.push(".".to_string());
}
let roots = match roots
.iter()
.map(|r| Root::new(r))
.collect::<Result<Vec<_>, _>>()
{
Ok(roots) => roots,
Err(e) => return usage(&e),
};
let state = Arc::new(State {
roots,
allow_url_fetch,
fetch_policy: fetch_policy(),
permits: Arc::new(tokio::sync::Semaphore::new(concurrency)),
warned_text_layer: AtomicBool::new(false),
warm: docling::WarmPipeline::new(),
});
docling::memory::pin_mmap_threshold();
let runtime = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
eprintln!("error: tokio runtime: {e}");
return ExitCode::FAILURE;
}
};
let result = if http {
let cfg = HttpConfig {
host,
port,
allow_remote,
token,
};
runtime.block_on(serve_http(state, cfg))
} else {
runtime.block_on(serve_stdio(state))
};
runtime.shutdown_background();
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
fn usage(err: &str) -> ExitCode {
eprintln!("error: {err}");
eprintln!("usage: docling-rs mcp [--root DIR]... [--allow-url-fetch] [--concurrency N] [--http [--host HOST] [--port PORT] [--allow-remote] [--bearer-token TOKEN]]");
ExitCode::from(2)
}
fn describe(state: &State, transport: &str) {
eprintln!(
"docling-rs mcp {} · {transport} · root {} · URL fetch {}",
env!("CARGO_PKG_VERSION"),
state.roots_display(),
if state.allow_url_fetch { "on" } else { "off" }
);
}
impl State {
fn roots_display(&self) -> String {
let roots: Vec<String> = self
.roots
.iter()
.map(|r| r.given.display().to_string())
.collect();
roots.join(", ")
}
}
async fn serve_stdio(state: Arc<State>) -> Result<(), String> {
describe(&state, "stdio");
let service = DoclingMcp::new(state)
.serve(rmcp::transport::stdio())
.await
.map_err(|e| format!("mcp: {e}"))?;
service
.waiting()
.await
.map(|_| ())
.map_err(|e| format!("mcp: {e}"))
}
async fn serve_http(state: Arc<State>, cfg: HttpConfig) -> Result<(), String> {
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
use rmcp::transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService,
};
let addr = tokio::net::lookup_host((cfg.host.as_str(), cfg.port))
.await
.map_err(|e| format!("--host {}: {e}", cfg.host))?
.next()
.ok_or_else(|| format!("--host {}: no address", cfg.host))?;
let loopback = addr.ip().is_loopback();
if !loopback && !cfg.allow_remote {
return Err(format!(
"refusing to bind {addr}: not a loopback address — the server reads files under \
its root for whoever can reach it; pass --allow-remote (and --bearer-token) to \
expose it"
));
}
if !loopback && cfg.token.is_none() {
eprintln!(
"warning: serving {addr} without a bearer token — anyone who can reach it can \
read the files under the root"
);
}
let mut config = StreamableHttpServerConfig::default();
let ct = config.cancellation_token.clone();
config = if loopback {
config.with_allowed_origins(["http://localhost:*", "http://127.0.0.1:*", "http://[::1]:*"])
} else {
config.disable_allowed_hosts()
};
let service: StreamableHttpService<DoclingMcp, LocalSessionManager> =
StreamableHttpService::new(
{
let state = state.clone();
move || Ok(DoclingMcp::new(state.clone()))
},
Default::default(),
config,
);
let mut router = axum::Router::new().nest_service("/mcp", service);
if let Some(token) = cfg.token.clone() {
let expected: Arc<[u8]> = format!("Bearer {token}").into_bytes().into();
router = router.layer(axum::middleware::from_fn(
move |req: axum::extract::Request, next: axum::middleware::Next| {
let expected = expected.clone();
async move {
let given = req
.headers()
.get(axum::http::header::AUTHORIZATION)
.map(|v| v.as_bytes())
.unwrap_or_default();
if constant_time_eq(given, &expected) {
next.run(req).await
} else {
axum::response::IntoResponse::into_response((
axum::http::StatusCode::UNAUTHORIZED,
[(axum::http::header::WWW_AUTHENTICATE, "Bearer")],
"missing or wrong bearer token\n",
))
}
}
},
));
}
let listener = tokio::net::TcpListener::bind(addr)
.await
.map_err(|e| format!("binding {addr}: {e}"))?;
let bound = listener.local_addr().map_err(|e| e.to_string())?;
describe(&state, &format!("Streamable HTTP on http://{bound}/mcp"));
axum::serve(listener, router)
.with_graceful_shutdown(async move {
shutdown_signal().await;
ct.cancel();
})
.await
.map_err(|e| format!("serving {bound}: {e}"))
}
async fn shutdown_signal() {
#[cfg(unix)]
let terminate = async {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut s) => {
s.recv().await;
}
Err(_) => std::future::pending::<()>().await,
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = terminate => {}
}
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
#[derive(Deserialize, schemars::JsonSchema)]
struct ConvertToMarkdownArgs {
uri: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
struct ConvertArgs {
uri: String,
#[serde(default)]
#[schemars(transform = drop_null)]
to: Option<String>,
#[serde(default)]
#[schemars(transform = drop_null)]
images: Option<String>,
#[serde(default)]
#[schemars(transform = drop_null)]
options: Option<serde_json::Map<String, serde_json::Value>>,
}
fn drop_null(schema: &mut schemars::Schema) {
if let Some(serde_json::Value::Array(types)) = schema.get("type").cloned() {
let kept: Vec<_> = types.into_iter().filter(|t| t != "null").collect();
if let [single] = kept.as_slice() {
schema.insert("type".into(), single.clone());
}
}
if schema.get("default") == Some(&serde_json::Value::Null) {
schema.remove("default");
}
}
#[derive(Clone)]
struct DoclingMcp {
state: Arc<State>,
tool_router: ToolRouter<Self>,
}
impl DoclingMcp {
fn new(state: Arc<State>) -> Self {
Self {
state,
tool_router: Self::tool_router(),
}
}
async fn run_conversion(
&self,
request: Request,
ctx: &RequestContext<RoleServer>,
) -> Result<String, String> {
let permit = self
.state
.permits
.clone()
.acquire_owned()
.await
.map_err(|e| e.to_string())?;
let state = self.state.clone();
let mut job = tokio::task::spawn_blocking(move || {
let _permit = permit;
let result = convert(&state, &request);
docling::memory::release_free_memory();
result
});
let token = ctx.meta.get_progress_token();
let mut tick = tokio::time::interval(Duration::from_secs(10));
tick.tick().await; let mut beats = 0u32;
loop {
tokio::select! {
done = &mut job => {
return done.map_err(|e| format!("conversion worker failed: {e}"))?;
}
_ = tick.tick(), if token.is_some() => {
beats += 1;
let param = ProgressNotificationParam::new(
token.clone().expect("guarded"),
f64::from(beats),
)
.with_message(format!("converting ({}s)", beats * 10));
let _ = ctx.peer.notify_progress(param).await;
}
}
}
}
}
#[tool_router]
impl DoclingMcp {
#[tool(annotations(
title = "Convert to Markdown",
read_only_hint = true,
open_world_hint = true
))]
async fn convert_to_markdown(
&self,
Parameters(args): Parameters<ConvertToMarkdownArgs>,
ctx: RequestContext<RoleServer>,
) -> Result<String, String> {
let request = Request {
uri: args.uri,
to: "md".into(),
images: ImageMode::Placeholder,
options: ConvertOptions::default(),
};
self.run_conversion(request, &ctx).await
}
#[tool(annotations(
title = "Convert document",
read_only_hint = true,
open_world_hint = true
))]
async fn convert(
&self,
Parameters(args): Parameters<ConvertArgs>,
ctx: RequestContext<RoleServer>,
) -> Result<String, String> {
let request = Request::from_args(args)?;
self.run_conversion(request, &ctx).await
}
#[tool(annotations(title = "List input formats", read_only_hint = true))]
async fn list_input_formats(&self) -> String {
let mut out = InputFormat::supported_extensions().join("\n");
out.push_str(if self.state.allow_url_fetch {
"\n\nURL inputs (http/https): enabled"
} else {
"\n\nURL inputs (http/https): disabled (the server was started without --allow-url-fetch)"
});
out
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for DoclingMcp {
fn get_info(&self) -> ServerConfig {
let mut info = Implementation::new("docling-rs", env!("CARGO_PKG_VERSION"));
info.title = Some("docling.rs".into());
info.website_url = Some("https://github.com/docling-project/docling.rs".into());
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(info)
.with_instructions(format!(
"Converts documents to Markdown or docling JSON for reading: PDF (layout, \
tables, OCR), DOCX, PPTX, XLSX, HTML, EPUB, e-mail, images, audio and more. \
Use convert_to_markdown for text; convert for other formats or options \
(e.g. {{\"pages\": \"1-5\"}}). Local files: paths under {}. URLs: {}.",
self.state.roots_display(),
if self.state.allow_url_fetch {
"enabled"
} else {
"disabled on this server"
}
))
}
}
struct Request {
uri: String,
to: String,
images: ImageMode,
options: ConvertOptions,
}
const OUTPUTS: &[&str] = &[
"md", "json", "html", "text", "latex", "chunks", "vtt", "pandoc",
];
impl Request {
fn from_args(args: ConvertArgs) -> Result<Self, String> {
let to = match args.to.as_deref().map(str::trim) {
None | Some("") | Some("md") | Some("markdown") => "md".to_string(),
Some(t) if OUTPUTS.contains(&t) => t.to_string(),
Some(t) => {
return Err(format!(
"unknown to={t:?} (expected one of: {})",
OUTPUTS.join(", ")
))
}
};
let images = match args.images.as_deref().map(str::trim) {
None | Some("") | Some("placeholder") => ImageMode::Placeholder,
Some("embedded") => ImageMode::Embedded,
Some(other) => {
return Err(format!(
"unknown images={other:?} (expected: placeholder, embedded)"
))
}
};
let options = match args.options {
None => ConvertOptions::default(),
Some(map) => {
let unknown = ConvertOptions::unknown_keys(map.keys().map(String::as_str), &[]);
if let Some(key) = unknown.first() {
return Err(format!(
"options: unknown field `{key}` (see docs/OPTIONS.md for the list)"
));
}
serde_json::from_value(serde_json::Value::Object(map))
.map_err(|e| format!("options: {e}"))?
}
};
Ok(Self {
uri: args.uri,
to,
images,
options,
})
}
}
fn convert(state: &State, request: &Request) -> Result<String, String> {
let source = resolve_source(state, &request.uri)?;
let mut options = guard_options(state, &request.options)?;
if request.to == "md" && options.compact_tables.is_none() {
options.compact_tables = Some(true);
}
let document = convert_document(state, &source, &options)?;
render(&document, &request.to, request.images)
}
fn convert_document(
state: &State,
source: &SourceDocument,
options: &ConvertOptions,
) -> Result<docling::DoclingDocument, String> {
let converter = options
.apply(DocumentConverter::new().warm_pipeline(&state.warm))
.map_err(|e| e.to_string())?;
if let Some(vlm) = options.vlm_options().map_err(|e| e.to_string())? {
let mut document = docling::vlm::convert_vlm(source, &vlm).map_err(|e| e.to_string())?;
converter.finish_document(&mut document);
return Ok(document);
}
match converter.convert(source.clone()) {
Ok(result) => Ok(result.document),
Err(e)
if source.format == InputFormat::Pdf
&& options.text_layer_only.is_none()
&& e.to_string().contains("model not found") =>
{
if !state.warned_text_layer.swap(true, Ordering::Relaxed) {
eprintln!(
"docling-rs mcp: the layout models are missing, so PDFs convert from \
their text layer (no layout analysis, table structure or OCR). Fetch \
them with scripts/install/download_dependencies.sh, or point \
DOCLING_RS_MODELS_DIR at a .models directory."
);
}
let retry = DocumentConverter::new().text_layer_only(true);
let converter = options.apply(retry).map_err(|e| e.to_string())?;
converter
.convert(source.clone())
.map(|r| r.document)
.map_err(|e| e.to_string())
}
Err(e) => Err(e.to_string()),
}
}
fn render(
document: &docling::DoclingDocument,
to: &str,
images: ImageMode,
) -> Result<String, String> {
Ok(match to {
"md" => match images {
ImageMode::Placeholder => document.export_to_markdown(),
mode => document.export_to_markdown_with_images(mode, "artifacts").0,
},
"json" => document.export_to_json(),
"html" => document.export_to_html(),
"text" => document.export_to_text(),
"latex" => document.export_to_latex(),
"vtt" => document.export_to_vtt(),
"chunks" => {
let mut warn = |msg: String| eprintln!("warning: {msg}");
let records = docling::chunks::chunk_records(document, &mut warn);
serde_json::to_string_pretty(&records).map_err(|e| e.to_string())?
}
"pandoc" => {
document
.export_to_pandoc_json_with(&docling::pandoc::PandocExportOptions {
image_mode: images,
..Default::default()
})
.map_err(|e| e.to_string())?
.0
}
other => return Err(format!("unknown to={other:?}")),
})
}
fn guard_options(state: &State, options: &ConvertOptions) -> Result<ConvertOptions, String> {
let mut o = options.clone();
o.validate().map_err(|e| e.to_string())?;
let requested = o.image_sources().map_err(|e| e.to_string())?.or_else(|| {
o.fetch_images
.filter(|&f| f)
.map(|_| docling::ImageSources::Remote)
});
if requested == Some(docling::ImageSources::Remote) && !state.allow_url_fetch {
o.image_sources = Some(docling::ImageSources::Embedded.to_string());
o.fetch_images = Some(false);
}
if let Some(endpoint) = o.vlm_endpoint.as_deref().filter(|v| !v.trim().is_empty()) {
if !state.allow_url_fetch {
return Err(
"a request-supplied vlm_endpoint needs the server started with \
--allow-url-fetch (or pin it with DOCLING_RS_VLM_ENDPOINT)"
.into(),
);
}
docling::fetch::check_outbound_url(endpoint, false)?;
}
if o.use_web_browser == Some(true) && !state.allow_url_fetch {
return Err(
"use_web_browser loads the page's remote resources; it needs the server \
started with --allow-url-fetch"
.into(),
);
}
if let Some(dir) = o.xbrl_taxonomy.as_deref() {
o.xbrl_taxonomy = Some(local_path(state, Path::new(dir))?.display().to_string());
}
if let Some(layout) = o.ebcdic_layout.as_deref() {
if !layout.trim_start().starts_with('{') {
o.ebcdic_layout = Some(local_path(state, Path::new(layout))?.display().to_string());
}
}
if let Some(model) = o.asr_model.as_deref() {
if model.contains(['/', '\\']) || model.contains("..") {
return Err(format!("asr_model must be a preset name, got {model:?}"));
}
}
Ok(o)
}
fn resolve_source(state: &State, uri: &str) -> Result<SourceDocument, String> {
let uri = uri.trim();
if uri.is_empty() {
return Err("uri is empty".into());
}
let lower = uri.get(..8).unwrap_or(uri).to_ascii_lowercase();
if lower.starts_with("data:") {
return docling::fetch::from_data_url(uri).map_err(|e| e.to_string());
}
if lower.starts_with("http://") || lower.starts_with("https://") {
if !state.allow_url_fetch {
return Err(format!(
"fetching URLs is disabled on this server (it was started without \
--allow-url-fetch); pass a local path or file: URI under {} instead",
state.roots_display()
));
}
return SourceDocument::from_url(uri, &state.fetch_policy).map_err(|e| e.to_string());
}
let path = if lower.starts_with("file:") {
docling::fetch::file_url_path(uri)?
} else if let Some(scheme) = uri_scheme(uri) {
return Err(format!(
"unsupported URI scheme {scheme:?} (expected http, https, file, data, or a path)"
));
} else {
PathBuf::from(uri)
};
let path = local_path(state, &path)?;
if path.is_dir() {
return Err(format!("{} is a directory, not a document", path.display()));
}
match SourceDocument::from_file(&path) {
Ok(source) => Ok(source),
Err(_) => {
let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("document");
let mut source = SourceDocument::from_named_bytes(name, bytes, None)
.map_err(|e| format!("{}: {e}", path.display()))?;
source.path = Some(path);
Ok(source)
}
}
}
fn fetch_policy() -> docling::FetchPolicy {
docling::FetchPolicy {
max_redirects: 5,
..docling::FetchPolicy::default()
}
}
fn uri_scheme(uri: &str) -> Option<&str> {
let (scheme, _) = uri.split_once(':')?;
let valid = scheme.len() >= 2
&& scheme.starts_with(|c: char| c.is_ascii_alphabetic())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
valid.then_some(scheme)
}
fn local_path(state: &State, path: &Path) -> Result<PathBuf, String> {
let joined = if path.is_absolute() {
path.to_path_buf()
} else {
state.roots[0].canonical.join(path)
};
let outside = || {
format!(
"{} is outside the server's root ({}); start the server with --root to allow it",
path.display(),
state.roots_display()
)
};
let lexical = normalize(&joined);
let under = |p: &Path| {
state
.roots
.iter()
.any(|r| p.starts_with(&r.given) || p.starts_with(&r.canonical))
};
if !under(&lexical) {
return Err(outside());
}
let canonical = std::fs::canonicalize(&lexical)
.map_err(|e| format!("cannot open {}: {e}", path.display()))?;
if !state
.roots
.iter()
.any(|r| canonical.starts_with(&r.canonical))
{
return Err(outside());
}
Ok(canonical)
}
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn state(root: &Path, allow_url_fetch: bool) -> State {
State {
roots: vec![Root::new(root.to_str().unwrap()).unwrap()],
allow_url_fetch,
fetch_policy: fetch_policy(),
permits: Arc::new(tokio::sync::Semaphore::new(1)),
warned_text_layer: AtomicBool::new(false),
warm: docling::WarmPipeline::new(),
}
}
fn fixtures() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/data")
}
#[test]
fn paths_stay_under_the_root() {
let root = fixtures().join("md");
let s = state(&root, false);
let ok = local_path(&s, Path::new("sources")).unwrap();
assert!(ok.ends_with("md/sources"));
for escape in [
"../docx/sources",
"sources/../../docx",
"/etc/passwd",
"/nonexistent/x.pdf",
] {
let err = local_path(&s, Path::new(escape)).unwrap_err();
assert!(err.contains("outside the server's root"), "{escape}: {err}");
}
}
#[cfg(unix)]
#[test]
fn a_root_behind_a_symlink_takes_both_spellings() {
let dir = std::env::temp_dir().join(format!("docling-mcp-link-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let link = dir.join("docs");
std::os::unix::fs::symlink(fixtures().join("md"), &link).unwrap();
let s = state(&link, false);
let via_link = link.join("sources");
assert!(local_path(&s, &via_link).is_ok());
let via_target = fixtures().join("md/sources");
assert!(local_path(&s, &std::fs::canonicalize(via_target).unwrap()).is_ok());
assert!(local_path(&s, &dir.join("docs/../elsewhere")).is_err());
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn a_symlink_cannot_lead_out_of_the_root() {
let dir = std::env::temp_dir().join(format!("docling-mcp-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::os::unix::fs::symlink(fixtures().join("docx"), dir.join("out")).unwrap();
let s = state(&dir, false);
let err = local_path(&s, Path::new("out/sources")).unwrap_err();
assert!(err.contains("outside the server's root"), "{err}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn uri_kinds() {
assert_eq!(uri_scheme("s3://bucket/x.pdf"), Some("s3"));
assert_eq!(uri_scheme("ftp://h/x"), Some("ftp"));
assert_eq!(uri_scheme("C:\\docs\\a.pdf"), None);
assert_eq!(uri_scheme("report.pdf"), None);
let s = state(&fixtures(), false);
let err = resolve_source(&s, "ftp://example.com/a.pdf").unwrap_err();
assert!(err.contains("unsupported URI scheme"), "{err}");
let err = resolve_source(&s, "https://example.com/a.pdf").unwrap_err();
assert!(err.contains("--allow-url-fetch"), "{err}");
let err = resolve_source(&s, "docx").unwrap_err();
assert!(err.contains("is a directory"), "{err}");
}
#[test]
fn data_uris_decode() {
let s = state(&fixtures(), false);
let src = resolve_source(&s, "data:text/markdown;base64,IyBUaXRsZQoKSGVsbG8=").unwrap();
assert_eq!(src.format, InputFormat::Md);
assert_eq!(src.bytes, b"# Title\n\nHello");
let src = resolve_source(&s, "DATA:text/html,%3Ch1%3EHi%3C/h1%3E").unwrap();
assert_eq!(src.format, InputFormat::Html);
assert_eq!(src.bytes, b"<h1>Hi</h1>");
let src = resolve_source(&s, "data:;base64,JVBERi0xLjQK").unwrap();
assert_eq!(src.format, InputFormat::Pdf);
let src = resolve_source(&s, "data:text/csv;charset=shift_jis,a,b").unwrap();
assert_eq!(src.encoding.as_deref(), Some("shift_jis"));
assert!(resolve_source(&s, "data:text/plain").is_err());
assert!(resolve_source(&s, "data:application/octet-stream,xyz").is_err());
}
#[test]
fn options_follow_the_operator_policy() {
let s = state(&fixtures(), false);
let opts: ConvertOptions =
serde_json::from_value(serde_json::json!({"image_sources": "remote"})).unwrap();
let guarded = guard_options(&s, &opts).unwrap();
assert_eq!(guarded.image_sources.as_deref(), Some("embedded"));
let opts: ConvertOptions =
serde_json::from_value(serde_json::json!({"vlm_endpoint": "http://h/v1"})).unwrap();
assert!(guard_options(&s, &opts)
.unwrap_err()
.contains("--allow-url-fetch"));
let opts: ConvertOptions =
serde_json::from_value(serde_json::json!({"xbrl_taxonomy": "/etc"})).unwrap();
assert!(guard_options(&s, &opts).unwrap_err().contains("outside"));
let opts: ConvertOptions =
serde_json::from_value(serde_json::json!({"asr_model": "../../x"})).unwrap();
assert!(guard_options(&s, &opts)
.unwrap_err()
.contains("preset name"));
let s = state(&fixtures(), true);
let opts: ConvertOptions =
serde_json::from_value(serde_json::json!({"fetch_images": true})).unwrap();
let guarded = guard_options(&s, &opts).unwrap();
assert_ne!(guarded.image_sources.as_deref(), Some("embedded"));
}
#[test]
fn requests_validate_their_arguments() {
let args = |v: serde_json::Value| serde_json::from_value::<ConvertArgs>(v).unwrap();
let r = Request::from_args(args(serde_json::json!({"uri": "a.docx"}))).unwrap();
assert_eq!(r.to, "md");
let err = Request::from_args(args(serde_json::json!({"uri": "a", "to": "dclx"})))
.map(|_| ())
.unwrap_err();
assert!(err.contains("unknown to"), "{err}");
let err = Request::from_args(args(
serde_json::json!({"uri": "a", "options": {"text_layer_onyl": true}}),
))
.map(|_| ())
.unwrap_err();
assert!(err.contains("unknown field `text_layer_onyl`"), "{err}");
let r = Request::from_args(args(
serde_json::json!({"uri": "a", "to": "json", "options": {"pages": "1-2"}}),
))
.unwrap();
assert_eq!(r.options.pages.as_deref(), Some("1-2"));
}
#[test]
fn converts_a_docx_to_compact_markdown() {
let s = state(&fixtures(), false);
let request = Request {
uri: "docx/sources/lorem_ipsum.docx".into(),
to: "md".into(),
images: ImageMode::Placeholder,
options: ConvertOptions::default(),
};
let md = convert(&s, &request).unwrap();
assert!(md.contains("Lorem ipsum"), "{md}");
}
#[test]
fn calls_on_the_warm_pipeline_keep_their_own_options() {
let warm = state(&fixtures(), false);
let request = |pages: Option<&str>| Request {
uri: "pdf/sources/2206.01062.pdf".into(),
to: "json".into(),
images: ImageMode::Placeholder,
options: ConvertOptions {
text_layer_only: Some(true),
pages: pages.map(str::to_string),
..Default::default()
},
};
for pages in [Some("2-3"), None, Some("1-1"), None] {
let cold = convert(&state(&fixtures(), false), &request(pages)).unwrap();
assert_eq!(
convert(&warm, &request(pages)).unwrap(),
cold,
"pages {pages:?}"
);
}
}
#[test]
fn tool_schemas_are_portable() {
let schema = serde_json::to_value(schemars::schema_for!(ConvertArgs)).unwrap();
assert_eq!(schema["required"], serde_json::json!(["uri"]));
for (name, ty) in [
("to", "string"),
("images", "string"),
("options", "object"),
] {
let prop = &schema["properties"][name];
assert_eq!(prop["type"], ty, "{name}: {prop}");
assert!(prop.get("default").is_none(), "{name}: {prop}");
}
let args: ConvertArgs = serde_json::from_value(
serde_json::json!({"uri": "a.md", "to": null, "images": null, "options": null}),
)
.unwrap();
assert_eq!(Request::from_args(args).unwrap().to, "md");
}
#[test]
fn bearer_comparison() {
assert!(constant_time_eq(b"Bearer abc", b"Bearer abc"));
assert!(!constant_time_eq(b"Bearer abd", b"Bearer abc"));
assert!(!constant_time_eq(b"Bearer ab", b"Bearer abc"));
assert!(!constant_time_eq(b"", b"Bearer abc"));
}
}