diff --git a/mcp/README.md b/mcp/README.md index 905acdfdb..ccc87ac45 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -31,9 +31,11 @@ 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_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 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. +search results, the full comment payload for post replies and comment replies, and detailed +metadata for recent posts. diff --git a/mcp/src/openisle_mcp/schemas.py b/mcp/src/openisle_mcp/schemas.py index 941f13983..720c2b9db 100644 --- a/mcp/src/openisle_mcp/schemas.py +++ b/mcp/src/openisle_mcp/schemas.py @@ -177,6 +177,12 @@ class CommentReplyResult(BaseModel): 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.""" diff --git a/mcp/src/openisle_mcp/search_client.py b/mcp/src/openisle_mcp/search_client.py index 797d3f1f8..3e061211c 100644 --- a/mcp/src/openisle_mcp/search_client.py +++ b/mcp/src/openisle_mcp/search_client.py @@ -66,6 +66,35 @@ class SearchClient: 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.""" diff --git a/mcp/src/openisle_mcp/server.py b/mcp/src/openisle_mcp/server.py index 094c815a8..9f5a6af31 100644 --- a/mcp/src/openisle_mcp/server.py +++ b/mcp/src/openisle_mcp/server.py @@ -12,6 +12,7 @@ from pydantic import Field as PydanticField from .config import get_settings from .schemas import ( + CommentCreateResult, CommentData, CommentReplyResult, PostDetail, @@ -41,9 +42,9 @@ async def lifespan(_: FastMCP): 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." + "Use this server to search OpenIsle content, reply to posts and 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, @@ -96,6 +97,100 @@ async def search( 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.",