From c698e054895f4ff072b44e9f7e3ec6e597f3cc0e Mon Sep 17 00:00:00 2001 From: Jonathan Springer Date: Thu, 13 Aug 2026 11:36:31 +0100 Subject: [PATCH 1/4] build: add integration optional extra for fastmcp-based test server Adds the 'integration' optional-dependency extra (fastmcp, uvicorn) and registers an 'integration' pytest marker, per issue #2. Unit-test installs are unaffected; integration test modules skip via pytest.importorskip when the extra is absent. Signed-off-by: Jonathan Springer --- pyproject.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 820a9f8..e896f92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,10 @@ dev = [ "ruff>=0.9.1", "black>=25.1.0" ] +integration = [ + "fastmcp>=3.4.7", + "uvicorn>=0.30.0" +] # ---------------------------------------------------------------- # Console scripts @@ -126,4 +130,7 @@ testpaths = ["tests"] python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] +markers = [ + "integration: end-to-end integration tests against a live FastMCP companion server (requires the 'integration' extra)", +] addopts = "-v --cov=mcp_reverse_proxy --cov-report=term-missing" \ No newline at end of file From b0376ad72b611e770ad6bac371fdaf68d5ec22e9 Mon Sep 17 00:00:00 2001 From: Jonathan Springer Date: Thu, 13 Aug 2026 11:36:31 +0100 Subject: [PATCH 2/4] test: add integration suite with FastMCP companion MCP server Minimal in-tree equivalent of mcp-context-forge's test_reverse_proxy_mcp_server (issue #2): a FastMCP companion server with deterministic echo/add tools, a resource, and a prompt, servable over stdio, SSE, and Streamable HTTP. - test_adapters_integration.py drives a full MCP session (initialize, tools/list, tools/call, resources/list) through each real transport adapter against the live server - test_end_to_end.py bridges the stdio companion server through ReverseProxyClient to a fake in-process WebSocket gateway, verifying registration and a gateway->MCP->gateway tool-call round-trip Signed-off-by: Jonathan Springer --- tests/integration/__init__.py | 1 + tests/integration/companion_server.py | 69 ++++++++++++ tests/integration/conftest.py | 84 +++++++++++++++ tests/integration/helpers.py | 70 ++++++++++++ .../integration/test_adapters_integration.py | 61 +++++++++++ tests/integration/test_end_to_end.py | 102 ++++++++++++++++++ 6 files changed, 387 insertions(+) create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/companion_server.py create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/helpers.py create mode 100644 tests/integration/test_adapters_integration.py create mode 100644 tests/integration/test_end_to_end.py diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..3637ec0 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Reverse-proxy integration test package (issue #2).""" diff --git a/tests/integration/companion_server.py b/tests/integration/companion_server.py new file mode 100644 index 0000000..ee6a885 --- /dev/null +++ b/tests/integration/companion_server.py @@ -0,0 +1,69 @@ +"""Companion MCP server for reverse-proxy integration tests. + +A minimal FastMCP server exposing deterministic tools, a resource, and a +prompt over stdio, SSE, and Streamable HTTP. It is the minimal equivalent of +``tests/mcp-servers/python/test_reverse_proxy_mcp_server/`` from the +mcp-context-forge repository (see issue #2), small enough to live in-tree and +run as a pytest fixture. +""" + +# Future +from __future__ import annotations + +# Standard +import argparse +import platform +from typing import Literal + +# Third-Party (integration extra) +from fastmcp import FastMCP +from starlette.applications import Starlette + +mcp = FastMCP("reverse-proxy-companion") + + +@mcp.tool() +def echo(message: str) -> str: + """Echo the message back verbatim.""" + return message + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two integers.""" + return a + b + + +@mcp.resource("companion://info") +def server_info() -> str: + """Static resource identifying the companion server.""" + return f"reverse-proxy-companion on Python {platform.python_version()}" + + +@mcp.prompt() +def greet(name: str) -> str: + """Return a greeting prompt for the given name.""" + return f"Say hello to {name}." + + +def create_app(transport: Literal["sse", "streamable-http"]) -> Starlette: + """Build the ASGI app for an HTTP transport.""" + return mcp.http_app(transport=transport) + + +def main() -> None: + """Launch the companion server from the command line.""" + parser = argparse.ArgumentParser(description="Reverse-proxy companion test MCP server") + parser.add_argument("--transport", choices=["stdio", "sse", "streamable-http"], default="stdio") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8000) + args = parser.parse_args() + + if args.transport == "stdio": + mcp.run(transport="stdio", show_banner=False) + else: + mcp.run(transport=args.transport, host=args.host, port=args.port, show_banner=False) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..7fdfe01 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,84 @@ +"""Fixtures for reverse-proxy integration tests (issue #2). + +These tests exercise the real transport adapters against a live FastMCP +companion server. They require the ``integration`` optional dependency extra +(``pip install -e ".[integration]"``); the test modules skip cleanly when it +is not installed, so the unit-test suite is unaffected. +""" + +# Future +from __future__ import annotations + +# Standard +import asyncio +import sys +from collections.abc import AsyncIterator +from pathlib import Path + +# Third-Party +import pytest + +# First-Party +from mcp_reverse_proxy.base import McpServerTransport +from mcp_reverse_proxy.transports.sse_adapter import SseAdapter +from mcp_reverse_proxy.transports.stdio_adapter import StdioAdapter +from mcp_reverse_proxy.transports.streamablehttp_adapter import StreamableHttpAdapter +from tests.integration.helpers import MessageCollector + +COMPANION_SERVER = Path(__file__).parent / "companion_server.py" + +SERVER_STARTUP_TIMEOUT = 10.0 + + +@pytest.fixture +def companion_stdio_command() -> str: + """Command line that launches the companion server over stdio.""" + return f"{sys.executable} {COMPANION_SERVER} --transport stdio" + + +@pytest.fixture(params=["stdio", "sse", "streamable-http"]) +async def mcp_transport(request: pytest.FixtureRequest, companion_stdio_command: str) -> AsyncIterator[McpServerTransport]: + """Start each MCP-server-side transport against the live companion server.""" + kind: str = request.param + server = None + serve_task = None + + if kind == "stdio": + adapter: McpServerTransport = StdioAdapter(companion_stdio_command) + else: + # Third-Party (integration extra, imported lazily so unit-only installs can collect this file) + import uvicorn + + from tests.integration.companion_server import create_app + + config = uvicorn.Config(create_app(kind), host="127.0.0.1", port=0, log_level="warning") + server = uvicorn.Server(config) + serve_task = asyncio.create_task(server.serve()) + + deadline = asyncio.get_running_loop().time() + SERVER_STARTUP_TIMEOUT + while not server.started: + if asyncio.get_running_loop().time() > deadline: + serve_task.cancel() + pytest.fail(f"companion server ({kind}) failed to start within {SERVER_STARTUP_TIMEOUT}s") + await asyncio.sleep(0.05) + + port = server.servers[0].sockets[0].getsockname()[1] + base_url = f"http://127.0.0.1:{port}" + adapter = SseAdapter(f"{base_url}/sse") if kind == "sse" else StreamableHttpAdapter(f"{base_url}/mcp") + + await adapter.start() + try: + yield adapter + finally: + await adapter.stop() + if server is not None and serve_task is not None: + server.should_exit = True + await serve_task + + +@pytest.fixture +def collector(mcp_transport: McpServerTransport) -> MessageCollector: + """Attach a MessageCollector to the running transport.""" + message_collector = MessageCollector() + mcp_transport.add_message_handler(message_collector) + return message_collector diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py new file mode 100644 index 0000000..c02acba --- /dev/null +++ b/tests/integration/helpers.py @@ -0,0 +1,70 @@ +"""Shared helpers for reverse-proxy integration tests.""" + +# Future +from __future__ import annotations + +# Standard +import asyncio +import json +from typing import Any + +# First-Party +from mcp_reverse_proxy.base import McpServerTransport +from mcp_reverse_proxy.transports.sse_adapter import SseAdapter + +DEFAULT_TIMEOUT = 15.0 + +INITIALIZE_PARAMS: dict[str, Any] = { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "mcp-reverse-proxy-integration-tests", "version": "0.1.0"}, +} + +INITIALIZED_NOTIFICATION = json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + + +def rpc_request(request_id: int, method: str, params: dict[str, Any] | None = None) -> str: + """Serialize a JSON-RPC 2.0 request.""" + message: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + message["params"] = params + return json.dumps(message) + + +class MessageCollector: + """Collect JSON-RPC messages dispatched by a transport adapter.""" + + def __init__(self) -> None: + """Initialize the collector with an empty queue.""" + self.queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + + async def __call__(self, message: str) -> None: + """Adapter message handler entry point.""" + await self.queue.put(json.loads(message)) + + async def response_for(self, request_id: int, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]: + """Return the first queued message whose ``id`` equals request_id.""" + deadline = asyncio.get_running_loop().time() + timeout + while True: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise TimeoutError(f"no response received for request id={request_id}") + message = await asyncio.wait_for(self.queue.get(), remaining) + if message.get("id") == request_id: + return message + + +async def wait_until_ready(adapter: McpServerTransport, timeout: float = DEFAULT_TIMEOUT) -> None: + """Wait until the transport is ready to accept MCP messages. + + The SSE adapter learns its message endpoint asynchronously from the + server's ``endpoint`` event; stdio and Streamable HTTP are ready as soon + as ``start()`` returns. + """ + if not isinstance(adapter, SseAdapter): + return + deadline = asyncio.get_running_loop().time() + timeout + while adapter._message_endpoint is None: + if asyncio.get_running_loop().time() > deadline: + raise TimeoutError("SSE endpoint event not received from companion server") + await asyncio.sleep(0.05) diff --git a/tests/integration/test_adapters_integration.py b/tests/integration/test_adapters_integration.py new file mode 100644 index 0000000..50340ea --- /dev/null +++ b/tests/integration/test_adapters_integration.py @@ -0,0 +1,61 @@ +"""Integration tests: transport adapters against the live companion server. + +Each MCP-server-side transport (stdio, SSE, Streamable HTTP) is driven +through a full MCP session - initialize, initialized notification, +tools/list, tools/call, resources/list - against the FastMCP companion +server (issue #2). +""" + +# Future +from __future__ import annotations + +# Third-Party +import pytest + +# First-Party +from mcp_reverse_proxy.base import McpServerTransport +from tests.integration.helpers import ( + INITIALIZE_PARAMS, + INITIALIZED_NOTIFICATION, + MessageCollector, + rpc_request, + wait_until_ready, +) + +pytest.importorskip("fastmcp", reason="requires the 'integration' extra (pip install -e '.[integration]')") +pytestmark = pytest.mark.integration + + +async def test_full_mcp_session_round_trip(mcp_transport: McpServerTransport, collector: MessageCollector) -> None: + """Drive a complete MCP session over the parametrized transport.""" + await wait_until_ready(mcp_transport) + + # initialize + await mcp_transport.send(rpc_request(1, "initialize", INITIALIZE_PARAMS)) + response = await collector.response_for(1) + assert response["result"]["serverInfo"]["name"] == "reverse-proxy-companion" + + # notifications/initialized + await mcp_transport.send(INITIALIZED_NOTIFICATION) + + # tools/list + await mcp_transport.send(rpc_request(2, "tools/list")) + response = await collector.response_for(2) + tool_names = {tool["name"] for tool in response["result"]["tools"]} + assert {"echo", "add"} <= tool_names + + # tools/call: echo + await mcp_transport.send(rpc_request(3, "tools/call", {"name": "echo", "arguments": {"message": "hello-proxy"}})) + response = await collector.response_for(3) + assert any("hello-proxy" in item.get("text", "") for item in response["result"]["content"]) + + # tools/call: add + await mcp_transport.send(rpc_request(4, "tools/call", {"name": "add", "arguments": {"a": 2, "b": 40}})) + response = await collector.response_for(4) + assert any("42" in item.get("text", "") for item in response["result"]["content"]) + + # resources/list + await mcp_transport.send(rpc_request(5, "resources/list")) + response = await collector.response_for(5) + uris = {resource["uri"] for resource in response["result"]["resources"]} + assert "companion://info" in uris diff --git a/tests/integration/test_end_to_end.py b/tests/integration/test_end_to_end.py new file mode 100644 index 0000000..0c6cdef --- /dev/null +++ b/tests/integration/test_end_to_end.py @@ -0,0 +1,102 @@ +"""End-to-end integration test for the reverse proxy (issue #2). + +Bridges the live FastMCP companion server (stdio) through ReverseProxyClient +to a fake in-process WebSocket gateway, verifying registration and a full +request/response round-trip from gateway to MCP server and back. +""" + +# Future +from __future__ import annotations + +# Standard +import asyncio +import json +from typing import Any + +# Third-Party +import pytest +from websockets.asyncio.server import ServerConnection, serve + +# First-Party +from mcp_reverse_proxy.client import ReverseProxyClient +from mcp_reverse_proxy.transports.stdio_adapter import StdioAdapter +from mcp_reverse_proxy.transports.websocket_adapter import WebSocketAdapter +from tests.integration.helpers import INITIALIZE_PARAMS, rpc_request + +pytest.importorskip("fastmcp", reason="requires the 'integration' extra (pip install -e '.[integration]')") +pytestmark = pytest.mark.integration + +GATEWAY_TIMEOUT = 30.0 +SESSION_ID = "integration-e2e" + + +async def test_reverse_proxy_end_to_end_stdio(companion_stdio_command: str) -> None: + """Exercise client + stdio adapter + WebSocket adapter against real MCP server and fake gateway.""" + loop = asyncio.get_running_loop() + registered: asyncio.Future[str] = loop.create_future() + tool_result: asyncio.Future[dict[str, Any]] = loop.create_future() + + async def gateway_handler(websocket: ServerConnection) -> None: + async for raw in websocket: + message = json.loads(raw) + msg_type = message.get("type") + + if msg_type == "register": + session_id = message["sessionId"] + await websocket.send(json.dumps({"type": "register_ack", "sessionId": session_id, "status": "received"})) + await websocket.send(json.dumps({"type": "register_complete", "sessionId": session_id, "status": "success"})) + if not registered.done(): + registered.set_result(session_id) + # Drive an MCP session through the proxy, as the real gateway would + await websocket.send( + json.dumps( + { + "type": "request", + "sessionId": session_id, + "payload": json.loads(rpc_request(1, "initialize", INITIALIZE_PARAMS)), + } + ) + ) + elif msg_type == "response": + payload = message.get("payload", {}) + session_id = message["sessionId"] + if payload.get("id") == 1: + # initialize complete: send initialized notification, then a tool call + await websocket.send( + json.dumps( + { + "type": "request", + "sessionId": session_id, + "payload": {"jsonrpc": "2.0", "method": "notifications/initialized"}, + } + ) + ) + await websocket.send( + json.dumps( + { + "type": "request", + "sessionId": session_id, + "payload": json.loads( + rpc_request(2, "tools/call", {"name": "echo", "arguments": {"message": "end-to-end"}}) + ), + } + ) + ) + elif payload.get("id") == 2 and not tool_result.done(): + tool_result.set_result(payload) + + async with serve(gateway_handler, "127.0.0.1", 0) as gateway: + port = gateway.sockets[0].getsockname()[1] + client = ReverseProxyClient( + mcp_transport=StdioAdapter(companion_stdio_command), + gateway_transport=WebSocketAdapter(gateway_url=f"ws://127.0.0.1:{port}", session_id=SESSION_ID), + session_id=SESSION_ID, + keepalive_interval=60.0, + ) + await client.connect() + try: + assert await asyncio.wait_for(registered, GATEWAY_TIMEOUT) == SESSION_ID + payload = await asyncio.wait_for(tool_result, GATEWAY_TIMEOUT) + assert any("end-to-end" in item.get("text", "") for item in payload["result"]["content"]) + finally: + await client.disconnect() From 5facb388f30105d57d649eaf2819b70449963b68 Mon Sep 17 00:00:00 2001 From: Jonathan Springer Date: Thu, 13 Aug 2026 11:36:31 +0100 Subject: [PATCH 3/4] ci: add non-blocking integration test job Runs tests/integration on Python 3.13 with the integration extra; continue-on-error keeps it advisory while the suite proves itself (issue #2). Signed-off-by: Jonathan Springer --- .github/workflows/ci.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26ce978..5d451de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,29 @@ jobs: - name: Run tests run: uv run pytest tests/ -q + integration: + name: integration tests (non-blocking) + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install package with dev and integration dependencies + run: | + uv venv + uv pip install -e ".[dev,integration]" + + - name: Run integration tests + run: uv run pytest tests/integration -q + build: name: Build wheel + sdist runs-on: ubuntu-latest From ab62e2849bd8de85d1ac7a6bd46aba41bfbda620 Mon Sep 17 00:00:00 2001 From: Jonathan Springer Date: Thu, 13 Aug 2026 11:36:31 +0100 Subject: [PATCH 4/4] docs: document integration test suite CHANGELOG entry and tests/README section covering the integration extra, how to run the suite, and its skip behavior without fastmcp. Signed-off-by: Jonathan Springer --- CHANGELOG.md | 1 + tests/README.md | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5108b09..b3c4410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,3 +10,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial import of the MCP Reverse Proxy from [IBM/mcp-context-forge](https://github.com/IBM/mcp-context-forge), extracted as part of the PR #5417 decomposition. Includes the standalone client package with multi-transport support (stdio, Streamable HTTP, SSE, WebSocket), two-layer health monitoring, TLS certificate handling, and the `mcp-reverse-proxy` console script. +- Integration test suite (`tests/integration/`) exercising the stdio, SSE, and Streamable HTTP adapters end-to-end against a minimal FastMCP companion server, plus a full reverse-proxy round-trip test with a fake WebSocket gateway. Requires the new `integration` optional extra (`fastmcp`, `uvicorn`); runs as a non-blocking CI job. (issue #2) diff --git a/tests/README.md b/tests/README.md index d5acf82..dcbf7b2 100644 --- a/tests/README.md +++ b/tests/README.md @@ -17,12 +17,22 @@ The tests were moved from `tests/unit/mcpgateway/test_mcp_reverse_proxy_*` to th - `test_mcp_reverse_proxy_websocket_adapter.py` - Tests for WebSocket transport adapter ## Running Tests - From the repository root: ```bash pytest tests/ ``` +## Integration Tests + +`tests/integration/` contains end-to-end tests that run the real transport adapters (stdio, SSE, Streamable HTTP) against a live FastMCP companion server (`companion_server.py`), plus a full reverse-proxy round-trip test using a fake in-process WebSocket gateway. + +They require the `integration` optional extra: +```bash +pip install -e ".[dev,integration]" +pytest tests/integration/ +``` + +Without the extra, the integration test modules are skipped automatically, so the unit-test suite runs unchanged. In CI they run as a separate non-blocking job. ## Import Changes All imports have been updated from: