refactor(mcp): 升级 mcp 2.3,改用 MCPServer 与 Streamable HTTP(端点 /mcp)

- 内置工具与资源改为 AutoBangumiMCP(MCPServer 子类)上的 @mcp.tool() /
  @mcp.resource(),参数 schema 由类型注解生成,工具名、描述与参数名不变;
  结果仍是一段 JSON 文本,异常返回 is_error 结果
- 插件工具与资源在子类的 list / call / read 中追加与分发,熔断与超时逻辑
  不变;mcp 2 不再校验参数,插件工具按 input_schema 用 jsonschema 校验,
  不合法的参数不计入熔断
- 传输只保留 Streamable HTTP,以路由挂载在 /mcp(mount 会把 /mcp 重定向到
  /mcp/),删除 /mcp/sse 与 /mcp/messages/;会话管理器在应用 lifespan 中运行;
  关闭 DNS rebinding 防护,访问控制仍由 IP 白名单与令牌负责
- 更新 MCP 文档、e2e 用例与 4.0 更新日志的破坏性变更
This commit is contained in:
Estrella Pan
2026-10-20 11:40:32 +02:00
parent 7013d476ac
commit 73fbca985b
19 changed files with 657 additions and 715 deletions

View File

@@ -19,10 +19,11 @@ dependencies = [
"openai>=1.54.3",
"semver>=3.1.0",
"webauthn>=2.8.0",
"mcp>=1.8.0",
"mcp>=2.3.0",
"anthropic>=0.116.0",
"google-genai>=2.10.0",
"packaging>=26.3",
"jsonschema>=4.20.0",
]
[dependency-groups]
@@ -82,6 +83,11 @@ ignore_missing_imports = true
module = "aiohttp"
ignore_missing_imports = true
# jsonschema 不带类型信息(无 py.typed)
[[tool.mypy.overrides]]
module = "jsonschema"
ignore_missing_imports = true
# 测试代码惯用 `obj.method = AsyncMock()` 直接打桩实例方法,这是标准、安全的
# unittest.mock 用法,但 mypy 默认禁止给方法赋值。只关闭这一条错误码,
# attr-defined 等其余检查(含真实 bug)在测试代码中保持开启。

View File

@@ -7,12 +7,13 @@ import uvicorn
from fastapi import APIRouter, Depends, FastAPI, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from starlette.routing import Route
from module.api import v1
from module.api.health import router as health_router
from module.conf import VERSION, settings, setup_logger
from module.core import AppContext
from module.mcp import create_mcp_app
from module.mcp import create_mcp_app, mcp
setup_logger(reset=True)
logger = logging.getLogger(__name__)
@@ -45,7 +46,9 @@ async def lifespan(app: FastAPI):
# supervised background task and returns immediately (CONTRACT #5),
# so this does not block server startup.
await ctx.start_tasks()
yield
# 子应用不会运行自己的 lifespan,MCP 会话管理器在这里启动
async with app.state.mcp_sessions.run():
yield
# Shutdown
await ctx.stop()
await ctx.plugins.stop()
@@ -71,8 +74,11 @@ def create_app() -> FastAPI:
# it stays reachable without auth for container/orchestrator health checks
app.include_router(health_router)
# mount MCP server (SSE transport for LLM tool integration)
app.mount("/mcp", create_mcp_app(ctx))
# MCP 服务(Streamable HTTP):用路由而不是 mount,端点才是 /mcp 本身
# (mount 只匹配 /mcp/...,/mcp 会被 307 重定向到 /mcp/)
app.router.routes.append(Route("/mcp", endpoint=create_mcp_app(ctx)))
# 每次 create_mcp_app 都新建会话管理器,记下本应用的这一个供 lifespan 运行
app.state.mcp_sessions = mcp.session_manager
return app

View File

@@ -1,13 +1,16 @@
"""MCP (Model Context Protocol) server for AutoBangumi.
Exposes anime subscriptions, RSS feeds, and download status to MCP clients
(e.g. Claude Desktop) over a local-network-restricted SSE endpoint.
(e.g. Claude Desktop) over a local-network-restricted Streamable HTTP endpoint.
Usage::
from module.mcp import create_mcp_app
app = create_mcp_app(ctx) # returns a Starlette ASGI app, mount at /mcp
app = create_mcp_app(ctx) # returns a Starlette ASGI app, route it at /mcp
"""
# 导入即注册内置工具与资源
from . import resources, tools # noqa: F401
from .server import create_mcp_starlette_app as create_mcp_app
from .server import mcp

View File

@@ -17,6 +17,7 @@ import re
from collections.abc import Callable
from typing import Any
import jsonschema
from mcp import types
from pydantic import AnyUrl
@@ -78,7 +79,7 @@ def _tool_spec(entry: ProviderEntry, tool: McpTool) -> types.Tool:
return types.Tool(
name=tool_name(entry.plugin_id, entry.id),
description=tool.description,
inputSchema=tool.input_schema,
input_schema=tool.input_schema,
)
@@ -105,7 +106,7 @@ def list_plugin_tools() -> list[types.Tool]:
async def call_plugin_tool(name: str, arguments: dict) -> tuple[bool, Any]:
"""调用插件工具;``name`` 不是插件工具时返回 ``(False, None)``。
插件处理函数的异常会继续抛出(由 ``handle_tool`` 统一转成错误 JSON),
插件处理函数的异常会继续抛出(由 ``AutoBangumiMCP.call_tool`` 统一转成错误结果),
在此之前计入熔断。
"""
if TOOL_SEPARATOR not in name:
@@ -114,6 +115,11 @@ async def call_plugin_tool(name: str, arguments: dict) -> tuple[bool, Any]:
if found is None:
return False, None
entry, tool, _ = found
# mcp 2 的服务端不再按 inputSchema 校验参数;参数不合法是客户端的错,不计入熔断
try:
jsonschema.validate(arguments, tool.input_schema)
except jsonschema.ValidationError as e:
raise ValueError(f"Input validation error: {e.message}") from None
try:
result = await asyncio.wait_for(tool.handler(arguments), tool.timeout)
except Exception as e:
@@ -137,10 +143,10 @@ def resource_uri(plugin_id: str, resource_id: str) -> str:
def _resource_spec(entry: ProviderEntry, resource: McpResource) -> types.Resource:
return types.Resource(
uri=AnyUrl(resource_uri(entry.plugin_id, entry.id)),
uri=str(AnyUrl(resource_uri(entry.plugin_id, entry.id))),
name=resource.name,
description=resource.description or None,
mimeType=resource.mime_type,
mime_type=resource.mime_type,
)
@@ -148,7 +154,7 @@ def _plugin_resources() -> dict[str, tuple[ProviderEntry, McpResource, types.Res
# 以 AnyUrl 规范化后的 URI 为键:含空格或非 ASCII 字符的 id 会被百分号编码,
# 客户端读取时传回的正是列表里的编码形式
return {
str(spec.uri): (entry, resource, spec)
spec.uri: (entry, resource, spec)
for entry, resource, spec in _resolve(
points.MCP_RESOURCE, McpResource, _resource_spec
)

View File

@@ -1,128 +1,82 @@
"""MCP resource definitions and handlers for AutoBangumi.
"""内置 MCP 资源:静态资源与 ``autobangumi://anime/{id}`` 模板,内容均为 JSON 文本。
``RESOURCES`` lists static resources; ``RESOURCE_TEMPLATES`` lists URI
templates for parameterised lookups. ``handle_resource`` resolves a URI
to its JSON payload.
插件提供的资源(``autobangumi://plugins/<plugin-id>/<id>``)由
``AutoBangumiMCP.read_resource`` 分发。
"""
import json
import logging
from mcp import types
from pydantic import AnyUrl
from module.conf import VERSION
from module.database import Database
from module.manager import TorrentManager
from module.models import Bangumi
from module.rss import RSSEngine
from .plugins import list_plugin_resources, read_plugin_resource
from .runtime import get_context
from .tools import _bangumi_to_dict
from .server import mcp
from .tools import _bangumi_to_dict, get_program_status
logger = logging.getLogger(__name__)
RESOURCES = [
types.Resource(
uri=AnyUrl("autobangumi://anime/list"),
name="All tracked anime",
description="List of all anime subscriptions being tracked by AutoBangumi",
mimeType="application/json",
),
types.Resource(
uri=AnyUrl("autobangumi://status"),
name="Program status",
description="Current AutoBangumi program status, version, and state",
mimeType="application/json",
),
types.Resource(
uri=AnyUrl("autobangumi://rss/feeds"),
name="RSS feeds",
description="All configured RSS feeds with health status",
mimeType="application/json",
),
]
RESOURCE_TEMPLATES = [
types.ResourceTemplate(
uriTemplate="autobangumi://anime/{id}",
name="Anime details",
description="Detailed information about a specific tracked anime by ID",
mimeType="application/json",
),
]
JSON = "application/json"
def all_resources() -> list[types.Resource]:
"""内置资源 + 当前已启用插件提供的资源(``autobangumi://plugins/<id>/...``)。"""
return RESOURCES + list_plugin_resources()
@mcp.resource(
"autobangumi://anime/list",
name="All tracked anime",
description="List of all anime subscriptions being tracked by AutoBangumi",
mime_type=JSON,
)
async def anime_list() -> str:
async with Database() as db:
items = await db.bangumi.search_all()
return json.dumps([_bangumi_to_dict(b) for b in items], ensure_ascii=False)
async def handle_resource(uri: str) -> str:
"""Return a JSON string for the given MCP resource URI.
@mcp.resource(
"autobangumi://status",
name="Program status",
description="Current AutoBangumi program status, version, and state",
mime_type=JSON,
)
def program_status() -> str:
return json.dumps(get_program_status())
Supported URIs:
- ``autobangumi://anime/list`` - all tracked anime
- ``autobangumi://status`` - program version and running state
- ``autobangumi://rss/feeds`` - configured RSS feeds
- ``autobangumi://anime/{id}`` - single anime by integer ID
- ``autobangumi://plugins/<plugin-id>/<id>`` - 插件提供的资源
"""
if uri == "autobangumi://anime/list":
async with Database() as db:
items = await db.bangumi.search_all()
return json.dumps([_bangumi_to_dict(b) for b in items], ensure_ascii=False)
elif uri == "autobangumi://status":
ctx = get_context()
return json.dumps(
@mcp.resource(
"autobangumi://rss/feeds",
name="RSS feeds",
description="All configured RSS feeds with health status",
mime_type=JSON,
)
async def rss_feeds() -> str:
async with Database() as db:
feeds = await db.rss.search_all()
return json.dumps(
[
{
"version": VERSION,
"running": ctx.is_running if ctx is not None else False,
"first_run": ctx.first_run if ctx is not None else True,
"id": f.id,
"name": f.name,
"url": f.url,
"enabled": f.enabled,
"connection_status": f.connection_status,
"last_checked_at": f.last_checked_at,
}
)
for f in feeds
],
ensure_ascii=False,
)
elif uri == "autobangumi://rss/feeds":
async with Database() as db:
feeds = await db.rss.search_all()
return json.dumps(
[
{
"id": f.id,
"name": f.name,
"url": f.url,
"enabled": f.enabled,
"connection_status": f.connection_status,
"last_checked_at": f.last_checked_at,
}
for f in feeds
],
ensure_ascii=False,
)
elif uri.startswith("autobangumi://anime/"):
anime_id_str = uri.split("/")[-1]
try:
anime_id = int(anime_id_str)
except ValueError:
return json.dumps({"error": f"Invalid anime ID: {anime_id_str}"})
async with Database() as db:
manager = TorrentManager(db)
result = await manager.search_one(anime_id)
if isinstance(result, Bangumi):
return json.dumps(_bangumi_to_dict(result), ensure_ascii=False)
return json.dumps({"error": result.msg_en})
@mcp.resource(
"autobangumi://anime/{id}",
name="Anime details",
description="Detailed information about a specific tracked anime by ID",
mime_type=JSON,
)
async def anime_detail(id: str) -> str:
try:
handled, content = await read_plugin_resource(uri)
except Exception as e:
logger.warning("Plugin resource %s failed: %s", uri, e)
return json.dumps({"error": str(e)}, ensure_ascii=False)
if handled:
if isinstance(content, str):
return content
return json.dumps(content, ensure_ascii=False, default=str)
return json.dumps({"error": f"Unknown resource: {uri}"})
anime_id = int(id)
except ValueError:
return json.dumps({"error": f"Invalid anime ID: {id}"})
async with Database() as db:
manager = TorrentManager(db)
result = await manager.search_one(anime_id)
if isinstance(result, Bangumi):
return json.dumps(_bangumi_to_dict(result), ensure_ascii=False)
return json.dumps({"error": result.msg_en})

View File

@@ -1,99 +1,115 @@
"""MCP server assembly for AutoBangumi.
Wires together the MCP ``Server``, SSE transport, tool/resource handlers,
and local-network middleware into a single Starlette ASGI application.
``mcp`` 是 :class:`AutoBangumiMCP`(``MCPServer`` 子类)的单例:内置工具与资源
在 ``tools.py`` / ``resources.py`` 中用 ``@mcp.tool()`` / ``@mcp.resource()``
注册,插件提供的工具与资源在本类的 list / call / read 中追加与分发。
Mount the app returned by ``create_mcp_starlette_app`` at a path prefix
(e.g. ``/mcp``) in the parent FastAPI application to expose the MCP
endpoint at ``/mcp/sse``.
``create_mcp_starlette_app`` 返回 Streamable HTTP 传输的 Starlette 应用,
由 ``main.py`` 以 ``/mcp`` 路由挂载。
"""
import json
import logging
from collections.abc import Iterable
from typing import Any
from mcp import types
from mcp.server import Server
from mcp.server.sse import SseServerTransport
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver.exceptions import ResourceError
from mcp.server.transport_security import TransportSecuritySettings
from pydantic import AnyUrl
from starlette.applications import Starlette
from starlette.routing import Mount, Route
from starlette.types import Receive, Scope, Send
from .resources import RESOURCE_TEMPLATES, all_resources, handle_resource
from .plugins import (
call_plugin_tool,
list_plugin_resources,
list_plugin_tools,
read_plugin_resource,
)
from .runtime import set_context
from .security import McpAccessMiddleware
from .tools import all_tools, handle_tool
logger = logging.getLogger(__name__)
server = Server("autobangumi")
sse = SseServerTransport("/messages/")
def _text(value: Any) -> types.TextContent:
return types.TextContent(
type="text", text=json.dumps(value, ensure_ascii=False, default=str)
)
@server.list_tools()
async def list_tools() -> list[types.Tool]:
return all_tools()
class AutoBangumiMCP(MCPServer):
"""内置工具与资源走 MCPServer 的注册表,插件的从扩展注册表现取。"""
async def list_tools(self) -> list[types.Tool]:
return await super().list_tools() + list_plugin_tools()
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
logger.debug("Tool called: %s", name)
return await handle_tool(name, arguments)
@server.list_resources()
async def list_resources() -> list[types.Resource]:
return all_resources()
@server.list_resource_templates()
async def list_resource_templates() -> list[types.ResourceTemplate]:
return RESOURCE_TEMPLATES
@server.read_resource()
async def read_resource(uri: AnyUrl) -> str:
logger.debug("Resource read: %s", uri)
# 底层 read_resource 传入的是 pydantic AnyUrl,handle_resource 按字符串匹配
return await handle_resource(str(uri))
class _SseEndpoint:
"""``GET /sse`` 的裸 ASGI 端点:建立 SSE 连接并运行 MCP 会话直到客户端断开。
``connect_sse`` 自己经 ``send`` 写出整个 HTTP 响应,所以这里不能是返回
``Response`` 的普通 endpoint 函数——Starlette 会在 SSE 结束后再发一次
``http.response.start``,每次断开都触发 ``AssertionError``。
"""
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
async with sse.connect_sse(scope, receive, send) as streams:
await server.run(
streams[0],
streams[1],
server.create_initialization_options(),
async def call_tool(
self,
name: str,
arguments: dict[str, Any],
context: Context | None = None,
) -> types.CallToolResult:
# 结果统一序列化为一段 JSON 文本;内置工具注册时关闭 structured_output,
# 不声明 outputSchema
logger.debug("Tool called: %s", name)
try:
handled, result = await call_plugin_tool(name, arguments)
if not handled:
result = await self._tool_manager.call_tool(
name, arguments, context or Context(mcp_server=self)
)
except Exception as e:
logger.exception("Tool %s failed", name)
# MCPServer 把工具抛出的异常包成 ToolError,原始异常在 __cause__
error = e.__cause__ or e
return types.CallToolResult(
content=[_text({"error": str(error)})], is_error=True
)
return types.CallToolResult(content=[_text(result)])
async def list_resources(self) -> list[types.Resource]:
return await super().list_resources() + list_plugin_resources()
async def read_resource(
self, uri: AnyUrl | str, context: Context | None = None
) -> Iterable[ReadResourceContents] | types.InputRequiredResult:
logger.debug("Resource read: %s", uri)
try:
handled, content = await read_plugin_resource(str(uri))
except Exception as e:
logger.warning("Plugin resource %s failed: %s", uri, e)
raise ResourceError(str(e) or type(e).__name__) from e
if not handled:
return await super().read_resource(uri, context)
if not isinstance(content, str):
content = json.dumps(content, ensure_ascii=False, default=str)
return [ReadResourceContents(content=content, mime_type=None)]
handle_sse = _SseEndpoint()
mcp = AutoBangumiMCP("autobangumi")
def create_mcp_starlette_app(ctx=None) -> Starlette:
"""Build and return the MCP Starlette sub-application.
Routes:
- ``GET /sse`` - SSE stream for MCP clients
- ``POST /messages/`` - client-to-server message posting
只有一个 Streamable HTTP 端点 ``/mcp``(GET / POST / DELETE)。每次调用都
新建会话管理器,调用方须在应用 lifespan 中运行 ``mcp.session_manager.run()``。
``ctx`` is the application :class:`AppContext`; it is stored so status
tools/resources can report live program state. ``McpAccessMiddleware`` is
applied to enforce configurable IP whitelist and bearer token access control.
"""
set_context(ctx)
app = Starlette(
routes=[
Route("/sse", endpoint=handle_sse),
Mount("/messages", app=sse.handle_post_message),
],
app = mcp.streamable_http_app(
streamable_http_path="/mcp",
# 默认 host=127.0.0.1 会开启 DNS rebinding 防护并只放行 localhost 的
# Host 头,局域网地址与反向代理域名都会被拒(421)。访问控制由
# McpAccessMiddleware 的 IP 白名单与令牌负责,与原 SSE 端点一致
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
)
app.add_middleware(McpAccessMiddleware)
return app

View File

@@ -1,8 +1,13 @@
import json
import logging
from typing import Any
"""内置 MCP 工具:参数 schema 由类型注解生成。
from mcp import types
``structured_output=False``:结果由 ``AutoBangumiMCP.call_tool`` 序列化为一段
JSON 文本,不声明 outputSchema。
"""
import json
from typing import Annotated, Literal
from pydantic import Field
from module.conf import VERSION
from module.database import Database
@@ -12,174 +17,10 @@ from module.models import Bangumi, BangumiUpdate, RSSItem
from module.rss import RSSAnalyser, RSSEngine
from module.searcher import SearchTorrent
from .plugins import call_plugin_tool, list_plugin_tools
from .runtime import get_context
from .server import mcp
logger = logging.getLogger(__name__)
TOOLS = [
types.Tool(
name="list_anime",
description="List all tracked anime subscriptions. Returns title, season, status, and episode offset for each.",
inputSchema={
"type": "object",
"properties": {
"active_only": {
"type": "boolean",
"description": "If true, only return active (non-disabled) anime",
"default": False,
},
},
},
),
types.Tool(
name="get_anime",
description="Get detailed information about a specific anime subscription by its ID.",
inputSchema={
"type": "object",
"properties": {
"id": {
"type": "integer",
"description": "The anime/bangumi ID",
},
},
"required": ["id"],
},
),
types.Tool(
name="search_anime",
description="Search for anime torrents across torrent sites (Mikan, DMHY, Nyaa). Returns available anime matching the keywords.",
inputSchema={
"type": "object",
"properties": {
"keywords": {
"type": "string",
"description": "Search keywords (e.g. anime title)",
},
"site": {
"type": "string",
"description": "Torrent site to search",
"enum": ["mikan", "dmhy", "nyaa"],
"default": "mikan",
},
},
"required": ["keywords"],
},
),
types.Tool(
name="subscribe_anime",
description="Subscribe to an anime series by providing its RSS link. Analyzes the RSS feed and sets up automatic downloading.",
inputSchema={
"type": "object",
"properties": {
"rss_link": {
"type": "string",
"description": "RSS feed URL for the anime (obtained from search_anime results)",
},
"parser": {
"type": "string",
"description": "RSS parser type",
"enum": ["mikan", "dmhy", "nyaa"],
"default": "mikan",
},
},
"required": ["rss_link"],
},
),
types.Tool(
name="unsubscribe_anime",
description="Unsubscribe from an anime. Can either disable (keeps data) or fully delete the subscription.",
inputSchema={
"type": "object",
"properties": {
"id": {
"type": "integer",
"description": "The anime/bangumi ID to unsubscribe",
},
"delete": {
"type": "boolean",
"description": "If true, permanently delete the subscription. If false, just disable it.",
"default": False,
},
},
"required": ["id"],
},
),
types.Tool(
name="list_downloads",
description="Show current torrent download status from the download client (qBittorrent/Aria2).",
inputSchema={
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Filter by download status",
"enum": ["all", "downloading", "completed", "paused"],
"default": "all",
},
},
},
),
types.Tool(
name="list_rss_feeds",
description="List all configured RSS feeds with their connection status and health information.",
inputSchema={
"type": "object",
"properties": {},
},
),
types.Tool(
name="get_program_status",
description="Get the current program status including version, running state, and first-run flag.",
inputSchema={
"type": "object",
"properties": {},
},
),
types.Tool(
name="refresh_feeds",
description="Trigger an immediate refresh of all RSS feeds to check for new episodes.",
inputSchema={
"type": "object",
"properties": {},
},
),
types.Tool(
name="update_anime",
description="Update settings for a tracked anime (episode offset, season offset, filters, etc.).",
inputSchema={
"type": "object",
"properties": {
"id": {
"type": "integer",
"description": "The anime/bangumi ID to update",
},
"episode_offset": {
"type": "integer",
"description": "Episode number offset for renaming",
},
"season_offset": {
"type": "integer",
"description": "Season number offset for renaming",
},
"season": {
"type": "integer",
"description": "Season number",
},
"filter": {
"type": "string",
"description": "Comma-separated filter patterns to exclude",
},
},
"required": ["id"],
},
),
]
def all_tools() -> list[types.Tool]:
"""内置工具 + 当前已启用插件提供的工具(``<plugin-id>__<id>``)。"""
return TOOLS + list_plugin_tools()
Site = Literal["mikan", "dmhy", "nyaa"]
def _bangumi_to_dict(b: Bangumi) -> dict:
@@ -205,51 +46,16 @@ def _bangumi_to_dict(b: Bangumi) -> dict:
}
async def handle_tool(name: str, arguments: dict) -> list[types.TextContent]:
try:
result = await _dispatch(name, arguments)
return [
types.TextContent(
type="text", text=json.dumps(result, ensure_ascii=False, default=str)
)
]
except Exception as e:
logger.exception("Tool %s failed", name)
return [
types.TextContent(
type="text", text=json.dumps({"error": str(e)}, ensure_ascii=False)
)
]
async def _dispatch(name: str, args: dict) -> Any:
if name == "list_anime":
return await _list_anime(args.get("active_only", False))
elif name == "get_anime":
return await _get_anime(args["id"])
elif name == "search_anime":
return await _search_anime(args["keywords"], args.get("site", "mikan"))
elif name == "subscribe_anime":
return await _subscribe_anime(args["rss_link"], args.get("parser", "mikan"))
elif name == "unsubscribe_anime":
return await _unsubscribe_anime(args["id"], args.get("delete", False))
elif name == "list_downloads":
return await _list_downloads(args.get("status", "all"))
elif name == "list_rss_feeds":
return await _list_rss_feeds()
elif name == "get_program_status":
return _get_program_status()
elif name == "refresh_feeds":
return await _refresh_feeds()
elif name == "update_anime":
return await _update_anime(args)
handled, result = await call_plugin_tool(name, args)
if handled:
return result
return {"error": f"Unknown tool: {name}"}
async def _list_anime(active_only: bool) -> list[dict]:
@mcp.tool(
description="List all tracked anime subscriptions. Returns title, season, status, and episode offset for each.",
structured_output=False,
)
async def list_anime(
active_only: Annotated[
bool,
Field(description="If true, only return active (non-disabled) anime"),
] = False,
) -> list[dict]:
async with Database() as db:
manager = TorrentManager(db)
if active_only:
@@ -259,16 +65,29 @@ async def _list_anime(active_only: bool) -> list[dict]:
return [_bangumi_to_dict(b) for b in items]
async def _get_anime(bangumi_id: int) -> dict:
@mcp.tool(
description="Get detailed information about a specific anime subscription by its ID.",
structured_output=False,
)
async def get_anime(
id: Annotated[int, Field(description="The anime/bangumi ID")],
) -> dict:
async with Database() as db:
manager = TorrentManager(db)
result = await manager.search_one(bangumi_id)
result = await manager.search_one(id)
if isinstance(result, Bangumi):
return _bangumi_to_dict(result)
return {"error": result.msg_en}
async def _search_anime(keywords: str, site: str) -> list[dict]:
@mcp.tool(
description="Search for anime torrents across torrent sites (Mikan, DMHY, Nyaa). Returns available anime matching the keywords.",
structured_output=False,
)
async def search_anime(
keywords: Annotated[str, Field(description="Search keywords (e.g. anime title)")],
site: Annotated[Site, Field(description="Torrent site to search")] = "mikan",
) -> list[dict]:
keyword_list = keywords.split()
results = []
st = SearchTorrent()
@@ -279,7 +98,19 @@ async def _search_anime(keywords: str, site: str) -> list[dict]:
return results
async def _subscribe_anime(rss_link: str, parser: str) -> dict:
@mcp.tool(
description="Subscribe to an anime series by providing its RSS link. Analyzes the RSS feed and sets up automatic downloading.",
structured_output=False,
)
async def subscribe_anime(
rss_link: Annotated[
str,
Field(
description="RSS feed URL for the anime (obtained from search_anime results)"
),
],
parser: Annotated[Site, Field(description="RSS parser type")] = "mikan",
) -> dict:
analyser = RSSAnalyser()
rss = RSSItem(url=rss_link, parser=parser)
data = await analyser.link_to_data(rss)
@@ -289,17 +120,38 @@ async def _subscribe_anime(rss_link: str, parser: str) -> dict:
return {"status": resp.status, "message": resp.msg_en}
async def _unsubscribe_anime(bangumi_id: int, delete: bool) -> dict:
@mcp.tool(
description="Unsubscribe from an anime. Can either disable (keeps data) or fully delete the subscription.",
structured_output=False,
)
async def unsubscribe_anime(
id: Annotated[int, Field(description="The anime/bangumi ID to unsubscribe")],
delete: Annotated[
bool,
Field(
description="If true, permanently delete the subscription. If false, just disable it."
),
] = False,
) -> dict:
async with Database() as db:
manager = TorrentManager(db)
if delete:
resp = await manager.delete_rule(bangumi_id)
resp = await manager.delete_rule(id)
else:
resp = await manager.disable_rule(bangumi_id)
resp = await manager.disable_rule(id)
return {"status": resp.status, "message": resp.msg_en}
async def _list_downloads(status: str) -> list[dict]:
@mcp.tool(
description="Show current torrent download status from the download client (qBittorrent/Aria2).",
structured_output=False,
)
async def list_downloads(
status: Annotated[
Literal["all", "downloading", "completed", "paused"],
Field(description="Filter by download status"),
] = "all",
) -> list[dict]:
status_filter = None if status == "all" else status
torrents = await list_torrents(status_filter) or []
return [
@@ -317,7 +169,11 @@ async def _list_downloads(status: str) -> list[dict]:
]
async def _list_rss_feeds() -> list[dict]:
@mcp.tool(
description="List all configured RSS feeds with their connection status and health information.",
structured_output=False,
)
async def list_rss_feeds() -> list[dict]:
async with Database() as db:
feeds = await db.rss.search_all()
return [
@@ -336,7 +192,11 @@ async def _list_rss_feeds() -> list[dict]:
]
def _get_program_status() -> dict:
@mcp.tool(
description="Get the current program status including version, running state, and first-run flag.",
structured_output=False,
)
def get_program_status() -> dict:
ctx = get_context()
return {
"version": VERSION,
@@ -345,7 +205,11 @@ def _get_program_status() -> dict:
}
async def _refresh_feeds() -> dict:
@mcp.tool(
description="Trigger an immediate refresh of all RSS feeds to check for new episodes.",
structured_output=False,
)
async def refresh_feeds() -> dict:
async with DownloaderPool() as downloaders:
async with Database() as db:
engine = RSSEngine(db)
@@ -353,23 +217,38 @@ async def _refresh_feeds() -> dict:
return {"status": True, "message": "RSS feeds refreshed successfully"}
async def _update_anime(args: dict) -> dict:
bangumi_id = args["id"]
@mcp.tool(
description="Update settings for a tracked anime (episode offset, season offset, filters, etc.).",
structured_output=False,
)
async def update_anime(
id: Annotated[int, Field(description="The anime/bangumi ID to update")],
episode_offset: Annotated[
int | None, Field(description="Episode number offset for renaming")
] = None,
season_offset: Annotated[
int | None, Field(description="Season number offset for renaming")
] = None,
season: Annotated[int | None, Field(description="Season number")] = None,
filter: Annotated[
str | None, Field(description="Comma-separated filter patterns to exclude")
] = None,
) -> dict:
async with Database() as db:
manager = TorrentManager(db)
existing = await db.bangumi.search_id(bangumi_id)
existing = await db.bangumi.search_id(id)
if not existing:
return {"error": f"Anime with id {bangumi_id} not found"}
return {"error": f"Anime with id {id} not found"}
update_data = BangumiUpdate(**existing.model_dump())
if "episode_offset" in args:
update_data.episode_offset = args["episode_offset"]
if "season_offset" in args:
update_data.season_offset = args["season_offset"]
if "season" in args:
update_data.season = args["season"]
if "filter" in args:
update_data.filter = args["filter"]
if episode_offset is not None:
update_data.episode_offset = episode_offset
if season_offset is not None:
update_data.season_offset = season_offset
if season is not None:
update_data.season = season
if filter is not None:
update_data.filter = filter
resp = await manager.update_rule(bangumi_id, update_data)
resp = await manager.update_rule(id, update_data)
return {"status": resp.status, "message": resp.msg_en}

View File

@@ -64,3 +64,59 @@ class TestLifespan:
pass
# 迁移失败时插件不应启动(插件 KV 表由迁移创建)
ctx.plugins.start.assert_not_called()
class TestMcpEndpoint:
"""MCP 走 Streamable HTTP,端点就是 /mcp;会话管理器由应用 lifespan 启动。"""
HEADERS = {
"Authorization": "Bearer mcp-token",
"Accept": "application/json, text/event-stream",
}
def test_streamable_http_initialize_and_list_tools(self):
service = MagicMock()
service.authenticate_api_token = AsyncMock(return_value=MagicMock())
with (
patch("main.AppContext.build", return_value=_lifespan_ctx()),
patch("module.mcp.security.auth_service", service),
):
app = main.create_app()
with TestClient(app, follow_redirects=False) as client:
init = client.post(
"/mcp",
headers=self.HEADERS,
json={
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "test", "version": "0"},
},
},
)
assert init.status_code == 200
session = {"mcp-session-id": init.headers["mcp-session-id"]}
client.post(
"/mcp",
headers=self.HEADERS | session,
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
)
listed = client.post(
"/mcp",
headers=self.HEADERS | session,
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
)
assert listed.status_code == 200
assert '"list_anime"' in listed.text
# 旧的 SSE 端点已删除
assert client.get("/mcp/sse", headers=self.HEADERS).status_code == 404
def test_rejected_without_token_or_whitelisted_ip(self):
with patch("main.AppContext.build", return_value=_lifespan_ctx()):
app = main.create_app()
with TestClient(app) as client:
resp = client.post("/mcp", json={})
assert resp.status_code == 403

View File

@@ -1,16 +1,14 @@
"""Tests for module.mcp.resources - await handle_resource() and _bangumi_to_dict()."""
"""Tests for module.mcp.resources - reads via mcp.read_resource() and _bangumi_to_dict()."""
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
from module.mcp.resources import (
RESOURCE_TEMPLATES,
RESOURCES,
_bangumi_to_dict,
handle_resource,
)
from module.mcp import mcp
from module.mcp.tools import _bangumi_to_dict
from module.models import Bangumi, ResponseModel
from test.factories import make_bangumi
@@ -53,36 +51,38 @@ def _mock_sync_manager(bangumi_list=None, single=None):
return mock_mgr, mock_mgr
async def handle_resource(uri: str) -> str:
"""经 ``mcp.read_resource`` 读取资源,返回文本内容。"""
[content] = await mcp.read_resource(uri)
assert isinstance(content, ReadResourceContents)
assert isinstance(content.content, str)
return content.content
def _parse(raw: str) -> dict | list:
return json.loads(raw)
# ---------------------------------------------------------------------------
# Static metadata (RESOURCES / RESOURCE_TEMPLATES)
# Static metadata (list_resources / list_resource_templates)
# ---------------------------------------------------------------------------
class TestResourceMetadata:
"""Verify the static resource and template lists."""
async def test_resources_is_list(self):
assert isinstance(RESOURCES, list)
async def test_resources_not_empty(self):
assert len(RESOURCES) > 0
"""Verify the registered resources and templates."""
async def test_resource_uris_present(self):
uris = {str(r.uri) for r in RESOURCES}
assert "autobangumi://anime/list" in uris
assert "autobangumi://status" in uris
assert "autobangumi://rss/feeds" in uris
async def test_resource_templates_is_list(self):
assert isinstance(RESOURCE_TEMPLATES, list)
resources = await mcp.list_resources()
assert {r.uri for r in resources} == {
"autobangumi://anime/list",
"autobangumi://status",
"autobangumi://rss/feeds",
}
assert all(r.mime_type == "application/json" for r in resources)
async def test_anime_id_template_present(self):
templates = [str(t.uriTemplate) for t in RESOURCE_TEMPLATES]
assert "autobangumi://anime/{id}" in templates
templates = [t.uri_template for t in await mcp.list_resource_templates()]
assert templates == ["autobangumi://anime/{id}"]
# ---------------------------------------------------------------------------
@@ -214,8 +214,8 @@ class TestHandleResourceStatus:
async def test_returns_json_string(self, mock_program):
with (
patch("module.mcp.resources.VERSION", "3.2.0-test"),
patch("module.mcp.resources.get_context", return_value=mock_program),
patch("module.mcp.tools.VERSION", "3.2.0-test"),
patch("module.mcp.tools.get_context", return_value=mock_program),
):
raw = await handle_resource("autobangumi://status")
assert isinstance(raw, str)
@@ -223,8 +223,8 @@ class TestHandleResourceStatus:
async def test_version_in_output(self, mock_program):
with (
patch("module.mcp.resources.VERSION", "3.2.0-test"),
patch("module.mcp.resources.get_context", return_value=mock_program),
patch("module.mcp.tools.VERSION", "3.2.0-test"),
patch("module.mcp.tools.get_context", return_value=mock_program),
):
result = _parse(await handle_resource("autobangumi://status"))
assert isinstance(result, dict)
@@ -233,8 +233,8 @@ class TestHandleResourceStatus:
async def test_running_true(self, mock_program):
mock_program.is_running = True
with (
patch("module.mcp.resources.VERSION", "3.2.0-test"),
patch("module.mcp.resources.get_context", return_value=mock_program),
patch("module.mcp.tools.VERSION", "3.2.0-test"),
patch("module.mcp.tools.get_context", return_value=mock_program),
):
result = _parse(await handle_resource("autobangumi://status"))
assert isinstance(result, dict)
@@ -243,8 +243,8 @@ class TestHandleResourceStatus:
async def test_first_run_false(self, mock_program):
mock_program.first_run = False
with (
patch("module.mcp.resources.VERSION", "3.2.0-test"),
patch("module.mcp.resources.get_context", return_value=mock_program),
patch("module.mcp.tools.VERSION", "3.2.0-test"),
patch("module.mcp.tools.get_context", return_value=mock_program),
):
result = _parse(await handle_resource("autobangumi://status"))
assert isinstance(result, dict)
@@ -252,8 +252,8 @@ class TestHandleResourceStatus:
async def test_all_keys_present(self, mock_program):
with (
patch("module.mcp.resources.VERSION", "3.2.0-test"),
patch("module.mcp.resources.get_context", return_value=mock_program),
patch("module.mcp.tools.VERSION", "3.2.0-test"),
patch("module.mcp.tools.get_context", return_value=mock_program),
):
result = _parse(await handle_resource("autobangumi://status"))
assert isinstance(result, dict)
@@ -374,26 +374,15 @@ class TestHandleResourceAnimeById:
class TestHandleResourceUnknown:
"""Tests for unrecognised resource URIs."""
async def test_unknown_uri_returns_json_error(self):
"""An unrecognised URI produces a JSON object with 'error'."""
result = _parse(await handle_resource("autobangumi://does/not/exist"))
assert "error" in result
async def test_unknown_uri_mentions_uri_in_error(self):
"""The error message includes the unrecognised URI."""
uri = "autobangumi://does/not/exist"
result = _parse(await handle_resource(uri))
assert isinstance(result, dict)
assert uri in result["error"]
async def test_empty_uri_returns_error(self):
"""An empty string URI returns a JSON error."""
result = _parse(await handle_resource(""))
assert "error" in result
async def test_completely_different_scheme_returns_error(self):
"""A URI with a wrong scheme returns a JSON error."""
result = _parse(
await handle_resource("https://autobangumi.example.com/anime/list")
)
assert "error" in result
@pytest.mark.parametrize(
"uri",
[
"autobangumi://does/not/exist",
"",
"https://autobangumi.example.com/anime/list",
],
)
async def test_unknown_uri_raises_not_found(self, uri):
"""未知 URI 抛出 ResourceNotFoundError(客户端收到 JSON-RPC 错误),消息带上 URI。"""
with pytest.raises(ResourceNotFoundError, match=f"Unknown resource: {uri}"):
await mcp.read_resource(uri)

View File

@@ -4,7 +4,7 @@ from unittest.mock import MagicMock
from module.mcp import create_mcp_app
from module.mcp.runtime import get_context, set_context
from module.mcp.tools import _get_program_status
from module.mcp.tools import get_program_status
class TestRuntimeContext:
@@ -28,7 +28,7 @@ class TestProgramStatusReadsContext:
ctx.first_run = False
set_context(ctx)
try:
result = _get_program_status()
result = get_program_status()
finally:
set_context(None)
assert result["running"] is True
@@ -36,6 +36,6 @@ class TestProgramStatusReadsContext:
def test_status_without_context_is_safe(self):
set_context(None)
result = _get_program_status()
result = get_program_status()
assert result["running"] is False
assert result["first_run"] is True

View File

@@ -1,16 +1,13 @@
"""Tests for module.mcp.tools - _bangumi_to_dict helper and _dispatch routing."""
"""Tests for module.mcp.tools - _bangumi_to_dict helper and tool calls via AutoBangumiMCP."""
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from mcp import types
from module.mcp.tools import (
TOOLS,
_bangumi_to_dict,
_dispatch,
handle_tool,
)
from module.mcp import mcp
from module.mcp.tools import _bangumi_to_dict
from module.models import Bangumi, ResponseModel
from test.factories import make_bangumi
@@ -19,6 +16,18 @@ from test.factories import make_bangumi
# ---------------------------------------------------------------------------
def _json(result: types.CallToolResult):
"""结果只有一段 JSON 文本,返回解析后的值。"""
[content] = result.content
assert isinstance(content, types.TextContent)
return json.loads(content.text)
async def _call(name: str, args: dict):
"""经 ``mcp.call_tool`` 调用工具,返回解析后的 JSON 文本。"""
return _json(await mcp.call_tool(name, args))
def _make_response(status: bool = True, msg: str = "OK") -> ResponseModel:
return ResponseModel(status=status, status_code=200, msg_en=msg, msg_zh=msg)
@@ -171,47 +180,56 @@ class TestBangumiToDict:
# ---------------------------------------------------------------------------
# TOOLS list
# tool definitions
# ---------------------------------------------------------------------------
class TestToolsDefinitions:
"""Sanity-check the static TOOLS list."""
"""内置工具的名称与参数名必须与旧的手写 schema 一致,已有客户端才能继续使用。"""
def test_tools_is_list(self):
assert isinstance(TOOLS, list)
def test_tools_not_empty(self):
assert len(TOOLS) > 0
def test_all_tools_have_names(self):
for tool in TOOLS:
assert tool.name, f"Tool missing name: {tool}"
def test_expected_tool_names_present(self):
names = {t.name for t in TOOLS}
required = {
"list_anime",
"get_anime",
"search_anime",
"subscribe_anime",
"unsubscribe_anime",
"list_downloads",
"list_rss_feeds",
"get_program_status",
"refresh_feeds",
"update_anime",
async def test_tool_names_and_arguments_unchanged(self):
tools = {t.name: t for t in await mcp.list_tools()}
expected = {
"list_anime": {"active_only"},
"get_anime": {"id"},
"search_anime": {"keywords", "site"},
"subscribe_anime": {"rss_link", "parser"},
"unsubscribe_anime": {"id", "delete"},
"list_downloads": {"status"},
"list_rss_feeds": set(),
"get_program_status": set(),
"refresh_feeds": set(),
"update_anime": {
"id",
"episode_offset",
"season_offset",
"season",
"filter",
},
}
assert required.issubset(names)
assert set(tools) == set(expected)
for name, args in expected.items():
assert set(tools[name].input_schema["properties"]) == args, name
assert tools[name].description
# 结果是 JSON 文本,不声明 outputSchema
assert tools[name].output_schema is None
async def test_required_and_enum_from_type_hints(self):
tools = {t.name: t for t in await mcp.list_tools()}
search = tools["search_anime"].input_schema
assert search["required"] == ["keywords"]
assert search["properties"]["site"]["enum"] == ["mikan", "dmhy", "nyaa"]
assert search["properties"]["site"]["default"] == "mikan"
assert tools["update_anime"].input_schema["required"] == ["id"]
# ---------------------------------------------------------------------------
# _dispatch routing
# tool routing
# ---------------------------------------------------------------------------
class TestDispatch:
"""Verify _dispatch delegates to the correct handler for each tool name."""
"""Verify mcp.call_tool delegates to the correct handler for each tool name."""
# --- list_anime ---
@@ -221,7 +239,7 @@ class TestDispatch:
ctx = _mock_db(bangumi_list=bangumi)
with patch("module.mcp.tools.Database", return_value=ctx):
result = await _dispatch("list_anime", {})
result = await _call("list_anime", {})
assert isinstance(result, list)
assert len(result) == 2
@@ -232,7 +250,7 @@ class TestDispatch:
ctx, mock_mgr = _mock_sync_manager(bangumi_list=bangumi)
with patch("module.mcp.tools.TorrentManager", return_value=ctx):
result = await _dispatch("list_anime", {"active_only": True})
result = await _call("list_anime", {"active_only": True})
mock_mgr.search_all_bangumi.assert_called_once()
assert len(result) == 1
@@ -245,7 +263,7 @@ class TestDispatch:
ctx, _ = _mock_sync_manager(single=bangumi)
with patch("module.mcp.tools.TorrentManager", return_value=ctx):
result = await _dispatch("get_anime", {"id": 5})
result = await _call("get_anime", {"id": 5})
assert isinstance(result, dict)
assert result["id"] == 5
@@ -262,7 +280,7 @@ class TestDispatch:
ctx, _ = _mock_sync_manager(single=not_found)
with patch("module.mcp.tools.TorrentManager", return_value=ctx):
result = await _dispatch("get_anime", {"id": 999})
result = await _call("get_anime", {"id": 999})
assert isinstance(result, dict)
assert "error" in result
@@ -285,7 +303,7 @@ class TestDispatch:
mock_st.__aexit__ = AsyncMock(return_value=False)
with patch("module.mcp.tools.SearchTorrent", return_value=mock_st):
result = await _dispatch(
result = await _call(
"search_anime", {"keywords": "One Piece", "site": "mikan"}
)
@@ -308,7 +326,7 @@ class TestDispatch:
mock_st.__aexit__ = AsyncMock(return_value=False)
with patch("module.mcp.tools.SearchTorrent", return_value=mock_st):
await _dispatch("search_anime", {"keywords": "Bleach"})
await _call("search_anime", {"keywords": "Bleach"})
assert captured_site == ["mikan"]
@@ -329,7 +347,7 @@ class TestDispatch:
new=AsyncMock(return_value=fake_resp),
),
):
result = await _dispatch(
result = await _call(
"subscribe_anime",
{"rss_link": "https://mikanani.me/RSS/test", "parser": "mikan"},
)
@@ -348,7 +366,7 @@ class TestDispatch:
mock_analyser.link_to_data = AsyncMock(return_value=fake_error)
with patch("module.mcp.tools.RSSAnalyser", return_value=mock_analyser):
result = await _dispatch(
result = await _call(
"subscribe_anime",
{"rss_link": "https://bad-rss.example.com", "parser": "mikan"},
)
@@ -363,7 +381,7 @@ class TestDispatch:
mock_mgr.disable_rule = AsyncMock(return_value=_make_response(True, "Disabled"))
with patch("module.mcp.tools.TorrentManager", return_value=ctx):
result = await _dispatch("unsubscribe_anime", {"id": 3, "delete": False})
result = await _call("unsubscribe_anime", {"id": 3, "delete": False})
mock_mgr.disable_rule.assert_called_once_with(3)
assert isinstance(result, dict)
@@ -375,7 +393,7 @@ class TestDispatch:
mock_mgr.delete_rule = AsyncMock(return_value=_make_response(True, "Deleted"))
with patch("module.mcp.tools.TorrentManager", return_value=ctx):
result = await _dispatch("unsubscribe_anime", {"id": 3, "delete": True})
result = await _call("unsubscribe_anime", {"id": 3, "delete": True})
mock_mgr.delete_rule.assert_called_once_with(3)
assert isinstance(result, dict)
@@ -404,7 +422,7 @@ class TestDispatch:
with patch(
"module.downloader.download_client.DownloadClient", return_value=mock_client
):
result = await _dispatch("list_downloads", {"status": "all"})
result = await _call("list_downloads", {"status": "all"})
mock_client.get_torrent_info.assert_called_once_with(
status_filter=None, category="Bangumi"
@@ -422,7 +440,7 @@ class TestDispatch:
with patch(
"module.downloader.download_client.DownloadClient", return_value=mock_client
):
await _dispatch("list_downloads", {"status": "downloading"})
await _call("list_downloads", {"status": "downloading"})
mock_client.get_torrent_info.assert_called_once_with(
status_filter="downloading", category="Bangumi"
@@ -450,7 +468,7 @@ class TestDispatch:
with patch(
"module.downloader.download_client.DownloadClient", return_value=mock_client
):
result = await _dispatch("list_downloads", {})
result = await _call("list_downloads", {})
expected_keys = {
"name",
@@ -482,7 +500,7 @@ class TestDispatch:
ctx = _mock_db(feeds=[fake_feed])
with patch("module.mcp.tools.Database", return_value=ctx):
result = await _dispatch("list_rss_feeds", {})
result = await _call("list_rss_feeds", {})
assert isinstance(result, list)
assert result[0]["name"] == "Mikan"
@@ -498,18 +516,11 @@ class TestDispatch:
with (
patch("module.mcp.tools.VERSION", "3.2.0-beta"),
patch("module.mcp.tools._get_program_status") as mock_fn,
patch("module.mcp.tools.get_context", return_value=mock_program),
):
mock_fn.return_value = {
"version": "3.2.0-beta",
"running": True,
"first_run": False,
}
result = await _dispatch("get_program_status", {})
result = await _call("get_program_status", {})
assert "version" in result
assert "running" in result
assert "first_run" in result
assert result == {"version": "3.2.0-beta", "running": True, "first_run": False}
# --- refresh_feeds ---
@@ -526,7 +537,7 @@ class TestDispatch:
patch("module.mcp.tools.DownloaderPool", return_value=mock_client),
patch("module.mcp.tools.RSSEngine", return_value=mock_engine),
):
result = await _dispatch("refresh_feeds", {})
result = await _call("refresh_feeds", {})
assert isinstance(result, dict)
assert result["status"] is True
@@ -547,7 +558,7 @@ class TestDispatch:
patch("module.mcp.tools.Database", return_value=db_ctx),
patch("module.mcp.tools.TorrentManager", return_value=ctx),
):
result = await _dispatch(
result = await _call(
"update_anime",
{"id": 7, "episode_offset": 12, "season": 2},
)
@@ -562,7 +573,7 @@ class TestDispatch:
mock_mgr.bangumi.search_id.return_value = None
with patch("module.mcp.tools.TorrentManager", return_value=ctx):
result = await _dispatch("update_anime", {"id": 9999})
result = await _call("update_anime", {"id": 9999})
assert isinstance(result, dict)
assert "error" in result
@@ -571,60 +582,44 @@ class TestDispatch:
# --- unknown tool ---
async def test_dispatch_unknown_tool(self):
"""An unrecognised tool name returns an error dict."""
result = await _dispatch("does_not_exist", {})
assert isinstance(result, dict)
assert "error" in result
assert "does_not_exist" in result["error"]
"""An unrecognised tool name returns an is_error result."""
result = await mcp.call_tool("does_not_exist", {})
assert result.is_error
body = _json(result)
assert body == {"error": "Unknown tool: does_not_exist"}
# ---------------------------------------------------------------------------
# handle_tool wrapper
# CallToolResult
# ---------------------------------------------------------------------------
class TestHandleTool:
"""Verify handle_tool wraps results correctly and handles exceptions."""
class TestCallToolResult:
"""Verify call_tool wraps results in one JSON text block and turns errors into is_error."""
async def test_handle_tool_returns_text_content_list(self):
"""handle_tool always returns a list of TextContent objects."""
from mcp import types
bangumi = [make_bangumi(id=1)]
async def test_result_is_single_json_text_block(self):
"""列表结果也只有一段 JSON 文本(MCPServer 默认会拆成每项一段)。"""
bangumi = [make_bangumi(id=1), make_bangumi(id=2, title_raw="Another")]
ctx = _mock_db(bangumi_list=bangumi)
with patch("module.mcp.tools.Database", return_value=ctx):
result = await handle_tool("list_anime", {})
result = await mcp.call_tool("list_anime", {})
assert isinstance(result, list)
assert all(isinstance(item, types.TextContent) for item in result)
assert not result.is_error
assert [b["id"] for b in _json(result)] == [1, 2]
async def test_handle_tool_result_is_valid_json(self):
"""The text in TextContent is valid JSON."""
bangumi = [make_bangumi(id=1)]
ctx = _mock_db(bangumi_list=bangumi)
with patch("module.mcp.tools.Database", return_value=ctx):
result = await handle_tool("list_anime", {})
parsed = json.loads(result[0].text)
assert isinstance(parsed, list)
async def test_handle_tool_exception_returns_error_json(self):
"""If the underlying handler raises, handle_tool returns a JSON error."""
async def test_exception_returns_is_error_with_message(self):
"""处理函数抛出异常时返回 is_error 结果,错误文本保留原始消息。"""
with patch(
"module.mcp.tools._dispatch",
new=AsyncMock(side_effect=RuntimeError("something broke")),
"module.mcp.tools.Database",
side_effect=RuntimeError("something broke"),
):
result = await handle_tool("list_anime", {})
result = await mcp.call_tool("list_anime", {})
assert len(result) == 1
body = json.loads(result[0].text)
assert "error" in body
assert "something broke" in body["error"]
assert result.is_error
assert _json(result) == {"error": "something broke"}
async def test_handle_tool_unknown_name_returns_error_json(self):
"""An unknown tool name bubbles up as a JSON error via handle_tool."""
result = await handle_tool("totally_unknown_tool", {})
body = json.loads(result[0].text)
assert "error" in body
async def test_invalid_arguments_return_is_error(self):
result = await mcp.call_tool("get_anime", {"id": "abc"})
assert result.is_error
assert "id" in _json(result)["error"]

View File

@@ -4,6 +4,7 @@ import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import mcp.types as mcp_types
import pytest
from fastapi import APIRouter, FastAPI, HTTPException
from fastapi.testclient import TestClient
@@ -383,9 +384,29 @@ async def _search(args):
return {"q": args.get("q"), "hits": [1, 2]}
def _tool_json(result: mcp_types.CallToolResult):
"""工具结果只有一段 JSON 文本,返回解析后的值。"""
[content] = result.content
assert isinstance(content, mcp_types.TextContent)
return json.loads(content.text)
async def _read_text(uri: str) -> str:
from mcp.server.lowlevel.helper_types import ReadResourceContents
from module.mcp import mcp
[content] = await mcp.read_resource(uri)
assert isinstance(content, ReadResourceContents)
assert isinstance(content.content, str)
return content.content
class TestMcp:
def test_plugin_tools_listed_with_namespace(self, registry):
from module.mcp.tools import TOOLS, all_tools
async def test_plugin_tools_listed_with_namespace(self, registry):
from mcp.server.mcpserver import MCPServer
from module.mcp import mcp
add(
registry,
@@ -394,27 +415,29 @@ class TestMcp:
McpTool("Search", _search, {"type": "object"}),
plugin_id="demo",
)
names = [t.name for t in all_tools()]
assert names[: len(TOOLS)] == [t.name for t in TOOLS]
assert "demo__search" in names
builtin = [t.name for t in await MCPServer.list_tools(mcp)]
names = [t.name for t in await mcp.list_tools()]
assert names == builtin + ["demo__search"]
async def test_call_dispatches_to_plugin(self, registry):
from module.mcp.tools import handle_tool
from module.mcp import mcp
add(registry, points.MCP_TOOL, "search", McpTool("S", _search), "demo")
[content] = await handle_tool("demo__search", {"q": "x"})
assert json.loads(content.text) == {"q": "x", "hits": [1, 2]}
result = await mcp.call_tool("demo__search", {"q": "x"})
assert not result.is_error
assert _tool_json(result) == {"q": "x", "hits": [1, 2]}
async def test_unknown_names_keep_error(self, registry):
from module.mcp.tools import handle_tool
from module.mcp import mcp
add(registry, points.MCP_TOOL, "search", McpTool("S", _search), "demo")
for name in ("nope", "demo__nope", "other__search"):
[content] = await handle_tool(name, {})
assert json.loads(content.text) == {"error": f"Unknown tool: {name}"}
result = await mcp.call_tool(name, {})
assert result.is_error
assert _tool_json(result) == {"error": f"Unknown tool: {name}"}
async def test_failure_returns_error_and_counts(self, registry, breaker):
from module.mcp.tools import handle_tool
from module.mcp import mcp
async def boom(args):
raise RuntimeError("plugin bug")
@@ -422,10 +445,29 @@ class TestMcp:
add(registry, points.MCP_TOOL, "boom", McpTool("B", boom), "demo")
ctx = SimpleNamespace(plugins=SimpleNamespace(breaker=breaker))
with patch("module.mcp.plugins.get_context", return_value=ctx):
[content] = await handle_tool("demo__boom", {})
assert json.loads(content.text) == {"error": "plugin bug"}
result = await mcp.call_tool("demo__boom", {})
assert result.is_error
assert _tool_json(result) == {"error": "plugin bug"}
assert breaker._failures["demo"] == 1
async def test_invalid_arguments_rejected_before_handler(self, registry, breaker):
from module.mcp import mcp
handler = AsyncMock()
schema = {
"type": "object",
"properties": {"q": {"type": "string"}},
"required": ["q"],
}
add(registry, points.MCP_TOOL, "search", McpTool("S", handler, schema), "demo")
ctx = SimpleNamespace(plugins=SimpleNamespace(breaker=breaker))
with patch("module.mcp.plugins.get_context", return_value=ctx):
result = await mcp.call_tool("demo__search", {})
assert result.is_error
assert "'q' is a required property" in _tool_json(result)["error"]
handler.assert_not_called()
assert "demo" not in breaker._failures
def test_invalid_names_and_bad_factories_skipped(self, registry):
from module.mcp.plugins import list_plugin_tools
@@ -435,7 +477,10 @@ class TestMcp:
assert [t.name for t in list_plugin_tools()] == ["demo__ok"]
async def test_plugin_resources(self, registry):
from module.mcp.resources import RESOURCES, all_resources, handle_resource
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
from module.mcp import mcp
async def stats():
return {"count": 3}
@@ -445,24 +490,26 @@ class TestMcp:
add(registry, points.MCP_RESOURCE, "stats", McpResource("Stats", stats), "demo")
add(registry, points.MCP_RESOURCE, "text", McpResource("Text", text), "demo")
uris = [str(r.uri) for r in all_resources()]
assert uris[: len(RESOURCES)] == [str(r.uri) for r in RESOURCES]
builtin = [r.uri for r in await MCPServer.list_resources(mcp)]
uris = [r.uri for r in await mcp.list_resources()]
assert uris[: len(builtin)] == builtin
assert "autobangumi://plugins/demo/stats" in uris
stats_json = await handle_resource("autobangumi://plugins/demo/stats")
stats_json = await _read_text("autobangumi://plugins/demo/stats")
assert json.loads(stats_json) == {"count": 3}
assert await handle_resource("autobangumi://plugins/demo/text") == "plain"
missing = await handle_resource("autobangumi://plugins/demo/none")
assert json.loads(missing) == {
"error": "Unknown resource: autobangumi://plugins/demo/none"
}
assert await _read_text("autobangumi://plugins/demo/text") == "plain"
with pytest.raises(
ResourceNotFoundError,
match="Unknown resource: autobangumi://plugins/demo/none",
):
await mcp.read_resource("autobangumi://plugins/demo/none")
@pytest.mark.parametrize("resource_id", ["stats", "Weekly Stats", "统计"])
async def test_read_resource_via_protocol_returns_plugin_content(
self, registry, resource_id
):
from mcp import types
from mcp import Client, types
from module.mcp import server
from module.mcp import mcp
async def stats():
return {"count": 3}
@@ -474,20 +521,14 @@ class TestMcp:
McpResource("Stats", stats),
"demo",
)
[listed] = [
r
for r in await server.list_resources()
if str(r.uri).startswith("autobangumi://plugins/")
]
handler = server.server.request_handlers[types.ReadResourceRequest]
result = await handler(
types.ReadResourceRequest(
method="resources/read",
params=types.ReadResourceRequestParams(uri=listed.uri),
)
)
assert isinstance(result.root, types.ReadResourceResult)
[content] = result.root.contents
async with Client(mcp) as client:
[listed] = [
r
for r in (await client.list_resources()).resources
if r.uri.startswith("autobangumi://plugins/")
]
result = await client.read_resource(listed.uri)
[content] = result.contents
assert isinstance(content, types.TextResourceContents)
assert json.loads(content.text) == {"count": 3}
@@ -498,17 +539,18 @@ class TestMcp:
(points.MCP_RESOURCE, McpResource(None, _search)), # type: ignore[arg-type]
],
)
def test_list_malformed_plugin_spec_skipped_and_counted(
async def test_list_malformed_plugin_spec_skipped_and_counted(
self, registry, breaker, point, impl
):
from module.mcp.resources import RESOURCES, all_resources
from module.mcp.tools import TOOLS, all_tools
from mcp.server.mcpserver import MCPServer
from module.mcp import mcp
add(registry, point, "bad", impl, "demo")
ctx = SimpleNamespace(plugins=SimpleNamespace(breaker=breaker))
with patch("module.mcp.plugins.get_context", return_value=ctx):
assert len(all_tools()) == len(TOOLS)
assert len(all_resources()) == len(RESOURCES)
assert await mcp.list_tools() == await MCPServer.list_tools(mcp)
assert await mcp.list_resources() == await MCPServer.list_resources(mcp)
assert breaker._failures["demo"] == 1
async def test_read_plugin_resource_hung_handler_times_out_and_counts(
@@ -516,8 +558,9 @@ class TestMcp:
):
import asyncio
from module.mcp import plugins
from module.mcp.resources import handle_resource
from mcp.server.mcpserver.exceptions import ResourceError
from module.mcp import mcp, plugins
async def hang():
await asyncio.Event().wait()
@@ -525,16 +568,25 @@ class TestMcp:
monkeypatch.setattr(plugins, "DEFAULT_TOOL_TIMEOUT", 0.01)
add(registry, points.MCP_RESOURCE, "hang", McpResource("H", hang), "demo")
ctx = SimpleNamespace(plugins=SimpleNamespace(breaker=breaker))
with patch("module.mcp.plugins.get_context", return_value=ctx):
raw = await handle_resource("autobangumi://plugins/demo/hang")
assert "error" in json.loads(raw)
with (
patch("module.mcp.plugins.get_context", return_value=ctx),
pytest.raises(ResourceError, match="TimeoutError"),
):
await mcp.read_resource("autobangumi://plugins/demo/hang")
assert breaker._failures["demo"] == 1
async def test_mcp_server_handlers_include_plugins(self, registry):
from module.mcp import server
async def test_plugin_tool_via_protocol(self, registry):
from mcp import Client
from module.mcp import mcp
add(registry, points.MCP_TOOL, "search", McpTool("S", _search), "demo")
assert "demo__search" in [t.name for t in await server.list_tools()]
async with Client(mcp) as client:
listed = await client.list_tools()
assert "demo__search" in [t.name for t in listed.tools]
result = await client.call_tool("demo__search", {"q": "x"})
assert not result.is_error
assert _tool_json(result) == {"q": "x", "hits": [1, 2]}
# ---------------------------------------------------------------- end to end
@@ -589,7 +641,7 @@ entry = "x:Y"
async def test_plugin_end_to_end(registry, monkeypatch, tmp_path):
from module.core import AppContext
from module.core.scheduler import Scheduler
from module.mcp.tools import all_tools, handle_tool
from module.mcp import mcp
# AppContext 会设置进程级 bus/runner;用 monkeypatch 让测试结束后复原
monkeypatch.setattr(host, "_bus", None)
@@ -632,9 +684,9 @@ async def test_plugin_end_to_end(registry, monkeypatch, tmp_path):
assert title == f"yo: {event.describe()[0]}"
# MCP
assert "full__greet" in [t.name for t in all_tools()]
[content] = await handle_tool("full__greet", {})
assert json.loads(content.text) == {"greeting": "yo"}
assert "full__greet" in [t.name for t in await mcp.list_tools()]
result = await mcp.call_tool("full__greet", {})
assert _tool_json(result) == {"greeting": "yo"}
# 路由
app = FastAPI()
@@ -652,44 +704,7 @@ async def test_plugin_end_to_end(registry, monkeypatch, tmp_path):
settings_obj.plugins.enabled["full"] = False
await manager.apply_settings()
assert client.get("/api/v1/plugins/full/greet").status_code == 404
assert "full__greet" not in [t.name for t in all_tools()]
assert "full__greet" not in [t.name for t in await mcp.list_tools()]
assert await render_event(event, "bark") == event.describe()
finally:
await manager.stop()
# ---------------------------------------------------------------- MCP SSE
async def test_mcp_sse_endpoint_sends_response_only_once(monkeypatch):
"""/sse 结束后不能再发第二次 http.response.start(3.x 起每次断开都报
AssertionError)。"""
from contextlib import asynccontextmanager
from module.mcp import server as mcp_server
@asynccontextmanager
async def fake_connect_sse(scope, receive, send):
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b"", "more_body": False})
yield (object(), object())
async def fake_run(*args, **kwargs):
return None
monkeypatch.setattr(mcp_server.sse, "connect_sse", fake_connect_sse)
monkeypatch.setattr(mcp_server.server, "run", fake_run)
sent: list[dict] = []
async def receive():
return {"type": "http.disconnect"}
async def send(message):
sent.append(message)
await mcp_server.handle_sse(
{"type": "http", "method": "GET", "path": "/sse", "headers": []},
receive,
send,
)
assert [m["type"] for m in sent].count("http.response.start") == 1

View File

@@ -200,8 +200,8 @@ class TestSpaCsp:
assert resp.status_code == 200
assert "content-security-policy" not in resp.headers
def test_mcp_mount_wins_over_fallback(self, spa):
resp = spa.get("/mcp/sse", headers=HTML)
def test_mcp_route_wins_over_fallback(self, spa):
resp = spa.get("/mcp", headers=HTML)
assert resp.status_code != 200
assert "content-security-policy" not in resp.headers

52
backend/uv.lock generated
View File

@@ -150,6 +150,7 @@ dependencies = [
{ name = "google-genai" },
{ name = "httpx2", extra = ["socks"] },
{ name = "jinja2" },
{ name = "jsonschema" },
{ name = "mcp" },
{ name = "openai" },
{ name = "packaging" },
@@ -184,7 +185,8 @@ requires-dist = [
{ name = "google-genai", specifier = ">=2.10.0" },
{ name = "httpx2", extras = ["socks"], specifier = ">=2.15" },
{ name = "jinja2", specifier = ">=3.1.2" },
{ name = "mcp", specifier = ">=1.8.0" },
{ name = "jsonschema", specifier = ">=4.20.0" },
{ name = "mcp", specifier = ">=2.3.0" },
{ name = "openai", specifier = ">=1.54.3" },
{ name = "packaging", specifier = ">=26.3" },
{ name = "pydantic", specifier = ">=2.14.1" },
@@ -620,15 +622,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "httpx-sse"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
]
[[package]]
name = "httpx2"
version = "2.15.1"
@@ -881,15 +874,15 @@ wheels = [
[[package]]
name = "mcp"
version = "1.26.0"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "httpx2" },
{ name = "jsonschema" },
{ name = "mcp-types" },
{ name = "opentelemetry-api" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
@@ -899,9 +892,22 @@ dependencies = [
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
{ url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" },
]
[[package]]
name = "mcp-types"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" },
]
[[package]]
@@ -1128,20 +1134,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"

View File

@@ -208,7 +208,7 @@ curl -N -H "Authorization: Bearer $AB_TOKEN" http://your-host:7892/api/v1/events
## MCP
AB 在根路径的 `/mcp` 下提供 MCP 服务(SSE 传输):客户端连接 `GET /mcp/sse`,并向 `POST /mcp/messages/` 发送消息。
AB 在根路径的 `/mcp` 提供 MCP 服务(Streamable HTTP 传输):在 MCP 客户端中把服务地址设为 `http://<AB 地址>:7892/mcp`。3.x 与 4.0.0-beta.2 及更早版本的 SSE 端点 `/mcp/sse` 已删除。
- 访问控制与 REST API 分开:客户端 IP 在 `security.mcp_whitelist` 内,或请求带 `scope=mcp` 的令牌(`Authorization: Bearer <令牌>`)。`mcp_whitelist` 为空时拒绝所有基于 IP 的访问,令牌仍然有效。
- 内置工具:`list_anime`、`get_anime`、`search_anime`、`subscribe_anime`、`unsubscribe_anime`、`list_downloads`、`list_rss_feeds`、`get_program_status`、`refresh_feeds`、`update_anime`。

View File

@@ -4,6 +4,16 @@
升级步骤、回滚方法与升级后的检查项见 [从 3.3 升级到 4.0](../deploy/upgrade-4.0)。
## 4.0.0-beta.3
### 破坏性变更
- MCP 改用 Streamable HTTP,端点为 `/mcp`,旧的 `/mcp/sse` 已删除,请更新 MCP 客户端的地址。
- 带 JSON 请求体的 API 调用必须发送 `Content-Type: application/json`,否则返回 422。
- 未知的 `/api/…` 路径返回 404,不再返回 WebUI 页面。
- 插件 SDK(`ab_sdk`)0.6.0:`AdapterContext.build_http_client` 返回 `httpx2.AsyncClient`,只调用 `get` / `post` 的插件不需要改动,直接使用 httpx 专有 API 的插件必须更新。
- 运行环境:Python 3.14。
## 4.0.0-beta.1
Docker 镜像:`ghcr.io/estrellaxd/auto_bangumi:4.0.0-beta.1`(`dev-latest` 也指向最新测试版)。

View File

@@ -208,7 +208,7 @@ These endpoints need no authentication. `GET /setup/status` is always available
## MCP
AB provides an MCP server under `/mcp` at the root path (SSE transport). A client connects to `GET /mcp/sse` and sends messages to `POST /mcp/messages/`.
AB provides an MCP server at `/mcp` at the root path (Streamable HTTP transport). In the MCP client, set the server URL to `http://<AB address>:7892/mcp`. The SSE endpoint `/mcp/sse` of 3.x and 4.0.0-beta.2 and earlier is removed.
- Access control is different from the REST API. The client IP must be in `security.mcp_whitelist`, or the request must have a token with `scope=mcp` (`Authorization: Bearer <token>`). If `mcp_whitelist` is empty, AB refuses all IP-based access. Tokens still work.
- Built-in tools: `list_anime`, `get_anime`, `search_anime`, `subscribe_anime`, `unsubscribe_anime`, `list_downloads`, `list_rss_feeds`, `get_program_status`, `refresh_feeds`, `update_anime`.

View File

@@ -208,7 +208,7 @@ curl -N -H "Authorization: Bearer $AB_TOKEN" http://your-host:7892/api/v1/events
## MCP
AB はルートパスの `/mcp` 以下で MCP サーバー(SSE トランスポート)を提供します。クライアントは `GET /mcp/sse` に接続し、`POST /mcp/messages/` にメッセージを送ります。
AB はルートパスの `/mcp` で MCP サーバー(Streamable HTTP トランスポート)を提供します。MCP クライアントにはサーバーの URL として `http://<AB のアドレス>:7892/mcp` を設定します。3.x と 4.0.0-beta.2 以前の SSE エンドポイント `/mcp/sse` は削除されました。
- アクセス制御は REST API とは別です。クライアントの IP が `security.mcp_whitelist` に含まれるか、リクエストに `scope=mcp` のトークン(`Authorization: Bearer <トークン>`)が必要です。`mcp_whitelist` が空の場合、IP によるアクセスはすべて拒否されます。トークンは引き続き使えます。
- 組み込みツール:`list_anime`、`get_anime`、`search_anime`、`subscribe_anime`、`unsubscribe_anime`、`list_downloads`、`list_rss_feeds`、`get_program_status`、`refresh_feeds`、`update_anime`。

View File

@@ -106,7 +106,7 @@ test('API and MCP tokens are isolated and Bearer cannot control accounts', async
await bearerApi.bearerGet('/api/v1/users', apiToken.token, 403);
await bearerApi.bearerGet('/api/v1/tokens', apiToken.token, 403);
await bearerApi.bearerGet('/api/v1/bangumi/get/all', mcpToken.token, 401);
await bearerApi.bearerGet('/mcp/sse', apiToken.token, 403);
await bearerApi.bearerGet('/mcp', apiToken.token, 403);
// An explicit invalid Authorization header must beat the valid admin cookie.
await api.bearerGet('/api/v1/bangumi/get/all', mcpToken.token, 401);
@@ -114,8 +114,23 @@ test('API and MCP tokens are isolated and Bearer cannot control accounts', async
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(`${environment.baseURL}/mcp/sse`, {
headers: { Authorization: `Bearer ${mcpToken.token}` },
const response = await fetch(`${environment.baseURL}/mcp`, {
method: 'POST',
headers: {
Authorization: `Bearer ${mcpToken.token}`,
Accept: 'application/json, text/event-stream',
'Content-Type': 'application/json',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'e2e', version: '0' },
},
}),
signal: controller.signal,
});
expect(response.status).toBe(200);