Compare commits

...

15 Commits

Author SHA1 Message Date
Tim
353041f929 feat(mcp): add post detail retrieval tool 2025-10-27 20:19:01 +08:00
Tim
26ca9fc916 Merge pull request #1093 from nagisa77/codex/add-reply-and-recent-post-query-apis 2025-10-27 16:13:22 +08:00
Tim
cad70c23b3 feat(mcp): add comment reply and recent posts tools 2025-10-27 16:13:06 +08:00
Tim
016276dbc3 Merge pull request #1092 from nagisa77/codex/add-endpoints-for-post-and-comment-queries 2025-10-27 16:06:18 +08:00
Tim
bd2d6e7485 Add recent post and comment context APIs 2025-10-27 16:05:40 +08:00
Tim
df59a9fd4b Merge pull request #1091 from nagisa77/feature/nginx
fix: 修改配置
2025-10-27 14:28:18 +08:00
tim
2e70a3d273 fix: 修改配置 2025-10-27 14:27:17 +08:00
Tim
3dc6935d19 Merge pull request #1090 from nagisa77/feature/nginx
Feature/nginx
2025-10-26 14:28:05 +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
21 changed files with 1019 additions and 60 deletions

View File

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

View File

@@ -1,11 +1,13 @@
package com.openisle.controller;
import com.openisle.dto.CommentContextDto;
import com.openisle.dto.CommentDto;
import com.openisle.dto.CommentRequest;
import com.openisle.dto.PostChangeLogDto;
import com.openisle.dto.TimelineItemDto;
import com.openisle.mapper.CommentMapper;
import com.openisle.mapper.PostChangeLogMapper;
import com.openisle.mapper.PostMapper;
import com.openisle.model.Comment;
import com.openisle.model.CommentSort;
import com.openisle.service.*;
@@ -40,6 +42,7 @@ public class CommentController {
private final PointService pointService;
private final PostChangeLogService changeLogService;
private final PostChangeLogMapper postChangeLogMapper;
private final PostMapper postMapper;
@Value("${app.captcha.enabled:false}")
private boolean captchaEnabled;
@@ -184,6 +187,37 @@ public class CommentController {
return itemDtoList;
}
@GetMapping("/comments/{commentId}/context")
@Operation(
summary = "Comment context",
description = "Get a comment along with its previous comments and related post"
)
@ApiResponse(
responseCode = "200",
description = "Comment context",
content = @Content(schema = @Schema(implementation = CommentContextDto.class))
)
public ResponseEntity<CommentContextDto> getCommentContext(@PathVariable Long commentId) {
log.debug("getCommentContext called for comment {}", commentId);
Comment comment = commentService.getComment(commentId);
CommentContextDto dto = new CommentContextDto();
dto.setPost(postMapper.toSummaryDto(comment.getPost()));
dto.setTargetComment(commentMapper.toDtoWithReplies(comment));
dto.setPreviousComments(
commentService
.getCommentsBefore(comment)
.stream()
.map(commentMapper::toDtoWithReplies)
.collect(Collectors.toList())
);
log.debug(
"getCommentContext returning {} previous comments for comment {}",
dto.getPreviousComments().size(),
commentId
);
return ResponseEntity.ok(dto);
}
@DeleteMapping("/comments/{id}")
@Operation(summary = "Delete comment", description = "Delete a comment")
@ApiResponse(responseCode = "200", description = "Deleted")

View File

@@ -224,6 +224,26 @@ public class PostController {
.collect(Collectors.toList());
}
@GetMapping("/recent")
@Operation(
summary = "Recent posts",
description = "List posts created within the specified number of minutes"
)
@ApiResponse(
responseCode = "200",
description = "Recent posts",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
)
)
public List<PostSummaryDto> recentPosts(@RequestParam("minutes") int minutes) {
return postService
.listRecentPosts(minutes)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
@GetMapping("/ranking")
@Operation(summary = "Ranking posts", description = "List posts by view rankings")
@ApiResponse(

View File

@@ -0,0 +1,15 @@
package com.openisle.dto;
import java.util.List;
import lombok.Data;
/**
* DTO representing the context of a comment including its post and previous comments.
*/
@Data
public class CommentContextDto {
private PostSummaryDto post;
private CommentDto targetComment;
private List<CommentDto> previousComments;
}

View File

@@ -3,6 +3,7 @@ package com.openisle.repository;
import com.openisle.model.Comment;
import com.openisle.model.Post;
import com.openisle.model.User;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -10,6 +11,10 @@ import org.springframework.data.jpa.repository.JpaRepository;
public interface CommentRepository extends JpaRepository<Comment, Long> {
List<Comment> findByPostAndParentIsNullOrderByCreatedAtAsc(Post post);
List<Comment> findByParentOrderByCreatedAtAsc(Comment parent);
List<Comment> findByPostAndCreatedAtLessThanOrderByCreatedAtAsc(
Post post,
LocalDateTime createdAt
);
List<Comment> findByAuthorOrderByCreatedAtDesc(User author, Pageable pageable);
List<Comment> findByContentContainingIgnoreCase(String keyword);

View File

@@ -19,6 +19,10 @@ public interface PostRepository extends JpaRepository<Post, Long> {
List<Post> findByStatusOrderByCreatedAtDesc(PostStatus status, Pageable pageable);
List<Post> findByStatusOrderByViewsDesc(PostStatus status);
List<Post> findByStatusOrderByViewsDesc(PostStatus status, Pageable pageable);
List<Post> findByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
PostStatus status,
LocalDateTime createdAt
);
List<Post> findByAuthorAndStatusOrderByCreatedAtDesc(
User author,
PostStatus status,

View File

@@ -266,6 +266,27 @@ public class CommentService {
return replies;
}
public Comment getComment(Long commentId) {
log.debug("getComment called for id {}", commentId);
return commentRepository
.findById(commentId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
}
public List<Comment> getCommentsBefore(Comment comment) {
log.debug("getCommentsBefore called for comment {}", comment.getId());
List<Comment> comments = commentRepository.findByPostAndCreatedAtLessThanOrderByCreatedAtAsc(
comment.getPost(),
comment.getCreatedAt()
);
log.debug(
"getCommentsBefore returning {} comments for comment {}",
comments.size(),
comment.getId()
);
return comments;
}
public List<Comment> getRecentCommentsByUser(String username, int limit) {
log.debug("getRecentCommentsByUser called for user {} with limit {}", username, limit);
User user = userRepository

View File

@@ -770,6 +770,18 @@ public class PostService {
return listPostsByCategories(null, null, null);
}
public List<Post> listRecentPosts(int minutes) {
if (minutes <= 0) {
throw new IllegalArgumentException("Minutes must be positive");
}
LocalDateTime since = LocalDateTime.now().minusMinutes(minutes);
List<Post> posts = postRepository.findByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
PostStatus.PUBLISHED,
since
);
return sortByPinnedAndCreated(posts);
}
public List<Post> listPostsByViews(Integer page, Integer pageSize) {
return listPostsByViews(null, null, page, pageSize);
}

View File

@@ -40,12 +40,12 @@ echo "👉 Build images ..."
docker compose -f "$compose_file" --env-file "$env_file" \
build --pull \
--build-arg NUXT_ENV=production \
frontend_service
frontend_service mcp
echo "👉 Recreate & start all target services (no dev profile)..."
docker compose -f "$compose_file" --env-file "$env_file" \
up -d --force-recreate --remove-orphans --no-deps \
mysql redis rabbitmq websocket-service springboot frontend_service
mysql redis rabbitmq websocket-service springboot frontend_service mcp
echo "👉 Current status:"
docker compose -f "$compose_file" --env-file "$env_file" ps

View File

@@ -39,12 +39,12 @@ echo "👉 Build images (staging)..."
docker compose -f "$compose_file" --env-file "$env_file" \
build --pull \
--build-arg NUXT_ENV=staging \
frontend_service
frontend_service mcp
echo "👉 Recreate & start all target services (no dev profile)..."
docker compose -f "$compose_file" --env-file "$env_file" \
up -d --force-recreate --remove-orphans --no-deps \
mysql redis rabbitmq websocket-service springboot frontend_service
mysql redis rabbitmq websocket-service springboot frontend_service mcp
echo "👉 Current status:"
docker compose -f "$compose_file" --env-file "$env_file" ps

View File

@@ -178,6 +178,32 @@ services:
- dev
- prod
mcp:
build:
context: ..
dockerfile: docker/mcp.Dockerfile
container_name: ${COMPOSE_PROJECT_NAME}-openisle-mcp
env_file:
- ${ENV_FILE:-../.env}
environment:
OPENISLE_MCP_BACKEND_BASE_URL: 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:
image: maven:3.9-eclipse-temurin-17
container_name: ${COMPOSE_PROJECT_NAME}-openisle-websocket

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

39
mcp/README.md Normal file
View File

@@ -0,0 +1,39 @@
# OpenIsle MCP Server
This package provides a [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server
that exposes OpenIsle's search capabilities as MCP tools. The initial release focuses on the
global search endpoint so the agent ecosystem can retrieve relevant posts, users, tags, and
other resources.
## Configuration
The server is configured through environment variables (all prefixed with `OPENISLE_MCP_`):
| Variable | Default | Description |
| --- | --- | --- |
| `BACKEND_BASE_URL` | `http://springboot:8080` | Base URL of the OpenIsle backend. |
| `PORT` | `8085` | TCP port when running with the `streamable-http` transport. |
| `HOST` | `0.0.0.0` | Interface to bind when serving HTTP. |
| `TRANSPORT` | `streamable-http` | Transport to use (`stdio`, `sse`, or `streamable-http`). |
| `REQUEST_TIMEOUT` | `10.0` | Timeout (seconds) for backend HTTP requests. |
## Running locally
```bash
pip install .
OPENISLE_MCP_BACKEND_BASE_URL="http://localhost:8080" openisle-mcp
```
By default the server listens on port `8085` and serves MCP over Streamable HTTP.
## Available tools
| Tool | Description |
| --- | --- |
| `search` | Perform a global search against the OpenIsle backend. |
| `reply_to_comment` | Reply to an existing comment using a JWT token. |
| `recent_posts` | Retrieve posts created within the last *N* minutes. |
The tools return structured data mirroring the backend DTOs, including highlighted snippets for
search results, the full comment payload for replies, and detailed metadata for recent posts.

27
mcp/pyproject.toml Normal file
View File

@@ -0,0 +1,27 @@
[build-system]
requires = ["hatchling>=1.25"]
build-backend = "hatchling.build"
[project]
name = "openisle-mcp"
version = "0.1.0"
description = "Model Context Protocol server exposing OpenIsle search capabilities."
readme = "README.md"
authors = [{ name = "OpenIsle", email = "engineering@openisle.example" }]
requires-python = ">=3.11"
dependencies = [
"mcp>=1.19.0",
"httpx>=0.28,<0.29",
"pydantic>=2.12,<3",
"pydantic-settings>=2.11,<3"
]
[project.scripts]
openisle-mcp = "openisle_mcp.server:main"
[tool.hatch.build]
packages = ["src/openisle_mcp"]
[tool.ruff]
line-length = 100

View File

@@ -0,0 +1,6 @@
"""OpenIsle MCP server package."""
from .config import Settings, get_settings
__all__ = ["Settings", "get_settings"]

View File

@@ -0,0 +1,52 @@
"""Application configuration helpers for the OpenIsle MCP server."""
from __future__ import annotations
from functools import lru_cache
from typing import Literal
from pydantic import Field
from pydantic.networks import AnyHttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Configuration for the MCP server."""
backend_base_url: AnyHttpUrl = Field(
"http://springboot:8080",
description="Base URL for the OpenIsle backend service.",
)
host: str = Field(
"0.0.0.0",
description="Host interface to bind when running with HTTP transports.",
)
port: int = Field(
8085,
ge=1,
le=65535,
description="TCP port for HTTP transports.",
)
transport: Literal["stdio", "sse", "streamable-http"] = Field(
"streamable-http",
description="MCP transport to use when running the server.",
)
request_timeout: float = Field(
10.0,
gt=0,
description="Timeout (seconds) for backend search requests.",
)
model_config = SettingsConfigDict(
env_prefix="OPENISLE_MCP_",
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""Return cached application settings."""
return Settings()

View File

@@ -0,0 +1,273 @@
"""Pydantic models describing tool inputs and outputs."""
from __future__ import annotations
from datetime import datetime
from typing import Any, 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.",
)
class AuthorInfo(BaseModel):
"""Summary of a post or comment author."""
id: Optional[int] = Field(default=None, description="Author identifier.")
username: Optional[str] = Field(default=None, description="Author username.")
avatar: Optional[str] = Field(default=None, description="URL of the author's avatar.")
display_medal: Optional[str] = Field(
default=None,
alias="displayMedal",
description="Medal displayed next to the author, when available.",
)
model_config = ConfigDict(populate_by_name=True, extra="allow")
class CategoryInfo(BaseModel):
"""Basic information about a post category."""
id: Optional[int] = Field(default=None, description="Category identifier.")
name: Optional[str] = Field(default=None, description="Category name.")
description: Optional[str] = Field(
default=None, description="Human friendly description of the category."
)
icon: Optional[str] = Field(default=None, description="Icon URL associated with the category.")
small_icon: Optional[str] = Field(
default=None,
alias="smallIcon",
description="Compact icon URL for the category.",
)
count: Optional[int] = Field(default=None, description="Number of posts within the category.")
model_config = ConfigDict(populate_by_name=True, extra="allow")
class TagInfo(BaseModel):
"""Details for a tag assigned to a post."""
id: Optional[int] = Field(default=None, description="Tag identifier.")
name: Optional[str] = Field(default=None, description="Tag name.")
description: Optional[str] = Field(default=None, description="Description of the tag.")
icon: Optional[str] = Field(default=None, description="Icon URL for the tag.")
small_icon: Optional[str] = Field(
default=None,
alias="smallIcon",
description="Compact icon URL for the tag.",
)
created_at: Optional[datetime] = Field(
default=None,
alias="createdAt",
description="When the tag was created.",
)
count: Optional[int] = Field(default=None, description="Number of posts using the tag.")
model_config = ConfigDict(populate_by_name=True, extra="allow")
class ReactionInfo(BaseModel):
"""Representation of a reaction on a post or comment."""
id: Optional[int] = Field(default=None, description="Reaction identifier.")
type: Optional[str] = Field(default=None, description="Reaction type (emoji, like, etc.).")
user: Optional[str] = Field(default=None, description="Username of the reacting user.")
post_id: Optional[int] = Field(
default=None,
alias="postId",
description="Related post identifier when applicable.",
)
comment_id: Optional[int] = Field(
default=None,
alias="commentId",
description="Related comment identifier when applicable.",
)
message_id: Optional[int] = Field(
default=None,
alias="messageId",
description="Related message identifier when applicable.",
)
reward: Optional[int] = Field(default=None, description="Reward granted for the reaction, if any.")
model_config = ConfigDict(populate_by_name=True, extra="allow")
class CommentData(BaseModel):
"""Comment information returned by the backend."""
id: Optional[int] = Field(default=None, description="Comment identifier.")
content: Optional[str] = Field(default=None, description="Markdown content of the comment.")
created_at: Optional[datetime] = Field(
default=None,
alias="createdAt",
description="Timestamp when the comment was created.",
)
pinned_at: Optional[datetime] = Field(
default=None,
alias="pinnedAt",
description="Timestamp when the comment was pinned, if applicable.",
)
author: Optional[AuthorInfo] = Field(default=None, description="Author of the comment.")
replies: list["CommentData"] = Field(
default_factory=list,
description="Nested replies associated with the comment.",
)
reactions: list[ReactionInfo] = Field(
default_factory=list,
description="Reactions applied to the comment.",
)
reward: Optional[int] = Field(default=None, description="Reward gained by posting the comment.")
point_reward: Optional[int] = Field(
default=None,
alias="pointReward",
description="Points rewarded for the comment.",
)
model_config = ConfigDict(populate_by_name=True, extra="allow")
class CommentReplyResult(BaseModel):
"""Structured response returned when replying to a comment."""
comment: CommentData = Field(description="Reply comment returned by the backend.")
class PostSummary(BaseModel):
"""Summary information for a post."""
id: Optional[int] = Field(default=None, description="Post identifier.")
title: Optional[str] = Field(default=None, description="Title of the post.")
content: Optional[str] = Field(default=None, description="Excerpt or content of the post.")
created_at: Optional[datetime] = Field(
default=None,
alias="createdAt",
description="When the post was created.",
)
author: Optional[AuthorInfo] = Field(default=None, description="Author who created the post.")
category: Optional[CategoryInfo] = Field(default=None, description="Category of the post.")
tags: list[TagInfo] = Field(default_factory=list, description="Tags assigned to the post.")
views: Optional[int] = Field(default=None, description="Total view count for the post.")
comment_count: Optional[int] = Field(
default=None,
alias="commentCount",
description="Number of comments on the post.",
)
status: Optional[str] = Field(default=None, description="Workflow status of the post.")
pinned_at: Optional[datetime] = Field(
default=None,
alias="pinnedAt",
description="When the post was pinned, if ever.",
)
last_reply_at: Optional[datetime] = Field(
default=None,
alias="lastReplyAt",
description="Timestamp of the most recent reply.",
)
reactions: list[ReactionInfo] = Field(
default_factory=list,
description="Reactions received by the post.",
)
participants: list[AuthorInfo] = Field(
default_factory=list,
description="Users participating in the discussion.",
)
subscribed: Optional[bool] = Field(
default=None,
description="Whether the current user is subscribed to the post.",
)
reward: Optional[int] = Field(default=None, description="Reward granted for the post.")
point_reward: Optional[int] = Field(
default=None,
alias="pointReward",
description="Points granted for the post.",
)
type: Optional[str] = Field(default=None, description="Type of the post.")
lottery: Optional[dict[str, Any]] = Field(
default=None, description="Lottery information for the post."
)
poll: Optional[dict[str, Any]] = Field(
default=None, description="Poll information for the post."
)
rss_excluded: Optional[bool] = Field(
default=None,
alias="rssExcluded",
description="Whether the post is excluded from RSS feeds.",
)
closed: Optional[bool] = Field(default=None, description="Whether the post is closed for replies.")
visible_scope: Optional[str] = Field(
default=None,
alias="visibleScope",
description="Visibility scope configuration for the post.",
)
model_config = ConfigDict(populate_by_name=True, extra="allow")
class RecentPostsResponse(BaseModel):
"""Structured response for the recent posts tool."""
minutes: int = Field(description="Time window, in minutes, used for the query.")
total: int = Field(description="Number of posts returned by the backend.")
posts: list[PostSummary] = Field(
default_factory=list,
description="Posts created within the requested time window.",
)
CommentData.model_rebuild()
class PostDetail(PostSummary):
"""Detailed information for a single post, including comments."""
comments: list[CommentData] = Field(
default_factory=list,
description="Comments that belong to the post.",
)
model_config = ConfigDict(populate_by_name=True, extra="allow")

View File

@@ -0,0 +1,110 @@
"""HTTP client helpers for talking to the OpenIsle backend endpoints."""
from __future__ import annotations
import json
from typing import Any
import httpx
class SearchClient:
"""Client for calling the OpenIsle HTTP APIs used by the MCP server."""
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._ensure_dict(entry) for entry in payload]
async def reply_to_comment(
self,
comment_id: int,
token: str,
content: str,
captcha: str | None = None,
) -> dict[str, Any]:
"""Reply to an existing comment and return the created reply."""
client = self._get_client()
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
}
payload: dict[str, Any] = {"content": content}
if captcha is not None:
stripped_captcha = captcha.strip()
if stripped_captcha:
payload["captcha"] = stripped_captcha
response = await client.post(
f"/api/comments/{comment_id}/replies",
json=payload,
headers=headers,
)
response.raise_for_status()
return self._ensure_dict(response.json())
async def recent_posts(self, minutes: int) -> list[dict[str, Any]]:
"""Return posts created within the given timeframe."""
client = self._get_client()
response = await client.get(
"/api/posts/recent",
params={"minutes": minutes},
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 recent posts endpoint: {formatted}"
)
return [self._ensure_dict(entry) for entry in payload]
async def get_post(self, post_id: int, token: str | None = None) -> dict[str, Any]:
"""Retrieve the detailed payload for a single post."""
client = self._get_client()
headers = {"Accept": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
response = await client.get(f"/api/posts/{post_id}", headers=headers)
response.raise_for_status()
return self._ensure_dict(response.json())
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 _ensure_dict(entry: Any) -> dict[str, Any]:
if not isinstance(entry, dict):
raise ValueError(f"Expected JSON object, got: {type(entry)!r}")
return entry

View File

@@ -0,0 +1,305 @@
"""Entry point for running the OpenIsle MCP server."""
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import Annotated
import httpx
from mcp.server.fastmcp import Context, FastMCP
from pydantic import ValidationError
from pydantic import Field as PydanticField
from .config import get_settings
from .schemas import (
CommentData,
CommentReplyResult,
PostDetail,
PostSummary,
RecentPostsResponse,
SearchResponse,
SearchResultItem,
)
from .search_client import SearchClient
settings = get_settings()
search_client = SearchClient(
str(settings.backend_base_url), timeout=settings.request_timeout
)
@asynccontextmanager
async def lifespan(_: FastMCP):
"""Lifecycle hook that disposes shared resources when the server stops."""
try:
yield
finally:
await search_client.aclose()
app = FastMCP(
name="openisle-mcp",
instructions=(
"Use this server to search OpenIsle content, reply to comments with an authentication "
"token, retrieve details for a specific post, and list posts created within a recent time "
"window."
),
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:
"""Call the OpenIsle global search endpoint and return structured results."""
sanitized = keyword.strip()
if not sanitized:
raise ValueError("Keyword must not be empty.")
try:
raw_results = await search_client.global_search(sanitized)
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
message = (
"OpenIsle backend returned HTTP "
f"{exc.response.status_code} while searching for '{sanitized}'."
)
if ctx is not None:
await ctx.error(message)
raise ValueError(message) from exc
except httpx.RequestError as exc: # pragma: no cover - network errors
message = f"Unable to reach OpenIsle backend search service: {exc}."
if ctx is not None:
await ctx.error(message)
raise ValueError(message) from exc
try:
results = [SearchResultItem.model_validate(entry) for entry in raw_results]
except ValidationError as exc:
message = "Received malformed data from the OpenIsle backend search endpoint."
if ctx is not None:
await ctx.error(message)
raise ValueError(message) from exc
if ctx is not None:
await ctx.info(f"Search keyword '{sanitized}' returned {len(results)} results.")
return SearchResponse(keyword=sanitized, total=len(results), results=results)
@app.tool(
name="reply_to_comment",
description="Reply to an existing comment using an authentication token.",
structured_output=True,
)
async def reply_to_comment(
comment_id: Annotated[
int,
PydanticField(ge=1, description="Identifier of the comment being replied to."),
],
token: Annotated[str, PydanticField(description="JWT bearer token for the user performing the reply.")],
content: Annotated[
str,
PydanticField(description="Markdown content of the reply."),
],
captcha: Annotated[
str | None,
PydanticField(
default=None,
description="Optional captcha solution if the backend requires it.",
),
] = None,
ctx: Context | None = None,
) -> CommentReplyResult:
"""Create a reply for a comment and return the backend payload."""
sanitized_content = content.strip()
if not sanitized_content:
raise ValueError("Reply content must not be empty.")
sanitized_token = token.strip()
if not sanitized_token:
raise ValueError("Authentication token must not be empty.")
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
try:
raw_comment = await search_client.reply_to_comment(
comment_id,
sanitized_token,
sanitized_content,
sanitized_captcha,
)
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
status_code = exc.response.status_code
if status_code == 401:
message = (
"Authentication failed while replying to comment "
f"{comment_id}. Please verify the token."
)
elif status_code == 403:
message = (
"The provided token is not authorized to reply to comment "
f"{comment_id}."
)
else:
message = (
"OpenIsle backend returned HTTP "
f"{status_code} while replying to comment {comment_id}."
)
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 = (
"Unable to reach OpenIsle backend comment service: "
f"{exc}."
)
if ctx is not None:
await ctx.error(message)
raise ValueError(message) from exc
try:
comment = CommentData.model_validate(raw_comment)
except ValidationError as exc:
message = "Received malformed data from the reply comment endpoint."
if ctx is not None:
await ctx.error(message)
raise ValueError(message) from exc
if ctx is not None:
await ctx.info(
"Reply created successfully for comment "
f"{comment_id}."
)
return CommentReplyResult(comment=comment)
@app.tool(
name="recent_posts",
description="Retrieve posts created in the last N minutes.",
structured_output=True,
)
async def recent_posts(
minutes: Annotated[
int,
PydanticField(gt=0, le=1440, description="Time window in minutes to search for new posts."),
],
ctx: Context | None = None,
) -> RecentPostsResponse:
"""Fetch recent posts from the backend and return structured data."""
try:
raw_posts = await search_client.recent_posts(minutes)
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
message = (
"OpenIsle backend returned HTTP "
f"{exc.response.status_code} while fetching recent posts for the last {minutes} minutes."
)
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 recent posts service: {exc}."
if ctx is not None:
await ctx.error(message)
raise ValueError(message) from exc
try:
posts = [PostSummary.model_validate(entry) for entry in raw_posts]
except ValidationError as exc:
message = "Received malformed data from the recent posts endpoint."
if ctx is not None:
await ctx.error(message)
raise ValueError(message) from exc
if ctx is not None:
await ctx.info(
f"Found {len(posts)} posts created within the last {minutes} minutes."
)
return RecentPostsResponse(minutes=minutes, total=len(posts), posts=posts)
@app.tool(
name="get_post",
description="Retrieve detailed information for a single post.",
structured_output=True,
)
async def get_post(
post_id: Annotated[
int,
PydanticField(ge=1, description="Identifier of the post to retrieve."),
],
token: Annotated[
str | None,
PydanticField(
default=None,
description="Optional JWT bearer token to view the post as an authenticated user.",
),
] = None,
ctx: Context | None = None,
) -> PostDetail:
"""Fetch post details from the backend and validate the response."""
sanitized_token = token.strip() if isinstance(token, str) else None
if sanitized_token == "":
sanitized_token = None
try:
raw_post = await search_client.get_post(post_id, sanitized_token)
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
status_code = exc.response.status_code
if status_code == 404:
message = f"Post {post_id} was not found."
elif status_code == 401:
message = "Authentication failed while retrieving the post."
elif status_code == 403:
message = "The provided token is not authorized to view this post."
else:
message = (
"OpenIsle backend returned HTTP "
f"{status_code} while retrieving post {post_id}."
)
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 post service: {exc}."
if ctx is not None:
await ctx.error(message)
raise ValueError(message) from exc
try:
post = PostDetail.model_validate(raw_post)
except ValidationError as exc:
message = "Received malformed data from the post detail endpoint."
if ctx is not None:
await ctx.error(message)
raise ValueError(message) from exc
if ctx is not None:
await ctx.info(f"Retrieved post {post_id} successfully.")
return post
def main() -> None:
"""Run the MCP server using the configured transport."""
app.run(transport=settings.transport)
if __name__ == "__main__": # pragma: no cover - manual execution
main()

View File

@@ -100,10 +100,28 @@ server {
# auth_basic_user_file /etc/nginx/.htpasswd;
}
# ---------- WEBSOCKET GATEWAY TO :8082 ----------
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_set_header Upgrade $http_upgrade;

View File

@@ -8,11 +8,8 @@ server {
listen 443 ssl;
server_name staging.open-isle.com www.staging.open-isle.com;
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 /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;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
@@ -40,59 +37,13 @@ server {
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 ----------
location /api/ {
proxy_pass http://127.0.0.1:8081/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
@@ -109,7 +60,6 @@ server {
proxy_cache_bypass 1;
}
# ---------- WEBSOCKET GATEWAY TO :8083 ----------
location ^~ /websocket/ {
proxy_pass http://127.0.0.1:8083/;
proxy_http_version 1.1;
@@ -130,4 +80,24 @@ server {
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;
}
}