Skip to main content

docling_cli/
mcp.rs

1//! `docling-rs mcp` (#669): a [Model Context Protocol](https://modelcontextprotocol.io)
2//! server, so agents — Claude Desktop, Claude Code, IDE assistants — call
3//! docling.rs directly.
4//!
5//! Three tools:
6//!
7//! - `convert_to_markdown(uri)` — MarkItDown's MCP tool (`markitdown-mcp`),
8//!   same name and contract, so a config that pointed at MarkItDown keeps
9//!   working: the document behind an `http:`/`https:`/`file:`/`data:` URI as
10//!   Markdown. A plain path is accepted too.
11//! - `convert(uri, to?, images?, options?)` — any output format docling.rs
12//!   writes as text, with the conversion options every other surface takes
13//!   ([`docling::ConvertOptions`], docs/OPTIONS.md).
14//! - `list_input_formats()` — the extensions this binary converts.
15//!
16//! Transports: stdio (the default — what a desktop client launches) or
17//! Streamable HTTP with `--http` (`/mcp` on `--host`/`--port`, 127.0.0.1:3001
18//! by default), both through rmcp, the protocol's official Rust SDK.
19//!
20//! The server reads files and, when allowed, the network on behalf of a
21//! model, whose inputs may be steered by the documents it reads — so what a
22//! call can reach is the operator's decision, made on the command line:
23//!
24//! - local reads are confined to `--root DIR` (repeatable; the working
25//!   directory by default) — paths are checked lexically first, so a probe
26//!   outside the root learns nothing about what exists there, then after
27//!   resolving symlinks;
28//! - URLs are fetched only with `--allow-url-fetch`, through
29//!   [`docling::fetch`]: the SSRF block-list on every redirect hop, a size
30//!   cap (`DOCLING_RS_MAX_FETCH_BYTES`, 256 MiB) and timeouts. The options
31//!   that reach the network on their own — remote `<img src>`
32//!   (`image_sources=remote`), a request-supplied `vlm_endpoint`, the
33//!   headless browser — sit behind the same flag, docling-serve's rules;
34//!   options that name files (`xbrl_taxonomy`, a copybook path in
35//!   `ebcdic_layout`) are held to the root;
36//! - `--http` binds loopback unless `--allow-remote`, checks the `Host`
37//!   header (DNS rebinding) and the browser `Origin` on a loopback bind, and
38//!   takes an optional bearer token (`--bearer-token` /
39//!   `DOCLING_RS_MCP_TOKEN`).
40//!
41//! Conversions run on the blocking pool, at most `--concurrency` (2) at a
42//! time; a call that carries a progress token gets a heartbeat
43//! `notifications/progress` every 10 s, so clients that reset their request
44//! timeout on progress wait out a long PDF. stdout is the stdio transport's
45//! wire — everything else the process says goes to stderr.
46//!
47//! The same server runs from the `docling-rs` binary ([`run`]) and from
48//! embedders such as the Python package's `docling-rs-mcp` launcher
49//! ([`run_with`] and a [`Launcher`] naming the command and where its PDF
50//! models come from).
51
52use 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/// How the server presents itself: the command that starts it (in `--help`,
67/// usage errors and the startup line), the client configuration `--help`
68/// shows, and how to get the PDF models when they are missing. The binary
69/// uses [`Launcher::CLI`].
70#[derive(Clone, Copy, Debug)]
71pub struct Launcher<'a> {
72    /// The command line that starts the server, e.g. `docling-rs mcp`.
73    pub command: &'a str,
74    /// A Claude Desktop `mcpServers` entry that launches it.
75    pub client_config: &'a str,
76    /// The sentence that tells the operator how to fetch the layout models.
77    pub models_hint: &'a str,
78    /// Flags the launcher handles itself, listed in `--help` (empty for none).
79    pub extra_flags: &'a str,
80}
81
82impl Launcher<'static> {
83    /// The `docling-rs` binary's `mcp` subcommand.
84    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
133/// A `--root` directory in both spellings a path may arrive in: as the
134/// operator gave it (absolute, `..` folded — what an agent quotes back) and
135/// canonical (symlinks resolved: macOS's `/tmp` is `/private/tmp`, Windows
136/// adds a `\\?\` prefix).
137struct 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
156/// What the operator allowed, shared by every session.
157struct State {
158    /// The directories local reads are confined to; the first one resolves
159    /// relative paths.
160    roots: Vec<Root>,
161    allow_url_fetch: bool,
162    /// How `http(s)` inputs are fetched (only with `--allow-url-fetch`).
163    fetch_policy: docling::FetchPolicy,
164    permits: Arc<tokio::sync::Semaphore>,
165    /// The missing-models note is printed once per process.
166    warned_text_layer: AtomicBool,
167    /// The command that started the server, for the stderr notes.
168    command: String,
169    /// How to get the layout models when they are missing.
170    models_hint: String,
171    /// The PDF/image models, loaded on the first call that needs them and
172    /// kept for the next — docling-serve's warm pipeline, shared by every
173    /// session of this process.
174    warm: docling::WarmPipeline,
175}
176
177struct HttpConfig {
178    host: String,
179    port: u16,
180    allow_remote: bool,
181    token: Option<String>,
182}
183
184/// `docling-rs mcp …` — parse the flags, then serve until the client hangs
185/// up (stdio) or Ctrl-C (HTTP).
186pub fn run(args: Vec<String>) -> ExitCode {
187    ExitCode::from(run_with(args, &Launcher::CLI))
188}
189
190/// [`run`] for an embedder: the flags after the command, how the server
191/// should name itself; returns the process exit status (0, 1 on a serving
192/// error, 2 on a usage error). Call it before other threads read the
193/// environment — it defaults `DOCLING_RS_NO_ARENA`.
194pub fn run_with(args: Vec<String>, launcher: &Launcher) -> u8 {
195    // A long-lived server holding warm ONNX sessions, like `serve` (#263):
196    // the CPU arena off unless the operator says otherwise. Measured on the
197    // warm MCP server (4 vCPU, a 9-page paper and two short PDFs in turn):
198    // 2.1 GB live with the arena, 0.55 GB — the models — without, at the
199    // same latency.
200    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    // The `--http`-only flags, so a stdio launch that carries one fails
213    // loudly instead of ignoring it.
214    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    // Desktop clients often start servers in `/`: the working-directory
280    // default would then open the whole file system. That takes an explicit
281    // `--root`.
282    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    // A long-lived process: large buffers are mapped and returned, not
300    // carved from the per-thread arenas where they stay resident.
301    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    // A conversion still running for a client that hung up has no one to
321    // answer: exit now rather than wait for it.
322    runtime.shutdown_background();
323    match result {
324        Ok(()) => 0,
325        Err(e) => {
326            eprintln!("error: {e}");
327            1
328        }
329    }
330}
331
332/// The `--flag=value` spelling next to `--flag value`, so the argument
333/// lists MCP catalogs generate work (the registry's `server.json` describes
334/// a named argument as `--flag={value}`): value flags split in two, the
335/// switches take `=true` / `=false`.
336fn 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
367/// One line on stderr saying what this server may reach — the operator's
368/// check that the flags did what they meant.
369fn 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    /// The roots as the operator gave them, comma-separated.
381    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    // Ctrl-C closes the open sessions as well as the listener.
431    let ct = config.cancellation_token.clone();
432    config = if loopback {
433        // The default allowed `Host`s (localhost, 127.0.0.1, ::1) stop DNS
434        // rebinding; the `Origin` list stops a web page in the user's own
435        // browser from driving the server — requests without an `Origin`
436        // (every non-browser client) pass.
437        config.with_allowed_origins(["http://localhost:*", "http://127.0.0.1:*", "http://[::1]:*"])
438    } else {
439        // Remote clients name the server by its own host name; the bearer
440        // token is the control there.
441        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
491/// Ctrl-C, or SIGTERM on Unix — what `docker stop` sends, and a process
492/// that is a container's PID 1 ignores a signal it installed no handler for.
493async 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
511/// Byte comparison whose time does not depend on where the inputs differ —
512/// a token check must not leak its prefix through response timing.
513fn 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// --- the tools ----------------------------------------------------------------
521
522#[derive(Deserialize, schemars::JsonSchema)]
523struct ConvertToMarkdownArgs {
524    /// The document: an http: or https: URL, a file: URI, a data: URI, or
525    /// a path (relative paths resolve against the server's root).
526    uri: String,
527}
528
529#[derive(Deserialize, schemars::JsonSchema)]
530struct ConvertArgs {
531    /// The document: an http: or https: URL, a file: URI, a data: URI, or
532    /// a path (relative paths resolve against the server's root).
533    uri: String,
534    /// Output format: md (default), json (docling's DoclingDocument),
535    /// html, text, latex, chunks (RAG chunk records as JSON), vtt, pandoc
536    /// (Pandoc's JSON AST).
537    // Optional parameters are single-typed in the schema and left out of
538    // `required` — a `["string", "null"]` type union is what some clients'
539    // function-calling dialects reject (MCP Inspector's portability check);
540    // an explicit `null` still deserializes as "not given".
541    #[serde(default)]
542    #[schemars(transform = drop_null)]
543    to: Option<String>,
544    /// Pictures in md/html/pandoc output: placeholder (default) or
545    /// embedded (data: URIs).
546    #[serde(default)]
547    #[schemars(transform = drop_null)]
548    images: Option<String>,
549    /// Conversion options, docling.rs's shared set (docs/OPTIONS.md), e.g.
550    /// {"pages": "1-5"}, {"text_layer_only": true} (digital PDFs, fastest),
551    /// {"ocr_lang": "ch"}, {"force_full_page_ocr": true},
552    /// {"do_picture_ocr": true} (OCR pictures inside Office files),
553    /// {"password": "…"}, {"compact_tables": false}.
554    #[serde(default)]
555    #[schemars(transform = drop_null)]
556    options: Option<serde_json::Map<String, serde_json::Value>>,
557}
558
559/// An `Option<T>` field's schema as `T`'s: `"type": ["T", "null"]` → `"T"`,
560/// and no `"default": null` — absence already says "not given".
561fn 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/// The MCP service: one per stdio connection or HTTP session, sharing the
574/// operator's [`State`].
575#[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    /// Convert on the blocking pool under a concurrency permit, sending a
590    /// progress heartbeat while it runs when the caller asked for progress.
591    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        // The permit lives as long as the work: a call the client cancels
605        // drops this future, not the conversion already running, and must
606        // not free a slot that conversion still occupies.
607        let mut job = tokio::task::spawn_blocking(move || {
608            let _permit = permit;
609            let result = convert(&state, &request);
610            // What the conversion freed goes back to the OS, not into the
611            // allocator's arenas (`docling::memory`).
612            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; // the first tick is immediate
618        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    /// Convert a resource described by an http:, https:, file: or data: URI
641    /// (or a local path) to Markdown — PDFs with layout analysis, table
642    /// structure and OCR; Word, PowerPoint, Excel, HTML, EPUB, e-mail,
643    /// images, audio and more. MarkItDown's tool of the same name.
644    #[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    /// Convert a document (URI or path, as convert_to_markdown) to Markdown,
664    /// docling JSON, HTML, plain text, LaTeX, RAG chunks, WebVTT or Pandoc
665    /// JSON, with docling.rs's conversion options (page ranges, OCR
666    /// language and mode, text-layer-only PDFs, passwords, picture OCR, …).
667    #[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    /// The input file extensions this server converts, one per line, and
682    /// whether URL inputs are enabled.
683    #[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
718// --- the conversion -----------------------------------------------------------
719
720/// One validated tool call.
721struct Request {
722    uri: String,
723    to: String,
724    images: ImageMode,
725    options: ConvertOptions,
726}
727
728/// The `to` values the tools return — every text output of the CLI's
729/// `--to` (`dclx` is a ZIP and `images` page PNGs, neither of which fits a
730/// text result).
731const 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
778/// Resolve, convert and render one request — the blocking half of a tool
779/// call.
780fn 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    // Markdown for a model to read: unpadded `| a | b |` tables carry the
784    // same cells in far fewer tokens (the MarkItDown facade's choice too);
785    // the caller's explicit `compact_tables` wins.
786    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    // The remote VLM pipeline (#304) is a sibling path, as in docling-serve.
802    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        // No layout model on this machine (the release binaries ship none):
810        // a digital PDF still has its text layer, which is what MarkItDown
811        // reads — convert that rather than fail, and say once how to get the
812        // full pipeline. An explicit `text_layer_only` is the caller's call.
813        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            // A converter of its own: the text-layer pipeline loads no
826            // models, and putting it in the warm slot would only evict them.
827            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
871/// The caller's options under the operator's policy — docling-serve's rules
872/// for everything that leaves the machine, the root for everything that
873/// names a file.
874fn 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    // Remote `<img src>` is an outbound fetch like a URL input: without the
878    // flag the call gets the safe tier (`data:` URIs and container parts)
879    // instead of a surprise request — docling-serve's behavior.
880    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    // Inline copybook JSON is data; anything else names a file.
910    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    // A preset is a directory name under `.models/asr/`, never a path.
916    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
924/// The document a `uri` names: a `data:` URI, an `http(s)` URL (with
925/// `--allow-url-fetch`), a `file:` URI or a path under the root.
926fn 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        // No (known) extension: read it and let the content decide.
961        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
975/// The server's URL-fetch policy on top of the library's bounds
976/// (`DOCLING_RS_MAX_FETCH_BYTES`, 10 s connect / 300 s total): up to five
977/// redirects, each hop re-checked against the private-address block —
978/// links agents are handed (`http:` → `https:`, an arXiv abstract → its
979/// PDF) redirect routinely, and the per-hop check keeps a public URL from
980/// bouncing onto an internal one. docling-serve refuses redirects instead.
981fn fetch_policy() -> docling::FetchPolicy {
982    docling::FetchPolicy {
983        max_redirects: 5,
984        ..docling::FetchPolicy::default()
985    }
986}
987
988/// `scheme` of `scheme:rest` when `uri` has one — a URI scheme is two or
989/// more characters, so a Windows drive (`C:\docs\a.pdf`) is a path.
990fn 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
1000/// `path` resolved against the first root and confined to the roots: the
1001/// lexically normalized path must lie under one (so nothing outside is
1002/// touched — not even to learn whether it exists), and so must the
1003/// canonical one (a symlink inside a root cannot lead out of it). The
1004/// lexical check takes a root in either spelling, so `/tmp/docs/a.pdf`
1005/// passes under a `/tmp/docs` root whose canonical form is
1006/// `/private/tmp/docs`.
1007fn 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
1042/// `.` and `..` folded away without touching the file system.
1043fn 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/// A `data:[<mime>][;charset=…][;base64],<payload>` URI as a document: the
1058/// format from the media type, else the content's signature; a `charset`
1059/// decodes text formats.
1060#[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        // Relative paths resolve against the root.
1086        let ok = local_path(&s, Path::new("sources")).unwrap();
1087        assert!(ok.ends_with("md/sources"));
1088        // `..` out of the root is refused before the file system is asked.
1089        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    /// A root reached through a symlink (macOS's `/tmp` → `/private/tmp`)
1101    /// accepts absolute paths in the spelling the operator gave.
1102    #[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    /// `data:` URIs decode in the library (`docling::fetch::from_data_url`)
1148    /// whether or not URL fetching is on — nothing leaves the machine.
1149    #[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        // No media type: the signature decides.
1159        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        // With the flag, remote images stay remote.
1188        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    /// Calls share the server's warm pipeline (#669) without sharing their
1231    /// options: a page window on one call does not cut the next.
1232    #[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    /// `--flag=value` reads like `--flag value`; switches take true/false.
1256    #[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    /// Optional parameters are single-typed and not required (MCP
1274    /// Inspector's portability check: no `["string", "null"]` unions).
1275    #[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        // An explicit null is still "not given".
1289        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}