feat(mcp): ppclock-mcp 入口——stdio/HTTP 双模 + token 鉴权 + 非回环守卫

This commit is contained in:
chenwei committed 2026-07-30 16:39:13 +00:00
1 parent bb3dbcb06a
commit 9506490ce2
3 files changed
+162

No files matched your search

+88
View File
@@ -0,0 +1,88 @@
"""ppclock-mcp —— MCP 服务器入口(stdio / streamable HTTP 双模)。
stdio:本机 agent 直挂(Claude Code/Desktop 等)。
http :局域网 agent 平台远程调用;绑非回环地址必须设 PPCLOCK_MCP_TOKEN。
"""
from __future__ import annotations
import argparse
import asyncio
import os
from mcp.server.fastmcp import FastMCP
from .device_manager import DeviceManager
from .mcp_tools import register_tools
TOKEN_ENV = "PPCLOCK_MCP_TOKEN"
def build_server(manager: DeviceManager) -> FastMCP:
mcp = FastMCP("ppclock", stateless_http=True)
register_tools(mcp, manager)
return mcp
def parse_args(argv=None) -> argparse.Namespace:
p = argparse.ArgumentParser(prog="ppclock-mcp",
description="ppclock 墨水屏 MCP 服务器")
p.add_argument("--transport", choices=["stdio", "http"], default="stdio")
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=8972)
p.add_argument("--mac", default=None, help="设备 MAC;缺省自动扫描 NRF- 前缀")
p.add_argument("--connect-timeout", type=float, default=90.0,
help="守候连接秒数(覆盖设备 30-60s 广播窗口)")
p.add_argument("--idle-timeout", type=float, default=300.0,
help="空闲断开秒数;0=每次调用独立连接")
return p.parse_args(argv)
class TokenAuthMiddleware:
"""纯 ASGI 中间件:校验 Authorization: Bearer <token>,不符 401。"""
def __init__(self, app, token: str):
self.app = app
self.token = token
async def __call__(self, scope, receive, send):
if scope["type"] == "http":
headers = dict(scope.get("headers") or [])
auth = headers.get(b"authorization", b"").decode("latin1")
if auth != f"Bearer {self.token}":
await send({"type": "http.response.start", "status": 401,
"headers": [(b"content-type", b"text/plain")]})
await send({"type": "http.response.body",
"body": b"unauthorized"})
return
await self.app(scope, receive, send)
async def _run_http(mcp: FastMCP, host: str, port: int, token: str | None):
import uvicorn
app = mcp.streamable_http_app()
if token:
app = TokenAuthMiddleware(app, token)
config = uvicorn.Config(app, host=host, port=port, log_level="info")
await uvicorn.Server(config).serve()
def main(argv=None) -> None:
args = parse_args(argv)
manager = DeviceManager(mac=args.mac,
connect_timeout=args.connect_timeout,
idle_timeout=args.idle_timeout)
mcp = build_server(manager)
if args.transport == "stdio":
try:
mcp.run(transport="stdio")
finally:
asyncio.run(manager.close())
return
token = os.environ.get(TOKEN_ENV)
loopback = args.host in ("127.0.0.1", "localhost", "::1")
if not loopback and not token:
raise SystemExit(f"绑定非回环地址 {args.host} 必须设置 {TOKEN_ENV}")
try:
asyncio.run(_run_http(mcp, args.host, args.port, token))
finally:
asyncio.run(manager.close())