Compare commits

..

8 Commits

Author SHA1 Message Date
tim
2e70a3d273 fix: 修改配置 2025-10-27 14:27:17 +08:00
Tim
779bb2db78 Merge pull request #1089 from nagisa77/codex/update-.env.example-for-openisle_mcp_port
Configure MCP proxy routes
2025-10-26 14:26:35 +08:00
Tim
b3b0b194a3 Configure MCP port and nginx proxies 2025-10-26 14:25:09 +08:00
tim
e21b2f42d2 fix: 精简websocket配置 2025-10-26 14:17:38 +08:00
Tim
05a5acee7e Merge pull request #1088 from nagisa77/feature/mcp
Feature/mcp
2025-10-26 14:08:46 +08:00
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
20 changed files with 342 additions and 499 deletions

View File

@@ -2,6 +2,7 @@
SERVER_PORT=8080 SERVER_PORT=8080
FRONTEND_PORT=3000 FRONTEND_PORT=3000
WEBSOCKET_PORT=8082 WEBSOCKET_PORT=8082
OPENISLE_MCP_PORT=8085
MYSQL_PORT=3306 MYSQL_PORT=3306
REDIS_PORT=6379 REDIS_PORT=6379
RABBITMQ_PORT=5672 RABBITMQ_PORT=5672

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 frontend_service mcp
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 frontend_service mysql redis rabbitmq websocket-service springboot frontend_service mcp
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

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 frontend_service mcp
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 frontend_service mysql redis rabbitmq websocket-service springboot frontend_service mcp
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

View File

@@ -178,6 +178,32 @@ services:
- dev - dev
- prod - 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: http://springboot:${SERVER_PORT:-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: 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
@@ -213,30 +239,6 @@ services:
- dev_local_backend - dev_local_backend
- prod - 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: frontend_dev:
image: node:20 image: node:20
container_name: ${COMPOSE_PROJECT_NAME}-openisle-frontend-dev 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 # 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://modelcontextprotocol.io) (MCP) server
The initial release focuses on surfacing the platform's search capabilities so that AI assistants can discover that exposes OpenIsle's search capabilities as MCP tools. The initial release focuses on the
users and posts directly through the existing REST API. Future iterations can expand this service with post global search endpoint so the agent ecosystem can retrieve relevant posts, users, tags, and
creation and other productivity tools. other resources.
## Features ## Configuration
- 🔍 Keyword search across users and posts using the OpenIsle backend APIs The server is configured through environment variables (all prefixed with `OPENISLE_MCP_`):
- ✅ Structured MCP tool response for downstream reasoning
- 🩺 Lightweight health check endpoint (`/health`) for container orchestration | Variable | Default | Description |
- ⚙️ Configurable via environment variables with sensible defaults for Docker Compose | --- | --- | --- |
| `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 ## Running locally
```bash ```bash
cd mcp
pip install . 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 By default the server listens on port `8085` and serves MCP over Streamable HTTP.
`OPENISLE_API_BASE_URL` before starting the service.
## Environment variables ## Available tools
| Variable | Default | Description | | Tool | Description |
| -------- | ------- | ----------- | | --- | --- |
| `OPENISLE_API_BASE_URL` | `http://localhost:8080` | Base URL of the OpenIsle backend API | | `search` | Perform a global search against the OpenIsle backend. |
| `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 |
## 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] [build-system]
requires = ["setuptools>=68", "wheel"] requires = ["hatchling>=1.25"]
build-backend = "setuptools.build_meta" build-backend = "hatchling.build"
[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", email = "engineering@openisle.example" }]
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.29",
"pydantic>=2.7.0" "pydantic>=2.12,<3",
"pydantic-settings>=2.11,<3"
] ]
[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.packages.find] [tool.hatch.build]
where = ["src"] 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.""" """OpenIsle MCP server package."""
from .config import Settings, get_settings from .config import Settings, get_settings
from .models import SearchItem, SearchResponse, SearchScope
__all__ = [ __all__ = ["Settings", "get_settings"]
"Settings",
"get_settings",
"SearchItem",
"SearchResponse",
"SearchScope",
]
__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 from __future__ import annotations
import os
from functools import lru_cache from functools import lru_cache
from typing import Dict, Literal from typing import Literal
from pydantic import AnyHttpUrl, BaseModel, Field, ValidationError from pydantic import Field
from pydantic.networks import AnyHttpUrl
from .models import SearchScope from pydantic_settings import BaseSettings, SettingsConfigDict
TransportType = Literal["stdio", "sse", "streamable-http"]
class Settings(BaseModel): class Settings(BaseSettings):
"""Runtime configuration for the MCP server.""" """Configuration for the MCP server."""
backend_base_url: AnyHttpUrl = Field( backend_base_url: AnyHttpUrl = Field(
default="http://localhost:8080", "http://springboot:8080",
description="Base URL of the OpenIsle backend API.", description="Base URL for the OpenIsle backend service.",
) )
request_timeout_seconds: float = Field( host: str = Field(
default=10.0, "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, gt=0,
description="HTTP timeout when talking to the backend APIs.", description="Timeout (seconds) for backend search requests.",
)
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: model_config = SettingsConfigDict(
"""Return the backend path associated with a given search scope.""" env_prefix="OPENISLE_MCP_",
env_file=".env",
try: env_file_encoding="utf-8",
return self.search_paths[scope.value] case_sensitive=False,
except KeyError as exc: # pragma: no cover - defensive guard )
raise ValueError(f"Unsupported search scope: {scope}") from exc
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def get_settings() -> Settings: def get_settings() -> Settings:
"""Load settings from environment variables with caching.""" """Return cached application settings."""
raw_settings: Dict[str, object] = {} return Settings()
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

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

View File

@@ -100,10 +100,28 @@ server {
# auth_basic_user_file /etc/nginx/.htpasswd; # auth_basic_user_file /etc/nginx/.htpasswd;
} }
# ---------- WEBSOCKET GATEWAY TO :8082 ----------
location ^~ /websocket/ { location ^~ /websocket/ {
proxy_pass http://127.0.0.1:8084/; proxy_pass http://127.0.0.1:8084/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_buffering off;
proxy_cache off;
add_header Cache-Control "no-store" always;
}
location /mcp {
proxy_pass http://127.0.0.1:8085;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;

View File

@@ -8,11 +8,8 @@ server {
listen 443 ssl; listen 443 ssl;
server_name staging.open-isle.com www.staging.open-isle.com; server_name staging.open-isle.com www.staging.open-isle.com;
ssl_certificate /etc/letsencrypt/live/staging.open-isle.com/fullchain.pem; ssl_certificate /etc/letsencrypt/live/staging.open-isle.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/staging.open-isle.com/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/staging.open-isle.com/privkey.pem;
# ssl_certificate /etc/letsencrypt/live/open-isle.com/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/open-isle.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf; include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
@@ -40,59 +37,13 @@ server {
add_header X-Upstream $upstream_addr always; add_header X-Upstream $upstream_addr always;
} }
# 1) 原生 WebSocket
location ^~ /api/ws {
proxy_pass http://127.0.0.1:8081; # 不要尾随 /,保留原样 URI
proxy_http_version 1.1;
# 升级所需
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# 统一透传这些头(你在 /api/ 有,/api/ws 也要有)
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_buffering off;
proxy_cache off;
}
# 2) SockJS包含 /info、/iframe.html、/.../websocket 等)
location ^~ /api/sockjs {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_buffering off;
proxy_cache off;
# 如要同源 iframe 回退,下面两行二选一(或者交给 Spring Security 的 sameOrigin
# proxy_hide_header X-Frame-Options;
# add_header X-Frame-Options "SAMEORIGIN" always;
}
# ---------- API ---------- # ---------- API ----------
location /api/ { location /api/ {
proxy_pass http://127.0.0.1:8081/api/; proxy_pass http://127.0.0.1:8081/api/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade; proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -109,7 +60,6 @@ server {
proxy_cache_bypass 1; proxy_cache_bypass 1;
} }
# ---------- WEBSOCKET GATEWAY TO :8083 ----------
location ^~ /websocket/ { location ^~ /websocket/ {
proxy_pass http://127.0.0.1:8083/; proxy_pass http://127.0.0.1:8083/;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -130,4 +80,24 @@ server {
add_header Cache-Control "no-store" always; add_header Cache-Control "no-store" always;
} }
} location /mcp {
proxy_pass http://127.0.0.1:8086;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_buffering off;
proxy_cache off;
add_header Cache-Control "no-store" always;
}
}