mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-28 09:00:48 +08:00
Compare commits
1 Commits
codex/crea
...
codex/crea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfa7530373 |
@@ -7,15 +7,6 @@ REDIS_PORT=6379
|
||||
RABBITMQ_PORT=5672
|
||||
RABBITMQ_MANAGEMENT_PORT=15672
|
||||
|
||||
# === MCP Server ===
|
||||
OPENISLE_MCP_TRANSPORT=http
|
||||
OPENISLE_MCP_HOST=0.0.0.0
|
||||
OPENISLE_MCP_PORT=8974
|
||||
OPENISLE_API_BASE_URL=http://springboot:8080
|
||||
OPENISLE_API_TIMEOUT=10
|
||||
OPENISLE_MCP_DEFAULT_LIMIT=20
|
||||
OPENISLE_MCP_SNIPPET_LENGTH=160
|
||||
|
||||
# === OpenSearch Configuration ===
|
||||
OPENSEARCH_PORT=9200
|
||||
OPENSEARCH_METRICS_PORT=9600
|
||||
|
||||
@@ -28,7 +28,6 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
|
||||
- 支持图片上传,默认使用腾讯云 COS 扩展
|
||||
- 默认头像使用 DiceBear Avatars,可通过 `AVATAR_STYLE` 和 `AVATAR_SIZE` 环境变量自定义主题和大小
|
||||
- 浏览器推送通知,离开网站也能及时收到提醒
|
||||
- 新增 Python MCP 搜索服务,方便 AI 助手通过统一协议检索社区内容
|
||||
|
||||
## 🌟 项目优势
|
||||
|
||||
|
||||
@@ -178,34 +178,6 @@ services:
|
||||
- dev
|
||||
- prod
|
||||
|
||||
mcp-server:
|
||||
build:
|
||||
context: ../mcp
|
||||
dockerfile: 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_API_TIMEOUT: ${OPENISLE_API_TIMEOUT:-10}
|
||||
OPENISLE_MCP_DEFAULT_LIMIT: ${OPENISLE_MCP_DEFAULT_LIMIT:-20}
|
||||
OPENISLE_MCP_SNIPPET_LENGTH: ${OPENISLE_MCP_SNIPPET_LENGTH:-160}
|
||||
OPENISLE_MCP_TRANSPORT: ${OPENISLE_MCP_TRANSPORT:-http}
|
||||
OPENISLE_MCP_HOST: 0.0.0.0
|
||||
OPENISLE_MCP_PORT: ${OPENISLE_MCP_PORT:-8974}
|
||||
ports:
|
||||
- "${OPENISLE_MCP_PORT:-8974}:${OPENISLE_MCP_PORT:-8974}"
|
||||
depends_on:
|
||||
springboot:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
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
|
||||
@@ -241,6 +213,30 @@ 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
|
||||
|
||||
20
docker/mcp-service.Dockerfile
Normal file
20
docker/mcp-service.Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY mcp/pyproject.toml mcp/README.md ./
|
||||
COPY mcp/src ./src
|
||||
RUN pip install --upgrade pip \
|
||||
&& pip install .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENV OPENISLE_API_BASE_URL=http://springboot:8080 \
|
||||
OPENISLE_MCP_HOST=0.0.0.0 \
|
||||
OPENISLE_MCP_PORT=8000 \
|
||||
OPENISLE_MCP_TRANSPORT=streamable-http
|
||||
|
||||
CMD ["openisle-mcp"]
|
||||
6
mcp/.gitignore
vendored
Normal file
6
mcp/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.build/
|
||||
.venv/
|
||||
.env
|
||||
@@ -1,27 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src ./src
|
||||
|
||||
RUN pip install --upgrade pip \
|
||||
&& pip install --no-cache-dir . \
|
||||
&& pip cache purge
|
||||
|
||||
ENV OPENISLE_MCP_TRANSPORT=http \
|
||||
OPENISLE_MCP_HOST=0.0.0.0 \
|
||||
OPENISLE_MCP_PORT=8974
|
||||
|
||||
EXPOSE 8974
|
||||
|
||||
ENTRYPOINT ["openisle-mcp"]
|
||||
@@ -1,51 +1,45 @@
|
||||
# OpenIsle MCP Server
|
||||
|
||||
This package provides a Python implementation of a Model Context Protocol (MCP) server for OpenIsle. The server focuses on the community search APIs so that AI assistants and other MCP-aware clients can discover OpenIsle users, posts, categories, comments, and tags. Additional capabilities such as content creation tools can be layered on later without changing the transport or deployment model.
|
||||
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.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Implements the MCP tooling interface using [FastMCP](https://github.com/modelcontextprotocol/fastmcp).
|
||||
- 🔍 Exposes a `search` tool that proxies requests to the existing OpenIsle REST endpoints and normalises the response payload.
|
||||
- ⚙️ Configurable through environment variables for API base URL, timeout, result limits, and snippet size.
|
||||
- 🐳 Packaged with a Docker image so it can be launched alongside the other OpenIsle services.
|
||||
- 🔍 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
|
||||
|
||||
## Running locally
|
||||
|
||||
```bash
|
||||
cd mcp
|
||||
pip install .
|
||||
openisle-mcp # starts the MCP server on http://127.0.0.1:8000 by default
|
||||
```
|
||||
|
||||
By default the server targets `http://localhost:8080` for backend requests. Override the target by setting
|
||||
`OPENISLE_API_BASE_URL` before starting the service.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `OPENISLE_API_BASE_URL` | `http://springboot:8080` | Base URL of the OpenIsle backend REST API. |
|
||||
| `OPENISLE_API_TIMEOUT` | `10` | Timeout (in seconds) used when calling the backend search endpoints. |
|
||||
| `OPENISLE_MCP_DEFAULT_LIMIT` | `20` | Default maximum number of search results to return when the caller does not provide a limit. Use `0` or a negative number to disable limiting. |
|
||||
| `OPENISLE_MCP_SNIPPET_LENGTH` | `160` | Maximum length (in characters) of the normalised summary snippet returned by the MCP tool. |
|
||||
| `OPENISLE_MCP_TRANSPORT` | `stdio` | Transport used when running the server directly. Set to `http` when running inside Docker. |
|
||||
| `OPENISLE_MCP_HOST` | `127.0.0.1` | Bind host used when the transport is HTTP/SSE. |
|
||||
| `OPENISLE_MCP_PORT` | `8974` | Bind port used when the transport is HTTP/SSE. |
|
||||
| -------- | ------- | ----------- |
|
||||
| `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 |
|
||||
|
||||
## Local development
|
||||
## Docker
|
||||
|
||||
The repository's Docker Compose stack now includes the MCP server. To start it alongside other services:
|
||||
|
||||
```bash
|
||||
cd mcp
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
pip install -e .
|
||||
OPENISLE_API_BASE_URL=http://localhost:8080 OPENISLE_MCP_TRANSPORT=http openisle-mcp
|
||||
cd docker
|
||||
docker compose --profile dev up mcp-server
|
||||
```
|
||||
|
||||
By default the server listens over stdio, which is useful when integrating with MCP-aware IDEs. When the `OPENISLE_MCP_TRANSPORT` variable is set to `http` the server will expose an HTTP transport on `OPENISLE_MCP_HOST:OPENISLE_MCP_PORT`.
|
||||
|
||||
## Docker image
|
||||
|
||||
The accompanying `Dockerfile` builds a minimal image that installs the package and starts the MCP server. The root Docker Compose manifest is configured to launch this service and connect it to the same internal network as the Spring Boot API so the MCP tools can reach the search endpoints.
|
||||
|
||||
## MCP tool contract
|
||||
|
||||
The `search` tool accepts the following arguments:
|
||||
|
||||
- `keyword` (string, required): Search phrase passed directly to the OpenIsle API.
|
||||
- `scope` (string, optional): One of `global`, `posts`, `posts_content`, `posts_title`, or `users`. Defaults to `global`.
|
||||
- `limit` (integer, optional): Overrides the default limit from `OPENISLE_MCP_DEFAULT_LIMIT`.
|
||||
|
||||
The tool returns a JSON object containing both the raw API response and a normalised representation with concise titles, subtitles, and snippets for each result.
|
||||
|
||||
Future tools (for example posting or moderation helpers) can be added within this package and exposed via additional decorators without changing the deployment setup.
|
||||
The service exposes port `8000` by default. Update `OPENISLE_MCP_PORT` to customize the mapped port.
|
||||
|
||||
@@ -1,30 +1,28 @@
|
||||
[build-system]
|
||||
requires = ["hatchling>=1.25.0"]
|
||||
build-backend = "hatchling.build"
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "openisle-mcp"
|
||||
version = "0.1.0"
|
||||
description = "Model Context Protocol server exposing OpenIsle search functionality."
|
||||
description = "Model Context Protocol server exposing OpenIsle search capabilities."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
authors = [{name = "OpenIsle Contributors"}]
|
||||
requires-python = ">=3.11"
|
||||
authors = [{ name = "OpenIsle" }]
|
||||
dependencies = [
|
||||
"fastmcp>=2.12.5",
|
||||
"mcp>=1.19.0",
|
||||
"httpx>=0.28.1",
|
||||
"pydantic>=2.7",
|
||||
"pydantic>=2.7.0"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/openisle/openisle"
|
||||
|
||||
[project.scripts]
|
||||
openisle-mcp = "openisle_mcp.server:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/openisle_mcp"]
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = [
|
||||
"src/openisle_mcp",
|
||||
"README.md",
|
||||
"pyproject.toml",
|
||||
]
|
||||
[tool.setuptools.package-data]
|
||||
openisle_mcp = ["py.typed"]
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
"""OpenIsle MCP server package."""
|
||||
|
||||
from .server import main
|
||||
from .config import Settings, get_settings
|
||||
from .models import SearchItem, SearchResponse, SearchScope
|
||||
|
||||
__all__ = ["main"]
|
||||
__all__ = [
|
||||
"Settings",
|
||||
"get_settings",
|
||||
"SearchItem",
|
||||
"SearchResponse",
|
||||
"SearchScope",
|
||||
]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
@@ -1,218 +1,33 @@
|
||||
"""HTTP client wrappers for interacting with the OpenIsle backend."""
|
||||
"""HTTP client helpers for interacting with the OpenIsle backend APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
from typing import Any, Iterable
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .models import NormalizedSearchResult, SearchResponse, SearchScope
|
||||
from .settings import Settings
|
||||
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
_WHITESPACE_RE = re.compile(r"\s+")
|
||||
from .config import Settings, get_settings
|
||||
from .models import SearchScope
|
||||
|
||||
|
||||
class SearchClient:
|
||||
"""High level client around the OpenIsle search API."""
|
||||
class OpenIsleAPI:
|
||||
"""Thin wrapper around the OpenIsle REST API used by the MCP server."""
|
||||
|
||||
_ENDPOINTS: dict[SearchScope, str] = {
|
||||
SearchScope.GLOBAL: "/api/search/global",
|
||||
SearchScope.POSTS: "/api/search/posts",
|
||||
SearchScope.POSTS_CONTENT: "/api/search/posts/content",
|
||||
SearchScope.POSTS_TITLE: "/api/search/posts/title",
|
||||
SearchScope.USERS: "/api/search/users",
|
||||
}
|
||||
def __init__(self, settings: Settings | None = None) -> None:
|
||||
self._settings = settings or get_settings()
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._base_url = settings.sanitized_base_url()
|
||||
self._timeout = settings.request_timeout
|
||||
self._default_limit = settings.default_limit
|
||||
self._snippet_length = settings.snippet_length
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self._base_url,
|
||||
timeout=self._timeout,
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
async def search(self, scope: SearchScope, keyword: str) -> list[Any]:
|
||||
"""Execute a search request against the backend API."""
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
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()
|
||||
|
||||
def endpoint_path(self, scope: SearchScope) -> str:
|
||||
return self._ENDPOINTS[scope]
|
||||
|
||||
def endpoint_url(self, scope: SearchScope) -> str:
|
||||
return f"{self._base_url}{self.endpoint_path(scope)}"
|
||||
|
||||
async def search(
|
||||
self,
|
||||
keyword: str,
|
||||
scope: SearchScope,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> SearchResponse:
|
||||
"""Execute a search request and normalise the results."""
|
||||
|
||||
keyword = keyword.strip()
|
||||
effective_limit = self._resolve_limit(limit)
|
||||
|
||||
if not keyword:
|
||||
return SearchResponse(
|
||||
keyword=keyword,
|
||||
scope=scope,
|
||||
endpoint=self.endpoint_url(scope),
|
||||
limit=effective_limit,
|
||||
total_results=0,
|
||||
returned_results=0,
|
||||
normalized=[],
|
||||
raw=[],
|
||||
)
|
||||
|
||||
response = await self._client.get(
|
||||
self.endpoint_path(scope),
|
||||
params={"keyword": keyword},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list): # pragma: no cover - defensive programming
|
||||
raise ValueError("Search API did not return a JSON array")
|
||||
|
||||
total_results = len(payload)
|
||||
items = payload if effective_limit is None else payload[:effective_limit]
|
||||
normalized = [self._normalise_item(scope, item) for item in items]
|
||||
|
||||
return SearchResponse(
|
||||
keyword=keyword,
|
||||
scope=scope,
|
||||
endpoint=self.endpoint_url(scope),
|
||||
limit=effective_limit,
|
||||
total_results=total_results,
|
||||
returned_results=len(items),
|
||||
normalized=normalized,
|
||||
raw=items,
|
||||
)
|
||||
|
||||
def _resolve_limit(self, requested: int | None) -> int | None:
|
||||
value = requested if requested is not None else self._default_limit
|
||||
if value is None:
|
||||
return None
|
||||
if value <= 0:
|
||||
return None
|
||||
return value
|
||||
|
||||
def _normalise_item(
|
||||
self,
|
||||
scope: SearchScope,
|
||||
item: Any,
|
||||
) -> NormalizedSearchResult:
|
||||
"""Normalise raw API objects into a consistent structure."""
|
||||
|
||||
if not isinstance(item, dict): # pragma: no cover - defensive programming
|
||||
return NormalizedSearchResult(type=scope.value, metadata={"raw": item})
|
||||
|
||||
if scope == SearchScope.GLOBAL:
|
||||
return self._normalise_global(item)
|
||||
if scope in {SearchScope.POSTS, SearchScope.POSTS_CONTENT, SearchScope.POSTS_TITLE}:
|
||||
return self._normalise_post(item)
|
||||
if scope == SearchScope.USERS:
|
||||
return self._normalise_user(item)
|
||||
return NormalizedSearchResult(type=scope.value, metadata=item)
|
||||
|
||||
def _normalise_global(self, item: dict[str, Any]) -> NormalizedSearchResult:
|
||||
highlights = {
|
||||
"title": item.get("highlightedText"),
|
||||
"subtitle": item.get("highlightedSubText"),
|
||||
"snippet": item.get("highlightedExtra"),
|
||||
}
|
||||
snippet_source = highlights.get("snippet") or item.get("extra")
|
||||
metadata = {
|
||||
"postId": item.get("postId"),
|
||||
"highlights": {k: v for k, v in highlights.items() if v},
|
||||
}
|
||||
return NormalizedSearchResult(
|
||||
type=str(item.get("type", "result")),
|
||||
id=_safe_int(item.get("id")),
|
||||
title=highlights.get("title") or _safe_str(item.get("text")),
|
||||
subtitle=highlights.get("subtitle") or _safe_str(item.get("subText")),
|
||||
snippet=self._snippet(snippet_source),
|
||||
metadata={k: v for k, v in metadata.items() if v not in (None, {}, [])},
|
||||
)
|
||||
|
||||
def _normalise_post(self, item: dict[str, Any]) -> NormalizedSearchResult:
|
||||
author = _safe_dict(item.get("author"))
|
||||
category = _safe_dict(item.get("category"))
|
||||
tags = [tag.get("name") for tag in _safe_iter(item.get("tags")) if isinstance(tag, dict)]
|
||||
metadata = {
|
||||
"author": author.get("username"),
|
||||
"category": category.get("name"),
|
||||
"tags": tags,
|
||||
"views": item.get("views"),
|
||||
"commentCount": item.get("commentCount"),
|
||||
"status": item.get("status"),
|
||||
"apiUrl": f"{self._base_url}/api/posts/{item.get('id')}" if item.get("id") else None,
|
||||
}
|
||||
return NormalizedSearchResult(
|
||||
type="post",
|
||||
id=_safe_int(item.get("id")),
|
||||
title=_safe_str(item.get("title")),
|
||||
subtitle=_safe_str(category.get("name")),
|
||||
snippet=self._snippet(item.get("content")),
|
||||
metadata={k: v for k, v in metadata.items() if v not in (None, [], {})},
|
||||
)
|
||||
|
||||
def _normalise_user(self, item: dict[str, Any]) -> NormalizedSearchResult:
|
||||
metadata = {
|
||||
"followers": item.get("followers"),
|
||||
"following": item.get("following"),
|
||||
"totalViews": item.get("totalViews"),
|
||||
"role": item.get("role"),
|
||||
"subscribed": item.get("subscribed"),
|
||||
"apiUrl": f"{self._base_url}/api/users/{item.get('id')}" if item.get("id") else None,
|
||||
}
|
||||
return NormalizedSearchResult(
|
||||
type="user",
|
||||
id=_safe_int(item.get("id")),
|
||||
title=_safe_str(item.get("username")),
|
||||
subtitle=_safe_str(item.get("email") or item.get("role")),
|
||||
snippet=self._snippet(item.get("introduction")),
|
||||
metadata={k: v for k, v in metadata.items() if v not in (None, [], {})},
|
||||
)
|
||||
|
||||
def _snippet(self, value: Any) -> str | None:
|
||||
text = _safe_str(value)
|
||||
if not text:
|
||||
return None
|
||||
text = html.unescape(text)
|
||||
text = _TAG_RE.sub(" ", text)
|
||||
text = _WHITESPACE_RE.sub(" ", text).strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) <= self._snippet_length:
|
||||
return text
|
||||
return text[: self._snippet_length - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError): # pragma: no cover - defensive
|
||||
return None
|
||||
|
||||
|
||||
def _safe_str(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _safe_dict(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _safe_iter(value: Any) -> Iterable[Any]:
|
||||
if isinstance(value, list | tuple | set):
|
||||
return value
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
raise RuntimeError("Unexpected search response payload: expected a list")
|
||||
return data
|
||||
|
||||
83
mcp/src/openisle_mcp/config.py
Normal file
83
mcp/src/openisle_mcp/config.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Configuration helpers for the OpenIsle MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from typing import Dict, Literal
|
||||
|
||||
from pydantic import AnyHttpUrl, BaseModel, Field, ValidationError
|
||||
|
||||
from .models import SearchScope
|
||||
|
||||
TransportType = Literal["stdio", "sse", "streamable-http"]
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
"""Runtime configuration for the MCP server."""
|
||||
|
||||
backend_base_url: AnyHttpUrl = Field(
|
||||
default="http://localhost:8080",
|
||||
description="Base URL of the OpenIsle backend API.",
|
||||
)
|
||||
request_timeout_seconds: float = Field(
|
||||
default=10.0,
|
||||
gt=0,
|
||||
description="HTTP timeout when talking to the backend APIs.",
|
||||
)
|
||||
transport: TransportType = Field(
|
||||
default="streamable-http",
|
||||
description="Transport mode for the MCP server.",
|
||||
)
|
||||
host: str = Field(default="127.0.0.1", description="Hostname/interface used by the MCP HTTP server.")
|
||||
port: int = Field(default=8000, ge=0, description="Port used by the MCP HTTP server.")
|
||||
search_paths: Dict[str, str] = Field(
|
||||
default_factory=lambda: {
|
||||
SearchScope.GLOBAL.value: "/api/search/global",
|
||||
SearchScope.USERS.value: "/api/search/users",
|
||||
SearchScope.POSTS.value: "/api/search/posts",
|
||||
SearchScope.POSTS_TITLE.value: "/api/search/posts/title",
|
||||
SearchScope.POSTS_CONTENT.value: "/api/search/posts/content",
|
||||
},
|
||||
description="Mapping between search scopes and backend API paths.",
|
||||
)
|
||||
|
||||
def get_search_path(self, scope: SearchScope) -> str:
|
||||
"""Return the backend path associated with a given search scope."""
|
||||
|
||||
try:
|
||||
return self.search_paths[scope.value]
|
||||
except KeyError as exc: # pragma: no cover - defensive guard
|
||||
raise ValueError(f"Unsupported search scope: {scope}") from exc
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
"""Load settings from environment variables with caching."""
|
||||
|
||||
raw_settings: Dict[str, object] = {}
|
||||
|
||||
backend_url = os.getenv("OPENISLE_API_BASE_URL")
|
||||
if backend_url:
|
||||
raw_settings["backend_base_url"] = backend_url
|
||||
|
||||
timeout = os.getenv("OPENISLE_MCP_TIMEOUT_SECONDS")
|
||||
if timeout:
|
||||
raw_settings["request_timeout_seconds"] = float(timeout)
|
||||
|
||||
transport = os.getenv("OPENISLE_MCP_TRANSPORT")
|
||||
if transport:
|
||||
raw_settings["transport"] = transport
|
||||
|
||||
host = os.getenv("OPENISLE_MCP_HOST")
|
||||
if host:
|
||||
raw_settings["host"] = host
|
||||
|
||||
port = os.getenv("OPENISLE_MCP_PORT")
|
||||
if port:
|
||||
raw_settings["port"] = int(port)
|
||||
|
||||
try:
|
||||
return Settings(**raw_settings)
|
||||
except (ValidationError, ValueError) as exc: # pragma: no cover - configuration errors should surface clearly
|
||||
raise RuntimeError(f"Invalid MCP configuration: {exc}") from exc
|
||||
@@ -1,71 +1,45 @@
|
||||
"""Shared models for the OpenIsle MCP server."""
|
||||
"""Data models for the OpenIsle MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SearchScope(str, Enum):
|
||||
"""Supported search endpoints."""
|
||||
"""Supported search scopes exposed via the MCP tool."""
|
||||
|
||||
GLOBAL = "global"
|
||||
POSTS = "posts"
|
||||
POSTS_CONTENT = "posts_content"
|
||||
POSTS_TITLE = "posts_title"
|
||||
USERS = "users"
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - convenience for logging
|
||||
return self.value
|
||||
POSTS = "posts"
|
||||
POSTS_TITLE = "posts_title"
|
||||
POSTS_CONTENT = "posts_content"
|
||||
|
||||
|
||||
class NormalizedSearchResult(BaseModel):
|
||||
"""Compact structure returned by the MCP search tool."""
|
||||
class Highlight(BaseModel):
|
||||
"""Highlighted fragments returned by the backend search API."""
|
||||
|
||||
type: str = Field(description="Entity type, e.g. user, post, comment.")
|
||||
id: int | None = Field(default=None, description="Primary identifier of the entity.")
|
||||
title: str | None = Field(default=None, description="Display title for the result.")
|
||||
subtitle: str | None = Field(default=None, description="Secondary line of context.")
|
||||
snippet: str | None = Field(default=None, description="Short summary of the result.")
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Additional attributes extracted from the API response.",
|
||||
)
|
||||
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.")
|
||||
|
||||
model_config = {
|
||||
"extra": "ignore",
|
||||
}
|
||||
|
||||
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):
|
||||
"""Payload returned to MCP clients."""
|
||||
"""Structured response returned by the MCP search tool."""
|
||||
|
||||
keyword: str
|
||||
scope: SearchScope
|
||||
endpoint: str
|
||||
limit: int | None = Field(
|
||||
default=None,
|
||||
description="Result limit applied to the request. None means unlimited.",
|
||||
)
|
||||
total_results: int = Field(
|
||||
default=0,
|
||||
description="Total number of items returned by the OpenIsle API before limiting.",
|
||||
)
|
||||
returned_results: int = Field(
|
||||
default=0,
|
||||
description="Number of items returned to the MCP client after limiting.",
|
||||
)
|
||||
normalized: list[NormalizedSearchResult] = Field(
|
||||
default_factory=list,
|
||||
description="Normalised representation of each search hit.",
|
||||
)
|
||||
raw: list[Any] = Field(
|
||||
default_factory=list,
|
||||
description="Raw response objects from the OpenIsle REST API.",
|
||||
)
|
||||
|
||||
model_config = {
|
||||
"extra": "ignore",
|
||||
}
|
||||
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.")
|
||||
|
||||
0
mcp/src/openisle_mcp/py.typed
Normal file
0
mcp/src/openisle_mcp/py.typed
Normal file
100
mcp/src/openisle_mcp/search.py
Normal file
100
mcp/src/openisle_mcp/search.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Utilities for normalising OpenIsle search results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .models import Highlight, SearchItem, SearchScope
|
||||
|
||||
|
||||
def _truncate(text: str | None, *, limit: int = 240) -> str | None:
|
||||
"""Compress whitespace and truncate overly long text fragments."""
|
||||
|
||||
if not text:
|
||||
return None
|
||||
compact = re.sub(r"\s+", " ", text).strip()
|
||||
if len(compact) <= limit:
|
||||
return compact
|
||||
return f"{compact[:limit - 1]}…"
|
||||
|
||||
|
||||
def _extract_highlight(data: dict[str, Any]) -> Highlight | None:
|
||||
highlighted = {
|
||||
"text": data.get("highlightedText"),
|
||||
"sub_text": data.get("highlightedSubText"),
|
||||
"extra": data.get("highlightedExtra"),
|
||||
}
|
||||
if any(highlighted.values()):
|
||||
return Highlight(**highlighted)
|
||||
return None
|
||||
|
||||
|
||||
def normalise_results(scope: SearchScope, payload: Iterable[dict[str, Any]]) -> list[SearchItem]:
|
||||
"""Convert backend payloads into :class:`SearchItem` entries."""
|
||||
|
||||
normalised: list[SearchItem] = []
|
||||
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
if scope is SearchScope.GLOBAL:
|
||||
normalised.append(
|
||||
SearchItem(
|
||||
category=item.get("type", scope.value),
|
||||
title=_truncate(item.get("text")),
|
||||
description=_truncate(item.get("subText")),
|
||||
metadata={
|
||||
"id": item.get("id"),
|
||||
"postId": item.get("postId"),
|
||||
"extra": item.get("extra"),
|
||||
},
|
||||
highlights=_extract_highlight(item),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if scope in {SearchScope.POSTS, SearchScope.POSTS_CONTENT, SearchScope.POSTS_TITLE}:
|
||||
author = item.get("author") or {}
|
||||
category = item.get("category") or {}
|
||||
metadata = {
|
||||
"id": item.get("id"),
|
||||
"author": author.get("username"),
|
||||
"category": category.get("name"),
|
||||
"views": item.get("views"),
|
||||
"commentCount": item.get("commentCount"),
|
||||
"tags": [tag.get("name") for tag in item.get("tags", []) if isinstance(tag, dict)],
|
||||
}
|
||||
normalised.append(
|
||||
SearchItem(
|
||||
category="post",
|
||||
title=_truncate(item.get("title")),
|
||||
description=_truncate(item.get("content")),
|
||||
metadata={k: v for k, v in metadata.items() if v is not None},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if scope is SearchScope.USERS:
|
||||
metadata = {
|
||||
"id": item.get("id"),
|
||||
"email": item.get("email"),
|
||||
"followers": item.get("followers"),
|
||||
"following": item.get("following"),
|
||||
"role": item.get("role"),
|
||||
}
|
||||
normalised.append(
|
||||
SearchItem(
|
||||
category="user",
|
||||
title=_truncate(item.get("username")),
|
||||
description=_truncate(item.get("introduction")),
|
||||
metadata={k: v for k, v in metadata.items() if v is not None},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Fallback: include raw entry to aid debugging of unsupported scopes
|
||||
normalised.append(SearchItem(category=scope.value, metadata=item))
|
||||
|
||||
return normalised
|
||||
@@ -1,95 +1,121 @@
|
||||
"""Entrypoint for the OpenIsle MCP server."""
|
||||
"""Entry point for the OpenIsle MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
from fastmcp import Context, FastMCP
|
||||
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 .client import SearchClient
|
||||
from .client import OpenIsleAPI
|
||||
from .config import Settings, get_settings
|
||||
from .models import SearchResponse, SearchScope
|
||||
from .settings import Settings
|
||||
from .search import normalise_results
|
||||
|
||||
__all__ = ["main"]
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _create_lifespan(settings: Settings):
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastMCP):
|
||||
client = SearchClient(settings)
|
||||
setattr(app, "_search_client", client)
|
||||
try:
|
||||
yield {"client": client}
|
||||
finally:
|
||||
await client.aclose()
|
||||
if hasattr(app, "_search_client"):
|
||||
delattr(app, "_search_client")
|
||||
def _create_server(settings: Settings) -> FastMCP:
|
||||
"""Instantiate the FastMCP server with configured metadata."""
|
||||
|
||||
return lifespan
|
||||
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
|
||||
|
||||
|
||||
_settings = Settings.from_env()
|
||||
|
||||
mcp = FastMCP(
|
||||
name="OpenIsle Search",
|
||||
version="0.1.0",
|
||||
instructions=(
|
||||
"Provides access to OpenIsle search endpoints for retrieving users, posts, "
|
||||
"comments, tags, and categories."
|
||||
),
|
||||
lifespan=_create_lifespan(_settings),
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool("search")
|
||||
async def search(
|
||||
async def _execute_search(
|
||||
*,
|
||||
api: OpenIsleAPI,
|
||||
scope: SearchScope,
|
||||
keyword: str,
|
||||
scope: SearchScope = SearchScope.GLOBAL,
|
||||
limit: int | None = None,
|
||||
ctx: Context | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Perform a search against the OpenIsle backend."""
|
||||
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)
|
||||
|
||||
client = _resolve_client(ctx)
|
||||
try:
|
||||
response: SearchResponse = await client.search(keyword=keyword, scope=scope, limit=limit)
|
||||
except httpx.HTTPError as exc:
|
||||
message = f"OpenIsle search request failed: {exc}".rstrip()
|
||||
raise RuntimeError(message) from exc
|
||||
|
||||
payload = response.model_dump()
|
||||
payload["transport"] = {
|
||||
"scope": scope.value,
|
||||
"endpoint": client.endpoint_url(scope),
|
||||
}
|
||||
return payload
|
||||
payload = await api.search(scope, keyword)
|
||||
items = normalise_results(scope, payload)
|
||||
return SearchResponse(scope=scope, keyword=keyword, results=items)
|
||||
|
||||
|
||||
def _resolve_client(ctx: Context | None) -> SearchClient:
|
||||
app = ctx.fastmcp if ctx is not None else mcp
|
||||
client = getattr(app, "_search_client", None)
|
||||
if client is None:
|
||||
raise RuntimeError("Search client is not initialised; lifespan hook not executed")
|
||||
return client
|
||||
def build_server(settings: Settings | None = None) -> FastMCP:
|
||||
"""Configure and return the FastMCP server instance."""
|
||||
|
||||
resolved_settings = settings or get_settings()
|
||||
server = _create_server(resolved_settings)
|
||||
api_client = OpenIsleAPI(resolved_settings)
|
||||
|
||||
@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
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entrypoint."""
|
||||
"""CLI entry point used by the console script."""
|
||||
|
||||
transport = os.getenv("OPENISLE_MCP_TRANSPORT", "stdio").strip().lower()
|
||||
show_banner = os.getenv("OPENISLE_MCP_SHOW_BANNER", "true").lower() in {"1", "true", "yes"}
|
||||
run_kwargs: dict[str, Any] = {"show_banner": show_banner}
|
||||
settings = get_settings()
|
||||
configure_logging("INFO")
|
||||
server = build_server(settings)
|
||||
|
||||
if transport in {"http", "sse", "streamable-http"}:
|
||||
host = os.getenv("OPENISLE_MCP_HOST", "127.0.0.1")
|
||||
port = int(os.getenv("OPENISLE_MCP_PORT", "8974"))
|
||||
run_kwargs.update({"host": host, "port": port})
|
||||
transport = os.getenv("OPENISLE_MCP_TRANSPORT", settings.transport)
|
||||
if transport not in {"stdio", "sse", "streamable-http"}:
|
||||
raise RuntimeError(f"Unsupported transport mode: {transport}")
|
||||
|
||||
mcp.run(transport=transport, **run_kwargs)
|
||||
_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 guard
|
||||
if __name__ == "__main__": # pragma: no cover - manual execution path
|
||||
main()
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
"""Environment configuration for the MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError, field_validator
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
"""Runtime configuration sourced from environment variables."""
|
||||
|
||||
api_base_url: str = Field(
|
||||
default="http://springboot:8080",
|
||||
description="Base URL of the OpenIsle backend REST API.",
|
||||
)
|
||||
request_timeout: float = Field(
|
||||
default=10.0,
|
||||
description="Timeout in seconds for outgoing HTTP requests.",
|
||||
ge=0.1,
|
||||
)
|
||||
default_limit: int = Field(
|
||||
default=20,
|
||||
description="Default maximum number of results returned by the search tool.",
|
||||
)
|
||||
snippet_length: int = Field(
|
||||
default=160,
|
||||
description="Maximum length for the normalised snippet field.",
|
||||
ge=40,
|
||||
)
|
||||
|
||||
model_config = {
|
||||
"extra": "ignore",
|
||||
"validate_assignment": True,
|
||||
}
|
||||
|
||||
@field_validator("api_base_url", mode="before")
|
||||
@classmethod
|
||||
def _strip_trailing_slash(cls, value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if value.endswith("/"):
|
||||
return value.rstrip("/")
|
||||
return value
|
||||
|
||||
@field_validator("default_limit", mode="before")
|
||||
@classmethod
|
||||
def _parse_default_limit(cls, value: Any) -> Any:
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError as exc: # pragma: no cover - defensive
|
||||
raise ValueError("default_limit must be an integer") from exc
|
||||
return value
|
||||
|
||||
@field_validator("snippet_length", mode="before")
|
||||
@classmethod
|
||||
def _parse_snippet_length(cls, value: Any) -> Any:
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError as exc: # pragma: no cover - defensive
|
||||
raise ValueError("snippet_length must be an integer") from exc
|
||||
return value
|
||||
|
||||
@field_validator("request_timeout", mode="before")
|
||||
@classmethod
|
||||
def _parse_timeout(cls, value: Any) -> Any:
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError as exc: # pragma: no cover - defensive
|
||||
raise ValueError("request_timeout must be a number") from exc
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Settings":
|
||||
"""Build a settings object using environment variables."""
|
||||
|
||||
data: dict[str, Any] = {}
|
||||
mapping = {
|
||||
"api_base_url": "OPENISLE_API_BASE_URL",
|
||||
"request_timeout": "OPENISLE_API_TIMEOUT",
|
||||
"default_limit": "OPENISLE_MCP_DEFAULT_LIMIT",
|
||||
"snippet_length": "OPENISLE_MCP_SNIPPET_LENGTH",
|
||||
}
|
||||
for field, env_key in mapping.items():
|
||||
value = os.getenv(env_key)
|
||||
if value is not None and value != "":
|
||||
data[field] = value
|
||||
try:
|
||||
return cls.model_validate(data)
|
||||
except ValidationError as exc: # pragma: no cover - validation errors surface early
|
||||
raise ValueError(
|
||||
"Invalid MCP settings derived from environment variables"
|
||||
) from exc
|
||||
|
||||
def sanitized_base_url(self) -> str:
|
||||
"""Return the API base URL without trailing slashes."""
|
||||
|
||||
return self.api_base_url.rstrip("/")
|
||||
Reference in New Issue
Block a user