Skip to content

Commit e72b983

Browse files
authored
feat: Support HTTP in MCP (#6109)
* feat: Support HTTP in MCP Signed-off-by: aaronzuo <anarionzuo@outlook.com> * dev Signed-off-by: aaronzuo <anarionzuo@outlook.com> * format Signed-off-by: aaronzuo <anarionzuo@outlook.com> * fix Signed-off-by: aaronzuo <anarionzuo@outlook.com> * fix Signed-off-by: aaronzuo <anarionzuo@outlook.com> * fix Signed-off-by: aaronzuo <anarionzuo@outlook.com> * dev Signed-off-by: aaronzuo <anarionzuo@outlook.com> * fix Signed-off-by: aaronzuo <anarionzuo@outlook.com> * fix reviews Signed-off-by: aaronzuo <anarionzuo@outlook.com> * fix logger Signed-off-by: aaronzuo <anarionzuo@outlook.com> * fix logger test Signed-off-by: aaronzuo <anarionzuo@outlook.com> * fix comment Signed-off-by: aaronzuo <anarionzuo@outlook.com> * resolve comments Signed-off-by: aaronzuo <anarionzuo@outlook.com> --------- Signed-off-by: aaronzuo <anarionzuo@outlook.com>
1 parent 4dac5b2 commit e72b983

9 files changed

Lines changed: 286 additions & 65 deletions

File tree

.github/workflows/pr_integration_tests.yml

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,80 @@ jobs:
9696
run: make test-python-integration
9797
- name: Minimize uv cache
9898
run: uv cache prune --ci
99+
100+
mcp-feature-server-runtime:
101+
if:
102+
((github.event.action == 'labeled' && (github.event.label.name == 'approved' || github.event.label.name == 'lgtm' || github.event.label.name == 'ok-to-test')) ||
103+
(github.event.action != 'labeled' && (contains(github.event.pull_request.labels.*.name, 'ok-to-test') || contains(github.event.pull_request.labels.*.name, 'approved') || contains(github.event.pull_request.labels.*.name, 'lgtm')))) &&
104+
github.repository == 'feast-dev/feast'
105+
runs-on: ubuntu-latest
106+
steps:
107+
- uses: actions/checkout@v4
108+
with:
109+
ref: refs/pull/${{ github.event.pull_request.number }}/merge
110+
submodules: recursive
111+
persist-credentials: false
112+
- name: Setup Python
113+
uses: actions/setup-python@v5
114+
with:
115+
python-version: "3.11"
116+
architecture: x64
117+
- name: Install the latest version of uv
118+
uses: astral-sh/setup-uv@v5
119+
with:
120+
enable-cache: true
121+
- name: Install dependencies
122+
run: make install-python-dependencies-ci
123+
- name: Start feature server (MCP HTTP)
124+
run: |
125+
cd examples/mcp_feature_store
126+
uv run python -m feast.cli.cli serve --host 127.0.0.1 --port 6566 --workers 1 --no-access-log &
127+
SERVER_PID=$!
128+
echo $SERVER_PID > /tmp/feast_server_pid
129+
for i in $(seq 1 60); do
130+
kill -0 "$SERVER_PID" || { echo "server died"; exit 1; }
131+
if curl -fsS http://127.0.0.1:6566/health >/dev/null; then
132+
break
133+
fi
134+
sleep 1
135+
done
136+
curl -fsS http://127.0.0.1:6566/health >/dev/null
137+
- name: Validate MCP endpoint
138+
run: |
139+
rm -f /tmp/mcp_headers /tmp/mcp_headers2 /tmp/mcp_body2
140+
141+
curl -sS -D /tmp/mcp_headers -o /dev/null --max-time 10 \
142+
-X POST \
143+
-H "Accept: application/json, text/event-stream" \
144+
-H "Content-Type: application/json" \
145+
-H "mcp-protocol-version: 2025-03-26" \
146+
--data '{}' \
147+
http://127.0.0.1:6566/mcp
148+
149+
SESSION_ID=$(grep -i "^mcp-session-id:" /tmp/mcp_headers | head -1 | awk '{print $2}' | tr -d '\r')
150+
if [ -z "${SESSION_ID}" ]; then
151+
cat /tmp/mcp_headers || true
152+
exit 1
153+
fi
154+
155+
curl -sS -D /tmp/mcp_headers2 -o /tmp/mcp_body2 --max-time 10 \
156+
-X POST \
157+
-H "Accept: application/json, text/event-stream" \
158+
-H "Content-Type: application/json" \
159+
-H "mcp-protocol-version: 2025-03-26" \
160+
-H "mcp-session-id: ${SESSION_ID}" \
161+
--data '{}' \
162+
http://127.0.0.1:6566/mcp
163+
164+
grep -Eq "^HTTP/.* 400" /tmp/mcp_headers2
165+
grep -Eiq "^content-type: application/json" /tmp/mcp_headers2
166+
grep -Eiq "^mcp-session-id: ${SESSION_ID}" /tmp/mcp_headers2
167+
- name: Stop feature server
168+
if: always()
169+
run: |
170+
if [ -f /tmp/feast_server_pid ]; then
171+
kill "$(cat /tmp/feast_server_pid)" || true
172+
fi
173+
- name: Minimize uv cache
174+
if: always()
175+
run: uv cache prune --ci

docs/getting-started/genai.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,13 @@ Feast supports the Model Context Protocol (MCP), which enables AI agents and app
146146
type: mcp
147147
enabled: true
148148
mcp_enabled: true
149+
mcp_transport: http
149150
mcp_server_name: "feast-feature-store"
150151
mcp_server_version: "1.0.0"
151152
```
152153
154+
By default, Feast uses the SSE-based MCP transport (`mcp_transport: sse`). Streamable HTTP (`mcp_transport: http`) is recommended for improved compatibility with some MCP clients.
155+
153156
### How It Works
154157

155158
The MCP integration uses the `fastapi_mcp` library to automatically transform your Feast feature server's FastAPI endpoints into MCP-compatible tools. When you enable MCP support:
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# MCP Feature Server
2+
3+
## Overview
4+
5+
Feast can expose the Python Feature Server as an MCP (Model Context Protocol) server using `fastapi_mcp`. When enabled, MCP clients can discover and call Feast tools such as online feature retrieval.
6+
7+
## Installation
8+
9+
```bash
10+
pip install feast[mcp]
11+
```
12+
13+
## Configuration
14+
15+
Add an MCP `feature_server` block to your `feature_store.yaml`:
16+
17+
```yaml
18+
feature_server:
19+
type: mcp
20+
enabled: true
21+
mcp_enabled: true
22+
mcp_transport: http
23+
mcp_server_name: "feast-feature-store"
24+
mcp_server_version: "1.0.0"
25+
```
26+
27+
### mcp_transport
28+
29+
`mcp_transport` controls how MCP is mounted into the Feature Server:
30+
31+
- `sse`: SSE-based transport. This is the default for backward compatibility.
32+
- `http`: Streamable HTTP transport. This is recommended for improved compatibility with some MCP clients.
33+
34+
If `mcp_transport: http` is configured but your installed `fastapi_mcp` version does not support Streamable HTTP mounting, Feast will fail fast with an error asking you to upgrade `fastapi_mcp` (or reinstall `feast[mcp]`).
35+
36+
## Endpoints
37+
38+
MCP is mounted at:
39+
40+
- `/mcp`
41+
42+
## Connecting an MCP client
43+
44+
Use your MCP client’s “HTTP” configuration and point it to the Feature Server base URL. For example, if your Feature Server runs at `http://localhost:6566`, use:
45+
46+
- `http://localhost:6566/mcp`
47+
48+
## Troubleshooting
49+
50+
- If you see a deprecation warning about `mount()` at runtime, upgrade `fastapi_mcp` and use `mcp_transport: http` or `mcp_transport: sse`.
51+
- If your MCP client has intermittent connectivity issues with `mcp_transport: sse`, switch to `mcp_transport: http`.

examples/mcp_feature_store/feature_store.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ feature_server:
1414
type: mcp
1515
enabled: true
1616
mcp_enabled: true # Enable MCP support - defaults to false
17+
mcp_transport: http
1718
mcp_server_name: "feast-feature-store"
1819
mcp_server_version: "1.0.0"
1920
feature_logging:
2021
enabled: false
2122

22-
entity_key_serialization_version: 3
23+
entity_key_serialization_version: 3

sdk/python/feast/feature_server.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,7 @@ async def websocket_endpoint(websocket: WebSocket):
730730

731731
def _add_mcp_support_if_enabled(app, store: "feast.FeatureStore"):
732732
"""Add MCP support to the FastAPI app if enabled in configuration."""
733+
mcp_transport_not_supported_error = None
733734
try:
734735
# Check if MCP is enabled in feature server config
735736
if (
@@ -738,7 +739,16 @@ def _add_mcp_support_if_enabled(app, store: "feast.FeatureStore"):
738739
and store.config.feature_server.type == "mcp"
739740
and getattr(store.config.feature_server, "mcp_enabled", False)
740741
):
741-
from feast.infra.mcp_servers.mcp_server import add_mcp_support_to_app
742+
try:
743+
from feast.infra.mcp_servers.mcp_server import (
744+
McpTransportNotSupportedError,
745+
add_mcp_support_to_app,
746+
)
747+
748+
mcp_transport_not_supported_error = McpTransportNotSupportedError
749+
except ImportError as e:
750+
logger.error(f"Error checking/adding MCP support: {e}")
751+
return
742752

743753
mcp_server = add_mcp_support_to_app(app, store, store.config.feature_server)
744754

@@ -749,6 +759,10 @@ def _add_mcp_support_if_enabled(app, store: "feast.FeatureStore"):
749759
else:
750760
logger.debug("MCP support is not enabled in feature server configuration")
751761
except Exception as e:
762+
if mcp_transport_not_supported_error and isinstance(
763+
e, mcp_transport_not_supported_error
764+
):
765+
raise
752766
logger.error(f"Error checking/adding MCP support: {e}")
753767
# Don't fail the entire server if MCP fails to initialize
754768

sdk/python/feast/infra/mcp_servers/mcp_config.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Literal, Optional
1+
from typing import Literal
22

33
from pydantic import StrictBool, StrictStr
44

@@ -20,8 +20,7 @@ class McpFeatureServerConfig(BaseFeatureServerConfig):
2020
# MCP server version
2121
mcp_server_version: StrictStr = "1.0.0"
2222

23-
# Optional MCP transport configuration
24-
mcp_transport: Optional[StrictStr] = None
23+
mcp_transport: Literal["sse", "http"] = "sse"
2524

2625
# The endpoint definition for transformation_service (inherited from base)
2726
transformation_service_endpoint: StrictStr = "localhost:6566"

sdk/python/feast/infra/mcp_servers/mcp_server.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
FastApiMCP = None
2727

2828

29+
class McpTransportNotSupportedError(RuntimeError):
30+
pass
31+
32+
2933
def add_mcp_support_to_app(app, store: FeatureStore, config) -> Optional["FastApiMCP"]:
3034
"""Add MCP support to the FastAPI app if enabled in configuration."""
3135
if not MCP_AVAILABLE:
@@ -40,8 +44,29 @@ def add_mcp_support_to_app(app, store: FeatureStore, config) -> Optional["FastAp
4044
description="Feast Feature Store MCP Server - Access feature store data and operations through MCP",
4145
)
4246

43-
# Mount the MCP server to the FastAPI app
44-
mcp.mount()
47+
transport = getattr(config, "mcp_transport", "sse")
48+
if transport == "http":
49+
mount_http = getattr(mcp, "mount_http", None)
50+
if mount_http is None:
51+
raise McpTransportNotSupportedError(
52+
"mcp_transport=http requires fastapi_mcp with FastApiMCP.mount_http(). "
53+
"Upgrade fastapi_mcp (or install feast[mcp]) to a newer version."
54+
)
55+
mount_http()
56+
elif transport == "sse":
57+
mount_sse = getattr(mcp, "mount_sse", None)
58+
if mount_sse is not None:
59+
mount_sse()
60+
else:
61+
logger.warning(
62+
"transport sse not supported, fallback to the deprecated mount()."
63+
)
64+
mcp.mount()
65+
else:
66+
# Defensive guard for programmatic callers.
67+
raise McpTransportNotSupportedError(
68+
f"Unsupported mcp_transport={transport!r}. Expected 'sse' or 'http'."
69+
)
4570

4671
logger.info(
4772
"MCP support has been enabled for the Feast feature server at /mcp endpoint"
@@ -53,6 +78,8 @@ def add_mcp_support_to_app(app, store: FeatureStore, config) -> Optional["FastAp
5378

5479
return mcp
5580

81+
except McpTransportNotSupportedError:
82+
raise
5683
except Exception as e:
57-
logger.error(f"Failed to initialize MCP integration: {e}")
84+
logger.error(f"Failed to initialize MCP integration: {e}", exc_info=True)
5885
return None

sdk/python/tests/integration/test_mcp_feature_server.py

Lines changed: 30 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pytest
55
from fastapi import FastAPI
66
from fastapi.testclient import TestClient
7+
from pydantic import ValidationError
78

89
from feast.feature_store import FeatureStore
910
from feast.infra.mcp_servers.mcp_config import McpFeatureServerConfig
@@ -49,7 +50,7 @@ def test_mcp_server_functionality_with_mock_store(self):
4950
mcp_server_version="1.0.0",
5051
)
5152

52-
mock_mcp_instance = Mock()
53+
mock_mcp_instance = Mock(spec_set=["mount_sse", "mount_http", "mount"])
5354
mock_fast_api_mcp.return_value = mock_mcp_instance
5455

5556
result = add_mcp_support_to_app(mock_app, mock_store, config)
@@ -58,7 +59,7 @@ def test_mcp_server_functionality_with_mock_store(self):
5859
self.assertIsNotNone(result)
5960
self.assertEqual(result, mock_mcp_instance)
6061
mock_fast_api_mcp.assert_called_once()
61-
mock_mcp_instance.mount.assert_called_once()
62+
mock_mcp_instance.mount_sse.assert_called_once()
6263

6364
@patch("feast.infra.mcp_servers.mcp_server.MCP_AVAILABLE", True)
6465
@patch("feast.infra.mcp_servers.mcp_server.FastApiMCP")
@@ -77,7 +78,7 @@ def test_complete_mcp_setup_flow(self, mock_fast_api_mcp):
7778
transformation_service_endpoint="localhost:6566",
7879
)
7980

80-
mock_mcp_instance = Mock()
81+
mock_mcp_instance = Mock(spec_set=["mount_sse", "mount_http", "mount"])
8182
mock_fast_api_mcp.return_value = mock_mcp_instance
8283

8384
# Execute the flow
@@ -90,7 +91,7 @@ def test_complete_mcp_setup_flow(self, mock_fast_api_mcp):
9091
name="e2e-test-server",
9192
description="Feast Feature Store MCP Server - Access feature store data and operations through MCP",
9293
)
93-
mock_mcp_instance.mount.assert_called_once()
94+
mock_mcp_instance.mount_sse.assert_called_once()
9495
self.assertEqual(result, mock_mcp_instance)
9596

9697
@pytest.mark.skipif(
@@ -160,36 +161,29 @@ def test_feature_server_with_mcp_config(self):
160161
def test_mcp_server_configuration_validation(self):
161162
"""Test comprehensive MCP server configuration validation."""
162163
# Test various configuration combinations
163-
test_configs = [
164-
{
165-
"enabled": True,
166-
"mcp_enabled": True,
167-
"mcp_server_name": "test-server-1",
168-
"mcp_server_version": "1.0.0",
169-
"mcp_transport": "sse",
170-
},
171-
{
172-
"enabled": True,
173-
"mcp_enabled": True,
174-
"mcp_server_name": "test-server-2",
175-
"mcp_server_version": "2.0.0",
176-
"mcp_transport": "websocket",
177-
},
178-
{
179-
"enabled": False,
180-
"mcp_enabled": False,
181-
"mcp_server_name": "disabled-server",
182-
"mcp_server_version": "1.0.0",
183-
"mcp_transport": None,
184-
},
185-
]
186-
187-
for config_dict in test_configs:
188-
config = McpFeatureServerConfig(**config_dict)
189-
self.assertEqual(config.enabled, config_dict["enabled"])
190-
self.assertEqual(config.mcp_enabled, config_dict["mcp_enabled"])
191-
self.assertEqual(config.mcp_server_name, config_dict["mcp_server_name"])
192-
self.assertEqual(
193-
config.mcp_server_version, config_dict["mcp_server_version"]
164+
for transport in ["sse", "http"]:
165+
config = McpFeatureServerConfig(
166+
enabled=True,
167+
mcp_enabled=True,
168+
mcp_server_name="test-server",
169+
mcp_server_version="1.0.0",
170+
mcp_transport=transport,
171+
)
172+
self.assertEqual(config.mcp_transport, transport)
173+
174+
config_default = McpFeatureServerConfig(
175+
enabled=True,
176+
mcp_enabled=True,
177+
mcp_server_name="test-server-default",
178+
mcp_server_version="1.0.0",
179+
)
180+
self.assertEqual(config_default.mcp_transport, "sse")
181+
182+
with self.assertRaises(ValidationError):
183+
McpFeatureServerConfig(
184+
enabled=True,
185+
mcp_enabled=True,
186+
mcp_server_name="bad-transport",
187+
mcp_server_version="1.0.0",
188+
mcp_transport="websocket",
194189
)
195-
self.assertEqual(config.mcp_transport, config_dict["mcp_transport"])

0 commit comments

Comments
 (0)