Skip to content

Commit 47364c9

Browse files
committed
FastMCP working
1 parent e817d75 commit 47364c9

File tree

6 files changed

+70
-119
lines changed

6 files changed

+70
-119
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
.venv/
22
node_modules/
33
src/vendor/
4-
.vscode/
4+
.vscode/
5+
.wrangler/

README.md

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,13 @@
1-
# Vendoring Packages: FastAPI + Jinja2 Example
1+
# Python Workers: FastMCP Example
22

3-
*Note: You must have Python Packages enabled on your account for built-in packages to work. Request Access to our Closed Beta using [This Form](https://forms.gle/FcjjhV3YtPyjRPaL8)*
4-
5-
This is an example of a Python Worker that uses a built-in package (FastAPI) with a vendored package (Jinja2).
3+
This is an example of a Python Worker that uses the FastMCP package.
64

75
## Adding Packages
86

9-
Built-in packages can be selected from [this list](https://developers.cloudflare.com/workers/languages/python/packages/#supported-packages) and added to your `requirements.txt` file. These can be used with no other explicit install step.
10-
117
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.
128

139
### Vendoring Packages
1410

15-
[//]: # (NOTE: when updating the instructions below, be sure to also update the vendoring.yml CI workflow)
16-
1711
First, install Python3.12 and pip for Python 3.12.
1812

1913
*Currently, other versions of Python will not work - use 3.12!*
@@ -30,34 +24,11 @@ Within our virtual environment, install the pyodide CLI:
3024
.venv/bin/pyodide venv .venv-pyodide
3125
```
3226

33-
Next, add packages to your vendor.txt file. Here we'll add jinja2
34-
```
35-
jinja2
36-
```
37-
38-
Lastly, add these packages to your source files at `src/vendor`. For any additional packages, re-run this command.
27+
Lastly, download the vendored packages. For any additional packages, re-run this command.
3928
```console
4029
.venv-pyodide/bin/pip install -t src/vendor -r vendor.txt
4130
```
4231

43-
### Using Vendored packages
44-
45-
In your wrangler.toml, make the vendor directory available:
46-
47-
```toml
48-
[[rules]]
49-
globs = ["vendor/**"]
50-
type = "Data"
51-
fallthrough = true
52-
```
53-
54-
Now, you can import and use the packages:
55-
56-
```python
57-
import jinja2
58-
# ... etc ...
59-
```
60-
6132
### Developing and Deploying
6233

6334
To develop your Worker, run `npx wrangler@latest dev`.

src/asgi.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -274,14 +274,12 @@ async def process_request(app, req, env):
274274
await receive_queue.put({"body": b"", "more_body": False, "type": "http.request"})
275275

276276
async def receive():
277-
print("Receiving")
278277
message = None
279278
if not receive_queue.empty():
280279
message = await receive_queue.get()
281280
else:
282281
await finished_response.wait()
283282
message = {"type": "http.disconnect"}
284-
print(f"Received {message}")
285283
return message
286284

287285
# Create a transform stream for handling streaming responses
@@ -295,7 +293,6 @@ async def send(got: "ASGISendEvent"):
295293
nonlocal headers
296294
nonlocal is_sse
297295

298-
print(got)
299296
if got["type"] == "http.response.start":
300297
status = got["status"]
301298
# Like above, we need to convert byte-pairs into string explicitly.
@@ -305,7 +302,6 @@ async def send(got: "ASGISendEvent"):
305302
if k.lower() == "content-type" and v.lower().startswith(
306303
"text/event-stream"
307304
):
308-
print("SSE RESPONSE")
309305
is_sse = True
310306

311307
# For SSE, create and return the response immediately after http.response.start
@@ -318,7 +314,6 @@ async def send(got: "ASGISendEvent"):
318314
elif got["type"] == "http.response.body":
319315
body = got["body"]
320316
more_body = got.get("more_body", False)
321-
print(f"{body=}, {more_body=}")
322317

323318
# Convert body to JS buffer
324319
px = create_proxy(body)
@@ -367,7 +362,6 @@ async def run_app():
367362
# For non-SSE responses, we need to wait for the application to complete
368363
if not is_sse:
369364
await app_task
370-
print(f"Returning response! {is_sse}")
371365
return response
372366

373367

src/uvicorn.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
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.

src/worker.py

Lines changed: 49 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,52 @@
1-
# from pydantic import BaseModel, create_model
21

3-
# from fastapi import FastAPI, Request
4-
# from fastapi_mcp import FastApiMCP
2+
from workers import DurableObject
3+
4+
5+
class FastMCPServer(DurableObject):
6+
def __init__(self, state, env):
7+
from exceptions import HTTPException, http_exception
8+
from mcp.server.fastmcp import FastMCP
9+
self.state = state
10+
mcp = FastMCP("Demo")
11+
12+
@mcp.tool()
13+
def add(a: int, b: int) -> int:
14+
"""Add two numbers"""
15+
return a + b
16+
17+
@mcp.resource("greeting://{name}")
18+
def get_greeting(name: str) -> str:
19+
"""Get a personalized greeting"""
20+
return f"Hello, {name}!"
21+
22+
@mcp.tool()
23+
def calculate_bmi(weight_kg: float, height_m: float) -> float:
24+
"""Calculate BMI given weight in kg and height in meters"""
25+
return weight_kg / (height_m**2)
26+
27+
@mcp.prompt()
28+
def echo_prompt(message: str) -> str:
29+
"""Create an echo prompt"""
30+
return f"Please process this message: {message}"
31+
32+
# mcp depends on uvicorn and imports it at the top scope that we have to patch that to move the
33+
# import into the function that uses it.
34+
# TODO(now): Change uvicorn to optional in mcp
35+
app = mcp.sse_app()
36+
# Starlette default http exception handler is sync which starlette tries to run in threadpool
37+
# in https://github.com/encode/starlette/blob/master/starlette/_exception_handler.py#L61.
38+
# Since we don't support threads we need to override it with the same function but async.
39+
# TODO(now): change starlette's http_exception to be async, it is strictly slower to spawn a new
40+
# thread
41+
app.add_exception_handler(HTTPException, http_exception)
42+
self.mcp = mcp
43+
self.app = app
44+
45+
async def on_fetch(self, request, env, ctx):
46+
import asgi
47+
return await asgi.fetch(self.app, request, env)
548

6-
############## NORMAL ##############
7-
8-
# from js import Response
9-
10-
# async def on_fetch(request, env):
11-
# return Response.new("Hello")
12-
13-
####################################
14-
15-
############## FASTMCP ##############
16-
from exceptions import HTTPException, http_exception
17-
from mcp.server.fastmcp import FastMCP
18-
19-
mcp = FastMCP("Demo")
20-
21-
@mcp.tool()
22-
def add(a: int, b: int) -> int:
23-
"""Add two numbers"""
24-
return a + b
25-
26-
@mcp.resource("greeting://{name}")
27-
def get_greeting(name: str) -> str:
28-
"""Get a personalized greeting"""
29-
return f"Hello, {name}!"
30-
31-
@mcp.tool()
32-
def calculate_bmi(weight_kg: float, height_m: float) -> float:
33-
"""Calculate BMI given weight in kg and height in meters"""
34-
return weight_kg / (height_m**2)
35-
36-
@mcp.prompt()
37-
def echo_prompt(message: str) -> str:
38-
"""Create an echo prompt"""
39-
return f"Please process this message: {message}"
40-
41-
# mcp depends on uvicorn and imports it at the top scope that we have to patch that to move the
42-
# import into the function that uses it.
43-
# TODO(now): Change uvicorn to optional in mcp
44-
app = mcp.sse_app()
45-
# Starlette default http exception handler is sync which starlette tries to run in threadpool
46-
# in https://github.com/encode/starlette/blob/master/starlette/_exception_handler.py#L61.
47-
# Since we don't support threads we need to override it with the same function but async.
48-
# TODO(now): change starlette's http_exception to be async, it is strictly slower to spawn a new
49-
# thread
50-
app.add_exception_handler(HTTPException, http_exception)
5149
async def on_fetch(request, env):
52-
import asgi
53-
return await asgi.fetch(app, request, env)
54-
55-
#####################################
56-
57-
############## FASTAPI ##############
58-
# from fastapi import FastAPI, Request
59-
60-
# app = FastAPI()
61-
62-
63-
# @app.get("/")
64-
# async def root():
65-
# message = "This is an example of FastAPI"
66-
# return {"message": message}
67-
68-
69-
# @app.get("/env")
70-
# async def env(req: Request):
71-
# env = req.scope["env"]
72-
# return {
73-
# "message": "Here is an example of getting an environment variable: "
74-
# + env.MESSAGE
75-
# }
76-
77-
# async def on_fetch(request, env):
78-
# import asgi
79-
# return await asgi.fetch(app, request, env)
80-
81-
#####################################
50+
id = env.ns.idFromName("A")
51+
obj = env.ns.get(id)
52+
return await obj.fetch(request)

wrangler.jsonc

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
"name": "fastapi-worker",
33
"main": "src/worker.py",
44
"compatibility_flags": [
5-
"python_workers"
5+
"python_workers",
6+
"python_workers_durable_objects"
67
],
78
"compatibility_date": "2025-04-10",
89
"vars": {
@@ -16,5 +17,13 @@
1617
"type": "Data",
1718
"fallthrough": true
1819
}
19-
]
20+
],
21+
"durable_objects": {
22+
"bindings": [
23+
{
24+
"name": "ns",
25+
"class_name": "FastMCPServer"
26+
}
27+
]
28+
}
2029
}

0 commit comments

Comments
 (0)