diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..800b0ab --- /dev/null +++ b/.dockerignore @@ -0,0 +1,84 @@ +# Git +.git +.gitignore +.gitattributes +GIT-GUIDE.md + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.mypy_cache/ +.dmypy.json +dmypy.json + +# Virtual environments +venv/ +env/ +ENV/ +.venv + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Documentation +*.md +!README.md + + +# Tests +tests/ +pytest.ini +conftest.py + +# R files +R/ +*.R +*.Rproj + +# Scripts +scripts/ + +# Build tools +dodo.py + +# Conda +!environment.yml + +# ASCII art +*.txt + +# Docker +Dockerfile* +docker-compose*.yml +.dockerignore + +# Demo files (kept for runtime, excluded from context) +# demo/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..07e2ad0 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# EMBED_URL= +# EMBED_MODEL= + +LLM_API_URL= +LLM_API_KEY= +LLM_MODEL= +# LLM_TIMEOUT=240 +# LLM_CANDIDATE_LIMIT=10 +# LLM_LOG=1 +# LLM_DRY_RUN=0 +# LLM_USE_RESPONSES=0 + +# MCP_LOG_LEVEL=INFO diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e20ec82 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM mambaorg/micromamba:2.5-alpine3.22 + +USER root +WORKDIR /app + +RUN apk add --no-cache curl + +COPY --chown=$MAMBA_USER:$MAMBA_USER environment.yml /tmp/environment.yml +RUN micromamba install -y -n base -f /tmp/environment.yml && \ + micromamba clean --all --yes + +COPY --chown=$MAMBA_USER:$MAMBA_USER core/ ./core/ +COPY --chown=$MAMBA_USER:$MAMBA_USER mcp_server/ ./mcp_server/ +COPY --chown=$MAMBA_USER:$MAMBA_USER acp_agent/ ./acp_agent/ +COPY --chown=$MAMBA_USER:$MAMBA_USER docs/ ./docs/ +COPY --chown=$MAMBA_USER:$MAMBA_USER pyproject.toml ./ + +RUN micromamba run -n base pip install --no-cache-dir -e . + +RUN mkdir -p /data/phenotype_index && chown -R $MAMBA_USER:$MAMBA_USER /data + +USER $MAMBA_USER + +ENV PYTHONUNBUFFERED=1 \ + PHENOTYPE_INDEX_DIR=/data/phenotype_index \ + MCP_TRANSPORT=http \ + MCP_HOST=0.0.0.0 \ + MCP_PORT=8790 \ + MCP_PATH=/mcp \ + STUDY_AGENT_HOST=0.0.0.0 \ + STUDY_AGENT_PORT=8765 \ + STUDY_AGENT_HOST_GATEWAY=host.docker.internal \ + STUDY_AGENT_MCP_URL=http://host.docker.internal:8790/mcp + +CMD ["micromamba", "run", "-n", "base", "study-agent-mcp"] diff --git a/README.md b/README.md index 2c6c881..9d3f3b8 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,68 @@ curl -s -X POST http://127.0.0.1:8765/flows/phenotype_recommendation \ -d '{"study_intent":"Identify clinical risk factors for older adult patients who experience an adverse event of acute gastro-intenstinal (GI) bleeding", "top_k":20, "max_results":10,"candidate_limit":10}' ``` +### Docker quickstart + +Use Docker compose to run MCP and ACP together with MCP over HTTP. + +NOTE: If you plan to use phenotype services, you will need to phenotype index (see `./docs/PHENOTYPE_INDEXING.md`) with output to `data/phenotype_index/` + +1. Prepare environment variables: + +```bash +cp .env.example .env +``` + +Recommended contents of `.env`: +``` +EMBED_API_KEY= +EMBED_MODEL= +EMBED_URL=http://172.17.0.1:3000/ollama/api/embed # or equivalent +LLM_API_KEY= +LLM_API_URL=http://172.17.0.1:3000/api/chat/completions # or equivalent +LLM_MODEL= +LLM_LOG=1 +LLM_USE_RESPONSES=0 +LLM_TIMEOUT=180 +STUDY_AGENT_ALLOW_CORE_FALLBACK=0 +STUDY_AGENT_DEBUG=1 +``` + +2. Build and start both services: + +```bash +docker compose up --build -d +``` + +3. Check service health and tool listing: + +```bash +curl -s http://127.0.0.1:8765/health | python -m json.tool +``` + +Expected output: +``` +{ + "status": "ok", + "mcp": { + "ok": true, + "mode": "http" + }, + "mcp_index": { + "skipped": true + } +} +``` + +This should show a number of services with an empty warnings list +```bash +curl -s http://127.0.0.1:8765/services | python -m json.tool +``` + +Notes: +- ACP is exposed on port 8765 and MCP on port 8790. +- The phenotype index is mounted from `./data/phenotype_index` into MCP at `/data/phenotype_index`. + ## Planned Services Below is a set of planned study agent services, organized by category. For each service, document the input, output, and validation approach. diff --git a/acp_agent/study_agent_acp/llm_client.py b/acp_agent/study_agent_acp/llm_client.py index 22a3c92..b8695b4 100644 --- a/acp_agent/study_agent_acp/llm_client.py +++ b/acp_agent/study_agent_acp/llm_client.py @@ -7,6 +7,7 @@ import urllib.request from typing import Any, Dict, Optional +from study_agent_core.net import rewrite_container_host_url def build_prompt( overview: str, @@ -219,6 +220,8 @@ def _extract_json_object(text: str) -> Optional[Dict[str, Any]]: def call_llm(prompt: str) -> Optional[Dict[str, Any]]: api_url = os.getenv("LLM_API_URL", "http://localhost:3000/api/chat/completions") + api_url = rewrite_container_host_url(api_url) + api_key = os.getenv("LLM_API_KEY") model = os.getenv("LLM_MODEL", "agentstudyassistant") timeout = int(os.getenv("LLM_TIMEOUT", "180")) diff --git a/acp_agent/study_agent_acp/mcp_client.py b/acp_agent/study_agent_acp/mcp_client.py index 37cca81..e641056 100644 --- a/acp_agent/study_agent_acp/mcp_client.py +++ b/acp_agent/study_agent_acp/mcp_client.py @@ -135,6 +135,8 @@ def _ensure_session(self) -> None: with self._lock: if self._session is not None: return + self._session = None + self._exit_stack = None self._portal_cm = start_blocking_portal() self._portal = self._portal_cm.__enter__() assert self._portal is not None @@ -155,6 +157,24 @@ async def _async_init(self) -> None: self._session = session def close(self) -> None: + portal = self._portal + try: + if portal is not None: + try: + portal.call(self._async_close) + except Exception: + pass + finally: + if self._portal_cm is not None: + try: + self._portal_cm.__exit__(None, None, None) + except Exception: + pass + self._portal_cm = None + self._portal = None + self._session = None + self._exit_stack = None + if self._portal is None: return try: @@ -214,13 +234,15 @@ def __init__(self, config: HttpMCPClientConfig) -> None: def list_tools(self) -> List[Dict[str, Any]]: self._ensure_session() - assert self._portal is not None - return self._portal.call(self._list_tools) + with self._lock: + assert self._portal is not None + return self._portal.call(self._list_tools) def call_tool(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: self._ensure_session() - assert self._portal is not None - return self._portal.call(self._call_tool, name, arguments) + with self._lock: + assert self._portal is not None + return self._portal.call(self._call_tool, name, arguments) def health_check(self) -> Dict[str, Any]: if self._host and self._port: @@ -231,8 +253,9 @@ def health_check(self) -> Dict[str, Any]: return {"ok": False, "error": str(exc)} try: self._ensure_session() - assert self._portal is not None - return self._portal.call(self._ping) + with self._lock: + assert self._portal is not None + return self._portal.call(self._ping) except Exception as exc: return {"ok": False, "error": str(exc)} @@ -259,6 +282,8 @@ def _ensure_session(self) -> None: with self._lock: if self._session is not None: return + self._session = None + self._exit_stack = None self._portal_cm = start_blocking_portal() self._portal = self._portal_cm.__enter__() assert self._portal is not None @@ -279,17 +304,24 @@ async def _async_init(self) -> None: await self._exit_stack.enter_async_context(session) await session.initialize() self._session = session - def close(self) -> None: - if self._portal is None: - return + portal = self._portal try: - self._portal.call(self._async_close) + if portal is not None: + try: + portal.call(self._async_close) + except Exception: + pass finally: if self._portal_cm is not None: - self._portal_cm.__exit__(None, None, None) + try: + self._portal_cm.__exit__(None, None, None) + except Exception: + pass self._portal_cm = None self._portal = None + self._session = None + self._exit_stack = None async def _async_close(self) -> None: if self._exit_stack is not None: diff --git a/acp_agent/study_agent_acp/server.py b/acp_agent/study_agent_acp/server.py index ea2f9c5..ebb8fe3 100644 --- a/acp_agent/study_agent_acp/server.py +++ b/acp_agent/study_agent_acp/server.py @@ -2,6 +2,7 @@ import json import os +from urllib.parse import parse_qs, urlsplit from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer from typing import Any, Dict, Optional @@ -68,6 +69,23 @@ def _load_registry_services() -> tuple[list[Dict[str, Any]], list[str]]: return services, warnings +def _call_mcp_tool_with_retry(mcp_client: object, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: + try: + return mcp_client.call_tool(name, arguments) + except Exception as exc: + message = str(exc) + if "cancel scope" not in message.lower(): + raise + close = getattr(mcp_client, "close", None) + if callable(close): + try: + close() + except Exception: + pass + return mcp_client.call_tool(name, arguments) + + + class ACPRequestHandler(BaseHTTPRequestHandler): agent: StudyAgent mcp_client: Optional[object] @@ -82,21 +100,37 @@ def do_GET(self) -> None: if self.debug: content_type = self.headers.get("Content-Type") print(f"ACP GET > path={self.path} content_type={content_type}") - if self.path == "/health": + + parsed = urlsplit(self.path) + + if parsed.path == "/health": payload = {"status": "ok"} if self.mcp_client is not None: payload["mcp"] = self.mcp_client.health_check() - if payload["mcp"].get("ok"): + params = parse_qs(parsed.query) + deep = ( + params.get("deep", ["0"])[0] == "1" + or os.getenv("STUDY_AGENT_HEALTH_DEEP", "0") == "1" + ) + payload["mcp_index"] = {"skipped": not deep} + if deep and payload["mcp"].get("ok"): try: - payload["mcp_index"] = self.mcp_client.call_tool("phenotype_index_status", {}) + payload["mcp_index"] = _call_mcp_tool_with_retry( + self.mcp_client, + "phenotype_index_status", + {}, + ) except Exception as exc: payload["mcp_index"] = {"error": str(exc)} + _write_json(self, 200, payload) return - if self.path == "/tools": + + if parsed.path == "/tools": _write_json(self, 200, {"tools": self.agent.list_tools()}) return - if self.path == "/services": + + if parsed.path == "/services": registry_services, warnings = _load_registry_services() registry_map = {svc["endpoint"]: svc for svc in registry_services} runtime_map = {svc["endpoint"]: svc for svc in SERVICES} @@ -113,6 +147,7 @@ def do_GET(self) -> None: _write_json(self, 200, {"services": services, "warnings": warnings}) return + _write_json(self, 404, {"error": "not_found"}) def do_POST(self) -> None: @@ -519,8 +554,9 @@ def _shutdown(signum, frame) -> None: except Exception: pass - signal.signal(signal.SIGINT, _shutdown) signal.signal(signal.SIGTERM, _shutdown) + signal.signal(signal.SIGINT, _shutdown) + _serve(server, mcp_client) @@ -528,8 +564,14 @@ def _serve(server: HTTPServer, mcp_client: Optional[object]) -> None: try: server.serve_forever() finally: - if mcp_client is not None: - mcp_client.close() + try: + server.server_close() + finally: + if mcp_client is not None: + try: + mcp_client.close() + except Exception: + pass if __name__ == "__main__": diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..be0c738 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,37 @@ +name: study-agent + +services: + mcp-server: + build: + context: . + image: study-agent + env_file: + - .env + environment: + STUDY_AGENT_HOST_GATEWAY: host.docker.internal + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - "8790:8790" + volumes: + - ./data/phenotype_index:/data/phenotype_index + command: ["micromamba", "run", "-n", "base", "study-agent-mcp"] + restart: unless-stopped + + acp-agent: + build: + context: . + image: study-agent + env_file: + - .env + environment: + STUDY_AGENT_HOST_GATEWAY: host.docker.internal + STUDY_AGENT_MCP_URL: http://mcp-server:8790/mcp + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - "8765:8765" + depends_on: + - mcp-server + command: ["micromamba", "run", "-n", "base", "study-agent-acp"] + restart: unless-stopped diff --git a/core/study_agent_core/net.py b/core/study_agent_core/net.py new file mode 100644 index 0000000..bb68f5f --- /dev/null +++ b/core/study_agent_core/net.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import os +from urllib.parse import urlsplit, urlunsplit + +_DOCKER_LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "172.17.0.1"} + + +def running_in_container() -> bool: + return os.path.exists("/.dockerenv") + + +def rewrite_container_host_url(url: str, gateway_host: str | None = None) -> str: + if not url or not running_in_container(): + return url + + parts = urlsplit(url) + if not parts.hostname or parts.hostname not in _DOCKER_LOCAL_HOSTS: + return url + + gateway_host = gateway_host or os.getenv("STUDY_AGENT_HOST_GATEWAY", "host.docker.internal") + if not gateway_host: + return url + + auth = "" + if parts.username: + auth = parts.username + if parts.password: + auth = f"{auth}:{parts.password}" + auth = f"{auth}@" + + port = f":{parts.port}" if parts.port is not None else "" + netloc = f"{auth}{gateway_host}{port}" + return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) diff --git a/environment.yml b/environment.yml index 4c79604..2d50e38 100644 --- a/environment.yml +++ b/environment.yml @@ -11,5 +11,6 @@ dependencies: - mcp - numpy - pydantic + - pyyaml - pytest - requests diff --git a/mcp_server/study_agent_mcp/retrieval/index.py b/mcp_server/study_agent_mcp/retrieval/index.py index 97ab1e9..8d4b5d6 100644 --- a/mcp_server/study_agent_mcp/retrieval/index.py +++ b/mcp_server/study_agent_mcp/retrieval/index.py @@ -11,6 +11,8 @@ from dataclasses import dataclass from typing import Any, Dict, List, Optional +from study_agent_core.net import rewrite_container_host_url + _TOKEN_RE = re.compile(r"[a-z0-9]+") @@ -328,7 +330,9 @@ def get_default_index() -> PhenotypeIndex: catalog_info = status["files"].get("catalog") or {} if not catalog_info.get("exists"): raise RuntimeError(f"Phenotype catalog not found: {catalog_info.get('path')}") - embed_url = os.getenv("EMBED_URL", "http://localhost:3000/ollama/api/embed") + embed_url = rewrite_container_host_url( + os.getenv("EMBED_URL", "http://localhost:3000/ollama/api/embed") + ) embed_model = os.getenv("EMBED_MODEL", "qwen3-embedding:4b") api_key = os.getenv("EMBED_API_KEY") embedding_client = EmbeddingClient(url=embed_url, model=embed_model, api_key=api_key) diff --git a/mcp_server/study_agent_mcp/server.py b/mcp_server/study_agent_mcp/server.py index 6c6979f..e34f696 100644 --- a/mcp_server/study_agent_mcp/server.py +++ b/mcp_server/study_agent_mcp/server.py @@ -6,7 +6,7 @@ from study_agent_mcp.tools import register_all from study_agent_mcp.retrieval import index_status -mcp = FastMCP("study-agent") +mcp = FastMCP("study-agent", host=os.getenv("MCP_HOST", "0.0.0.0")) register_all(mcp) def _log(level: str, message: str) -> None: diff --git a/pyproject.toml b/pyproject.toml index 7d61ad2..0d372b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ requires-python = ">=3.10" dependencies = [ "mcp>=1.0.0", "pydantic>=2.0.0", + "PyYAML>=6.0", ] [project.scripts]