Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,48 @@ In **[LM Studio](https://lmstudio.ai/)**, edit the `mcp.json` file with the appr

Other integrations are described in [./docs/integrations/](./docs/integrations/).

## Filesystem access (MCP Roots)

Docling MCP supports the [MCP **Roots** protocol](https://modelcontextprotocol.io/specification/draft/client/roots),
which lets the client tell the server which directories on the host
filesystem the user has authorized it to read from. The reference for the
behavior is the
[`modelcontextprotocol/servers` filesystem server](https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem):

> MCP clients that support Roots can dynamically update the Allowed
> directories. Roots notified by Client to Server, completely replace any
> server-side Allowed directories when provided.

In practice this means:

- **Claude Desktop / Claude Code** users get filesystem access scoped
exactly to the folders they have authorized in the client. When the user
changes that authorization, the server's allowed-paths set is refreshed
automatically via `notifications/roots/list_changed`.
- **Clients without Roots support** can still constrain the server with the
`--allowed-directories` CLI flag (see below). When the client never sends
roots, this static list governs.
- **No flag and no client roots** preserves the legacy unconstrained
behavior — every path the server can resolve is allowed. Set at least one
of the two if you care about path containment.

The conversion tools (`convert_document_into_docling_document` and
`convert_directory_files_into_docling_document`) check the source path
against the active root set before reading from disk. Remote URLs
(`http://`, `https://`, `ftp://`, `s3://`, etc.) pass through unchecked —
roots authorize filesystem access, not network access.

### Static fallback

```sh
uvx --from docling-mcp docling-mcp-server \
--transport stdio \
--allowed-directories /Users/me/Documents /Users/me/Downloads
```

The server will only resolve paths under those two directories until/unless
a Roots-capable client overrides them.

## Examples

### Converting documents
Expand Down
57 changes: 57 additions & 0 deletions docling_mcp/_path_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Helpers for distinguishing URL-style and filesystem-style ``source`` strings.

The ``convert_document_into_docling_document`` tool accepts both remote URLs
and local filesystem paths in a single ``source`` parameter. Roots-based
authorization only applies to filesystem paths, so we need a small predicate
to decide which validator to apply.

This module is deliberately tiny and dependency-free so it can be imported
from both the registry and the conversion tools without introducing cycles.
"""

from __future__ import annotations

from pathlib import Path
from urllib.parse import unquote, urlparse

# URL schemes we treat as remote/non-filesystem and therefore exempt from
# roots authorization. ``file://`` is intentionally excluded so it falls
# through to filesystem-path handling.
_REMOTE_SCHEMES: frozenset[str] = frozenset({"http", "https", "ftp", "ftps", "s3"})


def is_remote_url(source: str) -> bool:
"""Return ``True`` if ``source`` is a remote URL (not a filesystem path).

``file://`` URLs are considered filesystem paths, not remote URLs, so
they are subject to roots authorization.
"""
parsed = urlparse(source)
return parsed.scheme.lower() in _REMOTE_SCHEMES


def to_filesystem_path(source: str) -> Path:
"""Resolve ``source`` to an absolute filesystem ``Path``.

Accepts either a plain path string or a ``file://`` URL. Raises
``ValueError`` for any other URL scheme — callers should gate this with
:func:`is_remote_url` first.
"""
parsed = urlparse(source)
scheme = parsed.scheme.lower()

if scheme in _REMOTE_SCHEMES:
raise ValueError(
f"to_filesystem_path() called on a remote URL: {source!r}. "
"Use is_remote_url() to gate this call."
)

if scheme == "file":
# urlparse on "file:///a/b" gives path="/a/b"; unquote so that
# percent-encoded characters (e.g. %20 for space in macOS paths
# like "Application Support") match the real filesystem path.
candidate = Path(unquote(parsed.path))
else:
candidate = Path(source)

return candidate.expanduser().resolve()
108 changes: 108 additions & 0 deletions docling_mcp/_roots_wiring.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Glue between the MCP protocol layer and ``AllowedRootsRegistry``.

The MCP Python SDK's ``Server.notification_handlers`` dispatch site
(``mcp.server.lowlevel.server.Server._handle_notification``) does **not**
propagate the active ``ServerSession`` to handler callables — only the
notification itself is passed. To call ``session.list_roots()`` from inside
a ``notifications/roots/list_changed`` handler we therefore have to capture
the session from the surrounding ``_handle_message`` scope into a
``ContextVar``. That's what this module does.

The patch is applied once at server startup via :func:`install_roots_handlers`;
it is a no-op if invoked twice on the same server instance.
"""

from __future__ import annotations

import contextvars
from typing import TYPE_CHECKING, Any

from mcp.types import (
InitializedNotification,
RootsListChangedNotification,
)

from docling_mcp.logger import setup_logger
from docling_mcp.shared import allowed_roots, mcp

if TYPE_CHECKING:
from mcp.server.session import ServerSession

logger = setup_logger()

# ContextVar that holds the active ServerSession during message dispatch.
# Notification handlers fish the session out of here when they need to issue
# server-to-client requests like ``roots/list``.
_active_session: contextvars.ContextVar[ServerSession | None] = contextvars.ContextVar(
"docling_mcp_active_session", default=None
)

# Sentinel attribute we set on the patched bound method so a second
# install_roots_handlers() call is a no-op.
_PATCH_MARKER = "_docling_mcp_roots_patched"


async def _refresh_from_client(session: ServerSession) -> None:
"""Pull the current roots list from the client and rebuild the registry."""
caps = session.client_params.capabilities if session.client_params else None
if caps is None or caps.roots is None:
logger.debug("client has no roots capability; skipping list_roots()")
return
try:
result = await session.list_roots()
except Exception:
logger.exception("session.list_roots() failed")
return
allowed_roots.update_from_client_roots([str(r.uri) for r in result.roots])


async def _on_initialized(notify: InitializedNotification) -> None:
"""Seed the registry from the client's roots once the handshake completes."""
session = _active_session.get()
if session is None:
logger.warning("notifications/initialized fired with no active session")
return
await _refresh_from_client(session)


async def _on_roots_list_changed(notify: RootsListChangedNotification) -> None:
"""Refresh the registry when the client signals roots have changed."""
session = _active_session.get()
if session is None:
logger.warning("notifications/roots/list_changed fired with no active session")
return
await _refresh_from_client(session)


def install_roots_handlers() -> None:
"""Wire roots notification handlers onto the shared FastMCP server.

Idempotent — safe to call more than once.
"""
server = mcp._mcp_server # access the underlying low-level Server

# Register notification handlers (keyed by type, per the SDK).
server.notification_handlers[InitializedNotification] = _on_initialized
server.notification_handlers[RootsListChangedNotification] = _on_roots_list_changed

# Patch _handle_message to expose session via the ContextVar.
if getattr(server._handle_message, _PATCH_MARKER, False):
return

original = server._handle_message

async def _handle_message_with_session(
message: Any,
session: Any,
lifespan_context: Any,
raise_exceptions: bool = False,
) -> Any:
token = _active_session.set(session)
try:
return await original(message, session, lifespan_context, raise_exceptions)
finally:
_active_session.reset(token)

setattr(_handle_message_with_session, _PATCH_MARKER, True)
server._handle_message = _handle_message_with_session # type: ignore[method-assign]
logger.info("roots handlers installed (initialized + list_changed)")
155 changes: 155 additions & 0 deletions docling_mcp/roots.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""Roots-aware allowed-paths registry for the Docling MCP server.

This module implements the server-side state for the Model Context Protocol
``roots`` capability (see ``modelcontextprotocol/servers/tree/main/src/filesystem``
for the canonical reference implementation in TypeScript). It tracks the set
of filesystem directories the active client has authorized the server to
access.

Semantics, per the canonical MCP docs:

"MCP clients that support Roots can dynamically update the Allowed
directories. Roots notified by Client to Server, completely replace
any server-side Allowed directories when provided."

So when the client sends a ``roots/list_changed`` notification, the registry
*replaces* (not unions) its allowed set. When the client never sends roots,
the registry falls back to the static set seeded from the
``--allowed-directories`` CLI flag, preserving backward compatibility for
clients that don't speak the roots protocol.
"""

from __future__ import annotations

import threading
from pathlib import Path
from urllib.parse import unquote, urlparse

from docling_mcp._path_utils import is_remote_url, to_filesystem_path
from docling_mcp.logger import setup_logger

logger = setup_logger()


class AllowedRootsRegistry:
"""Thread-safe registry of filesystem paths the server is allowed to read.

The registry has two layers:

* ``_static_roots`` — populated once at startup from the
``--allowed-directories`` CLI flag. Used only when no client roots
have been received.
* ``_client_roots`` — populated dynamically from
``roots/list_changed`` notifications. When non-empty, this set fully
replaces the static set as the authoritative allowed list.

Path comparisons are done on resolved absolute paths so that symlinks,
``..``, and ``~`` do not bypass authorization.
"""

def __init__(self) -> None:
self._lock = threading.Lock()
self._static_roots: set[Path] = set()
self._client_roots: set[Path] | None = None

# ------------------------------------------------------------------
# Seeding
# ------------------------------------------------------------------
def set_static_roots(self, raw_paths: list[str]) -> None:
"""Replace the static-root set from ``--allowed-directories`` values."""
resolved = {Path(p).expanduser().resolve() for p in raw_paths}
with self._lock:
self._static_roots = resolved
logger.info(f"static allowed-roots seeded: {sorted(str(p) for p in resolved)}")

def update_from_client_roots(self, roots_uris: list[str]) -> None:
"""Replace the client-root set from a ``ListRootsResult``.

``roots_uris`` is the list of ``Root.uri`` strings the client returned
— typically ``file://`` URLs. Non-``file://`` roots are ignored with
a warning, since the server only authorizes filesystem access.
"""
new_set: set[Path] = set()
for uri in roots_uris:
parsed = urlparse(uri)
if parsed.scheme.lower() != "file":
logger.warning(
f"ignoring non-file root from client: {uri!r} "
"(only file:// roots constrain filesystem access)"
)
continue
# Decode percent-escapes so file:///path/with%20space matches
# the real filesystem path. Clients (Claude Desktop, Cowork)
# frequently send roots like file:///Users/x/Library/Application%20Support/...
new_set.add(Path(unquote(parsed.path)).expanduser().resolve())

with self._lock:
self._client_roots = new_set
logger.info(
f"client allowed-roots refreshed ({len(new_set)} entries): "
f"{sorted(str(p) for p in new_set)}"
)

def clear_client_roots(self) -> None:
"""Drop the client-root set, falling back to static roots."""
with self._lock:
self._client_roots = None
logger.info("client allowed-roots cleared; falling back to static roots")

# ------------------------------------------------------------------
# Inspection
# ------------------------------------------------------------------
@property
def is_unconstrained(self) -> bool:
"""Return True iff no client roots AND no static roots are configured.

In this state the server validates nothing — preserves the existing
behavior for users who haven't opted into the roots protocol or
passed ``--allowed-directories``.
"""
with self._lock:
return self._client_roots is None and not self._static_roots

def active_roots(self) -> set[Path]:
"""Return the currently-authoritative allowed-paths set.

Client roots, when present, fully replace the static set per the
canonical MCP semantics quoted at module top.
"""
with self._lock:
if self._client_roots is not None:
return set(self._client_roots)
return set(self._static_roots)

# ------------------------------------------------------------------
# Validation
# ------------------------------------------------------------------
def validate_source(self, source: str) -> None:
"""Raise ``PermissionError`` if ``source`` is not under any allowed root.

Remote URLs (http/https/ftp/etc.) are passed through unchecked —
roots authorize filesystem access, not network access.

If the registry is unconstrained (no client roots, no static
roots), this is a no-op, preserving the pre-roots behavior of the
server.
"""
if is_remote_url(source):
return

if self.is_unconstrained:
return

target = to_filesystem_path(source)
roots = self.active_roots()
for root in roots:
try:
target.relative_to(root)
except ValueError:
continue
return

raise PermissionError(
f"path {str(target)!r} is not under any allowed root. "
f"active roots: {sorted(str(r) for r in roots)}"
)
Loading
Loading