# 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": , "data": {...}}`;失败 `{"ok": false, "tool": , "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 ,不符 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://: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 ` → 调 `scan_devices`/`set_time`/`upload_image`/`set_mode image0` 目视上屏。 6. HTTP 实测:设 `PPCLOCK_MCP_TOKEN` → 启动 → 另一终端 `curl -H "Authorization: Bearer " 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(...)"` 为准。