feat(mcp): ppclock-mcp 入口——stdio/HTTP 双模 + token 鉴权 + 非回环守卫
This commit is contained in:
1 parent
bb3dbcb06a
commit
9506490ce2
3 files changed
+162
No files matched your search
@@ -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())
|
||||
Reference in new issue
Block a user