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)]
555 #[schemars(transform = drop_null)]
556 options: Option<serde_json::Map<String, serde_json::Value>>,
557}
558
559fn drop_null(schema: &mut schemars::Schema) {
562 if let Some(serde_json::Value::Array(types)) = schema.get("type").cloned() {
563 let kept: Vec<_> = types.into_iter().filter(|t| t != "null").collect();
564 if let [single] = kept.as_slice() {
565 schema.insert("type".into(), single.clone());
566 }
567 }
568 if schema.get("default") == Some(&serde_json::Value::Null) {
569 schema.remove("default");
570 }
571}
572
573#[derive(Clone)]
576struct DoclingMcp {
577 state: Arc<State>,
578 tool_router: ToolRouter<Self>,
579}
580
581impl DoclingMcp {
582 fn new(state: Arc<State>) -> Self {
583 Self {
584 state,
585 tool_router: Self::tool_router(),
586 }
587 }
588
589 async fn run_conversion(
592 &self,
593 request: Request,
594 ctx: &RequestContext<RoleServer>,
595 ) -> Result<String, String> {
596 let permit = self
597 .state
598 .permits
599 .clone()
600 .acquire_owned()
601 .await
602 .map_err(|e| e.to_string())?;
603 let state = self.state.clone();
604 let mut job = tokio::task::spawn_blocking(move || {
608 let _permit = permit;
609 let result = convert(&state, &request);
610 docling::memory::release_free_memory();
613 result
614 });
615 let token = ctx.meta.get_progress_token();
616 let mut tick = tokio::time::interval(Duration::from_secs(10));
617 tick.tick().await; let mut beats = 0u32;
619 loop {
620 tokio::select! {
621 done = &mut job => {
622 return done.map_err(|e| format!("conversion worker failed: {e}"))?;
623 }
624 _ = tick.tick(), if token.is_some() => {
625 beats += 1;
626 let param = ProgressNotificationParam::new(
627 token.clone().expect("guarded"),
628 f64::from(beats),
629 )
630 .with_message(format!("converting ({}s)", beats * 10));
631 let _ = ctx.peer.notify_progress(param).await;
632 }
633 }
634 }
635 }
636}
637
638#[tool_router]
639impl DoclingMcp {
640 #[tool(annotations(
645 title = "Convert to Markdown",
646 read_only_hint = true,
647 open_world_hint = true
648 ))]
649 async fn convert_to_markdown(
650 &self,
651 Parameters(args): Parameters<ConvertToMarkdownArgs>,
652 ctx: RequestContext<RoleServer>,
653 ) -> Result<String, String> {
654 let request = Request {
655 uri: args.uri,
656 to: "md".into(),
657 images: ImageMode::Placeholder,
658 options: ConvertOptions::default(),
659 };
660 self.run_conversion(request, &ctx).await
661 }
662
663 #[tool(annotations(
668 title = "Convert document",
669 read_only_hint = true,
670 open_world_hint = true
671 ))]
672 async fn convert(
673 &self,
674 Parameters(args): Parameters<ConvertArgs>,
675 ctx: RequestContext<RoleServer>,
676 ) -> Result<String, String> {
677 let request = Request::from_args(args)?;
678 self.run_conversion(request, &ctx).await
679 }
680
681 #[tool(annotations(title = "List input formats", read_only_hint = true))]
684 async fn list_input_formats(&self) -> String {
685 let mut out = InputFormat::supported_extensions().join("\n");
686 out.push_str(if self.state.allow_url_fetch {
687 "\n\nURL inputs (http/https): enabled"
688 } else {
689 "\n\nURL inputs (http/https): disabled (the server was started without --allow-url-fetch)"
690 });
691 out
692 }
693}
694
695#[tool_handler(router = self.tool_router)]
696impl ServerHandler for DoclingMcp {
697 fn get_info(&self) -> ServerConfig {
698 let mut info = Implementation::new("docling-rs", env!("CARGO_PKG_VERSION"));
699 info.title = Some("docling.rs".into());
700 info.website_url = Some("https://github.com/docling-project/docling.rs".into());
701 ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
702 .with_server_info(info)
703 .with_instructions(format!(
704 "Converts documents to Markdown or docling JSON for reading: PDF (layout, \
705 tables, OCR), DOCX, PPTX, XLSX, HTML, EPUB, e-mail, images, audio and more. \
706 Use convert_to_markdown for text; convert for other formats or options \
707 (e.g. {{\"pages\": \"1-5\"}}). Local files: paths under {}. URLs: {}.",
708 self.state.roots_display(),
709 if self.state.allow_url_fetch {
710 "enabled"
711 } else {
712 "disabled on this server"
713 }
714 ))
715 }
716}
717
718struct Request {
722 uri: String,
723 to: String,
724 images: ImageMode,
725 options: ConvertOptions,
726}
727
728const OUTPUTS: &[&str] = &[
732 "md", "json", "html", "text", "latex", "chunks", "vtt", "pandoc",
733];
734
735impl Request {
736 fn from_args(args: ConvertArgs) -> Result<Self, String> {
737 let to = match args.to.as_deref().map(str::trim) {
738 None | Some("") | Some("md") | Some("markdown") => "md".to_string(),
739 Some(t) if OUTPUTS.contains(&t) => t.to_string(),
740 Some(t) => {
741 return Err(format!(
742 "unknown to={t:?} (expected one of: {})",
743 OUTPUTS.join(", ")
744 ))
745 }
746 };
747 let images = match args.images.as_deref().map(str::trim) {
748 None | Some("") | Some("placeholder") => ImageMode::Placeholder,
749 Some("embedded") => ImageMode::Embedded,
750 Some(other) => {
751 return Err(format!(
752 "unknown images={other:?} (expected: placeholder, embedded)"
753 ))
754 }
755 };
756 let options = match args.options {
757 None => ConvertOptions::default(),
758 Some(map) => {
759 let unknown = ConvertOptions::unknown_keys(map.keys().map(String::as_str), &[]);
760 if let Some(key) = unknown.first() {
761 return Err(format!(
762 "options: unknown field `{key}` (see docs/OPTIONS.md for the list)"
763 ));
764 }
765 serde_json::from_value(serde_json::Value::Object(map))
766 .map_err(|e| format!("options: {e}"))?
767 }
768 };
769 Ok(Self {
770 uri: args.uri,
771 to,
772 images,
773 options,
774 })
775 }
776}
777
778fn convert(state: &State, request: &Request) -> Result<String, String> {
781 let source = resolve_source(state, &request.uri)?;
782 let mut options = guard_options(state, &request.options)?;
783 if request.to == "md" && options.compact_tables.is_none() {
787 options.compact_tables = Some(true);
788 }
789 let document = convert_document(state, &source, &options)?;
790 render(&document, &request.to, request.images)
791}
792
793fn convert_document(
794 state: &State,
795 source: &SourceDocument,
796 options: &ConvertOptions,
797) -> Result<docling::DoclingDocument, String> {
798 let converter = options
799 .apply(DocumentConverter::new().warm_pipeline(&state.warm))
800 .map_err(|e| e.to_string())?;
801 if let Some(vlm) = options.vlm_options().map_err(|e| e.to_string())? {
803 let mut document = docling::vlm::convert_vlm(source, &vlm).map_err(|e| e.to_string())?;
804 converter.finish_document(&mut document);
805 return Ok(document);
806 }
807 match converter.convert(source.clone()) {
808 Ok(result) => Ok(result.document),
809 Err(e)
814 if source.format == InputFormat::Pdf
815 && options.text_layer_only.is_none()
816 && e.to_string().contains("model not found") =>
817 {
818 if !state.warned_text_layer.swap(true, Ordering::Relaxed) {
819 eprintln!(
820 "{}: the layout models are missing, so PDFs convert from their text \
821 layer (no layout analysis, table structure or OCR). {}",
822 state.command, state.models_hint
823 );
824 }
825 let retry = DocumentConverter::new().text_layer_only(true);
828 let converter = options.apply(retry).map_err(|e| e.to_string())?;
829 converter
830 .convert(source.clone())
831 .map(|r| r.document)
832 .map_err(|e| e.to_string())
833 }
834 Err(e) => Err(e.to_string()),
835 }
836}
837
838fn render(
839 document: &docling::DoclingDocument,
840 to: &str,
841 images: ImageMode,
842) -> Result<String, String> {
843 Ok(match to {
844 "md" => match images {
845 ImageMode::Placeholder => document.export_to_markdown(),
846 mode => document.export_to_markdown_with_images(mode, "artifacts").0,
847 },
848 "json" => document.export_to_json(),
849 "html" => document.export_to_html(),
850 "text" => document.export_to_text(),
851 "latex" => document.export_to_latex(),
852 "vtt" => document.export_to_vtt(),
853 "chunks" => {
854 let mut warn = |msg: String| eprintln!("warning: {msg}");
855 let records = docling::chunks::chunk_records(document, &mut warn);
856 serde_json::to_string_pretty(&records).map_err(|e| e.to_string())?
857 }
858 "pandoc" => {
859 document
860 .export_to_pandoc_json_with(&docling::pandoc::PandocExportOptions {
861 image_mode: images,
862 ..Default::default()
863 })
864 .map_err(|e| e.to_string())?
865 .0
866 }
867 other => return Err(format!("unknown to={other:?}")),
868 })
869}
870
871fn guard_options(state: &State, options: &ConvertOptions) -> Result<ConvertOptions, String> {
875 let mut o = options.clone();
876 o.validate().map_err(|e| e.to_string())?;
877 let requested = o.image_sources().map_err(|e| e.to_string())?.or_else(|| {
881 o.fetch_images
882 .filter(|&f| f)
883 .map(|_| docling::ImageSources::Remote)
884 });
885 if requested == Some(docling::ImageSources::Remote) && !state.allow_url_fetch {
886 o.image_sources = Some(docling::ImageSources::Embedded.to_string());
887 o.fetch_images = Some(false);
888 }
889 if let Some(endpoint) = o.vlm_endpoint.as_deref().filter(|v| !v.trim().is_empty()) {
890 if !state.allow_url_fetch {
891 return Err(
892 "a request-supplied vlm_endpoint needs the server started with \
893 --allow-url-fetch (or pin it with DOCLING_RS_VLM_ENDPOINT)"
894 .into(),
895 );
896 }
897 docling::fetch::check_outbound_url(endpoint, false)?;
898 }
899 if o.use_web_browser == Some(true) && !state.allow_url_fetch {
900 return Err(
901 "use_web_browser loads the page's remote resources; it needs the server \
902 started with --allow-url-fetch"
903 .into(),
904 );
905 }
906 if let Some(dir) = o.xbrl_taxonomy.as_deref() {
907 o.xbrl_taxonomy = Some(local_path(state, Path::new(dir))?.display().to_string());
908 }
909 if let Some(layout) = o.ebcdic_layout.as_deref() {
911 if !layout.trim_start().starts_with('{') {
912 o.ebcdic_layout = Some(local_path(state, Path::new(layout))?.display().to_string());
913 }
914 }
915 if let Some(model) = o.asr_model.as_deref() {
917 if model.contains(['/', '\\']) || model.contains("..") {
918 return Err(format!("asr_model must be a preset name, got {model:?}"));
919 }
920 }
921 Ok(o)
922}
923
924fn resolve_source(state: &State, uri: &str) -> Result<SourceDocument, String> {
927 let uri = uri.trim();
928 if uri.is_empty() {
929 return Err("uri is empty".into());
930 }
931 let lower = uri.get(..8).unwrap_or(uri).to_ascii_lowercase();
932 if lower.starts_with("data:") {
933 return docling::fetch::from_data_url(uri).map_err(|e| e.to_string());
934 }
935 if lower.starts_with("http://") || lower.starts_with("https://") {
936 if !state.allow_url_fetch {
937 return Err(format!(
938 "fetching URLs is disabled on this server (it was started without \
939 --allow-url-fetch); pass a local path or file: URI under {} instead",
940 state.roots_display()
941 ));
942 }
943 return SourceDocument::from_url(uri, &state.fetch_policy).map_err(|e| e.to_string());
944 }
945 let path = if lower.starts_with("file:") {
946 docling::fetch::file_url_path(uri)?
947 } else if let Some(scheme) = uri_scheme(uri) {
948 return Err(format!(
949 "unsupported URI scheme {scheme:?} (expected http, https, file, data, or a path)"
950 ));
951 } else {
952 PathBuf::from(uri)
953 };
954 let path = local_path(state, &path)?;
955 if path.is_dir() {
956 return Err(format!("{} is a directory, not a document", path.display()));
957 }
958 match SourceDocument::from_file(&path) {
959 Ok(source) => Ok(source),
960 Err(_) => {
962 let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
963 let name = path
964 .file_name()
965 .and_then(|n| n.to_str())
966 .unwrap_or("document");
967 let mut source = SourceDocument::from_named_bytes(name, bytes, None)
968 .map_err(|e| format!("{}: {e}", path.display()))?;
969 source.path = Some(path);
970 Ok(source)
971 }
972 }
973}
974
975fn fetch_policy() -> docling::FetchPolicy {
982 docling::FetchPolicy {
983 max_redirects: 5,
984 ..docling::FetchPolicy::default()
985 }
986}
987
988fn uri_scheme(uri: &str) -> Option<&str> {
991 let (scheme, _) = uri.split_once(':')?;
992 let valid = scheme.len() >= 2
993 && scheme.starts_with(|c: char| c.is_ascii_alphabetic())
994 && scheme
995 .chars()
996 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
997 valid.then_some(scheme)
998}
999
1000fn local_path(state: &State, path: &Path) -> Result<PathBuf, String> {
1008 let joined = if path.is_absolute() {
1009 path.to_path_buf()
1010 } else {
1011 state.roots[0].canonical.join(path)
1012 };
1013 let outside = || {
1014 format!(
1015 "{} is outside the server's root ({}); start the server with --root to allow it",
1016 path.display(),
1017 state.roots_display()
1018 )
1019 };
1020 let lexical = normalize(&joined);
1021 let under = |p: &Path| {
1022 state
1023 .roots
1024 .iter()
1025 .any(|r| p.starts_with(&r.given) || p.starts_with(&r.canonical))
1026 };
1027 if !under(&lexical) {
1028 return Err(outside());
1029 }
1030 let canonical = std::fs::canonicalize(&lexical)
1031 .map_err(|e| format!("cannot open {}: {e}", path.display()))?;
1032 if !state
1033 .roots
1034 .iter()
1035 .any(|r| canonical.starts_with(&r.canonical))
1036 {
1037 return Err(outside());
1038 }
1039 Ok(canonical)
1040}
1041
1042fn normalize(path: &Path) -> PathBuf {
1044 let mut out = PathBuf::new();
1045 for component in path.components() {
1046 match component {
1047 Component::ParentDir => {
1048 out.pop();
1049 }
1050 Component::CurDir => {}
1051 other => out.push(other),
1052 }
1053 }
1054 out
1055}
1056
1057#[cfg(test)]
1061mod tests {
1062 use super::*;
1063
1064 fn state(root: &Path, allow_url_fetch: bool) -> State {
1065 State {
1066 roots: vec![Root::new(root.to_str().unwrap()).unwrap()],
1067 allow_url_fetch,
1068 fetch_policy: fetch_policy(),
1069 permits: Arc::new(tokio::sync::Semaphore::new(1)),
1070 warned_text_layer: AtomicBool::new(false),
1071 command: Launcher::CLI.command.to_string(),
1072 models_hint: Launcher::CLI.models_hint.to_string(),
1073 warm: docling::WarmPipeline::new(),
1074 }
1075 }
1076
1077 fn fixtures() -> PathBuf {
1078 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/data")
1079 }
1080
1081 #[test]
1082 fn paths_stay_under_the_root() {
1083 let root = fixtures().join("md");
1084 let s = state(&root, false);
1085 let ok = local_path(&s, Path::new("sources")).unwrap();
1087 assert!(ok.ends_with("md/sources"));
1088 for escape in [
1090 "../docx/sources",
1091 "sources/../../docx",
1092 "/etc/passwd",
1093 "/nonexistent/x.pdf",
1094 ] {
1095 let err = local_path(&s, Path::new(escape)).unwrap_err();
1096 assert!(err.contains("outside the server's root"), "{escape}: {err}");
1097 }
1098 }
1099
1100 #[cfg(unix)]
1103 #[test]
1104 fn a_root_behind_a_symlink_takes_both_spellings() {
1105 let dir = std::env::temp_dir().join(format!("docling-mcp-link-{}", std::process::id()));
1106 let _ = std::fs::remove_dir_all(&dir);
1107 std::fs::create_dir_all(&dir).unwrap();
1108 let link = dir.join("docs");
1109 std::os::unix::fs::symlink(fixtures().join("md"), &link).unwrap();
1110 let s = state(&link, false);
1111 let via_link = link.join("sources");
1112 assert!(local_path(&s, &via_link).is_ok());
1113 let via_target = fixtures().join("md/sources");
1114 assert!(local_path(&s, &std::fs::canonicalize(via_target).unwrap()).is_ok());
1115 assert!(local_path(&s, &dir.join("docs/../elsewhere")).is_err());
1116 std::fs::remove_dir_all(&dir).unwrap();
1117 }
1118
1119 #[cfg(unix)]
1120 #[test]
1121 fn a_symlink_cannot_lead_out_of_the_root() {
1122 let dir = std::env::temp_dir().join(format!("docling-mcp-{}", std::process::id()));
1123 let _ = std::fs::remove_dir_all(&dir);
1124 std::fs::create_dir_all(&dir).unwrap();
1125 std::os::unix::fs::symlink(fixtures().join("docx"), dir.join("out")).unwrap();
1126 let s = state(&dir, false);
1127 let err = local_path(&s, Path::new("out/sources")).unwrap_err();
1128 assert!(err.contains("outside the server's root"), "{err}");
1129 std::fs::remove_dir_all(&dir).unwrap();
1130 }
1131
1132 #[test]
1133 fn uri_kinds() {
1134 assert_eq!(uri_scheme("s3://bucket/x.pdf"), Some("s3"));
1135 assert_eq!(uri_scheme("ftp://h/x"), Some("ftp"));
1136 assert_eq!(uri_scheme("C:\\docs\\a.pdf"), None);
1137 assert_eq!(uri_scheme("report.pdf"), None);
1138 let s = state(&fixtures(), false);
1139 let err = resolve_source(&s, "ftp://example.com/a.pdf").unwrap_err();
1140 assert!(err.contains("unsupported URI scheme"), "{err}");
1141 let err = resolve_source(&s, "https://example.com/a.pdf").unwrap_err();
1142 assert!(err.contains("--allow-url-fetch"), "{err}");
1143 let err = resolve_source(&s, "docx").unwrap_err();
1144 assert!(err.contains("is a directory"), "{err}");
1145 }
1146
1147 #[test]
1150 fn data_uris_decode() {
1151 let s = state(&fixtures(), false);
1152 let src = resolve_source(&s, "data:text/markdown;base64,IyBUaXRsZQoKSGVsbG8=").unwrap();
1153 assert_eq!(src.format, InputFormat::Md);
1154 assert_eq!(src.bytes, b"# Title\n\nHello");
1155 let src = resolve_source(&s, "DATA:text/html,%3Ch1%3EHi%3C/h1%3E").unwrap();
1156 assert_eq!(src.format, InputFormat::Html);
1157 assert_eq!(src.bytes, b"<h1>Hi</h1>");
1158 let src = resolve_source(&s, "data:;base64,JVBERi0xLjQK").unwrap();
1160 assert_eq!(src.format, InputFormat::Pdf);
1161 let src = resolve_source(&s, "data:text/csv;charset=shift_jis,a,b").unwrap();
1162 assert_eq!(src.encoding.as_deref(), Some("shift_jis"));
1163 assert!(resolve_source(&s, "data:text/plain").is_err());
1164 assert!(resolve_source(&s, "data:application/octet-stream,xyz").is_err());
1165 }
1166
1167 #[test]
1168 fn options_follow_the_operator_policy() {
1169 let s = state(&fixtures(), false);
1170 let opts: ConvertOptions =
1171 serde_json::from_value(serde_json::json!({"image_sources": "remote"})).unwrap();
1172 let guarded = guard_options(&s, &opts).unwrap();
1173 assert_eq!(guarded.image_sources.as_deref(), Some("embedded"));
1174 let opts: ConvertOptions =
1175 serde_json::from_value(serde_json::json!({"vlm_endpoint": "http://h/v1"})).unwrap();
1176 assert!(guard_options(&s, &opts)
1177 .unwrap_err()
1178 .contains("--allow-url-fetch"));
1179 let opts: ConvertOptions =
1180 serde_json::from_value(serde_json::json!({"xbrl_taxonomy": "/etc"})).unwrap();
1181 assert!(guard_options(&s, &opts).unwrap_err().contains("outside"));
1182 let opts: ConvertOptions =
1183 serde_json::from_value(serde_json::json!({"asr_model": "../../x"})).unwrap();
1184 assert!(guard_options(&s, &opts)
1185 .unwrap_err()
1186 .contains("preset name"));
1187 let s = state(&fixtures(), true);
1189 let opts: ConvertOptions =
1190 serde_json::from_value(serde_json::json!({"fetch_images": true})).unwrap();
1191 let guarded = guard_options(&s, &opts).unwrap();
1192 assert_ne!(guarded.image_sources.as_deref(), Some("embedded"));
1193 }
1194
1195 #[test]
1196 fn requests_validate_their_arguments() {
1197 let args = |v: serde_json::Value| serde_json::from_value::<ConvertArgs>(v).unwrap();
1198 let r = Request::from_args(args(serde_json::json!({"uri": "a.docx"}))).unwrap();
1199 assert_eq!(r.to, "md");
1200 let err = Request::from_args(args(serde_json::json!({"uri": "a", "to": "dclx"})))
1201 .map(|_| ())
1202 .unwrap_err();
1203 assert!(err.contains("unknown to"), "{err}");
1204 let err = Request::from_args(args(
1205 serde_json::json!({"uri": "a", "options": {"text_layer_onyl": true}}),
1206 ))
1207 .map(|_| ())
1208 .unwrap_err();
1209 assert!(err.contains("unknown field `text_layer_onyl`"), "{err}");
1210 let r = Request::from_args(args(
1211 serde_json::json!({"uri": "a", "to": "json", "options": {"pages": "1-2"}}),
1212 ))
1213 .unwrap();
1214 assert_eq!(r.options.pages.as_deref(), Some("1-2"));
1215 }
1216
1217 #[test]
1218 fn converts_a_docx_to_compact_markdown() {
1219 let s = state(&fixtures(), false);
1220 let request = Request {
1221 uri: "docx/sources/lorem_ipsum.docx".into(),
1222 to: "md".into(),
1223 images: ImageMode::Placeholder,
1224 options: ConvertOptions::default(),
1225 };
1226 let md = convert(&s, &request).unwrap();
1227 assert!(md.contains("Lorem ipsum"), "{md}");
1228 }
1229
1230 #[test]
1233 fn calls_on_the_warm_pipeline_keep_their_own_options() {
1234 let warm = state(&fixtures(), false);
1235 let request = |pages: Option<&str>| Request {
1236 uri: "pdf/sources/2206.01062.pdf".into(),
1237 to: "json".into(),
1238 images: ImageMode::Placeholder,
1239 options: ConvertOptions {
1240 text_layer_only: Some(true),
1241 pages: pages.map(str::to_string),
1242 ..Default::default()
1243 },
1244 };
1245 for pages in [Some("2-3"), None, Some("1-1"), None] {
1246 let cold = convert(&state(&fixtures(), false), &request(pages)).unwrap();
1247 assert_eq!(
1248 convert(&warm, &request(pages)).unwrap(),
1249 cold,
1250 "pages {pages:?}"
1251 );
1252 }
1253 }
1254
1255 #[test]
1257 fn flags_accept_the_equals_spelling() {
1258 let v = |a: &[&str]| split_flag_values(a.iter().map(|s| s.to_string()).collect());
1259 assert_eq!(
1260 v(&[
1261 "--root=/a",
1262 "--allow-url-fetch=true",
1263 "--http=false",
1264 "--port=9"
1265 ])
1266 .unwrap(),
1267 ["--root", "/a", "--allow-url-fetch", "--port", "9"]
1268 );
1269 assert_eq!(v(&["--root", "/a=b"]).unwrap(), ["--root", "/a=b"]);
1270 assert!(v(&["--allow-url-fetch=maybe"]).is_err());
1271 }
1272
1273 #[test]
1276 fn tool_schemas_are_portable() {
1277 let schema = serde_json::to_value(schemars::schema_for!(ConvertArgs)).unwrap();
1278 assert_eq!(schema["required"], serde_json::json!(["uri"]));
1279 for (name, ty) in [
1280 ("to", "string"),
1281 ("images", "string"),
1282 ("options", "object"),
1283 ] {
1284 let prop = &schema["properties"][name];
1285 assert_eq!(prop["type"], ty, "{name}: {prop}");
1286 assert!(prop.get("default").is_none(), "{name}: {prop}");
1287 }
1288 let args: ConvertArgs = serde_json::from_value(
1290 serde_json::json!({"uri": "a.md", "to": null, "images": null, "options": null}),
1291 )
1292 .unwrap();
1293 assert_eq!(Request::from_args(args).unwrap().to, "md");
1294 }
1295
1296 #[test]
1297 fn bearer_comparison() {
1298 assert!(constant_time_eq(b"Bearer abc", b"Bearer abc"));
1299 assert!(!constant_time_eq(b"Bearer abd", b"Bearer abc"));
1300 assert!(!constant_time_eq(b"Bearer ab", b"Bearer abc"));
1301 assert!(!constant_time_eq(b"", b"Bearer abc"));
1302 }
1303}