diff --git a/Pipfile b/Pipfile index f799422..8f964f7 100644 --- a/Pipfile +++ b/Pipfile @@ -12,6 +12,7 @@ docker = "*" faker = "*" isort = "*" mypy = "*" +ollmcp = "*" pyflakes = "*" pytest = "*" pytest-asyncio = "*" diff --git a/README.md b/README.md index 4b467d9..86d1a1e 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ with Enapter using Python. Install from PyPI: ```bash -pip install enapter==0.13.1 +pip install enapter==0.14.0-rc3 ``` ## Usage diff --git a/examples/standalone/mi-fan-1c/requirements.txt b/examples/standalone/mi-fan-1c/requirements.txt index b6c267c..887b3ae 100644 --- a/examples/standalone/mi-fan-1c/requirements.txt +++ b/examples/standalone/mi-fan-1c/requirements.txt @@ -1,2 +1,2 @@ -enapter==0.13.1 +enapter==0.14.0-rc3 python-miio==0.5.12 diff --git a/examples/standalone/psutil-battery/requirements.txt b/examples/standalone/psutil-battery/requirements.txt index c8cb53e..8a09c6d 100644 --- a/examples/standalone/psutil-battery/requirements.txt +++ b/examples/standalone/psutil-battery/requirements.txt @@ -1,2 +1,2 @@ -enapter==0.13.1 +enapter==0.14.0-rc3 psutil==7.1.2 diff --git a/examples/standalone/rl6-simulator/requirements.txt b/examples/standalone/rl6-simulator/requirements.txt index c208b31..6a8d322 100644 --- a/examples/standalone/rl6-simulator/requirements.txt +++ b/examples/standalone/rl6-simulator/requirements.txt @@ -1 +1 @@ -enapter==0.13.1 +enapter==0.14.0-rc3 diff --git a/examples/standalone/snmp-eaton-ups/requirements.txt b/examples/standalone/snmp-eaton-ups/requirements.txt index beb35eb..74081c6 100644 --- a/examples/standalone/snmp-eaton-ups/requirements.txt +++ b/examples/standalone/snmp-eaton-ups/requirements.txt @@ -1,3 +1,3 @@ -enapter==0.13.1 +enapter==0.14.0-rc3 pysnmp==4.4.12 pyasn1<=0.4.8 diff --git a/examples/standalone/wttr-in/requirements.txt b/examples/standalone/wttr-in/requirements.txt index 3aea75b..4722256 100644 --- a/examples/standalone/wttr-in/requirements.txt +++ b/examples/standalone/wttr-in/requirements.txt @@ -1,2 +1,2 @@ -enapter==0.13.1 +enapter==0.14.0-rc3 python-weather==2.1.0 diff --git a/examples/standalone/zigbee2mqtt/requirements.txt b/examples/standalone/zigbee2mqtt/requirements.txt index c208b31..6a8d322 100644 --- a/examples/standalone/zigbee2mqtt/requirements.txt +++ b/examples/standalone/zigbee2mqtt/requirements.txt @@ -1 +1 @@ -enapter==0.13.1 +enapter==0.14.0-rc3 diff --git a/setup.py b/setup.py index 06a7bf6..25e5454 100644 --- a/setup.py +++ b/setup.py @@ -18,6 +18,7 @@ def main() -> None: "dnspython==2.8.*", "json-log-formatter==1.1.*", "httpx==0.28.*", + "fastmcp==2.14.*", ], ) diff --git a/src/enapter/__init__.py b/src/enapter/__init__.py index 1cd9d40..be9f5b0 100644 --- a/src/enapter/__init__.py +++ b/src/enapter/__init__.py @@ -1,6 +1,6 @@ -__version__ = "0.13.1" +__version__ = "0.14.0-rc3" -from . import async_, log, mdns, mqtt, http, standalone # isort: skip +from . import async_, log, mdns, mqtt, http, mcp, standalone # isort: skip __all__ = [ "__version__", @@ -9,5 +9,6 @@ "mdns", "mqtt", "http", + "mcp", "standalone", ] diff --git a/src/enapter/cli/app.py b/src/enapter/cli/app.py index fd61a81..a7e5029 100644 --- a/src/enapter/cli/app.py +++ b/src/enapter/cli/app.py @@ -3,7 +3,7 @@ from enapter import log -from . import http +from . import http, mcp class App: @@ -20,6 +20,7 @@ def new(cls) -> "App": subparsers = parser.add_subparsers(dest="command", required=True) for command in [ http.Command, + mcp.Command, ]: command.register(subparsers) return cls(args=parser.parse_args()) @@ -29,5 +30,7 @@ async def run(self) -> None: match self.args.command: case "http": await http.Command.run(self.args) + case "mcp": + await mcp.Command.run(self.args) case _: raise NotImplementedError(self.args.command) diff --git a/src/enapter/cli/mcp/__init__.py b/src/enapter/cli/mcp/__init__.py new file mode 100644 index 0000000..265727c --- /dev/null +++ b/src/enapter/cli/mcp/__init__.py @@ -0,0 +1,3 @@ +from .command import Command + +__all__ = ["Command"] diff --git a/src/enapter/cli/mcp/client_command.py b/src/enapter/cli/mcp/client_command.py new file mode 100644 index 0000000..17f0b0f --- /dev/null +++ b/src/enapter/cli/mcp/client_command.py @@ -0,0 +1,30 @@ +import argparse + +from enapter import cli + +from .client_ping_command import ClientPingCommand + + +class ClientCommand(cli.Command): + + @staticmethod + def register(parent: cli.Subparsers) -> None: + parser = parent.add_parser( + "client", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument( + "-u", "--url", default="http://127.0.0.1:8000/mcp", help="MCP server URL" + ) + subparsers = parser.add_subparsers(dest="client_command", required=True) + for command in [ + ClientPingCommand, + ]: + command.register(subparsers) + + @staticmethod + async def run(args: argparse.Namespace) -> None: + match args.client_command: + case "ping": + await ClientPingCommand.run(args) + case _: + raise NotImplementedError(args.client_command) diff --git a/src/enapter/cli/mcp/client_ping_command.py b/src/enapter/cli/mcp/client_ping_command.py new file mode 100644 index 0000000..7f586c0 --- /dev/null +++ b/src/enapter/cli/mcp/client_ping_command.py @@ -0,0 +1,17 @@ +import argparse + +from enapter import cli, mcp + + +class ClientPingCommand(cli.Command): + + @staticmethod + def register(parent: cli.Subparsers) -> None: + _ = parent.add_parser( + "ping", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + + @staticmethod + async def run(args: argparse.Namespace) -> None: + async with mcp.Client(url=args.url) as client: + await client.ping() diff --git a/src/enapter/cli/mcp/command.py b/src/enapter/cli/mcp/command.py new file mode 100644 index 0000000..51513f8 --- /dev/null +++ b/src/enapter/cli/mcp/command.py @@ -0,0 +1,31 @@ +import argparse + +from enapter import cli + +from .client_command import ClientCommand +from .server_command import ServerCommand + + +class Command(cli.Command): + + @staticmethod + def register(parent: cli.Subparsers) -> None: + parser = parent.add_parser( + "mcp", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + subparsers = parser.add_subparsers(dest="mcp_command", required=True) + for command in [ + ClientCommand, + ServerCommand, + ]: + command.register(subparsers) + + @staticmethod + async def run(args: argparse.Namespace) -> None: + match args.mcp_command: + case "client": + await ClientCommand.run(args) + case "server": + await ServerCommand.run(args) + case _: + raise NotImplementedError(args.mcp_command) diff --git a/src/enapter/cli/mcp/server_command.py b/src/enapter/cli/mcp/server_command.py new file mode 100644 index 0000000..0f102b1 --- /dev/null +++ b/src/enapter/cli/mcp/server_command.py @@ -0,0 +1,27 @@ +import argparse +import asyncio + +from enapter import cli, mcp + + +class ServerCommand(cli.Command): + + @staticmethod + def register(parent: cli.Subparsers) -> None: + parser = parent.add_parser( + "server", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument("--host", default="127.0.0.1", help="Host to listen on") + parser.add_argument("--port", type=int, default=8000, help="Port to listen on") + parser.add_argument( + "--http-api-base-url", + default="https://api.enapter.com", + help="Base URL of Enapter HTTP API", + ) + + @staticmethod + async def run(args: argparse.Namespace) -> None: + async with mcp.Server( + host=args.host, port=args.port, http_api_base_url=args.http_api_base_url + ): + await asyncio.Event().wait() diff --git a/src/enapter/mcp/__init__.py b/src/enapter/mcp/__init__.py new file mode 100644 index 0000000..a8b3b98 --- /dev/null +++ b/src/enapter/mcp/__init__.py @@ -0,0 +1,4 @@ +from .client import Client +from .server import Server + +__all__ = ["Client", "Server"] diff --git a/src/enapter/mcp/client.py b/src/enapter/mcp/client.py new file mode 100644 index 0000000..2545344 --- /dev/null +++ b/src/enapter/mcp/client.py @@ -0,0 +1,22 @@ +from typing import Self + +import fastmcp + + +class Client: + + def __init__(self, url: str) -> None: + self._client = self._new_client(url) + + def _new_client(self, url: str) -> fastmcp.Client: + return fastmcp.Client(transport=fastmcp.client.StreamableHttpTransport(url)) + + async def __aenter__(self) -> Self: + await self._client.__aenter__() + return self + + async def __aexit__(self, *exc) -> None: + await self._client.__aexit__(*exc) + + async def ping(self) -> bool: + return await self._client.ping() diff --git a/src/enapter/mcp/server.py b/src/enapter/mcp/server.py new file mode 100644 index 0000000..be6b20f --- /dev/null +++ b/src/enapter/mcp/server.py @@ -0,0 +1,114 @@ +from typing import AsyncContextManager + +import fastmcp + +from enapter import async_, http + + +class Server(async_.Routine): + + def __init__(self, host: str, port: int, http_api_base_url: str) -> None: + super().__init__() + self._host = host + self._port = port + self._http_api_base_url = http_api_base_url + + async def _run(self) -> None: + mcp = fastmcp.FastMCP() + self._register_tools(mcp) + await mcp.run_async( + transport="streamable-http", + show_banner=False, + host=self._host, + port=self._port, + ) + + def _register_tools(self, mcp: fastmcp.FastMCP) -> None: + mcp.tool( + self._list_sites, + name="list_sites", + description="List all sites to which the authenticated user has access.", + ) + mcp.tool( + self._get_site, + name="get_site", + description="Get site by ID.", + ) + mcp.tool( + self._list_devices, + name="list_devices", + description="List devices.", + ) + mcp.tool( + self._get_device, + name="get_device", + description="Get device by ID.", + ) + mcp.tool( + self._get_latest_telemetry, + name="get_latest_telemetry", + description="Get latest telemetry of multiple devices.", + ) + + async def _list_sites(self) -> list: + async with self._new_http_api_client() as client: + async with client.sites.list() as stream: + return [site.to_dto() async for site in stream] + + async def _get_site(self, site_id: str) -> dict: + async with self._new_http_api_client() as client: + site = await client.sites.get(site_id) + return site.to_dto() + + async def _list_devices( + self, + expand_manifest: bool = False, + expand_properties: bool = False, + expand_connectivity: bool = False, + site_id: str | None = None, + ) -> list: + async with self._new_http_api_client() as client: + async with client.devices.list( + expand_manifest=expand_manifest, + expand_properties=expand_properties, + expand_connectivity=expand_connectivity, + site_id=site_id, + ) as stream: + return [device.to_dto() async for device in stream] + + async def _get_device( + self, + device_id: str, + expand_manifest: bool = False, + expand_properties: bool = False, + expand_connectivity: bool = False, + ) -> dict: + async with self._new_http_api_client() as client: + device = await client.devices.get( + device_id, + expand_manifest=expand_manifest, + expand_properties=expand_properties, + expand_connectivity=expand_connectivity, + ) + return device.to_dto() + + async def _get_latest_telemetry( + self, attributes_by_device: dict[str, list[str]] + ) -> dict[str, dict[str, str | int | float | bool | None]]: + async with self._new_http_api_client() as client: + telemetry = await client.telemetry.latest(attributes_by_device) + return { + device: { + attribute: datapoint.value if datapoint is not None else None + for attribute, datapoint in attributes.items() + } + for device, attributes in telemetry.items() + } + + def _new_http_api_client(self) -> AsyncContextManager[http.api.Client]: + # FIXME: Client instance gets created for each request. + headers = fastmcp.server.dependencies.get_http_headers() + token = headers["x-enapter-auth-token"] + return http.api.Client( + config=http.api.Config(token=token, base_url=self._http_api_base_url) + ) diff --git a/tests/integration/test_mcp.py b/tests/integration/test_mcp.py new file mode 100644 index 0000000..39134f9 --- /dev/null +++ b/tests/integration/test_mcp.py @@ -0,0 +1,12 @@ +import enapter + + +class TestServer: + + async def test_ping(self) -> None: + host = "127.0.0.1" + # FIXME: Hard-code. + port = 12345 + async with enapter.mcp.Server(host=host, port=port, http_api_base_url=""): + async with enapter.mcp.Client(url=f"http://{host}:{port}/mcp") as client: + assert await client.ping()