diff --git a/.env.example b/.env.example index e042d515b..c01384078 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,15 @@ SERVER_PORT=8080 FRONTEND_PORT=3000 WEBSOCKET_PORT=8082 +MCP_PORT=9090 MYSQL_PORT=3306 REDIS_PORT=6379 RABBITMQ_PORT=5672 RABBITMQ_MANAGEMENT_PORT=15672 +MCP_HOST=0.0.0.0 +MCP_BACKEND_BASE_URL=http://springboot:8080 +MCP_CONNECT_TIMEOUT=5 +MCP_READ_TIMEOUT=10 # === OpenSearch Configuration === OPENSEARCH_PORT=9200 diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 36e0fc5e1..0c182fc60 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -40,12 +40,12 @@ echo "👉 Build images ..." docker compose -f "$compose_file" --env-file "$env_file" \ build --pull \ --build-arg NUXT_ENV=production \ - frontend_service + frontend_service mcp-service echo "👉 Recreate & start all target services (no dev profile)..." docker compose -f "$compose_file" --env-file "$env_file" \ up -d --force-recreate --remove-orphans --no-deps \ - mysql redis rabbitmq websocket-service springboot frontend_service + mysql redis rabbitmq websocket-service springboot mcp-service frontend_service echo "👉 Current status:" docker compose -f "$compose_file" --env-file "$env_file" ps diff --git a/deploy/deploy_staging.sh b/deploy/deploy_staging.sh index fbeda44a7..6c48f4006 100644 --- a/deploy/deploy_staging.sh +++ b/deploy/deploy_staging.sh @@ -39,12 +39,12 @@ echo "👉 Build images (staging)..." docker compose -f "$compose_file" --env-file "$env_file" \ build --pull \ --build-arg NUXT_ENV=staging \ - frontend_service + frontend_service mcp-service echo "👉 Recreate & start all target services (no dev profile)..." docker compose -f "$compose_file" --env-file "$env_file" \ up -d --force-recreate --remove-orphans --no-deps \ - mysql redis rabbitmq websocket-service springboot frontend_service + mysql redis rabbitmq websocket-service springboot mcp-service frontend_service echo "👉 Current status:" docker compose -f "$compose_file" --env-file "$env_file" ps diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 5bf093c77..6980c7df0 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -178,6 +178,38 @@ services: - dev - prod + mcp-service: + build: + context: .. + dockerfile: mcp/Dockerfile + container_name: ${COMPOSE_PROJECT_NAME}-openisle-mcp + env_file: + - ${ENV_FILE:-../.env} + environment: + MCP_HOST: ${MCP_HOST:-0.0.0.0} + MCP_PORT: ${MCP_PORT:-9090} + MCP_BACKEND_BASE_URL: ${MCP_BACKEND_BASE_URL:-http://springboot:8080} + MCP_CONNECT_TIMEOUT: ${MCP_CONNECT_TIMEOUT:-5} + MCP_READ_TIMEOUT: ${MCP_READ_TIMEOUT:-10} + ports: + - "${MCP_PORT:-9090}:${MCP_PORT:-9090}" + depends_on: + springboot: + condition: service_healthy + command: ["openisle-mcp"] + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${MCP_PORT:-9090}/healthz || exit 1"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 20s + restart: unless-stopped + networks: + - openisle-network + profiles: + - dev + - prod + websocket-service: image: maven:3.9-eclipse-temurin-17 container_name: ${COMPOSE_PROJECT_NAME}-openisle-websocket diff --git a/mcp/Dockerfile b/mcp/Dockerfile new file mode 100644 index 000000000..75076d91b --- /dev/null +++ b/mcp/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY mcp/pyproject.toml ./pyproject.toml +COPY mcp/README.md ./README.md +COPY mcp/src ./src + +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir . + +EXPOSE 9090 + +CMD ["openisle-mcp"] diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 000000000..7fbc015c2 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,34 @@ +# OpenIsle MCP Service + +This package hosts a lightweight Python service that exposes OpenIsle search +capabilities through a Model Context Protocol (MCP) compatible HTTP interface. +It currently forwards search requests to the main Spring Boot backend and +returns the aggregated results. The service is intentionally simple so we can +iterate quickly and extend it with additional tools (for example, post +creation) in future updates. + +## Local development + +```bash +pip install -e ./mcp +openisle-mcp +``` + +By default the server listens on port `9090` and expects the Spring Boot backend +at `http://localhost:8080`. Configure the behaviour with the following +environment variables: + +- `MCP_PORT` – HTTP port the MCP service should listen on (default: `9090`). +- `MCP_HOST` – Bind host for the HTTP server (default: `0.0.0.0`). +- `MCP_BACKEND_BASE_URL` – Base URL of the Spring Boot backend that provides the + search endpoints (default: `http://springboot:8080`). +- `MCP_CONNECT_TIMEOUT` – Connection timeout (seconds) when calling the backend + (default: `5`). +- `MCP_READ_TIMEOUT` – Read timeout (seconds) when calling the backend (default: + `10`). + +## Docker + +The repository contains a Dockerfile that builds a slim Python image running the +service with `uvicorn`. The compose configuration wires the container into the +existing OpenIsle stack so that deployments automatically start the MCP service. diff --git a/mcp/pyproject.toml b/mcp/pyproject.toml new file mode 100644 index 000000000..2aff1e0a1 --- /dev/null +++ b/mcp/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["hatchling>=1.21.0"] +build-backend = "hatchling.build" + +[project] +name = "openisle-mcp" +version = "0.1.0" +description = "Model Context Protocol server exposing OpenIsle search capabilities" +readme = "README.md" +authors = [ + { name = "OpenIsle" } +] +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.111.0,<1.0.0", + "uvicorn[standard]>=0.29.0,<0.31.0", + "httpx>=0.27.0,<0.28.0", + "pydantic>=2.7.0,<3.0.0" +] + +[project.scripts] +openisle-mcp = "openisle_mcp.__main__:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/openisle_mcp"] diff --git a/mcp/src/openisle_mcp/__init__.py b/mcp/src/openisle_mcp/__init__.py new file mode 100644 index 000000000..9a9d37ddc --- /dev/null +++ b/mcp/src/openisle_mcp/__init__.py @@ -0,0 +1,6 @@ +"""OpenIsle MCP service package.""" + +from .config import Settings, get_settings +from .server import create_app + +__all__ = ["Settings", "get_settings", "create_app"] diff --git a/mcp/src/openisle_mcp/__main__.py b/mcp/src/openisle_mcp/__main__.py new file mode 100644 index 000000000..9baa8dab4 --- /dev/null +++ b/mcp/src/openisle_mcp/__main__.py @@ -0,0 +1,24 @@ +"""Entrypoint for running the MCP service with ``python -m``.""" + +from __future__ import annotations + +import logging + +import uvicorn + +from .config import get_settings + + +def main() -> None: + settings = get_settings() + logging.basicConfig(level=logging.INFO) + uvicorn.run( + "openisle_mcp.server:create_app", + host=settings.host, + port=settings.port, + factory=True, + ) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/mcp/src/openisle_mcp/client.py b/mcp/src/openisle_mcp/client.py new file mode 100644 index 000000000..bde81538a --- /dev/null +++ b/mcp/src/openisle_mcp/client.py @@ -0,0 +1,44 @@ +"""HTTP client helpers for talking to the Spring Boot backend.""" + +from __future__ import annotations + +import logging +from typing import Any + +import httpx + +from .config import Settings + +LOGGER = logging.getLogger(__name__) + + +class SearchClient: + """Wrapper around :class:`httpx.AsyncClient` for search operations.""" + + def __init__(self, settings: Settings): + timeout = httpx.Timeout( + connect=settings.connect_timeout, + read=settings.read_timeout, + write=settings.read_timeout, + pool=None, + ) + self._client = httpx.AsyncClient( + base_url=settings.normalized_backend_base_url, + timeout=timeout, + ) + + async def close(self) -> None: + await self._client.aclose() + + async def global_search(self, keyword: str) -> list[dict[str, Any]]: + LOGGER.debug("Performing global search for keyword '%s'", keyword) + response = await self._client.get("/api/search/global", params={"keyword": keyword}) + response.raise_for_status() + payload = response.json() + if isinstance(payload, list): + return payload + LOGGER.warning("Unexpected payload type from backend: %s", type(payload)) + return [] + + +__all__ = ["SearchClient"] diff --git a/mcp/src/openisle_mcp/config.py b/mcp/src/openisle_mcp/config.py new file mode 100644 index 000000000..55c58223d --- /dev/null +++ b/mcp/src/openisle_mcp/config.py @@ -0,0 +1,71 @@ +"""Configuration helpers for the MCP service.""" + +from __future__ import annotations + +import os +from functools import lru_cache +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, ValidationError + + +class Settings(BaseModel): + """Application settings sourced from environment variables.""" + + host: str = Field(default="0.0.0.0", description="Host to bind the HTTP server to") + port: int = Field(default=9090, ge=1, le=65535, description="Port exposed by the MCP server") + backend_base_url: str = Field( + default="http://springboot:8080", + description="Base URL of the Spring Boot backend that provides search endpoints", + ) + connect_timeout: float = Field( + default=5.0, + ge=0.0, + description="Connection timeout when communicating with the backend (seconds)", + ) + read_timeout: float = Field( + default=10.0, + ge=0.0, + description="Read timeout when communicating with the backend (seconds)", + ) + + model_config = ConfigDict(extra="ignore") + + @property + def normalized_backend_base_url(self) -> str: + """Return the backend base URL without a trailing slash.""" + + return self.backend_base_url.rstrip("/") + + +ENV_MAPPING: dict[str, str] = { + "host": "MCP_HOST", + "port": "MCP_PORT", + "backend_base_url": "MCP_BACKEND_BASE_URL", + "connect_timeout": "MCP_CONNECT_TIMEOUT", + "read_timeout": "MCP_READ_TIMEOUT", +} + + +def _load_environment_values() -> dict[str, Any]: + values: dict[str, Any] = {} + for field, env_name in ENV_MAPPING.items(): + value = os.getenv(env_name) + if value is None: + continue + values[field] = value + return values + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + """Load and validate application settings.""" + + values = _load_environment_values() + try: + return Settings(**values) + except ValidationError as exc: # pragma: no cover - defensive branch + raise RuntimeError("Invalid MCP configuration") from exc + + +__all__ = ["Settings", "get_settings"] diff --git a/mcp/src/openisle_mcp/models.py b/mcp/src/openisle_mcp/models.py new file mode 100644 index 000000000..1d6ae0088 --- /dev/null +++ b/mcp/src/openisle_mcp/models.py @@ -0,0 +1,38 @@ +"""Pydantic models shared across the MCP service.""" + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class SearchResult(BaseModel): + """Representation of a single search result entry.""" + + model_config = ConfigDict(extra="ignore") + + type: Optional[str] = Field(default=None, description="Type of the result entry") + id: Optional[int] = Field(default=None, description="Identifier of the result entry") + text: Optional[str] = Field(default=None, description="Primary text of the result entry") + subText: Optional[str] = Field(default=None, description="Secondary text associated with the result") + extra: Optional[str] = Field(default=None, description="Additional information about the result") + postId: Optional[int] = Field(default=None, description="Related post identifier, if applicable") + highlightedText: Optional[str] = Field(default=None, description="Highlighted primary text segment") + highlightedSubText: Optional[str] = Field( + default=None, + description="Highlighted secondary text segment", + ) + highlightedExtra: Optional[str] = Field( + default=None, + description="Highlighted additional information", + ) + + +class SearchResponse(BaseModel): + """Response payload returned by the search endpoint.""" + + results: list[SearchResult] = Field(default_factory=list) + + +__all__ = ["SearchResult", "SearchResponse"] diff --git a/mcp/src/openisle_mcp/server.py b/mcp/src/openisle_mcp/server.py new file mode 100644 index 000000000..e35ec9a8e --- /dev/null +++ b/mcp/src/openisle_mcp/server.py @@ -0,0 +1,66 @@ +"""FastAPI application exposing the MCP server endpoints.""" + +from __future__ import annotations + +import logging + +from fastapi import Depends, FastAPI, HTTPException, Query, Request +import httpx + +from .client import SearchClient +from .config import get_settings +from .models import SearchResponse, SearchResult + +LOGGER = logging.getLogger(__name__) + + +async def _lifespan(app: FastAPI): + settings = get_settings() + client = SearchClient(settings) + app.state.settings = settings + app.state.search_client = client + LOGGER.info( + "Starting MCP server on %s:%s targeting backend %s", + settings.host, + settings.port, + settings.normalized_backend_base_url, + ) + try: + yield + finally: + LOGGER.info("Shutting down MCP server") + await client.close() + + +def create_app() -> FastAPI: + """Create and configure the FastAPI application.""" + + app = FastAPI(title="OpenIsle MCP Server", lifespan=_lifespan) + + @app.get("/healthz", tags=["health"]) + async def healthcheck() -> dict[str, str]: + return {"status": "ok"} + + async def get_client(request: Request) -> SearchClient: + return request.app.state.search_client + + @app.get("/search", response_model=SearchResponse, tags=["search"]) + async def search( + keyword: str = Query(..., min_length=1, description="Keyword to search for"), + client: SearchClient = Depends(get_client), + ) -> SearchResponse: + try: + raw_results = await client.global_search(keyword) + except httpx.HTTPStatusError as exc: + LOGGER.warning("Backend responded with error %s", exc.response.status_code) + raise HTTPException(status_code=exc.response.status_code, detail="Backend error") from exc + except httpx.HTTPError as exc: + LOGGER.error("Failed to reach backend: %s", exc) + raise HTTPException(status_code=503, detail="Search service unavailable") from exc + results = [SearchResult.model_validate(item) for item in raw_results] + return SearchResponse(results=results) + + return app + + +__all__ = ["create_app"]