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
21 changes: 21 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,24 @@ jobs:
run: pipx install hatch
- name: Run tests
run: cd packages/markitdown; hatch test

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

steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0

- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.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
2 changes: 1 addition & 1 deletion packages/markitdown-mcp/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ 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",
]

Expand Down
69 changes: 22 additions & 47 deletions packages/markitdown-mcp/src/markitdown_mcp/__main__.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
import contextlib
import sys
import os
import sys
from collections.abc import AsyncIterator
from mcp.server.fastmcp import FastMCP
from mcp.server.mcpserver import MCPServer
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
import uvicorn

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


@mcp.tool()
Expand All @@ -31,49 +25,32 @@ 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:
# Two sub-apps supply the routes: /sse + /messages/ from the SSE transport,
# and /mcp from Streamable HTTP. Their routes are merged into one app so the
# published URL surface is unchanged.
# The host must match what uvicorn binds to: the SDK derives its
# Host/Origin allowlist from it, and a localhost default would reject
# remote requests with 421 when serving on another interface.
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 @@ -82,8 +59,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 @@ -126,7 +101,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
165 changes: 165 additions & 0 deletions packages/markitdown-mcp/tests/test_stdio_protocols.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
#!/usr/bin/env python3 -m pytest
Comment thread
afourney marked this conversation as resolved.
"""Drive the stdio server through both MCP handshake eras.

The server must answer clients that open with the legacy `initialize`
handshake as well as clients that open with the 2026-07-28 `server/discover`
request, since both are in use across MCP hosts.
"""

import json
import os
import subprocess
import sys
import tempfile
import threading
import time

import pytest

MODERN_ENVELOPE = {
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": {"name": "test", "version": "0"},
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
}

EXPECTED_MARKDOWN = "# Hello\n\nA **markitdown** fixture."


@pytest.fixture
def fixture_uri():
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "sample.md")
with open(path, "w") as fh:
fh.write(EXPECTED_MARKDOWN + "\n")
yield "file://" + path


def run_server(requests, expected_responses):
"""Feed newline-delimited requests to the stdio server, return the responses.

Stdin is held open until the expected responses arrive: closing it early
tears the server down before an in-flight request can be answered.
"""
process = subprocess.Popen(
[sys.executable, "-m", "markitdown_mcp"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
)
responses = []
try:
for request in requests:
process.stdin.write(json.dumps(request) + "\n")
process.stdin.flush()

reader = threading.Thread(
target=lambda: [
responses.append(json.loads(line))
for line in process.stdout
if line.strip()
],
daemon=True,
)
reader.start()

deadline = time.monotonic() + 60
while len(responses) < expected_responses and time.monotonic() < deadline:
time.sleep(0.05)
finally:
process.stdin.close()
process.terminate()
process.wait(timeout=10)
return sorted(responses, key=lambda response: response["id"])


def test_legacy_initialize_handshake(fixture_uri):
"""A client opening with `initialize` gets the pre-2026 protocol."""
responses = run_server(
[
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "test", "version": "0"},
},
},
{"jsonrpc": "2.0", "method": "notifications/initialized"},
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "convert_to_markdown",
"arguments": {"uri": fixture_uri},
},
},
],
expected_responses=2,
)

assert responses[0]["result"]["serverInfo"]["name"] == "markitdown"
call = responses[1]["result"]
assert call["isError"] is False
assert EXPECTED_MARKDOWN in call["content"][0]["text"]


def test_modern_discover_handshake(fixture_uri):
"""A client opening with `server/discover` gets the 2026-07-28 protocol."""
responses = run_server(
[
{
"jsonrpc": "2.0",
"id": 1,
"method": "server/discover",
"params": {"_meta": MODERN_ENVELOPE},
},
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "convert_to_markdown",
"arguments": {"uri": fixture_uri},
"_meta": MODERN_ENVELOPE,
},
},
],
expected_responses=2,
)

discover = responses[0]["result"]
assert "2026-07-28" in discover["supportedVersions"]
call = responses[1]["result"]
assert call["isError"] is False
assert EXPECTED_MARKDOWN in call["content"][0]["text"]


def test_unknown_method_does_not_kill_the_server(fixture_uri):
"""An unrecognized method is answered, and the session stays usable."""
responses = run_server(
[
{"jsonrpc": "2.0", "id": 1, "method": "totally/bogus", "params": {}},
{
"jsonrpc": "2.0",
"id": 2,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "test", "version": "0"},
},
},
],
expected_responses=2,
)

assert "error" in responses[0]
assert responses[1]["result"]["serverInfo"]["name"] == "markitdown"


if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
Loading