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
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ dev = [
"ruff>=0.9.1",
"black>=25.1.0"
]
integration = [
"fastmcp>=3.4.7",
"uvicorn>=0.30.0"
]

# ----------------------------------------------------------------
# Console scripts
Expand Down Expand Up @@ -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"
12 changes: 11 additions & 1 deletion tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions tests/integration/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Reverse-proxy integration test package (issue #2)."""
69 changes: 69 additions & 0 deletions tests/integration/companion_server.py
Original file line number Diff line number Diff line change
@@ -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()
84 changes: 84 additions & 0 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
@@ -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
70 changes: 70 additions & 0 deletions tests/integration/helpers.py
Original file line number Diff line number Diff line change
@@ -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)
61 changes: 61 additions & 0 deletions tests/integration/test_adapters_integration.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading