mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-18 21:10:57 +08:00
Compare commits
5 Commits
codex/add-
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99c3ac1837 | ||
|
|
749ab560ff | ||
|
|
541ad4d149 | ||
|
|
03eb027ea4 | ||
|
|
4194b2be91 |
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, ConfigDict
|
from pydantic import BaseModel, Field, ConfigDict, field_validator
|
||||||
|
|
||||||
|
|
||||||
class SearchResultItem(BaseModel):
|
class SearchResultItem(BaseModel):
|
||||||
@@ -170,6 +170,15 @@ class CommentData(BaseModel):
|
|||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
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):
|
class CommentReplyResult(BaseModel):
|
||||||
"""Structured response returned when replying to a comment."""
|
"""Structured response returned when replying to a comment."""
|
||||||
@@ -253,6 +262,15 @@ class PostSummary(BaseModel):
|
|||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
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):
|
class RecentPostsResponse(BaseModel):
|
||||||
"""Structured response for the recent posts tool."""
|
"""Structured response for the recent posts tool."""
|
||||||
@@ -278,6 +296,15 @@ class PostDetail(PostSummary):
|
|||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
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):
|
class NotificationData(BaseModel):
|
||||||
"""Unread notification payload returned by the backend."""
|
"""Unread notification payload returned by the backend."""
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Annotated, Any
|
from typing import Annotated
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from pydantic import Field as PydanticField
|
from pydantic import Field as PydanticField
|
||||||
from weakref import WeakKeyDictionary
|
|
||||||
|
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .schemas import (
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(_: FastMCP):
|
async def lifespan(_: FastMCP):
|
||||||
"""Lifecycle hook that disposes shared resources when the server stops."""
|
"""Lifecycle hook that disposes shared resources when the server stops."""
|
||||||
@@ -129,8 +65,8 @@ async def lifespan(_: FastMCP):
|
|||||||
app = FastMCP(
|
app = FastMCP(
|
||||||
name="openisle-mcp",
|
name="openisle-mcp",
|
||||||
instructions=(
|
instructions=(
|
||||||
"Use this server to search OpenIsle content, reply to posts and comments with "
|
"Use this server to search OpenIsle content, reply to posts and comments with an "
|
||||||
"session-managed authentication, retrieve details for a specific post, list posts created "
|
"authentication token, retrieve details for a specific post, list posts created "
|
||||||
"within a recent time window, and review unread notification messages."
|
"within a recent time window, and review unread notification messages."
|
||||||
),
|
),
|
||||||
host=settings.host,
|
host=settings.host,
|
||||||
@@ -139,25 +75,6 @@ app = FastMCP(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.tool(
|
|
||||||
name="set_token",
|
|
||||||
description=(
|
|
||||||
"Set JWT token for the current session to be reused by other tools."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
async def set_token(
|
|
||||||
token: Annotated[
|
|
||||||
str,
|
|
||||||
PydanticField(description="JWT token string."),
|
|
||||||
],
|
|
||||||
ctx: Context | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Persist a JWT token for the active MCP session."""
|
|
||||||
|
|
||||||
session_token_manager.resolve(ctx, token)
|
|
||||||
return "Token stored successfully."
|
|
||||||
|
|
||||||
|
|
||||||
@app.tool(
|
@app.tool(
|
||||||
name="search",
|
name="search",
|
||||||
description="Perform a global search across OpenIsle resources.",
|
description="Perform a global search across OpenIsle resources.",
|
||||||
@@ -211,7 +128,7 @@ async def search(
|
|||||||
|
|
||||||
@app.tool(
|
@app.tool(
|
||||||
name="reply_to_post",
|
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,
|
structured_output=True,
|
||||||
)
|
)
|
||||||
async def reply_to_post(
|
async def reply_to_post(
|
||||||
@@ -230,6 +147,15 @@ async def reply_to_post(
|
|||||||
description="Optional captcha solution if the backend requires it.",
|
description="Optional captcha solution if the backend requires it.",
|
||||||
),
|
),
|
||||||
] = None,
|
] = 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,
|
ctx: Context | None = None,
|
||||||
) -> CommentCreateResult:
|
) -> CommentCreateResult:
|
||||||
"""Create a comment on a post and return the backend payload."""
|
"""Create a comment on a post and return the backend payload."""
|
||||||
@@ -238,9 +164,9 @@ async def reply_to_post(
|
|||||||
if not sanitized_content:
|
if not sanitized_content:
|
||||||
raise ValueError("Reply content must not be empty.")
|
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:
|
try:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -250,7 +176,7 @@ async def reply_to_post(
|
|||||||
)
|
)
|
||||||
raw_comment = await search_client.reply_to_post(
|
raw_comment = await search_client.reply_to_post(
|
||||||
post_id,
|
post_id,
|
||||||
resolved_token,
|
sanitized_token,
|
||||||
sanitized_content,
|
sanitized_content,
|
||||||
sanitized_captcha,
|
sanitized_captcha,
|
||||||
)
|
)
|
||||||
@@ -309,7 +235,7 @@ async def reply_to_post(
|
|||||||
|
|
||||||
@app.tool(
|
@app.tool(
|
||||||
name="reply_to_comment",
|
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,
|
structured_output=True,
|
||||||
)
|
)
|
||||||
async def reply_to_comment(
|
async def reply_to_comment(
|
||||||
@@ -328,6 +254,15 @@ async def reply_to_comment(
|
|||||||
description="Optional captcha solution if the backend requires it.",
|
description="Optional captcha solution if the backend requires it.",
|
||||||
),
|
),
|
||||||
] = None,
|
] = 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,
|
ctx: Context | None = None,
|
||||||
) -> CommentReplyResult:
|
) -> CommentReplyResult:
|
||||||
"""Create a reply for a comment and return the backend payload."""
|
"""Create a reply for a comment and return the backend payload."""
|
||||||
@@ -336,9 +271,9 @@ async def reply_to_comment(
|
|||||||
if not sanitized_content:
|
if not sanitized_content:
|
||||||
raise ValueError("Reply content must not be empty.")
|
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:
|
try:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -348,7 +283,7 @@ async def reply_to_comment(
|
|||||||
)
|
)
|
||||||
raw_comment = await search_client.reply_to_comment(
|
raw_comment = await search_client.reply_to_comment(
|
||||||
comment_id,
|
comment_id,
|
||||||
resolved_token,
|
sanitized_token,
|
||||||
sanitized_content,
|
sanitized_content,
|
||||||
sanitized_captcha,
|
sanitized_captcha,
|
||||||
)
|
)
|
||||||
@@ -465,15 +400,24 @@ async def get_post(
|
|||||||
int,
|
int,
|
||||||
PydanticField(ge=1, description="Identifier of the post to retrieve."),
|
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,
|
ctx: Context | None = None,
|
||||||
) -> PostDetail:
|
) -> PostDetail:
|
||||||
"""Fetch post details from the backend and validate the response."""
|
"""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:
|
try:
|
||||||
logger.info("Fetching post details for post_id=%s", post_id)
|
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
|
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||||
status_code = exc.response.status_code
|
status_code = exc.response.status_code
|
||||||
if status_code == 404:
|
if status_code == 404:
|
||||||
@@ -538,11 +482,20 @@ async def list_unread_messages(
|
|||||||
description="Number of unread notifications to include per page.",
|
description="Number of unread notifications to include per page.",
|
||||||
),
|
),
|
||||||
] = 30,
|
] = 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,
|
ctx: Context | None = None,
|
||||||
) -> UnreadNotificationsResponse:
|
) -> UnreadNotificationsResponse:
|
||||||
"""Retrieve unread notifications and return structured data."""
|
"""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:
|
try:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -553,7 +506,7 @@ async def list_unread_messages(
|
|||||||
raw_notifications = await search_client.list_unread_notifications(
|
raw_notifications = await search_client.list_unread_notifications(
|
||||||
page=page,
|
page=page,
|
||||||
size=size,
|
size=size,
|
||||||
token=resolved_token,
|
token=sanitized_token,
|
||||||
)
|
)
|
||||||
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||||
message = (
|
message = (
|
||||||
|
|||||||
Reference in New Issue
Block a user