Skip to content

Commit fb2dd17

Browse files
EstrellaXDclaude
andcommitted
feat(mcp): add MCP server for LLM tool integration via SSE
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ba263e0 commit fb2dd17

12 files changed

Lines changed: 2128 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
# [3.2.3-beta.5] - 2026-02-22
2+
3+
## Backend
4+
5+
### Added
6+
7+
- 新增 MCP (Model Context Protocol) 服务器,支持通过 Claude Desktop 等 LLM 工具管理番剧订阅
8+
- SSE 传输层挂载在 `/mcp/sse`,支持 MCP 客户端连接
9+
- 10 个工具:list_anime、get_anime、search_anime、subscribe_anime、unsubscribe_anime、list_downloads、list_rss_feeds、get_program_status、refresh_feeds、update_anime
10+
- 4 个资源:anime/list、anime/{id}、status、rss/feeds
11+
- 本地网络 IP 白名单安全中间件(RFC 1918 + 回环地址),无需 JWT 认证
12+
13+
---
14+
115
# [3.2.3-beta.4] - 2026-02-22
216

317
## Backend

backend/pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "auto-bangumi"
3-
version = "3.2.3-beta.4"
3+
version = "3.2.3-beta.5"
44
description = "AutoBangumi - Automated anime download manager"
55
requires-python = ">=3.13"
66
dependencies = [
@@ -24,6 +24,7 @@ dependencies = [
2424
"sse-starlette>=1.6.5",
2525
"webauthn>=2.0.0",
2626
"urllib3>=2.0.3",
27+
"mcp[cli]>=1.8.0",
2728
]
2829

2930
[dependency-groups]

backend/src/main.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
88
from fastapi.staticfiles import StaticFiles
99
from fastapi.templating import Jinja2Templates
10+
1011
from module.api import v1
1112
from module.api.program import program
1213
from module.conf import VERSION, settings, setup_logger
14+
from module.mcp import create_mcp_app
1315

1416
setup_logger(reset=True)
1517
logger = logging.getLogger(__name__)
@@ -45,6 +47,9 @@ def create_app() -> FastAPI:
4547
# mount routers
4648
app.include_router(v1, prefix="/api")
4749

50+
# mount MCP server (SSE transport for LLM tool integration)
51+
app.mount("/mcp", create_mcp_app())
52+
4853
return app
4954

5055

@@ -73,6 +78,7 @@ def html(request: Request, path: str):
7378
else:
7479
context = {"request": request}
7580
return templates.TemplateResponse("index.html", context)
81+
7682
else:
7783

7884
@app.get("/", status_code=302, tags=["html"])

backend/src/module/mcp/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""MCP (Model Context Protocol) server for AutoBangumi.
2+
3+
Exposes anime subscriptions, RSS feeds, and download status to MCP clients
4+
(e.g. Claude Desktop) over a local-network-restricted SSE endpoint.
5+
6+
Usage::
7+
8+
from module.mcp import create_mcp_app
9+
10+
app = create_mcp_app() # returns a Starlette ASGI app, mount at /mcp
11+
"""
12+
13+
from .server import create_mcp_starlette_app as create_mcp_app
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""MCP resource definitions and handlers for AutoBangumi.
2+
3+
``RESOURCES`` lists static resources; ``RESOURCE_TEMPLATES`` lists URI
4+
templates for parameterised lookups. ``handle_resource`` resolves a URI
5+
to its JSON payload.
6+
"""
7+
8+
import json
9+
import logging
10+
11+
from mcp import types
12+
13+
from module.conf import VERSION
14+
from module.manager import TorrentManager
15+
from module.models import Bangumi
16+
from module.rss import RSSEngine
17+
18+
from .tools import _bangumi_to_dict
19+
20+
logger = logging.getLogger(__name__)
21+
22+
RESOURCES = [
23+
types.Resource(
24+
uri="autobangumi://anime/list",
25+
name="All tracked anime",
26+
description="List of all anime subscriptions being tracked by AutoBangumi",
27+
mimeType="application/json",
28+
),
29+
types.Resource(
30+
uri="autobangumi://status",
31+
name="Program status",
32+
description="Current AutoBangumi program status, version, and state",
33+
mimeType="application/json",
34+
),
35+
types.Resource(
36+
uri="autobangumi://rss/feeds",
37+
name="RSS feeds",
38+
description="All configured RSS feeds with health status",
39+
mimeType="application/json",
40+
),
41+
]
42+
43+
RESOURCE_TEMPLATES = [
44+
types.ResourceTemplate(
45+
uriTemplate="autobangumi://anime/{id}",
46+
name="Anime details",
47+
description="Detailed information about a specific tracked anime by ID",
48+
mimeType="application/json",
49+
),
50+
]
51+
52+
53+
def handle_resource(uri: str) -> str:
54+
"""Return a JSON string for the given MCP resource URI.
55+
56+
Supported URIs:
57+
- ``autobangumi://anime/list`` - all tracked anime
58+
- ``autobangumi://status`` - program version and running state
59+
- ``autobangumi://rss/feeds`` - configured RSS feeds
60+
- ``autobangumi://anime/{id}`` - single anime by integer ID
61+
"""
62+
if uri == "autobangumi://anime/list":
63+
with TorrentManager() as manager:
64+
items = manager.bangumi.search_all()
65+
return json.dumps([_bangumi_to_dict(b) for b in items], ensure_ascii=False)
66+
67+
elif uri == "autobangumi://status":
68+
from module.api.program import program
69+
70+
return json.dumps(
71+
{
72+
"version": VERSION,
73+
"running": program.is_running,
74+
"first_run": program.first_run,
75+
}
76+
)
77+
78+
elif uri == "autobangumi://rss/feeds":
79+
with RSSEngine() as engine:
80+
feeds = engine.rss.search_all()
81+
return json.dumps(
82+
[
83+
{
84+
"id": f.id,
85+
"name": f.name,
86+
"url": f.url,
87+
"enabled": f.enabled,
88+
"connection_status": f.connection_status,
89+
"last_checked_at": f.last_checked_at,
90+
}
91+
for f in feeds
92+
],
93+
ensure_ascii=False,
94+
)
95+
96+
elif uri.startswith("autobangumi://anime/"):
97+
anime_id = uri.split("/")[-1]
98+
try:
99+
anime_id = int(anime_id)
100+
except ValueError:
101+
return json.dumps({"error": f"Invalid anime ID: {anime_id}"})
102+
with TorrentManager() as manager:
103+
result = manager.search_one(anime_id)
104+
if isinstance(result, Bangumi):
105+
return json.dumps(_bangumi_to_dict(result), ensure_ascii=False)
106+
return json.dumps({"error": result.msg_en})
107+
108+
return json.dumps({"error": f"Unknown resource: {uri}"})

backend/src/module/mcp/security.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""MCP access control: restricts connections to local network addresses only."""
2+
3+
import ipaddress
4+
import logging
5+
6+
from starlette.middleware.base import BaseHTTPMiddleware
7+
from starlette.requests import Request
8+
from starlette.responses import JSONResponse
9+
10+
logger = logging.getLogger(__name__)
11+
12+
# RFC 1918 private ranges + loopback + IPv6 equivalents
13+
_ALLOWED_NETWORKS = [
14+
ipaddress.ip_network("127.0.0.0/8"),
15+
ipaddress.ip_network("10.0.0.0/8"),
16+
ipaddress.ip_network("172.16.0.0/12"),
17+
ipaddress.ip_network("192.168.0.0/16"),
18+
ipaddress.ip_network("::1/128"),
19+
ipaddress.ip_network("fe80::/10"),
20+
ipaddress.ip_network("fc00::/7"),
21+
]
22+
23+
24+
def _is_local(host: str) -> bool:
25+
"""Return True if *host* is a loopback or RFC 1918 private address."""
26+
try:
27+
addr = ipaddress.ip_address(host)
28+
except ValueError:
29+
return False
30+
return any(addr in net for net in _ALLOWED_NETWORKS)
31+
32+
33+
class LocalNetworkMiddleware(BaseHTTPMiddleware):
34+
"""Starlette middleware that blocks requests from non-local IP addresses.
35+
36+
Returns HTTP 403 for any client outside loopback, RFC 1918, or IPv6
37+
link-local/unique-local ranges.
38+
"""
39+
40+
async def dispatch(self, request: Request, call_next):
41+
client_host = request.client.host if request.client else None
42+
if not client_host or not _is_local(client_host):
43+
logger.warning("[MCP] Rejected non-local connection from %s", client_host)
44+
return JSONResponse(
45+
status_code=403,
46+
content={"error": "MCP access is restricted to local network"},
47+
)
48+
return await call_next(request)

backend/src/module/mcp/server.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""MCP server assembly for AutoBangumi.
2+
3+
Wires together the MCP ``Server``, SSE transport, tool/resource handlers,
4+
and local-network middleware into a single Starlette ASGI application.
5+
6+
Mount the app returned by ``create_mcp_starlette_app`` at a path prefix
7+
(e.g. ``/mcp``) in the parent FastAPI application to expose the MCP
8+
endpoint at ``/mcp/sse``.
9+
"""
10+
11+
import logging
12+
13+
from mcp import types
14+
from mcp.server import Server
15+
from mcp.server.sse import SseServerTransport
16+
from starlette.applications import Starlette
17+
from starlette.requests import Request
18+
from starlette.routing import Mount, Route
19+
20+
from .resources import RESOURCE_TEMPLATES, RESOURCES, handle_resource
21+
from .security import LocalNetworkMiddleware
22+
from .tools import TOOLS, handle_tool
23+
24+
logger = logging.getLogger(__name__)
25+
26+
server = Server("autobangumi")
27+
sse = SseServerTransport("/messages/")
28+
29+
30+
@server.list_tools()
31+
async def list_tools() -> list[types.Tool]:
32+
return TOOLS
33+
34+
35+
@server.call_tool()
36+
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
37+
logger.debug("[MCP] Tool called: %s", name)
38+
return await handle_tool(name, arguments)
39+
40+
41+
@server.list_resources()
42+
async def list_resources() -> list[types.Resource]:
43+
return RESOURCES
44+
45+
46+
@server.list_resource_templates()
47+
async def list_resource_templates() -> list[types.ResourceTemplate]:
48+
return RESOURCE_TEMPLATES
49+
50+
51+
@server.read_resource()
52+
async def read_resource(uri: str) -> str:
53+
logger.debug("[MCP] Resource read: %s", uri)
54+
return handle_resource(uri)
55+
56+
57+
async def handle_sse(request: Request):
58+
"""Accept an SSE connection, run the MCP session until the client disconnects."""
59+
async with sse.connect_sse(
60+
request.scope, request.receive, request._send
61+
) as streams:
62+
await server.run(
63+
streams[0],
64+
streams[1],
65+
server.create_initialization_options(),
66+
)
67+
68+
69+
def create_mcp_starlette_app() -> Starlette:
70+
"""Build and return the MCP Starlette sub-application.
71+
72+
Routes:
73+
- ``GET /sse`` - SSE stream for MCP clients
74+
- ``POST /messages/`` - client-to-server message posting
75+
76+
``LocalNetworkMiddleware`` is applied so the endpoint is only reachable
77+
from loopback and RFC 1918 addresses.
78+
"""
79+
app = Starlette(
80+
routes=[
81+
Route("/sse", endpoint=handle_sse),
82+
Mount("/messages", app=sse.handle_post_message),
83+
],
84+
)
85+
app.add_middleware(LocalNetworkMiddleware)
86+
return app

0 commit comments

Comments
 (0)