Skip to content
Closed
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
1 change: 1 addition & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ docker = "*"
faker = "*"
isort = "*"
mypy = "*"
ollmcp = "*"
pyflakes = "*"
pytest = "*"
pytest-asyncio = "*"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/standalone/mi-fan-1c/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
enapter==0.13.1
enapter==0.14.0-rc3
python-miio==0.5.12
2 changes: 1 addition & 1 deletion examples/standalone/psutil-battery/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
enapter==0.13.1
enapter==0.14.0-rc3
psutil==7.1.2
2 changes: 1 addition & 1 deletion examples/standalone/rl6-simulator/requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
enapter==0.13.1
enapter==0.14.0-rc3
2 changes: 1 addition & 1 deletion examples/standalone/snmp-eaton-ups/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
enapter==0.13.1
enapter==0.14.0-rc3
pysnmp==4.4.12
pyasn1<=0.4.8
2 changes: 1 addition & 1 deletion examples/standalone/wttr-in/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
enapter==0.13.1
enapter==0.14.0-rc3
python-weather==2.1.0
2 changes: 1 addition & 1 deletion examples/standalone/zigbee2mqtt/requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
enapter==0.13.1
enapter==0.14.0-rc3
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def main() -> None:
"dnspython==2.8.*",
"json-log-formatter==1.1.*",
"httpx==0.28.*",
"fastmcp==2.14.*",
],
)

Expand Down
5 changes: 3 additions & 2 deletions src/enapter/__init__.py
Original file line number Diff line number Diff line change
@@ -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__",
Expand All @@ -9,5 +9,6 @@
"mdns",
"mqtt",
"http",
"mcp",
"standalone",
]
5 changes: 4 additions & 1 deletion src/enapter/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from enapter import log

from . import http
from . import http, mcp


class App:
Expand All @@ -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())
Expand All @@ -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)
3 changes: 3 additions & 0 deletions src/enapter/cli/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .command import Command

__all__ = ["Command"]
30 changes: 30 additions & 0 deletions src/enapter/cli/mcp/client_command.py
Original file line number Diff line number Diff line change
@@ -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)
17 changes: 17 additions & 0 deletions src/enapter/cli/mcp/client_ping_command.py
Original file line number Diff line number Diff line change
@@ -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()
31 changes: 31 additions & 0 deletions src/enapter/cli/mcp/command.py
Original file line number Diff line number Diff line change
@@ -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)
27 changes: 27 additions & 0 deletions src/enapter/cli/mcp/server_command.py
Original file line number Diff line number Diff line change
@@ -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()
4 changes: 4 additions & 0 deletions src/enapter/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .client import Client
from .server import Server

__all__ = ["Client", "Server"]
22 changes: 22 additions & 0 deletions src/enapter/mcp/client.py
Original file line number Diff line number Diff line change
@@ -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()
114 changes: 114 additions & 0 deletions src/enapter/mcp/server.py
Original file line number Diff line number Diff line change
@@ -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)
)
12 changes: 12 additions & 0 deletions tests/integration/test_mcp.py
Original file line number Diff line number Diff line change
@@ -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()
Loading