diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ccd5f1c32..17dae41f9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 diff --git a/packages/markitdown-mcp/pyproject.toml b/packages/markitdown-mcp/pyproject.toml index 746253be5..f10cb7be2 100644 --- a/packages/markitdown-mcp/pyproject.toml +++ b/packages/markitdown-mcp/pyproject.toml @@ -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", ] diff --git a/packages/markitdown-mcp/src/markitdown_mcp/__main__.py b/packages/markitdown-mcp/src/markitdown_mcp/__main__.py index 89f89444e..6a0796717 100644 --- a/packages/markitdown-mcp/src/markitdown_mcp/__main__.py +++ b/packages/markitdown-mcp/src/markitdown_mcp/__main__.py @@ -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() @@ -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, ) - 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, ) @@ -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( @@ -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, diff --git a/packages/markitdown-mcp/tests/test_stdio_protocols.py b/packages/markitdown-mcp/tests/test_stdio_protocols.py new file mode 100644 index 000000000..cfe57757a --- /dev/null +++ b/packages/markitdown-mcp/tests/test_stdio_protocols.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 -m pytest +"""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"]))