Files
qianbian/docs/superpowers/plans/2026-07-30-ppclock-mcp.md
T
2026-07-30 15:48:26 +00:00

1087 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ppclock-mcp Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把 ppclock SDK 包装成单层部署的 MCP 服务器(stdio + streamable HTTP 双模),供各 agent 平台调用,Windows 蓝牙服务器先行实测。
**Architecture:** 新增三个模块(`device_manager.py` 连接管理 / `mcp_tools.py` 工具层 / `mcp_server.py` 装配入口),作为 SDK 的纯消费者;连接管理用方案 C(按需连接 + 空闲超时保持 + 全局串行锁 + 掉线重连重试一次)。
**Tech Stack:** Python ≥3.10、官方 `mcp` 包(FastMCP)、bleak(Windows=WinRT / Linux=BlueZ)、pytest + pytest-asyncio。
**Spec:** `docs/superpowers/specs/2026-07-30-ppclock-mcp-design.md`
## Global Constraints
- **SDK 零改动**:不改 `client.py`/`commands.py`/`protocol.py`/`image_pipeline.py`/`transports/*` 任何一行。
- `mcp>=1.10` 只能进 optional extra,基础 SDK 依赖(bleak、pillow)不变。
- 输出契约:成功 `{"ok": true, "tool": <name>, "data": {...}}`;失败 `{"ok": false, "tool": <name>, "error": {"code","message","hint"?}}`。
- 错误码仅:`INVALID_PARAM` / `DEVICE_NOT_FOUND` / `CONNECT_TIMEOUT` / `BLE_ERROR` / `DEVICE_DISCONNECTED` / `INTERNAL`。
- HTTP 默认 `--host 127.0.0.1 --port 8972`;绑非回环地址必须设环境变量 `PPCLOCK_MCP_TOKEN`。
- 代码注释/docstring 用中文,风格对齐现有源码;commit 前缀用 `feat:`/`test:`/`docs:`。
- 测试用 `@pytest.mark.asyncio`(对齐 tests/ 现有模式),FakeTransport 模式复用 tests/test_client.py。
- 全部测试命令用 `.venv/bin/python -m pytest tests/ -q`(仓库根目录)。
---
### Task 1: DeviceManager(连接管理器)
**Files:**
- Create: `src/ppclock/device_manager.py`
- Test: `tests/test_device_manager.py`
**Interfaces:**
- Consumes: `ppclock.client.PPClient`、`ppclock.transports.base.TransportError`
- Produces:
- `DeviceManager(mac: str|None=None, connect_timeout: float=90.0, idle_timeout: float=300.0, client_factory: Callable[[str|None, float], PPClient]|None=None)`
- `async manager.run(op) -> Any` — `op` 是 `async (PPClient) -> Any`;串行执行,掉线自动重连重试一次
- `async manager.status() -> dict` — `{connected, mac, idle_seconds, connect_count, connect_timeout, idle_timeout, device_id?}`
- `async manager.close() -> None`
- `manager.connected: bool`(property)
- 下游(Task 2/3)只依赖以上名字与签名。
- [ ] **Step 1: 写失败测试**
```python
"""DeviceManager 测试:按需连接/串行/空闲超时/掉线重连。"""
import asyncio
import pytest
from ppclock.client import PPClient
from ppclock.device_manager import DeviceManager
from ppclock.transports.base import TransportError
class FakeTransport:
def __init__(self):
self.address = "AA:BB:CC:DD:EE:FF"
self.enters = 0
self.exits = 0
self.rxtx_writes = []
self.fail_next_write = False
async def __aenter__(self):
self.enters += 1
return self
async def __aexit__(self, *exc):
self.exits += 1
return False
async def write_epd(self, data, response=True):
pass
async def write_rxtx(self, data, response=True):
if self.fail_next_write:
self.fail_next_write = False
raise TransportError("connection lost")
self.rxtx_writes.append(data)
async def read_rxtx(self):
return b"\x00"
async def request_device_id(self, timeout=8.0):
return "81233F3C267112"
async def run_ota(self, image, on_progress=None):
return {}
async def delay(self, seconds):
pass
def make_manager(transports, **kw):
"""client_factory 依次弹出预置 transport 包成 PPClient。"""
def factory(mac, timeout):
return PPClient(transports.pop(0))
kw.setdefault("client_factory", factory)
return DeviceManager(**kw)
class TestConnect:
@pytest.mark.asyncio
async def test_connect_on_demand_and_reuse(self):
t = FakeTransport()
m = make_manager([t])
assert not m.connected
await m.run(lambda dev: dev.set_mode("clock1"))
await m.run(lambda dev: dev.set_mode("clock2"))
assert t.enters == 1 # 第二次复用连接
assert m.connected
await m.close()
@pytest.mark.asyncio
async def test_remembers_address(self):
t = FakeTransport()
m = make_manager([t], mac=None)
await m.run(lambda dev: dev.set_mode("clock1"))
assert (await m.status())["mac"] == "AA:BB:CC:DD:EE:FF"
await m.close()
class TestSerialization:
@pytest.mark.asyncio
async def test_ops_are_serialized(self):
t = FakeTransport()
m = make_manager([t])
concurrent = 0
peak = 0
async def op(dev):
nonlocal concurrent, peak
concurrent += 1
peak = max(peak, concurrent)
await asyncio.sleep(0.02)
concurrent -= 1
return 1
await asyncio.gather(*(m.run(op) for _ in range(5)))
assert peak == 1
await m.close()
class TestIdleTimeout:
@pytest.mark.asyncio
async def test_idle_disconnect(self):
t = FakeTransport()
m = make_manager([t], idle_timeout=0.05)
await m.run(lambda dev: dev.set_mode("clock1"))
assert m.connected
await asyncio.sleep(0.15)
assert not m.connected
assert t.exits == 1
@pytest.mark.asyncio
async def test_activity_resets_idle(self):
t = FakeTransport()
m = make_manager([t], idle_timeout=0.1)
await m.run(lambda dev: dev.set_mode("clock1"))
await asyncio.sleep(0.06)
await m.run(lambda dev: dev.set_mode("clock2"))
await asyncio.sleep(0.06)
assert m.connected # 第二次活动续期
await m.close()
class TestReconnect:
@pytest.mark.asyncio
async def test_reconnect_once_on_transport_error(self):
t1, t2 = FakeTransport(), FakeTransport()
t1.fail_next_write = True
m = make_manager([t1, t2])
await m.run(lambda dev: dev.set_mode("clock1"))
assert t1.enters == 1 and t2.enters == 1 # 掉线重连一次并成功
await m.close()
@pytest.mark.asyncio
async def test_retry_exhaustion_raises(self):
t1, t2 = FakeTransport(), FakeTransport()
t1.fail_next_write = True
t2.fail_next_write = True
m = make_manager([t1, t2])
with pytest.raises(TransportError):
await m.run(lambda dev: dev.set_mode("clock1"))
await m.close()
class TestStatus:
@pytest.mark.asyncio
async def test_status_fields(self):
t = FakeTransport()
m = make_manager([t], connect_timeout=90.0, idle_timeout=300.0)
s = await m.status()
assert s["connected"] is True # status 自身触发连接
assert s["device_id"] == "81233F3C267112"
assert s["connect_timeout"] == 90.0
assert s["idle_timeout"] == 300.0
assert isinstance(s["idle_seconds"], float)
await m.close()
```
注:`status()` 在设备 ID 读取上复用已建立连接;实现上 status 在连接时顺手 `get_device_id()`。`test_status_fields` 假定 status 会确保连接(见实现)。
- [ ] **Step 2: 跑测试确认失败**
Run: `.venv/bin/python -m pytest tests/test_device_manager.py -q`
Expected: FAIL(`ModuleNotFoundError: No module named 'ppclock.device_manager'`)
- [ ] **Step 3: 实现 device_manager.py**
```python
"""设备连接管理器(方案 C:按需连接 + 空闲保持,全操作串行)。
- 一把 asyncio.Lock 串行所有设备操作(BLE 适配器独占)
- 首次操作守候连接(connect_timeout 应覆盖设备 30-60s 广播窗口)
- 操作后保持连接;空闲 idle_timeout 秒自动断开让设备睡眠
- 操作中 TransportError(连接丢失)→ 重连并重试一次,再失败向上抛
"""
from __future__ import annotations
import asyncio
import time
from .client import PPClient
from .transports.base import TransportError
class DeviceManager:
def __init__(self, mac: str | None = None, connect_timeout: float = 90.0,
idle_timeout: float = 300.0, client_factory=None):
self._mac = mac
self._connect_timeout = connect_timeout
self._idle_timeout = idle_timeout
self._factory = client_factory or (
lambda mac, timeout: PPClient.via_local(mac=mac, timeout=timeout))
self._client = None
self._lock = asyncio.Lock()
self._last_activity: float | None = None
self._idle_task: asyncio.Task | None = None
self._connect_count = 0
@property
def connected(self) -> bool:
return self._client is not None
async def run(self, op):
"""串行执行 op(client);掉线重连重试一次。"""
async with self._lock:
try:
return await self._run_with_retry(op)
finally:
self._touch()
async def status(self) -> dict:
async with self._lock:
await self._ensure_connected()
self._touch()
info = {
"connected": self.connected,
"mac": self._mac,
"idle_seconds": None if self._last_activity is None
else round(time.monotonic() - self._last_activity, 1),
"connect_count": self._connect_count,
"connect_timeout": self._connect_timeout,
"idle_timeout": self._idle_timeout,
}
try:
info["device_id"] = await self._client.get_device_id()
except Exception: # noqa: BLE001 - 状态查询尽力而为
pass
return info
async def close(self) -> None:
if self._idle_task is not None:
self._idle_task.cancel()
async with self._lock:
await self._drop()
# ---------- 内部 ----------
async def _run_with_retry(self, op):
await self._ensure_connected()
try:
return await op(self._client)
except TransportError:
await self._drop()
await self._ensure_connected()
return await op(self._client)
async def _ensure_connected(self):
if self._client is not None:
return
client = self._factory(self._mac, self._connect_timeout)
await client.__aenter__()
self._client = client
self._connect_count += 1
if self._mac is None: # 自动扫描后记住实际地址
self._mac = getattr(client._t, "address", None)
async def _drop(self):
client, self._client = self._client, None
if client is not None:
try:
await client.__aexit__(None, None, None)
except Exception: # noqa: BLE001 - 断开失败不影响状态
pass
def _touch(self):
self._last_activity = time.monotonic()
if self._idle_task is not None:
self._idle_task.cancel()
self._idle_task = None
if self.connected:
self._idle_task = asyncio.create_task(self._idle_watch())
async def _idle_watch(self):
try:
await asyncio.sleep(self._idle_timeout)
async with self._lock:
if (self._last_activity is not None
and time.monotonic() - self._last_activity >= self._idle_timeout):
await self._drop()
except asyncio.CancelledError:
pass
```
注:`client._t` 是同包内访问 PPClient 持有的传输层(Transport 协议公开 `address` 属性),SDK 无公开访问器,此处为有意为之的包内约定。
- [ ] **Step 4: 跑测试确认通过**
Run: `.venv/bin/python -m pytest tests/test_device_manager.py -q`
Expected: 9 passed
- [ ] **Step 5: 回归 + 提交**
Run: `.venv/bin/python -m pytest tests/ -q`
Expected: 165 + 9 全绿
```bash
git add src/ppclock/device_manager.py tests/test_device_manager.py
git commit -m "feat(mcp): DeviceManager——按需连接+空闲保持+串行锁+掉线重连(方案 C)"
```
---
### Task 2: mcp_tools(工具层:图源解码 + 错误契约 + 13 工具)
**Files:**
- Create: `src/ppclock/mcp_tools.py`
- Test: `tests/test_mcp_tools.py`
**Interfaces:**
- Consumes: Task 1 的 `DeviceManager.run(op)` / `DeviceManager.status()`;SDK `PPClient` 全部高层方法
- Produces:
- `decode_image_source(source: str) -> PIL.Image.Image` — 路径或 base64(可带 `data:*;base64,` 前缀);非法输入抛 `ValueError`
- `register_tools(mcp, manager) -> None` — 把 13 个工具注册到 FastMCP 实例
- 工具名(Task 3 与文档依赖,逐字):`scan_devices / device_status / set_time / set_mode / toggle / upload_image / render_template / countdown / countdown_off / calendar_text / set_sleep / clear_screen / raw_send`
- [ ] **Step 1: 写失败测试**
```python
"""mcp_tools 测试:图源解码 + 工具契约(内存 MCP 会话端到端)。"""
import base64
import io
import json
import pytest
from mcp.server.fastmcp import FastMCP
from mcp.shared.memory import create_connected_server_and_client_session
from PIL import Image
from ppclock.client import PPClient
from ppclock.mcp_tools import decode_image_source, register_tools
from ppclock.transports.base import TransportError
class FakeTransport:
def __init__(self):
self.address = "AA:BB:CC:DD:EE:FF"
self.epd_writes = []
self.rxtx_writes = []
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def write_epd(self, data, response=True):
self.epd_writes.append(data)
async def write_rxtx(self, data, response=True):
self.rxtx_writes.append(data)
async def read_rxtx(self):
return b"\x00"
async def request_device_id(self, timeout=8.0):
return "81233F3C267112"
async def run_ota(self, image, on_progress=None):
return {}
async def delay(self, seconds):
pass
class FakeManager:
"""模拟 DeviceManager:直接对 fake client 执行 op,或抛预置错误。"""
def __init__(self, error=None):
self.client = PPClient(FakeTransport())
self.error = error
self.ops = 0
async def run(self, op):
self.ops += 1
if self.error is not None:
raise self.error
return await op(self.client)
async def status(self):
return {"connected": True, "mac": "AA:BB:CC:DD:EE:FF",
"idle_seconds": 0.0, "connect_count": 1,
"connect_timeout": 90.0, "idle_timeout": 300.0,
"device_id": "81233F3C267112"}
def make_session_coro(manager):
mcp = FastMCP("ppclock-test")
register_tools(mcp, manager)
return mcp
async def call(mcp, tool, args=None):
async with create_connected_server_and_client_session(mcp._mcp_server) as s:
result = await s.call_tool(tool, args or {})
assert not result.isError, result.content
return json.loads(result.content[0].text)
def png_b64() -> str:
buf = io.BytesIO()
Image.new("RGB", (400, 300), "white").save(buf, "PNG")
return base64.b64encode(buf.getvalue()).decode()
class TestDecodeImageSource:
def test_path(self, tmp_path):
p = tmp_path / "a.png"
Image.new("RGB", (10, 10)).save(p)
assert decode_image_source(str(p)).size == (10, 10)
def test_base64(self):
assert decode_image_source(png_b64()).size == (400, 300)
def test_data_uri(self):
src = "data:image/png;base64," + png_b64()
assert decode_image_source(src).size == (400, 300)
def test_garbage(self):
with pytest.raises(ValueError):
decode_image_source("!!!not-base64!!!")
class TestTools:
@pytest.mark.asyncio
async def test_set_time_ok(self):
r = await call(make_session_coro(FakeManager()), "set_time", {"tz": 8.0})
assert r["ok"] is True and r["tool"] == "set_time"
@pytest.mark.asyncio
async def test_upload_image_base64(self):
r = await call(make_session_coro(FakeManager()), "upload_image",
{"source": png_b64(), "slot": 0})
assert r["ok"] is True
assert r["data"]["bytes_bw"] == 15000
assert r["data"]["slot"] == 0
@pytest.mark.asyncio
async def test_set_mode_invalid_param(self):
r = await call(make_session_coro(FakeManager()), "set_mode",
{"mode": "nonsense"})
assert r["ok"] is False
assert r["error"]["code"] == "INVALID_PARAM"
@pytest.mark.asyncio
async def test_transport_error_mapped(self):
m = FakeManager(error=TransportError("Connection timed out"))
r = await call(make_session_coro(m), "set_time", {})
assert r["ok"] is False
assert r["error"]["code"] == "CONNECT_TIMEOUT"
assert "hint" in r["error"]
@pytest.mark.asyncio
async def test_device_status(self):
r = await call(make_session_coro(FakeManager()), "device_status", {})
assert r["ok"] is True
assert r["data"]["device_id"] == "81233F3C267112"
@pytest.mark.asyncio
async def test_raw_send(self):
r = await call(make_session_coro(FakeManager()), "raw_send",
{"data_hex": "e201", "channel": "alt"})
assert r["ok"] is True
@pytest.mark.asyncio
async def test_raw_send_bad_hex(self):
r = await call(make_session_coro(FakeManager()), "raw_send",
{"data_hex": "zz", "channel": "alt"})
assert r["ok"] is False
assert r["error"]["code"] == "INVALID_PARAM"
@pytest.mark.asyncio
async def test_render_template(self):
r = await call(make_session_coro(FakeManager()), "render_template",
{"name": "custom", "payload": {"text": "你好"}, "slot": 0})
assert r["ok"] is True
assert r["data"]["template"] == "custom"
@pytest.mark.asyncio
async def test_countdown(self):
r = await call(make_session_coro(FakeManager()), "countdown",
{"date": "2026-12-31", "mode": "clock", "prefix": "目标"})
assert r["ok"] is True
@pytest.mark.asyncio
async def test_scan_devices(self, monkeypatch):
async def fake_scan(uri="local", timeout=5.0):
return [{"name": "NRF-5DBF28", "address": "18:BC:5A:5D:BF:28",
"rssi": -60}]
monkeypatch.setattr(PPClient, "scan_devices", staticmethod(fake_scan))
r = await call(make_session_coro(FakeManager()), "scan_devices", {})
assert r["ok"] is True
assert r["data"]["devices"][0]["address"] == "18:BC:5A:5D:BF:28"
```
- [ ] **Step 2: 装依赖 + 跑测试确认失败**
Run: `.venv/bin/pip install "mcp>=1.10" -q && .venv/bin/python -m pytest tests/test_mcp_tools.py -q`
Expected: FAIL(`ModuleNotFoundError: No module named 'ppclock.mcp_tools'`)
- [ ] **Step 3: 实现 mcp_tools.py**
```python
"""MCP 工具层:13 个高层工具,统一输出契约(与 CLI --json 同构)。
成功 {"ok": true, "tool": name, "data": {...}}
失败 {"ok": false, "tool": name, "error": {"code", "message", "hint"?}}
"""
from __future__ import annotations
import base64
import binascii
import datetime
import io
import os
from PIL import Image
from .client import PPClient
from .transports.base import TransportError
_TIMEOUT_HINT = "设备长睡眠、广播窗口 30-60s;调大 --connect-timeout 或稍后重试"
_NOTFOUND_HINT = "未发现 NRF- 前缀设备;确认设备在位且未处于深睡"
def decode_image_source(source: str) -> Image.Image:
"""source = 服务器本地路径,或 base64(可带 data URI 前缀)。"""
if os.path.exists(source):
return Image.open(source)
data = source
if data.startswith("data:"):
if "," not in data:
raise ValueError("data URI 缺少 ',' 分隔")
data = data.split(",", 1)[1]
try:
raw = base64.b64decode(data, validate=True)
except (binascii.Error, ValueError):
raise ValueError("source 既不是存在的文件路径,也不是合法 base64") from None
return Image.open(io.BytesIO(raw))
def _err(tool: str, code: str, message: str, hint: str | None = None) -> dict:
error = {"code": code, "message": message}
if hint:
error["hint"] = hint
return {"ok": False, "tool": tool, "error": error}
def _map_transport_error(tool: str, exc: TransportError) -> dict:
msg = str(exc)
low = msg.lower()
if "timeout" in low or "timed out" in low:
return _err(tool, "CONNECT_TIMEOUT", msg, _TIMEOUT_HINT)
if "not found" in low or "no device" in low:
return _err(tool, "DEVICE_NOT_FOUND", msg, _NOTFOUND_HINT)
return _err(tool, "BLE_ERROR", msg)
def register_tools(mcp, manager) -> None:
"""把 13 个工具注册到 FastMCP 实例;manager 为 DeviceManager(测试可注入 fake)。"""
async def _call(tool: str, op) -> dict:
try:
data = await manager.run(op)
return {"ok": True, "tool": tool, "data": data if data is not None else {}}
except TransportError as exc:
return _map_transport_error(tool, exc)
except ValueError as exc:
return _err(tool, "INVALID_PARAM", str(exc))
except Exception as exc: # noqa: BLE001 - 工具层兜底,错误须回 agent 而非炸会话
return _err(tool, "INTERNAL", f"{type(exc).__name__}: {exc}")
@mcp.tool()
async def scan_devices(timeout: float = 5.0) -> dict:
"""扫描附近 NRF- 前缀墨水屏设备,返回 [{name,address,rssi}]。"""
try:
devices = await PPClient.scan_devices("local", timeout=timeout)
return {"ok": True, "tool": "scan_devices", "data": {"devices": devices}}
except TransportError as exc:
return _map_transport_error("scan_devices", exc)
except Exception as exc: # noqa: BLE001
return _err("scan_devices", "INTERNAL", f"{type(exc).__name__}: {exc}")
@mcp.tool()
async def device_status() -> dict:
"""查询连接状态/绑定 MAC/设备 ID/空闲秒数。"""
try:
return {"ok": True, "tool": "device_status", "data": await manager.status()}
except Exception as exc: # noqa: BLE001
return _err("device_status", "INTERNAL", f"{type(exc).__name__}: {exc}")
@mcp.tool()
async def set_time(tz: float = 8.0) -> dict:
"""对时;tz 为时区偏移(默认 8=北京时间)。"""
return await _call("set_time", lambda dev: dev.set_time(tz=tz))
@mcp.tool()
async def set_mode(mode: str) -> dict:
"""切换显示模式:clock1-3/calendar1-3/image0-3/tricolor/mono。"""
return await _call("set_mode", lambda dev: dev.set_mode(mode))
@mcp.tool()
async def toggle(name: str) -> dict:
"""单字节切换:invert/font/rotate180/hour_format/clock_color。"""
return await _call("toggle", lambda dev: dev.toggle(name))
@mcp.tool()
async def upload_image(source: str, slot: int = 0, algo: str = "atkinson",
mono: bool = False,
threshold: float | None = None,
diffusion: float | None = None,
brightness: float | None = None,
contrast: float | None = None,
saturation: float | None = None,
rotate: float | None = None) -> dict:
"""传图到指定槽位。source=服务器路径或 base64;algo∈
none/floydsteinberg/atkinson/bayer/stucki/jarvis。"""
adjust = {k: v for k, v in {
"threshold": threshold, "diffusion": diffusion,
"brightness": brightness, "contrast": contrast,
"saturation": saturation, "rotate": rotate}.items() if v is not None}
img = decode_image_source(source)
return await _call("upload_image", lambda dev: dev.upload_image(
img, slot=slot, algo=algo, mono=mono, **adjust))
@mcp.tool()
async def render_template(name: str, payload: dict | None = None,
slot: int = 0, algo: str = "atkinson") -> dict:
"""本地渲染模板并上传:schedule/businesscard/memo/course/qrcode/custom。"""
return await _call("render_template", lambda dev: dev.render_template(
name, payload, slot=slot, algo=algo))
@mcp.tool()
async def countdown(date: str, mode: str = "clock",
prefix: str | None = None) -> dict:
"""倒计时;date 为 ISO YYYY-MM-DD;mode=clock/calendar。"""
d = datetime.date.fromisoformat(date)
return await _call("countdown", lambda dev: dev.countdown(
d, mode=mode, prefix=prefix))
@mcp.tool()
async def countdown_off() -> dict:
"""关闭倒计时。"""
return await _call("countdown_off", lambda dev: dev.countdown_off())
@mcp.tool()
async def calendar_text(text: str) -> dict:
"""设置日历中文文字并切到日历模式一。"""
return await _call("calendar_text", lambda dev: dev.calendar_text(text))
@mcp.tool()
async def set_sleep(on: bool, start_h: int, end_h: int) -> dict:
"""设置休眠时段(0-23 时)。"""
return await _call("set_sleep", lambda dev: dev.sleep(on, start_h, end_h))
@mcp.tool()
async def clear_screen() -> dict:
"""刷屏(EPD 清屏)。"""
return await _call("clear_screen", lambda dev: dev.clear())
@mcp.tool()
async def raw_send(data_hex: str, channel: str = "alt") -> dict:
"""逃生舱:直发十六进制字节串;channel=rxtx/epd/alt。"""
data = bytes.fromhex(data_hex)
return await _call("raw_send", lambda dev: dev.raw(data, channel))
```
注:`upload_image`/`countdown`/`raw_send` 在进入 `_call` 前的本地解析(decode/fromisoformat/fromhex)抛 `ValueError`——需被工具级兜底捕获。处理方式:这些工具体外不包 try 的话异常会炸 MCP 会话。把 `_call` 的 try 改为包住整个 lambda 构造不可行(解析发生在 `_call` 之外)。**实现时**把这三个工具改为:
```python
@mcp.tool()
async def upload_image(source: str, slot: int = 0, algo: str = "atkinson",
mono: bool = False, **kw) -> dict:
"""..."""
try:
adjust = {k: v for k, v in kw.items() if v is not None}
img = decode_image_source(source)
except ValueError as exc:
return _err("upload_image", "INVALID_PARAM", str(exc))
return await _call("upload_image", lambda dev: dev.upload_image(
img, slot=slot, algo=algo, mono=mono, **adjust))
```
`countdown`/`raw_send` 同理包 try(fromisoformat/fromhex 的 ValueError → INVALID_PARAM)。其余工具参数无需预解析,直接 `_call`。
⚠ `upload_image` 用 `**kw` 收 adjust 参数时,FastMCP 会从签名生成 schema——`**kw` 不进 schema。为保持 schema 显式,**保留显式参数签名**(threshold/diffusion/brightness/contrast/saturation/rotate 六个 `float | None = None`),仅把解析段包 try。以显式签名为准。
- [ ] **Step 4: 跑测试确认通过**
Run: `.venv/bin/python -m pytest tests/test_mcp_tools.py -q`
Expected: 14 passed
- [ ] **Step 5: 回归 + 提交**
Run: `.venv/bin/python -m pytest tests/ -q`
Expected: 全绿
```bash
git add src/ppclock/mcp_tools.py tests/test_mcp_tools.py
git commit -m "feat(mcp): 13 个高层 MCP 工具 + 图源解码 + 统一输出契约"
```
---
### Task 3: mcp_server(装配入口 + HTTP token 鉴权 + 打包)
**Files:**
- Create: `src/ppclock/mcp_server.py`
- Modify: `pyproject.toml`(extra + entry point)
- Test: `tests/test_mcp_server.py`
**Interfaces:**
- Consumes: Task 1 `DeviceManager`、Task 2 `register_tools`
- Produces:
- `build_server(manager) -> FastMCP`
- `parse_args(argv=None) -> argparse.Namespace`(字段:`transport/host/port/mac/connect_timeout/idle_timeout`)
- `TokenAuthMiddleware(app, token)`(ASGI)
- `main(argv=None) -> None`(`ppclock-mcp` 入口)
- `TOKEN_ENV = "PPCLOCK_MCP_TOKEN"`
- [ ] **Step 1: 写失败测试**
```python
"""mcp_server 测试:参数解析 + 非回环强制 token + ASGI 鉴权中间件。"""
import asyncio
import pytest
from ppclock.mcp_server import (TOKEN_ENV, TokenAuthMiddleware,
parse_args)
class TestParseArgs:
def test_defaults(self):
a = parse_args([])
assert a.transport == "stdio"
assert a.host == "127.0.0.1"
assert a.port == 8972
assert a.mac is None
assert a.connect_timeout == 90.0
assert a.idle_timeout == 300.0
def test_http_flags(self):
a = parse_args(["--transport", "http", "--host", "0.0.0.0",
"--port", "9000", "--mac", "AA:BB:CC:DD:EE:FF",
"--connect-timeout", "120", "--idle-timeout", "60"])
assert a.transport == "http" and a.host == "0.0.0.0"
assert a.port == 9000 and a.mac == "AA:BB:CC:DD:EE:FF"
assert a.connect_timeout == 120.0 and a.idle_timeout == 60.0
def run_middleware(token_set, auth_header):
"""驱动 TokenAuthMiddleware,返回 (status, app_called)。"""
captured = {}
async def app(scope, receive, send):
captured["called"] = True
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b"ok"})
sent = []
async def send(msg):
sent.append(msg)
headers = []
if auth_header is not None:
headers.append((b"authorization", auth_header.encode()))
scope = {"type": "http", "headers": headers}
mw = TokenAuthMiddleware(app, token_set)
asyncio.run(mw(scope, None, send))
status = next(m["status"] for m in sent if m["type"] == "http.response.start")
return status, captured.get("called", False)
class TestTokenAuth:
def test_no_header_401(self):
status, called = run_middleware("secret", None)
assert status == 401 and called is False
def test_wrong_token_401(self):
status, called = run_middleware("secret", "Bearer nope")
assert status == 401 and called is False
def test_right_token_passes(self):
status, called = run_middleware("secret", "Bearer secret")
assert status == 200 and called is True
class TestBindGuard:
def test_non_loopback_requires_token(self, monkeypatch):
from ppclock.mcp_server import main
monkeypatch.delenv(TOKEN_ENV, raising=False)
with pytest.raises(SystemExit):
main(["--transport", "http", "--host", "0.0.0.0"])
```
- [ ] **Step 2: 跑测试确认失败**
Run: `.venv/bin/python -m pytest tests/test_mcp_server.py -q`
Expected: FAIL(`ModuleNotFoundError: No module named 'ppclock.mcp_server'`)
- [ ] **Step 3: 实现 mcp_server.py + pyproject 打包**
```python
"""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())
```
pyproject.toml 修改(三处):
```toml
# [project.optional-dependencies] 下追加:
mcp = ["mcp>=1.10"]
# [project.scripts] 下追加:
ppclock-mcp = "ppclock.mcp_server:main"
```
- [ ] **Step 4: 跑测试确认通过**
Run: `.venv/bin/pip install -e ".[mcp]" -q && .venv/bin/python -m pytest tests/test_mcp_server.py -q`
Expected: 6 passed
- [ ] **Step 5: stdio 冒烟(真实 MCP 握手,无硬件)**
Run(应输出工具清单 JSON 后退出):
```bash
.venv/bin/python - <<'EOF'
import anyio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def main():
params = StdioServerParameters(command=".venv/bin/python",
args=["-m", "ppclock.mcp_server"])
async with stdio_client(params) as (r, w):
async with ClientSession(r, w) as s:
await s.initialize()
tools = await s.list_tools()
names = sorted(t.name for t in tools.tools)
assert len(names) == 13, names
print(names)
anyio.run(main)
EOF
```
Expected: 打印 13 个工具名。若无 `python -m` 入口(模块无 `__main__` 守卫),改用 `args=["-c", "from ppclock.mcp_server import main; main()"]` 并设 `env` 使 `PYTHONPATH=src`,或以 console script `.venv/bin/ppclock-mcp` 为 command。
- [ ] **Step 6: 回归 + 提交**
Run: `.venv/bin/python -m pytest tests/ -q`
Expected: 全绿
```bash
git add src/ppclock/mcp_server.py tests/test_mcp_server.py pyproject.toml
git commit -m "feat(mcp): ppclock-mcp 入口——stdio/HTTP 双模 + token 鉴权 + 非回环守卫"
```
---
### Task 4: 文档与 0.2.0
**Files:**
- Create: `docs/sdk/MCP.md`
- Modify: `src/ppclock/__init__.py`(`__version__`)、`pyproject.toml`(version)、`CHANGELOG.md`、`README.md`、`llms.txt`、`docs/sdk/API.md`
**Interfaces:**
- Consumes: Task 1-3 全部公开名
- Produces: 文档与版本号;无新代码接口
- [ ] **Step 1: docs/sdk/MCP.md(工具参考 + agent 挂载示例)**
内容骨架(逐项写全,勿留 TODO):
1. 一句话:ppclock-mcp 是跑在蓝牙所在机器上的单层 MCP 服务器,13 个工具覆盖对时/模式/传图/模板/倒计时/休眠等。
2. 安装:`pip install -e ".[mcp]"`;要求 Python ≥3.10、本机蓝牙。
3. 启动:stdio(`ppclock-mcp --mac XX`)与 http(`PPCLOCK_MCP_TOKEN=... ppclock-mcp --transport http --host 0.0.0.0 --port 8972`)双例。
4. agent 挂载示例:Claude Code `claude mcp add ppclock -- ppclock-mcp --mac XX`(stdio);streamable HTTP 端点 `http://<host>:8972/mcp` + Bearer token。
5. 13 工具参考表(名字/参数/返回,逐字对齐 mcp_tools.py docstring 与签名;`upload_image` 列出 6 个可选 adjust 参数)。
6. 输出契约与错误码表(6 码 + hint 语义)。
7. 连接行为说明:守候窗口/空闲超时/串行/重连一次(对应 `--connect-timeout`/`--idle-timeout`)。
8. Windows 部署节:venv → `pip install -e ".[mcp]"` → 环境变量 → 启动 → 防火墙放行 8972(仅局域网)→ 不动 8971 bridge 与既有服务。
9. 不暴露能力清单(OTA/激活/LUT/WiFi/轮播)及原因一句。
- [ ] **Step 2: 版本与既有文档同步**
- `src/ppclock/__init__.py`:`__version__ = "0.2.0"`
- `pyproject.toml`:`version = "0.2.0"`
- `CHANGELOG.md`:新增 `## 0.2.0(2026-07-30)`——ppclock-mcp(13 工具/双模/鉴权/方案 C 连接管理)、DeviceManager 公开为子包能力。
- `README.md`:SDK 用法后追加「MCP 服务器」小节(启动双例 + 指向 docs/sdk/MCP.md)。
- `llms.txt`:目录行加 `ppclock-mcp`(mcp_server/device_manager/mcp_tools);快速示例后加 MCP 挂载一句。
- `docs/sdk/API.md`:子包速查表加三行(`ppclock.mcp_server`/`ppclock.device_manager`/`ppclock.mcp_tools`)。
- [ ] **Step 3: 回归 + 提交**
Run: `.venv/bin/python -m pytest tests/ -q`
Expected: 全绿
```bash
git add -A
git commit -m "docs+release: MCP.md 工具参考与部署指南;0.2.0"
```
---
### Task 5: Windows 部署包与熠管家协作
**Files:**
- Create: `tools/mcp_windows_deploy.md`(熠管家执行清单)
- 外发:Worker Bridge `assist.requested`
**Interfaces:**
- Consumes: Task 4 的 docs/sdk/MCP.md §Windows 部署
- Produces: 可执行部署清单 + 熠管家 assist 请求记录
- [ ] **Step 1: 写 tools/mcp_windows_deploy.md**
清单(每步含验证命令与预期输出):
1. 前置:Windows 机 Python ≥3.10(`python --version`)、蓝牙适配器在位、设备 NRF- 在附近。
2. 取码:`git clone`(或压缩包拷贝)到 `C:\ppclock`;`cd C:\ppclock`。
3. 环境:`python -m venv .venv; .venv\Scripts\pip install -e ".[mcp]"`。
4. 冒烟(无设备):`.venv\Scripts\ppclock-mcp --help` 打全参数表。
5. stdio 实测:`claude mcp add ppclock -- C:\ppclock\.venv\Scripts\ppclock-mcp.exe --mac <MAC>` → 调 `scan_devices`/`set_time`/`upload_image`/`set_mode image0` 目视上屏。
6. HTTP 实测:设 `PPCLOCK_MCP_TOKEN` → 启动 → 另一终端 `curl -H "Authorization: Bearer <token>" http://127.0.0.1:8972/mcp` 初始化握手;无 token 请求预期 401。
7. 约束核对:8971 bridge 与既有服务进程不动;8972 防火墙仅局域网放行。
8. 回报格式:每步截图/文本输出 + 目视结果。
- [ ] **Step 2: 经 Worker Bridge 请求熠管家协助**
```bash
W="$HOME/.local/bin/yi-worker-claude-current"
"$W" task.init ppclock-mcp-deploy '{"title":"ppclock-mcp Windows 部署实测","request_summary":"把 ppclock-mcp 部署到蓝牙所在 Windows 服务器并跑通 stdio+HTTP 实测(清单 tools/mcp_windows_deploy.md)","workspace":"/mnt/documents/Works/Eink/qianbian","native_session_id":"HOOK_INJECTED_EXACT_ID"}'
"$W" assist.requested ppclock-mcp-deploy '{"category":"action","question":"请按 tools/mcp_windows_deploy.md 在 Windows 服务器执行部署与实测,或给我该机的访问方式;约束:不动 8971 bridge 与既有服务","attempted":["本机 Linux 单元/协议测试全绿"],"needed":"Windows 执行环境或访问凭据","priority":"action_required"}'
```
(`native_session_id` 用 Hook 注入的原值;以上命令在 Task 5 执行时原样跑。)
- [ ] **Step 3: 提交清单**
```bash
git add tools/mcp_windows_deploy.md
git commit -m "docs: Windows 部署执行清单(熠管家协作)"
```
- [ ] **Step 4: 等复并执行联调**
inbox 回复到达后按回复执行(拿到访问方式则远程部署;熠管家代办则核对回报与验证清单)。每条事件处理后 `event.ack`。此步产出实测记录追加到 `docs/superpowers/specs/2026-07-30-ppclock-mcp-design.md` 末尾「实测记录」节。
---
## Self-Review 记录
- Spec 覆盖:13 工具 ✅(Task 2)、双模+鉴权 ✅(Task 3)、方案 C ✅(Task 1)、打包 extra ✅(Task 3)、文档/版本 ✅(Task 4)、Windows+熠管家 ✅(Task 5)、Linux 兼容(bleak 抽象,无平台分支代码)✅。
- 类型一致性:`DeviceManager.run(op)`/`status()` 签名在 Task 1 定义、Task 2 FakeManager 镜像;工具名 13 个 Task 2/3/4 一致;`TOKEN_ENV` Task 3 定义使用一致。
- 已知风险(执行时验证):FastMCP `stateless_http`/`streamable_http_app` 存在于 mcp≥1.10——Task 3 Step 2 失败则 `.venv/bin/pip install -U mcp` 并按实际 API 调整;`create_connected_server_and_client_session` 路径若变更,以 `python -c "import mcp.shared.memory; help(...)"` 为准。