mirror of
https://github.com/microsoft/markitdown.git
synced 2026-10-10 19:49:23 +02:00
233 lines
8.2 KiB
Python
233 lines
8.2 KiB
Python
import asyncio
|
|
import errno
|
|
import os
|
|
from pathlib import Path
|
|
from unittest.mock import Mock
|
|
|
|
import httpx2 as httpx
|
|
import pytest
|
|
import requests
|
|
from markitdown import (
|
|
DocumentConverter,
|
|
FileConversionException,
|
|
MarkItDown,
|
|
MarkItDownException,
|
|
UnsupportedFormatException,
|
|
)
|
|
from mcp.server.mcpserver.exceptions import ToolError, UnexpectedToolError
|
|
from requests.exceptions import ConnectionError, HTTPError, Timeout
|
|
|
|
from markitdown_mcp import __main__ as server
|
|
|
|
|
|
def call_convert(uri: str = "https://example.com/sample.pdf"):
|
|
return asyncio.run(server.mcp.call_tool("convert_to_markdown", {"uri": uri}))
|
|
|
|
|
|
def original_cause(exc):
|
|
"""The first non-ToolError in the __cause__ chain.
|
|
|
|
Sanitized detail is withheld from the client but retained on the chain. The
|
|
SDK re-wraps the tool's ToolError in one of its own, so walk past every
|
|
ToolError rather than assuming a fixed depth.
|
|
"""
|
|
while isinstance(exc, ToolError):
|
|
exc = exc.__cause__
|
|
return exc
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"uri, error_type, expected_message",
|
|
[
|
|
(
|
|
(
|
|
Path(__file__).resolve().parents[2]
|
|
/ "markitdown/tests/test_files/random.bin"
|
|
).as_uri(),
|
|
UnsupportedFormatException,
|
|
"No converter attempted a conversion",
|
|
),
|
|
(
|
|
"gopher://example.com/sample.pdf",
|
|
ValueError,
|
|
"Unsupported URI scheme: gopher. Supported schemes are: "
|
|
"file:, data:, http:, https:",
|
|
),
|
|
],
|
|
)
|
|
def test_client_input_failures_reach_the_sdk(uri, error_type, expected_message):
|
|
"""Diagnoses that only restate the client's own input are returned verbatim."""
|
|
with pytest.raises(ToolError) as raised:
|
|
call_convert(uri)
|
|
|
|
assert not isinstance(raised.value, UnexpectedToolError)
|
|
cause = original_cause(raised.value)
|
|
assert isinstance(cause, error_type)
|
|
assert expected_message in str(raised.value)
|
|
assert str(cause) in str(raised.value)
|
|
|
|
|
|
def test_http_status_is_returned_without_transport_details(monkeypatch):
|
|
response = requests.Response()
|
|
response.status_code = 404
|
|
error = HTTPError(
|
|
"404 Client Error: Not Found for url: https://internal.corp/sample.pdf",
|
|
response=response,
|
|
)
|
|
converter = Mock()
|
|
converter.convert_uri.side_effect = error
|
|
monkeypatch.setattr(server, "MarkItDown", Mock(return_value=converter))
|
|
|
|
with pytest.raises(ToolError) as raised:
|
|
call_convert()
|
|
|
|
assert not isinstance(raised.value, UnexpectedToolError)
|
|
assert "HTTP status 404" in str(raised.value)
|
|
assert "internal.corp" not in str(raised.value)
|
|
assert original_cause(raised.value) is error
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"error",
|
|
[
|
|
# requests exceptions subclass OSError, and their messages describe the
|
|
# server's own network position rather than the client's request.
|
|
ConnectionError(
|
|
"HTTPSConnectionPool(host='10.0.0.5', port=443): Max retries exceeded "
|
|
"(Caused by ProxyError('Unable to connect to proxy', "
|
|
"NameResolutionError(\"Failed to resolve 'proxy.internal.corp'\")))"
|
|
),
|
|
Timeout(
|
|
"HTTPSConnectionPool(host='10.0.0.5', port=443): Read timed out. "
|
|
"(proxy.internal.corp)"
|
|
),
|
|
],
|
|
)
|
|
def test_network_failure_details_remain_private(monkeypatch, error):
|
|
converter = Mock()
|
|
converter.convert_uri.side_effect = error
|
|
monkeypatch.setattr(server, "MarkItDown", Mock(return_value=converter))
|
|
|
|
with pytest.raises(ToolError) as raised:
|
|
call_convert()
|
|
|
|
message = str(raised.value)
|
|
assert not isinstance(raised.value, UnexpectedToolError)
|
|
assert "Could not fetch the resource" in message
|
|
assert "10.0.0.5" not in message
|
|
assert "proxy.internal.corp" not in message
|
|
assert "proxy.internal.corp" in str(original_cause(raised.value))
|
|
|
|
|
|
def test_local_read_failure_reports_errno_without_the_path(monkeypatch):
|
|
error = PermissionError(
|
|
errno.EACCES, os.strerror(errno.EACCES), "/srv/private/sample.pdf"
|
|
)
|
|
converter = Mock()
|
|
converter.convert_uri.side_effect = error
|
|
monkeypatch.setattr(server, "MarkItDown", Mock(return_value=converter))
|
|
|
|
with pytest.raises(ToolError) as raised:
|
|
call_convert("file:///srv/private/sample.pdf")
|
|
|
|
message = str(raised.value)
|
|
assert not isinstance(raised.value, UnexpectedToolError)
|
|
assert os.strerror(errno.EACCES) in message
|
|
assert "/srv/private/sample.pdf" not in message
|
|
assert "/srv/private/sample.pdf" in str(original_cause(raised.value))
|
|
|
|
|
|
@pytest.mark.parametrize("error_type", [RuntimeError, MarkItDownException])
|
|
def test_unexpected_failure_details_remain_private(monkeypatch, error_type):
|
|
converter = Mock()
|
|
converter.convert_uri.side_effect = error_type("internal implementation detail")
|
|
monkeypatch.setattr(server, "MarkItDown", Mock(return_value=converter))
|
|
|
|
with pytest.raises(UnexpectedToolError) as raised:
|
|
call_convert("file:///sample.pdf")
|
|
|
|
assert "internal implementation detail" not in str(raised.value)
|
|
|
|
|
|
def crashing_markitdown(error_type, private_detail):
|
|
"""A MarkItDown whose sole converter raises, to exercise the real
|
|
convert_uri/_convert pipeline that aggregates converter exceptions into
|
|
FileConversionException."""
|
|
|
|
class CrashingConverter(DocumentConverter):
|
|
def accepts(self, file_stream, stream_info, **kwargs):
|
|
return True
|
|
|
|
def convert(self, file_stream, stream_info, **kwargs):
|
|
raise error_type(private_detail)
|
|
|
|
converter = MarkItDown(enable_builtins=False, enable_plugins=False)
|
|
converter.register_converter(CrashingConverter())
|
|
return converter
|
|
|
|
|
|
@pytest.mark.parametrize("error_type", [RuntimeError, ValueError, OSError])
|
|
def test_wrapped_converter_crash_retains_the_cause(monkeypatch, error_type):
|
|
private_detail = "private converter implementation detail"
|
|
# Replace only construction, so the real pipeline does the aggregation.
|
|
monkeypatch.setattr(
|
|
server,
|
|
"MarkItDown",
|
|
Mock(return_value=crashing_markitdown(error_type, private_detail)),
|
|
)
|
|
|
|
with pytest.raises(ToolError) as raised:
|
|
call_convert("data:text/plain,example")
|
|
|
|
assert "File conversion failed" in str(raised.value)
|
|
assert private_detail not in str(raised.value)
|
|
|
|
cause = original_cause(raised.value)
|
|
assert isinstance(cause, FileConversionException)
|
|
assert cause.attempts
|
|
for attempt in cause.attempts:
|
|
assert isinstance(attempt.exc_info[1], error_type)
|
|
assert str(attempt.exc_info[1]) == private_detail
|
|
assert attempt.exc_info[2] is not None
|
|
|
|
|
|
@pytest.mark.parametrize("error_type", [RuntimeError, ValueError, OSError])
|
|
def test_wrapped_converter_crash_remains_private_over_http(monkeypatch, error_type):
|
|
private_detail = "private converter implementation detail"
|
|
monkeypatch.setattr(
|
|
server,
|
|
"MarkItDown",
|
|
Mock(return_value=crashing_markitdown(error_type, private_detail)),
|
|
)
|
|
|
|
async def call_tool():
|
|
app = server.create_starlette_app(server.mcp)
|
|
async with app.router.lifespan_context(app):
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=app),
|
|
base_url="http://127.0.0.1:3001",
|
|
headers={"accept": "application/json, text/event-stream"},
|
|
) as client:
|
|
return await client.post(
|
|
"/mcp",
|
|
json={
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "tools/call",
|
|
"params": {
|
|
"name": "convert_to_markdown",
|
|
"arguments": {"uri": "data:text/plain,example"},
|
|
},
|
|
},
|
|
)
|
|
|
|
response = asyncio.run(asyncio.wait_for(call_tool(), timeout=15))
|
|
|
|
assert response.status_code == 200
|
|
result = response.json()["result"]
|
|
assert result["isError"] is True
|
|
assert "File conversion failed" in result["content"][0]["text"]
|
|
assert private_detail not in response.text
|
|
assert error_type.__name__ not in response.text
|
|
assert "CrashingConverter" not in response.text
|