Compare commits

...

11 Commits

Author SHA1 Message Date
Tim
7a2cf829c7 Fix search client reply argument order 2025-10-28 16:32:53 +08:00
Tim
12329b43d1 Merge pull request #1113 from nagisa77/codex/pass-bearer-token-to-backend-apis
feat: forward Authorization header for MCP backend requests
2025-10-28 16:22:17 +08:00
Tim
1a45603e0f feat: forward authorization headers to backend 2025-10-28 16:22:04 +08:00
Tim
4a73503399 Merge pull request #1112 from nagisa77/codex/fix-authorization-parameter-error
Fix hosted MCP authorization configuration
2025-10-28 16:00:07 +08:00
Tim
83bf8c1d5e Fix hosted MCP auth header collision 2025-10-28 15:59:57 +08:00
Tim
34e206f05d Merge pull request #1111 from nagisa77/codex/refactor-getbaseinstructions-to-remove-openisletoken
Inject OpenIsle token into MCP requests
2025-10-28 15:57:04 +08:00
Tim
dc349923e9 Inject OpenIsle token into MCP requests 2025-10-28 15:56:51 +08:00
Tim
0d44c9a823 Merge pull request #1110 from nagisa77/codex/update-coffee-bot-prize-image
Update coffee bot prize image instructions
2025-10-28 15:21:23 +08:00
Tim
02645af321 Update coffee bot prize image instructions 2025-10-28 15:21:03 +08:00
Tim
c3a175f13f Merge pull request #1109 from nagisa77/codex/create-coffee-bot-for-lottery-post
Add coffee bot lottery poster and schedule
2025-10-28 15:14:51 +08:00
Tim
257794ca00 feat: bot father 允许创建帖子 2025-10-28 15:12:53 +08:00
4 changed files with 105 additions and 109 deletions

View File

@@ -12,16 +12,12 @@ export abstract class BotFather {
"get_post", "get_post",
"list_unread_messages", "list_unread_messages",
"mark_notifications_read", "mark_notifications_read",
"create_post",
]; ];
protected readonly mcp = hostedMcpTool({ protected readonly openisleToken = (process.env.OPENISLE_TOKEN ?? "").trim();
serverLabel: "openisle_mcp",
serverUrl: "https://www.open-isle.com/mcp",
allowedTools: this.allowedMcpTools,
requireApproval: "never",
});
protected readonly openisleToken = process.env.OPENISLE_TOKEN ?? ""; protected readonly mcp = this.createHostedMcpTool();
protected readonly agent: Agent; protected readonly agent: Agent;
constructor(protected readonly name: string) { constructor(protected readonly name: string) {
@@ -33,8 +29,8 @@ export abstract class BotFather {
console.log( console.log(
this.openisleToken this.openisleToken
? "🔑 OPENISLE_TOKEN detected in environment." ? "🔑 OPENISLE_TOKEN detected in environment; it will be attached to MCP requests."
: "🔓 OPENISLE_TOKEN not set; agent will request it if required." : "🔓 OPENISLE_TOKEN not set; authenticated MCP tools may be unavailable."
); );
this.agent = new Agent({ this.agent = new Agent({
@@ -65,13 +61,29 @@ export abstract class BotFather {
"You are a helpful assistant for https://www.open-isle.com.", "You are a helpful assistant for https://www.open-isle.com.",
"Finish tasks end-to-end before replying. If multiple MCP tools are needed, call them sequentially until the task is truly done.", "Finish tasks end-to-end before replying. If multiple MCP tools are needed, call them sequentially until the task is truly done.",
"When presenting the result, reply in Chinese with a concise summary and include any important URLs or IDs.", "When presenting the result, reply in Chinese with a concise summary and include any important URLs or IDs.",
this.openisleToken
? `If tools require auth, use this token exactly where the tool schema expects it: ${this.openisleToken}`
: "If a tool requires auth, ask me to provide OPENISLE_TOKEN via env.",
"After finishing replies, call mark_notifications_read with all processed notification IDs to keep the inbox clean.", "After finishing replies, call mark_notifications_read with all processed notification IDs to keep the inbox clean.",
]; ];
} }
private createHostedMcpTool() {
const token = this.openisleToken;
const authConfig = token
? {
headers: {
Authorization: `Bearer ${token}`,
},
}
: {};
return hostedMcpTool({
serverLabel: "openisle_mcp",
serverUrl: "https://www.open-isle.com/mcp",
allowedTools: this.allowedMcpTools,
requireApproval: "never",
...authConfig,
});
}
protected getAdditionalInstructions(): string[] { protected getAdditionalInstructions(): string[] {
return []; return [];
} }

View File

@@ -41,6 +41,7 @@ class CoffeeBot extends BotFather {
2. 正文包含: 2. 正文包含:
- 亲切的早安问候; - 亲切的早安问候;
- 明确奖品写作“Coffee x 1” - 明确奖品写作“Coffee x 1”
- 奖品图片链接https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/0d6a9b33e9ca4fe5a90540187d3f9ecb.png
- 公布开奖时间为今天下午 15:00北京时间写成 ${drawTimeText} - 公布开奖时间为今天下午 15:00北京时间写成 ${drawTimeText}
- 标注“领奖请私聊站长 @nagisa” - 标注“领奖请私聊站长 @nagisa”
- 鼓励大家留言互动。 - 鼓励大家留言互动。

View File

@@ -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,8 +111,9 @@ class SearchClient:
async def reply_to_comment( async def reply_to_comment(
self, self,
comment_id: int, comment_id: int,
token: str,
content: str, content: str,
*,
token: str | None = None,
captcha: str | None = None, captcha: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Reply to an existing comment and return the created reply.""" """Reply to an existing comment and return the created reply."""
@@ -143,8 +145,9 @@ class SearchClient:
async def reply_to_post( async def reply_to_post(
self, self,
post_id: int, post_id: int,
token: str,
content: str, content: str,
*,
token: str | None = None,
captcha: str | None = None, captcha: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Create a comment on a post and return the backend payload.""" """Create a comment on a post and return the backend payload."""

View File

@@ -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 "