Compare commits

...

7 Commits

Author SHA1 Message Date
Tim
99c3ac1837 Handle null list fields in notification schemas 2025-10-28 10:27:40 +08:00
tim
749ab560ff Revert "Cache MCP session JWT tokens"
This reverts commit 997dacdbe6.
2025-10-28 01:55:46 +08:00
tim
541ad4d149 Revert "Remove token parameters from MCP tools"
This reverts commit e585100625.
2025-10-28 01:55:41 +08:00
tim
03eb027ea4 Revert "Add MCP tool for setting session token"
This reverts commit 9dadaad5ba.
2025-10-28 01:55:36 +08:00
Tim
4194b2be91 Merge pull request #1100 from nagisa77/codex/add-initialization-tool-for-jwt-token
Add MCP tool for initializing session JWT tokens
2025-10-28 01:47:28 +08:00
Tim
9dadaad5ba Add MCP tool for setting session token 2025-10-28 01:47:16 +08:00
Tim
d4b3400c5f Merge pull request #1099 from nagisa77/codex/remove-token-parameters-from-mcp-api
Remove explicit token parameters from MCP tools
2025-10-28 01:32:18 +08:00
2 changed files with 79 additions and 80 deletions

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, Field, ConfigDict, field_validator
class SearchResultItem(BaseModel):
@@ -170,6 +170,15 @@ class CommentData(BaseModel):
model_config = ConfigDict(populate_by_name=True, extra="allow")
@field_validator("replies", "reactions", mode="before")
@classmethod
def _ensure_comment_lists(cls, value: Any) -> list[Any]:
"""Convert ``None`` payloads to empty lists for comment collections."""
if value is None:
return []
return value
class CommentReplyResult(BaseModel):
"""Structured response returned when replying to a comment."""
@@ -253,6 +262,15 @@ class PostSummary(BaseModel):
model_config = ConfigDict(populate_by_name=True, extra="allow")
@field_validator("tags", "reactions", "participants", mode="before")
@classmethod
def _ensure_post_lists(cls, value: Any) -> list[Any]:
"""Normalize ``None`` values returned by the backend to empty lists."""
if value is None:
return []
return value
class RecentPostsResponse(BaseModel):
"""Structured response for the recent posts tool."""
@@ -278,6 +296,15 @@ class PostDetail(PostSummary):
model_config = ConfigDict(populate_by_name=True, extra="allow")
@field_validator("comments", mode="before")
@classmethod
def _ensure_comments_list(cls, value: Any) -> list[Any]:
"""Treat ``None`` comments payloads as empty lists."""
if value is None:
return []
return value
class NotificationData(BaseModel):
"""Unread notification payload returned by the backend."""

View File

@@ -4,13 +4,12 @@ from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from typing import Annotated, Any
from typing import Annotated
import httpx
from mcp.server.fastmcp import Context, FastMCP
from pydantic import ValidationError
from pydantic import Field as PydanticField
from weakref import WeakKeyDictionary
from .config import get_settings
from .schemas import (
@@ -51,69 +50,6 @@ search_client = SearchClient(
)
class SessionTokenManager:
"""Cache JWT access tokens on a per-session basis."""
def __init__(self) -> None:
self._tokens: WeakKeyDictionary[Any, str] = WeakKeyDictionary()
def resolve(
self, ctx: Context | None, token: str | None = None
) -> str | None:
"""Resolve and optionally persist the token for the current session."""
session = self._get_session(ctx)
if isinstance(token, str):
stripped = token.strip()
if stripped:
if session is not None:
self._tokens[session] = stripped
logger.debug(
"Stored JWT token for session %s.",
self._describe_session(session),
)
return stripped
if session is not None and session in self._tokens:
logger.debug(
"Clearing stored JWT token for session %s due to empty input.",
self._describe_session(session),
)
del self._tokens[session]
return None
if session is not None:
cached = self._tokens.get(session)
if cached:
logger.debug(
"Reusing cached JWT token for session %s.",
self._describe_session(session),
)
return cached
return None
@staticmethod
def _get_session(ctx: Context | None) -> Any | None:
if ctx is None:
return None
try:
return ctx.session
except Exception: # pragma: no cover - defensive guard
return None
@staticmethod
def _describe_session(session: Any) -> str:
identifier = getattr(session, "mcp_session_id", None)
if isinstance(identifier, str) and identifier:
return identifier
return hex(id(session))
session_token_manager = SessionTokenManager()
@asynccontextmanager
async def lifespan(_: FastMCP):
"""Lifecycle hook that disposes shared resources when the server stops."""
@@ -129,8 +65,8 @@ async def lifespan(_: FastMCP):
app = FastMCP(
name="openisle-mcp",
instructions=(
"Use this server to search OpenIsle content, reply to posts and comments with "
"session-managed authentication, retrieve details for a specific post, list posts created "
"Use this server to search OpenIsle content, reply to posts and comments with an "
"authentication token, retrieve details for a specific post, list posts created "
"within a recent time window, and review unread notification messages."
),
host=settings.host,
@@ -192,7 +128,7 @@ async def search(
@app.tool(
name="reply_to_post",
description="Create a comment on a post using session authentication.",
description="Create a comment on a post using an authentication token.",
structured_output=True,
)
async def reply_to_post(
@@ -211,6 +147,15 @@ async def reply_to_post(
description="Optional captcha solution if the backend requires it.",
),
] = None,
token: Annotated[
str | None,
PydanticField(
default=None,
description=(
"Optional JWT bearer token. When omitted the configured access token is used."
),
),
] = None,
ctx: Context | None = None,
) -> CommentCreateResult:
"""Create a comment on a post and return the backend payload."""
@@ -219,9 +164,9 @@ async def reply_to_post(
if not sanitized_content:
raise ValueError("Reply content must not be empty.")
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
sanitized_token = token.strip() if isinstance(token, str) else None
resolved_token = session_token_manager.resolve(ctx)
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
try:
logger.info(
@@ -231,7 +176,7 @@ async def reply_to_post(
)
raw_comment = await search_client.reply_to_post(
post_id,
resolved_token,
sanitized_token,
sanitized_content,
sanitized_captcha,
)
@@ -290,7 +235,7 @@ async def reply_to_post(
@app.tool(
name="reply_to_comment",
description="Reply to an existing comment using session authentication.",
description="Reply to an existing comment using an authentication token.",
structured_output=True,
)
async def reply_to_comment(
@@ -309,6 +254,15 @@ async def reply_to_comment(
description="Optional captcha solution if the backend requires it.",
),
] = None,
token: Annotated[
str | None,
PydanticField(
default=None,
description=(
"Optional JWT bearer token. When omitted the configured access token is used."
),
),
] = None,
ctx: Context | None = None,
) -> CommentReplyResult:
"""Create a reply for a comment and return the backend payload."""
@@ -317,9 +271,9 @@ async def reply_to_comment(
if not sanitized_content:
raise ValueError("Reply content must not be empty.")
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
sanitized_token = token.strip() if isinstance(token, str) else None
resolved_token = session_token_manager.resolve(ctx)
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
try:
logger.info(
@@ -329,7 +283,7 @@ async def reply_to_comment(
)
raw_comment = await search_client.reply_to_comment(
comment_id,
resolved_token,
sanitized_token,
sanitized_content,
sanitized_captcha,
)
@@ -446,15 +400,24 @@ async def get_post(
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."""
resolved_token = session_token_manager.resolve(ctx)
sanitized_token = token.strip() if isinstance(token, str) else None
if sanitized_token == "":
sanitized_token = None
try:
logger.info("Fetching post details for post_id=%s", post_id)
raw_post = await search_client.get_post(post_id, resolved_token)
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:
@@ -519,11 +482,20 @@ async def list_unread_messages(
description="Number of unread notifications to include per page.",
),
] = 30,
token: Annotated[
str | None,
PydanticField(
default=None,
description=(
"Optional JWT bearer token. When omitted the configured access token is used."
),
),
] = None,
ctx: Context | None = None,
) -> UnreadNotificationsResponse:
"""Retrieve unread notifications and return structured data."""
resolved_token = session_token_manager.resolve(ctx)
sanitized_token = token.strip() if isinstance(token, str) else None
try:
logger.info(
@@ -534,7 +506,7 @@ async def list_unread_messages(
raw_notifications = await search_client.list_unread_notifications(
page=page,
size=size,
token=resolved_token,
token=sanitized_token,
)
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
message = (