1use std::path::{Component, Path, PathBuf};
53use std::process::ExitCode;
54use std::sync::atomic::{AtomicBool, Ordering};
55use std::sync::Arc;
56use std::time::Duration;
57
58use docling::{ConvertOptions, DocumentConverter, ImageMode, InputFormat, SourceDocument};
59use rmcp::handler::server::router::tool::ToolRouter;
60use rmcp::handler::server::wrapper::Parameters;
61use rmcp::model::{Implementation, ProgressNotificationParam, ServerCapabilities, ServerConfig};
62use rmcp::service::RequestContext;
63use rmcp::{tool, tool_handler, tool_router, RoleServer, ServerHandler, ServiceExt};
64use serde::Deserialize;
65
66#[derive(Clone, Copy, Debug)]
71pub struct Launcher<'a> {
72 pub command: &'a str,
74 pub client_config: &'a str,
76 pub models_hint: &'a str,
78 pub extra_flags: &'a str,
80}
81
82impl Launcher<'static> {
83 pub const CLI: Self = Self {
85 command: "docling-rs mcp",
86 client_config: "{\"mcpServers\": {\"docling\": {\"command\": \"docling-rs\",\n \"args\": [\"mcp\", \"--root\", \"/Users/me/Documents\"]}}}",
87 models_hint: "Fetch them with scripts/install/download_dependencies.sh (--cli \
88 beside a cargo-installed binary), or point DOCLING_RS_MODELS_DIR at a \
89 .models directory.",
90 extra_flags: "",
91 };
92}
93
94fn help(launcher: &Launcher) -> String {
95 let cmd = launcher.command;
96 let pad = " ".repeat(cmd.len());
97 format!(
98 "\
99{cmd} — a Model Context Protocol server over the docling.rs converter
100
101usage: {cmd} [--root DIR]... [--allow-url-fetch] [--concurrency N]
102 {cmd} --http [--host HOST] [--port PORT] [--allow-remote]
103 {pad} [--bearer-token TOKEN] [...]
104
105Tools: convert_to_markdown(uri) (MarkItDown's MCP tool, same contract),
106convert(uri, to, images, options), list_input_formats().
107
108 --root DIR directory local paths and file: URIs may read from;
109 repeatable (default: the working directory)
110 --allow-url-fetch convert http(s) URLs, remote <img src> and a
111 request-supplied vlm_endpoint (outbound requests from
112 this machine; private and loopback targets stay refused
113 unless DOCLING_RS_ALLOW_PRIVATE_IP_FETCH=1)
114 --concurrency N conversions running at once (default 2)
115 --http serve Streamable HTTP at /mcp instead of stdio
116 --host HOST bind address for --http (default 127.0.0.1)
117 --port PORT port for --http (default 3001)
118 --allow-remote let --http bind a non-loopback address
119 --bearer-token T require `Authorization: Bearer T` on --http
120 (default: DOCLING_RS_MCP_TOKEN when set)
121{}
122Claude Desktop (claude_desktop_config.json):
123 {}",
124 if launcher.extra_flags.is_empty() {
125 String::new()
126 } else {
127 format!("{}\n", launcher.extra_flags.trim_end())
128 },
129 launcher.client_config
130 )
131}
132
133struct Root {
138 given: PathBuf,
139 canonical: PathBuf,
140}
141
142impl Root {
143 fn new(dir: &str) -> Result<Self, String> {
144 let canonical = std::fs::canonicalize(dir).map_err(|e| format!("--root {dir}: {e}"))?;
145 if !canonical.is_dir() {
146 return Err(format!("--root {dir}: not a directory"));
147 }
148 let given = std::path::absolute(dir).map_err(|e| format!("--root {dir}: {e}"))?;
149 Ok(Self {
150 given: normalize(&given),
151 canonical,
152 })
153 }
154}
155
156struct State {
158 roots: Vec<Root>,
161 allow_url_fetch: bool,
162 fetch_policy: docling::FetchPolicy,
164 permits: Arc<tokio::sync::Semaphore>,
165 warned_text_layer: AtomicBool,
167 command: String,
169 models_hint: String,
171 warm: docling::WarmPipeline,
175}
176
177struct HttpConfig {
178 host: String,
179 port: u16,
180 allow_remote: bool,
181 token: Option<String>,
182}
183
184pub fn run(args: Vec<String>) -> ExitCode {
187 ExitCode::from(run_with(args, &Launcher::CLI))
188}
189
190pub fn run_with(args: Vec<String>, launcher: &Launcher) -> u8 {
195 if std::env::var_os("DOCLING_RS_NO_ARENA").is_none() {
201 std::env::set_var("DOCLING_RS_NO_ARENA", "1");
202 }
203 let usage = |err: &str| usage(launcher, err);
204 let mut roots: Vec<String> = Vec::new();
205 let mut allow_url_fetch = false;
206 let mut concurrency: usize = 2;
207 let mut http = false;
208 let mut host = "127.0.0.1".to_string();
209 let mut port: u16 = 3001;
210 let mut allow_remote = false;
211 let mut token = docling_core::env::nonempty("DOCLING_RS_MCP_TOKEN");
212 let mut http_flag: Option<&'static str> = None;
215 let args = match split_flag_values(args) {
216 Ok(args) => args,
217 Err(e) => return usage(&e),
218 };
219 let mut it = args.into_iter();
220 while let Some(arg) = it.next() {
221 match arg.as_str() {
222 "--help" | "-h" => {
223 println!("{}", help(launcher));
224 return 0;
225 }
226 "--root" => match it.next() {
227 Some(v) if !v.is_empty() => roots.push(v),
228 _ => return usage("--root needs a directory"),
229 },
230 "--allow-url-fetch" => allow_url_fetch = true,
231 "--concurrency" => match it.next().and_then(|v| v.parse().ok()) {
232 Some(n) if n >= 1 => concurrency = n,
233 _ => return usage("--concurrency needs a positive integer"),
234 },
235 "--http" => http = true,
236 "--host" => match it.next() {
237 Some(v) if !v.is_empty() => {
238 host = v;
239 http_flag = Some("--host");
240 }
241 _ => return usage("--host needs an address"),
242 },
243 "--port" => match it.next().and_then(|v| v.parse().ok()) {
244 Some(p) => {
245 port = p;
246 http_flag = Some("--port");
247 }
248 None => return usage("--port needs a port number"),
249 },
250 "--allow-remote" => {
251 allow_remote = true;
252 http_flag = Some("--allow-remote");
253 }
254 "--bearer-token" => match it.next() {
255 Some(v) if !v.is_empty() => {
256 token = Some(v);
257 http_flag = Some("--bearer-token");
258 }
259 _ => return usage("--bearer-token needs a token"),
260 },
261 other => return usage(&format!("unknown argument '{other}'")),
262 }
263 }
264 if let (false, Some(flag)) = (http, http_flag) {
265 return usage(&format!("{flag} applies to --http only"));
266 }
267 let defaulted = roots.is_empty();
268 if defaulted {
269 roots.push(".".to_string());
270 }
271 let roots = match roots
272 .iter()
273 .map(|r| Root::new(r))
274 .collect::<Result<Vec<_>, _>>()
275 {
276 Ok(roots) => roots,
277 Err(e) => return usage(&e),
278 };
279 if defaulted && roots[0].canonical.parent().is_none() {
283 return usage(&format!(
284 "the working directory is the file system root ({}); pass --root DIR \
285 to choose what the server may read",
286 roots[0].canonical.display()
287 ));
288 }
289 let state = Arc::new(State {
290 roots,
291 allow_url_fetch,
292 fetch_policy: fetch_policy(),
293 permits: Arc::new(tokio::sync::Semaphore::new(concurrency)),
294 warned_text_layer: AtomicBool::new(false),
295 command: launcher.command.to_string(),
296 models_hint: launcher.models_hint.to_string(),
297 warm: docling::WarmPipeline::new(),
298 });
299 docling::memory::pin_mmap_threshold();
302 let runtime = match tokio::runtime::Runtime::new() {
303 Ok(rt) => rt,
304 Err(e) => {
305 eprintln!("error: tokio runtime: {e}");
306 return 1;
307 }
308 };
309 let result = if http {
310 let cfg = HttpConfig {
311 host,
312 port,
313 allow_remote,
314 token,
315 };
316 runtime.block_on(serve_http(state, cfg))
317 } else {
318 runtime.block_on(serve_stdio(state))
319 };
320 runtime.shutdown_background();
323 match result {
324 Ok(()) => 0,
325 Err(e) => {
326 eprintln!("error: {e}");
327 1
328 }
329 }
330}
331
332fn split_flag_values(args: Vec<String>) -> Result<Vec<String>, String> {
337 const SWITCHES: [&str; 3] = ["--allow-url-fetch", "--http", "--allow-remote"];
338 let mut out = Vec::with_capacity(args.len());
339 for arg in args {
340 let Some((flag, value)) = arg.split_once('=').filter(|(f, _)| f.starts_with("--")) else {
341 out.push(arg);
342 continue;
343 };
344 if SWITCHES.contains(&flag) {
345 match value.to_ascii_lowercase().as_str() {
346 "" | "true" | "1" | "yes" | "on" => out.push(flag.to_string()),
347 "false" | "0" | "no" | "off" => {}
348 _ => return Err(format!("{flag} takes true or false, not '{value}'")),
349 }
350 } else {
351 out.push(flag.to_string());
352 out.push(value.to_string());
353 }
354 }
355 Ok(out)
356}
357
358fn usage(launcher: &Launcher, err: &str) -> u8 {
359 eprintln!("error: {err}");
360 eprintln!(
361 "usage: {} [--root DIR]... [--allow-url-fetch] [--concurrency N] [--http [--host HOST] [--port PORT] [--allow-remote] [--bearer-token TOKEN]]",
362 launcher.command
363 );
364 2
365}
366
367fn describe(state: &State, transport: &str) {
370 eprintln!(
371 "{} {} · {transport} · root {} · URL fetch {}",
372 state.command,
373 env!("CARGO_PKG_VERSION"),
374 state.roots_display(),
375 if state.allow_url_fetch { "on" } else { "off" }
376 );
377}
378
379impl State {
380 fn roots_display(&self) -> String {
382 let roots: Vec<String> = self
383 .roots
384 .iter()
385 .map(|r| r.given.display().to_string())
386 .collect();
387 roots.join(", ")
388 }
389}
390
391async fn serve_stdio(state: Arc<State>) -> Result<(), String> {
392 describe(&state, "stdio");
393 let service = DoclingMcp::new(state)
394 .serve(rmcp::transport::stdio())
395 .await
396 .map_err(|e| format!("mcp: {e}"))?;
397 service
398 .waiting()
399 .await
400 .map(|_| ())
401 .map_err(|e| format!("mcp: {e}"))
402}
403
404async fn serve_http(state: Arc<State>, cfg: HttpConfig) -> Result<(), String> {
405 use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
406 use rmcp::transport::streamable_http_server::{
407 StreamableHttpServerConfig, StreamableHttpService,
408 };
409
410 let addr = tokio::net::lookup_host((cfg.host.as_str(), cfg.port))
411 .await
412 .map_err(|e| format!("--host {}: {e}", cfg.host))?
413 .next()
414 .ok_or_else(|| format!("--host {}: no address", cfg.host))?;
415 let loopback = addr.ip().is_loopback();
416 if !loopback && !cfg.allow_remote {
417 return Err(format!(
418 "refusing to bind {addr}: not a loopback address — the server reads files under \
419 its root for whoever can reach it; pass --allow-remote (and --bearer-token) to \
420 expose it"
421 ));
422 }
423 if !loopback && cfg.token.is_none() {
424 eprintln!(
425 "warning: serving {addr} without a bearer token — anyone who can reach it can \
426 read the files under the root"
427 );
428 }
429 let mut config = StreamableHttpServerConfig::default();
430 let ct = config.cancellation_token.clone();
432 config = if loopback {
433 config.with_allowed_origins(["http://localhost:*", "http://127.0.0.1:*", "http://[::1]:*"])
438 } else {
439 config.disable_allowed_hosts()
442 };
443 let service: StreamableHttpService<DoclingMcp, LocalSessionManager> =
444 StreamableHttpService::new(
445 {
446 let state = state.clone();
447 move || Ok(DoclingMcp::new(state.clone()))
448 },
449 Default::default(),
450 config,
451 );
452 let mut router = axum::Router::new().nest_service("/mcp", service);
453 if let Some(token) = cfg.token.clone() {
454 let expected: Arc<[u8]> = format!("Bearer {token}").into_bytes().into();
455 router = router.layer(axum::middleware::from_fn(
456 move |req: axum::extract::Request, next: axum::middleware::Next| {
457 let expected = expected.clone();
458 async move {
459 let given = req
460 .headers()
461 .get(axum::http::header::AUTHORIZATION)
462 .map(|v| v.as_bytes())
463 .unwrap_or_default();
464 if constant_time_eq(given, &expected) {
465 next.run(req).await
466 } else {
467 axum::response::IntoResponse::into_response((
468 axum::http::StatusCode::UNAUTHORIZED,
469 [(axum::http::header::WWW_AUTHENTICATE, "Bearer")],
470 "missing or wrong bearer token\n",
471 ))
472 }
473 }
474 },
475 ));
476 }
477 let listener = tokio::net::TcpListener::bind(addr)
478 .await
479 .map_err(|e| format!("binding {addr}: {e}"))?;
480 let bound = listener.local_addr().map_err(|e| e.to_string())?;
481 describe(&state, &format!("Streamable HTTP on http://{bound}/mcp"));
482 axum::serve(listener, router)
483 .with_graceful_shutdown(async move {
484 shutdown_signal().await;
485 ct.cancel();
486 })
487 .await
488 .map_err(|e| format!("serving {bound}: {e}"))
489}
490
491async fn shutdown_signal() {
494 #[cfg(unix)]
495 let terminate = async {
496 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
497 Ok(mut s) => {
498 s.recv().await;
499 }
500 Err(_) => std::future::pending::<()>().await,
501 }
502 };
503 #[cfg(not(unix))]
504 let terminate = std::future::pending::<()>();
505 tokio::select! {
506 _ = tokio::signal::ctrl_c() => {}
507 _ = terminate => {}
508 }
509}
510
511fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
514 if a.len() != b.len() {
515 return false;
516 }
517 a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
518}
519
520#[derive(Deserialize, schemars::JsonSchema)]
523struct ConvertToMarkdownArgs {
524 uri: String,
527}
528
529#[derive(Deserialize, schemars::JsonSchema)]
530struct ConvertArgs {
531 uri: String,
534 #[serde(default)]
542 #[schemars(transform = drop_null)]
543 to: Option<String>,
544 #[serde(default)]
547 #[schemars(transform = drop_null)]
548 images: Option<String>,
549 #[serde(default)]
556 #[schemars(transform = drop_null)]
557 options: Option<serde_json::Map<String, serde_json::Value>>,
558}
559
560fn drop_null(schema: &mut schemars::Schema) {
563 if let Some(serde_json::Value::Array(types)) = schema.get("type").cloned() {
564 let kept: Vec<_> = types.into_iter().filter(|t| t != "null").collect();
565 if let [single] = kept.as_slice() {
566 schema.insert("type".into(), single.clone());
567 }
568 }
569 if schema.get("default") == Some(&serde_json::Value::Null) {
570 schema.remove("default");
571 }
572}
573
574#[derive(Clone)]
577struct DoclingMcp {
578 state: Arc<State>,
579 tool_router: ToolRouter<Self>,
580}
581
582impl DoclingMcp {
583 fn new(state: Arc<State>) -> Self {
584 Self {
585 state,
586 tool_router: Self::tool_router(),
587 }
588 }
589
590 async fn run_conversion(
593 &self,
594 request: Request,
595 ctx: &RequestContext<RoleServer>,
596 ) -> Result<String, String> {
597 let permit = self
598 .state
599 .permits
600 .clone()
601 .acquire_owned()
602 .await
603 .map_err(|e| e.to_string())?;
604 let state = self.state.clone();
605 let mut job = tokio::task::spawn_blocking(move || {
609 let _permit = permit;
610 let result = convert(&state, &request);
611 docling::memory::release_free_memory();
614 result
615 });
616 let token = ctx.meta.get_progress_token();
617 let mut tick = tokio::time::interval(Duration::from_secs(10));
618 tick.tick().await; let mut beats = 0u32;
620 loop {
621 tokio::select! {
622 done = &mut job => {
623 return done.map_err(|e| format!("conversion worker failed: {e}"))?;
624 }
625 _ = tick.tick(), if token.is_some() => {
626 beats += 1;
627 let param = ProgressNotificationParam::new(
628 token.clone().expect("guarded"),
629 f64::from(beats),
630 )
631 .with_message(format!("converting ({}s)", beats * 10));
632 let _ = ctx.peer.notify_progress(param).await;
633 }
634 }
635 }
636 }
637}
638
639#[tool_router]
640impl DoclingMcp {
641 #[tool(annotations(
646 title = "Convert to Markdown",
647 read_only_hint = true,
648 open_world_hint = true
649 ))]
650 async fn convert_to_markdown(
651 &self,
652 Parameters(args): Parameters<ConvertToMarkdownArgs>,
653 ctx: RequestContext<RoleServer>,
654 ) -> Result<String, String> {
655 let request = Request {
656 uri: args.uri,
657 to: "md".into(),
658 images: ImageMode::Placeholder,
659 options: ConvertOptions::default(),
660 };
661 self.run_conversion(request, &ctx).await
662 }
663
664 #[tool(annotations(
669 title = "Convert document",
670 read_only_hint = true,
671 open_world_hint = true
672 ))]
673 async fn convert(
674 &self,
675 Parameters(args): Parameters<ConvertArgs>,
676 ctx: RequestContext<RoleServer>,
677 ) -> Result<String, String> {
678 let request = Request::from_args(args)?;
679 self.run_conversion(request, &ctx).await
680 }
681
682 #[tool(annotations(title = "List input formats", read_only_hint = true))]
685 async fn list_input_formats(&self) -> String {
686 let mut out = InputFormat::supported_extensions().join("\n");
687 out.push_str(if self.state.allow_url_fetch {
688 "\n\nURL inputs (http/https): enabled"
689 } else {
690 "\n\nURL inputs (http/https): disabled (the server was started without --allow-url-fetch)"
691 });
692 out
693 }
694}
695
696#[tool_handler(router = self.tool_router)]
697impl ServerHandler for DoclingMcp {
698 fn get_info(&self) -> ServerConfig {
699 let mut info = Implementation::new("docling-rs", env!("CARGO_PKG_VERSION"));
700 info.title = Some("docling.rs".into());
701 info.website_url = Some("https://github.com/docling-project/docling.rs".into());
702 ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
703 .with_server_info(info)
704 .with_instructions(format!(
705 "Converts documents to Markdown or docling JSON for reading: PDF (layout, \
706 tables, OCR), DOCX, PPTX, XLSX, HTML, EPUB, e-mail, images, audio and more. \
707 Use convert_to_markdown for text; convert for other formats or options \
708 (e.g. {{\"pages\": \"1-5\"}}). Local files: paths under {}. URLs: {}.",
709 self.state.roots_display(),
710 if self.state.allow_url_fetch {
711 "enabled"
712 } else {
713 "disabled on this server"
714 }
715 ))
716 }
717}
718
719struct Request {
723 uri: String,
724 to: String,
725 images: ImageMode,
726 options: ConvertOptions,
727}
728
729const OUTPUTS: &[&str] = &[
733 "md", "json", "html", "text", "latex", "chunks", "vtt", "pandoc",
734];
735
736impl Request {
737 fn from_args(args: ConvertArgs) -> Result<Self, String> {
738 let to = match args.to.as_deref().map(str::trim) {
739 None | Some("") | Some("md") | Some("markdown") => "md".to_string(),
740 Some(t) if OUTPUTS.contains(&t) => t.to_string(),
741 Some(t) => {
742 return Err(format!(
743 "unknown to={t:?} (expected one of: {})",
744 OUTPUTS.join(", ")
745 ))
746 }
747 };
748 let images = match args.images.as_deref().map(str::trim) {
749 None | Some("") | Some("placeholder") => ImageMode::Placeholder,
750 Some("embedded") => ImageMode::Embedded,
751 Some(other) => {
752 return Err(format!(
753 "unknown images={other:?} (expected: placeholder, embedded)"
754 ))
755 }
756 };
757 let options = match args.options {
758 None => ConvertOptions::default(),
759 Some(map) => {
760 let unknown = ConvertOptions::unknown_keys(map.keys().map(String::as_str), &[]);
761 if let Some(key) = unknown.first() {
762 return Err(format!(
763 "options: unknown field `{key}` (see docs/OPTIONS.md for the list)"
764 ));
765 }
766 ConvertOptions::from_loose_json(map).map_err(|e| format!("options: {e}"))?
769 }
770 };
771 Ok(Self {
772 uri: args.uri,
773 to,
774 images,
775 options,
776 })
777 }
778}
779
780fn convert(state: &State, request: &Request) -> Result<String, String> {
783 let source = resolve_source(state, &request.uri)?;
784 let mut options = guard_options(state, &request.options)?;
785 if request.to == "md" && options.compact_tables.is_none() {
789 options.compact_tables = Some(true);
790 }
791 let document = convert_document(state, &source, &options)?;
792 render(&document, &request.to, request.images)
793}
794
795fn convert_document(
796 state: &State,
797 source: &SourceDocument,
798 options: &ConvertOptions,
799) -> Result<docling::DoclingDocument, String> {
800 let converter = options
801 .apply(DocumentConverter::new().warm_pipeline(&state.warm))
802 .map_err(|e| e.to_string())?;
803 if let Some(vlm) = options.vlm_options().map_err(|e| e.to_string())? {
805 let mut document = docling::vlm::convert_vlm(source, &vlm).map_err(|e| e.to_string())?;
806 converter.finish_document(&mut document);
807 return Ok(document);
808 }
809 match converter.convert(source.clone()) {
810 Ok(result) => Ok(result.document),
811 Err(e)
816 if source.format == InputFormat::Pdf
817 && options.text_layer_only.is_none()
818 && e.to_string().contains("model not found") =>
819 {
820 if !state.warned_text_layer.swap(true, Ordering::Relaxed) {
821 eprintln!(
822 "{}: the layout models are missing, so PDFs convert from their text \
823 layer (no layout analysis, table structure or OCR). {}",
824 state.command, state.models_hint
825 );
826 }
827 let retry = DocumentConverter::new().text_layer_only(true);
830 let converter = options.apply(retry).map_err(|e| e.to_string())?;
831 converter
832 .convert(source.clone())
833 .map(|r| r.document)
834 .map_err(|e| e.to_string())
835 }
836 Err(e) => Err(e.to_string()),
837 }
838}
839
840fn render(
841 document: &docling::DoclingDocument,
842 to: &str,
843 images: ImageMode,
844) -> Result<String, String> {
845 Ok(match to {
846 "md" => match images {
847 ImageMode::Placeholder => document.export_to_markdown(),
848 mode => document.export_to_markdown_with_images(mode, "artifacts").0,
849 },
850 "json" => document.export_to_json(),
851 "html" => document.export_to_html(),
852 "text" => document.export_to_text(),
853 "latex" => document.export_to_latex(),
854 "vtt" => document.export_to_vtt(),
855 "chunks" => {
856 let mut warn = |msg: String| eprintln!("warning: {msg}");
857 let records = docling::chunks::chunk_records(document, &mut warn);
858 serde_json::to_string_pretty(&records).map_err(|e| e.to_string())?
859 }
860 "pandoc" => {
861 document
862 .export_to_pandoc_json_with(&docling::pandoc::PandocExportOptions {
863 image_mode: images,
864 ..Default::default()
865 })
866 .map_err(|e| e.to_string())?
867 .0
868 }
869 other => return Err(format!("unknown to={other:?}")),
870 })
871}
872
873fn guard_options(state: &State, options: &ConvertOptions) -> Result<ConvertOptions, String> {
877 let mut o = options.clone();
878 o.validate().map_err(|e| e.to_string())?;
879 let requested = o.image_sources().map_err(|e| e.to_string())?.or_else(|| {
883 o.fetch_images
884 .filter(|&f| f)
885 .map(|_| docling::ImageSources::Remote)
886 });
887 if requested == Some(docling::ImageSources::Remote) && !state.allow_url_fetch {
888 o.image_sources = Some(docling::ImageSources::Embedded.to_string());
889 o.fetch_images = Some(false);
890 }
891 if let Some(endpoint) = o.vlm_endpoint.as_deref().filter(|v| !v.trim().is_empty()) {
892 if !state.allow_url_fetch {
893 return Err(
894 "a request-supplied vlm_endpoint needs the server started with \
895 --allow-url-fetch (or pin it with DOCLING_RS_VLM_ENDPOINT)"
896 .into(),
897 );
898 }
899 docling::fetch::check_outbound_url(endpoint, false)?;
900 }
901 if o.use_web_browser == Some(true) && !state.allow_url_fetch {
902 return Err(
903 "use_web_browser loads the page's remote resources; it needs the server \
904 started with --allow-url-fetch"
905 .into(),
906 );
907 }
908 if let Some(dir) = o.xbrl_taxonomy.as_deref() {
909 o.xbrl_taxonomy = Some(local_path(state, Path::new(dir))?.display().to_string());
910 }
911 if let Some(layout) = o.ebcdic_layout.as_deref() {
913 if !layout.trim_start().starts_with('{') {
914 o.ebcdic_layout = Some(local_path(state, Path::new(layout))?.display().to_string());
915 }
916 }
917 if let Some(model) = o.asr_model.as_deref() {
919 if model.contains(['/', '\\']) || model.contains("..") {
920 return Err(format!("asr_model must be a preset name, got {model:?}"));
921 }
922 }
923 Ok(o)
924}
925
926fn resolve_source(state: &State, uri: &str) -> Result<SourceDocument, String> {
929 let uri = uri.trim();
930 if uri.is_empty() {
931 return Err("uri is empty".into());
932 }
933 let lower = uri.get(..8).unwrap_or(uri).to_ascii_lowercase();
934 if lower.starts_with("data:") {
935 return docling::fetch::from_data_url(uri).map_err(|e| e.to_string());
936 }
937 if lower.starts_with("http://") || lower.starts_with("https://") {
938 if !state.allow_url_fetch {
939 return Err(format!(
940 "fetching URLs is disabled on this server (it was started without \
941 --allow-url-fetch); pass a local path or file: URI under {} instead",
942 state.roots_display()
943 ));
944 }
945 return SourceDocument::from_url(uri, &state.fetch_policy).map_err(|e| e.to_string());
946 }
947 let path = if lower.starts_with("file:") {
948 docling::fetch::file_url_path(uri)?
949 } else if let Some(scheme) = uri_scheme(uri) {
950 return Err(format!(
951 "unsupported URI scheme {scheme:?} (expected http, https, file, data, or a path)"
952 ));
953 } else {
954 PathBuf::from(uri)
955 };
956 let path = local_path(state, &path)?;
957 if path.is_dir() {
958 return Err(format!("{} is a directory, not a document", path.display()));
959 }
960 match SourceDocument::from_file(&path) {
961 Ok(source) => Ok(source),
962 Err(_) => {
964 let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
965 let name = path
966 .file_name()
967 .and_then(|n| n.to_str())
968 .unwrap_or("document");
969 let mut source = SourceDocument::from_named_bytes(name, bytes, None)
970 .map_err(|e| format!("{}: {e}", path.display()))?;
971 source.path = Some(path);
972 Ok(source)
973 }
974 }
975}
976
977fn fetch_policy() -> docling::FetchPolicy {
984 docling::FetchPolicy {
985 max_redirects: 5,
986 ..docling::FetchPolicy::default()
987 }
988}
989
990fn uri_scheme(uri: &str) -> Option<&str> {
993 let (scheme, _) = uri.split_once(':')?;
994 let valid = scheme.len() >= 2
995 && scheme.starts_with(|c: char| c.is_ascii_alphabetic())
996 && scheme
997 .chars()
998 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
999 valid.then_some(scheme)
1000}
1001
1002fn local_path(state: &State, path: &Path) -> Result<PathBuf, String> {
1010 let joined = if path.is_absolute() {
1011 path.to_path_buf()
1012 } else {
1013 state.roots[0].canonical.join(path)
1014 };
1015 let outside = || {
1016 format!(
1017 "{} is outside the server's root ({}); start the server with --root to allow it",
1018 path.display(),
1019 state.roots_display()
1020 )
1021 };
1022 let lexical = normalize(&joined);
1023 let under = |p: &Path| {
1024 state
1025 .roots
1026 .iter()
1027 .any(|r| p.starts_with(&r.given) || p.starts_with(&r.canonical))
1028 };
1029 if !under(&lexical) {
1030 return Err(outside());
1031 }
1032 let canonical = std::fs::canonicalize(&lexical)
1033 .map_err(|e| format!("cannot open {}: {e}", path.display()))?;
1034 if !state
1035 .roots
1036 .iter()
1037 .any(|r| canonical.starts_with(&r.canonical))
1038 {
1039 return Err(outside());
1040 }
1041 Ok(canonical)
1042}
1043
1044fn normalize(path: &Path) -> PathBuf {
1046 let mut out = PathBuf::new();
1047 for component in path.components() {
1048 match component {
1049 Component::ParentDir => {
1050 out.pop();
1051 }
1052 Component::CurDir => {}
1053 other => out.push(other),
1054 }
1055 }
1056 out
1057}
1058
1059#[cfg(test)]
1063mod tests {
1064 use super::*;
1065
1066 fn state(root: &Path, allow_url_fetch: bool) -> State {
1067 State {
1068 roots: vec![Root::new(root.to_str().unwrap()).unwrap()],
1069 allow_url_fetch,
1070 fetch_policy: fetch_policy(),
1071 permits: Arc::new(tokio::sync::Semaphore::new(1)),
1072 warned_text_layer: AtomicBool::new(false),
1073 command: Launcher::CLI.command.to_string(),
1074 models_hint: Launcher::CLI.models_hint.to_string(),
1075 warm: docling::WarmPipeline::new(),
1076 }
1077 }
1078
1079 fn fixtures() -> PathBuf {
1080 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/data")
1081 }
1082
1083 #[test]
1084 fn paths_stay_under_the_root() {
1085 let root = fixtures().join("md");
1086 let s = state(&root, false);
1087 let ok = local_path(&s, Path::new("sources")).unwrap();
1089 assert!(ok.ends_with("md/sources"));
1090 for escape in [
1092 "../docx/sources",
1093 "sources/../../docx",
1094 "/etc/passwd",
1095 "/nonexistent/x.pdf",
1096 ] {
1097 let err = local_path(&s, Path::new(escape)).unwrap_err();
1098 assert!(err.contains("outside the server's root"), "{escape}: {err}");
1099 }
1100 }
1101
1102 #[cfg(unix)]
1105 #[test]
1106 fn a_root_behind_a_symlink_takes_both_spellings() {
1107 let dir = std::env::temp_dir().join(format!("docling-mcp-link-{}", std::process::id()));
1108 let _ = std::fs::remove_dir_all(&dir);
1109 std::fs::create_dir_all(&dir).unwrap();
1110 let link = dir.join("docs");
1111 std::os::unix::fs::symlink(fixtures().join("md"), &link).unwrap();
1112 let s = state(&link, false);
1113 let via_link = link.join("sources");
1114 assert!(local_path(&s, &via_link).is_ok());
1115 let via_target = fixtures().join("md/sources");
1116 assert!(local_path(&s, &std::fs::canonicalize(via_target).unwrap()).is_ok());
1117 assert!(local_path(&s, &dir.join("docs/../elsewhere")).is_err());
1118 std::fs::remove_dir_all(&dir).unwrap();
1119 }
1120
1121 #[cfg(unix)]
1122 #[test]
1123 fn a_symlink_cannot_lead_out_of_the_root() {
1124 let dir = std::env::temp_dir().join(format!("docling-mcp-{}", std::process::id()));
1125 let _ = std::fs::remove_dir_all(&dir);
1126 std::fs::create_dir_all(&dir).unwrap();
1127 std::os::unix::fs::symlink(fixtures().join("docx"), dir.join("out")).unwrap();
1128 let s = state(&dir, false);
1129 let err = local_path(&s, Path::new("out/sources")).unwrap_err();
1130 assert!(err.contains("outside the server's root"), "{err}");
1131 std::fs::remove_dir_all(&dir).unwrap();
1132 }
1133
1134 #[test]
1135 fn uri_kinds() {
1136 assert_eq!(uri_scheme("s3://bucket/x.pdf"), Some("s3"));
1137 assert_eq!(uri_scheme("ftp://h/x"), Some("ftp"));
1138 assert_eq!(uri_scheme("C:\\docs\\a.pdf"), None);
1139 assert_eq!(uri_scheme("report.pdf"), None);
1140 let s = state(&fixtures(), false);
1141 let err = resolve_source(&s, "ftp://example.com/a.pdf").unwrap_err();
1142 assert!(err.contains("unsupported URI scheme"), "{err}");
1143 let err = resolve_source(&s, "https://example.com/a.pdf").unwrap_err();
1144 assert!(err.contains("--allow-url-fetch"), "{err}");
1145 let err = resolve_source(&s, "docx").unwrap_err();
1146 assert!(err.contains("is a directory"), "{err}");
1147 }
1148
1149 #[test]
1152 fn data_uris_decode() {
1153 let s = state(&fixtures(), false);
1154 let src = resolve_source(&s, "data:text/markdown;base64,IyBUaXRsZQoKSGVsbG8=").unwrap();
1155 assert_eq!(src.format, InputFormat::Md);
1156 assert_eq!(src.bytes, b"# Title\n\nHello");
1157 let src = resolve_source(&s, "DATA:text/html,%3Ch1%3EHi%3C/h1%3E").unwrap();
1158 assert_eq!(src.format, InputFormat::Html);
1159 assert_eq!(src.bytes, b"<h1>Hi</h1>");
1160 let src = resolve_source(&s, "data:;base64,JVBERi0xLjQK").unwrap();
1162 assert_eq!(src.format, InputFormat::Pdf);
1163 let src = resolve_source(&s, "data:text/csv;charset=shift_jis,a,b").unwrap();
1164 assert_eq!(src.encoding.as_deref(), Some("shift_jis"));
1165 assert!(resolve_source(&s, "data:text/plain").is_err());
1166 let src = resolve_source(&s, "data:application/octet-stream,xyz").unwrap();
1168 assert_eq!(src.format, InputFormat::Text);
1169 assert!(resolve_source(&s, "data:application/octet-stream;base64,AAECAwQF").is_err());
1170 }
1171
1172 #[test]
1173 fn options_follow_the_operator_policy() {
1174 let s = state(&fixtures(), false);
1175 let opts: ConvertOptions =
1176 serde_json::from_value(serde_json::json!({"image_sources": "remote"})).unwrap();
1177 let guarded = guard_options(&s, &opts).unwrap();
1178 assert_eq!(guarded.image_sources.as_deref(), Some("embedded"));
1179 let opts: ConvertOptions =
1180 serde_json::from_value(serde_json::json!({"vlm_endpoint": "http://h/v1"})).unwrap();
1181 assert!(guard_options(&s, &opts)
1182 .unwrap_err()
1183 .contains("--allow-url-fetch"));
1184 let opts: ConvertOptions =
1185 serde_json::from_value(serde_json::json!({"xbrl_taxonomy": "/etc"})).unwrap();
1186 assert!(guard_options(&s, &opts).unwrap_err().contains("outside"));
1187 let opts: ConvertOptions =
1188 serde_json::from_value(serde_json::json!({"asr_model": "../../x"})).unwrap();
1189 assert!(guard_options(&s, &opts)
1190 .unwrap_err()
1191 .contains("preset name"));
1192 let s = state(&fixtures(), true);
1194 let opts: ConvertOptions =
1195 serde_json::from_value(serde_json::json!({"fetch_images": true})).unwrap();
1196 let guarded = guard_options(&s, &opts).unwrap();
1197 assert_ne!(guarded.image_sources.as_deref(), Some("embedded"));
1198 }
1199
1200 #[test]
1201 fn requests_validate_their_arguments() {
1202 let args = |v: serde_json::Value| serde_json::from_value::<ConvertArgs>(v).unwrap();
1203 let r = Request::from_args(args(serde_json::json!({"uri": "a.docx"}))).unwrap();
1204 assert_eq!(r.to, "md");
1205 let err = Request::from_args(args(serde_json::json!({"uri": "a", "to": "dclx"})))
1206 .map(|_| ())
1207 .unwrap_err();
1208 assert!(err.contains("unknown to"), "{err}");
1209 let err = Request::from_args(args(
1210 serde_json::json!({"uri": "a", "options": {"text_layer_onyl": true}}),
1211 ))
1212 .map(|_| ())
1213 .unwrap_err();
1214 assert!(err.contains("unknown field `text_layer_onyl`"), "{err}");
1215 let r = Request::from_args(args(
1216 serde_json::json!({"uri": "a", "to": "json", "options": {"pages": "1-2"}}),
1217 ))
1218 .unwrap();
1219 assert_eq!(r.options.pages.as_deref(), Some("1-2"));
1220 let r = Request::from_args(args(serde_json::json!({
1222 "uri": "a",
1223 "options": {"force_full_page_ocr": "true", "ocr_scale": "1.5", "pages": "3", "video_frames": 2}
1224 })))
1225 .unwrap();
1226 assert_eq!(r.options.force_full_page_ocr, Some(true));
1227 assert_eq!(r.options.ocr_scale, Some(1.5));
1228 assert_eq!(r.options.pages.as_deref(), Some("3"));
1229 assert_eq!(r.options.video_frames, Some(2));
1230 let err = Request::from_args(args(
1231 serde_json::json!({"uri": "a", "options": {"force_full_page_ocr": "maybe"}}),
1232 ))
1233 .map(|_| ())
1234 .unwrap_err();
1235 assert!(
1236 err.contains("force_full_page_ocr: cannot read \"maybe\""),
1237 "{err}"
1238 );
1239 }
1240
1241 #[test]
1242 fn converts_a_docx_to_compact_markdown() {
1243 let s = state(&fixtures(), false);
1244 let request = Request {
1245 uri: "docx/sources/lorem_ipsum.docx".into(),
1246 to: "md".into(),
1247 images: ImageMode::Placeholder,
1248 options: ConvertOptions::default(),
1249 };
1250 let md = convert(&s, &request).unwrap();
1251 assert!(md.contains("Lorem ipsum"), "{md}");
1252 }
1253
1254 #[test]
1257 fn calls_on_the_warm_pipeline_keep_their_own_options() {
1258 let warm = state(&fixtures(), false);
1259 let request = |pages: Option<&str>| Request {
1260 uri: "pdf/sources/2206.01062.pdf".into(),
1261 to: "json".into(),
1262 images: ImageMode::Placeholder,
1263 options: ConvertOptions {
1264 text_layer_only: Some(true),
1265 pages: pages.map(str::to_string),
1266 ..Default::default()
1267 },
1268 };
1269 for pages in [Some("2-3"), None, Some("1-1"), None] {
1270 let cold = convert(&state(&fixtures(), false), &request(pages)).unwrap();
1271 assert_eq!(
1272 convert(&warm, &request(pages)).unwrap(),
1273 cold,
1274 "pages {pages:?}"
1275 );
1276 }
1277 }
1278
1279 #[test]
1281 fn flags_accept_the_equals_spelling() {
1282 let v = |a: &[&str]| split_flag_values(a.iter().map(|s| s.to_string()).collect());
1283 assert_eq!(
1284 v(&[
1285 "--root=/a",
1286 "--allow-url-fetch=true",
1287 "--http=false",
1288 "--port=9"
1289 ])
1290 .unwrap(),
1291 ["--root", "/a", "--allow-url-fetch", "--port", "9"]
1292 );
1293 assert_eq!(v(&["--root", "/a=b"]).unwrap(), ["--root", "/a=b"]);
1294 assert!(v(&["--allow-url-fetch=maybe"]).is_err());
1295 }
1296
1297 #[test]
1300 fn tool_schemas_are_portable() {
1301 let schema = serde_json::to_value(schemars::schema_for!(ConvertArgs)).unwrap();
1302 assert_eq!(schema["required"], serde_json::json!(["uri"]));
1303 for (name, ty) in [
1304 ("to", "string"),
1305 ("images", "string"),
1306 ("options", "object"),
1307 ] {
1308 let prop = &schema["properties"][name];
1309 assert_eq!(prop["type"], ty, "{name}: {prop}");
1310 assert!(prop.get("default").is_none(), "{name}: {prop}");
1311 }
1312 let args: ConvertArgs = serde_json::from_value(
1314 serde_json::json!({"uri": "a.md", "to": null, "images": null, "options": null}),
1315 )
1316 .unwrap();
1317 assert_eq!(Request::from_args(args).unwrap().to, "md");
1318 }
1319
1320 #[test]
1321 fn bearer_comparison() {
1322 assert!(constant_time_eq(b"Bearer abc", b"Bearer abc"));
1323 assert!(!constant_time_eq(b"Bearer abd", b"Bearer abc"));
1324 assert!(!constant_time_eq(b"Bearer ab", b"Bearer abc"));
1325 assert!(!constant_time_eq(b"", b"Bearer abc"));
1326 }
1327}