Skip to main content
Glama
Calandrel

mcp-mux

by Calandrel
README.md
# ๐Ÿ”€ MCP Mux

> **Dynamic Multi-Endpoint Python Model Context Protocol (MCP) Router & Orchestrator**

`mcp-mux` is a dynamic MCP server orchestrator and multiplexer. It acts as a central proxy to multiple remote and local sub-MCP servers, monitoring a `config.yaml` file to hot-reload endpoints live without server restarts. It supports Server-Sent Events (SSE) and Streamable HTTP backend transports, including a local SSE bridge for clients that need an SSE endpoint while the upstream server speaks Streamable HTTP. It also provides a lightweight `/summary` endpoint to minimize context token flooding for AI agents.

---

## ๐ŸŒŸ Key Features

- ๐Ÿ”„ **Dynamic Hot-Reloading**: Reloads endpoint configuration in place and uses
  one shared filesystem observer to invalidate only managed backends whose watched
  source trees changed.
- ๐Ÿš€ **Flexible Sub-Server Modes**:
  - **Remote**: Seamless proxying to external HTTP MCP endpoints.
  - **Managed CLI**: Native spawning of command-line tools (e.g., via `npx` or `uvx`).
- ๐Ÿงฉ **Configurable Request Headers**: Adds endpoint-specific upstream headers, including tokens loaded from environment variables.
- ๐ŸŒ‰ **Optional Local SSE-to-Streamable-HTTP Bridge**: Streamable HTTP endpoints are proxied as Streamable HTTP by default. Endpoints can opt into a legacy SSE compatibility bridge when traditional SSE clients need a local `event: endpoint` flow.
- โšก **Automatic Transport Auto-Detection**: Dynamically detects the backend transport mode (`streamable-http` vs `sse`) based on URL paths. Sub-servers with `/mcp` or `/mcp/` in their URL automatically default to `streamable-http`.
- ๐Ÿ›ก๏ธ **Session Propagation & Isolation**: For opt-in legacy bridge sessions, tracks local sessions per endpoint, maps upstream `Mcp-Session-Id` values to the correct local session, and rejects cross-endpoint session reuse.
- ๐Ÿค **Streamable HTTP Client Compatibility**: Normalizes upstream `Accept` headers for Streamable HTTP POST/DELETE requests and fills in missing JSON-RPC `"jsonrpc": "2.0"` fields for request bodies that otherwise look like JSON-RPC messages.
- ๐Ÿงผ **Decoded Response Header Safety**: Strips stale `Content-Encoding` and upstream `Content-Length` headers when the router reads and rebuilds JSON responses.
- ๐Ÿ“Š **Token-Saving Metadata Endpoint**: Registers a custom `/summary` route returning only namespaces and descriptions, shielding AI clients from schema bloat.
- ๐Ÿงน **Clean Subprocess Lifecycle**: The manager isolates background subprocesses inside unique Unix process groups (`os.setsid`) to guarantee no zombie processes are left behind on teardown.

---

## ๐Ÿ“ Architecture

```mermaid
graph TD
    A[main.py - Uvicorn/Starlette Server] --> B[config_loader.py - ConfigWatcher]
    B -->|Watches & Parses| C[config.yaml]
    A --> J[source_watcher.py - Shared Observer]
    J -->|Debounce and invalidate| E
    A --> D[process_manager.py - ProcessManager]
    D -->|Spawns / Cleans up| E[Managed Subprocesses: uvx / npx]
    A --> F[server.py - MCPRouter]
    F -->|Proxy SSE| G[Remote SSE Servers]
    F -->|Proxy Streamable HTTP & map sessions| H[Streamable HTTP Servers]
    A --> I[summary route]
```

---

## โš™๏ธ Configuration (`config.yaml`)

Define your endpoints in `mcp_router/config.yaml`. Here is an example layout:

```yaml
endpoints:
  - path: "web-search"
    mode: "remote"
    url: "https://mcp.garion.us/mcp"
    summary: "Google Search and content extraction tool"
    # transport: "streamable-http"  (Automatically detected due to /mcp path suffix)
    allowed_tools:
      - "google_search"
      - "batch_extract_urls"

  - path: "firecrawl"
    mode: "managed_cli"
    command: "export NVM_DIR=$HOME/.config/nvm && [ -s $NVM_DIR/nvm.sh ] && . $NVM_DIR/nvm.sh && HTTP_STREAMABLE_SERVER=true PORT=3033 HOST=localhost FIRECRAWL_API_URL=http://garion.us:3002 npx --yes firecrawl-mcp"
    url: "http://localhost:3033/mcp"
    summary: "Firecrawl Web Content Extraction Tool"
    timeout: 300  # Automatically shuts down after 300 seconds of inactivity
    watch:
      paths:
        - "/absolute/path/to/firecrawl-mcp/src"
      debounce_ms: 400
    allowed_tools:
      - "firecrawl_search"
      - "firecrawl_scrape"

  - path: "huggingface"
    mode: "remote"
    url: "https://huggingface.co/mcp"
    summary: "HuggingFace MCP Server โ€” model search, hub browsing, model download"
    headers:
      Authorization: "Bearer ${HF_TOKEN:-}"
```

### Environment Variable Expansion

Configuration values support shell-style environment references before validation:

- `${NAME}` expands to the required environment variable `NAME` and fails config loading if it is missing.
- `${NAME:-fallback}` expands to `NAME` when set, otherwise to `fallback`.
- Empty `Authorization: "Bearer ${HF_TOKEN:-}"` values are omitted, allowing endpoints such as Hugging Face to fall back to anonymous access.
- If `HF_TOKEN` is accidentally set to `Bearer hf_...`, the loader normalizes `Bearer Bearer hf_...` to `Bearer hf_...`.

### Configuration Parameters

| Parameter | Type | Required | Description |
|---|---|---|---|
| `path` | String | Yes | Unique namespace/route for the sub-server. |
| `mode` | String | Yes | Spawning mode. `remote` and `managed_cli` are currently handled by the router. |
| `url` | String | Yes (for remote/managed) | Target endpoint URL. |
| `command` | String | Yes (for managed) | Command string to spawn the local server process. |
| `summary` | String | Yes | Brief description of the sub-server, returned by `/summary`. |
| `timeout` | Integer | No | Inactivity timeout in seconds for CLI mode (defaults to 300). |
| `transport` | String | No | Transport mode (`sse` or `streamable-http`). Automatically detected if omitted. |
| `legacy_sse_bridge` | Boolean | No | For `streamable-http` endpoints only. Defaults to `false`; set to `true` to expose the local legacy SSE bridge instead of preserving upstream GET+SSE behavior. |
| `headers` | Mapping | No | Extra request headers forwarded upstream after environment expansion. |
| `allowed_tools` | List of Strings | No | Allowlist of tool names. Only these tools are exposed. |
| `denied_tools` | List of Strings | No | Denylist of tool names. These tools are excluded. (Ignored if `allowed_tools` is set). |
| `watch.paths` | List of Strings | No | Absolute, existing source directories that invalidate this managed endpoint when files change. Managed CLI endpoints only. |
| `watch.debounce_ms` | Integer | No | Per-endpoint change coalescing window from 50 through 60,000 milliseconds. Defaults to 400. |
| `watch.ignore_patterns` | List of Strings | No | Filename or relative-path glob patterns ignored by the watcher. Defaults to Python bytecode, editor swap/temporary files, and backup files. |

### Managed Backend Source Invalidation

`watch` provides selective development-time reload behavior without restarting the
mux or unrelated backends:

```yaml
endpoints:
  - path: "gh"
    mode: "managed_cli"
    command: >-
      cd /workspace/mcp_servers/gh_mcp &&
      MCP_GH_TRANSPORT=streamable-http
      MCP_GH_HTTP_PORT=8768
      uv run mcp-gh
    url: "http://localhost:8768/mcp"
    summary: "GitHub CLI MCP server"
    timeout: 300
    transport: "streamable-http"
    watch:
      paths:
        - "/workspace/mcp_servers/gh_mcp/src"
      debounce_ms: 400
```

The mux creates one watchdog `Observer` for all configured endpoints and schedules
each unique source directory only once. A file create, delete, modification, or
atomic move is mapped back to every endpoint that watches that path. Changes under
version-control metadata, virtual environments, dependency directories, and common
tool caches are always ignored. `ignore_patterns` provides additional
endpoint-specific exclusions.

Events are transferred from watchdog's thread to the mux event loop and coalesced
per endpoint. When the debounce window expires, the mux:

1. acquires the endpoint's existing startup lock;
2. verifies that the current configuration still watches the endpoint;
3. discards its local bridge sessions and inactivity timestamp;
4. terminates its complete managed process group; and
5. leaves the endpoint stopped.

The next request follows the normal on-demand activation path and starts the backend
from its revised source. An endpoint that was already stopped is not started merely
because a file changed. Shared source directories may invalidate multiple explicitly
configured endpoints, while unrelated managed and remote endpoints remain untouched.

Watch paths must be absolute, existing directories. Filesystem roots are rejected to
prevent accidentally monitoring an entire host. To include dependency manifests or
other files outside `src`, watch a suitably narrow common directory and add
`ignore_patterns` for generated content. Do not watch `.venv`, `node_modules`, build
output, logs, or other high-churn trees.

Invalidation intentionally interrupts active calls to the affected backend. Clients
must retry or reinitialize according to the backend transport; stateless Streamable
HTTP backends provide the cleanest behavior. This mechanism reloads backend code but
does not force external clients such as ChatGPT to rediscover a cached MCP tool
schema.

### Streamable HTTP Bridge Behavior

For `streamable-http` endpoints, the mux preserves the original transport by default:

- `POST /<path>` forwards JSON-RPC messages to the upstream MCP endpoint.
- `GET /<path>` with `Accept: text/event-stream` forwards to the upstream MCP endpoint and preserves the upstream response status and stream.

To expose the legacy local SSE bridge, set `legacy_sse_bridge: true` on that endpoint. SSE clients can then connect with:

```bash
curl -N -H 'Accept: text/event-stream' http://127.0.0.1:8012/huggingface
```

The first SSE event contains a local POST endpoint such as:

```text
event: endpoint
data: /huggingface?session_id=<local-session-id>
```

Client POSTs to that local URL are forwarded upstream. If the upstream returns `Mcp-Session-Id`, the router stores it on the local bridge session and forwards it on later POSTs for the same endpoint. Sessions are removed when the local SSE stream closes or when their endpoint is removed or changed during config reload.

---

## ๐Ÿš€ Getting Started

### Prerequisites
Make sure you have [uv](https://github.com/astral-sh/uv) installed.

### 1. Installation & Setup

Clone the repository and install all dependencies:
```bash
# Activate virtual environment
source .venv/bin/activate

# Install & sync dependencies
uv sync
```

### 2. Running the Orchestrator

Start the main router server (default port is `8012`):
```bash
export HF_TOKEN=hf_xxx  # optional; omit for anonymous Hugging Face access
uv run python main.py --port 8012
```

### 3. Querying Endpoint Summary
You can check active routes and summaries by visiting:
```bash
curl http://127.0.0.1:8012/summary
```

---

## ๐Ÿงช Testing

The project is fully tested using `pytest` and `pytest-asyncio`. To execute unit tests:

```bash
uv run pytest
```

Current verification state: `35 passed`.