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
2 changes: 2 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ jobs:
run: pipx install hatch
- name: Run tests
run: cd packages/markitdown; hatch test
- name: Run MCP tests
run: cd packages/markitdown-mcp; hatch test
20 changes: 18 additions & 2 deletions packages/markitdown-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@

The `markitdown-mcp` package provides a lightweight STDIO, Streamable HTTP, and SSE MCP server for calling MarkItDown.

It exposes one tool: `convert_to_markdown(uri)`, where uri can be any `http:`, `https:`, `file:`, or `data:` URI.
It exposes one tool: `convert_to_markdown(uri, output_file=None)`, where uri can be any `http:`, `https:`, `file:`, or `data:` URI.

For conversions that are too large to return through an MCP client, set
`output_file` to a path on the machine running the MCP server. The tool writes
the complete markdown to a new file and returns its absolute path instead of
returning the markdown content. Parent directories must already exist and
existing files are not overwritten. The caller is responsible for removing the
file when it is no longer needed.

## Installation

Expand Down Expand Up @@ -54,6 +61,15 @@ docker run -it --rm -v /home/user/data:/workdir markitdown-mcp:latest

Once mounted, all files under data will be accessible under `/workdir` in the container. For example, if you have a file `example.txt` in `/home/user/data`, it will be accessible in the container at `/workdir/example.txt`.

The same mount can receive large conversion results without returning them
through MCP. For example, call `convert_to_markdown` with
`uri="file:///workdir/example.pdf"` and `output_file="/workdir/example.md"`.
The resulting `example.md` will be available in the mounted host directory.

`output_file` is useful only when the MCP client and server share a filesystem
or mounted directory. A path created on a separate HTTP or SSE server is local
to that server.

## Accessing from Claude Desktop

It is recommended to use the Docker image when running the MCP server for Claude Desktop.
Expand Down Expand Up @@ -131,7 +147,7 @@ Finally:

## Security Considerations

The server does not support authentication, and runs with the privileges of the user running it. For this reason, when running in SSE or Streamable HTTP mode, the server binds by default to `localhost`. Even still, it is important to recognize that the server can be accessed by any process or users on the same local machine, and that the `convert_to_markdown` tool can be used to read any file that the server's user has access to, or any data from the network. If you require additional security, consider running the server in a sandboxed environment, such as a virtual machine or container, and ensure that the user permissions are properly configured to limit access to sensitive files and network segments. Above all, DO NOT bind the server to other interfaces (non-localhost) unless you understand the security implications of doing so.
The server does not support authentication, and runs with the privileges of the user running it. For this reason, when running in SSE or Streamable HTTP mode, the server binds by default to `localhost`. Even still, it is important to recognize that the server can be accessed by any process or users on the same local machine, and that the `convert_to_markdown` tool can be used to read any file that the server's user has access to, fetch data from the network, or create a new file at a caller-supplied `output_file` path. Output files use mode `0600` on POSIX systems and platform-default permissions on Windows; existing files are not overwritten. If you require additional security, consider running the server in a sandboxed environment, such as a virtual machine or container, and ensure that the user permissions are properly configured to limit access to sensitive files and network segments. Above all, DO NOT bind the server to other interfaces (non-localhost) unless you understand the security implications of doing so.

## Trademarks

Expand Down
38 changes: 34 additions & 4 deletions packages/markitdown-mcp/src/markitdown_mcp/__main__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import contextlib
import sys
import os
import sys
from collections.abc import AsyncIterator
from pathlib import Path
from mcp.server.fastmcp import FastMCP
from starlette.applications import Starlette
from mcp.server.sse import SseServerTransport
Expand All @@ -18,9 +19,38 @@


@mcp.tool()
async def convert_to_markdown(uri: str) -> str:
"""Convert a resource described by an http:, https:, file: or data: URI to markdown"""
return MarkItDown(enable_plugins=check_plugins_enabled()).convert_uri(uri).markdown
async def convert_to_markdown(uri: str, output_file: str | None = None) -> str:
"""Convert a resource described by an http:, https:, file: or data: URI.

Set output_file to write the markdown to a new local file and return its
absolute path. Existing files are never overwritten.
"""
markdown = (
MarkItDown(enable_plugins=check_plugins_enabled()).convert_uri(uri).markdown
)
if output_file is None:
return markdown

return write_markdown_file(output_file, markdown)


def write_markdown_file(output_file: str, markdown: str) -> str:
output_path = Path(output_file).expanduser()
fd = os.open(
output_path,
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
0o600,
)
try:
with os.fdopen(fd, "wb") as output:
fd = -1
output.write(markdown.encode("utf-8"))
except BaseException:
if fd >= 0:
os.close(fd)
output_path.unlink(missing_ok=True)
raise
return str(output_path.resolve())


def check_plugins_enabled() -> bool:
Expand Down
108 changes: 108 additions & 0 deletions packages/markitdown-mcp/tests/test_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import asyncio
import os
import stat
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from markitdown_mcp.__main__ import convert_to_markdown, mcp

URI = "file:///tmp/example.txt"
MARKDOWN = "# Example\n\nConverted text with unicode: cafe \N{HOT BEVERAGE}.\n"


def test_convert_to_markdown_returns_content_by_default(monkeypatch) -> None:
monkeypatch.delenv("MARKITDOWN_ENABLE_PLUGINS", raising=False)
conversion = MagicMock(markdown=MARKDOWN)

with patch("markitdown_mcp.__main__.MarkItDown") as markitdown:
markitdown.return_value.convert_uri.return_value = conversion
result = asyncio.run(convert_to_markdown(URI))

assert result == MARKDOWN
markitdown.assert_called_once_with(enable_plugins=False)
markitdown.return_value.convert_uri.assert_called_once_with(URI)


def test_convert_to_markdown_can_write_output_file(monkeypatch, tmp_path: Path) -> None:
monkeypatch.delenv("MARKITDOWN_ENABLE_PLUGINS", raising=False)
conversion = MagicMock(markdown=MARKDOWN)
output_path = tmp_path / "converted.md"

with patch("markitdown_mcp.__main__.MarkItDown") as markitdown:
markitdown.return_value.convert_uri.return_value = conversion
result = asyncio.run(convert_to_markdown(URI, output_file=str(output_path)))

assert result == str(output_path.resolve())
assert output_path.read_bytes() == MARKDOWN.encode("utf-8")
if os.name != "nt":
assert stat.S_IMODE(output_path.stat().st_mode) == 0o600


def test_convert_to_markdown_does_not_overwrite(monkeypatch, tmp_path: Path) -> None:
monkeypatch.delenv("MARKITDOWN_ENABLE_PLUGINS", raising=False)
output_path = tmp_path / "existing.md"
output_path.write_bytes(b"existing")

with patch("markitdown_mcp.__main__.MarkItDown") as markitdown:
markitdown.return_value.convert_uri.return_value = MagicMock(markdown=MARKDOWN)
with pytest.raises(FileExistsError):
asyncio.run(convert_to_markdown(URI, output_file=str(output_path)))

assert output_path.read_bytes() == b"existing"


def test_convert_to_markdown_removes_partial_file(monkeypatch, tmp_path: Path) -> None:
monkeypatch.delenv("MARKITDOWN_ENABLE_PLUGINS", raising=False)
output_path = tmp_path / "partial.md"

with (
patch("markitdown_mcp.__main__.MarkItDown") as markitdown,
patch("markitdown_mcp.__main__.os.fdopen", side_effect=OSError("write failed")),
):
markitdown.return_value.convert_uri.return_value = MagicMock(markdown=MARKDOWN)
with pytest.raises(OSError, match="write failed"):
asyncio.run(convert_to_markdown(URI, output_file=str(output_path)))

assert not output_path.exists()


def test_convert_to_markdown_preserves_plugin_setting(monkeypatch) -> None:
monkeypatch.setenv("MARKITDOWN_ENABLE_PLUGINS", "true")
conversion = MagicMock(markdown=MARKDOWN)

with patch("markitdown_mcp.__main__.MarkItDown") as markitdown:
markitdown.return_value.convert_uri.return_value = conversion
result = asyncio.run(convert_to_markdown(URI))

assert result == MARKDOWN
markitdown.assert_called_once_with(enable_plugins=True)


def test_mcp_schema_and_tool_call(monkeypatch, tmp_path: Path) -> None:
monkeypatch.delenv("MARKITDOWN_ENABLE_PLUGINS", raising=False)
tools = asyncio.run(mcp.list_tools())
tool = next(tool for tool in tools if tool.name == "convert_to_markdown")
output_schema = tool.inputSchema["properties"]["output_file"]
assert output_schema == {
"anyOf": [{"type": "string"}, {"type": "null"}],
"default": None,
"title": "Output File",
}
assert tool.inputSchema["required"] == ["uri"]

output_path = tmp_path / "mcp-output.md"
with patch("markitdown_mcp.__main__.MarkItDown") as markitdown:
markitdown.return_value.convert_uri.return_value = MagicMock(markdown=MARKDOWN)
result = asyncio.run(
mcp.call_tool(
"convert_to_markdown",
{"uri": URI, "output_file": str(output_path)},
)
)

assert len(result) == 1
assert result[0].type == "text"
assert result[0].text == str(output_path.resolve())
assert output_path.read_bytes() == MARKDOWN.encode("utf-8")