mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-26 16:10:49 +08:00
Compare commits
6 Commits
codex/add-
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dca14390ca | ||
|
|
39875acd35 | ||
|
|
62edc75735 | ||
|
|
26ca9fc916 | ||
|
|
cad70c23b3 | ||
|
|
016276dbc3 |
@@ -31,7 +31,11 @@ By default the server listens on port `8085` and serves MCP over Streamable HTTP
|
|||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `search` | Perform a global search against the OpenIsle backend. |
|
| `search` | Perform a global search against the OpenIsle backend. |
|
||||||
|
| `reply_to_post` | Create a new comment on a post using a JWT token. |
|
||||||
|
| `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
|
The tools return structured data mirroring the backend DTOs, including highlighted snippets for
|
||||||
provided by the backend.
|
search results, the full comment payload for post replies and comment replies, and detailed
|
||||||
|
metadata for recent posts.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
from datetime import datetime
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, ConfigDict
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
|
|
||||||
@@ -53,3 +54,226 @@ class SearchResponse(BaseModel):
|
|||||||
description="Ordered collection of search results.",
|
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 CommentCreateResult(BaseModel):
|
||||||
|
"""Structured response returned when creating a comment on a post."""
|
||||||
|
|
||||||
|
comment: CommentData = Field(description="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")
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ import httpx
|
|||||||
|
|
||||||
|
|
||||||
class SearchClient:
|
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:
|
def __init__(self, base_url: str, *, timeout: float = 10.0) -> None:
|
||||||
self._base_url = base_url.rstrip("/")
|
self._base_url = base_url.rstrip("/")
|
||||||
@@ -35,7 +35,95 @@ class SearchClient:
|
|||||||
if not isinstance(payload, list):
|
if not isinstance(payload, list):
|
||||||
formatted = json.dumps(payload, ensure_ascii=False)[:200]
|
formatted = json.dumps(payload, ensure_ascii=False)[:200]
|
||||||
raise ValueError(f"Unexpected response format from search endpoint: {formatted}")
|
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 reply_to_post(
|
||||||
|
self,
|
||||||
|
post_id: int,
|
||||||
|
token: str,
|
||||||
|
content: str,
|
||||||
|
captcha: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a comment on a post and return the backend payload."""
|
||||||
|
|
||||||
|
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/posts/{post_id}/comments",
|
||||||
|
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:
|
async def aclose(self) -> None:
|
||||||
"""Dispose of the underlying HTTP client."""
|
"""Dispose of the underlying HTTP client."""
|
||||||
@@ -45,7 +133,7 @@ class SearchClient:
|
|||||||
self._client = None
|
self._client = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _validate_entry(entry: Any) -> dict[str, Any]:
|
def _ensure_dict(entry: Any) -> dict[str, Any]:
|
||||||
if not isinstance(entry, dict):
|
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
|
return entry
|
||||||
|
|||||||
@@ -11,7 +11,16 @@ from pydantic import ValidationError
|
|||||||
from pydantic import Field as PydanticField
|
from pydantic import Field as PydanticField
|
||||||
|
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .schemas import SearchResponse, SearchResultItem
|
from .schemas import (
|
||||||
|
CommentCreateResult,
|
||||||
|
CommentData,
|
||||||
|
CommentReplyResult,
|
||||||
|
PostDetail,
|
||||||
|
PostSummary,
|
||||||
|
RecentPostsResponse,
|
||||||
|
SearchResponse,
|
||||||
|
SearchResultItem,
|
||||||
|
)
|
||||||
from .search_client import SearchClient
|
from .search_client import SearchClient
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -33,8 +42,9 @@ async def lifespan(_: FastMCP):
|
|||||||
app = FastMCP(
|
app = FastMCP(
|
||||||
name="openisle-mcp",
|
name="openisle-mcp",
|
||||||
instructions=(
|
instructions=(
|
||||||
"Use this server to search OpenIsle posts, users, tags, categories, and comments "
|
"Use this server to search OpenIsle content, reply to posts and comments with an "
|
||||||
"via the global search endpoint."
|
"authentication token, retrieve details for a specific post, and list posts created "
|
||||||
|
"within a recent time window."
|
||||||
),
|
),
|
||||||
host=settings.host,
|
host=settings.host,
|
||||||
port=settings.port,
|
port=settings.port,
|
||||||
@@ -87,6 +97,298 @@ async def search(
|
|||||||
return SearchResponse(keyword=sanitized, total=len(results), results=results)
|
return SearchResponse(keyword=sanitized, total=len(results), results=results)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool(
|
||||||
|
name="reply_to_post",
|
||||||
|
description="Create a comment on a post using an authentication token.",
|
||||||
|
structured_output=True,
|
||||||
|
)
|
||||||
|
async def reply_to_post(
|
||||||
|
post_id: Annotated[
|
||||||
|
int,
|
||||||
|
PydanticField(ge=1, description="Identifier of the post 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,
|
||||||
|
) -> CommentCreateResult:
|
||||||
|
"""Create a comment on a post 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_post(
|
||||||
|
post_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 post "
|
||||||
|
f"{post_id}. Please verify the token."
|
||||||
|
)
|
||||||
|
elif status_code == 403:
|
||||||
|
message = (
|
||||||
|
"The provided token is not authorized to reply to post "
|
||||||
|
f"{post_id}."
|
||||||
|
)
|
||||||
|
elif status_code == 404:
|
||||||
|
message = f"Post {post_id} was not found."
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
"OpenIsle backend returned HTTP "
|
||||||
|
f"{status_code} while replying to 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 = (
|
||||||
|
"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 post 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 post "
|
||||||
|
f"{post_id}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommentCreateResult(comment=comment)
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
def main() -> None:
|
||||||
"""Run the MCP server using the configured transport."""
|
"""Run the MCP server using the configured transport."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user