Skip to content

Commit d68151a

Browse files
authored
feat: python mcp template (#43)
1 parent 509fffd commit d68151a

File tree

6 files changed

+341
-0
lines changed

6 files changed

+341
-0
lines changed

python/mcp/README.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Python MCP Function (HTTP)
2+
3+
Welcome to your new Python Function! This Function is an MCP server that
4+
handles any HTTP requests with MCP requests at `/mcp`. The MCP is implemented
5+
using [Python-SDK](https://github.com/modelcontextprotocol/python-sdk) library.
6+
7+
## Function
8+
9+
The main code lives in `function/func.py`.
10+
The Function itself is ASGI compatible, implementing `handle(scope,receive,send)`
11+
which is the entry for your function and all requests are handled here.
12+
13+
You can also use `start` and `stop` methods which are implemented and you can
14+
see them in the bottom of the `function/func.py` file.
15+
16+
## Project Structure
17+
18+
```
19+
├── function/
20+
│ ├── __init__.py
21+
│ └── func.py # Main function code
22+
├── client/
23+
│ └── client.py # Example MCP client
24+
├── tests/
25+
│ └── test_func.py # simple HTTP test
26+
└── pyproject.toml # Project configuration
27+
```
28+
29+
## MCP Server
30+
31+
The MCP server is implemented via a `MCPServer` class.
32+
- Uses `FastMCP()` function from the Python SDK lib mentioned above
33+
- Uses `streamable_http_app` transport which is a lower level function in the
34+
Python-SDK library that is plugged in directly.
35+
- Integrates with the Functions middleware without running its own server
36+
- Routes all incoming MCP requests to the MCP handler
37+
38+
## MCP Client
39+
40+
Since we are using the Python-SDK library, in order to communicate easily
41+
with the server we can use the Python-SDK Clients. You can refer to their
42+
[client docs](https://github.com/modelcontextprotocol/python-sdk?tab=readme-ov-file#writing-mcp-clients)
43+
for more information.
44+
45+
You can find an example implementation of a client in your function's `client/` directory.
46+
Refer to [Testing section](#testing) for how to run clients.
47+
48+
## Deployment
49+
50+
Before running your Function, it is recommended to create a virtual environment
51+
to isolate the project for easier dependency management.
52+
53+
Subsequently install necessary dependencies and you're all set up.
54+
55+
```bash
56+
# Create the virtual env
57+
python3 -m venv venv
58+
59+
# Optional: Activate the venv
60+
source venv/bin/activate
61+
62+
# Install dependencies
63+
pip install -e .
64+
```
65+
66+
## Testing
67+
68+
Tests can be found in `tests/` directory with a simple test for HTTP requests
69+
in `test_func.py`. To run tests:
70+
71+
```bash
72+
# Install dependencies (if not already installed)
73+
pip install -e .
74+
75+
# Run tests
76+
pytest
77+
78+
# Run tests with verbose output
79+
pytest -v
80+
```
81+
82+
For testing the MCP functionality, you can use the included client at `client/client.py`:
83+
84+
```bash
85+
# run your mcp server locally
86+
func run --builder=host --container=false
87+
88+
# in different terminal: run mcp client
89+
python client/client.py
90+
```
91+
92+
## Contact and Docs
93+
94+
Please share your functions or ask us questions in our CNCF Slack
95+
[Functions channel](https://cloud-native.slack.com/archives/C04LKEZUXEE)!
96+
97+
For more info about the Python Functions implementation itself, please visit
98+
[python template docs](https://github.com/knative/func/blob/main/docs/function-templates/python.md)
99+
and
100+
[python default http template](https://github.com/knative/func/tree/main/templates/python/http)
101+
102+
For even more, see [the complete documentation](https://github.com/knative/func/tree/main/docs)

python/mcp/client/client.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import asyncio
2+
from mcp import ClientSession
3+
from mcp.client.streamable_http import streamablehttp_client
4+
5+
async def main():
6+
# check your running Function MCP Server, it will output where its available
7+
# at during initialization.
8+
async with streamablehttp_client("http://localhost:8080/mcp") as streams:
9+
read_stream,write_stream = streams[0],streams[1]
10+
11+
async with ClientSession(read_stream,write_stream) as s:
12+
print("Initializing connection...",end="")
13+
await s.initialize()
14+
print("done!\n")
15+
16+
# List all available tools
17+
#tools = await s.list_tools()
18+
#print("--- List of tools ---")
19+
#print(tools.tools)
20+
21+
# Call hello tool which will greet Thomas
22+
hello_tool = await s.call_tool(
23+
name="hello_tool",
24+
arguments={"name": "Thomas"}
25+
)
26+
27+
# Print the actual content of the result
28+
print(hello_tool.content[0].text)
29+
30+
if __name__ == "__main__":
31+
asyncio.run(main())

python/mcp/function/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .func import new

python/mcp/function/func.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# function/func.py
2+
3+
# Function as an MCP Server implementation
4+
import logging
5+
6+
from mcp.server.fastmcp import FastMCP
7+
import asyncio
8+
9+
def new():
10+
""" New is the only method that must be implemented by a Function.
11+
The instance returned can be of any name.
12+
"""
13+
return Function()
14+
15+
class MCPServer:
16+
"""MCP server that exposes tools, resources, and prompts via the MCP protocol."""
17+
18+
def __init__(self):
19+
# Create FastMCP instance with stateless HTTP for Kubernetes deployment
20+
self.mcp = FastMCP("Function MCP Server", stateless_http=True)
21+
22+
self._register_tools()
23+
#self._register_resources()
24+
#self._register_prompts()
25+
26+
# Get the ASGI app from FastMCP
27+
self._app = self.mcp.streamable_http_app()
28+
29+
def _register_tools(self):
30+
"""Register MCP tools."""
31+
@self.mcp.tool()
32+
def hello_tool(name: str) -> str:
33+
"""Say hello to someone."""
34+
return f"Hey there {name}!"
35+
36+
@self.mcp.tool()
37+
def add_numbers(a: int, b: int) -> int:
38+
"""Add two numbers together."""
39+
return a + b
40+
41+
## Other MCP objects include resources and prompts.
42+
## Add them here to be registered in the same fashion as tools above.
43+
# def _register_resources(self):
44+
# """Register MCP resources."""
45+
# @self.mcp.resource("echo://{message}")
46+
# def echo_resource(message: str) -> str:
47+
# """Echo the message as a resource."""
48+
# return f"Echo: {message}"
49+
#
50+
# def _register_prompts(self):
51+
# """Register MCP prompts."""
52+
# @self.mcp.prompt()
53+
# def greeting_prompt(name: str = "Big Dave"):
54+
# """Generate a greeting prompt."""
55+
# return [
56+
# {
57+
# "role": "user",
58+
# "content": f"Please write a friendly greeting for {name}"
59+
# }
60+
# ]
61+
62+
async def handle(self, scope, receive, send):
63+
"""Handle ASGI requests - both lifespan and HTTP."""
64+
await self._app(scope, receive, send)
65+
66+
class Function:
67+
def __init__(self):
68+
""" The init method is an optional method where initialization can be
69+
performed. See the start method for a startup hook which includes
70+
configuration.
71+
"""
72+
self.mcp_server = MCPServer()
73+
self._mcp_initialized = False
74+
75+
async def handle(self, scope, receive, send):
76+
"""
77+
Main entry to your Function.
78+
This handles all the incoming requests.
79+
"""
80+
81+
# Initialize MCP server on first request
82+
if not self._mcp_initialized:
83+
await self._initialize_mcp()
84+
85+
# Route MCP requests
86+
if scope['path'].startswith('/mcp'):
87+
await self.mcp_server.handle(scope, receive, send)
88+
return
89+
90+
# Default response for non-MCP requests
91+
await self._send_default_response(send)
92+
93+
async def _initialize_mcp(self):
94+
"""Initialize the MCP server by sending lifespan startup event."""
95+
lifespan_scope = {'type': 'lifespan', 'asgi': {'version': '3.0'}}
96+
startup_sent = False
97+
98+
async def lifespan_receive():
99+
nonlocal startup_sent
100+
if not startup_sent:
101+
startup_sent = True
102+
return {'type': 'lifespan.startup'}
103+
await asyncio.Event().wait() # Wait forever for shutdown
104+
105+
async def lifespan_send(message):
106+
if message['type'] == 'lifespan.startup.complete':
107+
self._mcp_initialized = True
108+
elif message['type'] == 'lifespan.startup.failed':
109+
logging.error(f"MCP startup failed: {message}")
110+
111+
# Start lifespan in background
112+
asyncio.create_task(self.mcp_server.handle(
113+
lifespan_scope, lifespan_receive, lifespan_send
114+
))
115+
116+
# Brief wait for startup completion
117+
await asyncio.sleep(0.1)
118+
119+
async def _send_default_response(self, send):
120+
"""
121+
Send default OK response.
122+
This is for your non MCP requests if desired.
123+
"""
124+
await send({
125+
'type': 'http.response.start',
126+
'status': 200,
127+
'headers': [[b'content-type', b'text/plain']],
128+
})
129+
await send({
130+
'type': 'http.response.body',
131+
'body': b'OK',
132+
})
133+
134+
def start(self, cfg):
135+
logging.info("Function starting")
136+
137+
def stop(self):
138+
logging.info("Function stopping")
139+
140+
def alive(self):
141+
return True, "Alive"
142+
143+
def ready(self):
144+
return True, "Ready"

python/mcp/pyproject.toml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
[project]
2+
name = "function"
3+
description = ""
4+
version = "0.1.0"
5+
requires-python = ">=3.9"
6+
readme = "README.md"
7+
license = "MIT"
8+
dependencies = [
9+
"httpx",
10+
"pytest",
11+
"pytest-asyncio",
12+
"mcp"
13+
]
14+
authors = [
15+
{ name="Your Name", email="you@example.com"},
16+
]
17+
18+
[build-system]
19+
requires = ["hatchling"]
20+
build-backend = "hatchling.build"
21+
22+
[tool.pytest.ini_options]
23+
asyncio_mode = "strict"
24+
asyncio_default_fixture_loop_scope = "function"
25+

python/mcp/tests/test_func.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""
2+
An example set of unit tests which confirm that the main handler (the
3+
callable function) returns 200 OK for a simple HTTP GET.
4+
"""
5+
import pytest
6+
from function import new
7+
8+
9+
@pytest.mark.asyncio
10+
async def test_function_handle():
11+
f = new() # Instantiate Function to Test
12+
13+
sent_ok = False
14+
sent_headers = False
15+
sent_body = False
16+
17+
# Mock Send
18+
async def send(message):
19+
nonlocal sent_ok
20+
nonlocal sent_headers
21+
nonlocal sent_body
22+
23+
if message.get('status') == 200:
24+
sent_ok = True
25+
26+
if message.get('type') == 'http.response.start':
27+
sent_headers = True
28+
29+
if message.get('type') == 'http.response.body':
30+
sent_body = True
31+
32+
# Invoke the Function
33+
await f.handle({}, {}, send)
34+
35+
# Assert send was called
36+
assert sent_ok, "Function did not send a 200 OK"
37+
assert sent_headers, "Function did not send headers"
38+
assert sent_body, "Function did not send a body"

0 commit comments

Comments
 (0)