From 8fd268bd11242ed8e23e6196e1ad6358d79d1c75 Mon Sep 17 00:00:00 2001 From: tim Date: Sat, 25 Oct 2025 23:33:51 +0800 Subject: [PATCH] feat: add mcp --- docker/docker-compose.yaml | 26 +++++++ docker/mcp.Dockerfile | 21 ++++++ mcp/README.md | 37 ++++++++++ mcp/pyproject.toml | 27 ++++++++ mcp/src/openisle_mcp/__init__.py | 6 ++ mcp/src/openisle_mcp/config.py | 52 ++++++++++++++ mcp/src/openisle_mcp/schemas.py | 55 +++++++++++++++ mcp/src/openisle_mcp/search_client.py | 51 ++++++++++++++ mcp/src/openisle_mcp/server.py | 98 +++++++++++++++++++++++++++ 9 files changed, 373 insertions(+) create mode 100644 docker/mcp.Dockerfile create mode 100644 mcp/README.md create mode 100644 mcp/pyproject.toml create mode 100644 mcp/src/openisle_mcp/__init__.py create mode 100644 mcp/src/openisle_mcp/config.py create mode 100644 mcp/src/openisle_mcp/schemas.py create mode 100644 mcp/src/openisle_mcp/search_client.py create mode 100644 mcp/src/openisle_mcp/server.py diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 5bf093c77..49425c5fd 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -178,6 +178,32 @@ services: - dev - prod + mcp: + build: + context: .. + dockerfile: docker/mcp.Dockerfile + container_name: ${COMPOSE_PROJECT_NAME}-openisle-mcp + env_file: + - ${ENV_FILE:-../.env} + environment: + OPENISLE_MCP_BACKEND_BASE_URL: ${OPENISLE_MCP_BACKEND_BASE_URL:-http://springboot:8080} + OPENISLE_MCP_HOST: 0.0.0.0 + OPENISLE_MCP_PORT: ${OPENISLE_MCP_PORT:-8085} + OPENISLE_MCP_TRANSPORT: ${OPENISLE_MCP_TRANSPORT:-streamable-http} + OPENISLE_MCP_REQUEST_TIMEOUT: ${OPENISLE_MCP_REQUEST_TIMEOUT:-10.0} + ports: + - "${OPENISLE_MCP_PORT:-8085}:${OPENISLE_MCP_PORT:-8085}" + depends_on: + springboot: + condition: service_started + networks: + - openisle-network + profiles: + - dev + - dev_local_backend + - prod + + websocket-service: image: maven:3.9-eclipse-temurin-17 container_name: ${COMPOSE_PROJECT_NAME}-openisle-websocket diff --git a/docker/mcp.Dockerfile b/docker/mcp.Dockerfile new file mode 100644 index 000000000..9db1849ab --- /dev/null +++ b/docker/mcp.Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.11-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY mcp/pyproject.toml mcp/README.md ./ +COPY mcp/src ./src + +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir . + +ENV OPENISLE_MCP_HOST=0.0.0.0 \ + OPENISLE_MCP_PORT=8085 \ + OPENISLE_MCP_TRANSPORT=streamable-http + +EXPOSE 8085 + +CMD ["openisle-mcp"] + diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 000000000..8a66ac452 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,37 @@ +# OpenIsle MCP Server + +This package provides a [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server +that exposes OpenIsle's search capabilities as MCP tools. The initial release focuses on the +global search endpoint so the agent ecosystem can retrieve relevant posts, users, tags, and +other resources. + +## Configuration + +The server is configured through environment variables (all prefixed with `OPENISLE_MCP_`): + +| Variable | Default | Description | +| --- | --- | --- | +| `BACKEND_BASE_URL` | `http://springboot:8080` | Base URL of the OpenIsle backend. | +| `PORT` | `8085` | TCP port when running with the `streamable-http` transport. | +| `HOST` | `0.0.0.0` | Interface to bind when serving HTTP. | +| `TRANSPORT` | `streamable-http` | Transport to use (`stdio`, `sse`, or `streamable-http`). | +| `REQUEST_TIMEOUT` | `10.0` | Timeout (seconds) for backend HTTP requests. | + +## Running locally + +```bash +pip install . +OPENISLE_MCP_BACKEND_BASE_URL="http://localhost:8080" openisle-mcp +``` + +By default the server listens on port `8085` and serves MCP over Streamable HTTP. + +## Available tools + +| Tool | Description | +| --- | --- | +| `search` | Perform a global search against the OpenIsle backend. | + +The tool returns structured data describing each search hit including highlighted snippets when +provided by the backend. + diff --git a/mcp/pyproject.toml b/mcp/pyproject.toml new file mode 100644 index 000000000..6688e9ba6 --- /dev/null +++ b/mcp/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["hatchling>=1.25"] +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", email = "engineering@openisle.example" }] +requires-python = ">=3.11" +dependencies = [ + "mcp>=1.19.0", + "httpx>=0.28,<0.29", + "pydantic>=2.12,<3", + "pydantic-settings>=2.11,<3" +] + +[project.scripts] +openisle-mcp = "openisle_mcp.server:main" + +[tool.hatch.build] +packages = ["src/openisle_mcp"] + +[tool.ruff] +line-length = 100 + diff --git a/mcp/src/openisle_mcp/__init__.py b/mcp/src/openisle_mcp/__init__.py new file mode 100644 index 000000000..697ffe079 --- /dev/null +++ b/mcp/src/openisle_mcp/__init__.py @@ -0,0 +1,6 @@ +"""OpenIsle MCP server package.""" + +from .config import Settings, get_settings + +__all__ = ["Settings", "get_settings"] + diff --git a/mcp/src/openisle_mcp/config.py b/mcp/src/openisle_mcp/config.py new file mode 100644 index 000000000..60499fe70 --- /dev/null +++ b/mcp/src/openisle_mcp/config.py @@ -0,0 +1,52 @@ +"""Application configuration helpers for the OpenIsle MCP server.""" + +from __future__ import annotations + +from functools import lru_cache +from typing import Literal + +from pydantic import Field +from pydantic.networks import AnyHttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Configuration for the MCP server.""" + + backend_base_url: AnyHttpUrl = Field( + "http://springboot:8080", + description="Base URL for the OpenIsle backend service.", + ) + host: str = Field( + "0.0.0.0", + description="Host interface to bind when running with HTTP transports.", + ) + port: int = Field( + 8085, + ge=1, + le=65535, + description="TCP port for HTTP transports.", + ) + transport: Literal["stdio", "sse", "streamable-http"] = Field( + "streamable-http", + description="MCP transport to use when running the server.", + ) + request_timeout: float = Field( + 10.0, + gt=0, + description="Timeout (seconds) for backend search requests.", + ) + + model_config = SettingsConfigDict( + env_prefix="OPENISLE_MCP_", + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + ) + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + """Return cached application settings.""" + + return Settings() diff --git a/mcp/src/openisle_mcp/schemas.py b/mcp/src/openisle_mcp/schemas.py new file mode 100644 index 000000000..935269d31 --- /dev/null +++ b/mcp/src/openisle_mcp/schemas.py @@ -0,0 +1,55 @@ +"""Pydantic models describing tool inputs and outputs.""" + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, Field, ConfigDict + + +class SearchResultItem(BaseModel): + """A single search result entry.""" + + type: str = Field(description="Entity type for the result (post, user, tag, etc.).") + id: Optional[int] = Field(default=None, description="Identifier of the matched entity.") + text: Optional[str] = Field(default=None, description="Primary text associated with the result.") + sub_text: Optional[str] = Field( + default=None, + alias="subText", + description="Secondary text, e.g. a username or excerpt.", + ) + extra: Optional[str] = Field(default=None, description="Additional contextual information.") + post_id: Optional[int] = Field( + default=None, + alias="postId", + description="Associated post identifier when relevant.", + ) + highlighted_text: Optional[str] = Field( + default=None, + alias="highlightedText", + description="Highlighted snippet of the primary text if available.", + ) + highlighted_sub_text: Optional[str] = Field( + default=None, + alias="highlightedSubText", + description="Highlighted snippet of the secondary text if available.", + ) + highlighted_extra: Optional[str] = Field( + default=None, + alias="highlightedExtra", + description="Highlighted snippet of extra information if available.", + ) + + model_config = ConfigDict(populate_by_name=True) + + +class SearchResponse(BaseModel): + """Structured response returned by the search tool.""" + + keyword: str = Field(description="The keyword that was searched.") + total: int = Field(description="Total number of matches returned by the backend.") + results: list[SearchResultItem] = Field( + default_factory=list, + description="Ordered collection of search results.", + ) + diff --git a/mcp/src/openisle_mcp/search_client.py b/mcp/src/openisle_mcp/search_client.py new file mode 100644 index 000000000..819438ff0 --- /dev/null +++ b/mcp/src/openisle_mcp/search_client.py @@ -0,0 +1,51 @@ +"""HTTP client helpers for talking to the OpenIsle backend search endpoints.""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx + + +class SearchClient: + """Client for calling the OpenIsle search API.""" + + def __init__(self, base_url: str, *, timeout: float = 10.0) -> None: + self._base_url = base_url.rstrip("/") + self._timeout = timeout + self._client: httpx.AsyncClient | None = None + + def _get_client(self) -> httpx.AsyncClient: + if self._client is None: + self._client = httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout) + return self._client + + async def global_search(self, keyword: str) -> list[dict[str, Any]]: + """Call the global search endpoint and return the parsed JSON payload.""" + + client = self._get_client() + response = await client.get( + "/api/search/global", + params={"keyword": keyword}, + headers={"Accept": "application/json"}, + ) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, list): + formatted = json.dumps(payload, ensure_ascii=False)[:200] + raise ValueError(f"Unexpected response format from search endpoint: {formatted}") + return [self._validate_entry(entry) for entry in payload] + + async def aclose(self) -> None: + """Dispose of the underlying HTTP client.""" + + if self._client is not None: + await self._client.aclose() + self._client = None + + @staticmethod + def _validate_entry(entry: Any) -> dict[str, Any]: + if not isinstance(entry, dict): + raise ValueError(f"Search entry must be an object, got: {type(entry)!r}") + return entry diff --git a/mcp/src/openisle_mcp/server.py b/mcp/src/openisle_mcp/server.py new file mode 100644 index 000000000..19a71fa0d --- /dev/null +++ b/mcp/src/openisle_mcp/server.py @@ -0,0 +1,98 @@ +"""Entry point for running the OpenIsle MCP server.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import Annotated + +import httpx +from mcp.server.fastmcp import Context, FastMCP +from pydantic import ValidationError +from pydantic import Field as PydanticField + +from .config import get_settings +from .schemas import SearchResponse, SearchResultItem +from .search_client import SearchClient + +settings = get_settings() +search_client = SearchClient( + str(settings.backend_base_url), timeout=settings.request_timeout +) + + +@asynccontextmanager +async def lifespan(_: FastMCP): + """Lifecycle hook that disposes shared resources when the server stops.""" + + try: + yield + finally: + await search_client.aclose() + + +app = FastMCP( + name="openisle-mcp", + instructions=( + "Use this server to search OpenIsle posts, users, tags, categories, and comments " + "via the global search endpoint." + ), + host=settings.host, + port=settings.port, + lifespan=lifespan, +) + + +@app.tool( + name="search", + description="Perform a global search across OpenIsle resources.", + structured_output=True, +) +async def search( + keyword: Annotated[str, PydanticField(description="Keyword to search for.")], + ctx: Context | None = None, +) -> SearchResponse: + """Call the OpenIsle global search endpoint and return structured results.""" + + sanitized = keyword.strip() + if not sanitized: + raise ValueError("Keyword must not be empty.") + + try: + raw_results = await search_client.global_search(sanitized) + except httpx.HTTPStatusError as exc: # pragma: no cover - network errors + message = ( + "OpenIsle backend returned HTTP " + f"{exc.response.status_code} while searching for '{sanitized}'." + ) + if ctx is not None: + await ctx.error(message) + raise ValueError(message) from exc + except httpx.RequestError as exc: # pragma: no cover - network errors + message = f"Unable to reach OpenIsle backend search service: {exc}." + if ctx is not None: + await ctx.error(message) + raise ValueError(message) from exc + + try: + results = [SearchResultItem.model_validate(entry) for entry in raw_results] + except ValidationError as exc: + message = "Received malformed data from the OpenIsle backend search endpoint." + if ctx is not None: + await ctx.error(message) + raise ValueError(message) from exc + + if ctx is not None: + await ctx.info(f"Search keyword '{sanitized}' returned {len(results)} results.") + + return SearchResponse(keyword=sanitized, total=len(results), results=results) + + +def main() -> None: + """Run the MCP server using the configured transport.""" + + app.run(transport=settings.transport) + + +if __name__ == "__main__": # pragma: no cover - manual execution + main() +