From ae25576f47ac68f9d89d06bdcac7d7bd38b4ae7b Mon Sep 17 00:00:00 2001 From: Dex Date: Thu, 9 Jul 2026 21:09:17 +0000 Subject: [PATCH] feat(mcp): add file output for large conversions Fixes #1332 --- .github/workflows/tests.yml | 2 + packages/markitdown-mcp/README.md | 20 +++- .../src/markitdown_mcp/__main__.py | 38 +++++- packages/markitdown-mcp/tests/test_main.py | 108 ++++++++++++++++++ 4 files changed, 162 insertions(+), 6 deletions(-) create mode 100644 packages/markitdown-mcp/tests/test_main.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4785bba1a..dbc6d46b1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 diff --git a/packages/markitdown-mcp/README.md b/packages/markitdown-mcp/README.md index d7e9224b4..3119f870d 100644 --- a/packages/markitdown-mcp/README.md +++ b/packages/markitdown-mcp/README.md @@ -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 @@ -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. @@ -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 diff --git a/packages/markitdown-mcp/src/markitdown_mcp/__main__.py b/packages/markitdown-mcp/src/markitdown_mcp/__main__.py index 89f89444e..d40d22a7a 100644 --- a/packages/markitdown-mcp/src/markitdown_mcp/__main__.py +++ b/packages/markitdown-mcp/src/markitdown_mcp/__main__.py @@ -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 @@ -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: diff --git a/packages/markitdown-mcp/tests/test_main.py b/packages/markitdown-mcp/tests/test_main.py new file mode 100644 index 000000000..90aa150ec --- /dev/null +++ b/packages/markitdown-mcp/tests/test_main.py @@ -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")