Skip to content

Commit fa7bfe8

Browse files
draft docker support
1 parent a9e4f27 commit fa7bfe8

File tree

6 files changed

+268
-24
lines changed

6 files changed

+268
-24
lines changed

mcp_bridge/config/final.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,15 @@ class SSEMCPServer(BaseModel):
2424
# TODO: expand this once I find a good definition for this
2525
url: str = Field(description="URL of the MCP server")
2626

27+
class DockerMCPServer(BaseModel):
28+
container_name: str | None = Field(default=None, description="Name of the docker container")
29+
image: str = Field(description="Image of the docker container")
30+
args: list[str] = Field(default_factory=list, description="Command line arguments for the docker container")
31+
env: dict[str, str] = Field(default_factory=dict, description="Environment variables for the docker container")
32+
2733

2834
MCPServer = Annotated[
29-
Union[StdioServerParameters, SSEMCPServer],
35+
Union[StdioServerParameters, SSEMCPServer, DockerMCPServer],
3036
Field(description="MCP server configuration"),
3137
]
3238

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import asyncio
2+
from mcp import ClientSession
3+
from .transports.docker import docker_client
4+
from config import config
5+
from config.final import DockerMCPServer
6+
from .AbstractClient import GenericMcpClient
7+
from loguru import logger
8+
9+
10+
class DockerClient(GenericMcpClient):
11+
config: DockerMCPServer
12+
session: ClientSession | None = None
13+
14+
def __init__(self, name: str, config: DockerMCPServer) -> None:
15+
super().__init__(name=name)
16+
17+
self.config = config
18+
19+
async def _maintain_session(self):
20+
async with docker_client(self.config) as client:
21+
logger.debug(f"made instance of docker client for {self.name}")
22+
async with ClientSession(*client) as session:
23+
await session.initialize()
24+
logger.debug(f"finished initialise session for {self.name}")
25+
self.session = session
26+
27+
try:
28+
while True:
29+
await asyncio.sleep(10)
30+
if config.logging.log_server_pings:
31+
logger.debug(f"pinging session for {self.name}")
32+
33+
await session.send_ping()
34+
35+
except Exception as exc:
36+
logger.error(f"ping failed for {self.name}: {exc}")
37+
self.session = None
38+
39+
logger.debug(f"exiting session for {self.name}")

mcp_bridge/mcp_clients/McpClientManager.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55

66
from .StdioClient import StdioClient
77
from .SseClient import SseClient
8-
from config.final import SSEMCPServer
8+
from .DockerClient import DockerClient
9+
from config.final import DockerMCPServer, SSEMCPServer
910

10-
client_types = Union[StdioClient, SseClient]
11+
client_types = Union[StdioClient, SseClient, DockerClient]
1112

1213

1314
class MCPClientManager:
@@ -36,6 +37,11 @@ async def construct_client(self, name, server_config) -> client_types:
3637
client = SseClient(name, server_config) # type: ignore
3738
await client.start()
3839
return client
40+
41+
if isinstance(server_config, DockerMCPServer):
42+
client = DockerClient(name, server_config)
43+
await client.start()
44+
return client
3945

4046
raise NotImplementedError("Client Type not supported")
4147

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import docker.client
2+
from contextlib import asynccontextmanager
3+
import anyio
4+
import anyio.lowlevel
5+
import anyio.to_thread
6+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
7+
from loguru import logger
8+
from config.final import DockerMCPServer
9+
from mcp import types
10+
11+
@asynccontextmanager
12+
async def docker_client(server: DockerMCPServer):
13+
"""
14+
Client transport for Docker: this will connect to a server by
15+
running a Docker container and communicating with it over its stdin/stdout.
16+
"""
17+
read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception]
18+
read_stream_writer: MemoryObjectSendStream[types.JSONRPCMessage | Exception]
19+
20+
write_stream: MemoryObjectSendStream[types.JSONRPCMessage]
21+
write_stream_reader: MemoryObjectReceiveStream[types.JSONRPCMessage]
22+
23+
read_stream_writer, read_stream = anyio.create_memory_object_stream(0)
24+
write_stream, write_stream_reader = anyio.create_memory_object_stream(0)
25+
26+
client = docker.client.from_env()
27+
container = client.containers.run(
28+
image=server.image,
29+
name=server.container_name if server.container_name else None,
30+
command=server.args if server.args else None,
31+
environment=server.env if isinstance(server.env, (dict, list)) else {},
32+
detach=True,
33+
stdin_open=True,
34+
stdout=True,
35+
stderr=True,
36+
tty=False,
37+
)
38+
39+
logger.debug(f"made instance of docker client for {container.name}")
40+
41+
# Attach to container's input/output streams
42+
attach_socket = container.attach_socket(params={"stdin": 1, "stdout": 1, "stderr": 1, "stream": 1})
43+
attach_socket._sock.setblocking(False)
44+
45+
async def read_from_stdout():
46+
try:
47+
async with read_stream_writer:
48+
buffer = ""
49+
while True:
50+
chunk = await anyio.to_thread.run_sync(lambda: attach_socket._sock.recv(1024))
51+
if not chunk:
52+
break
53+
54+
chunk = chunk.decode("utf-8")
55+
lines = (buffer + chunk).split("\n")
56+
buffer = lines.pop()
57+
58+
for line in lines:
59+
try:
60+
message = types.JSONRPCMessage.model_validate_json(line)
61+
except Exception as exc:
62+
await read_stream_writer.send(exc)
63+
continue
64+
65+
await read_stream_writer.send(message)
66+
except anyio.ClosedResourceError:
67+
await anyio.lowlevel.checkpoint()
68+
69+
async def write_to_stdin():
70+
try:
71+
async with write_stream_reader:
72+
async for message in write_stream_reader:
73+
json = message.model_dump_json(by_alias=True, exclude_none=True)
74+
await anyio.to_thread.run_sync(lambda: attach_socket._sock.sendall((json + "\n").encode("utf-8")))
75+
except anyio.ClosedResourceError:
76+
await anyio.lowlevel.checkpoint()
77+
78+
try:
79+
async with anyio.create_task_group() as tg:
80+
tg.start_soon(read_from_stdout)
81+
tg.start_soon(write_to_stdin)
82+
yield read_stream, write_stream
83+
except Exception as e:
84+
logger.error(f"Error in docker client: {e}")
85+
finally:
86+
attach_socket._sock.close()
87+
# Do not stop or remove the container
88+
logger.debug(f"Container {container.name} remains running.")

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ readme = "README.md"
66
requires-python = ">=3.11"
77
dependencies = [
88
"deepmerge>=2.0",
9+
"docker>=7.1.0",
910
"fastapi>=0.115.6",
1011
"httpx>=0.28.1",
1112
"httpx-sse>=0.4.0",

0 commit comments

Comments
 (0)