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
116 changes: 116 additions & 0 deletions .github/workflows/test-image-mcp-server.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
name: MCP Server Image Smoke Test

# Builds the deployed container image and exercises it end-to-end. Every other
# test runs the server from source against the pinned lockfile, so nothing
# verified the *image*, which resolves dependencies at build time. That gap is
# exactly what let issue #934 through: a rebuild floated fastmcp to a major
# version whose MCP SDK enables DNS-rebinding protection by default, and the
# server started returning HTTP 421 for every request arriving through an ingress
# with a mismatched Host header.

on:
pull_request:
paths:
- 'kai_mcp_solution_server/**'
- '.github/workflows/test-image-mcp-server.yml'
push:
branches:
- main
- 'release-*'
paths:
- 'kai_mcp_solution_server/**'
- '.github/workflows/test-image-mcp-server.yml'

jobs:
image-host-header:
name: Built image accepts a proxied Host header
runs-on: ubuntu-latest
timeout-minutes: 25

steps:
- uses: actions/checkout@v4

- name: Build the solution server image
run: |
docker build \
-f kai_mcp_solution_server/tools/deploy/Containerfile \
-t kai-solution-server:ci \
kai_mcp_solution_server

- name: Assert the image did not resolve a breaking fastmcp major
run: |
# The image installs from the pinned requirements.txt (fastmcp 2.x).
# A jump to 3.x re-enables DNS-rebinding protection by default (#934),
# so pin the invariant here too: a deliberate 3.x bump must also add
# explicit transport_security config and update this check.
version=$(docker run --rm --entrypoint pip3.12 kai-solution-server:ci \
show fastmcp | awk '/^Version:/ {print $2}')
echo "Image fastmcp version: ${version}"
major=${version%%.*}
if [ "${major}" != "2" ]; then
echo "::error::Image resolved fastmcp ${version}; expected 2.x (see issue #934)"
exit 1
fi

- name: Start the container (streamable-http)
run: |
docker run -d --name kai-api \
-p 8000:8000 \
-e KAI_DB_DSN="sqlite+aiosqlite:////data/kai.db" \
-e KAI_LLM_PARAMS='{"model": "fake"}' \
kai-solution-server:ci

- name: Wait for the server to become ready
run: |
body='{"jsonrpc":"2.0","method":"initialize","id":0,"params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"probe","version":"0.0.0"}}}'
for i in $(seq 1 60); do
code=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST http://localhost:8000/ \
-H "Host: localhost:8000" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d "${body}" || true)
if [ "${code}" != "000" ]; then
echo "Server responding (HTTP ${code})"
break
fi
if [ "${i}" -eq 60 ]; then
echo "::error::Server did not become ready in time"
docker logs kai-api
exit 1
fi
sleep 2
done

- name: A proxied (mismatched) Host header must not be rejected with 421
run: |
# Mirrors the issue #934 repro: nginx forwards the external IP as Host.
body='{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"ingress-test","version":"0.0.0"}}}'
code=$(curl -s -o /tmp/resp.txt -w "%{http_code}" \
-X POST http://localhost:8000/ \
-H "Host: 192.168.49.2" \
-H "Origin: https://192.168.49.2" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d "${body}")
echo "HTTP ${code}"
head -c 500 /tmp/resp.txt || true
echo
if [ "${code}" = "421" ]; then
echo "::error::Built image returned HTTP 421 for a proxied Host header — DNS-rebinding regression (issue #934)"
docker logs kai-api
exit 1
fi
if [ "${code}" != "200" ]; then
echo "::error::Expected HTTP 200 for initialize, got ${code}"
docker logs kai-api
exit 1
fi

- name: Container logs
if: always()
run: docker logs kai-api || true

- name: Cleanup
if: always()
run: docker rm -f kai-api || true
5 changes: 4 additions & 1 deletion kai_mcp_solution_server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ dependencies = [
"aiosqlite>=0.21.0",
"alembic>=1.15.2",
"asyncpg>=0.30.0",
"fastmcp>=2.8.0",
# Upper bound pinned to the tested major: a fresh resolution across a major
# version enabled DNS-rebinding protection by default and broke ingress
# deployments (issue #934). Bump deliberately, not incidentally.
"fastmcp>=2.8.0,<3",
"langchain>=1.2.4",
"langchain-aws>=1.2.1",
"langchain-azure-ai>=1.0.0",
Expand Down
11 changes: 10 additions & 1 deletion kai_mcp_solution_server/src/kai_mcp_solution_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@


def with_db_recovery(
func: Callable[..., Coroutine[Any, Any, T]]
func: Callable[..., Coroutine[Any, Any, T]],
) -> Callable[..., Coroutine[Any, Any, T]]:
"""Decorator to execute database operations with automatic recovery on connection errors.

Expand Down Expand Up @@ -297,6 +297,15 @@ async def kai_solution_server_lifespan(
raise e


# NOTE: This server is deployed behind a trusted cluster ingress whose external
# Host/Origin header (OpenShift route, minikube IP, etc.) is unknowable at build
# time. The MCP SDK's DNS-rebinding protection MUST stay disabled or it returns
# HTTP 421 for every proxied request (see https://github.com/konveyor/kai/issues/934).
# With the pinned fastmcp (<3, see pyproject.toml) the underlying mcp SDK leaves
# that protection off by default, so no explicit configuration is needed here.
# If fastmcp is ever bumped to 3.x, this default flips: pass an explicit
# transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)
# (or a proper allowed_hosts allowlist) to whatever FastMCP 3.x exposes.
mcp: FastMCP[KaiSolutionServerContext] = FastMCP(
"KaiSolutionServer", lifespan=kai_solution_server_lifespan
)
Expand Down
167 changes: 167 additions & 0 deletions kai_mcp_solution_server/tests/test_host_header.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""
Regression test for https://github.com/konveyor/kai/issues/934.

When the deployed image floated up to a fastmcp major version whose MCP SDK
enables DNS-rebinding protection by default, the streamable-http server started
returning HTTP 421 "Misdirected Request" for every request whose Host header did
not match the bind host (e.g. requests arriving through an nginx ingress with the
external IP as the Host). This broke all editor-extensions @requires-minikube CI.

This test starts the streamable-http transport and sends an ``initialize`` request
with a deliberately mismatched Host/Origin header (mirroring the ingress case in
issue #934). It must NOT come back as 421. The repo's other HTTP tests always
connect with a matching ``localhost`` Host header, so they never exercised this
path.
"""

import os
import socket
import subprocess # nosec B404 - needed to launch the server like production does
import sys
import tempfile
import time
import unittest
from pathlib import Path

import httpx


def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]


class TestHostHeaderValidation(unittest.TestCase):
"""The server must not reject proxied requests with a mismatched Host header."""

def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.temp_path = Path(self.temp_dir.name)
self.port = _free_port()

self.server_path = Path(__file__).parent.parent / "src/kai_mcp_solution_server"

self.env = {
**os.environ,
"KAI_DB_DSN": f"sqlite+aiosqlite:///{self.temp_path}/kai_solution_server.db",
"KAI_LLM_PARAMS": '{"model": "fake"}',
"PYTHONUNBUFFERED": "1",
}

python_executable = sys.executable
self.assertTrue(
os.path.isfile(python_executable),
f"Invalid Python executable path: {python_executable}",
)
self.assertTrue(
os.path.exists(self.server_path),
f"Invalid server script path: {self.server_path}",
)

# Launch the streamable-http transport exactly as the container does.
self.proc = subprocess.Popen( # nosec B603 - args validated above
[
python_executable,
str(self.server_path),
"--transport",
"streamable-http",
"--host",
"127.0.0.1",
"--port",
str(self.port),
"--mount-path",
"/",
],
env=self.env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)

def tearDown(self):
if self.proc.poll() is None:
self.proc.terminate()
try:
self.proc.wait(timeout=10)
except subprocess.TimeoutExpired:
self.proc.kill()
try:
self.temp_dir.cleanup()
except (OSError, PermissionError) as e:
print(f"Warning: Failed to clean up after tests: {e}")

def _wait_until_ready(self, url: str, timeout: float = 30.0) -> None:
"""Poll the endpoint until the server accepts connections (or dies)."""
body = {
"jsonrpc": "2.0",
"method": "initialize",
"id": 0,
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "readiness-probe", "version": "0.0.0"},
},
}
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"Host": f"127.0.0.1:{self.port}",
}
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if self.proc.poll() is not None:
out = self.proc.stdout.read() if self.proc.stdout else ""
self.fail(f"Server exited early (code {self.proc.returncode}):\n{out}")
try:
httpx.post(url, json=body, headers=headers, timeout=2.0)
return
except httpx.HTTPError:
time.sleep(0.5)
self.fail(f"Server did not become ready within {timeout}s")

def test_mismatched_host_header_is_not_421(self):
"""A request with an ingress-style external Host must not return 421."""
url = f"http://127.0.0.1:{self.port}/"
self._wait_until_ready(url)

body = {
"jsonrpc": "2.0",
"method": "initialize",
"id": 1,
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "ingress-test", "version": "0.0.0"},
},
}
# Host/Origin the server never bound to — this is what nginx forwards in
# the issue #934 repro (the minikube external IP).
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"Host": "192.168.49.2",
"Origin": "https://192.168.49.2",
}

response = httpx.post(url, json=body, headers=headers, timeout=10.0)

self.assertNotEqual(
response.status_code,
421,
"Server returned 421 Misdirected Request for a proxied Host header — "
"DNS-rebinding protection has regressed (see issue #934). "
f"Body: {response.text[:500]}",
)
# Sanity: the request should actually be handled (initialize succeeds).
self.assertEqual(
response.status_code,
200,
f"Expected 200 for initialize, got {response.status_code}: "
f"{response.text[:500]}",
)


if __name__ == "__main__":
unittest.main()
10 changes: 8 additions & 2 deletions kai_mcp_solution_server/tools/deploy/Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,16 @@ RUN dnf --refresh -y update \
# Copy project files
COPY src/ src/
COPY pyproject.toml .
COPY requirements.txt .
COPY README.md .

# Install Python dependencies
RUN pip3.12 install --no-cache-dir .
# Install Python dependencies from the pinned lockfile-derived requirements.txt
# so the deployed image uses exactly the versions CI tests, then install the
# project itself without re-resolving. Installing from pyproject.toml directly
# would resolve fresh against unbounded floors and can silently pick up a
# breaking major version (this is what broke ingress deployments in issue #934).
RUN pip3.12 install --no-cache-dir -r requirements.txt
RUN pip3.12 install --no-cache-dir --no-deps .

# Create a non-root user
RUN useradd -m mcp
Expand Down
2 changes: 1 addition & 1 deletion kai_mcp_solution_server/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading