Skip to content
Open
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
78 changes: 78 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,84 @@ If you want to use the tools inside of [claude desktop](https://claude.ai/downlo

To add new MCP servers, edit the config.json file.

### MCP Server Types

MCP-Bridge supports three transport types for MCP servers.

Comment on lines +132 to +135
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section says MCP-Bridge supports “three transport types”, but the code/config also supports Docker-based MCP servers (DockerMCPServer). Either include Docker in this list (and document its config shape) or clarify that this list is only for non-Docker transports, to avoid misleading users.

Copilot uses AI. Check for mistakes.
**stdio** — runs a local command:
```json
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
}
```

**SSE** — connects to a remote server over Server-Sent Events:
```json
"my-server": {
"url": "http://example.com/sse"
}
```

**Streamable HTTP** — connects to a remote server using the newer Streamable HTTP transport (e.g. Home Assistant). Requires `"transport": "streamable_http"`:
```json
"homeassistant": {
"url": "http://homeassistant.local:8123/api/mcp",
"transport": "streamable_http"
}
```

Both SSE and Streamable HTTP support a `headers` field for passing authentication:
```json
"homeassistant": {
"url": "https://homeassistant.local:8123/api/mcp",
"headers": {
"Authorization": "Bearer <your-long-lived-access-token>"
},
"transport": "streamable_http"
}
```

#### Self-signed certificates (Docker)

If your MCP server uses a self-signed or private CA certificate, the Docker container won't trust it by default. The recommended fix is to mount your host's CA bundle (which should already include your custom cert after running `update-ca-certificates`) and point Python's SSL to it:

**1. Add the volume mount and environment variable to `compose.yml`:**
```yaml
services:
mcp-bridge:
environment:
- SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
volumes:
- /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro
```

**2. Use `ssl_verify` in `config.json` to explicitly point to the bundle:**
```json
"homeassistant": {
"url": "https://homeassistant.local:8123/api/mcp",
"headers": {
"Authorization": "Bearer <your-long-lived-access-token>"
},
"ssl_verify": "/etc/ssl/certs/ca-certificates.crt",
"transport": "streamable_http"
}
```

Make sure your CA certificate is trusted on the host first:
```bash
sudo cp /path/to/MyCA.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
```

### Docker DNS issues
If your tools or MCP servers (mainly ones running in docker) are having name resolution issues, you may need to hardcode DNS servers in your /etc/docker/daemon.json and restart docker.service. for example:
```json
{
"dns": ["192.168.2.2", "1.1.1.1"],
}
```

### API Key Authentication

MCP-Bridge supports API key authentication to secure your server. To enable this feature, add something like this to your `config.json` file:
Expand Down
13 changes: 11 additions & 2 deletions mcp_bridge/config/final.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,21 @@ class Sampling(BaseModel):


class SSEMCPServer(BaseModel):
# TODO: expand this once I find a good definition for this
url: str = Field(description="URL of the MCP server")
headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers to send with requests")
ssl_verify: bool | str = Field(default=True, description="SSL verification: True, False, or path to a CA bundle file")
transport: Literal["sse"] = Field(default="sse")


class StreamableHTTPMCPServer(BaseModel):
url: str = Field(description="URL of the MCP server")
headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers to send with requests")
Comment on lines +43 to +50
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

headers may contain sensitive values (e.g., Authorization: Bearer ...). Because these config models are logged elsewhere via {server_config} (pydantic repr), adding a plain dict[str, str] here risks leaking credentials into logs. Consider using pydantic.SecretStr for values and/or marking the field as repr=False so secrets aren’t printed.

Suggested change
headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers to send with requests")
ssl_verify: bool | str = Field(default=True, description="SSL verification: True, False, or path to a CA bundle file")
transport: Literal["sse"] = Field(default="sse")
class StreamableHTTPMCPServer(BaseModel):
url: str = Field(description="URL of the MCP server")
headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers to send with requests")
headers: dict[str, str] = Field(
default_factory=dict,
description="HTTP headers to send with requests",
repr=False,
)
ssl_verify: bool | str = Field(default=True, description="SSL verification: True, False, or path to a CA bundle file")
transport: Literal["sse"] = Field(default="sse")
class StreamableHTTPMCPServer(BaseModel):
url: str = Field(description="URL of the MCP server")
headers: dict[str, str] = Field(
default_factory=dict,
description="HTTP headers to send with requests",
repr=False,
)

Copilot uses AI. Check for mistakes.
ssl_verify: bool | str = Field(default=True, description="SSL verification: True, False, or path to a CA bundle file")
transport: Literal["streamable_http"]


MCPServer = Annotated[
Union[StdioServerParameters, SSEMCPServer, DockerMCPServer],
Union[StdioServerParameters, SSEMCPServer, StreamableHTTPMCPServer, DockerMCPServer],
Field(description="MCP server configuration"),
]

Expand Down
13 changes: 9 additions & 4 deletions mcp_bridge/mcp_clients/McpClientManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
from mcpx.client.transports.docker import DockerMCPServer

from mcp_bridge.config import config
from mcp_bridge.config.final import SSEMCPServer
from mcp_bridge.config.final import SSEMCPServer, StreamableHTTPMCPServer

from .DockerClient import DockerClient
from .SseClient import SseClient
from .StreamableHttpClient import StreamableHttpClient
from .StdioClient import StdioClient

client_types = Union[StdioClient, SseClient, DockerClient]
client_types = Union[StdioClient, SseClient, StreamableHttpClient, DockerClient]


class MCPClientManager:
Expand All @@ -36,11 +37,15 @@ async def construct_client(self, name, server_config) -> client_types:
return client

if isinstance(server_config, SSEMCPServer):
# TODO: implement sse client
client = SseClient(name, server_config) # type: ignore
await client.start()
return client


if isinstance(server_config, StreamableHTTPMCPServer):
client = StreamableHttpClient(name, server_config) # type: ignore
await client.start()
return client

if isinstance(server_config, DockerMCPServer):
client = DockerClient(name, server_config)
await client.start()
Expand Down
54 changes: 37 additions & 17 deletions mcp_bridge/mcp_clients/SseClient.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import httpx
from mcp.client.sse import sse_client
from mcp_bridge.config import config
from mcp_bridge.config.final import SSEMCPServer
Expand All @@ -16,22 +17,41 @@ def __init__(self, name: str, config: SSEMCPServer) -> None:
self.config = config

async def _maintain_session(self):
async with sse_client(self.config.url) as client:
async with McpClientSession(*client) as session:
await session.initialize()
logger.debug(f"finished initialise session for {self.name}")
self.session = session

try:
while True:
await asyncio.sleep(10)
if config.logging.log_server_pings:
logger.debug(f"pinging session for {self.name}")

await session.send_ping()

except Exception as exc:
logger.error(f"ping failed for {self.name}: {exc}")
self.session = None
ssl_verify = self.config.ssl_verify

if ssl_verify is not True:
# sse_client doesn't expose ssl options, so briefly patch httpx.AsyncClient
# to inject the verify setting before it creates its internal client.
_original_init = httpx.AsyncClient.__init__

def _patched_init(client_self, *args, **kwargs):
kwargs.setdefault("verify", ssl_verify)
_original_init(client_self, *args, **kwargs)

httpx.AsyncClient.__init__ = _patched_init

Comment on lines +20 to +32
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This temporarily monkeypatches httpx.AsyncClient.__init__ globally to inject verify. In an async server with multiple concurrent clients/requests, this can unintentionally change TLS verification settings for unrelated HTTPX clients created during the patch window (including disabling verification), creating both race conditions and potential security exposure. Prefer a solution that doesn’t mutate global HTTPX state (e.g., pass a preconfigured client/transport into the MCP client if supported, or implement a local wrapper around the transport that constructs its own httpx.AsyncClient(verify=...); if patching is unavoidable, at least guard it with a process-wide lock + nesting/reference counting to prevent cross-session interference).

Copilot uses AI. Check for mistakes.
try:
async with sse_client(
self.config.url, headers=self.config.headers or None
) as client:
async with McpClientSession(*client) as session:
await session.initialize()
logger.debug(f"finished initialise session for {self.name}")
self.session = session

try:
while True:
await asyncio.sleep(10)
if config.logging.log_server_pings:
logger.debug(f"pinging session for {self.name}")

await session.send_ping()

Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ping loop catches Exception, which will also catch asyncio.CancelledError and can prevent clean task cancellation/shutdown (it will log an error and then the session maintainer restarts). Re-raise CancelledError (and potentially BaseException subclasses) so shutdown works correctly.

Suggested change
except asyncio.CancelledError:
self.session = None
raise

Copilot uses AI. Check for mistakes.
except Exception as exc:
logger.error(f"ping failed for {self.name}: {exc}")
self.session = None
finally:
if ssl_verify is not True:
httpx.AsyncClient.__init__ = _original_init

logger.debug(f"exiting session for {self.name}")
56 changes: 56 additions & 0 deletions mcp_bridge/mcp_clients/StreamableHttpClient.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import asyncio
import httpx
from mcp.client.streamable_http import streamablehttp_client
from mcp_bridge.config import config
from mcp_bridge.config.final import StreamableHTTPMCPServer
from mcp_bridge.mcp_clients.session import McpClientSession
from .AbstractClient import GenericMcpClient
from loguru import logger


class StreamableHttpClient(GenericMcpClient):
config: StreamableHTTPMCPServer

def __init__(self, name: str, config: StreamableHTTPMCPServer) -> None:
super().__init__(name=name)
self.config = config

async def _maintain_session(self):
ssl_verify = self.config.ssl_verify

if ssl_verify is not True:
# streamablehttp_client doesn't expose ssl options, so briefly patch
# httpx.AsyncClient to inject the verify setting.
_original_init = httpx.AsyncClient.__init__
def _patched_init(client_self, *args, **kwargs):
kwargs.setdefault("verify", ssl_verify)
_original_init(client_self, *args, **kwargs)
httpx.AsyncClient.__init__ = _patched_init
Comment on lines +19 to +28
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This uses a global monkeypatch of httpx.AsyncClient.__init__ to force a verify value. That can affect other concurrent HTTPX usage in the process (including requests unrelated to this MCP server) and can lead to race conditions or unintended TLS verification behavior. Prefer an approach that scopes TLS configuration to just this transport (e.g., construct and pass a dedicated httpx.AsyncClient(verify=...) if the SDK supports it, or wrap/replicate the transport’s client creation). If you must patch, add a lock + nesting safety to avoid overlapping patches from multiple sessions.

Copilot uses AI. Check for mistakes.

try:
async with streamablehttp_client(
self.config.url,
headers=self.config.headers or None,
) as streams:
# SDK may yield (read, write) or (read, write, session_id_cb)
read_stream, write_stream = streams[0], streams[1]
async with McpClientSession(read_stream, write_stream) as session:
await session.initialize()
logger.debug(f"finished initialise session for {self.name}")
self.session = session

try:
while True:
await asyncio.sleep(10)
if config.logging.log_server_pings:
logger.debug(f"pinging session for {self.name}")
await session.send_ping()

Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The broad except Exception in the ping loop will catch asyncio.CancelledError, which can interfere with cancellation on shutdown (and cause the maintainer to restart instead of exiting). Handle CancelledError explicitly by re-raising it before the generic exception handler.

Suggested change
except asyncio.CancelledError:
raise

Copilot uses AI. Check for mistakes.
except Exception as exc:
logger.error(f"ping failed for {self.name}: {exc}")
self.session = None
finally:
if ssl_verify is not True:
httpx.AsyncClient.__init__ = _original_init

logger.debug(f"exiting session for {self.name}")
29 changes: 5 additions & 24 deletions mcp_bridge/mcp_clients/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,30 +40,11 @@ def __init__(
read_timeout_seconds=read_timeout_seconds,
)

async def __aenter__(self):
session = await super().__aenter__()
self._task_group.start_soon(self._consume_messages)
return session

async def _consume_messages(self):
try:
async for message in self.incoming_messages:
try:
if isinstance(message, Exception):
logger.error(f"Received exception in message stream: {message}")
elif isinstance(message, RequestResponder):
logger.debug(f"Received request: {message.request}")
elif isinstance(message, types.ServerNotification):
if isinstance(message.root, types.LoggingMessageNotification):
logger.debug(f"Received notification from server: {message.root.params}")
else:
logger.debug(f"Received notification from server: {message}")
else:
logger.debug(f"Received notification: {message}")
except Exception as e:
logger.exception(f"Error processing message: {e}")
except Exception as e:
logger.exception(f"Message consumer task failed: {e}")
async def _received_notification(self, notification: types.ServerNotification) -> None:
if isinstance(notification.root, types.LoggingMessageNotification):
logger.debug(f"Received notification from server: {notification.root.params}")
else:
logger.debug(f"Received notification from server: {notification}")

async def initialize(self) -> types.InitializeResult:
result = await self.send_request(
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ dependencies = [
"httpx-sse>=0.4.0",
"lmos-openai-types",
"loguru>=0.7.3",
"mcp>=1.2.0,<=1.7.1",
"mcp>=1.8.0,<=1.9.3",
"mcpx[docker]>=0.1.1",
Comment on lines 11 to 15
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pyproject.toml updates the allowed mcp version range, but the repo also tracks a uv.lock. To keep installs reproducible (and CI consistent), regenerate and commit the updated lockfile after changing dependency constraints.

Copilot uses AI. Check for mistakes.
"opentelemetry-api>=1.33.1",
"opentelemetry-exporter-otlp>=1.33.1",
Expand Down
Loading