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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ pylock.toml
.venv-workers
.pytest_cache
.ruff_cache

**/.DS_Store
**/default.profraw
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).
Expand Down
59 changes: 59 additions & 0 deletions mcp-server/README.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions mcp-server/migrations/0001_schema.sql
Original file line number Diff line number Diff line change
@@ -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
);
13 changes: 13 additions & 0 deletions mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
15 changes: 15 additions & 0 deletions mcp-server/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
]
141 changes: 141 additions & 0 deletions mcp-server/src/entry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
from urllib.parse import urlparse

from workers import Response, WorkerEntrypoint, asgi


def build_app(env):
from mcp.server import MCPServer
Comment thread
ryanking13 marked this conversation as resolved.

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)
21 changes: 21 additions & 0 deletions mcp-server/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -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
}
}
72 changes: 72 additions & 0 deletions tests/test_examples.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import re
import subprocess
import uuid
Expand Down Expand Up @@ -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",
}
Loading