mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-06 15:10:59 +08:00
feat: forward authorization headers to backend
This commit is contained in:
@@ -66,7 +66,8 @@ class SearchClient:
|
|||||||
resolved = self._resolve_token(token)
|
resolved = self._resolve_token(token)
|
||||||
if resolved is None:
|
if resolved is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Authenticated request requires an access token but none was provided."
|
"Authenticated request requires an access token. Provide a Bearer token "
|
||||||
|
"via the MCP Authorization header or configure a default token for the server."
|
||||||
)
|
)
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ class SearchClient:
|
|||||||
async def reply_to_comment(
|
async def reply_to_comment(
|
||||||
self,
|
self,
|
||||||
comment_id: int,
|
comment_id: int,
|
||||||
token: str,
|
token: str | None = None,
|
||||||
content: str,
|
content: str,
|
||||||
captcha: str | None = None,
|
captcha: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -143,7 +144,7 @@ class SearchClient:
|
|||||||
async def reply_to_post(
|
async def reply_to_post(
|
||||||
self,
|
self,
|
||||||
post_id: int,
|
post_id: int,
|
||||||
token: str,
|
token: str | None = None,
|
||||||
content: str,
|
content: str,
|
||||||
captcha: str | None = None,
|
captcha: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -52,6 +52,37 @@ search_client = SearchClient(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_authorization_token(ctx: Context | None) -> str | None:
|
||||||
|
"""Return the Bearer token from the incoming MCP request headers."""
|
||||||
|
|
||||||
|
if ctx is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
request_context = ctx.request_context
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
request = getattr(request_context, "request", None)
|
||||||
|
if request is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
headers = getattr(request, "headers", None)
|
||||||
|
if headers is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
authorization = headers.get("authorization")
|
||||||
|
if not authorization:
|
||||||
|
return None
|
||||||
|
|
||||||
|
scheme, _, token = authorization.partition(" ")
|
||||||
|
if scheme.lower() != "bearer":
|
||||||
|
return None
|
||||||
|
|
||||||
|
stripped = token.strip()
|
||||||
|
return stripped or None
|
||||||
|
|
||||||
|
|
||||||
@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."""
|
||||||
@@ -68,8 +99,9 @@ app = FastMCP(
|
|||||||
name="openisle-mcp",
|
name="openisle-mcp",
|
||||||
instructions=(
|
instructions=(
|
||||||
"Use this server to search OpenIsle content, create new posts, reply to posts and "
|
"Use this server to search OpenIsle content, create new posts, reply to posts and "
|
||||||
"comments with an authentication token, retrieve details for a specific post, list "
|
"comments using the Authorization header or configured access token, retrieve details "
|
||||||
"posts created within a recent time window, and review unread notification messages."
|
"for a specific post, list posts created within a recent time window, and review "
|
||||||
|
"unread notification messages."
|
||||||
),
|
),
|
||||||
host=settings.host,
|
host=settings.host,
|
||||||
port=settings.port,
|
port=settings.port,
|
||||||
@@ -130,7 +162,10 @@ async def search(
|
|||||||
|
|
||||||
@app.tool(
|
@app.tool(
|
||||||
name="reply_to_post",
|
name="reply_to_post",
|
||||||
description="Create a comment on a post using an authentication token.",
|
description=(
|
||||||
|
"Create a comment on a post using the request Authorization header or the configured "
|
||||||
|
"access token."
|
||||||
|
),
|
||||||
structured_output=True,
|
structured_output=True,
|
||||||
)
|
)
|
||||||
async def reply_to_post(
|
async def reply_to_post(
|
||||||
@@ -149,15 +184,6 @@ 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."""
|
||||||
@@ -166,12 +192,10 @@ 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_token = token.strip() if isinstance(token, str) else None
|
|
||||||
if sanitized_token == "":
|
|
||||||
sanitized_token = None
|
|
||||||
|
|
||||||
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
|
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
|
||||||
|
|
||||||
|
request_token = _extract_authorization_token(ctx)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Creating reply for post_id=%s (captcha=%s)",
|
"Creating reply for post_id=%s (captcha=%s)",
|
||||||
@@ -180,20 +204,20 @@ 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,
|
||||||
sanitized_token,
|
token=request_token,
|
||||||
sanitized_content,
|
content=sanitized_content,
|
||||||
sanitized_captcha,
|
captcha=sanitized_captcha,
|
||||||
)
|
)
|
||||||
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 == 401:
|
if status_code == 401:
|
||||||
message = (
|
message = (
|
||||||
"Authentication failed while replying to post "
|
"Authentication failed while replying to post "
|
||||||
f"{post_id}. Please verify the token."
|
f"{post_id}. Please verify the Authorization header or configured token."
|
||||||
)
|
)
|
||||||
elif status_code == 403:
|
elif status_code == 403:
|
||||||
message = (
|
message = (
|
||||||
"The provided token is not authorized to reply to post "
|
"The provided Authorization token is not authorized to reply to post "
|
||||||
f"{post_id}."
|
f"{post_id}."
|
||||||
)
|
)
|
||||||
elif status_code == 404:
|
elif status_code == 404:
|
||||||
@@ -239,7 +263,10 @@ async def reply_to_post(
|
|||||||
|
|
||||||
@app.tool(
|
@app.tool(
|
||||||
name="reply_to_comment",
|
name="reply_to_comment",
|
||||||
description="Reply to an existing comment using an authentication token.",
|
description=(
|
||||||
|
"Reply to an existing comment using the request Authorization header or the configured "
|
||||||
|
"access token."
|
||||||
|
),
|
||||||
structured_output=True,
|
structured_output=True,
|
||||||
)
|
)
|
||||||
async def reply_to_comment(
|
async def reply_to_comment(
|
||||||
@@ -258,15 +285,6 @@ 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."""
|
||||||
@@ -275,10 +293,10 @@ 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_token = token.strip() if isinstance(token, str) else None
|
|
||||||
|
|
||||||
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
|
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
|
||||||
|
|
||||||
|
request_token = _extract_authorization_token(ctx)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Creating reply for comment_id=%s (captcha=%s)",
|
"Creating reply for comment_id=%s (captcha=%s)",
|
||||||
@@ -287,20 +305,20 @@ 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,
|
||||||
sanitized_token,
|
token=request_token,
|
||||||
sanitized_content,
|
content=sanitized_content,
|
||||||
sanitized_captcha,
|
captcha=sanitized_captcha,
|
||||||
)
|
)
|
||||||
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 == 401:
|
if status_code == 401:
|
||||||
message = (
|
message = (
|
||||||
"Authentication failed while replying to comment "
|
"Authentication failed while replying to comment "
|
||||||
f"{comment_id}. Please verify the token."
|
f"{comment_id}. Please verify the Authorization header or configured token."
|
||||||
)
|
)
|
||||||
elif status_code == 403:
|
elif status_code == 403:
|
||||||
message = (
|
message = (
|
||||||
"The provided token is not authorized to reply to comment "
|
"The provided Authorization token is not authorized to reply to comment "
|
||||||
f"{comment_id}."
|
f"{comment_id}."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -344,7 +362,10 @@ async def reply_to_comment(
|
|||||||
|
|
||||||
@app.tool(
|
@app.tool(
|
||||||
name="create_post",
|
name="create_post",
|
||||||
description="Publish a new post using an authentication token.",
|
description=(
|
||||||
|
"Publish a new post using the request Authorization header or the configured access "
|
||||||
|
"token."
|
||||||
|
),
|
||||||
structured_output=True,
|
structured_output=True,
|
||||||
)
|
)
|
||||||
async def create_post(
|
async def create_post(
|
||||||
@@ -466,15 +487,6 @@ async def create_post(
|
|||||||
description="Captcha solution if the backend requires one to create posts.",
|
description="Captcha solution if the backend requires one to create posts.",
|
||||||
),
|
),
|
||||||
] = 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,
|
||||||
) -> PostCreateResult:
|
) -> PostCreateResult:
|
||||||
"""Create a new post in OpenIsle and return the detailed backend payload."""
|
"""Create a new post in OpenIsle and return the detailed backend payload."""
|
||||||
@@ -487,10 +499,6 @@ async def create_post(
|
|||||||
if not sanitized_content:
|
if not sanitized_content:
|
||||||
raise ValueError("Post content must not be empty.")
|
raise ValueError("Post content must not be empty.")
|
||||||
|
|
||||||
sanitized_token = token.strip() if isinstance(token, str) else None
|
|
||||||
if sanitized_token == "":
|
|
||||||
sanitized_token = None
|
|
||||||
|
|
||||||
sanitized_category_id: int | None = None
|
sanitized_category_id: int | None = None
|
||||||
if category_id is not None:
|
if category_id is not None:
|
||||||
if isinstance(category_id, bool):
|
if isinstance(category_id, bool):
|
||||||
@@ -635,7 +643,7 @@ async def create_post(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("Creating post with title='%s'", sanitized_title)
|
logger.info("Creating post with title='%s'", sanitized_title)
|
||||||
raw_post = await search_client.create_post(payload, token=sanitized_token)
|
raw_post = await search_client.create_post(payload, token=_extract_authorization_token(ctx))
|
||||||
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 == 400:
|
if status_code == 400:
|
||||||
@@ -643,9 +651,12 @@ async def create_post(
|
|||||||
"Post creation failed due to invalid input or captcha verification errors."
|
"Post creation failed due to invalid input or captcha verification errors."
|
||||||
)
|
)
|
||||||
elif status_code == 401:
|
elif status_code == 401:
|
||||||
message = "Authentication failed while creating the post. Please verify the token."
|
message = (
|
||||||
|
"Authentication failed while creating the post. Please verify the "
|
||||||
|
"Authorization header or configured token."
|
||||||
|
)
|
||||||
elif status_code == 403:
|
elif status_code == 403:
|
||||||
message = "The provided token is not authorized to create posts."
|
message = "The provided Authorization token is not authorized to create posts."
|
||||||
else:
|
else:
|
||||||
message = (
|
message = (
|
||||||
"OpenIsle backend returned HTTP "
|
"OpenIsle backend returned HTTP "
|
||||||
@@ -741,24 +752,15 @@ 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."""
|
||||||
|
|
||||||
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, sanitized_token)
|
raw_post = await search_client.get_post(
|
||||||
|
post_id, _extract_authorization_token(ctx)
|
||||||
|
)
|
||||||
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:
|
||||||
@@ -766,7 +768,7 @@ async def get_post(
|
|||||||
elif status_code == 401:
|
elif status_code == 401:
|
||||||
message = "Authentication failed while retrieving the post."
|
message = "Authentication failed while retrieving the post."
|
||||||
elif status_code == 403:
|
elif status_code == 403:
|
||||||
message = "The provided token is not authorized to view this post."
|
message = "The provided Authorization token is not authorized to view this post."
|
||||||
else:
|
else:
|
||||||
message = (
|
message = (
|
||||||
"OpenIsle backend returned HTTP "
|
"OpenIsle backend returned HTTP "
|
||||||
@@ -823,21 +825,10 @@ 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."""
|
||||||
|
|
||||||
sanitized_token = token.strip() if isinstance(token, str) else None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Fetching unread notifications (page=%s, size=%s)",
|
"Fetching unread notifications (page=%s, size=%s)",
|
||||||
@@ -847,7 +838,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=sanitized_token,
|
token=_extract_authorization_token(ctx),
|
||||||
)
|
)
|
||||||
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||||
message = (
|
message = (
|
||||||
@@ -906,29 +897,18 @@ async def mark_notifications_read(
|
|||||||
description="Notification identifiers that should be marked as read.",
|
description="Notification identifiers that should be marked as read.",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
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,
|
||||||
) -> NotificationCleanupResult:
|
) -> NotificationCleanupResult:
|
||||||
"""Mark the supplied notifications as read and report the processed identifiers."""
|
"""Mark the supplied notifications as read and report the processed identifiers."""
|
||||||
|
|
||||||
sanitized_token = token.strip() if isinstance(token, str) else None
|
|
||||||
if sanitized_token == "":
|
|
||||||
sanitized_token = None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Marking %d notifications as read", # pragma: no branch - logging
|
"Marking %d notifications as read", # pragma: no branch - logging
|
||||||
len(ids),
|
len(ids),
|
||||||
)
|
)
|
||||||
await search_client.mark_notifications_read(ids, token=sanitized_token)
|
await search_client.mark_notifications_read(
|
||||||
|
ids, token=_extract_authorization_token(ctx)
|
||||||
|
)
|
||||||
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||||
message = (
|
message = (
|
||||||
"OpenIsle backend returned HTTP "
|
"OpenIsle backend returned HTTP "
|
||||||
|
|||||||
Reference in New Issue
Block a user