Skip to content

Commit 0b93b43

Browse files
Merge pull request #25 from SecretiveShell/docker-client
Add docker client support
2 parents 3091dfd + a727557 commit 0b93b43

File tree

6 files changed

+482
-3
lines changed

6 files changed

+482
-3
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: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from aiodocker import Docker
2+
from contextlib import asynccontextmanager
3+
import anyio
4+
import anyio.lowlevel
5+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
6+
from loguru import logger
7+
from config.final import DockerMCPServer
8+
from mcp import types
9+
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+
docker = Docker()
27+
28+
try:
29+
# Pull the image if not available
30+
await docker.images.pull(server.image)
31+
32+
# Create the container
33+
container = await docker.containers.create({
34+
"Image": server.image,
35+
"Args": server.args,
36+
"OpenStdin": True,
37+
"AttachStdout": True,
38+
"AttachStderr": True,
39+
"Tty": False,
40+
"HostConfig": {"AutoRemove": True},
41+
})
42+
43+
await container.start()
44+
logger.debug(f"Started Docker container {container.id}")
45+
46+
# Attach to the container's input/output streams
47+
attach_result = container.attach(stdout=True, stdin=True)
48+
49+
async def read_from_stdout():
50+
try:
51+
async with read_stream_writer:
52+
buffer = ""
53+
while True:
54+
msg = await attach_result.read_out()
55+
if msg is None:
56+
continue
57+
chunk = msg.data
58+
if isinstance(chunk, bytes):
59+
chunk = chunk.decode("utf-8")
60+
lines = (buffer + chunk).split("\n")
61+
buffer = lines.pop()
62+
63+
for line in lines:
64+
try:
65+
json_message = types.JSONRPCMessage.model_validate_json(line)
66+
await read_stream_writer.send(json_message)
67+
except Exception as exc:
68+
await read_stream_writer.send(exc)
69+
except anyio.ClosedResourceError:
70+
await anyio.lowlevel.checkpoint()
71+
72+
async def write_to_stdin():
73+
try:
74+
async with write_stream_reader:
75+
async for message in write_stream_reader:
76+
json = message.model_dump_json(by_alias=True, exclude_none=True)
77+
await attach_result.write_in(json.encode("utf-8") + b"\n")
78+
except anyio.ClosedResourceError:
79+
await anyio.lowlevel.checkpoint()
80+
81+
try:
82+
async with anyio.create_task_group() as tg:
83+
tg.start_soon(read_from_stdout)
84+
tg.start_soon(write_to_stdin)
85+
yield read_stream, write_stream
86+
finally:
87+
await container.stop()
88+
await container.delete()
89+
90+
except Exception as e:
91+
logger.error(f"Error in docker client: {e}")
92+
finally:
93+
await docker.close()
94+
logger.debug("Docker client closed.")

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ description = "A middleware to provide an openAI compatible endpoint that can ca
55
readme = "README.md"
66
requires-python = ">=3.11"
77
dependencies = [
8+
"aiodocker>=0.24.0",
89
"deepmerge>=2.0",
910
"fastapi>=0.115.6",
1011
"httpx>=0.28.1",

0 commit comments

Comments
 (0)