diff --git a/README.md b/README.md index c396b82..7c59fb4 100644 --- a/README.md +++ b/README.md @@ -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)`. diff --git a/nest/common/__init__.py b/nest/common/__init__.py index e69de29..8b13789 100644 --- a/nest/common/__init__.py +++ b/nest/common/__init__.py @@ -0,0 +1 @@ + diff --git a/nest/common/mcp_resolver.py b/nest/common/mcp_resolver.py new file mode 100644 index 0000000..c67edba --- /dev/null +++ b/nest/common/mcp_resolver.py @@ -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"), + ) \ No newline at end of file diff --git a/nest/common/module.py b/nest/common/module.py index a877eab..ad5aea1 100644 --- a/nest/common/module.py +++ b/nest/common/module.py @@ -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): @@ -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) diff --git a/nest/core/__init__.py b/nest/core/__init__.py index e2dce6e..dd9a359 100644 --- a/nest/core/__init__.py +++ b/nest/core/__init__.py @@ -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, +) diff --git a/nest/core/decorators/mcp/__init__.py b/nest/core/decorators/mcp/__init__.py new file mode 100644 index 0000000..cd16ae1 --- /dev/null +++ b/nest/core/decorators/mcp/__init__.py @@ -0,0 +1 @@ +from nest.core.decorators.mcp.mcp_decorators import McpController, McpTool, McpResource, McpPrompt \ No newline at end of file diff --git a/nest/core/decorators/mcp/mcp_decorators.py b/nest/core/decorators/mcp/mcp_decorators.py new file mode 100644 index 0000000..50a2654 --- /dev/null +++ b/nest/core/decorators/mcp/mcp_decorators.py @@ -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 \ No newline at end of file diff --git a/nest/core/mcp_factory.py b/nest/core/mcp_factory.py new file mode 100644 index 0000000..d9d921c --- /dev/null +++ b/nest/core/mcp_factory.py @@ -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) \ No newline at end of file diff --git a/nest/core/pynest_mcp_application.py b/nest/core/pynest_mcp_application.py new file mode 100644 index 0000000..8136f51 --- /dev/null +++ b/nest/core/pynest_mcp_application.py @@ -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() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4faa078..ff5cd79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ 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 } @@ -56,6 +57,7 @@ black = "^24.10.0" 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" @@ -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"