mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-08-19 09:40:58 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57e08bdd6f | |||
| 393afeda95 | |||
| f72d5e7ba9 |
@@ -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*
|
||||||
|
|||||||
@@ -213,28 +213,28 @@ services:
|
|||||||
- dev_local_backend
|
- dev_local_backend
|
||||||
- prod
|
- prod
|
||||||
|
|
||||||
mcp-server:
|
mcp-service:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: docker/mcp-service.Dockerfile
|
dockerfile: mcp/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:
|
||||||
OPENISLE_API_BASE_URL: ${OPENISLE_API_BASE_URL:-http://springboot:8080}
|
OPENISLE_BACKEND_URL: ${OPENISLE_BACKEND_URL:-http://springboot:8080}
|
||||||
OPENISLE_MCP_HOST: ${OPENISLE_MCP_HOST:-0.0.0.0}
|
OPENISLE_BACKEND_TIMEOUT: ${OPENISLE_BACKEND_TIMEOUT:-10}
|
||||||
OPENISLE_MCP_PORT: ${OPENISLE_MCP_PORT:-8000}
|
OPENISLE_MCP_TRANSPORT: ${OPENISLE_MCP_TRANSPORT:-sse}
|
||||||
OPENISLE_MCP_TRANSPORT: ${OPENISLE_MCP_TRANSPORT:-streamable-http}
|
OPENISLE_MCP_SSE_MOUNT_PATH: ${OPENISLE_MCP_SSE_MOUNT_PATH:-/mcp}
|
||||||
ports:
|
ports:
|
||||||
- "${OPENISLE_MCP_PORT:-8000}:8000"
|
- "${MCP_PORT:-8765}: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:
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*.egg-info/
|
|
||||||
.build/
|
|
||||||
.venv/
|
|
||||||
.env
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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"]
|
||||||
+21
-29
@@ -1,45 +1,37 @@
|
|||||||
# OpenIsle MCP Server
|
# OpenIsle MCP Server
|
||||||
|
|
||||||
This package exposes a [Model Context Protocol](https://github.com/modelcontextprotocol) (MCP) server for OpenIsle.
|
This package provides a [Model Context Protocol](https://github.com/modelcontextprotocol) (MCP) server that exposes the OpenIsle
|
||||||
The initial release focuses on surfacing the platform's search capabilities so that AI assistants can discover
|
search capabilities to AI assistants. The server wraps the existing Spring Boot backend and currently provides a single `search`
|
||||||
users and posts directly through the existing REST API. Future iterations can expand this service with post
|
tool. Future iterations can extend the server with additional functionality such as publishing new posts or moderating content.
|
||||||
creation and other productivity tools.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🔍 Keyword search across users and posts using the OpenIsle backend APIs
|
- 🔍 **Global search** — delegates to the existing `/api/search/global` endpoint exposed by the OpenIsle backend.
|
||||||
- ✅ Structured MCP tool response for downstream reasoning
|
- 🧠 **Structured results** — responses include highlights and deep links so AI clients can present the results cleanly.
|
||||||
- 🩺 Lightweight health check endpoint (`/health`) for container orchestration
|
- ⚙️ **Configurable** — point the server at any reachable OpenIsle backend by setting environment variables.
|
||||||
- ⚙️ Configurable via environment variables with sensible defaults for Docker Compose
|
|
||||||
|
|
||||||
## Running locally
|
## Local development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd mcp
|
cd mcp
|
||||||
pip install .
|
python -m venv .venv
|
||||||
openisle-mcp # starts the MCP server on http://127.0.0.1:8000 by default
|
source .venv/bin/activate
|
||||||
|
pip install -e .
|
||||||
|
openisle-mcp --transport stdio # or "sse"/"streamable-http"
|
||||||
```
|
```
|
||||||
|
|
||||||
By default the server targets `http://localhost:8080` for backend requests. Override the target by setting
|
Environment variables:
|
||||||
`OPENISLE_API_BASE_URL` before starting the service.
|
|
||||||
|
|
||||||
## Environment variables
|
| Variable | Description | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
| Variable | Default | Description |
|
| `OPENISLE_BACKEND_URL` | Base URL of the Spring Boot backend | `http://springboot:8080` |
|
||||||
| -------- | ------- | ----------- |
|
| `OPENISLE_BACKEND_TIMEOUT` | Timeout (seconds) for backend HTTP calls | `10` |
|
||||||
| `OPENISLE_API_BASE_URL` | `http://localhost:8080` | Base URL of the OpenIsle backend API |
|
| `OPENISLE_PUBLIC_BASE_URL` | Optional base URL used to build deep links in search results | *(unset)* |
|
||||||
| `OPENISLE_MCP_HOST` | `127.0.0.1` | Hostname/interface for the MCP HTTP server |
|
| `OPENISLE_MCP_TRANSPORT` | MCP transport (`stdio`, `sse`, `streamable-http`) | `stdio` |
|
||||||
| `OPENISLE_MCP_PORT` | `8000` | Port for the MCP HTTP server |
|
| `OPENISLE_MCP_SSE_MOUNT_PATH` | Mount path when using SSE transport | `/mcp` |
|
||||||
| `OPENISLE_MCP_TRANSPORT` | `streamable-http` | Transport mode (`stdio`, `sse`, or `streamable-http`) |
|
|
||||||
| `OPENISLE_MCP_TIMEOUT_SECONDS` | `10` | HTTP timeout when calling the backend |
|
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
The repository's Docker Compose stack now includes the MCP server. To start it alongside other services:
|
A dedicated Docker image is provided and wired into `docker-compose.yaml`. The container listens on
|
||||||
|
`${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.
|
|
||||||
|
|||||||
+8
-7
@@ -5,22 +5,23 @@ 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.1",
|
"httpx>=0.28.0",
|
||||||
"pydantic>=2.7.0"
|
"pydantic>=2.12.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"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
"""OpenIsle MCP server package."""
|
"""OpenIsle MCP server package."""
|
||||||
|
|
||||||
from .config import Settings, get_settings
|
from importlib import metadata
|
||||||
from .models import SearchItem, SearchResponse, SearchScope
|
|
||||||
|
|
||||||
__all__ = [
|
try:
|
||||||
"Settings",
|
__version__ = metadata.version("openisle-mcp")
|
||||||
"get_settings",
|
except metadata.PackageNotFoundError: # pragma: no cover - best effort during dev
|
||||||
"SearchItem",
|
__version__ = "0.0.0"
|
||||||
"SearchResponse",
|
|
||||||
"SearchScope",
|
|
||||||
]
|
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__all__ = ["__version__"]
|
||||||
|
|||||||
@@ -1,33 +1,79 @@
|
|||||||
"""HTTP client helpers for interacting with the OpenIsle backend APIs."""
|
"""HTTP client for talking to the OpenIsle backend."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import List
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from .config import Settings, get_settings
|
from .models import BackendSearchResult
|
||||||
from .models import SearchScope
|
|
||||||
|
__all__ = ["BackendClientError", "OpenIsleBackendClient"]
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class OpenIsleAPI:
|
class BackendClientError(RuntimeError):
|
||||||
"""Thin wrapper around the OpenIsle REST API used by the MCP server."""
|
"""Raised when the backend cannot fulfil a request."""
|
||||||
|
|
||||||
def __init__(self, settings: Settings | None = None) -> None:
|
|
||||||
self._settings = settings or get_settings()
|
|
||||||
|
|
||||||
async def search(self, scope: SearchScope, keyword: str) -> list[Any]:
|
class OpenIsleBackendClient:
|
||||||
"""Execute a search request against the backend API."""
|
"""Tiny wrapper around the Spring Boot search endpoints."""
|
||||||
|
|
||||||
url_path = self._settings.get_search_path(scope)
|
def __init__(self, base_url: str, timeout: float = 10.0) -> None:
|
||||||
async with httpx.AsyncClient(
|
if not base_url:
|
||||||
base_url=str(self._settings.backend_base_url),
|
raise ValueError("base_url must not be empty")
|
||||||
timeout=self._settings.request_timeout_seconds,
|
self._base_url = base_url.rstrip("/")
|
||||||
) as client:
|
timeout = timeout if timeout > 0 else 10.0
|
||||||
response = await client.get(url_path, params={"keyword": keyword})
|
self._timeout = httpx.Timeout(timeout, connect=timeout, read=timeout)
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
if not isinstance(data, list):
|
@property
|
||||||
raise RuntimeError("Unexpected search response payload: expected a list")
|
def base_url(self) -> str:
|
||||||
return data
|
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]}…"
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,45 +1,58 @@
|
|||||||
"""Data models for the OpenIsle MCP server."""
|
"""Pydantic models used by the OpenIsle MCP server."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from enum import Enum
|
from typing import Dict, Optional
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BackendSearchResult",
|
||||||
|
"SearchResult",
|
||||||
|
"SearchResponse",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class SearchScope(str, Enum):
|
class BackendSearchResult(BaseModel):
|
||||||
"""Supported search scopes exposed via the MCP tool."""
|
"""Shape of the payload returned by the OpenIsle backend."""
|
||||||
|
|
||||||
GLOBAL = "global"
|
type: str
|
||||||
USERS = "users"
|
id: Optional[int] = None
|
||||||
POSTS = "posts"
|
text: Optional[str] = None
|
||||||
POSTS_TITLE = "posts_title"
|
sub_text: Optional[str] = Field(default=None, alias="subText")
|
||||||
POSTS_CONTENT = "posts_content"
|
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 Highlight(BaseModel):
|
class SearchResult(BaseModel):
|
||||||
"""Highlighted fragments returned by the backend search API."""
|
"""Structured search result returned to MCP clients."""
|
||||||
|
|
||||||
text: Optional[str] = Field(default=None, description="Highlighted main text snippet.")
|
type: str = Field(description="Entity type, e.g. post, comment, user")
|
||||||
sub_text: Optional[str] = Field(default=None, description="Highlighted secondary text snippet.")
|
id: Optional[int] = Field(default=None, description="Primary identifier for the entity")
|
||||||
extra: Optional[str] = Field(default=None, description="Additional highlighted data.")
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
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):
|
||||||
"""Structured response returned by the MCP search tool."""
|
"""Response envelope returned from the MCP search tool."""
|
||||||
|
|
||||||
scope: SearchScope = Field(description="Scope of the search that produced the results.")
|
keyword: str = Field(description="Sanitised keyword that was searched for")
|
||||||
keyword: str = Field(description="Keyword submitted to the backend search endpoint.")
|
total_results: int = Field(description="Total number of results returned by the backend")
|
||||||
results: list[SearchItem] = Field(default_factory=list, description="Normalized search results from the backend API.")
|
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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
+153
-86
@@ -2,120 +2,187 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Annotated
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
from mcp.server.fastmcp.logging import configure_logging
|
from mcp.server.fastmcp import exceptions as mcp_exceptions
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import JSONResponse, Response
|
|
||||||
|
|
||||||
from .client import OpenIsleAPI
|
from .client import BackendClientError, OpenIsleBackendClient
|
||||||
from .config import Settings, get_settings
|
from .models import BackendSearchResult, SearchResponse, SearchResult
|
||||||
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 _create_server(settings: Settings) -> FastMCP:
|
def _env(name: str, default: Optional[str] = None) -> Optional[str]:
|
||||||
"""Instantiate the FastMCP server with configured metadata."""
|
value = os.getenv(name, default)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
trimmed = value.strip()
|
||||||
|
return trimmed or default
|
||||||
|
|
||||||
server = FastMCP(
|
|
||||||
name="OpenIsle MCP",
|
def _load_timeout() -> float:
|
||||||
instructions=(
|
raw = _env("OPENISLE_BACKEND_TIMEOUT", str(DEFAULT_TIMEOUT))
|
||||||
"Access OpenIsle search functionality. Provide a keyword and optionally a scope to "
|
try:
|
||||||
"discover users and posts from the community."
|
timeout = float(raw) if raw is not None else DEFAULT_TIMEOUT
|
||||||
),
|
except ValueError:
|
||||||
host=settings.host,
|
logger.warning("Invalid OPENISLE_BACKEND_TIMEOUT value '%s', falling back to %s", raw, DEFAULT_TIMEOUT)
|
||||||
port=settings.port,
|
return DEFAULT_TIMEOUT
|
||||||
transport_security=None,
|
if timeout <= 0:
|
||||||
|
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"})
|
|
||||||
|
|
||||||
return server
|
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})"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def _execute_search(
|
@server.tool(name="search", description="Search OpenIsle content")
|
||||||
*,
|
async def search(keyword: KeywordParam, limit: LimitParam = DEFAULT_LIMIT, ctx: Optional[Context] = None) -> SearchResponse:
|
||||||
api: OpenIsleAPI,
|
"""Run a search query against the OpenIsle backend."""
|
||||||
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)
|
|
||||||
|
|
||||||
payload = await api.search(scope, keyword)
|
trimmed = keyword.strip()
|
||||||
items = normalise_results(scope, payload)
|
if not trimmed:
|
||||||
return SearchResponse(scope=scope, keyword=keyword, results=items)
|
raise mcp_exceptions.ToolError("Keyword must not be empty")
|
||||||
|
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.debug(f"Searching OpenIsle for '{trimmed}' (limit={limit})")
|
||||||
|
|
||||||
def build_server(settings: Settings | None = None) -> FastMCP:
|
try:
|
||||||
"""Configure and return the FastMCP server instance."""
|
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
|
||||||
|
|
||||||
resolved_settings = settings or get_settings()
|
results = [_to_search_result(result) for result in raw_results]
|
||||||
server = _create_server(resolved_settings)
|
limited = results[:limit]
|
||||||
api_client = OpenIsleAPI(resolved_settings)
|
|
||||||
|
|
||||||
@server.tool(
|
if ctx is not None:
|
||||||
name="openisle_search",
|
await ctx.info(
|
||||||
description="Search OpenIsle for users and posts.",
|
"Search completed",
|
||||||
)
|
keyword=trimmed,
|
||||||
async def openisle_search(
|
total_results=len(results),
|
||||||
keyword: Annotated[str, Field(description="Keyword used to query OpenIsle search.")],
|
returned=len(limited),
|
||||||
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=trimmed, total_results=len(results), limit=limit, results=limited)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""CLI entry point used by the console script."""
|
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",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--host",
|
||||||
|
default=_env("OPENISLE_MCP_HOST", "0.0.0.0"),
|
||||||
|
help="Host to bind when using SSE or Streamable HTTP",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--port",
|
||||||
|
type=int,
|
||||||
|
default=int(_env("OPENISLE_MCP_PORT", "8000")),
|
||||||
|
help="Port to bind when using SSE or Streamable HTTP",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
settings = get_settings()
|
logging.basicConfig(level=os.getenv("OPENISLE_MCP_LOG_LEVEL", "INFO"))
|
||||||
configure_logging("INFO")
|
logger.info(
|
||||||
server = build_server(settings)
|
"Starting OpenIsle MCP server", extra={"transport": args.transport, "backend": _BACKEND_CLIENT.base_url}
|
||||||
|
)
|
||||||
|
|
||||||
transport = os.getenv("OPENISLE_MCP_TRANSPORT", settings.transport)
|
if args.transport == "stdio":
|
||||||
if transport not in {"stdio", "sse", "streamable-http"}:
|
# stdio 模式照旧
|
||||||
raise RuntimeError(f"Unsupported transport mode: {transport}")
|
server.run(transport="stdio")
|
||||||
|
return
|
||||||
|
|
||||||
_logger.info("Starting OpenIsle MCP server on %s:%s via %s", settings.host, settings.port, transport)
|
# SSE / Streamable HTTP:手动跑 uvicorn,显式控制 host/port
|
||||||
|
import uvicorn
|
||||||
if transport == "stdio":
|
if args.transport == "sse":
|
||||||
server.run("stdio")
|
app = server.sse_app(args.mount_path)
|
||||||
elif transport == "sse":
|
elif args.transport == "streamable-http":
|
||||||
mount_path = os.getenv("OPENISLE_MCP_SSE_PATH")
|
app = server.streamable_http_app()
|
||||||
server.run("sse", mount_path=mount_path)
|
|
||||||
else:
|
else:
|
||||||
server.run("streamable-http")
|
raise RuntimeError(f"Unsupported transport: {args.transport}")
|
||||||
|
|
||||||
|
uvicorn.run(app, host=args.host, port=args.port)
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover - manual execution path
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user