diff --git a/.gitignore b/.gitignore index 815e444..ce6ab6c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ pylock.toml .venv-workers .pytest_cache .ruff_cache + +**/.DS_Store +**/default.profraw diff --git a/README.md b/README.md index 218e1f4..311cc74 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Need to deploy your Worker to Cloudflare? Python Workers are in open beta and ha - [**`binding/`**](binding) — shows how [bindings](https://developers.cloudflare.com/workers/configuration/bindings/) work in Python Workers. Put a key into Workers KV, and then read it. - [**`fastapi/`**](fastapi) — demonstrates how to use the [FastAPI](https://fastapi.tiangolo.com/) package with Python Workers - [**`query-d1/`**](query-d1) - shows how to query D1 with Python Workers +- [**`mcp-server/`**](mcp-server) — an MCP server backed by D1. - [**`langchain/`**](langchain) — demonstrates how to use the [LangChain](https://pypi.org/project/langchain/) package with Python Workers. Currently broken. - [**`assets/`**](assets) — An example with an assets binding. - [**`durable-objects/`**](durable-objects) — An example with storing state in a [Durable Object](https://developers.cloudflare.com/durable-objects/). diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 0000000..3fa1f1d --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,59 @@ +# MCP Server with D1 + +[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/python-workers-examples/tree/main/mcp-server) + +This example shows how to create a simple [Model Context Protocol](https://modelcontextprotocol.io/) server. +It uses the official [Python MCP SDK](https://py.sdk.modelcontextprotocol.io/) and a D1 database. + +It implements 3 tools, as a simple incident management system: + +- `open_incident(title, severity)` +- `add_update(id, note)` +- `list_open_incidents()` + +This is based on the stateless MCP protocol version `2026-07-28`. + +## Development + +Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then +initialize local D1 and start the Worker: + +```sh +uv run pywrangler d1 migrations apply mcp-incidents --local +uv run pywrangler dev +``` + +Use modern JSON-RPC requests with the protocol header. For example, discover +the server and list its tools: + +```sh +curl -X POST http://localhost:8787/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H 'MCP-Protocol-Version: 2026-07-28' \ + -H 'Mcp-Method: server/discover' \ + --data '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' + +curl -X POST http://localhost:8787/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H 'MCP-Protocol-Version: 2026-07-28' \ + -H 'Mcp-Method: tools/call' \ + -H 'Mcp-Name: open_incident' \ + --data '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}},"name":"open_incident","arguments":{"title":"API latency","severity":"high"}}}' +``` + +You can also use your favorite MCP client to test the server. For example, if you are using VS Code, add the following to your settings: + +```json +{ + "servers": { + "incident-server": { + "type": "http", + "url": "http://localhost:8787/mcp" + } + } +} +``` + +and your MCP client should be able to discover and use the server. diff --git a/mcp-server/migrations/0001_schema.sql b/mcp-server/migrations/0001_schema.sql new file mode 100644 index 0000000..75faab4 --- /dev/null +++ b/mcp-server/migrations/0001_schema.sql @@ -0,0 +1,15 @@ +CREATE TABLE incidents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + severity TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE incident_updates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + incident_id INTEGER NOT NULL REFERENCES incidents(id) ON DELETE CASCADE, + note TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/mcp-server/package.json b/mcp-server/package.json new file mode 100644 index 0000000..b79350e --- /dev/null +++ b/mcp-server/package.json @@ -0,0 +1,13 @@ +{ + "name": "mcp-server", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "uv run pywrangler deploy", + "dev": "uv run pywrangler dev", + "start": "uv run pywrangler dev" + }, + "devDependencies": { + "wrangler": "^4.114.0" + } +} diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml new file mode 100644 index 0000000..effcb13 --- /dev/null +++ b/mcp-server/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "mcp-server" +version = "0.0.0" +description = "An MCP incident server using Python Workers and D1" +readme = "README.md" +requires-python = ">=3.14" +dependencies = [ + "mcp==2.2.0" +] + +[dependency-groups] +dev = [ + "workers-py", + "workers-runtime-sdk" +] diff --git a/mcp-server/src/entry.py b/mcp-server/src/entry.py new file mode 100644 index 0000000..3d7d18a --- /dev/null +++ b/mcp-server/src/entry.py @@ -0,0 +1,141 @@ +from urllib.parse import urlparse + +from workers import Response, WorkerEntrypoint, asgi + + +def build_app(env): + from mcp.server import MCPServer + + server = MCPServer("Incident server") + + @server.tool() + async def open_incident(title: str, severity: str) -> dict: + """Open an incident with a title and severity, then return the new incident.""" + if len(title) > 200 or len(severity) > 32: + return {"error": "title or severity is too long"} + + row = ( + await env.DB.prepare( + """ + INSERT INTO incidents (title, severity) + VALUES (?, ?) + RETURNING id, title, severity, status, created_at, updated_at + """ + ) + .bind(title, severity) + .first() + ) + return { + "id": row.id, + "title": row.title, + "severity": row.severity, + "status": row.status, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + @server.tool() + async def add_update(id: int, note: str) -> dict: + """Add a note to an open incident identified by id and return the update.""" + if len(note) > 4000: + return {"error": "note is too long", "id": id} + + incident = ( + await env.DB.prepare("SELECT id FROM incidents WHERE id = ?") + .bind(id) + .first() + ) + if incident is None: + return {"error": "incident not found", "id": id} + + results = await env.DB.batch( + [ + env.DB.prepare( + """ + INSERT INTO incident_updates (incident_id, note) + VALUES (?, ?) + RETURNING id, incident_id, note, created_at + """ + ).bind(id, note), + env.DB.prepare( + "UPDATE incidents SET updated_at = CURRENT_TIMESTAMP WHERE id = ?" + ).bind(id), + ] + ) + update = results[0].results[0] + return { + "id": update.id, + "incident_id": update.incident_id, + "note": update.note, + "created_at": update.created_at, + } + + @server.tool() + async def list_open_incidents() -> list[dict]: + """List open incidents in newest-first order, including their chronological updates.""" + rows = await env.DB.prepare( + """ + SELECT + i.id AS id, + i.title AS title, + i.severity AS severity, + i.status AS status, + i.created_at AS created_at, + i.updated_at AS updated_at, + u.id AS update_id, + u.incident_id AS update_incident_id, + u.note AS update_note, + u.created_at AS update_created_at + FROM incidents i + LEFT JOIN incident_updates u ON u.incident_id = i.id + WHERE i.status = 'open' + ORDER BY i.created_at DESC, i.id DESC, u.created_at ASC, u.id ASC + """ + ).all() + + incidents_by_id = {} + result = [] + for row in rows.results: + incident = incidents_by_id.get(row.id) + if incident is None: + incident = { + "id": row.id, + "title": row.title, + "severity": row.severity, + "status": row.status, + "created_at": row.created_at, + "updated_at": row.updated_at, + "updates": [], + } + incidents_by_id[row.id] = incident + result.append(incident) + if row.update_id is not None: + incident["updates"].append( + { + "id": row.update_id, + "incident_id": row.update_incident_id, + "note": row.update_note, + "created_at": row.update_created_at, + } + ) + return result + + app = server.streamable_http_app( + streamable_http_path="/mcp", + stateless_http=True, + ) + + return app + + +class Default(WorkerEntrypoint): + def __init__(self, ctx, env): + super().__init__(ctx, env) + self.app = build_app(env) + + async def fetch(self, request): + path = urlparse(request.url).path + if path == "/mcp" and request.method == "POST": + return await asgi.fetch(self.app, request, self.env) + + return Response("Not found", status=404) diff --git a/mcp-server/wrangler.jsonc b/mcp-server/wrangler.jsonc new file mode 100644 index 0000000..e663d7e --- /dev/null +++ b/mcp-server/wrangler.jsonc @@ -0,0 +1,21 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "mcp-server", + "main": "src/entry.py", + "compatibility_date": "2026-09-07", + "compatibility_flags": [ + "python_workers", + "python_workers_314" + ], + "d1_databases": [ + { + "binding": "DB", + "database_name": "mcp-incidents", + "database_id": "00000000-0000-0000-0000-000000000000", // Replace with your D1 database UUID. + "migrations_dir": "./migrations" + } + ], + "observability": { + "enabled": true + } +} diff --git a/tests/test_examples.py b/tests/test_examples.py index 0dbb96d..a44e7f0 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,3 +1,4 @@ +import json import re import subprocess import uuid @@ -409,3 +410,74 @@ def test_django_markdown_r2(dev_server): session.get(f"{base_url}/articles/missing-{uuid.uuid4().hex}/").status_code == 404 ) + + +@pytest.fixture +def init_mcp_server_db(): + subprocess.run( + [ + "uv", + "run", + "pywrangler", + "d1", + "migrations", + "apply", + "mcp-incidents", + "--local", + ], + cwd=REPO_ROOT / "mcp-server", + check=True, + ) + + +def mcp_json_response(response): + if "text/event-stream" not in response.headers.get("content-type", ""): + return response.json() + + messages = [ + json.loads(line.removeprefix("data: ")) + for line in response.text.splitlines() + if line.startswith("data: ") + ] + assert messages + return messages[-1] + + +def test_mcp_server(init_mcp_server_db, dev_server): + base = f"http://localhost:{dev_server}/mcp" + headers = { + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + "MCP-Protocol-Version": "2026-07-28", + } + meta = { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + } + + def request(method, params, request_id): + request_headers = {**headers, "Mcp-Method": method} + if method == "tools/call": + request_headers["Mcp-Name"] = params["name"] + response = requests.post( + base, + headers=request_headers, + json={ + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": {"_meta": meta, **params}, + }, + ) + assert response.status_code == 200 + return response + + discovered = mcp_json_response(request("server/discover", {}, 1)) + assert "result" in discovered + + tools = mcp_json_response(request("tools/list", {}, 2))["result"]["tools"] + assert {tool["name"] for tool in tools} == { + "open_incident", + "add_update", + "list_open_incidents", + }