Skip to content

Commit 7fc9d63

Browse files
committed
add basic mcp support
1 parent 8befb4d commit 7fc9d63

File tree

11 files changed

+248
-2
lines changed

11 files changed

+248
-2
lines changed

src/enapter/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
__version__ = "0.13.1"
22

3-
from . import async_, log, mdns, mqtt, http, standalone # isort: skip
3+
from . import async_, log, mdns, mqtt, http, mcp, standalone # isort: skip
44

55
__all__ = [
66
"__version__",
@@ -9,5 +9,6 @@
99
"mdns",
1010
"mqtt",
1111
"http",
12+
"mcp",
1213
"standalone",
1314
]

src/enapter/cli/app.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from enapter import log
55

6-
from . import http
6+
from . import http, mcp
77

88

99
class App:
@@ -20,6 +20,7 @@ def new(cls) -> "App":
2020
subparsers = parser.add_subparsers(dest="command", required=True)
2121
for command in [
2222
http.Command,
23+
mcp.Command,
2324
]:
2425
command.register(subparsers)
2526
return cls(args=parser.parse_args())
@@ -29,5 +30,7 @@ async def run(self) -> None:
2930
match self.args.command:
3031
case "http":
3132
await http.Command.run(self.args)
33+
case "mcp":
34+
await mcp.Command.run(self.args)
3235
case _:
3336
raise NotImplementedError(self.args.command)

src/enapter/cli/mcp/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .command import Command
2+
3+
__all__ = ["Command"]
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import argparse
2+
3+
from enapter import cli
4+
5+
from .client_ping_command import ClientPingCommand
6+
7+
8+
class ClientCommand(cli.Command):
9+
10+
@staticmethod
11+
def register(parent: cli.Subparsers) -> None:
12+
parser = parent.add_parser(
13+
"client", formatter_class=argparse.ArgumentDefaultsHelpFormatter
14+
)
15+
parser.add_argument(
16+
"-u", "--url", default="http://127.0.0.1:8000/mcp", help="MCP server URL"
17+
)
18+
subparsers = parser.add_subparsers(dest="client_command", required=True)
19+
for command in [
20+
ClientPingCommand,
21+
]:
22+
command.register(subparsers)
23+
24+
@staticmethod
25+
async def run(args: argparse.Namespace) -> None:
26+
match args.client_command:
27+
case "ping":
28+
await ClientPingCommand.run(args)
29+
case _:
30+
raise NotImplementedError(args.client_command)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import argparse
2+
3+
from enapter import cli, mcp
4+
5+
6+
class ClientPingCommand(cli.Command):
7+
8+
@staticmethod
9+
def register(parent: cli.Subparsers) -> None:
10+
_ = parent.add_parser(
11+
"ping", formatter_class=argparse.ArgumentDefaultsHelpFormatter
12+
)
13+
14+
@staticmethod
15+
async def run(args: argparse.Namespace) -> None:
16+
async with mcp.Client(url=args.url) as client:
17+
await client.ping()

src/enapter/cli/mcp/command.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import argparse
2+
3+
from enapter import cli
4+
5+
from .client_command import ClientCommand
6+
from .server_command import ServerCommand
7+
8+
9+
class Command(cli.Command):
10+
11+
@staticmethod
12+
def register(parent: cli.Subparsers) -> None:
13+
parser = parent.add_parser(
14+
"mcp", formatter_class=argparse.ArgumentDefaultsHelpFormatter
15+
)
16+
subparsers = parser.add_subparsers(dest="mcp_command", required=True)
17+
for command in [
18+
ClientCommand,
19+
ServerCommand,
20+
]:
21+
command.register(subparsers)
22+
23+
@staticmethod
24+
async def run(args: argparse.Namespace) -> None:
25+
match args.mcp_command:
26+
case "client":
27+
await ClientCommand.run(args)
28+
case "server":
29+
await ServerCommand.run(args)
30+
case _:
31+
raise NotImplementedError(args.mcp_command)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import argparse
2+
import asyncio
3+
4+
from enapter import cli, mcp
5+
6+
7+
class ServerCommand(cli.Command):
8+
9+
@staticmethod
10+
def register(parent: cli.Subparsers) -> None:
11+
parser = parent.add_parser(
12+
"server", formatter_class=argparse.ArgumentDefaultsHelpFormatter
13+
)
14+
parser.add_argument("--host", default="127.0.0.1", help="Host to listen on")
15+
parser.add_argument("--port", type=int, default=8000, help="Port to listen on")
16+
parser.add_argument(
17+
"--http-api-base-url",
18+
default="https://api.enapter.com",
19+
help="Base URL of Enapter HTTP API",
20+
)
21+
22+
@staticmethod
23+
async def run(args: argparse.Namespace) -> None:
24+
async with mcp.Server(
25+
host=args.host, port=args.port, http_api_base_url=args.http_api_base_url
26+
):
27+
await asyncio.Event().wait()

src/enapter/mcp/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .client import Client
2+
from .server import Server
3+
4+
__all__ = ["Client", "Server"]

src/enapter/mcp/client.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from typing import Self
2+
3+
import fastmcp
4+
5+
6+
class Client:
7+
8+
def __init__(self, url: str) -> None:
9+
self._client = self._new_client(url)
10+
11+
def _new_client(self, url: str) -> fastmcp.Client:
12+
return fastmcp.Client(transport=fastmcp.client.StreamableHttpTransport(url))
13+
14+
async def __aenter__(self) -> Self:
15+
await self._client.__aenter__()
16+
return self
17+
18+
async def __aexit__(self, *exc) -> None:
19+
await self._client.__aexit__(*exc)
20+
21+
async def ping(self) -> bool:
22+
return await self._client.ping()

src/enapter/mcp/server.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from typing import AsyncContextManager
2+
3+
import fastmcp
4+
5+
from enapter import async_, http
6+
7+
8+
class Server(async_.Routine):
9+
10+
def __init__(self, host: str, port: int, http_api_base_url: str) -> None:
11+
super().__init__()
12+
self._host = host
13+
self._port = port
14+
self._http_api_base_url = http_api_base_url
15+
16+
async def _run(self) -> None:
17+
mcp = fastmcp.FastMCP()
18+
self._register_tools(mcp)
19+
await mcp.run_async(
20+
transport="streamable-http",
21+
show_banner=False,
22+
host=self._host,
23+
port=self._port,
24+
)
25+
26+
def _register_tools(self, mcp: fastmcp.FastMCP) -> None:
27+
mcp.tool(
28+
self._list_sites,
29+
name="list_sites",
30+
description="List all sites to which the authenticated user has access.",
31+
)
32+
mcp.tool(
33+
self._get_site,
34+
name="get_site",
35+
description="Get site by ID.",
36+
)
37+
mcp.tool(
38+
self._list_devices,
39+
name="list_devices",
40+
description="List devices.",
41+
)
42+
mcp.tool(
43+
self._get_device,
44+
name="get_device",
45+
description="Get device by ID.",
46+
)
47+
48+
async def _list_sites(self) -> list:
49+
async with self._new_http_api_client() as client:
50+
async with client.sites.list() as stream:
51+
return [site.to_dto() async for site in stream]
52+
53+
async def _get_site(self, site_id: str) -> dict:
54+
async with self._new_http_api_client() as client:
55+
site = await client.sites.get(site_id)
56+
return site.to_dto()
57+
58+
async def _list_devices(
59+
self,
60+
expand_manifest: bool = False,
61+
expand_properties: bool = False,
62+
expand_connectivity: bool = False,
63+
site_id: str | None = None,
64+
) -> list:
65+
async with self._new_http_api_client() as client:
66+
async with client.devices.list(
67+
expand_manifest=expand_manifest,
68+
expand_properties=expand_properties,
69+
expand_connectivity=expand_connectivity,
70+
site_id=site_id,
71+
) as stream:
72+
return [device.to_dto() async for device in stream]
73+
74+
async def _get_device(
75+
self,
76+
device_id: str,
77+
expand_manifest: bool = False,
78+
expand_properties: bool = False,
79+
expand_connectivity: bool = False,
80+
) -> dict:
81+
async with self._new_http_api_client() as client:
82+
device = await client.devices.get(
83+
device_id,
84+
expand_manifest=expand_manifest,
85+
expand_properties=expand_properties,
86+
expand_connectivity=expand_connectivity,
87+
)
88+
return device.to_dto()
89+
90+
def _new_http_api_client(self) -> AsyncContextManager[http.api.Client]:
91+
# FIXME: Client instance gets created for each request.
92+
headers = fastmcp.server.dependencies.get_http_headers()
93+
token = headers["x-enapter-auth-token"]
94+
return http.api.Client(
95+
config=http.api.Config(token=token, base_url=self._http_api_base_url)
96+
)

0 commit comments

Comments
 (0)