Compare commits

..

3 Commits

Author SHA1 Message Date
Tim
755982098b Merge pull request #1087 from nagisa77/codex/add-mcp-service-to-deploy-scripts
Include MCP service in deployment scripts
2025-10-26 14:07:15 +08:00
Tim
af24263c0a Include MCP service in deployment scripts 2025-10-26 14:07:03 +08:00
tim
8fd268bd11 feat: add mcp 2025-10-25 23:33:51 +08:00
17 changed files with 297 additions and 443 deletions

View File

@@ -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
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 frontend_service mcp
echo "👉 Current status:"
docker compose -f "$compose_file" --env-file "$env_file" ps

View File

@@ -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
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 frontend_service mcp
echo "👉 Current status:"
docker compose -f "$compose_file" --env-file "$env_file" ps

View File

@@ -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
@@ -213,30 +239,6 @@ services:
- dev_local_backend
- prod
mcp-server:
build:
context: ..
dockerfile: docker/mcp-service.Dockerfile
container_name: ${COMPOSE_PROJECT_NAME}-openisle-mcp
env_file:
- ${ENV_FILE:-../.env}
environment:
OPENISLE_API_BASE_URL: ${OPENISLE_API_BASE_URL:-http://springboot:8080}
OPENISLE_MCP_HOST: ${OPENISLE_MCP_HOST:-0.0.0.0}
OPENISLE_MCP_PORT: ${OPENISLE_MCP_PORT:-8000}
OPENISLE_MCP_TRANSPORT: ${OPENISLE_MCP_TRANSPORT:-streamable-http}
ports:
- "${OPENISLE_MCP_PORT:-8000}:8000"
depends_on:
springboot:
condition: service_healthy
networks:
- openisle-network
profiles:
- dev
- dev_local_backend
- prod
frontend_dev:
image: node:20
container_name: ${COMPOSE_PROJECT_NAME}-openisle-frontend-dev

View File

@@ -1,20 +0,0 @@
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"]

21
docker/mcp.Dockerfile Normal file
View File

@@ -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"]

6
mcp/.gitignore vendored
View File

@@ -1,6 +0,0 @@
__pycache__/
*.py[cod]
*.egg-info/
.build/
.venv/
.env

View File

@@ -1,45 +1,37 @@
# OpenIsle MCP Server
This package exposes a [Model Context Protocol](https://github.com/modelcontextprotocol) (MCP) server for OpenIsle.
The initial release focuses on surfacing the platform's search capabilities so that AI assistants can discover
users and posts directly through the existing REST API. Future iterations can expand this service with post
creation and other productivity tools.
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.
## Features
## Configuration
- 🔍 Keyword search across users and posts using the OpenIsle backend APIs
- ✅ Structured MCP tool response for downstream reasoning
- 🩺 Lightweight health check endpoint (`/health`) for container orchestration
- ⚙️ Configurable via environment variables with sensible defaults for Docker Compose
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
cd mcp
pip install .
openisle-mcp # starts the MCP server on http://127.0.0.1:8000 by default
OPENISLE_MCP_BACKEND_BASE_URL="http://localhost:8080" openisle-mcp
```
By default the server targets `http://localhost:8080` for backend requests. Override the target by setting
`OPENISLE_API_BASE_URL` before starting the service.
By default the server listens on port `8085` and serves MCP over Streamable HTTP.
## Environment variables
## Available tools
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `OPENISLE_API_BASE_URL` | `http://localhost:8080` | Base URL of the OpenIsle backend API |
| `OPENISLE_MCP_HOST` | `127.0.0.1` | Hostname/interface for the MCP HTTP server |
| `OPENISLE_MCP_PORT` | `8000` | Port for the MCP HTTP server |
| `OPENISLE_MCP_TRANSPORT` | `streamable-http` | Transport mode (`stdio`, `sse`, or `streamable-http`) |
| `OPENISLE_MCP_TIMEOUT_SECONDS` | `10` | HTTP timeout when calling the backend |
| Tool | Description |
| --- | --- |
| `search` | Perform a global search against the OpenIsle backend. |
## Docker
The tool returns structured data describing each search hit including highlighted snippets when
provided by the backend.
The repository's Docker Compose stack now includes the MCP server. To start it alongside other services:
```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.

View File

@@ -1,28 +1,27 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
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"
authors = [{ name = "OpenIsle" }]
dependencies = [
"mcp>=1.19.0",
"httpx>=0.28.1",
"pydantic>=2.7.0"
"httpx>=0.28,<0.29",
"pydantic>=2.12,<3",
"pydantic-settings>=2.11,<3"
]
[project.urls]
Homepage = "https://github.com/openisle/openisle"
[project.scripts]
openisle-mcp = "openisle_mcp.server:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.hatch.build]
packages = ["src/openisle_mcp"]
[tool.ruff]
line-length = 100
[tool.setuptools.package-data]
openisle_mcp = ["py.typed"]

View File

@@ -1,14 +1,6 @@
"""OpenIsle MCP server package."""
from .config import Settings, get_settings
from .models import SearchItem, SearchResponse, SearchScope
__all__ = [
"Settings",
"get_settings",
"SearchItem",
"SearchResponse",
"SearchScope",
]
__all__ = ["Settings", "get_settings"]
__version__ = "0.1.0"

View File

@@ -1,33 +0,0 @@
"""HTTP client helpers for interacting with the OpenIsle backend APIs."""
from __future__ import annotations
from typing import Any
import httpx
from .config import Settings, get_settings
from .models import SearchScope
class OpenIsleAPI:
"""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()
async def search(self, scope: SearchScope, keyword: str) -> list[Any]:
"""Execute a search request against the backend API."""
url_path = self._settings.get_search_path(scope)
async with httpx.AsyncClient(
base_url=str(self._settings.backend_base_url),
timeout=self._settings.request_timeout_seconds,
) as client:
response = await client.get(url_path, params={"keyword": keyword})
response.raise_for_status()
data = response.json()
if not isinstance(data, list):
raise RuntimeError("Unexpected search response payload: expected a list")
return data

View File

@@ -1,83 +1,52 @@
"""Configuration helpers for the OpenIsle MCP server."""
"""Application configuration helpers for the OpenIsle MCP server."""
from __future__ import annotations
import os
from functools import lru_cache
from typing import Dict, Literal
from typing import Literal
from pydantic import AnyHttpUrl, BaseModel, Field, ValidationError
from .models import SearchScope
TransportType = Literal["stdio", "sse", "streamable-http"]
from pydantic import Field
from pydantic.networks import AnyHttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseModel):
"""Runtime configuration for the MCP server."""
class Settings(BaseSettings):
"""Configuration for the MCP server."""
backend_base_url: AnyHttpUrl = Field(
default="http://localhost:8080",
description="Base URL of the OpenIsle backend API.",
"http://springboot:8080",
description="Base URL for the OpenIsle backend service.",
)
request_timeout_seconds: float = Field(
default=10.0,
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="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.",
description="Timeout (seconds) for backend search requests.",
)
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
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:
"""Load settings from environment variables with caching."""
"""Return cached application settings."""
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
return Settings()

View File

@@ -1,45 +0,0 @@
"""Data models for the OpenIsle MCP server."""
from __future__ import annotations
from enum import Enum
from typing import Any, Dict, Optional
from pydantic import BaseModel, Field
class SearchScope(str, Enum):
"""Supported search scopes exposed via the MCP tool."""
GLOBAL = "global"
USERS = "users"
POSTS = "posts"
POSTS_TITLE = "posts_title"
POSTS_CONTENT = "posts_content"
class Highlight(BaseModel):
"""Highlighted fragments returned by the backend search API."""
text: Optional[str] = Field(default=None, description="Highlighted main text snippet.")
sub_text: Optional[str] = Field(default=None, description="Highlighted secondary text snippet.")
extra: Optional[str] = Field(default=None, description="Additional highlighted data.")
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):
"""Structured response returned by the MCP search tool."""
scope: SearchScope = Field(description="Scope of the search that produced the results.")
keyword: str = Field(description="Keyword submitted to the backend search endpoint.")
results: list[SearchItem] = Field(default_factory=list, description="Normalized search results from the backend API.")

View File

View File

@@ -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.",
)

View File

@@ -1,100 +0,0 @@
"""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

View File

@@ -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

View File

@@ -1,121 +1,98 @@
"""Entry point for the OpenIsle MCP server."""
"""Entry point for running the OpenIsle MCP server."""
from __future__ import annotations
import logging
import os
from contextlib import asynccontextmanager
from typing import Annotated
import httpx
from mcp.server.fastmcp import Context, FastMCP
from mcp.server.fastmcp.logging import configure_logging
from pydantic import Field
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from pydantic import ValidationError
from pydantic import Field as PydanticField
from .client import OpenIsleAPI
from .config import Settings, get_settings
from .models import SearchResponse, SearchScope
from .search import normalise_results
from .config import get_settings
from .schemas import SearchResponse, SearchResultItem
from .search_client import SearchClient
_logger = logging.getLogger(__name__)
settings = get_settings()
search_client = SearchClient(
str(settings.backend_base_url), timeout=settings.request_timeout
)
def _create_server(settings: Settings) -> FastMCP:
"""Instantiate the FastMCP server with configured metadata."""
@asynccontextmanager
async def lifespan(_: FastMCP):
"""Lifecycle hook that disposes shared resources when the server stops."""
server = FastMCP(
name="OpenIsle MCP",
instructions=(
"Access OpenIsle search functionality. Provide a keyword and optionally a scope to "
"discover users and posts from the community."
),
host=settings.host,
port=settings.port,
transport_security=None,
)
@server.custom_route("/health", methods=["GET"])
async def health(_: Request) -> Response: # pragma: no cover - exercised via runtime checks
return JSONResponse({"status": "ok"})
return server
try:
yield
finally:
await search_client.aclose()
async def _execute_search(
*,
api: OpenIsleAPI,
scope: SearchScope,
keyword: str,
context: Context | None,
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:
message = f"Searching OpenIsle scope={scope.value} keyword={keyword!r}"
if context is not None:
context.info(message)
else:
_logger.info(message)
"""Call the OpenIsle global search endpoint and return structured results."""
payload = await api.search(scope, keyword)
items = normalise_results(scope, payload)
return SearchResponse(scope=scope, keyword=keyword, results=items)
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
def build_server(settings: Settings | None = None) -> FastMCP:
"""Configure and return the FastMCP server instance."""
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
resolved_settings = settings or get_settings()
server = _create_server(resolved_settings)
api_client = OpenIsleAPI(resolved_settings)
if ctx is not None:
await ctx.info(f"Search keyword '{sanitized}' returned {len(results)} results.")
@server.tool(
name="openisle_search",
description="Search OpenIsle for users and posts.",
)
async def openisle_search(
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 server
return SearchResponse(keyword=sanitized, total=len(results), results=results)
def main() -> None:
"""CLI entry point used by the console script."""
"""Run the MCP server using the configured transport."""
settings = get_settings()
configure_logging("INFO")
server = build_server(settings)
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")
app.run(transport=settings.transport)
if __name__ == "__main__": # pragma: no cover - manual execution path
if __name__ == "__main__": # pragma: no cover - manual execution
main()