feat(mcp): OMNIVOICE_MCP_ALLOWED_HOSTS — configurable host allowlist for MCP transport security (#1249) (#1250)

* feat(mcp): OMNIVOICE_MCP_ALLOWED_HOSTS env var for transport-security allowlist (#1249)

Agents running in Docker containers (or on other machines) connect via a
hostname like host.containers.internal, which the MCP SDK's DNS-rebinding
guard rejects with 421. Add OMNIVOICE_MCP_ALLOWED_HOSTS (comma-separated
host patterns) that extends both allowed_hosts and allowed_origins in
create_mcp_server(). Default empty → no behavior change.

Test: assert the env var extends the allowlist + origins. Docs: mcp.md
notes the env var for Docker/LAN agents.

* fix(changelog): move MCP_ALLOWED_HOSTS entry after Highlights per quiet style

* fix(mcp): add https:// origins for HTTPS reverse proxy clients (greptile P1)

* docs(mcp): add security note for remote agent connections (coderabbit)
This commit is contained in:
Paolo Antinori
2026-07-26 02:02:23 -07:00
committed by GitHub
parent e6ca31d7a6
commit 67d1f6a43d
4 changed files with 44 additions and 0 deletions
+4
View File
@@ -23,6 +23,10 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
- First run: the status bar (Logs, version, Sponsors) appears once you reach the studio, instead of overlaying the setup steps (#1241) - First run: the status bar (Logs, version, Sponsors) appears once you reach the studio, instead of overlaying the setup steps (#1241)
### Added
- `OMNIVOICE_MCP_ALLOWED_HOSTS` — comma-separated host patterns (e.g. `host.containers.internal:*,192.168.1.5:*`) that extend the MCP SDK's DNS-rebinding allowlist, so AI agents running in Docker containers or on other machines can reach the `/mcp` endpoint. The SDK default is localhost-only; this env var is opt-in (#1249)
### Docs ### Docs
- Docker: ROCm section explains that `torch.cuda.is_available() == True` isn't proof the app is on the GPU, and notes the `--group-add` needed for `/dev/kfd` on rootless hosts (#1228) - Docker: ROCm section explains that `torch.cuda.is_available() == True` isn't proof the app is on the GPU, and notes the `--group-add` needed for `/dev/kfd` on rootless hosts (#1228)
+18
View File
@@ -114,6 +114,24 @@ def create_mcp_server():
except Exception: except Exception:
pass pass
# Extend the MCP SDK's DNS-rebinding allowlist so agents on non-localhost
# hosts (Docker's host.containers.internal, a LAN IP, a reverse proxy) can
# reach the /mcp endpoint. The SDK default is localhost-only.
_mcp_hosts = os.environ.get("OMNIVOICE_MCP_ALLOWED_HOSTS", "")
if _mcp_hosts.strip():
hosts = [h.strip() for h in _mcp_hosts.split(",") if h.strip()]
try:
mcp.settings.transport_security.allowed_hosts.extend(hosts)
# Also extend origins for both http and https (browser-based MCP
# clients behind a proxy send an Origin header — agent clients
# typically don't, but a reverse proxy may use either scheme).
origins = [
f"{scheme}://{h}" for h in hosts for scheme in ("http", "https")
]
mcp.settings.transport_security.allowed_origins.extend(origins)
except Exception as e:
logger.warning("OMNIVOICE_MCP_ALLOWED_HOSTS not applied (%s)", e)
# ── Helpers ───────────────────────────────────────────────────────── # ── Helpers ─────────────────────────────────────────────────────────
def _api_base() -> str: def _api_base() -> str:
+8
View File
@@ -30,6 +30,14 @@ To bind this agent to a specific voice, send an
`X-OmniVoice-Client-Id` header (e.g. `claude-code`). See `X-OmniVoice-Client-Id` header (e.g. `claude-code`). See
[per-agent voices](#per-agent-voices). [per-agent voices](#per-agent-voices).
**Agents in Docker or on another machine:** the MCP SDK rejects non-localhost
Host headers by default (DNS-rebinding guard). Set
`OMNIVOICE_MCP_ALLOWED_HOSTS` to a comma-separated list of host patterns the
agent connects from (e.g. `host.containers.internal:*,192.168.1.50:*`).
Keep this on a trusted LAN or behind TLS (Tailscale Serve, a reverse proxy
with HTTPS) — the MCP transport is not authenticated, so don't expose it on
the open internet.
### stdio (clients that only speak stdio) ### stdio (clients that only speak stdio)
Use the bundled shim — it proxies stdio ↔ the mounted HTTP endpoint. Drop Use the bundled shim — it proxies stdio ↔ the mounted HTTP endpoint. Drop
+14
View File
@@ -103,3 +103,17 @@ def test_decode_ref_audio_rejects_garbage_without_raising():
def test_sniff_audio_ext_matches_magic_bytes(raw, ext): def test_sniff_audio_ext_matches_magic_bytes(raw, ext):
from mcp_server import _sniff_audio_ext from mcp_server import _sniff_audio_ext
assert _sniff_audio_ext(raw) == ext assert _sniff_audio_ext(raw) == ext
def test_mcp_allowed_hosts_env_extends_allowlist(monkeypatch):
"""OMNIVOICE_MCP_ALLOWED_HOSTS must extend the transport-security allowlist."""
from mcp_server import create_mcp_server
monkeypatch.setenv("OMNIVOICE_MCP_ALLOWED_HOSTS", "host.containers.internal:*,10.0.0.1:*")
server = create_mcp_server()
allowed = server.settings.transport_security.allowed_hosts
assert "host.containers.internal:*" in allowed
assert "10.0.0.1:*" in allowed
origins = server.settings.transport_security.allowed_origins
assert "http://host.containers.internal:*" in origins
assert "https://host.containers.internal:*" in origins