Skip to content
Merged
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
21 changes: 21 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,24 @@ jobs:
- name: Run tests
working-directory: packages/markitdown-ocr
run: pytest


mcp-tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}

- name: Install packages
run: pip install "./packages/markitdown[all]" ./packages/markitdown-mcp pytest

- name: Run tests
working-directory: packages/markitdown-mcp
run: pytest
3 changes: 2 additions & 1 deletion packages/markitdown-mcp/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ classifiers = [
"Programming Language :: Python :: Implementation :: PyPy",
]
dependencies = [
"mcp~=1.8.0",
"mcp>=2.1.1,<3.0.0",
"markitdown[all]>=0.1.1,<0.2.0",
"requests>=2.0.0,<3.0.0",
]

[project.urls]
Expand Down
100 changes: 47 additions & 53 deletions packages/markitdown-mcp/src/markitdown_mcp/__main__.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,50 @@
import contextlib
import sys
import logging
import os
import sys
from collections.abc import AsyncIterator
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.server import Settings
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from starlette.applications import Starlette
from mcp.server.sse import SseServerTransport
from starlette.requests import Request
from starlette.routing import Mount, Route
from starlette.types import Receive, Scope, Send
from mcp.server import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from markitdown import MarkItDown
from markitdown import MarkItDown, FileConversionException, UnsupportedFormatException
import requests
import uvicorn

# Resolve the forward reference in Settings.lifespan before the model is first
# instantiated, otherwise pydantic-settings emits IncompleteFieldDefinitionWarning.
Settings.model_rebuild()
logger = logging.getLogger(__name__)

# Initialize FastMCP server for MarkItDown (SSE)
mcp = FastMCP("markitdown")
# Initialize the MCP server for MarkItDown
mcp = MCPServer("markitdown")


@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
converter = MarkItDown(enable_plugins=check_plugins_enabled())
try:
return converter.convert_uri(uri).markdown

# SDK 2.x only exposes ToolError messages.
except UnsupportedFormatException as exc:
raise ToolError(str(exc)) from exc
except requests.exceptions.HTTPError as exc:
status = exc.response.status_code if exc.response is not None else "unknown"
raise ToolError(
f"Fetching the resource failed with HTTP status {status}."
) from exc
except requests.exceptions.RequestException as exc:
raise ToolError("Could not fetch the resource.") from exc
except FileConversionException as exc:
raise ToolError("File conversion failed.") from exc
except OSError as exc:
# str(exc) embeds the resolved path; errno's fixed strerror does not.
detail = (
os.strerror(exc.errno) if exc.errno else "the resource could not be read"
)
raise ToolError(f"Could not read the resource: {detail}.") from exc
except ValueError as exc:
# URI validation failures raised by convert_uri itself, which only
# restate the URI the client supplied.
raise ToolError(str(exc)) from exc


def check_plugins_enabled() -> bool:
Expand All @@ -36,49 +55,26 @@ def check_plugins_enabled() -> bool:
)


def create_starlette_app(mcp_server: Server, *, debug: bool = False) -> Starlette:
sse = SseServerTransport("/messages/")
session_manager = StreamableHTTPSessionManager(
app=mcp_server,
event_store=None,
def create_starlette_app(
mcp_server: MCPServer, *, host: str = "127.0.0.1", debug: bool = False
) -> Starlette:
sse_app = mcp_server.sse_app(host=host)
http_app = mcp_server.streamable_http_app(
json_response=True,
stateless=True,
stateless_http=True,
host=host,
Comment thread
afourney marked this conversation as resolved.
)

async def handle_sse(request: Request) -> None:
async with sse.connect_sse(
request.scope,
request.receive,
request._send,
) as (read_stream, write_stream):
await mcp_server.run(
read_stream,
write_stream,
mcp_server.create_initialization_options(),
)

async def handle_streamable_http(
scope: Scope, receive: Receive, send: Send
) -> None:
await session_manager.handle_request(scope, receive, send)

@contextlib.asynccontextmanager
async def lifespan(app: Starlette) -> AsyncIterator[None]:
"""Context manager for session manager."""
async with session_manager.run():
print("Application started with StreamableHTTP session manager!")
try:
"""Run both sub-apps' lifespans (the Streamable HTTP session manager)."""
async with sse_app.router.lifespan_context(app):
async with http_app.router.lifespan_context(app):
yield
finally:
print("Application shutting down...")

return Starlette(
debug=debug,
routes=[
Route("/sse", endpoint=handle_sse),
Mount("/mcp", app=handle_streamable_http),
Mount("/messages/", app=sse.handle_post_message),
],
routes=[*sse_app.routes, *http_app.routes],
lifespan=lifespan,
)

Expand All @@ -87,8 +83,6 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]:
def main():
import argparse

mcp_server = mcp._mcp_server

parser = argparse.ArgumentParser(description="Run a MarkItDown MCP server")

parser.add_argument(
Expand Down Expand Up @@ -131,7 +125,7 @@ def main():
"Only proceed if you understand the security implications.\n",
file=sys.stderr,
)
starlette_app = create_starlette_app(mcp_server, debug=True)
starlette_app = create_starlette_app(mcp, host=host, debug=True)
uvicorn.run(
starlette_app,
host=host,
Expand Down
158 changes: 158 additions & 0 deletions packages/markitdown-mcp/tests/test_http_transports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""Exercise the combined HTTP app without a listening socket or subprocess."""

import asyncio
import json
from contextlib import aclosing, asynccontextmanager
from urllib.parse import parse_qs, urlsplit

import httpx2 as httpx

from markitdown_mcp.__main__ import create_starlette_app, mcp


BASE_URL = "http://127.0.0.1:3001"
INITIALIZE = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "test", "version": "0"},
},
}
INITIALIZED = {"jsonrpc": "2.0", "method": "notifications/initialized"}
CONVERT = {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "convert_to_markdown",
"arguments": {"uri": "data:text/plain,Hello%20from%20HTTP"},
},
}


class _SSEBody(httpx.AsyncByteStream):
def __init__(self, messages):
self.messages = messages

async def __aiter__(self):
while True:
message = await self.messages.get()
assert message["type"] == "http.response.body"
yield message.get("body", b"")
if not message.get("more_body", False):
return


@asynccontextmanager
async def sse_connection(app):
# ASGITransport buffers responses until completion, so drive the open SSE
# request directly and let httpx parse its streamed response body.
incoming = asyncio.Queue()
outgoing = asyncio.Queue()
await incoming.put({"type": "http.request", "body": b"", "more_body": False})
scope = {
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.4"},
"http_version": "1.1",
"method": "GET",
"scheme": "http",
"path": "/sse",
"raw_path": b"/sse",
"root_path": "",
"query_string": b"",
"headers": [
(b"host", b"127.0.0.1:3001"),
(b"accept", b"text/event-stream"),
],
"client": ("127.0.0.1", 1234),
"server": ("127.0.0.1", 3001),
}
task = asyncio.create_task(app(scope, incoming.get, outgoing.put))
try:
start = await outgoing.get()
assert start["type"] == "http.response.start"
assert start["status"] == 200
response = httpx.Response(
start["status"],
headers=start["headers"],
stream=_SSEBody(outgoing),
request=httpx.Request("GET", BASE_URL + "/sse"),
)
assert response.headers["content-type"].startswith("text/event-stream")
async with aclosing(httpx.EventSource(response).__aiter__()) as events:
yield events
finally:
await incoming.put({"type": "http.disconnect"})
await asyncio.wait_for(task, timeout=5)


def assert_conversion(response):
assert response["id"] == CONVERT["id"]
result = response["result"]
assert result["isError"] is False
assert result["content"][0]["text"] == "Hello from HTTP"


def test_streamable_http_initializes_with_app_lifespan():
async def exercise():
app = create_starlette_app(mcp)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url=BASE_URL,
headers={"accept": "application/json, text/event-stream"},
) as client:
response = await client.post("/mcp", json=INITIALIZE)
assert response.status_code == 200
assert response.headers["content-type"].startswith("application/json")
initialized = response.json()
assert initialized["id"] == INITIALIZE["id"]
assert initialized["result"]["serverInfo"]["name"] == "markitdown"
assert "mcp-session-id" not in response.headers

client.headers["mcp-protocol-version"] = initialized["result"][
"protocolVersion"
]
response = await client.post("/mcp", json=INITIALIZED)
assert response.status_code == 202
response = await client.post("/mcp", json=CONVERT)
assert response.status_code == 200
assert_conversion(response.json())

asyncio.run(asyncio.wait_for(exercise(), timeout=15))


def test_sse_session_uses_preserved_message_route():
async def exercise():
app = create_starlette_app(mcp)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url=BASE_URL
) as client:
async with sse_connection(app) as events:
endpoint = await anext(events)
assert endpoint.event == "endpoint"
url = urlsplit(endpoint.data)
assert url.path == "/messages/"
assert parse_qs(url.query)["session_id"]

response = await client.post(endpoint.data, json=INITIALIZE)
assert response.status_code == 202
event = await anext(events)
assert event.event == "message"
initialized = json.loads(event.data)
assert initialized["id"] == INITIALIZE["id"]
assert initialized["result"]["serverInfo"]["name"] == "markitdown"

response = await client.post(endpoint.data, json=INITIALIZED)
assert response.status_code == 202
response = await client.post(endpoint.data, json=CONVERT)
assert response.status_code == 202
event = await anext(events)
assert event.event == "message"
assert_conversion(json.loads(event.data))

asyncio.run(asyncio.wait_for(exercise(), timeout=15))
Loading