diff --git a/.github/workflows/test-image-mcp-server.yml b/.github/workflows/test-image-mcp-server.yml new file mode 100644 index 00000000..90c58ab0 --- /dev/null +++ b/.github/workflows/test-image-mcp-server.yml @@ -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 diff --git a/kai_mcp_solution_server/pyproject.toml b/kai_mcp_solution_server/pyproject.toml index d3e8d35e..62b6bfcc 100644 --- a/kai_mcp_solution_server/pyproject.toml +++ b/kai_mcp_solution_server/pyproject.toml @@ -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", diff --git a/kai_mcp_solution_server/src/kai_mcp_solution_server/server.py b/kai_mcp_solution_server/src/kai_mcp_solution_server/server.py index 1f45d4de..21404a99 100644 --- a/kai_mcp_solution_server/src/kai_mcp_solution_server/server.py +++ b/kai_mcp_solution_server/src/kai_mcp_solution_server/server.py @@ -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. @@ -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 ) diff --git a/kai_mcp_solution_server/tests/test_host_header.py b/kai_mcp_solution_server/tests/test_host_header.py new file mode 100644 index 00000000..3121e713 --- /dev/null +++ b/kai_mcp_solution_server/tests/test_host_header.py @@ -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() diff --git a/kai_mcp_solution_server/tools/deploy/Containerfile b/kai_mcp_solution_server/tools/deploy/Containerfile index 153e700e..330f7fde 100644 --- a/kai_mcp_solution_server/tools/deploy/Containerfile +++ b/kai_mcp_solution_server/tools/deploy/Containerfile @@ -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 diff --git a/kai_mcp_solution_server/uv.lock b/kai_mcp_solution_server/uv.lock index 44272394..0311fa34 100644 --- a/kai_mcp_solution_server/uv.lock +++ b/kai_mcp_solution_server/uv.lock @@ -1839,7 +1839,7 @@ requires-dist = [ { name = "aiosqlite", specifier = ">=0.21.0" }, { name = "alembic", specifier = ">=1.15.2" }, { name = "asyncpg", specifier = ">=0.30.0" }, - { name = "fastmcp", specifier = ">=2.8.0" }, + { name = "fastmcp", specifier = ">=2.8.0,<3" }, { name = "langchain", specifier = ">=1.2.4" }, { name = "langchain-aws", specifier = ">=1.2.1" }, { name = "langchain-azure-ai", specifier = ">=1.0.0" },