Skip to content

Commit 4fc9726

Browse files
dbbvitorntkathole
authored andcommitted
feat: Serve the MCP server dual-stack on --host ::
feast mcp now takes --host :: and serves IPv4 and IPv6: a pre-bound dual-stack socket for uvicorn (as for ui and lineage), [::] for gunicorn. Signed-off-by: dbbvitor <[email protected]>
1 parent ae74360 commit 4fc9726

3 files changed

Lines changed: 73 additions & 4 deletions

File tree

‎docs/reference/feature-servers/mcp-server.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ A `feast-mcp` console script is also installed. It is equivalent to `feast mcp`,
4141

4242
**Server options:**
4343
* `--transport`: MCP transport to serve: `stdio`, `http`, `streamable-http`, or `sse` (default: `stdio`)
44-
* `--host`: Bind address for HTTP transports (default: `0.0.0.0`)
44+
* `--host`: Bind address for HTTP transports (default: `0.0.0.0`). Use `::` to serve both IPv6 and IPv4 clients; it falls back to `0.0.0.0` where IPv6 is unavailable.
4545
* `--port`: Bind port for HTTP transports (default: 8000)
4646
* `--workers`: Run under gunicorn with this many workers. Not supported by the `sse` transport
4747

‎sdk/python/feast/mcp/server.py‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,25 @@ def _run_uvicorn(mcp: FastMCP, cfg: Config) -> None:
109109
import uvicorn
110110

111111
app = _build_http_app(mcp, cfg)
112-
uvicorn.run(app, host=cfg.server.host, port=cfg.server.port)
112+
if cfg.server.host == "::":
113+
# uvicorn.run(host="::") binds IPv6-only, which drops IPv4 clients
114+
# (see feast.utils._make_dual_stack_socket).
115+
from feast.utils import _make_dual_stack_socket
116+
117+
sock = _make_dual_stack_socket(cfg.server.port)
118+
uvicorn.Server(uvicorn.Config(app)).run(sockets=[sock])
119+
else:
120+
uvicorn.run(app, host=cfg.server.host, port=cfg.server.port)
121+
122+
123+
def _gunicorn_bind(cfg: Config) -> str:
124+
"""Bind address for gunicorn: ``::`` becomes the bracketed ``[::]`` (dual-stack), or ``0.0.0.0`` where IPv6 is unavailable."""
125+
host = cfg.server.host
126+
if host == "::":
127+
from feast.utils import _ipv6_available
128+
129+
host = "[::]" if _ipv6_available() else "0.0.0.0"
130+
return f"{host}:{cfg.server.port}"
113131

114132

115133
def _run_gunicorn(mcp: FastMCP, cfg: Config) -> None:
@@ -119,7 +137,7 @@ def _run_gunicorn(mcp: FastMCP, cfg: Config) -> None:
119137

120138
class FeastMCPApplication(BaseApplication):
121139
def load_config(self) -> None:
122-
self.cfg.set("bind", f"{cfg.server.host}:{cfg.server.port}")
140+
self.cfg.set("bind", _gunicorn_bind(cfg))
123141
self.cfg.set("workers", cfg.server.workers)
124142
self.cfg.set("worker_class", "uvicorn.workers.UvicornWorker")
125143
self.cfg.set("accesslog", "-")
@@ -225,7 +243,7 @@ def run_server(
225243
@click.option(
226244
"--host",
227245
default=None,
228-
help="Bind address for HTTP transports. [default: 0.0.0.0]",
246+
help="Bind address for HTTP transports; '::' serves IPv6 and IPv4. [default: 0.0.0.0]",
229247
)
230248
@click.option(
231249
"--port",
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""IPv6 / dual-stack binds for the standalone MCP server (host ``::``)."""
2+
3+
from types import SimpleNamespace
4+
from unittest.mock import MagicMock, patch
5+
6+
import pytest
7+
8+
from feast.mcp.server import _gunicorn_bind, _run_uvicorn
9+
10+
11+
def _cfg(host: str, port: int = 8000):
12+
return SimpleNamespace(server=SimpleNamespace(host=host, port=port))
13+
14+
15+
@patch("feast.mcp.server._build_http_app")
16+
@patch("uvicorn.run")
17+
@patch("uvicorn.Server")
18+
@patch("uvicorn.Config")
19+
def test_uvicorn_dual_stack_binds_prebuilt_socket(
20+
mock_config, mock_server, mock_run, _mock_app
21+
):
22+
"""uvicorn.run(host="::") binds IPv6-only and drops IPv4 clients."""
23+
sock = MagicMock()
24+
with patch("feast.utils._make_dual_stack_socket", return_value=sock) as make:
25+
_run_uvicorn(MagicMock(), _cfg("::"))
26+
27+
make.assert_called_once_with(8000)
28+
mock_server.return_value.run.assert_called_once_with(sockets=[sock])
29+
mock_run.assert_not_called()
30+
31+
32+
@patch("feast.mcp.server._build_http_app")
33+
@patch("uvicorn.run")
34+
def test_uvicorn_other_hosts_use_plain_run(mock_run, mock_app):
35+
_run_uvicorn(MagicMock(), _cfg("0.0.0.0"))
36+
37+
mock_run.assert_called_once_with(mock_app.return_value, host="0.0.0.0", port=8000)
38+
39+
40+
@pytest.mark.parametrize(
41+
"host, ipv6, want",
42+
[
43+
("::", True, "[::]:8000"),
44+
("::", False, "0.0.0.0:8000"),
45+
("0.0.0.0", True, "0.0.0.0:8000"),
46+
("127.0.0.1", False, "127.0.0.1:8000"),
47+
],
48+
)
49+
def test_gunicorn_bind(host, ipv6, want):
50+
with patch("feast.utils._ipv6_available", return_value=ipv6):
51+
assert _gunicorn_bind(_cfg(host)) == want

0 commit comments

Comments
 (0)