diff --git a/mcp/README.md b/mcp/README.md index 8a66ac452..905acdfdb 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -31,7 +31,9 @@ By default the server listens on port `8085` and serves MCP over Streamable HTTP | 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 tool returns structured data describing each search hit including highlighted snippets when -provided by the backend. +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. diff --git a/mcp/src/openisle_mcp/schemas.py b/mcp/src/openisle_mcp/schemas.py index 935269d31..57d026177 100644 --- a/mcp/src/openisle_mcp/schemas.py +++ b/mcp/src/openisle_mcp/schemas.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import Optional +from datetime import datetime +from typing import Any, Optional from pydantic import BaseModel, Field, ConfigDict @@ -53,3 +54,209 @@ class SearchResponse(BaseModel): 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() diff --git a/mcp/src/openisle_mcp/search_client.py b/mcp/src/openisle_mcp/search_client.py index 819438ff0..bfb3172b0 100644 --- a/mcp/src/openisle_mcp/search_client.py +++ b/mcp/src/openisle_mcp/search_client.py @@ -1,4 +1,4 @@ -"""HTTP client helpers for talking to the OpenIsle backend search endpoints.""" +"""HTTP client helpers for talking to the OpenIsle backend endpoints.""" from __future__ import annotations @@ -9,7 +9,7 @@ import httpx class SearchClient: - """Client for calling the OpenIsle search API.""" + """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("/") @@ -35,7 +35,54 @@ class SearchClient: 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] + 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 aclose(self) -> None: """Dispose of the underlying HTTP client.""" @@ -45,7 +92,7 @@ class SearchClient: self._client = None @staticmethod - def _validate_entry(entry: Any) -> dict[str, Any]: + def _ensure_dict(entry: Any) -> dict[str, Any]: if not isinstance(entry, dict): - raise ValueError(f"Search entry must be an object, got: {type(entry)!r}") + raise ValueError(f"Expected JSON object, got: {type(entry)!r}") return entry diff --git a/mcp/src/openisle_mcp/server.py b/mcp/src/openisle_mcp/server.py index 19a71fa0d..5748d5562 100644 --- a/mcp/src/openisle_mcp/server.py +++ b/mcp/src/openisle_mcp/server.py @@ -11,7 +11,14 @@ from pydantic import ValidationError from pydantic import Field as PydanticField from .config import get_settings -from .schemas import SearchResponse, SearchResultItem +from .schemas import ( + CommentData, + CommentReplyResult, + PostSummary, + RecentPostsResponse, + SearchResponse, + SearchResultItem, +) from .search_client import SearchClient settings = get_settings() @@ -33,8 +40,8 @@ async def lifespan(_: FastMCP): app = FastMCP( name="openisle-mcp", instructions=( - "Use this server to search OpenIsle posts, users, tags, categories, and comments " - "via the global search endpoint." + "Use this server to search OpenIsle content, reply to comments with an authentication " + "token, and list posts created within a recent time window." ), host=settings.host, port=settings.port, @@ -87,6 +94,141 @@ async def search( 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) + + def main() -> None: """Run the MCP server using the configured transport."""