├── .github └── workflows │ └── test.yml ├── .gitignore ├── README.md ├── package.json ├── pyproject.toml ├── requirements-dev.txt ├── requirements-test.txt ├── scripts ├── build.sh ├── lint.sh └── test.sh ├── src ├── exceptions.py ├── logger.py ├── uvicorn.py └── worker.py ├── tests ├── pytest.ini └── test_worker.py ├── vendor.txt └── wrangler.jsonc /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test Deploy CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | vendoring: 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, macos-latest] # , windows-latest 16 | runs-on: ${{ matrix.os }} 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Python 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: '3.12' 26 | 27 | - name: Install Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: 22 31 | 32 | - name: Install project dependencies 33 | run: npm install 34 | 35 | - name: Run build with npm script 36 | run: npm run build 37 | 38 | - name: Lint and format 39 | run: npm run lint 40 | 41 | - name: Test worker deployment 42 | run: npm run deploy -- --dry-run 43 | 44 | - name: Run tests 45 | run: npm run test 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | .venv-pyodide/ 3 | .pytest_cache/ 4 | node_modules/ 5 | package-lock.json 6 | __pycache__/ 7 | src/vendor/ 8 | .vscode/ 9 | .wrangler/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Workers: FastMCP Example 2 | 3 | This is an example of a Python Worker that uses the FastMCP package. 4 | 5 | [![Deploy to Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/ai/tree/main/demos/python-workers-mcp) 6 | 7 | >[!NOTE] 8 | >Due to the [size](https://developers.cloudflare.com/workers/platform/limits/#worker-size) of the Worker, this example can only be deployed if you're using the Workers Paid plan. Free plan users will encounter deployment errors because this Worker exceeds the 3MB size limit. 9 | 10 | ## Adding Packages 11 | 12 | Vendored packages are added to your source files and need to be installed in a special manner. The Python Workers team plans to make this process automatic in the future, but for now, manual steps need to be taken. 13 | 14 | ### Vendoring Packages 15 | 16 | First, install Python3.12 and pip for Python 3.12. 17 | 18 | *Currently, other versions of Python will not work - use 3.12!* 19 | 20 | Then set up your local pyodide virtual environment: 21 | ```console 22 | npm run build 23 | ``` 24 | 25 | ### Developing and Deploying 26 | 27 | To develop your Worker run: 28 | ```console 29 | npm run dev 30 | ``` 31 | 32 | To deploy your Worker run: 33 | ```console 34 | npm run deploy 35 | ``` 36 | 37 | ### Testing 38 | 39 | To test run: 40 | ```console 41 | npm run test 42 | ``` 43 | 44 | ### Linting and Formatting 45 | 46 | This project uses Ruff for linting and formatting: 47 | 48 | ```console 49 | npm run lint 50 | ``` 51 | 52 | ### IDE Integration 53 | 54 | To have good autocompletions in your IDE simply select .venv-pyodide/bin/python as your IDE's interpreter. 55 | 56 | You should also install your dependencies for type hints. 57 | 58 | ```console 59 | .venv-pyodide/bin/pip install -r requirements-dev.txt 60 | ``` 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "python-workers-mcp", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "Python Workers MCP Demo", 6 | "scripts": { 7 | "build": "./scripts/build.sh", 8 | "test": "./scripts/test.sh", 9 | "lint": "./scripts/lint.sh", 10 | "dev": "wrangler dev", 11 | "deploy": "wrangler deploy" 12 | }, 13 | "devDependencies": { 14 | "wrangler": "^4.14.0" 15 | } 16 | } -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | target-version = "py312" 3 | line-length = 100 4 | [tool.ruff.lint] 5 | select = [ 6 | "E", # pycodestyle errors 7 | "F", # pyflakes 8 | "B", # flake8-bugbear 9 | "I", # isort 10 | "C4", # flake8-comprehensions 11 | "UP", # pyupgrade 12 | "N", # pep8-naming 13 | "RUF", # ruff-specific rules 14 | ] 15 | ignore = [] 16 | 17 | [tool.ruff.lint.isort] 18 | known-first-party = ["src"] 19 | 20 | [tool.ruff.format] 21 | quote-style = "double" 22 | indent-style = "space" 23 | line-ending = "auto" 24 | skip-magic-trailing-comma = false 25 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | requests 3 | mcp 4 | pytest-asyncio 5 | ruff 6 | mcp 7 | structlog 8 | webtypy 9 | pyodide-py -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | requests 3 | mcp 4 | pytest-asyncio 5 | ruff -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Check if Python 3.12 is installed 5 | if ! command -v python3.12 &> /dev/null; then 6 | echo "Error: Python 3.12 is required but not installed." 7 | exit 1 8 | fi 9 | 10 | # Create Python virtual environment 11 | if [ ! -d ".venv" ]; then 12 | echo "Creating Python virtual environment (.venv)..." 13 | python3.12 -m venv .venv 14 | else 15 | echo "Using existing Python virtual environment (.venv)..." 16 | fi 17 | 18 | 19 | # Create pyodide virtual environment if it doesn't exist 20 | if [ ! -d ".venv-pyodide" ]; then 21 | # Activate the Python virtual environment 22 | echo "Activating Python virtual environment..." 23 | source .venv/bin/activate 24 | 25 | # Install pyodide-build in the Python venv 26 | echo "Installing pyodide-build..." 27 | pip install pyodide-build 28 | 29 | echo "Creating pyodide virtual environment (.venv-pyodide)..." 30 | pyodide venv .venv-pyodide 31 | 32 | # Deactivate the virtual environment 33 | echo "Deactivating Python virtual environment..." 34 | deactivate 35 | else 36 | echo "Using existing pyodide virtual environment (.venv-pyodide)..." 37 | fi 38 | 39 | # Download vendored packages 40 | echo "Installing vendored packages from vendor.txt..." 41 | .venv-pyodide/bin/pip install -t src/vendor -r vendor.txt 42 | 43 | echo "Build completed successfully!" 44 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | source .venv/bin/activate 2 | pip install ruff 3 | ruff format . # Format code 4 | ruff check . # Run linting -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | source .venv/bin/activate 2 | pip install -r requirements-test.txt 3 | pytest tests -------------------------------------------------------------------------------- /src/exceptions.py: -------------------------------------------------------------------------------- 1 | from starlette.exceptions import HTTPException 2 | from starlette.requests import Request 3 | from starlette.responses import PlainTextResponse, Response 4 | 5 | from logger import logger 6 | 7 | 8 | async def http_exception(request: Request, exc: Exception) -> Response: 9 | assert isinstance(exc, HTTPException) 10 | logger.exception(exc) 11 | if exc.status_code in {204, 304}: 12 | return Response(status_code=exc.status_code, headers=exc.headers) 13 | return PlainTextResponse(exc.detail, status_code=exc.status_code, headers=exc.headers) 14 | -------------------------------------------------------------------------------- /src/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | import structlog 5 | 6 | # Create two handlers - one for stdout and one for stderr 7 | stdout_handler = logging.StreamHandler(sys.stdout) 8 | stderr_handler = logging.StreamHandler(sys.stderr) 9 | 10 | # Configure stdout handler to only handle INFO and DEBUG 11 | stdout_handler.setLevel(logging.DEBUG) 12 | stdout_handler.addFilter(lambda record: record.levelno <= logging.INFO) 13 | 14 | # Configure stderr handler to only handle WARNING and above 15 | stderr_handler.setLevel(logging.WARNING) 16 | 17 | # Get the root logger and add both handlers 18 | root_logger = logging.getLogger() 19 | root_logger.setLevel(logging.INFO) # Allow all logs to pass through 20 | root_logger.addHandler(stdout_handler) 21 | root_logger.addHandler(stderr_handler) 22 | 23 | structlog.configure( 24 | processors=[ 25 | structlog.stdlib.filter_by_level, 26 | structlog.contextvars.merge_contextvars, 27 | structlog.processors.add_log_level, 28 | structlog.processors.TimeStamper(fmt="iso"), 29 | structlog.processors.EventRenamer("message"), 30 | structlog.processors.StackInfoRenderer(), 31 | structlog.processors.ExceptionRenderer(), 32 | structlog.processors.JSONRenderer(), 33 | ], 34 | context_class=dict, 35 | logger_factory=structlog.stdlib.LoggerFactory(), 36 | cache_logger_on_first_use=True, 37 | ) 38 | 39 | # Get a logger 40 | logger: structlog.stdlib.BoundLogger = structlog.get_logger() 41 | -------------------------------------------------------------------------------- /src/uvicorn.py: -------------------------------------------------------------------------------- 1 | # This file must exist as a hack to satisfy mcp. 2 | # mcp has an optional dependency on uvicorn but still imports it at the top scope, see: 3 | # https://github.com/modelcontextprotocol/python-sdk/blob/main/src/mcp/server/fastmcp/server.py#L18 4 | # Because we never call `run_sse_async` this is not required. However, Python workers used asgi.py 5 | # rather than uvicorn which is why this hack is needed. With this, the import succeeds. 6 | -------------------------------------------------------------------------------- /src/worker.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | sys.path.insert(0, "/session/metadata/vendor") 4 | sys.path.insert(0, "/session/metadata") 5 | 6 | 7 | def setup_server(): 8 | from mcp.server.fastmcp import FastMCP 9 | 10 | from exceptions import HTTPException, http_exception 11 | mcp = FastMCP("Demo", stateless_http=True) 12 | 13 | @mcp.tool() 14 | def add(a: int, b: int) -> int: 15 | """Add two numbers""" 16 | return a + b 17 | 18 | @mcp.resource("greeting://{name}") 19 | def get_greeting(name: str) -> str: 20 | """Get a personalized greeting""" 21 | return f"Hello, {name}!" 22 | 23 | @mcp.tool() 24 | def calculate_bmi(weight_kg: float, height_m: float) -> float: 25 | """Calculate BMI given weight in kg and height in meters""" 26 | return weight_kg / (height_m**2) 27 | 28 | @mcp.prompt() 29 | def echo_prompt(message: str) -> str: 30 | """Create an echo prompt""" 31 | return f"Please process this message: {message}" 32 | 33 | app = mcp.streamable_http_app() 34 | app.add_exception_handler(HTTPException, http_exception) 35 | return mcp, app 36 | 37 | 38 | async def on_fetch(request, env, ctx): 39 | mcp, app = setup_server() 40 | import asgi 41 | 42 | return await asgi.fetch(app, request, env, ctx) 43 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | asyncio_default_fixture_loop_scope = session 3 | -------------------------------------------------------------------------------- /tests/test_worker.py: -------------------------------------------------------------------------------- 1 | import os 2 | import signal 3 | import socket 4 | import subprocess 5 | import time 6 | 7 | import pytest 8 | import requests 9 | from mcp import ClientSession 10 | from mcp.client.streamable_http import streamablehttp_client 11 | from mcp.types import ( 12 | CallToolResult, 13 | ListPromptsResult, 14 | ListResourcesResult, 15 | ListToolsResult, 16 | Prompt, 17 | PromptArgument, 18 | TextContent, 19 | Tool, 20 | ) 21 | 22 | 23 | def get_free_port(): 24 | """Find an available port on localhost.""" 25 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 26 | s.bind(("localhost", 0)) 27 | return s.getsockname()[1] 28 | 29 | 30 | class WorkerFixture: 31 | def __init__(self): 32 | self.process = None 33 | self.port = None 34 | self.base_url = None 35 | 36 | def start(self): 37 | """Start the worker in a subprocess.""" 38 | self.port = get_free_port() 39 | self.base_url = f"http://localhost:{self.port}" 40 | 41 | # Start the worker as a subprocess 42 | cmd = f"npx wrangler@latest dev --port {self.port}" 43 | self.process = subprocess.Popen( 44 | cmd, 45 | shell=True, 46 | preexec_fn=os.setsid, # So we can kill the process group later 47 | ) 48 | 49 | # Wait for server to start 50 | self._wait_for_server() 51 | 52 | return self 53 | 54 | def _wait_for_server(self, max_retries=10, retry_interval=1): 55 | """Wait until the server is responding to requests.""" 56 | for _ in range(max_retries): 57 | try: 58 | response = requests.get(self.base_url, timeout=20) 59 | if response.status_code < 500: # Accept any non-server error response 60 | return 61 | except requests.exceptions.RequestException: 62 | pass 63 | 64 | time.sleep(retry_interval) 65 | 66 | # If we got here, the server didn't start properly 67 | self.stop() 68 | raise Exception(f"worker failed to start on port {self.port}") 69 | 70 | def stop(self): 71 | """Stop the worker.""" 72 | if self.process: 73 | # Kill the process group (including any child processes) 74 | os.killpg(os.getpgid(self.process.pid), signal.SIGTERM) 75 | self.process = None 76 | 77 | 78 | @pytest.fixture(scope="session") 79 | def web_server(): 80 | """Pytest fixture that starts the worker for the entire test session.""" 81 | server = WorkerFixture() 82 | server.start() 83 | yield server 84 | server.stop() 85 | 86 | 87 | def test_nonexistent_page(web_server): 88 | """Test that a non-existent page returns a 404 status code.""" 89 | response = requests.get(f"{web_server.base_url}/nonexistent") 90 | assert response.status_code == 404 91 | 92 | 93 | @pytest.mark.asyncio 94 | async def test_sse_connection(web_server): 95 | """Test that we can establish a connection to the SSE endpoint.""" 96 | async with streamablehttp_client(f"{web_server.base_url}/mcp") as (read, write, _): 97 | async with ClientSession( 98 | read, 99 | write, 100 | ) as session: 101 | await session.initialize() 102 | 103 | # List available prompts 104 | prompts = await session.list_prompts() 105 | assert prompts == ListPromptsResult( 106 | prompts=[ 107 | Prompt( 108 | name="echo_prompt", 109 | description="Create an echo prompt", 110 | arguments=[ 111 | PromptArgument( 112 | name="message", 113 | description=None, 114 | required=True, 115 | ) 116 | ], 117 | ) 118 | ] 119 | ) 120 | 121 | # List available resources 122 | resources = await session.list_resources() 123 | assert resources == ListResourcesResult(resources=[]) 124 | 125 | # List available tools 126 | tools = await session.list_tools() 127 | assert tools == ListToolsResult( 128 | tools=[ 129 | Tool( 130 | name="add", 131 | description="Add two numbers", 132 | inputSchema={ 133 | "properties": { 134 | "a": {"title": "A", "type": "integer"}, 135 | "b": {"title": "B", "type": "integer"}, 136 | }, 137 | "required": ["a", "b"], 138 | "title": "addArguments", 139 | "type": "object", 140 | }, 141 | ), 142 | Tool( 143 | name="calculate_bmi", 144 | description="Calculate BMI given weight in kg and height in meters", 145 | inputSchema={ 146 | "properties": { 147 | "weight_kg": {"title": "Weight Kg", "type": "number"}, 148 | "height_m": {"title": "Height M", "type": "number"}, 149 | }, 150 | "required": ["weight_kg", "height_m"], 151 | "title": "calculate_bmiArguments", 152 | "type": "object", 153 | }, 154 | ), 155 | ] 156 | ) 157 | 158 | # Call a tool 159 | result = await session.call_tool("add", arguments={"a": 1, "b": 2}) 160 | assert result == CallToolResult(content=[TextContent(text="3", type="text")]) 161 | -------------------------------------------------------------------------------- /vendor.txt: -------------------------------------------------------------------------------- 1 | mcp 2 | structlog -------------------------------------------------------------------------------- /wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastapi-worker", 3 | "main": "src/worker.py", 4 | "compatibility_flags": [ 5 | "python_workers", 6 | ], 7 | "compatibility_date": "2025-04-10", 8 | "vars": { 9 | "API_HOST": "example.com" 10 | }, 11 | "rules": [ 12 | { 13 | "globs": [ 14 | "vendor/**/" 15 | ], 16 | "type": "Data", 17 | "fallthrough": true 18 | } 19 | ], 20 | "observability": { 21 | "enabled": true 22 | } 23 | } --------------------------------------------------------------------------------