Skip to content
Merged
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
29 changes: 28 additions & 1 deletion docs/user_guide/how_to_guides/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,29 @@ uvx --from redisvl[mcp] rvl mcp --config /path/to/mcp.yaml --transport sse --hos
Streamable HTTP and SSE endpoints are **unauthenticated by default**. Binding to a non-loopback host without auth fails closed unless you pass `--allow-unauthenticated`; binding to loopback without auth only warns. For real deployments, enable JWT authentication (see {doc}`mcp_authentication`) rather than using `--allow-unauthenticated`. When not using `--read-only`, the `upsert-records` tool is also exposed to any client that can reach the server.
```

### Transport Security (Host / Origin Validation)

On the HTTP transports the server validates the request `Host` and `Origin` headers against allowlists before any tool runs. This is **on by default** and defends against [DNS rebinding](https://en.wikipedia.org/wiki/DNS_rebinding): without it, a malicious web page could rebind its own hostname to `127.0.0.1` and use the victim's browser to reach a local MCP server, even one bound to loopback.

- **Host:** the allowlist is derived automatically from the bind address. Loopback binds accept `localhost`, `127.0.0.1`, and `[::1]` (with or without the port), so local MCP clients work with no configuration.
- **Origin:** requests with no `Origin` header (typical of non-browser MCP clients) always pass. A request that carries a cross-site `Origin` is rejected unless that origin is explicitly allowlisted.

You only need to configure this for deployments where the client-visible `Host` differs from the bind address (a reverse proxy, or a `--host 0.0.0.0` bind reached via a public hostname). Use the `REDISVL_MCP_ALLOWED_HOSTS` / `REDISVL_MCP_ALLOWED_ORIGINS` env vars, or a `server.transport_security` block in the config:

```yaml
server:
redis_url: ${REDIS_URL}
transport_security:
allowed_hosts: [mcp.example.com] # extra Host values to accept
allowed_origins: [https://app.example.com] # browser origins to accept
# allow_any_origin: true # trust upstream Origin validation
# enabled: false # disable when a trusted proxy validates
```

```{note}
Behind a reverse proxy or gateway that already validates `Host`/`Origin`, set `server.transport_security.enabled: false` (or `allow_any_origin: true`) so the proxy's rewritten headers are not rejected.
```

Run it in read-only mode to expose search without upsert:

```bash
Expand All @@ -83,6 +106,10 @@ You can also control boot settings through environment variables:
| `REDISVL_MCP_READ_ONLY` | Disable `upsert-records` when set to `true` |
| `REDISVL_MCP_TOOL_SEARCH_DESCRIPTION` | Set the base search tool description text; RedisVL still appends schema-derived typed filter, `exists`, and `return_fields` hints |
| `REDISVL_MCP_TOOL_UPSERT_DESCRIPTION` | Override the upsert tool description |
| `REDISVL_MCP_ALLOWED_HOSTS` | Comma-separated extra `Host` header values to accept on HTTP transports (in addition to the bind-derived defaults) |
| `REDISVL_MCP_ALLOWED_ORIGINS` | Comma-separated browser `Origin` values to accept on HTTP transports |
| `REDISVL_MCP_ALLOW_ANY_ORIGIN` | Accept any `Origin` (for trusted-proxy setups) when set to `true` |
| `REDISVL_MCP_TRANSPORT_SECURITY_ENABLED` | Set to `false` to disable Host/Origin validation (e.g. behind a validating proxy) |

## Connect a Remote MCP Client

Expand All @@ -91,7 +118,7 @@ When using Streamable HTTP or SSE transport, point your MCP client at the server
- **Streamable HTTP**: `http://<host>:<port>/mcp`
- **SSE**: `http://<host>:<port>/sse`

> **Note:** `<host>` here is the bind address the server was started with. The default `127.0.0.1` only accepts connections from the same machine. To allow connections from other machines, start the server with `--host 0.0.0.0` and use the machine's actual IP or hostname in the client URL.
> **Note:** `<host>` here is the bind address the server was started with. The default `127.0.0.1` only accepts connections from the same machine. To allow connections from other machines, start the server with `--host 0.0.0.0` and use the machine's actual IP or hostname in the client URL. Because a `0.0.0.0` bind has no single canonical `Host`, add the client-visible hostname(s) to `REDISVL_MCP_ALLOWED_HOSTS` (or `server.transport_security.allowed_hosts`), or Host validation will reject those requests. See [Transport Security](#transport-security-host-origin-validation).

For example, to configure a remote MCP client to connect to a Streamable HTTP server running on `192.168.1.10:8000`:

Expand Down
14 changes: 14 additions & 0 deletions docs/user_guide/how_to_guides/mcp_authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ The `stdio` transport is a local subprocess with no network surface and is never
authenticated.
```

```{important}
Authentication is a separate concern from **transport security** (Host/Origin
validation), which is always on for the HTTP transports and defends against DNS
rebinding independently of auth. See
[Transport Security](mcp.md#transport-security-host-origin-validation). Both
layers apply together: auth decides *who* may call; the Host/Origin guard
rejects requests whose claimed authority is not allowlisted.
```

## What RedisVL Enforces

RedisVL validates a bearer **JWT** that an existing identity provider (IdP)
Expand Down Expand Up @@ -199,6 +208,11 @@ Use RedisVL's JWT validation for authentication and coarse read/write
authorization. Layer a gateway on top when you need per-tenant Redis ACL
enforcement.

When such a gateway or reverse proxy terminates the connection and forwards a
rewritten `Host` header, set `server.transport_security.enabled: false` (or
declare the public host via `server.transport_security.allowed_hosts`) so the
proxy's rewritten `Host` is not rejected by the Host/Origin guard.

## See Also

- {doc}`mcp`: run and configure the RedisVL MCP server.
Expand Down
3 changes: 2 additions & 1 deletion redisvl/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from redisvl.mcp.config import MCPConfig, load_mcp_config
from redisvl.mcp.config import MCPConfig, MCPTransportSecurityConfig, load_mcp_config
from redisvl.mcp.errors import MCPErrorCode, RedisVLMCPError, map_exception
from redisvl.mcp.server import RedisVLMCPServer
from redisvl.mcp.settings import MCPSettings
Expand All @@ -7,6 +7,7 @@
"MCPConfig",
"MCPErrorCode",
"MCPSettings",
"MCPTransportSecurityConfig",
"RedisVLMCPError",
"RedisVLMCPServer",
"load_mcp_config",
Expand Down
30 changes: 30 additions & 0 deletions redisvl/mcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,41 @@ def _validate_jwt(self) -> "MCPAuthConfig":
return self


class MCPTransportSecurityConfig(BaseModel):
"""Host/Origin validation for the MCP server's HTTP transports.

Only serves requests that target a trusted authority: the ``Host`` header
must be allowlisted and any cross-site ``Origin`` must be too. This blocks
requests that reach the server claiming an authority it does not own --
DNS-rebinding being the primary motivating case, alongside cross-origin
browser requests and misrouted/proxied traffic.

Defaults to enabled. The effective host allowlist is the set derived from
the bind address (see :func:`default_allowed_hosts`) unioned with
``allowed_hosts``. ``stdio`` has no network surface and is never affected.
"""

enabled: bool = True
# Extra hosts accepted in addition to the bind-derived default allowlist.
# Needed for reverse-proxy / non-loopback deployments whose public Host
# differs from the bind address.
allowed_hosts: list[str] = Field(default_factory=list)
# Exact ``Origin`` values accepted from browser clients. Empty means no
# cross-site browser origin is allowed; requests with no Origin (typical of
# non-browser MCP clients) always pass.
allowed_origins: list[str] = Field(default_factory=list)
# Escape hatch for trusted-proxy setups that terminate/validate Origin
# upstream. Distinct from an empty allowlist so "no origins configured"
# (fail closed on any Origin) differs from "operator disabled the check".
allow_any_origin: bool = False


class MCPServerConfig(BaseModel):
"""Server-level bootstrap configuration."""

redis_url: str = Field(..., min_length=1)
auth: MCPAuthConfig | None = None
transport_security: MCPTransportSecurityConfig | None = None


class MCPIndexSearchConfig(BaseModel):
Expand Down
35 changes: 35 additions & 0 deletions redisvl/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
from redisvl.mcp.settings import MCPSettings
from redisvl.mcp.tools.search import register_search_tool
from redisvl.mcp.tools.upsert import register_upsert_tool
from redisvl.mcp.transport_security import (
build_host_origin_middleware,
resolve_transport_security_config,
)
from redisvl.redis.connection import RedisConnectionFactory, is_version_gte
from redisvl.schema import IndexSchema

Expand Down Expand Up @@ -84,8 +88,39 @@ def __init__(self, settings: MCPSettings):
self.auth_config = auth_config
self._auth_enabled = auth_provider is not None

# Host/Origin (DNS-rebinding) protection for HTTP transports. Resolved
# here so config errors surface at construction; the bind-derived host
# allowlist is finalized in run_async once host/port are known.
self._transport_security = resolve_transport_security_config(
settings, self._config_path
)

super().__init__("redisvl", lifespan=self._fastmcp_lifespan, auth=auth_provider)

async def run_async(
self,
transport: Any = None,
show_banner: bool | None = None,
**transport_kwargs: Any,
) -> None:
"""Run the server, injecting Host/Origin validation for HTTP transports.

The guard is prepended to any caller-supplied middleware so it runs
outermost, rejecting DNS-rebinding requests before auth or tool handlers.
``stdio`` is untouched.
"""
if transport in ("sse", "streamable-http"):
host = transport_kwargs.get("host", "127.0.0.1")
port = transport_kwargs.get("port", 8000)
guard = build_host_origin_middleware(self._transport_security, host, port)
if guard:
existing = transport_kwargs.get("middleware") or []
transport_kwargs["middleware"] = [*guard, *existing]

await super().run_async(
transport=transport, show_banner=show_banner, **transport_kwargs
)

async def startup(self) -> None:
"""Load config, inspect the configured index, and initialize dependencies."""
async with self._transition_lock:
Expand Down
28 changes: 28 additions & 0 deletions redisvl/mcp/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ class MCPSettings(BaseSettings):
auth_authorization_claim: str | None = None
auth_base_url: str | None = None

# Transport security overrides (``REDISVL_MCP_*``). When any are set they
# take precedence over the YAML ``server.transport_security`` block.
transport_security_enabled: bool | None = None
allowed_hosts: str | None = None
allowed_origins: str | None = None
allow_any_origin: bool | None = None

@classmethod
def from_env(
cls,
Expand Down Expand Up @@ -86,3 +93,24 @@ def auth_overrides(self) -> dict[str, Any]:
item.strip() for item in env_value.split(",") if item.strip()
]
return overrides

def transport_security_overrides(self) -> dict[str, Any]:
"""Return the non-``None`` transport-security fields as a config mapping.

Comma-separated ``allowed_hosts`` / ``allowed_origins`` are split into
lists. Returns an empty dict when no transport-security env vars are set.
"""
overrides: dict[str, Any] = {}
if self.transport_security_enabled is not None:
overrides["enabled"] = self.transport_security_enabled
if self.allow_any_origin is not None:
overrides["allow_any_origin"] = self.allow_any_origin
for env_value, field in (
(self.allowed_hosts, "allowed_hosts"),
(self.allowed_origins, "allowed_origins"),
):
if env_value is not None:
overrides[field] = [
item.strip() for item in env_value.split(",") if item.strip()
]
return overrides
Loading
Loading