Compare commits

..

1 Commits

Author SHA1 Message Date
Tim dfa7530373 feat: add MCP search server 2025-10-25 21:34:44 +08:00
13 changed files with 402 additions and 306 deletions
-1
View File
@@ -17,7 +17,6 @@ dist
# misc # misc
.DS_Store .DS_Store
__pycache__/
*.pem *.pem
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
+8 -10
View File
@@ -213,30 +213,28 @@ services:
- dev_local_backend - dev_local_backend
- prod - prod
mcp-service: mcp-server:
build: build:
context: .. context: ..
dockerfile: mcp/Dockerfile dockerfile: docker/mcp-service.Dockerfile
container_name: ${COMPOSE_PROJECT_NAME}-openisle-mcp container_name: ${COMPOSE_PROJECT_NAME}-openisle-mcp
env_file: env_file:
- ${ENV_FILE:-../.env} - ${ENV_FILE:-../.env}
environment: environment:
FASTMCP_HOST: 0.0.0.0 OPENISLE_API_BASE_URL: ${OPENISLE_API_BASE_URL:-http://springboot:8080}
FASTMCP_PORT: ${MCP_PORT:-8765} OPENISLE_MCP_HOST: ${OPENISLE_MCP_HOST:-0.0.0.0}
OPENISLE_BACKEND_URL: ${OPENISLE_BACKEND_URL:-http://springboot:8080} OPENISLE_MCP_PORT: ${OPENISLE_MCP_PORT:-8000}
OPENISLE_BACKEND_TIMEOUT: ${OPENISLE_BACKEND_TIMEOUT:-10} OPENISLE_MCP_TRANSPORT: ${OPENISLE_MCP_TRANSPORT:-streamable-http}
OPENISLE_MCP_TRANSPORT: ${OPENISLE_MCP_TRANSPORT:-sse}
OPENISLE_MCP_SSE_MOUNT_PATH: ${OPENISLE_MCP_SSE_MOUNT_PATH:-/mcp}
ports: ports:
- "${MCP_PORT:-8765}:${MCP_PORT:-8765}" - "${OPENISLE_MCP_PORT:-8000}:8000"
depends_on: depends_on:
springboot: springboot:
condition: service_healthy condition: service_healthy
restart: unless-stopped
networks: networks:
- openisle-network - openisle-network
profiles: profiles:
- dev - dev
- dev_local_backend
- prod - prod
frontend_dev: frontend_dev:
+20
View File
@@ -0,0 +1,20 @@
FROM python:3.11-slim AS base
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
COPY mcp/pyproject.toml mcp/README.md ./
COPY mcp/src ./src
RUN pip install --upgrade pip \
&& pip install .
EXPOSE 8000
ENV OPENISLE_API_BASE_URL=http://springboot:8080 \
OPENISLE_MCP_HOST=0.0.0.0 \
OPENISLE_MCP_PORT=8000 \
OPENISLE_MCP_TRANSPORT=streamable-http
CMD ["openisle-mcp"]
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.py[cod]
*.egg-info/
.build/
.venv/
.env
-17
View File
@@ -1,17 +0,0 @@
FROM python:3.11-slim AS runtime
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
COPY mcp/pyproject.toml /app/pyproject.toml
COPY mcp/README.md /app/README.md
COPY mcp/src /app/src
RUN pip install --upgrade pip \
&& pip install .
EXPOSE 8765
CMD ["openisle-mcp"]
+29 -23
View File
@@ -1,39 +1,45 @@
# OpenIsle MCP Server # OpenIsle MCP Server
This package provides a [Model Context Protocol](https://github.com/modelcontextprotocol) (MCP) server that exposes the OpenIsle This package exposes a [Model Context Protocol](https://github.com/modelcontextprotocol) (MCP) server for OpenIsle.
search capabilities to AI assistants. The server wraps the existing Spring Boot backend and currently provides a single `search` The initial release focuses on surfacing the platform's search capabilities so that AI assistants can discover
tool. Future iterations can extend the server with additional functionality such as publishing new posts or moderating content. users and posts directly through the existing REST API. Future iterations can expand this service with post
creation and other productivity tools.
## Features ## Features
- 🔍 **Global search** — delegates to the existing `/api/search/global` endpoint exposed by the OpenIsle backend. - 🔍 Keyword search across users and posts using the OpenIsle backend APIs
- 🧠 **Structured results** — responses include highlights and deep links so AI clients can present the results cleanly. - Structured MCP tool response for downstream reasoning
- ⚙️ **Configurable** — point the server at any reachable OpenIsle backend by setting environment variables. - 🩺 Lightweight health check endpoint (`/health`) for container orchestration
- ⚙️ Configurable via environment variables with sensible defaults for Docker Compose
## Local development ## Running locally
```bash ```bash
cd mcp cd mcp
python -m venv .venv pip install .
source .venv/bin/activate openisle-mcp # starts the MCP server on http://127.0.0.1:8000 by default
pip install -e .
openisle-mcp --transport stdio # or "sse"/"streamable-http"
``` ```
Environment variables: By default the server targets `http://localhost:8080` for backend requests. Override the target by setting
`OPENISLE_API_BASE_URL` before starting the service.
| Variable | Description | Default | ## Environment variables
| --- | --- | --- |
| `OPENISLE_BACKEND_URL` | Base URL of the Spring Boot backend | `http://springboot:8080` | | Variable | Default | Description |
| `OPENISLE_BACKEND_TIMEOUT` | Timeout (seconds) for backend HTTP calls | `10` | | -------- | ------- | ----------- |
| `OPENISLE_PUBLIC_BASE_URL` | Optional base URL used to build deep links in search results | *(unset)* | | `OPENISLE_API_BASE_URL` | `http://localhost:8080` | Base URL of the OpenIsle backend API |
| `OPENISLE_MCP_TRANSPORT` | MCP transport (`stdio`, `sse`, `streamable-http`) | `stdio` | | `OPENISLE_MCP_HOST` | `127.0.0.1` | Hostname/interface for the MCP HTTP server |
| `OPENISLE_MCP_SSE_MOUNT_PATH` | Mount path when using SSE transport | `/mcp` | | `OPENISLE_MCP_PORT` | `8000` | Port for the MCP HTTP server |
| `FASTMCP_HOST` | Host for SSE / HTTP transports | `127.0.0.1` | | `OPENISLE_MCP_TRANSPORT` | `streamable-http` | Transport mode (`stdio`, `sse`, or `streamable-http`) |
| `FASTMCP_PORT` | Port for SSE / HTTP transports | `8000` | | `OPENISLE_MCP_TIMEOUT_SECONDS` | `10` | HTTP timeout when calling the backend |
## Docker ## Docker
A dedicated Docker image is provided and wired into `docker-compose.yaml`. The container listens on The repository's Docker Compose stack now includes the MCP server. To start it alongside other services:
`${MCP_PORT:-8765}` and connects to the backend service running in the same compose stack.
```bash
cd docker
docker compose --profile dev up mcp-server
```
The service exposes port `8000` by default. Update `OPENISLE_MCP_PORT` to customize the mapped port.
+7 -8
View File
@@ -5,23 +5,22 @@ 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 = [{name = "OpenIsle Team"}]
license = {text = "MIT"}
requires-python = ">=3.11" requires-python = ">=3.11"
authors = [{ name = "OpenIsle" }]
dependencies = [ dependencies = [
"mcp>=1.19.0", "mcp>=1.19.0",
"httpx>=0.28.0", "httpx>=0.28.1",
"pydantic>=2.12.0", "pydantic>=2.7.0"
] ]
[project.urls]
Homepage = "https://github.com/openisle/openisle"
[project.scripts] [project.scripts]
openisle-mcp = "openisle_mcp.server:main" openisle-mcp = "openisle_mcp.server:main"
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
+10 -6
View File
@@ -1,10 +1,14 @@
"""OpenIsle MCP server package.""" """OpenIsle MCP server package."""
from importlib import metadata from .config import Settings, get_settings
from .models import SearchItem, SearchResponse, SearchScope
try: __all__ = [
__version__ = metadata.version("openisle-mcp") "Settings",
except metadata.PackageNotFoundError: # pragma: no cover - best effort during dev "get_settings",
__version__ = "0.0.0" "SearchItem",
"SearchResponse",
"SearchScope",
]
__all__ = ["__version__"] __version__ = "0.1.0"
+21 -67
View File
@@ -1,79 +1,33 @@
"""HTTP client for talking to the OpenIsle backend.""" """HTTP client helpers for interacting with the OpenIsle backend APIs."""
from __future__ import annotations from __future__ import annotations
import json from typing import Any
import logging
from typing import List
import httpx import httpx
from pydantic import ValidationError
from .models import BackendSearchResult from .config import Settings, get_settings
from .models import SearchScope
__all__ = ["BackendClientError", "OpenIsleBackendClient"]
logger = logging.getLogger(__name__)
class BackendClientError(RuntimeError): class OpenIsleAPI:
"""Raised when the backend cannot fulfil a request.""" """Thin wrapper around the OpenIsle REST API used by the MCP server."""
def __init__(self, settings: Settings | None = None) -> None:
self._settings = settings or get_settings()
class OpenIsleBackendClient: async def search(self, scope: SearchScope, keyword: str) -> list[Any]:
"""Tiny wrapper around the Spring Boot search endpoints.""" """Execute a search request against the backend API."""
def __init__(self, base_url: str, timeout: float = 10.0) -> None: url_path = self._settings.get_search_path(scope)
if not base_url: async with httpx.AsyncClient(
raise ValueError("base_url must not be empty") base_url=str(self._settings.backend_base_url),
self._base_url = base_url.rstrip("/") timeout=self._settings.request_timeout_seconds,
timeout = timeout if timeout > 0 else 10.0 ) as client:
self._timeout = httpx.Timeout(timeout, connect=timeout, read=timeout) response = await client.get(url_path, params={"keyword": keyword})
response.raise_for_status()
data = response.json()
@property if not isinstance(data, list):
def base_url(self) -> str: raise RuntimeError("Unexpected search response payload: expected a list")
return self._base_url return data
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]}"
+83
View File
@@ -0,0 +1,83 @@
"""Configuration helpers for the OpenIsle MCP server."""
from __future__ import annotations
import os
from functools import lru_cache
from typing import Dict, Literal
from pydantic import AnyHttpUrl, BaseModel, Field, ValidationError
from .models import SearchScope
TransportType = Literal["stdio", "sse", "streamable-http"]
class Settings(BaseModel):
"""Runtime configuration for the MCP server."""
backend_base_url: AnyHttpUrl = Field(
default="http://localhost:8080",
description="Base URL of the OpenIsle backend API.",
)
request_timeout_seconds: float = Field(
default=10.0,
gt=0,
description="HTTP timeout when talking to the backend APIs.",
)
transport: TransportType = Field(
default="streamable-http",
description="Transport mode for the MCP server.",
)
host: str = Field(default="127.0.0.1", description="Hostname/interface used by the MCP HTTP server.")
port: int = Field(default=8000, ge=0, description="Port used by the MCP HTTP server.")
search_paths: Dict[str, str] = Field(
default_factory=lambda: {
SearchScope.GLOBAL.value: "/api/search/global",
SearchScope.USERS.value: "/api/search/users",
SearchScope.POSTS.value: "/api/search/posts",
SearchScope.POSTS_TITLE.value: "/api/search/posts/title",
SearchScope.POSTS_CONTENT.value: "/api/search/posts/content",
},
description="Mapping between search scopes and backend API paths.",
)
def get_search_path(self, scope: SearchScope) -> str:
"""Return the backend path associated with a given search scope."""
try:
return self.search_paths[scope.value]
except KeyError as exc: # pragma: no cover - defensive guard
raise ValueError(f"Unsupported search scope: {scope}") from exc
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""Load settings from environment variables with caching."""
raw_settings: Dict[str, object] = {}
backend_url = os.getenv("OPENISLE_API_BASE_URL")
if backend_url:
raw_settings["backend_base_url"] = backend_url
timeout = os.getenv("OPENISLE_MCP_TIMEOUT_SECONDS")
if timeout:
raw_settings["request_timeout_seconds"] = float(timeout)
transport = os.getenv("OPENISLE_MCP_TRANSPORT")
if transport:
raw_settings["transport"] = transport
host = os.getenv("OPENISLE_MCP_HOST")
if host:
raw_settings["host"] = host
port = os.getenv("OPENISLE_MCP_PORT")
if port:
raw_settings["port"] = int(port)
try:
return Settings(**raw_settings)
except (ValidationError, ValueError) as exc: # pragma: no cover - configuration errors should surface clearly
raise RuntimeError(f"Invalid MCP configuration: {exc}") from exc
+30 -43
View File
@@ -1,58 +1,45 @@
"""Pydantic models used by the OpenIsle MCP server.""" """Data models for the OpenIsle MCP server."""
from __future__ import annotations from __future__ import annotations
from typing import Dict, Optional from enum import Enum
from typing import Any, Dict, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, Field
__all__ = [
"BackendSearchResult",
"SearchResult",
"SearchResponse",
]
class BackendSearchResult(BaseModel): class SearchScope(str, Enum):
"""Shape of the payload returned by the OpenIsle backend.""" """Supported search scopes exposed via the MCP tool."""
type: str GLOBAL = "global"
id: Optional[int] = None USERS = "users"
text: Optional[str] = None POSTS = "posts"
sub_text: Optional[str] = Field(default=None, alias="subText") POSTS_TITLE = "posts_title"
extra: Optional[str] = None POSTS_CONTENT = "posts_content"
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 Highlight(BaseModel):
"""Structured search result returned to MCP clients.""" """Highlighted fragments returned by the backend search API."""
type: str = Field(description="Entity type, e.g. post, comment, user") text: Optional[str] = Field(default=None, description="Highlighted main text snippet.")
id: Optional[int] = Field(default=None, description="Primary identifier for the entity") sub_text: Optional[str] = Field(default=None, description="Highlighted secondary text snippet.")
title: Optional[str] = Field(default=None, description="Primary text to display") extra: Optional[str] = Field(default=None, description="Additional highlighted data.")
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",
)
model_config = ConfigDict(populate_by_name=True)
class SearchItem(BaseModel):
"""Normalized representation of a single search result."""
category: str = Field(description="Type/category of the search result, e.g. user or post.")
title: Optional[str] = Field(default=None, description="Primary title or label for the result.")
description: Optional[str] = Field(default=None, description="Supporting description or summary text.")
url: Optional[str] = Field(default=None, description="Canonical URL that references the resource, if available.")
metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional structured metadata extracted from the API.")
highlights: Optional[Highlight] = Field(default=None, description="Highlighted snippets returned by the backend search API.")
class SearchResponse(BaseModel): class SearchResponse(BaseModel):
"""Response envelope returned from the MCP search tool.""" """Structured response returned by the MCP search tool."""
keyword: str = Field(description="Sanitised keyword that was searched for") scope: SearchScope = Field(description="Scope of the search that produced the results.")
total_results: int = Field(description="Total number of results returned by the backend") keyword: str = Field(description="Keyword submitted to the backend search endpoint.")
limit: int = Field(description="Maximum number of results included in the response") results: list[SearchItem] = Field(default_factory=list, description="Normalized search results from the backend API.")
results: list[SearchResult] = Field(default_factory=list, description="Search results up to the requested limit")
model_config = ConfigDict(populate_by_name=True)
+100
View File
@@ -0,0 +1,100 @@
"""Utilities for normalising OpenIsle search results."""
from __future__ import annotations
import re
from typing import Any, Iterable
from .models import Highlight, SearchItem, SearchScope
def _truncate(text: str | None, *, limit: int = 240) -> str | None:
"""Compress whitespace and truncate overly long text fragments."""
if not text:
return None
compact = re.sub(r"\s+", " ", text).strip()
if len(compact) <= limit:
return compact
return f"{compact[:limit - 1]}"
def _extract_highlight(data: dict[str, Any]) -> Highlight | None:
highlighted = {
"text": data.get("highlightedText"),
"sub_text": data.get("highlightedSubText"),
"extra": data.get("highlightedExtra"),
}
if any(highlighted.values()):
return Highlight(**highlighted)
return None
def normalise_results(scope: SearchScope, payload: Iterable[dict[str, Any]]) -> list[SearchItem]:
"""Convert backend payloads into :class:`SearchItem` entries."""
normalised: list[SearchItem] = []
for item in payload:
if not isinstance(item, dict):
continue
if scope is SearchScope.GLOBAL:
normalised.append(
SearchItem(
category=item.get("type", scope.value),
title=_truncate(item.get("text")),
description=_truncate(item.get("subText")),
metadata={
"id": item.get("id"),
"postId": item.get("postId"),
"extra": item.get("extra"),
},
highlights=_extract_highlight(item),
)
)
continue
if scope in {SearchScope.POSTS, SearchScope.POSTS_CONTENT, SearchScope.POSTS_TITLE}:
author = item.get("author") or {}
category = item.get("category") or {}
metadata = {
"id": item.get("id"),
"author": author.get("username"),
"category": category.get("name"),
"views": item.get("views"),
"commentCount": item.get("commentCount"),
"tags": [tag.get("name") for tag in item.get("tags", []) if isinstance(tag, dict)],
}
normalised.append(
SearchItem(
category="post",
title=_truncate(item.get("title")),
description=_truncate(item.get("content")),
metadata={k: v for k, v in metadata.items() if v is not None},
)
)
continue
if scope is SearchScope.USERS:
metadata = {
"id": item.get("id"),
"email": item.get("email"),
"followers": item.get("followers"),
"following": item.get("following"),
"role": item.get("role"),
}
normalised.append(
SearchItem(
category="user",
title=_truncate(item.get("username")),
description=_truncate(item.get("introduction")),
metadata={k: v for k, v in metadata.items() if v is not None},
)
)
continue
# Fallback: include raw entry to aid debugging of unsupported scopes
normalised.append(SearchItem(category=scope.value, metadata=item))
return normalised
+88 -131
View File
@@ -2,163 +2,120 @@
from __future__ import annotations from __future__ import annotations
import argparse
import logging import logging
import os import os
from typing import Annotated, Optional from typing import Annotated
from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp import Context, FastMCP
from mcp.server.fastmcp import exceptions as mcp_exceptions from mcp.server.fastmcp.logging import configure_logging
from pydantic import Field from pydantic import Field
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from .client import BackendClientError, OpenIsleBackendClient from .client import OpenIsleAPI
from .models import BackendSearchResult, SearchResponse, SearchResult from .config import Settings, get_settings
from .models import SearchResponse, SearchScope
from .search import normalise_results
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."
),
)
def _env(name: str, default: Optional[str] = None) -> Optional[str]: def _create_server(settings: Settings) -> FastMCP:
value = os.getenv(name, default) """Instantiate the FastMCP server with configured metadata."""
if value is None:
return None
trimmed = value.strip()
return trimmed or default
server = FastMCP(
def _load_timeout() -> float: name="OpenIsle MCP",
raw = _env("OPENISLE_BACKEND_TIMEOUT", str(DEFAULT_TIMEOUT)) instructions=(
try: "Access OpenIsle search functionality. Provide a keyword and optionally a scope to "
timeout = float(raw) if raw is not None else DEFAULT_TIMEOUT "discover users and posts from the community."
except ValueError: ),
logger.warning("Invalid OPENISLE_BACKEND_TIMEOUT value '%s', falling back to %s", raw, DEFAULT_TIMEOUT) host=settings.host,
return DEFAULT_TIMEOUT port=settings.port,
if timeout <= 0: transport_security=None,
logger.warning("Non-positive OPENISLE_BACKEND_TIMEOUT %s, falling back to %s", timeout, DEFAULT_TIMEOUT)
return DEFAULT_TIMEOUT
return timeout
_BACKEND_CLIENT = OpenIsleBackendClient(
base_url=_env("OPENISLE_BACKEND_URL", DEFAULT_BACKEND_URL) or DEFAULT_BACKEND_URL,
timeout=_load_timeout(),
)
_PUBLIC_BASE_URL = _env("OPENISLE_PUBLIC_BASE_URL")
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,
) )
@server.custom_route("/health", methods=["GET"])
async def health(_: Request) -> Response: # pragma: no cover - exercised via runtime checks
return JSONResponse({"status": "ok"})
KeywordParam = Annotated[str, Field(description="Keyword to search for", min_length=1)] return server
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 _execute_search(
async def search(keyword: KeywordParam, limit: LimitParam = DEFAULT_LIMIT, ctx: Optional[Context] = None) -> SearchResponse: *,
"""Run a search query against the OpenIsle backend.""" api: OpenIsleAPI,
scope: SearchScope,
keyword: str,
context: Context | None,
) -> SearchResponse:
message = f"Searching OpenIsle scope={scope.value} keyword={keyword!r}"
if context is not None:
context.info(message)
else:
_logger.info(message)
trimmed = keyword.strip() payload = await api.search(scope, keyword)
if not trimmed: items = normalise_results(scope, payload)
raise mcp_exceptions.ToolError("Keyword must not be empty") return SearchResponse(scope=scope, keyword=keyword, results=items)
if ctx is not None:
await ctx.debug(f"Searching OpenIsle for '{trimmed}' (limit={limit})")
try: def build_server(settings: Settings | None = None) -> FastMCP:
raw_results = await _BACKEND_CLIENT.search_global(trimmed) """Configure and return the FastMCP server instance."""
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] resolved_settings = settings or get_settings()
limited = results[:limit] server = _create_server(resolved_settings)
api_client = OpenIsleAPI(resolved_settings)
if ctx is not None: @server.tool(
await ctx.info( name="openisle_search",
"Search completed", description="Search OpenIsle for users and posts.",
keyword=trimmed, )
total_results=len(results), async def openisle_search(
returned=len(limited), keyword: Annotated[str, Field(description="Keyword used to query OpenIsle search.")],
) scope: Annotated[
SearchScope,
Field(
description=(
"Scope of the search. Use 'global' to search across users and posts, or specify "
"'users', 'posts', 'posts_title', or 'posts_content' to narrow the results."
)
),
] = SearchScope.GLOBAL,
context: Context | None = None,
) -> SearchResponse:
try:
return await _execute_search(api=api_client, scope=scope, keyword=keyword, context=context)
except Exception as exc: # pragma: no cover - surfaced to the MCP runtime
error_message = f"Search failed: {exc}"
if context is not None:
context.error(error_message)
_logger.exception("Search tool failed")
raise
return SearchResponse(keyword=trimmed, total_results=len(results), limit=limit, results=limited) return server
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description="Run the OpenIsle MCP server") """CLI entry point used by the console script."""
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")) settings = get_settings()
logger.info( configure_logging("INFO")
"Starting OpenIsle MCP server", extra={"transport": args.transport, "backend": _BACKEND_CLIENT.base_url} server = build_server(settings)
)
server.run(transport=args.transport, mount_path=args.mount_path) transport = os.getenv("OPENISLE_MCP_TRANSPORT", settings.transport)
if transport not in {"stdio", "sse", "streamable-http"}:
raise RuntimeError(f"Unsupported transport mode: {transport}")
_logger.info("Starting OpenIsle MCP server on %s:%s via %s", settings.host, settings.port, transport)
if transport == "stdio":
server.run("stdio")
elif transport == "sse":
mount_path = os.getenv("OPENISLE_MCP_SSE_PATH")
server.run("sse", mount_path=mount_path)
else:
server.run("streamable-http")
if __name__ == "__main__": if __name__ == "__main__": # pragma: no cover - manual execution path
main() main()