"""Local protocol fixtures; no devices, model calls or user data.""" import asyncio from contextlib import asynccontextmanager import os import socket import tempfile import threading import time from fastapi import FastAPI, HTTPException, Request import pytest import uvicorn from artemis.service_registry import ServiceRegistration, TOKEN_HEADER from mcp_server.shared import LocalMcpBoundary, create_shared_mcp @pytest.fixture def shared_mcp_backend(tmp_path, monkeypatch): monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) monkeypatch.setenv("TEMP", str(tmp_path)) monkeypatch.setenv("TMP", str(tmp_path)) monkeypatch.setenv("TMPDIR", str(tmp_path)) monkeypatch.setenv("ARTEMIS_APP_DIR", str(tmp_path / "app")) monkeypatch.setenv("ARTEMIS_TRACES_DIR", str(tmp_path / "traces")) monkeypatch.delenv("ARTEMIS_DAEMON_PORT", raising=False) monkeypatch.delenv("ARTEMIS_DAEMON_HOST", raising=False) listener = socket.socket() listener.bind(("127.0.0.1", 0)) port = listener.getsockname()[1] registration = ServiceRegistration( port=port, token="fixture-token", app_dir=tmp_path / "app", traces_dir=tmp_path / "traces" ) shared, executor = create_shared_mcp() calls = [] async def record(delay: float): calls.append(delay) await asyncio.sleep(delay) return {"pid": os.getpid(), "thread": threading.get_ident(), "count": len(calls)} @shared.tool() async def shared_probe(delay: float = 0.0) -> dict[str, int]: return await executor.invoke(record, delay) @asynccontextmanager async def lifespan(app): registration.acquire() executor.start() try: async with shared.session_manager.run(): yield finally: await executor.stop() registration.close() app = FastAPI(lifespan=lifespan) app.mount("/api/mcp", LocalMcpBoundary(shared.streamable_http_app(), "fixture-token")) @app.get("/api/system/shared-service") async def identity(request: Request): if request.headers.get(TOKEN_HEADER) != "fixture-token": raise HTTPException(403) return { key: registration.info[key] for key in ("protocol", "installation", "instance", "pid") } server = uvicorn.Server( uvicorn.Config( app, host="127.0.0.1", port=port, log_level="error", timeout_graceful_shutdown=5, # Match the user-started service's SSE shutdown budget. ) ) thread = threading.Thread(target=server.run, kwargs={"sockets": [listener]}, daemon=True) thread.start() deadline = time.monotonic() + 8 while not server.started and thread.is_alive() and time.monotonic() < deadline: time.sleep(0.01) assert server.started try: yield { "port": port, "registration": registration, "server": server, "calls": calls, "env": { key: os.environ[key] for key in ("TEMP", "TMP", "TMPDIR", "ARTEMIS_APP_DIR", "ARTEMIS_TRACES_DIR") }, } finally: server.should_exit = True thread.join(timeout=8) listener.close() assert not thread.is_alive()