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}. A value may also be
554    /// given as text ("true", "1.5"), as a form sends it.
555    #[serde(default)]
556    #[schemars(transform = drop_null)]
557    options: Option<serde_json::Map<String, serde_json::Value>>,
558}
559
560/// An `Option<T>` field's schema as `T`'s: `"type": ["T", "null"]` → `"T"`,
561/// and no `"default": null` — absence already says "not given".
562fn 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/// The MCP service: one per stdio connection or HTTP session, sharing the
575/// operator's [`State`].
576#[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    /// Convert on the blocking pool under a concurrency permit, sending a
591    /// progress heartbeat while it runs when the caller asked for progress.
592    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        // The permit lives as long as the work: a call the client cancels
606        // drops this future, not the conversion already running, and must
607        // not free a slot that conversion still occupies.
608        let mut job = tokio::task::spawn_blocking(move || {
609            let _permit = permit;
610            let result = convert(&state, &request);
611            // What the conversion freed goes back to the OS, not into the
612            // allocator's arenas (`docling::memory`).
613            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; // the first tick is immediate
619        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    /// Convert a resource described by an http:, https:, file: or data: URI
642    /// (or a local path) to Markdown — PDFs with layout analysis, table
643    /// structure and OCR; Word, PowerPoint, Excel, HTML, EPUB, e-mail,
644    /// images, audio and more. MarkItDown's tool of the same name.
645    #[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    /// Convert a document (URI or path, as convert_to_markdown) to Markdown,
665    /// docling JSON, HTML, plain text, LaTeX, RAG chunks, WebVTT or Pandoc
666    /// JSON, with docling.rs's conversion options (page ranges, OCR
667    /// language and mode, text-layer-only PDFs, passwords, picture OCR, …).
668    #[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    /// The input file extensions this server converts, one per line, and
683    /// whether URL inputs are enabled.
684    #[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
719// --- the conversion -----------------------------------------------------------
720
721/// One validated tool call.
722struct Request {
723    uri: String,
724    to: String,
725    images: ImageMode,
726    options: ConvertOptions,
727}
728
729/// The `to` values the tools return — every text output of the CLI's
730/// `--to` (`dclx` is a ZIP and `images` page PNGs, neither of which fits a
731/// text result).
732const 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                // Values as text too — MCP Inspector's form sends every
767                // value as a string: `{"force_full_page_ocr": "true"}`.
768                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
780/// Resolve, convert and render one request — the blocking half of a tool
781/// call.
782fn 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    // Markdown for a model to read: unpadded `| a | b |` tables carry the
786    // same cells in far fewer tokens (the MarkItDown facade's choice too);
787    // the caller's explicit `compact_tables` wins.
788    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    // The remote VLM pipeline (#304) is a sibling path, as in docling-serve.
804    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        // No layout model on this machine (the release binaries ship none):
812        // a digital PDF still has its text layer, which is what MarkItDown
813        // reads — convert that rather than fail, and say once how to get the
814        // full pipeline. An explicit `text_layer_only` is the caller's call.
815        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            // A converter of its own: the text-layer pipeline loads no
828            // models, and putting it in the warm slot would only evict them.
829            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
873/// The caller's options under the operator's policy — docling-serve's rules
874/// for everything that leaves the machine, the root for everything that
875/// names a file.
876fn 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    // Remote `<img src>` is an outbound fetch like a URL input: without the
880    // flag the call gets the safe tier (`data:` URIs and container parts)
881    // instead of a surprise request — docling-serve's behavior.
882    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    // Inline copybook JSON is data; anything else names a file.
912    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    // A preset is a directory name under `.models/asr/`, never a path.
918    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
926/// The document a `uri` names: a `data:` URI, an `http(s)` URL (with
927/// `--allow-url-fetch`), a `file:` URI or a path under the root.
928fn 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        // No (known) extension: read it and let the content decide.
963        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
977/// The server's URL-fetch policy on top of the library's bounds
978/// (`DOCLING_RS_MAX_FETCH_BYTES`, 10 s connect / 300 s total): up to five
979/// redirects, each hop re-checked against the private-address block —
980/// links agents are handed (`http:` → `https:`, an arXiv abstract → its
981/// PDF) redirect routinely, and the per-hop check keeps a public URL from
982/// bouncing onto an internal one. docling-serve refuses redirects instead.
983fn fetch_policy() -> docling::FetchPolicy {
984    docling::FetchPolicy {
985        max_redirects: 5,
986        ..docling::FetchPolicy::default()
987    }
988}
989
990/// `scheme` of `scheme:rest` when `uri` has one — a URI scheme is two or
991/// more characters, so a Windows drive (`C:\docs\a.pdf`) is a path.
992fn 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
1002/// `path` resolved against the first root and confined to the roots: the
1003/// lexically normalized path must lie under one (so nothing outside is
1004/// touched — not even to learn whether it exists), and so must the
1005/// canonical one (a symlink inside a root cannot lead out of it). The
1006/// lexical check takes a root in either spelling, so `/tmp/docs/a.pdf`
1007/// passes under a `/tmp/docs` root whose canonical form is
1008/// `/private/tmp/docs`.
1009fn 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
1044/// `.` and `..` folded away without touching the file system.
1045fn 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/// A `data:[<mime>][;charset=…][;base64],<payload>` URI as a document: the
1060/// format from the media type, else the content's signature; a `charset`
1061/// decodes text formats.
1062#[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        // Relative paths resolve against the root.
1088        let ok = local_path(&s, Path::new("sources")).unwrap();
1089        assert!(ok.ends_with("md/sources"));
1090        // `..` out of the root is refused before the file system is asked.
1091        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    /// A root reached through a symlink (macOS's `/tmp` → `/private/tmp`)
1103    /// accepts absolute paths in the spelling the operator gave.
1104    #[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    /// `data:` URIs decode in the library (`docling::fetch::from_data_url`)
1150    /// whether or not URL fetching is on — nothing leaves the machine.
1151    #[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        // No media type: the signature decides.
1161        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        // Untyped text is plain text since #671; untyped binary is refused.
1167        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        // With the flag, remote images stay remote.
1193        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        // A form's text values read as the option's type.
1221        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    /// Calls share the server's warm pipeline (#669) without sharing their
1255    /// options: a page window on one call does not cut the next.
1256    #[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    /// `--flag=value` reads like `--flag value`; switches take true/false.
1280    #[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    /// Optional parameters are single-typed and not required (MCP
1298    /// Inspector's portability check: no `["string", "null"]` unions).
1299    #[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        // An explicit null is still "not given".
1313        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}