Merge pull request #382 from cbcoutinho/renovate/mcp-1.x

fix(deps): update dependency mcp to >=1.23,<1.24
This commit is contained in:
Chris Coutinho
2025-12-12 18:00:04 +01:00
committed by GitHub
5 changed files with 306 additions and 7 deletions
+104
View File
@@ -0,0 +1,104 @@
# MCP 1.23.x DNS Rebinding Protection Fix
## Problem
MCP Python SDK 1.23.0 introduced **automatic DNS rebinding protection** that breaks containerized deployments (Kubernetes, Docker) when the protection is unintentionally auto-enabled.
### Root Cause
From `mcp/server/fastmcp/server.py:177-183` in the Python SDK:
```python
# Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6)
if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):
transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"],
allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"],
)
```
### What Was Happening
1. **FastMCP initialization** in `app.py` didn't pass `host` or `transport_security` parameters
2. **Defaults applied**: `host="127.0.0.1"`, `transport_security=None`
3. **Auto-enablement triggered**: Condition `transport_security is None and host == "127.0.0.1"` was TRUE
4. **Protection activated** with `allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"]`
5. **Kubernetes requests rejected**: `Host: nextcloud-mcp-server.default.svc.cluster.local:8000` didn't match allowed hosts
### Why `--host 0.0.0.0` Didn't Help
The `--host` CLI flag (used in Dockerfile/docker-compose) controls **uvicorn's bind address**, NOT the **FastMCP `host` parameter**. These are separate concerns:
- **Uvicorn bind address** (`--host 0.0.0.0`): Where the HTTP server listens
- **FastMCP host parameter** (defaulted to `"127.0.0.1"`): Used for auto-enablement logic
## Solution
Explicitly disable DNS rebinding protection by passing `transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)` to all FastMCP instances.
### Changes Made
Modified `nextcloud_mcp_server/app.py`:
1. **Import** `TransportSecuritySettings` from `mcp.server.transport_security`
2. **Updated all three FastMCP initializations**:
- OAuth mode (line 1015)
- Smithery stateless mode (line 1030)
- BasicAuth mode (line 1040)
Each now includes:
```python
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)
```
## Impact
### ✅ What This Fixes
- **Kubernetes deployments**: Requests with k8s service DNS names now work
- **Docker deployments**: Port-mapped requests (localhost:8000 → container) now work
- **Reverse proxy deployments**: Proxied requests with various Host headers now work
- **Ingress controllers**: Requests via ingress hostnames now work
### 🔒 Security Considerations
DNS rebinding protection defends against attacks where:
1. Attacker controls a DNS domain (e.g., `evil.com`)
2. DNS initially resolves to attacker's IP
3. After victim's browser caches the origin, DNS changes to victim's localhost
4. Attacker's page can now make requests to victim's localhost services
**Why it's safe to disable for this deployment:**
1. **OAuth authentication required** in production deployments (ADR-002, ADR-004)
2. **Network-level isolation** in containerized environments (k8s network policies, Docker networks)
3. **MCP is server-to-server**, not exposed to browsers (no CORS concerns)
4. **Host header validation inappropriate** for multi-tenant k8s environments
If DNS rebinding protection is needed for specific deployments, it can be re-enabled with a custom allowed hosts list:
```python
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=[
"nextcloud-mcp-server.default.svc.cluster.local:*",
"mcp.example.com:*",
# Add all your expected Host header values
]
)
```
## Testing
- ✅ Ruff linting passes
- ✅ Type checking passes (pre-existing warnings unrelated)
- ✅ Module imports successfully
- ✅ Compatible with MCP 1.23.x
## References
- [MCP Python SDK 1.23.0 Release](https://github.com/modelcontextprotocol/python-sdk/releases/tag/v1.23.0)
- Commit: `d3a1841` - "Auto-enable DNS rebinding protection for localhost servers"
- Issue #373 (original report of k8s breakage)
- PR #382 (MCP 1.23.x upgrade)
+23 -2
View File
@@ -19,6 +19,7 @@ import httpx
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp import Context, FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from starlette.middleware.authentication import AuthenticationMiddleware
@@ -1016,6 +1017,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
lifespan=oauth_lifespan,
token_verifier=token_verifier,
auth=auth_settings,
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
)
else:
# ADR-016: Use Smithery lifespan for stateless mode, BasicAuth otherwise
@@ -1024,11 +1030,26 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# json_response=True returns plain JSON-RPC instead of SSE format,
# required for Smithery scanner compatibility
mcp = FastMCP(
"Nextcloud MCP", lifespan=app_lifespan_smithery, json_response=True
"Nextcloud MCP",
lifespan=app_lifespan_smithery,
json_response=True,
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
)
else:
logger.info("Configuring MCP server for BasicAuth mode")
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic)
mcp = FastMCP(
"Nextcloud MCP",
lifespan=app_lifespan_basic,
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
)
@mcp.resource("nc://capabilities")
async def nc_get_capabilities():
+1 -1
View File
@@ -10,7 +10,7 @@ license = {text = "AGPL-3.0-only"}
requires-python = ">=3.11"
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
dependencies = [
"mcp[cli] (>=1.22,<1.23)",
"mcp[cli] (>=1.23,<1.24)",
"httpx (>=0.28.1,<0.29.0)",
"pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed
"icalendar (>=6.0.0,<7.0.0)",
+174
View File
@@ -0,0 +1,174 @@
"""
Test that DNS rebinding protection is properly disabled for containerized deployments.
This test verifies that the fix for MCP 1.23.x DNS rebinding protection works correctly.
Without the fix, requests with Host headers that don't match the default allowed list
(127.0.0.1:*, localhost:*, [::1]:*) would be rejected with a 421 Misdirected Request error.
"""
import httpx
import pytest
@pytest.mark.integration
async def test_accepts_various_host_headers():
"""Test that the MCP server accepts requests with various Host headers.
This test simulates what happens in containerized deployments where the Host
header might be a k8s service DNS name, a proxied hostname, or other values
that don't match the default allowed list.
Without the DNS rebinding protection fix, these requests would fail with:
- 421 Misdirected Request (for Host header mismatch)
- 403 Forbidden (for Origin header mismatch)
"""
mcp_url = "http://localhost:8000/mcp"
# Test various Host headers that would be rejected by DNS rebinding protection
test_cases = [
{
"name": "Kubernetes service DNS",
"headers": {
"Host": "nextcloud-mcp-server.default.svc.cluster.local:8000",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
},
{
"name": "Custom domain",
"headers": {
"Host": "mcp.example.com:8000",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
},
{
"name": "Proxied hostname",
"headers": {
"Host": "proxy.internal:8000",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
},
{
"name": "Default localhost (should always work)",
"headers": {
"Host": "localhost:8000",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
},
]
# Create a simple initialize request payload
initialize_request = {
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.0.0"},
},
"id": 1,
}
async with httpx.AsyncClient() as client:
for test_case in test_cases:
print(f"\n🧪 Testing: {test_case['name']}")
print(f" Host header: {test_case['headers']['Host']}")
response = await client.post(
mcp_url,
json=initialize_request,
headers=test_case["headers"],
timeout=10.0,
)
# With DNS rebinding protection enabled (MCP 1.23 default), these would fail with:
# - 421 Misdirected Request (Host header not in allowed list)
# - 403 Forbidden (Origin header not in allowed list)
#
# With our fix (enable_dns_rebinding_protection=False), they should succeed
assert response.status_code in [200, 202], (
f"Request failed for {test_case['name']}: "
f"status={response.status_code}, "
f"headers={test_case['headers']}, "
f"body={response.text[:200]}"
)
print(f" ✅ Status: {response.status_code}")
# For SSE responses (status 200), verify we got SSE format
# For JSON responses (status 202), verify we got valid JSON
if response.status_code == 200:
# SSE response - should start with "event: message" or similar
response_text = response.text
assert "event:" in response_text or "data:" in response_text, (
f"Expected SSE format for {test_case['name']}, got: {response_text[:200]}"
)
print(" ✅ Received SSE stream response")
elif response.status_code == 202:
# JSON response for notifications
response_json = response.json()
assert "jsonrpc" in response_json or response_json is None, (
f"Invalid response for {test_case['name']}: {response_json}"
)
print(" ✅ Received JSON response")
@pytest.mark.integration
async def test_dns_rebinding_protection_is_disabled():
"""Verify that DNS rebinding protection is actually disabled in the configuration.
This test makes a request that would DEFINITELY fail if DNS rebinding protection
was enabled with default settings (only allowing 127.0.0.1:*, localhost:*, [::1]:*).
"""
mcp_url = "http://localhost:8000/mcp"
# Use a Host header that would NEVER be in the default allowed list
malicious_host = "evil.attacker.com:8000"
initialize_request = {
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.0.0"},
},
"id": 1,
}
async with httpx.AsyncClient() as client:
response = await client.post(
mcp_url,
json=initialize_request,
headers={
"Host": malicious_host,
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
timeout=10.0,
)
# If DNS rebinding protection was enabled, this would return:
# - 421 Misdirected Request (Host header validation failed)
#
# Since we disabled it, this should succeed (status 200 or 202)
assert response.status_code in [200, 202], (
f"DNS rebinding protection may still be enabled! "
f"Request with Host='{malicious_host}' was rejected: "
f"status={response.status_code}, body={response.text[:500]}"
)
# Verify we got a valid response (SSE or JSON)
if response.status_code == 200:
response_text = response.text
assert "event:" in response_text or "data:" in response_text, (
f"Expected SSE format, got: {response_text[:200]}"
)
print("✅ DNS rebinding protection is properly disabled")
print(
f" Request with Host '{malicious_host}' succeeded: {response.status_code}"
)
Generated
+4 -4
View File
@@ -1671,7 +1671,7 @@ wheels = [
[[package]]
name = "mcp"
version = "1.22.0"
version = "1.23.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -1689,9 +1689,9 @@ dependencies = [
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/a2/c5ec0ab38b35ade2ae49a90fada718fbc76811dc5aa1760414c6aaa6b08a/mcp-1.22.0.tar.gz", hash = "sha256:769b9ac90ed42134375b19e777a2858ca300f95f2e800982b3e2be62dfc0ba01", size = 471788, upload-time = "2025-11-20T20:11:28.095Z" }
sdist = { url = "https://files.pythonhosted.org/packages/39/a9/0e95530946408747ae200e86553ceda0dbd851d4ae9bbe0d02a69cbd6ad5/mcp-1.23.2.tar.gz", hash = "sha256:df4e4b7273dca2aaf428f9cf7a25bbac0c9007528a65004854b246aef3d157bc", size = 599953, upload-time = "2025-12-08T15:51:02.432Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/bb/711099f9c6bb52770f56e56401cdfb10da5b67029f701e0df29362df4c8e/mcp-1.22.0-py3-none-any.whl", hash = "sha256:bed758e24df1ed6846989c909ba4e3df339a27b4f30f1b8b627862a4bade4e98", size = 175489, upload-time = "2025-11-20T20:11:26.542Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6a/1a726905cf41a69d00989e8dfd9de7bd9b4a9f3c8723dac3077b0ba1a7b9/mcp-1.23.2-py3-none-any.whl", hash = "sha256:d8e4c6af0317ad954ea0a53dfb5e229dddea2d0a54568c080e82e8fae4a8264e", size = 231897, upload-time = "2025-12-08T15:51:01.023Z" },
]
[package.optional-dependencies]
@@ -2027,7 +2027,7 @@ requires-dist = [
{ name = "jinja2", specifier = ">=3.1.6" },
{ name = "langchain-text-splitters", specifier = ">=1.0.0" },
{ name = "markdownify", specifier = ">=0.14.1" },
{ name = "mcp", extras = ["cli"], specifier = ">=1.22,<1.23" },
{ name = "mcp", extras = ["cli"], specifier = ">=1.23,<1.24" },
{ name = "openai", specifier = ">=2.8.1" },
{ name = "opentelemetry-api", specifier = ">=1.28.2" },
{ name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.28.2" },