ReMe/tests/unit/test_http_web_workspace.py
jinliyl 3d2ecc60d2
feat(service): expose MCP through HTTP backend (#498)
* feat(service): expose MCP through HTTP backend

Serve JSON/SSE job endpoints and streamable HTTP MCP from one FastAPI application, sharing the same jobs and application lifecycle. Preserve the standalone MCP backend, add configurable MCP HTTP settings, update startup metadata and integration docs, and cover routing, lifecycle, configuration, and compatibility behavior with unit tests.

* fix(service): preserve MCP request protections

Route the exact MCP path through the complete FastMCP ASGI application so its middleware and state remain active. Reject non-literal MCP paths and validate reserved Job conflicts before tolerant service registration. Add regression coverage for middleware preservation, route syntax, and startup failure.

* fix(service): reject encoded MCP paths

Reject percent signs in mcp_path so ASGI path decoding cannot turn an accepted configuration into an unreachable route. Cover encoded slash, space, and double-encoded slash inputs.
2026-08-27 17:23:11 +08:00

266 lines
9.7 KiB
Python

"""HTTP service coverage for MCP and the optional bundled web workspace."""
import asyncio
import sys
from pathlib import Path
from types import ModuleType, SimpleNamespace
import pytest
from fastapi.testclient import TestClient
from starlette.middleware.base import BaseHTTPMiddleware
from reme.components.service.http_service import HttpService
from reme.components.job import BaseJob, StreamJob
from reme.utils import REME_WEB_STATIC_DIR, resolve_web_static_dir
class _FakeApplication:
def __init__(self) -> None:
self.config = SimpleNamespace(app_name="ReMe test")
self.started = False
self.closed = False
async def start(self) -> None:
"""Record that the application lifespan started."""
self.started = True
async def close(self) -> None:
"""Record that the application lifespan closed."""
self.closed = True
def _static_build(tmp_path: Path) -> Path:
static_dir = tmp_path / "web"
assets_dir = static_dir / "assets"
assets_dir.mkdir(parents=True)
(static_dir / "index.html").write_text(
"<main>ReMe workspace</main>",
encoding="utf-8",
)
(static_dir / "favicon.svg").write_text("<svg></svg>", encoding="utf-8")
(assets_dir / "app.js").write_text("console.log('reme')", encoding="utf-8")
return static_dir
def test_http_service_serves_workspace_without_shadowing_jobs(tmp_path: Path) -> None:
"""Serve workspace files and preserve explicitly registered job routes."""
static_dir = _static_build(tmp_path)
app = _FakeApplication()
service = HttpService(web_static_dir=str(static_dir))
service.build_service(app) # type: ignore[arg-type]
@service.service.post("/status")
async def status():
return {"success": True}
service.finalize_service(app) # type: ignore[arg-type]
with TestClient(service.service) as client:
assert client.get("/").text == "<main>ReMe workspace</main>"
assert client.get("/memory/topic").text == "<main>ReMe workspace</main>"
assert client.get("/favicon.svg").text == "<svg></svg>"
assert client.get("/assets/app.js").text == "console.log('reme')"
assert client.post("/status").json() == {"success": True}
assert client.get("/status").status_code == 405
assert client.get("/status").headers["allow"] == "POST"
assert client.get("/").headers["cache-control"] == "no-cache, no-store, must-revalidate"
assert app.started is True
assert app.closed is True
def test_http_service_can_disable_workspace(tmp_path: Path) -> None:
"""Leave the root route unregistered when workspace serving is disabled."""
app = _FakeApplication()
service = HttpService(
web_enabled=False,
web_static_dir=str(_static_build(tmp_path)),
)
service.build_service(app) # type: ignore[arg-type]
service.finalize_service(app) # type: ignore[arg-type]
with TestClient(service.service) as client:
assert client.get("/").status_code == 404
def test_http_service_exposes_non_stream_jobs_as_mcp_tools() -> None:
"""The HTTP backend exposes the same non-stream Job instance through MCP."""
async def run() -> None:
service = HttpService(web_enabled=False)
service.build_service(_FakeApplication()) # type: ignore[arg-type]
job = BaseJob(name="search", description="Search memories")
assert service.add_job(job) is True
assert service.mcp_server is not None
assert await service.mcp_server.get_tool("search") is not None
assert any(route.path == "/search" for route in service.service.routes)
asyncio.run(run())
def test_http_service_skips_stream_jobs_for_mcp() -> None:
"""Stream jobs remain available over HTTP SSE without becoming MCP tools."""
async def run() -> None:
service = HttpService(web_enabled=False)
service.build_service(_FakeApplication()) # type: ignore[arg-type]
assert service.add_job(StreamJob(name="stream")) is True
assert service.mcp_server is not None
assert await service.mcp_server.get_tool("stream") is None
assert any(route.path == "/stream" for route in service.service.routes)
asyncio.run(run())
def test_http_service_uses_exact_mcp_path_and_runs_one_application_lifespan() -> None:
"""Serve MCP at /mcp while starting and closing the shared Application once."""
class CountingApplication(_FakeApplication):
"""Record how often the shared Application lifecycle is entered."""
def __init__(self) -> None:
super().__init__()
self.start_count = 0
self.close_count = 0
async def start(self) -> None:
self.start_count += 1
async def close(self) -> None:
self.close_count += 1
app = CountingApplication()
service = HttpService(web_enabled=False)
service.build_service(app) # type: ignore[arg-type]
mcp_server = service.mcp_server
class VerifyFastMCPAppMiddleware(BaseHTTPMiddleware):
"""Prove requests retain FastMCP middleware and application state."""
async def dispatch(self, request, call_next):
"""Verify the child app context and mark its response."""
assert request.app.state.fastmcp_server is mcp_server
response = await call_next(request)
response.headers["X-FastMCP-Middleware"] = "preserved"
return response
service.mcp_app.add_middleware(VerifyFastMCPAppMiddleware)
with TestClient(service.service, follow_redirects=False) as client:
response = client.post(
"/mcp",
headers={
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
},
json={
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1"},
},
},
)
assert response.status_code == 200
assert '"serverInfo"' in response.text
assert response.headers["X-FastMCP-Middleware"] == "preserved"
assert client.post("/mcp/mcp", json={}).status_code == 404
assert app.start_count == 1
assert app.close_count == 1
def test_http_service_can_disable_mcp() -> None:
"""Allow deployments to retain the legacy HTTP-only surface explicitly."""
service = HttpService(web_enabled=False, mcp_enabled=False)
service.build_service(_FakeApplication()) # type: ignore[arg-type]
with TestClient(service.service) as client:
assert client.post("/mcp", json={}).status_code == 404
def test_http_service_rejects_invalid_or_conflicting_mcp_paths() -> None:
"""Reject paths that shadow built-ins and jobs that shadow the MCP endpoint."""
for path in (
"mcp",
"/",
"/mcp/",
"/docs",
"/{rest:path}",
"/mcp?mode=test",
"/mcp#fragment",
"/mcp%2Fv2",
"/mcp%20v2",
"/mcp%252Fv2",
"/mcp path",
"/mcp//nested",
"/mcp/../nested",
):
with pytest.raises(ValueError, match="mcp_path"):
HttpService(mcp_path=path)
service = HttpService(web_enabled=False)
service.build_service(_FakeApplication()) # type: ignore[arg-type]
with pytest.raises(ValueError, match="conflicts with the MCP endpoint"):
service.add_job(BaseJob(name="mcp"))
def test_http_service_fails_startup_preflight_for_mcp_job_conflict() -> None:
"""Do not let BaseService's tolerant registration hide reserved-route conflicts."""
service = HttpService(web_enabled=False)
service.build_service(_FakeApplication()) # type: ignore[arg-type]
app = SimpleNamespace(
context=SimpleNamespace(jobs={"mcp": BaseJob(name="mcp")}),
)
with pytest.raises(ValueError, match="conflicts with the MCP endpoint"):
service.add_jobs(app)
def test_http_service_does_not_serve_symlinks_outside_static_dir(
tmp_path: Path,
) -> None:
"""Do not expose files reached through symlinks outside the static build."""
static_dir = _static_build(tmp_path)
secret_file = tmp_path / "secret.txt"
secret_file.write_text("private", encoding="utf-8")
try:
(static_dir / "escape.txt").symlink_to(secret_file)
except OSError as error:
pytest.skip(f"Symbolic links are unavailable: {error}")
app = _FakeApplication()
service = HttpService(web_static_dir=str(static_dir))
service.build_service(app) # type: ignore[arg-type]
service.finalize_service(app) # type: ignore[arg-type]
with TestClient(service.service) as client:
assert client.get("/escape.txt").text == "<main>ReMe workspace</main>"
def test_static_dir_configuration_precedes_environment(
monkeypatch,
tmp_path: Path,
) -> None:
"""Prefer an explicit static directory over the environment setting."""
configured = _static_build(tmp_path / "configured")
environment = _static_build(tmp_path / "environment")
monkeypatch.setenv(REME_WEB_STATIC_DIR, str(environment))
assert resolve_web_static_dir(str(configured)) == configured.resolve()
assert resolve_web_static_dir() == environment.resolve()
def test_static_dir_uses_optional_studio_package(monkeypatch, tmp_path: Path) -> None:
"""Use static assets supplied by the separately installed Studio wheel."""
static_dir = _static_build(tmp_path / "studio")
studio = ModuleType("reme_studio")
studio.static_dir = lambda: static_dir # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "reme_studio", studio)
assert resolve_web_static_dir() == static_dir.resolve()