Skip to content

Commit 0bda5c3

Browse files
Add MCP server support with FastMCP integration and decorators
Co-authored-by: itay.dar <itay.dar@lemonade.com>
1 parent fb56de2 commit 0bda5c3

File tree

8 files changed

+257
-0
lines changed

8 files changed

+257
-0
lines changed

README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,54 @@ PyNest is [MIT licensed](LICENSE).
186186
## Credits
187187

188188
PyNest is inspired by [NestJS](https://nestjs.com/).
189+
190+
## MCP servers with FastMCP
191+
192+
Build MCP servers that reuse the same modules, services, and controllers as your FastAPI and CLI apps.
193+
194+
Install optional dependency:
195+
196+
```bash
197+
pip install pynest-api[mcp]
198+
```
199+
200+
Define an MCP controller with tools, resources, and prompts:
201+
202+
```python
203+
from nest.core import Module, Injectable, McpController, McpTool, McpResource, McpPrompt
204+
from nest.core import MCPFactory
205+
206+
@Injectable
207+
class MathService:
208+
def add(self, a: int, b: int) -> int:
209+
return a + b
210+
211+
@McpController()
212+
class MathController:
213+
def __init__(self, math: MathService):
214+
self.math = math
215+
216+
@McpTool("add")
217+
def add_tool(self, a: int, b: int) -> int:
218+
return self.math.add(a, b)
219+
220+
@McpResource("greeting://{name}")
221+
def greeting(self, name: str) -> str:
222+
return f"Hello, {name}!"
223+
224+
@McpPrompt("summarize")
225+
def summarize(self, text: str) -> str:
226+
return f"Summarize: {text}"
227+
228+
@Module(controllers=[MathController], providers=[MathService])
229+
class AppModule:
230+
pass
231+
232+
# Create MCP app
233+
mcp_app = MCPFactory.create(AppModule, name="Demo MCP")
234+
server = mcp_app.get_server()
235+
# Run with desired transport, e.g. stdio
236+
server.run(transport="stdio")
237+
```
238+
239+
You can keep your FastAPI app alongside it using `PyNestFactory.create(AppModule)` and your CLI with `CLIAppFactory().create(AppModule)`.

nest/common/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from nest.common.mcp_resolver import MCPResolver

nest/common/mcp_resolver.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from typing import Any
2+
3+
4+
class MCPResolver:
5+
def __init__(self, container: Any, server: Any):
6+
# Type of server is FastMCP, but keep Any to avoid hard import at module import time
7+
self.container = container
8+
self.server = server
9+
10+
def register(self):
11+
for module in self.container.modules.values():
12+
for controller in module.controllers.values():
13+
self._register_controller(controller)
14+
15+
def _register_controller(self, controller: type):
16+
# Bind methods to the controller class object so that `self` refers to the class
17+
for attr_name, attr_value in controller.__dict__.items():
18+
if not callable(attr_value):
19+
continue
20+
21+
if hasattr(attr_value, "_mcp_tool"):
22+
bound_fn = attr_value.__get__(controller, controller)
23+
meta = getattr(attr_value, "_mcp_tool", {}) or {}
24+
# Use call-form registration to preserve signature
25+
self.server.tool(
26+
bound_fn,
27+
name=meta.get("name"),
28+
description=meta.get("description"),
29+
tags=meta.get("tags"),
30+
output_schema=meta.get("output_schema"),
31+
annotations=meta.get("annotations"),
32+
exclude_args=meta.get("exclude_args"),
33+
meta=meta.get("meta"),
34+
enabled=meta.get("enabled"),
35+
)
36+
37+
if hasattr(attr_value, "_mcp_resource"):
38+
bound_fn = attr_value.__get__(controller, controller)
39+
meta = getattr(attr_value, "_mcp_resource", {}) or {}
40+
uri = meta.get("uri")
41+
if not uri:
42+
continue
43+
self.server.add_resource_fn(
44+
bound_fn,
45+
uri,
46+
name=meta.get("name"),
47+
description=meta.get("description"),
48+
mime_type=meta.get("mime_type"),
49+
tags=meta.get("tags"),
50+
)
51+
52+
if hasattr(attr_value, "_mcp_prompt"):
53+
bound_fn = attr_value.__get__(controller, controller)
54+
meta = getattr(attr_value, "_mcp_prompt", {}) or {}
55+
self.server.prompt(
56+
bound_fn,
57+
name=meta.get("name"),
58+
description=meta.get("description"),
59+
tags=meta.get("tags"),
60+
enabled=meta.get("enabled"),
61+
meta=meta.get("meta"),
62+
)

nest/core/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,15 @@
1515
from nest.core.pynest_application import PyNestApp
1616
from nest.core.pynest_container import PyNestContainer
1717
from nest.core.pynest_factory import PyNestFactory
18+
19+
# MCP exports
20+
from nest.core.pynest_mcp_application import PyNestMCPApp
21+
from nest.core.mcp_factory import MCPFactory
22+
23+
# MCP decorators
24+
from nest.core.decorators.mcp.mcp_decorators import (
25+
McpController,
26+
McpTool,
27+
McpResource,
28+
McpPrompt,
29+
)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from nest.core.decorators.utils import get_instance_variables, parse_dependencies
2+
3+
4+
def McpController():
5+
def decorator(cls):
6+
dependencies = parse_dependencies(cls)
7+
setattr(cls, "__dependencies__", dependencies)
8+
9+
non_dep = get_instance_variables(cls)
10+
for key, value in non_dep.items():
11+
setattr(cls, key, value)
12+
13+
# Align behavior with other controllers: if __init__ exists, remove it;
14+
# do not raise if already removed by another decorator
15+
try:
16+
delattr(cls, "__init__")
17+
except AttributeError:
18+
pass
19+
20+
return cls
21+
22+
return decorator
23+
24+
25+
def McpTool(name: str | None = None, **kwargs):
26+
def decorator(func):
27+
metadata = {"name": name}
28+
metadata.update(kwargs)
29+
setattr(func, "_mcp_tool", metadata)
30+
return func
31+
32+
return decorator
33+
34+
35+
def McpResource(uri: str, **kwargs):
36+
def decorator(func):
37+
if not uri or not isinstance(uri, str):
38+
raise ValueError("McpResource requires a non-empty URI string")
39+
metadata = {"uri": uri}
40+
metadata.update(kwargs)
41+
setattr(func, "_mcp_resource", metadata)
42+
return func
43+
44+
return decorator
45+
46+
47+
def McpPrompt(name: str | None = None, **kwargs):
48+
def decorator(func):
49+
metadata = {"name": name}
50+
metadata.update(kwargs)
51+
setattr(func, "_mcp_prompt", metadata)
52+
return func
53+
54+
return decorator

nest/core/mcp_factory.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from typing import Type, TypeVar, Any
2+
3+
from nest.core.pynest_container import PyNestContainer
4+
from nest.core.pynest_mcp_application import PyNestMCPApp
5+
from nest.core.pynest_factory import AbstractPyNestFactory
6+
7+
ModuleType = TypeVar("ModuleType")
8+
9+
10+
class MCPFactory(AbstractPyNestFactory):
11+
"""Factory class for creating PyNest MCP applications."""
12+
13+
@staticmethod
14+
def create(main_module: Type[ModuleType], **kwargs) -> PyNestMCPApp:
15+
"""
16+
Create a PyNest MCP application with the specified main module class.
17+
18+
Args:
19+
main_module (ModuleType): The main module for the PyNest application.
20+
**kwargs: Additional keyword arguments forwarded to FastMCP constructor
21+
(e.g., name, instructions, version).
22+
23+
Returns:
24+
PyNestMCPApp: The created PyNest MCP application.
25+
"""
26+
container = PyNestContainer()
27+
container.add_module(main_module)
28+
29+
# Lazy import to avoid hard dependency when MCP is not used
30+
try:
31+
from fastmcp import FastMCP # type: ignore
32+
except Exception as e: # pragma: no cover - environment without fastmcp
33+
raise ImportError(
34+
"fastmcp is required to use MCPFactory. Install with `pip install fastmcp`"
35+
) from e
36+
37+
# Ensure a default name for the MCP server
38+
if "name" not in kwargs:
39+
kwargs["name"] = "PyNest MCP Server"
40+
mcp_server = FastMCP(**kwargs)
41+
return PyNestMCPApp(container, mcp_server)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from typing import Any
2+
3+
from nest.common.mcp_resolver import MCPResolver
4+
from nest.core.pynest_app_context import PyNestApplicationContext
5+
from nest.core.pynest_container import PyNestContainer
6+
7+
8+
class PyNestMCPApp(PyNestApplicationContext):
9+
"""
10+
PyNestMCPApp is the application class for MCP servers in the PyNest framework,
11+
managing the container and FastMCP server.
12+
"""
13+
14+
def __init__(self, container: PyNestContainer, mcp_server: Any):
15+
# Type for mcp_server is FastMCP, kept as Any to avoid hard dependency at import time
16+
self.container = container
17+
self.mcp_server = mcp_server
18+
super().__init__(self.container)
19+
self.mcp_resolver = MCPResolver(self.container, self.mcp_server)
20+
self.select_context_module()
21+
self.register_mcp_items()
22+
23+
def use(self, middleware: Any) -> "PyNestMCPApp":
24+
# FastMCP servers expose add_middleware; pass through
25+
if hasattr(self.mcp_server, "add_middleware"):
26+
self.mcp_server.add_middleware(middleware)
27+
return self
28+
29+
def get_server(self) -> Any:
30+
return self.mcp_server
31+
32+
def register_mcp_items(self):
33+
self.mcp_resolver.register()

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,15 @@ beanie = { version = "^1.27.0", optional = true }
4949
python-dotenv = { version = "^1.0.1", optional = true }
5050
greenlet = { version = "^3.1.1", optional = true }
5151
black = "^24.10.0"
52+
fastmcp = { version = ">=2.0.0", optional = true }
5253

5354

5455

5556
[tool.poetry.extras]
5657
postgres = ["sqlalchemy", "asyncpg", "psycopg2", "alembic", "greenlet", "python-dotenv"]
5758
mongo = ["beanie", "python-dotenv"]
5859
test = ["pytest"]
60+
mcp = ["fastmcp"]
5961

6062
[tool.poetry.group.build.dependencies]
6163
setuptools = "^75.3.0"
@@ -73,6 +75,7 @@ beanie = "^1.27.0"
7375
pydantic = "^2.9.2"
7476
python-dotenv = "^1.0.1"
7577
uvicorn = "^0.32.0"
78+
fastmcp = ">=2.0.0"
7679

7780
[tool.poetry.group.docs.dependencies]
7881
mkdocs-material = "^9.5.43"

0 commit comments

Comments
 (0)