Skip to content
Draft
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
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,54 @@ PyNest is [MIT licensed](LICENSE).
## Credits

PyNest is inspired by [NestJS](https://nestjs.com/).

## MCP servers with FastMCP

Build MCP servers that reuse the same modules, services, and controllers as your FastAPI and CLI apps.

Install optional dependency:

```bash
pip install pynest-api[mcp]
```

Define an MCP controller with tools, resources, and prompts:

```python
from nest.core import Module, Injectable, McpController, McpTool, McpResource, McpPrompt
from nest.core import MCPFactory

@Injectable
class MathService:
def add(self, a: int, b: int) -> int:
return a + b

@McpController()
class MathController:
def __init__(self, math: MathService):
self.math = math

@McpTool("add")
def add_tool(self, a: int, b: int) -> int:
return self.math.add(a, b)

@McpResource("greeting://{name}")
def greeting(self, name: str) -> str:
return f"Hello, {name}!"

@McpPrompt("summarize")
def summarize(self, text: str) -> str:
return f"Summarize: {text}"

@Module(controllers=[MathController], providers=[MathService])
class AppModule:
pass

# Create MCP app
mcp_app = MCPFactory.create(AppModule, name="Demo MCP")
server = mcp_app.get_server()
# Run with desired transport, e.g. stdio
server.run(transport="stdio")
```

You can keep your FastAPI app alongside it using `PyNestFactory.create(AppModule)` and your CLI with `CLIAppFactory().create(AppModule)`.
1 change: 1 addition & 0 deletions nest/common/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

62 changes: 62 additions & 0 deletions nest/common/mcp_resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from typing import Any


class MCPResolver:
def __init__(self, container: Any, server: Any):
# Type of server is FastMCP, but keep Any to avoid hard import at module import time
self.container = container
self.server = server

def register(self):
for module in self.container.modules.values():
for controller in module.controllers.values():
self._register_controller(controller)

def _register_controller(self, controller: type):
# Bind methods to the controller class object so that `self` refers to the class
for attr_name, attr_value in controller.__dict__.items():
if not callable(attr_value):
continue

if hasattr(attr_value, "_mcp_tool"):
bound_fn = attr_value.__get__(controller, controller)
meta = getattr(attr_value, "_mcp_tool", {}) or {}
# Use call-form registration to preserve signature
self.server.tool(
bound_fn,
name=meta.get("name"),
description=meta.get("description"),
tags=meta.get("tags"),
output_schema=meta.get("output_schema"),
annotations=meta.get("annotations"),
exclude_args=meta.get("exclude_args"),
meta=meta.get("meta"),
enabled=meta.get("enabled"),
)

if hasattr(attr_value, "_mcp_resource"):
bound_fn = attr_value.__get__(controller, controller)
meta = getattr(attr_value, "_mcp_resource", {}) or {}
uri = meta.get("uri")
if not uri:
continue
self.server.add_resource_fn(
bound_fn,
uri,
name=meta.get("name"),
description=meta.get("description"),
mime_type=meta.get("mime_type"),
tags=meta.get("tags"),
)

if hasattr(attr_value, "_mcp_prompt"):
bound_fn = attr_value.__get__(controller, controller)
meta = getattr(attr_value, "_mcp_prompt", {}) or {}
self.server.prompt(
bound_fn,
name=meta.get("name"),
description=meta.get("description"),
tags=meta.get("tags"),
enabled=meta.get("enabled"),
meta=meta.get("meta"),
)
6 changes: 2 additions & 4 deletions nest/common/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
from typing import Any, List, Type
from uuid import uuid4

from nest.core import Module


class ModulesContainer(dict):
def __init__(self):
Expand Down Expand Up @@ -104,10 +102,10 @@ def distance(self):
def distance(self, value):
self._distance = value

def add_import(self, module_ref: Module):
def add_import(self, module_ref: "Module"):
self._imports.add(module_ref)

def add_imports(self, module_ref: List[Module]):
def add_imports(self, module_ref: List["Module"]):
for module in module_ref:
self.add_import(module)

Expand Down
12 changes: 12 additions & 0 deletions nest/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,15 @@
from nest.core.pynest_application import PyNestApp
from nest.core.pynest_container import PyNestContainer
from nest.core.pynest_factory import PyNestFactory

# MCP application and factory (safe: fastmcp is lazily imported when used)
from nest.core.pynest_mcp_application import PyNestMCPApp
from nest.core.mcp_factory import MCPFactory

# MCP decorators
from nest.core.decorators.mcp import (
McpController,
McpTool,
McpResource,
McpPrompt,
)
1 change: 1 addition & 0 deletions nest/core/decorators/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from nest.core.decorators.mcp.mcp_decorators import McpController, McpTool, McpResource, McpPrompt
56 changes: 56 additions & 0 deletions nest/core/decorators/mcp/mcp_decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from typing import Optional

from nest.core.decorators.utils import get_instance_variables, parse_dependencies


def McpController():
def decorator(cls):
dependencies = parse_dependencies(cls)
setattr(cls, "__dependencies__", dependencies)

non_dep = get_instance_variables(cls)
for key, value in non_dep.items():
setattr(cls, key, value)

# Align behavior with other controllers: if __init__ exists, remove it;
# do not raise if already removed by another decorator
try:
delattr(cls, "__init__")
except AttributeError:
pass

return cls

return decorator


def McpTool(name: Optional[str] = None, **kwargs):
def decorator(func):
metadata = {"name": name}
metadata.update(kwargs)
setattr(func, "_mcp_tool", metadata)
return func

return decorator


def McpResource(uri: str, **kwargs):
def decorator(func):
if not uri or not isinstance(uri, str):
raise ValueError("McpResource requires a non-empty URI string")
metadata = {"uri": uri}
metadata.update(kwargs)
setattr(func, "_mcp_resource", metadata)
return func

return decorator


def McpPrompt(name: Optional[str] = None, **kwargs):
def decorator(func):
metadata = {"name": name}
metadata.update(kwargs)
setattr(func, "_mcp_prompt", metadata)
return func

return decorator
41 changes: 41 additions & 0 deletions nest/core/mcp_factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from typing import Type, TypeVar, Any

from nest.core.pynest_container import PyNestContainer
from nest.core.pynest_mcp_application import PyNestMCPApp
from nest.core.pynest_factory import AbstractPyNestFactory

ModuleType = TypeVar("ModuleType")


class MCPFactory(AbstractPyNestFactory):
"""Factory class for creating PyNest MCP applications."""

@staticmethod
def create(main_module: Type[ModuleType], **kwargs) -> PyNestMCPApp:
"""
Create a PyNest MCP application with the specified main module class.

Args:
main_module (ModuleType): The main module for the PyNest application.
**kwargs: Additional keyword arguments forwarded to FastMCP constructor
(e.g., name, instructions, version).

Returns:
PyNestMCPApp: The created PyNest MCP application.
"""
container = PyNestContainer()
container.add_module(main_module)

# Lazy import to avoid hard dependency when MCP is not used
try:
from fastmcp import FastMCP # type: ignore
except Exception as e: # pragma: no cover - environment without fastmcp
raise ImportError(
"fastmcp is required to use MCPFactory. Install with `pip install fastmcp`"
) from e

# Ensure a default name for the MCP server
if "name" not in kwargs:
kwargs["name"] = "PyNest MCP Server"
mcp_server = FastMCP(**kwargs)
return PyNestMCPApp(container, mcp_server)
33 changes: 33 additions & 0 deletions nest/core/pynest_mcp_application.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from typing import Any

from nest.common.mcp_resolver import MCPResolver
from nest.core.pynest_app_context import PyNestApplicationContext
from nest.core.pynest_container import PyNestContainer


class PyNestMCPApp(PyNestApplicationContext):
"""
PyNestMCPApp is the application class for MCP servers in the PyNest framework,
managing the container and FastMCP server.
"""

def __init__(self, container: PyNestContainer, mcp_server: Any):
# Type for mcp_server is FastMCP, kept as Any to avoid hard dependency at import time
self.container = container
self.mcp_server = mcp_server
super().__init__(self.container)
self.mcp_resolver = MCPResolver(self.container, self.mcp_server)
self.select_context_module()
self.register_mcp_items()

def use(self, middleware: Any) -> "PyNestMCPApp":
# FastMCP servers expose add_middleware; pass through
if hasattr(self.mcp_server, "add_middleware"):
self.mcp_server.add_middleware(middleware)
return self

def get_server(self) -> Any:
return self.mcp_server

def register_mcp_items(self):
self.mcp_resolver.register()
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,15 @@ beanie = { version = "^1.27.0", optional = true }
python-dotenv = { version = "^1.0.1", optional = true }
greenlet = { version = "^3.1.1", optional = true }
black = "^24.10.0"
fastmcp = { version = ">=2.0.0", optional = true }



[tool.poetry.extras]
postgres = ["sqlalchemy", "asyncpg", "psycopg2", "alembic", "greenlet", "python-dotenv"]
mongo = ["beanie", "python-dotenv"]
test = ["pytest"]
mcp = ["fastmcp"]

[tool.poetry.group.build.dependencies]
setuptools = "^75.3.0"
Expand All @@ -73,6 +75,7 @@ beanie = "^1.27.0"
pydantic = "^2.9.2"
python-dotenv = "^1.0.1"
uvicorn = "^0.32.0"
fastmcp = ">=2.0.0"

[tool.poetry.group.docs.dependencies]
mkdocs-material = "^9.5.43"
Expand Down
Loading