From 353041f929366f6ea0b15f379abc5bc0c09a4a2f Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Mon, 27 Oct 2025 20:19:01 +0800 Subject: [PATCH] feat(mcp): add post detail retrieval tool --- mcp/src/openisle_mcp/schemas.py | 11 +++++ mcp/src/openisle_mcp/search_client.py | 12 +++++ mcp/src/openisle_mcp/server.py | 67 ++++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/mcp/src/openisle_mcp/schemas.py b/mcp/src/openisle_mcp/schemas.py index 57d026177..941f13983 100644 --- a/mcp/src/openisle_mcp/schemas.py +++ b/mcp/src/openisle_mcp/schemas.py @@ -260,3 +260,14 @@ class RecentPostsResponse(BaseModel): 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") diff --git a/mcp/src/openisle_mcp/search_client.py b/mcp/src/openisle_mcp/search_client.py index bfb3172b0..797d3f1f8 100644 --- a/mcp/src/openisle_mcp/search_client.py +++ b/mcp/src/openisle_mcp/search_client.py @@ -84,6 +84,18 @@ class SearchClient: ) 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.""" diff --git a/mcp/src/openisle_mcp/server.py b/mcp/src/openisle_mcp/server.py index 5748d5562..094c815a8 100644 --- a/mcp/src/openisle_mcp/server.py +++ b/mcp/src/openisle_mcp/server.py @@ -14,6 +14,7 @@ from .config import get_settings from .schemas import ( CommentData, CommentReplyResult, + PostDetail, PostSummary, RecentPostsResponse, SearchResponse, @@ -41,7 +42,8 @@ app = FastMCP( name="openisle-mcp", instructions=( "Use this server to search OpenIsle content, reply to comments with an authentication " - "token, and list posts created within a recent time window." + "token, retrieve details for a specific post, and list posts created within a recent time " + "window." ), host=settings.host, port=settings.port, @@ -229,6 +231,69 @@ async def recent_posts( 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."""