Compare commits

..

1 Commits

Author SHA1 Message Date
Tim 28efd376b6 feat: add MCP search service 2025-10-24 17:06:13 +08:00
15 changed files with 352 additions and 295 deletions
-5
View File
@@ -2,15 +2,10 @@
SERVER_PORT=8080 SERVER_PORT=8080
FRONTEND_PORT=3000 FRONTEND_PORT=3000
WEBSOCKET_PORT=8082 WEBSOCKET_PORT=8082
MCP_PORT=9090
MYSQL_PORT=3306 MYSQL_PORT=3306
REDIS_PORT=6379 REDIS_PORT=6379
RABBITMQ_PORT=5672 RABBITMQ_PORT=5672
RABBITMQ_MANAGEMENT_PORT=15672 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 Configuration ===
OPENSEARCH_PORT=9200 OPENSEARCH_PORT=9200
+1
View File
@@ -17,6 +17,7 @@ dist
# misc # misc
.DS_Store .DS_Store
__pycache__/
*.pem *.pem
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
+2 -2
View File
@@ -40,12 +40,12 @@ echo "👉 Build images ..."
docker compose -f "$compose_file" --env-file "$env_file" \ docker compose -f "$compose_file" --env-file "$env_file" \
build --pull \ build --pull \
--build-arg NUXT_ENV=production \ --build-arg NUXT_ENV=production \
frontend_service mcp-service frontend_service
echo "👉 Recreate & start all target services (no dev profile)..." echo "👉 Recreate & start all target services (no dev profile)..."
docker compose -f "$compose_file" --env-file "$env_file" \ docker compose -f "$compose_file" --env-file "$env_file" \
up -d --force-recreate --remove-orphans --no-deps \ up -d --force-recreate --remove-orphans --no-deps \
mysql redis rabbitmq websocket-service springboot mcp-service frontend_service mysql redis rabbitmq websocket-service springboot frontend_service
echo "👉 Current status:" echo "👉 Current status:"
docker compose -f "$compose_file" --env-file "$env_file" ps docker compose -f "$compose_file" --env-file "$env_file" ps
+2 -2
View File
@@ -39,12 +39,12 @@ echo "👉 Build images (staging)..."
docker compose -f "$compose_file" --env-file "$env_file" \ docker compose -f "$compose_file" --env-file "$env_file" \
build --pull \ build --pull \
--build-arg NUXT_ENV=staging \ --build-arg NUXT_ENV=staging \
frontend_service mcp-service frontend_service
echo "👉 Recreate & start all target services (no dev profile)..." echo "👉 Recreate & start all target services (no dev profile)..."
docker compose -f "$compose_file" --env-file "$env_file" \ docker compose -f "$compose_file" --env-file "$env_file" \
up -d --force-recreate --remove-orphans --no-deps \ up -d --force-recreate --remove-orphans --no-deps \
mysql redis rabbitmq websocket-service springboot mcp-service frontend_service mysql redis rabbitmq websocket-service springboot frontend_service
echo "👉 Current status:" echo "👉 Current status:"
docker compose -f "$compose_file" --env-file "$env_file" ps docker compose -f "$compose_file" --env-file "$env_file" ps
+26 -32
View File
@@ -178,38 +178,6 @@ services:
- dev - dev
- prod - 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: websocket-service:
image: maven:3.9-eclipse-temurin-17 image: maven:3.9-eclipse-temurin-17
container_name: ${COMPOSE_PROJECT_NAME}-openisle-websocket container_name: ${COMPOSE_PROJECT_NAME}-openisle-websocket
@@ -245,6 +213,32 @@ services:
- dev_local_backend - dev_local_backend
- prod - prod
mcp-service:
build:
context: ..
dockerfile: mcp/Dockerfile
container_name: ${COMPOSE_PROJECT_NAME}-openisle-mcp
env_file:
- ${ENV_FILE:-../.env}
environment:
FASTMCP_HOST: 0.0.0.0
FASTMCP_PORT: ${MCP_PORT:-8765}
OPENISLE_BACKEND_URL: ${OPENISLE_BACKEND_URL:-http://springboot:8080}
OPENISLE_BACKEND_TIMEOUT: ${OPENISLE_BACKEND_TIMEOUT:-10}
OPENISLE_MCP_TRANSPORT: ${OPENISLE_MCP_TRANSPORT:-sse}
OPENISLE_MCP_SSE_MOUNT_PATH: ${OPENISLE_MCP_SSE_MOUNT_PATH:-/mcp}
ports:
- "${MCP_PORT:-8765}:${MCP_PORT:-8765}"
depends_on:
springboot:
condition: service_healthy
restart: unless-stopped
networks:
- openisle-network
profiles:
- dev
- prod
frontend_dev: frontend_dev:
image: node:20 image: node:20
container_name: ${COMPOSE_PROJECT_NAME}-openisle-frontend-dev container_name: ${COMPOSE_PROJECT_NAME}-openisle-frontend-dev
+9 -13
View File
@@ -1,21 +1,17 @@
FROM python:3.11-slim FROM python:3.11-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONUNBUFFERED=1 \
PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
WORKDIR /app WORKDIR /app
RUN apt-get update \ COPY mcp/pyproject.toml /app/pyproject.toml
&& apt-get install -y --no-install-recommends curl \ COPY mcp/README.md /app/README.md
&& rm -rf /var/lib/apt/lists/* COPY mcp/src /app/src
COPY mcp/pyproject.toml ./pyproject.toml RUN pip install --upgrade pip \
COPY mcp/README.md ./README.md && pip install .
COPY mcp/src ./src
RUN pip install --no-cache-dir --upgrade pip \ EXPOSE 8765
&& pip install --no-cache-dir .
EXPOSE 9090
CMD ["openisle-mcp"] CMD ["openisle-mcp"]
+28 -23
View File
@@ -1,34 +1,39 @@
# OpenIsle MCP Service # OpenIsle MCP Server
This package hosts a lightweight Python service that exposes OpenIsle search This package provides a [Model Context Protocol](https://github.com/modelcontextprotocol) (MCP) server that exposes the OpenIsle
capabilities through a Model Context Protocol (MCP) compatible HTTP interface. search capabilities to AI assistants. The server wraps the existing Spring Boot backend and currently provides a single `search`
It currently forwards search requests to the main Spring Boot backend and tool. Future iterations can extend the server with additional functionality such as publishing new posts or moderating content.
returns the aggregated results. The service is intentionally simple so we can
iterate quickly and extend it with additional tools (for example, post ## Features
creation) in future updates.
- 🔍 **Global search** — delegates to the existing `/api/search/global` endpoint exposed by the OpenIsle backend.
- 🧠 **Structured results** — responses include highlights and deep links so AI clients can present the results cleanly.
- ⚙️ **Configurable** — point the server at any reachable OpenIsle backend by setting environment variables.
## Local development ## Local development
```bash ```bash
pip install -e ./mcp cd mcp
openisle-mcp python -m venv .venv
source .venv/bin/activate
pip install -e .
openisle-mcp --transport stdio # or "sse"/"streamable-http"
``` ```
By default the server listens on port `9090` and expects the Spring Boot backend Environment variables:
at `http://localhost:8080`. Configure the behaviour with the following
environment variables:
- `MCP_PORT` HTTP port the MCP service should listen on (default: `9090`). | Variable | Description | Default |
- `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 | `OPENISLE_BACKEND_URL` | Base URL of the Spring Boot backend | `http://springboot:8080` |
search endpoints (default: `http://springboot:8080`). | `OPENISLE_BACKEND_TIMEOUT` | Timeout (seconds) for backend HTTP calls | `10` |
- `MCP_CONNECT_TIMEOUT` Connection timeout (seconds) when calling the backend | `OPENISLE_PUBLIC_BASE_URL` | Optional base URL used to build deep links in search results | *(unset)* |
(default: `5`). | `OPENISLE_MCP_TRANSPORT` | MCP transport (`stdio`, `sse`, `streamable-http`) | `stdio` |
- `MCP_READ_TIMEOUT` Read timeout (seconds) when calling the backend (default: | `OPENISLE_MCP_SSE_MOUNT_PATH` | Mount path when using SSE transport | `/mcp` |
`10`). | `FASTMCP_HOST` | Host for SSE / HTTP transports | `127.0.0.1` |
| `FASTMCP_PORT` | Port for SSE / HTTP transports | `8000` |
## Docker ## Docker
The repository contains a Dockerfile that builds a slim Python image running the A dedicated Docker image is provided and wired into `docker-compose.yaml`. The container listens on
service with `uvicorn`. The compose configuration wires the container into the `${MCP_PORT:-8765}` and connects to the backend service running in the same compose stack.
existing OpenIsle stack so that deployments automatically start the MCP service.
+16 -12
View File
@@ -1,25 +1,29 @@
[build-system] [build-system]
requires = ["hatchling>=1.21.0"] requires = ["setuptools>=68", "wheel"]
build-backend = "hatchling.build" build-backend = "setuptools.build_meta"
[project] [project]
name = "openisle-mcp" name = "openisle-mcp"
version = "0.1.0" version = "0.1.0"
description = "Model Context Protocol server exposing OpenIsle search capabilities" description = "Model Context Protocol server exposing OpenIsle search capabilities"
readme = "README.md" readme = "README.md"
authors = [ authors = [{name = "OpenIsle Team"}]
{ name = "OpenIsle" } license = {text = "MIT"}
]
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"fastapi>=0.111.0,<1.0.0", "mcp>=1.19.0",
"uvicorn[standard]>=0.29.0,<0.31.0", "httpx>=0.28.0",
"httpx>=0.27.0,<0.28.0", "pydantic>=2.12.0",
"pydantic>=2.7.0,<3.0.0"
] ]
[project.scripts] [project.scripts]
openisle-mcp = "openisle_mcp.__main__:main" openisle-mcp = "openisle_mcp.server:main"
[tool.hatch.build.targets.wheel] [tool.setuptools]
packages = ["src/openisle_mcp"] package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
openisle_mcp = ["py.typed"]
+8 -4
View File
@@ -1,6 +1,10 @@
"""OpenIsle MCP service package.""" """OpenIsle MCP server package."""
from .config import Settings, get_settings from importlib import metadata
from .server import create_app
__all__ = ["Settings", "get_settings", "create_app"] try:
__version__ = metadata.version("openisle-mcp")
except metadata.PackageNotFoundError: # pragma: no cover - best effort during dev
__version__ = "0.0.0"
__all__ = ["__version__"]
-24
View File
@@ -1,24 +0,0 @@
"""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()
+67 -32
View File
@@ -1,44 +1,79 @@
"""HTTP client helpers for talking to the Spring Boot backend.""" """HTTP client for talking to the OpenIsle backend."""
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from typing import Any from typing import List
import httpx import httpx
from pydantic import ValidationError
from .config import Settings from .models import BackendSearchResult
LOGGER = logging.getLogger(__name__) __all__ = ["BackendClientError", "OpenIsleBackendClient"]
logger = logging.getLogger(__name__)
class SearchClient: class BackendClientError(RuntimeError):
"""Wrapper around :class:`httpx.AsyncClient` for search operations.""" """Raised when the backend cannot fulfil a request."""
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"] class OpenIsleBackendClient:
"""Tiny wrapper around the Spring Boot search endpoints."""
def __init__(self, base_url: str, timeout: float = 10.0) -> None:
if not base_url:
raise ValueError("base_url must not be empty")
self._base_url = base_url.rstrip("/")
timeout = timeout if timeout > 0 else 10.0
self._timeout = httpx.Timeout(timeout, connect=timeout, read=timeout)
@property
def base_url(self) -> str:
return self._base_url
async def search_global(self, keyword: str) -> List[BackendSearchResult]:
"""Call `/api/search/global` and normalise the payload."""
url = f"{self._base_url}/api/search/global"
params = {"keyword": keyword}
headers = {"Accept": "application/json"}
logger.debug("Calling OpenIsle backend", extra={"url": url, "params": params})
try:
async with httpx.AsyncClient(timeout=self._timeout, headers=headers, follow_redirects=True) as client:
response = await client.get(url, params=params)
response.raise_for_status()
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors are rare in tests
body_preview = _truncate_body(exc.response.text)
raise BackendClientError(
f"Backend returned HTTP {exc.response.status_code}: {body_preview}"
) from exc
except httpx.RequestError as exc: # pragma: no cover - network errors are rare in tests
raise BackendClientError(f"Failed to reach backend: {exc}") from exc
try:
payload = response.json()
except json.JSONDecodeError as exc:
raise BackendClientError("Backend returned invalid JSON") from exc
if not isinstance(payload, list):
raise BackendClientError("Unexpected search payload type; expected a list")
results: list[BackendSearchResult] = []
for item in payload:
try:
results.append(BackendSearchResult.model_validate(item))
except ValidationError as exc:
raise BackendClientError(f"Invalid search result payload: {exc}") from exc
return results
def _truncate_body(body: str, limit: int = 200) -> str:
body = body.strip()
if len(body) <= limit:
return body
return f"{body[:limit]}"
-71
View File
@@ -1,71 +0,0 @@
"""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"]
+43 -23
View File
@@ -1,38 +1,58 @@
"""Pydantic models shared across the MCP service.""" """Pydantic models used by the OpenIsle MCP server."""
from __future__ import annotations from __future__ import annotations
from typing import Optional from typing import Dict, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
__all__ = [
"BackendSearchResult",
"SearchResult",
"SearchResponse",
]
class BackendSearchResult(BaseModel):
"""Shape of the payload returned by the OpenIsle backend."""
type: str
id: Optional[int] = None
text: Optional[str] = None
sub_text: Optional[str] = Field(default=None, alias="subText")
extra: Optional[str] = None
post_id: Optional[int] = Field(default=None, alias="postId")
highlighted_text: Optional[str] = Field(default=None, alias="highlightedText")
highlighted_sub_text: Optional[str] = Field(default=None, alias="highlightedSubText")
highlighted_extra: Optional[str] = Field(default=None, alias="highlightedExtra")
model_config = ConfigDict(populate_by_name=True, extra="ignore")
class SearchResult(BaseModel): class SearchResult(BaseModel):
"""Representation of a single search result entry.""" """Structured search result returned to MCP clients."""
model_config = ConfigDict(extra="ignore") type: str = Field(description="Entity type, e.g. post, comment, user")
id: Optional[int] = Field(default=None, description="Primary identifier for the entity")
title: Optional[str] = Field(default=None, description="Primary text to display")
subtitle: Optional[str] = Field(default=None, description="Secondary text (e.g. author or category)")
extra: Optional[str] = Field(default=None, description="Additional descriptive snippet")
post_id: Optional[int] = Field(default=None, description="Associated post id for comment results")
url: Optional[str] = Field(default=None, description="Deep link to the resource inside OpenIsle")
highlights: Dict[str, Optional[str]] = Field(
default_factory=dict,
description="Highlighted HTML fragments keyed by field name",
)
type: Optional[str] = Field(default=None, description="Type of the result entry") model_config = ConfigDict(populate_by_name=True)
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): class SearchResponse(BaseModel):
"""Response payload returned by the search endpoint.""" """Response envelope returned from the MCP search tool."""
results: list[SearchResult] = Field(default_factory=list) keyword: str = Field(description="Sanitised keyword that was searched for")
total_results: int = Field(description="Total number of results returned by the backend")
limit: int = Field(description="Maximum number of results included in the response")
results: list[SearchResult] = Field(default_factory=list, description="Search results up to the requested limit")
model_config = ConfigDict(populate_by_name=True)
__all__ = ["SearchResult", "SearchResponse"]
View File
+150 -52
View File
@@ -1,66 +1,164 @@
"""FastAPI application exposing the MCP server endpoints.""" """Entry point for the OpenIsle MCP server."""
from __future__ import annotations from __future__ import annotations
import argparse
import logging import logging
import os
from typing import Annotated, Optional
from fastapi import Depends, FastAPI, HTTPException, Query, Request from mcp.server.fastmcp import Context, FastMCP
import httpx from mcp.server.fastmcp import exceptions as mcp_exceptions
from pydantic import Field
from .client import SearchClient from .client import BackendClientError, OpenIsleBackendClient
from .config import get_settings from .models import BackendSearchResult, SearchResponse, SearchResult
from .models import SearchResponse, SearchResult
LOGGER = logging.getLogger(__name__) logger = logging.getLogger(__name__)
APP_NAME = "openisle-mcp"
DEFAULT_BACKEND_URL = "http://springboot:8080"
DEFAULT_TRANSPORT = "stdio"
DEFAULT_TIMEOUT = 10.0
DEFAULT_LIMIT = 20
MAX_LIMIT = 50
server = FastMCP(
APP_NAME,
instructions=(
"Use the `search` tool to query OpenIsle content. "
"Results include posts, comments, users, categories, and tags."
),
)
async def _lifespan(app: FastAPI): def _env(name: str, default: Optional[str] = None) -> Optional[str]:
settings = get_settings() value = os.getenv(name, default)
client = SearchClient(settings) if value is None:
app.state.settings = settings return None
app.state.search_client = client trimmed = value.strip()
LOGGER.info( return trimmed or default
"Starting MCP server on %s:%s targeting backend %s",
settings.host,
settings.port, def _load_timeout() -> float:
settings.normalized_backend_base_url, raw = _env("OPENISLE_BACKEND_TIMEOUT", str(DEFAULT_TIMEOUT))
)
try: try:
yield timeout = float(raw) if raw is not None else DEFAULT_TIMEOUT
finally: except ValueError:
LOGGER.info("Shutting down MCP server") logger.warning("Invalid OPENISLE_BACKEND_TIMEOUT value '%s', falling back to %s", raw, DEFAULT_TIMEOUT)
await client.close() return DEFAULT_TIMEOUT
if timeout <= 0:
logger.warning("Non-positive OPENISLE_BACKEND_TIMEOUT %s, falling back to %s", timeout, DEFAULT_TIMEOUT)
return DEFAULT_TIMEOUT
return timeout
def create_app() -> FastAPI: _BACKEND_CLIENT = OpenIsleBackendClient(
"""Create and configure the FastAPI application.""" base_url=_env("OPENISLE_BACKEND_URL", DEFAULT_BACKEND_URL) or DEFAULT_BACKEND_URL,
timeout=_load_timeout(),
app = FastAPI(title="OpenIsle MCP Server", lifespan=_lifespan) )
_PUBLIC_BASE_URL = _env("OPENISLE_PUBLIC_BASE_URL")
@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"] def _build_url(result: BackendSearchResult) -> Optional[str]:
if not _PUBLIC_BASE_URL:
return None
base = _PUBLIC_BASE_URL.rstrip("/")
if result.type in {"post", "post_title"} and result.id is not None:
return f"{base}/posts/{result.id}"
if result.type == "comment" and result.post_id is not None:
anchor = f"#comment-{result.id}" if result.id is not None else ""
return f"{base}/posts/{result.post_id}{anchor}"
if result.type == "user" and result.id is not None:
return f"{base}/users/{result.id}"
if result.type == "category" and result.id is not None:
return f"{base}/?categoryId={result.id}"
if result.type == "tag" and result.id is not None:
return f"{base}/?tagIds={result.id}"
return None
def _to_search_result(result: BackendSearchResult) -> SearchResult:
highlights = {
"text": result.highlighted_text,
"subText": result.highlighted_sub_text,
"extra": result.highlighted_extra,
}
# Remove empty highlight entries to keep the payload clean
highlights = {key: value for key, value in highlights.items() if value}
return SearchResult(
type=result.type,
id=result.id,
title=result.text,
subtitle=result.sub_text,
extra=result.extra,
post_id=result.post_id,
url=_build_url(result),
highlights=highlights,
)
KeywordParam = Annotated[str, Field(description="Keyword to search for", min_length=1)]
LimitParam = Annotated[
int,
Field(ge=1, le=MAX_LIMIT, description=f"Maximum number of results to return (<= {MAX_LIMIT})"),
]
@server.tool(name="search", description="Search OpenIsle content")
async def search(keyword: KeywordParam, limit: LimitParam = DEFAULT_LIMIT, ctx: Optional[Context] = None) -> SearchResponse:
"""Run a search query against the OpenIsle backend."""
trimmed = keyword.strip()
if not trimmed:
raise mcp_exceptions.ToolError("Keyword must not be empty")
if ctx is not None:
await ctx.debug(f"Searching OpenIsle for '{trimmed}' (limit={limit})")
try:
raw_results = await _BACKEND_CLIENT.search_global(trimmed)
except BackendClientError as exc:
if ctx is not None:
await ctx.error(f"Search request failed: {exc}")
raise mcp_exceptions.ToolError(f"Search failed: {exc}") from exc
results = [_to_search_result(result) for result in raw_results]
limited = results[:limit]
if ctx is not None:
await ctx.info(
"Search completed",
keyword=trimmed,
total_results=len(results),
returned=len(limited),
)
return SearchResponse(keyword=trimmed, total_results=len(results), limit=limit, results=limited)
def main() -> None:
parser = argparse.ArgumentParser(description="Run the OpenIsle MCP server")
parser.add_argument(
"--transport",
choices=["stdio", "sse", "streamable-http"],
default=_env("OPENISLE_MCP_TRANSPORT", DEFAULT_TRANSPORT),
help="Transport protocol to use",
)
parser.add_argument(
"--mount-path",
default=_env("OPENISLE_MCP_SSE_MOUNT_PATH", "/mcp"),
help="Mount path when using the SSE transport",
)
args = parser.parse_args()
logging.basicConfig(level=os.getenv("OPENISLE_MCP_LOG_LEVEL", "INFO"))
logger.info(
"Starting OpenIsle MCP server", extra={"transport": args.transport, "backend": _BACKEND_CLIENT.base_url}
)
server.run(transport=args.transport, mount_path=args.mount_path)
if __name__ == "__main__":
main()