--- title: 用 Cynthion + MCP 讓 LLM 逆向 USB 裝置 — 從零到端對端實作 date: 2026-05-11 tags: [USB, Cynthion, Facedancer, MCP, LLM, 逆向工程, Anthropic, ClaudeCode, FPGA] status: published description: 一個下午把開源 USB 測試儀 Cynthion 接上 Model Context Protocol,讓 Claude Code 用自然語言抓 USB 封包、解碼、從裝置 descriptor 反推身分、再用 Facedancer 模擬一個假裝置。17 個 MCP tool、3 個 Cynthion frame 格式陷阱加 6 個軟體層 bug(合計 9 個)、1 顆從 wire 還原指紋的藍牙 dongle 的完整實作筆記。 --- # 用 Cynthion + MCP 讓 LLM 逆向 USB 裝置 — 從零到端對端實作 ![cynthion-mcp-hero](https://hackmd.io/_uploads/ByS5gN11zl.jpg) > **TL;DR**:把 [Cynthion](https://greatscottgadgets.com/cynthion/) 這片 FPGA-based 開源 USB 測試儀包裝成 Model Context Protocol server,給 Claude Code 提供 17 個動詞 tool(capture、dissect、emulate、…),讓 LLM 能用自然語言跑完整的 USB 逆向工程流程。中間踩到 3 個 Cynthion 自家 frame 格式的解碼 bug,加上 6 個軟體層 bug(合計 9 個),最後成功從 passive wire capture 還原一顆未知藍牙 dongle 的完整指紋(含 MAC),並 clone 出一個 FTDI 裝置給 macOS IOKit 認到。完整 source code 在 [Oliver0804/cynthion-mcp](https://github.com/Oliver0804/cynthion-mcp) 。 --- ## 起點:一個 7 個字的 prompt > 「TARA 是什麼裝置?」 我把一顆陌生的 USB 裝置插到 Cynthion 的 TARGET-A 埠,然後在 Claude Code 對話框打了這 7 個字。三十秒後 Claude 回我: > Edimax Bluetooth Adapter(Realtek RTL chip),VID `7392:C611`,藍牙 MAC `00:E0:4C:23:99:87`,Full-Speed,標準 USB Bluetooth HCI class,支援 ACL data 與 SCO 語音。 它沒裝任何 driver、沒去問裝置本身、沒用任何 OS 端的 USB 列舉資訊 —— 只是抓了 28 秒的 USB wire-level 流量,把裡面的 device descriptor 18 個位元組逐欄解出來,順便 cross-check 了 string descriptor 跟 OUI 廠商編碼。 這篇文章是這個能力背後一個下午的工程實作筆記:從讀 [LUNA](https://github.com/greatscottgadgets/luna) 跟 [Cynthion](https://github.com/greatscottgadgets/cynthion) 兩個 repo 搞清楚誰幹什麼,到設計一個能讓 LLM 操作硬體的 MCP server,到撞到 3 個 frame 格式的 bug、9 個軟體層的 bug,最後跑通 sniffer → decoder → 逆向 → emulator 全鏈路。 如果你對嵌入式 + USB 協定 + LLM tooling 三者的交叉感興趣,這篇是寫給你的。 ### 前置條件 | 項目 | 版本 / 說明 | |---|---| | 硬體 | [Cynthion r1.4](https://greatscottgadgets.com/cynthion/) (FPGA:Lattice LFE5U-12F,ECP5 系列 12K LUT;輔助 MCU:SAMD11) | | Host | macOS(Linux 應該也行,沒在這篇實測) | | Python | 3.12 | | 關鍵套件 | `cynthion==0.2.4`、`facedancer==3.1.1`(**必須 pin**,下面會解釋)、`mcp==1.27.1` | | 解碼工具 | `tshark` from Wireshark 4.x(`brew install wireshark`) | | LLM 客戶端 | Claude Code(任何 MCP-aware 客戶端皆可) | | 本文 source | [Oliver0804/cynthion-mcp](https://github.com/Oliver0804/cynthion-mcp) | --- ## 工具拆解:LUNA、Cynthion、Facedancer、Packetry 誰幹什麼 開始實作之前先花 30 分鐘讀 GitHub 上四個 repo 的 README,搞清楚這個生態裡誰是引擎、誰是車、誰是儀表板。沒搞清楚會把資源放錯位置。 ![cynthion-mcp-architecture](https://hackmd.io/_uploads/SJxheEk1Ml.jpg) | 專案 | 角色 | 一句話 | |---|---|---| | [LUNA](https://github.com/greatscottgadgets/luna) | 引擎 | Amaranth HDL 寫的 USB gateware building blocks(USB 2.0 / 3.0 device/host)。是函式庫,不是產品。 | | [Cynthion](https://github.com/greatscottgadgets/cynthion) | 車 | 用 LUNA 寫出來的成品 applet(`analyzer.bit` / `facedancer.bit` / `selftest.bit`)+ 硬體設計 + host 端 Python CLI。 | | [Apollo](https://github.com/greatscottgadgets/apollo) | 引擎室 | Cynthion 板上的 debug MCU 韌體;負責 JTAG configure FPGA、SPI flash 上傳 bitstream。 | | [Facedancer](https://github.com/greatscottgadgets/facedancer) | 駕駛艙 | Python 框架,讓你寫 `class MyUSBDevice(USBDevice)` 來模擬 USB 裝置;後端可用 Cynthion 或舊版 GreatFET。3.0 是 [ground-up rewrite](https://github.com/greatscottgadgets/facedancer/blob/main/README.md) 。 | | [Packetry](https://github.com/greatscottgadgets/packetry) | 儀表板 | Rust 寫的 GUI 應用,讀 LINKTYPE_USB_2_0 格式的 `.pcap` 顯示 USB 流量。**只是 viewer**,沒有 CLI 模式。 | | [Moondancer](https://github.com/greatscottgadgets/cynthion/tree/main/firmware/moondancer) | 啟動引擎 | Rust 寫的 RISC-V SoC 韌體,跑在 `facedancer.bit` 內部的 soft core 上,透過 libgreat-RPC 接受 Facedancer 指令。 | 關鍵理解:**Cynthion 是硬體 + bitstream + host CLI 的組合包,它的 FPGA 一次只能跑一個 applet**。MCP server 要做的事就是把「切 applet + 對應的 host 端操作」包裝成 LLM 可呼叫的 tool。 LUNA 本身對 MCP 沒貢獻 —— 它是 design-time 函式庫,沒有 runtime state。Packetry 同理:它是 GUI viewer,不是 library,[沒有 CLI export 模式](https://github.com/greatscottgadgets/packetry/blob/main/README.md) 。所以 MCP server 不能呼叫 Packetry,只能跟它共用一個 pcap 格式輸出。 --- ## 架構選擇:一個 MCP server,三種能力,動態切換 bitstream 逆向 USB 裝置的工作流是三段式的:抓 → 看懂 → 複製或竄改。三段對應三種能力: ``` ┌─ switch_mode('analyzer') ──────┐ │ capture_start │ plug in target ───┤ (target enumerates) │ │ capture_stop │ └─ convert_to_pcap ───────────────┘ │ ▼ ┌─ transaction_summary ──────────┐ │ dissect_packets(filter, …) │ ← LLM 解析, │ find_vendor_requests │ 抽 descriptor, └────────────────────────────────┘ 找協定 pattern │ ▼ ┌─ switch_mode('facedancer') ────┐ │ emulator_diagnose │ │ emulate_from_descriptor(…) │ ← clone 裝置, │ emulate_device('ftdi') etc │ fuzz 回應, │ disconnect_device │ 重放 vendor req └────────────────────────────────┘ ``` `switch_mode` 是這條鏈的關節 —— 它呼叫 `cynthion run <applet>` 把 `.bit` 燒進 FPGA SRAM。Cynthion 也支援持久燒到 flash(`cynthion flash`),但 MCP 場景下用 SRAM 即可,反正每次測試都不一樣。 Tool 表面總計 **17 個**: | 群組 | tools | |---|---| | Hardware (3) | `get_status` / `switch_mode` / `recover` | | Sniffer (5) | `capture_start` / `capture_stop` / `capture_status` / `list_captures` / `read_capture` | | Decoder (4) | `convert_to_pcap` / `dissect_packets` / `transaction_summary` / `find_vendor_requests` | | Emulator (5) | `emulator_diagnose` / `emulate_device` / `emulate_from_descriptor` / `disconnect_device` / `inject_serial` | 設計原則只有一條:**給 LLM 的工具要是動詞,不是畫面**。tshark 之所以比 Wireshark GUI 更適合給 LLM 用,是因為它接受 display filter 這種**可組合的查詢語法** —— `usbll.pid == 0x2d` 比「按那個 SETUP 顏色的按鈕」對 LLM 友善太多。 ### MCP server 怎麼註冊到 Claude Code ```sh claude mcp add -s user cynthion /absolute/path/to/.venv/bin/cynthion-mcp claude mcp list | grep cynthion # → cynthion: …/.venv/bin/cynthion-mcp - ✓ Connected ``` 之後重啟 Claude Code,17 個 tool 會以 `mcp__cynthion__*` 命名出現在對話的 deferred tool list 裡。Anthropic 對 Model Context Protocol 的官方介紹見 [Introducing the Model Context Protocol](https://www.anthropic.com/news/model-context-protocol) ;協定本身的規格在 [modelcontextprotocol.io](https://modelcontextprotocol.io/specification/2025-11-25) 。 --- ## 為什麼要用 tshark,而不是自己寫 USB 解碼器 研究階段曾經考慮過自己寫一個 Cynthion native frame → human-readable transaction 的解碼器,後來決定不寫。 USB 不只是底層 packet framing;它是個堆疊: ``` ┌────────────────────────────────────┐ │ Class-specific (HID / MSC / UVC …)│ ├────────────────────────────────────┤ │ Standard requests (descriptors …) │ ├────────────────────────────────────┤ │ Transfers (control / bulk / int) │ ├────────────────────────────────────┤ │ Transactions (token-data-handshake)│ ├────────────────────────────────────┤ │ Packets (PID + payload + CRC) │ ← Cynthion analyzer 抓在這層 └────────────────────────────────────┘ ``` 要把 raw byte 解到 LLM 能直接讀的層級(「Mass Storage SCSI READ(10) LBA=0x1000 length=8 sectors」),上面這五層都要寫。USB-IF [文件庫](https://www.usb.org/documents) 裡 base spec + 各 class 規格加起來幾千頁,是業界二十年的累積。 我選的捷徑是:**自己只負責 Cynthion 自家 framed binary → 標準 pcap 的轉換,剩下的解析全部丟給 tshark**。 tshark 是 Wireshark 的 CLI 模式,包含完整的 USB protocol dissector(HID、MSC、UVC、Audio、CDC 等)。從 4.x 開始它的輸出 `-T json` 是 LLM 友善的結構化 record。Display filter 語法是固定 DSL,LLM 可以自己拼 query。 ```python # cynthion_mcp/tshark.py 的核心就一行 proc = subprocess.run( [tshark, "-r", str(pcap_path), "-T", "json", "-Y", display_filter or ""], capture_output=True, text=True, timeout=60, ) return json.loads(proc.stdout) ``` 整個 decoder layer 含 PID 命名表跟 summariser 加起來 175 行。如果自己寫完整 dissector 至少要兩個月。 --- ## 撞牆三連發:Cynthion native frame 的格式陷阱 ![cynthion-mcp-frame-bugs](https://hackmd.io/_uploads/rJzaxVkyMe.jpg) 寫 `decoder.py` 把 `.bin` 轉 pcap 的時候,前三次嘗試全錯,三個 bug 互相疊在一起: ### Bug 1:speed enum 對錯位 第一次 `capture_start(speed="auto")` 抓 5 秒得到 11 KB,decoder 解出 0 個 packet。所有的 byte 都是「事件」,沒有「封包」。 原因:[`cynthion.gateware.analyzer.speeds.USBAnalyzerSpeed`](https://github.com/greatscottgadgets/cynthion/blob/main/cynthion/python/src/gateware/analyzer/speeds.py) 的編碼是(其中 `USBSpeed.HIGH = 0`): ```python class USBAnalyzerSpeed(IntEnum): HIGH = 0b00 # ← USBSpeed.HIGH 是 0 FULL = 0b01 LOW = 0b10 AUTO = 0b11 # r0.6+ 才有 auto ``` 但我寫 `CaptureSpeed.AUTO = 0b00` —— 等於用 HS 模式試圖抓 Full-Speed Logitech 接收器,analyzer 在 HS 線根本看不到 FS 信號,所以只收到 timer wrap 事件,0 個 packet。 修法(在 [`capture.py`](https://github.com/Oliver0804/cynthion-mcp/blob/main/src/cynthion_mcp/capture.py) ):把 enum 跟 gateware 對齊。`"auto"` map 到 `0b11`,HS / FS / LS 各自對應 `0b00 / 0b01 / 0b10`。 ### Bug 2:endianness 寫反 修好 speed 後抓 5 秒得到 26,791 packet,tshark 卻顯示 `USBLL 768 SOF`。一個 SOF 應該是 3 bytes(PID + 11-bit frame number + 5-bit CRC5),怎麼會 768 bytes? 讀 gateware:[`cynthion.gateware.analyzer.fifo.Stream16to8`](https://github.com/greatscottgadgets/cynthion/blob/main/cynthion/python/src/gateware/analyzer/fifo.py) 的建構子預設 `msb_first=True`。每個 16-bit 值在 wire 上是 high byte 先送 —— **big-endian**。我的 decoder 讀的是 little-endian: ```python # 錯 size = data[pos] | (data[pos + 1] << 8) # 對 size = (data[pos] << 8) | data[pos + 1] ``` SOF 真實 size = `0x0003`。Big-endian 是 `00 03`,little-endian 讀成 `0x0300 = 768`。修法:所有 16-bit 欄位(packet size、timestamp)改用 big-endian。 ### Bug 3:16-bit 對齊的 padding 修好 endian 後 SOF 變 3 bytes ✓,但每兩個 packet 就有一個變成「Invalid Packet ID (0x14) 797 bytes」的鬼東西。 從 `.bin` 直接 dump bytes 看: ``` offset bytes 解讀 4-7 00 03 ae 26 header: size=3, time=0xae26 8-10 a5 ac ed payload: SOF (PID 0xa5) 11 00 ← padding!(size 是 3,奇數) 12-15 00 03 1d 64 下一個 header ``` Cynthion gateware 把所有記錄 16-bit 對齊;奇數長度的 packet 後面會 padding 一個位元組。我的 decoder `pos += 4 + size` 之後剛好停在 padding,下一輪讀錯位然後整串都歪。 修法: ```python # Gateware writes everything 16-bit-aligned, so odd-size packets # are followed by a single byte of padding. Advance past it. pos += 4 + size + (size & 1) ``` ### 三個 bug 修完之後 ```python $ python -c "from cynthion_mcp.decoder import cynthion_bin_to_pcap; ..." packets=26791 events=1 duration=5.003s speed=full CAPTURE_START_FULL: 1 ``` tshark 對轉出來的 pcap 完全認得: ``` 1 0.000000 host → broadcast USBLL 3 SOF 2 0.000125 host → 20.4 USBLL 3 IN 3 0.000130 host → 20.2 USBLL 3 IN 4 0.000136 host → 16.3 USBLL 3 IN 5 0.000139 16.3 → host USBLL 1 NAK ← Logitech 回 NAK 6 0.000250 host → 16.2 USBLL 3 IN 7 0.000253 16.2 → host USBLL 1 NAK 8 0.000999 host → broadcast USBLL 3 SOF ``` SOF 每 1 ms 一次 —— [Full-Speed USB 的標準 frame interval](https://www.beyondlogic.org/usbnutshell/usb3.shtml) 。device 16 跟 20 是 host 在 poll 的兩個 HID 裝置,全部回 NAK 表示「我沒事報告」—— 對應「滑鼠沒在動、鍵盤沒在敲」的真實 idle 狀態。 --- ## 實戰:從 wire 還原一顆 Bluetooth dongle 的完整指紋 ![cynthion-mcp-reverse-engineering](https://hackmd.io/_uploads/S1bCxN1yMl.jpg) 接下來把一顆陌生的 USB-A 裝置插到 TARGET-A,並在 capture 中段拔插一次觸發完整 enumeration: ```python mcp__cynthion__capture_start(speed="auto") # (使用者拔插 TARGET-A 上的目標裝置) mcp__cynthion__capture_stop() # → 647,660 bytes / 28.5 s ``` `convert_to_pcap` 之後跑 `transaction_summary`: ```json { "total_packets": 79350, "pid_counts": {"SOF": 25815, "IN": 53276, "SETUP": 28, "DATA0": 30, "DATA1": 56, "ACK": 85, "OUT": 26}, "device_counts": {"device_20": 51630, "device_16": 41, "device_17": 1614, "device_23": 41, "device_0": 4} } ``` `device_0` 出現 4 次 —— USB 規格規定[新 device 一開始都掛在 address 0](https://www.beyondlogic.org/usbnutshell/usb6.shtml) ,host 用 SET_ADDRESS 把它移到一個未占用的地址。4 個 packet 對應**兩次完整的「address 0 → SET_ADDRESS → 新地址」儀式**:第一次跑到 address 23、第二次跑到 address 16。 剩下的 device address 來歷: - `device_20`(51,630 packet)跟 `device_17`(1,614 packet)是 capture 啟動**之前**就已經接在 bus 上的既有裝置(HID 主控制器與其他常駐 HID),整個 capture 期間 host 持續 poll 它們,所以 packet 數很高 - `device_23` 跟 `device_16`(各 41 packet)就是這次拔插觸發 enumeration 的兩個地址 —— 配上「兩次 address 0 儀式」可以推斷這顆 dongle 是 **複合結構**:可能是內含 hub 並向 host 暴露多個子裝置,也可能是裝置自己 re-enumerate 出第二個 logical entry 抓 SETUP token 跟其後的 DATA1(descriptor 通常用 DATA1 回送): ```sh tshark -r capture.pcap -Y 'usbll.pid == DATA1' \ -T fields -e frame.number -e usbll.addr -e usbll.data ``` 開頭就是 device descriptor 18 個位元組: ``` 38737 23.0,host 12011001e0010140927311c6000201020301 ``` 逐欄解析([device descriptor 結構](https://www.beyondlogic.org/usbnutshell/usb5.shtml) ): | Offset | Bytes | Field | 解碼 | |---|---|---|---| | 0 | `12` | bLength | 18 | | 1 | `01` | bDescriptorType | DEVICE | | 2–3 | `10 01` | bcdUSB | 0x0110 = USB 1.1 | | **4** | **`e0`** | **bDeviceClass** | **Wireless Controller** — 對應 [USB-IF Class 0xE0](https://www.usb.org/defined-class-codes) | | **5** | **`01`** | **bDeviceSubClass** | **RF Controller** | | **6** | **`01`** | **bDeviceProtocol** | **Bluetooth Programming Interface** ([Bluetooth Core spec §3](https://www.bluetooth.com/wp-content/uploads/Files/Specification/HTML/Core-54/out/en/host-controller-interface/usb-transport-layer.html) ) | | 7 | `40` | bMaxPacketSize0 | 64 | | **8–9** | **`92 73`** | **idVendor (LE)** | **0x7392 = Edimax Technology** | | **10–11** | **`11 c6`** | **idProduct (LE)** | **0xC611** | | 12–13 | `00 02` | bcdDevice | 2.00 | | 14–16 | `01 02 03` | iMfg / iProd / iSer | 1 / 2 / 3 | | 17 | `01` | bNumConfigurations | 1 | 接著抓三個 string descriptor。USB string descriptor 是 [UTF-16LE 編碼](https://www.beyondlogic.org/usbnutshell/usb5.shtml#StringDescriptors) ,前兩個 byte 是長度跟 type(`0x03`),後面是字元: ``` 38782 10 03 52 00 65 00 61 00 6c 00 74 00 65 00 6b 00 → "Realtek" 38760 32 03 45 00 64 00 69 00 6d 00 61 00 78 00 20 00 ... → "Edimax Bluetooth Adapter" 38806 1a 03 30 00 30 00 45 00 30 00 34 00 43 00 ... → "00E04C239987" ``` 最後一個字串看起來是 serial number,但**結構完全是 Bluetooth MAC**:`00:E0:4C:23:99:87`。OUI `00:E0:4C` 在 [IEEE OUI 資料庫](https://standards-oui.ieee.org/) 對應 **REALTEK SEMICONDUCTOR CORP** —— 跟製造商字串完全吻合。 繼續解 configuration descriptor(從 frame 38828 開始的 DATA1): ``` 09 02 b1 00 02 01 00 e0 fa ← config: total 177 B, 2 interfaces, 500 mA 09 04 00 00 03 e0 01 01 04 ← iface 0: BT HCI class, 3 endpoints 07 05 81 03 10 00 01 ← EP 0x81 INT IN 16 B (HCI events) 07 05 02 02 40 00 00 ← EP 0x02 BULK OUT 64 B (ACL out) 07 05 82 02 40 00 00 ← EP 0x82 BULK IN 64 B (ACL in) 09 04 01 00 02 e0 01 01 04 ← iface 1 alt 0: SCO audio (zero bandwidth) ... (multiple alt settings for SCO voice payload sizes) ``` 3 個 endpoint 的配置剛好對應 [Bluetooth Core spec USB Transport](https://www.bluetooth.com/wp-content/uploads/Files/Specification/HTML/Core-54/out/en/host-controller-interface/usb-transport-layer.html) 規定的:interrupt IN 給 HCI events、bulk OUT/IN 給 ACL data。第二個介面是 SCO audio,有多個 alt setting 對應不同 voice codec payload size。 把所有線索拼起來: > **TARGET-A 上插的是一顆 Edimax Bluetooth dongle**:USB VID/PID `7392:C611`、Realtek RTL 系列藍牙 IC、Full-Speed、藍牙 MAC `00:E0:4C:23:99:87`、支援 HCI command + ACL data + SCO 語音的標準 USB Bluetooth HCI 裝置。 我沒裝任何 driver、沒對它送任何 command、host 端的 USB 列舉資訊全部沒參考 —— **這套指紋從 wire 上 79350 個 packet 純被動推理出來**。這就是 USB 逆向工程的本質。 --- ## Clone 的限制:FTDI 通了,Bluetooth 還沒 逆向出 descriptor 之後,最自然的下一步就是 clone 給 Cynthion 變成那個裝置。對應的 tool 是 `emulate_from_descriptor`,吃 hex 字串、丟給 facedancer 的 `USBBaseDevice.from_binary_descriptor()`、Moondancer SoC 把它送到 TARGET-C 上。 ![cynthion-mcp-device-clone](https://hackmd.io/_uploads/HyUgW4y1zg.jpg) 但這次的實驗結果是:**只給 descriptor 不夠**。我送了 Edimax 的 device descriptor + 一個自己手刻的 39 bytes config(保留 Bluetooth HCI interface 跟 3 個 endpoint)給 facedancer,emulation 啟動成功、Moondancer SoC 回 `ok`、但 macOS 始終沒列舉出來。 原因是 Bluetooth Class 0xE0 的 host driver 在 enumeration 之後還會送 **class-specific HCI command**(`HCI_RESET`、`HCI_READ_LOCAL_VERSION` 等),需要一個有 in-band 行為的假控制器。Passive descriptor responder 只能讓 USB layer 認識它,driver layer 一進來就翻臉。 要真的 clone Bluetooth dongle,得寫一個專屬 `EdimaxBluetoothDevice` 子類別,實作 HCI command/event handler + bidirectional ACL relay。這已經是寫一個 fake controller,不再是 descriptor replay 的範圍。 不過用 facedancer 內建的 `FTDIDevice` 模板測就直接成功: ```python mcp__cynthion__emulate_device(device_type="ftdi") ``` macOS `ioreg` 立即抓到: ``` +-o FTDI emulation@00112400 <class IOUSBHostDevice, registered, matched, active> "USB Product Name" = "FTDI emulation" "USB Vendor Name" = "not-FTDI" "idVendor" = 1027 (0x0403) "idProduct" = 24577 (0x6001) ``` Location ID `00112400` 跟前面 TARGET-A 上 Logitech 接收器穿透到 host 時出現的位置是同一個 USB hub 分支 —— 證實 emulated 裝置確實透過 Cynthion TARGET-C 抵達 macOS USB stack。 ### 為什麼要 pin facedancer 3.1.1 `facedancer==3.1.2` 是當前 PyPI 上的最新版(2025-12-05 釋出),但 `cynthion==0.2.4` wheel 內附的 Moondancer SoC 韌體是 2025-10 編的。兩者之間 libgreat-RPC protocol 有 silent break:connect verb 的 argument 配置改了,舊韌體不認新 protocol。 症狀:facedancer 3.1.2 + Moondancer 0.2.4 → 第一個 RPC 就 `LIBUSB_ERROR_TIMEOUT`,整個 SoC 看起來像死了。 解:[`pip install 'facedancer==3.1.1'`](https://github.com/greatscottgadgets/facedancer/releases) —— 這是 2025-08-01 跟 cynthion 0.2.3 同步釋出的版本,protocol 跟 0.2.4 內附韌體匹配。我把這個 pin 寫進 `cynthion-mcp` 的 `pyproject.toml`,加上 README 警告: ```toml # Pinned: 3.1.2 (latest) ships a libgreat-RPC protocol that the Moondancer # firmware bundled in cynthion 0.2.4 doesn't speak. 3.1.1 is the last # version that round-trips with the shipped firmware. "facedancer==3.1.1", ``` 未來 cynthion 出新 release 才能 unpin。 --- ## 整個下午抓到的 9 個 bug 寫 cynthion-mcp 的過程中總共抓到 9 個自己程式碼的 bug。除了上面三個 frame format 的,還有: | 檔案 | bug | 修法 | |---|---|---| | `hardware.py` | `_find_gsg_device` 只篩 VID 0x1d50 → 撞到旁邊 HackRF One(也是 GSG VID)被優先抓 | 改篩 `(VID, PID)` pair,只認 Cynthion 的 `615b/615c` | | `hardware.py` | `_open_apollo` 在 stub mode 遞迴呼叫 recover → 無限 log loop | 限制單次 recovery pass | | `hardware.py` | `_safe_soft_reset` 預設不帶 `force_offline`,stub 模式下 Apollo 會拒絕開啟 → soft_reset 從來沒真的 fire | 加 fallback 鏈:先試 default,失敗再用 `force_offline=True` | | `emulator.py` | `disconnect_device` 用 `asyncio.run_coroutine_threadsafe(raise EndEmulation)` 從外部 inject 例外 → **沒進 device.run() 的 try block** → Moondancer SoC 每次都 wedge | 改用 watcher coroutine 在 asyncio loop 內 polling `threading.Event`,set 時才 raise;事後 facedancer 自己的 finally block 會正確 disconnect | | `emulator.py` | `inject_serial` 呼叫不存在的 `send_data` 方法 | 改用 facedancer 3.1.x 真正存在的 `transmit` / `send` | | `emulator.py` | `emulate_from_descriptor` 沒包 `bytes.fromhex` 例外 → 壞 hex 直接 crash MCP server | 加 try/except,回友善訊息 | | `server.py` | 17 個 tool 全部沒包 try/except,一次 USB timeout 整個 stdio server 死掉 | 寫 `@_safe` decorator 包在 `@mcp.tool()` 下層,回 `{"error": ...}` 而非 raise;保留 `__signature__` 讓 pydantic 還能解析 enum 型別 | | `capture.py` | `stop_capture` 開第二把 USB handle 跟 drainer thread 競爭 | drainer thread 全程獨佔 handle,finally 內 `usb.util.dispose_resources(dev)` | 每個 bug 都不是設計問題,是「真實硬體下才暴露」的 edge case。例如「兩台 GSG 裝置同時插」這種狀況單元測試完全測不到 —— 我手上同時插了 HackRF One 跟 Cynthion 才看見 `_find_gsg_device` 的 VID-only 篩選會吃錯。 ### 給 LLM 用的 MCP server 跟給人用的 CLI 有什麼不一樣 最關鍵的差異是 **error handling 的彈性**。CLI 工具發生錯誤,使用者看到 stack trace 會自己決定重試或改參數。MCP server 不能這樣 —— 一次 unhandled exception 整個 stdio session 死掉,**所有 tool 同時失效**,使用者得 `/exit` 重開 Claude Code 才能繼續。 `@_safe` decorator 是這個故事的主角: ```python def _safe(fn): sig = inspect.signature(fn) # 保留 pydantic 需要的 type info @functools.wraps(fn) def wrapper(*args, **kwargs): try: return fn(*args, **kwargs) except Exception as e: log.warning("tool %s failed: %s", fn.__name__, e) return { "error": f"{type(e).__name__}: {e}", "tool": fn.__name__, "traceback_tail": traceback.format_exc().strip().splitlines()[-3:], } wrapper.__signature__ = sig return wrapper ``` 關鍵細節:`from __future__ import annotations` 要拿掉,否則 pydantic 沒辦法解 `Literal[…]` 這種 forward reference(FastMCP 用 pydantic 建每個 tool 的 argument model)。 修完之後跑一個錯誤 path 驗證: ```python >>> emulate_from_descriptor(device_descriptor_hex="not valid hex!!") { "error": "ValueError: device_descriptor_hex is not valid hex: …", "tool": "emulate_from_descriptor", "traceback_tail": [...] } # server 還活著,其他 16 個 tool 都還能用 ✓ ``` --- ## 給 LLM 的工具長什麼樣,怎麼用 ![cynthion-mcp-lessons](https://hackmd.io/_uploads/HkE--E1Jzx.jpg) 註冊到 Claude Code 之後,這段對話直接就能跑: > 「切到 analyzer 模式,抓 5 秒,我會在中途拔插一顆裝置。停掉後告訴我看到的所有 device address,並用 SETUP/DATA1 配對抽出每個裝置的 device descriptor 跟 string descriptor。」 Claude 會自己 chain 起來: ``` mcp__cynthion__switch_mode(applet="analyzer") → bitstream_name: "USB Analyzer" mcp__cynthion__capture_start(speed="auto") → id: "20260511-163801-315aec" (等使用者拔插) mcp__cynthion__capture_stop() mcp__cynthion__convert_to_pcap(capture_id="...") mcp__cynthion__transaction_summary(capture_id="...") mcp__cynthion__dissect_packets( capture_id="...", display_filter="usbll.pid == DATA1 and usbll.addr matches \"0\\.0|23\\.0|16\\.0\"", limit=20 ) ``` 接著它自己讀 hex、查 [USB-IF class code 表](https://www.usb.org/defined-class-codes) 、查 OUI、把結果交叉驗證。LLM 在這條工作流裡的角色是「會看 protocol spec 的工程師」,不是「會操作 GUI 的人」—— 後者它做不來,前者它做得比人快。 幾個觀察: 1. **LLM 是天然的 protocol 解碼器**。USB descriptor 規格幾百頁但結構固定(length-type-value),一次就能把 18 個 byte 解出 vendor / product / class 並 cross-check 字串 cross-check OUI。真正花時間的部分是「讀文件、對照」,這部分 LLM 做得比人快。 2. **「動詞」比「畫面」適合 LLM**。Packetry 是好工具但畫面是給人看的;tshark + display filter 提供的是「找某類 packet」這種動詞接口,LLM 可以自己拼 query 探索資料,不需要被 GUI 限制。 3. **MCP 把 expert tool 變大眾化**。原本要會用 Cynthion 你得熟 Apollo / Facedancer / Packetry / Wireshark / Amaranth 全套;現在一個非硬體背景的 user 只要會問問題就能用。 4. **硬體比預期耐操**。整個下午燒了 N 次 bitstream、JTAG TAP wedge 好幾次、SoC 死掉幾次 —— 每次都靠 Apollo soft_reset 或物理 replug 救回來,硬體本身完全沒事。 ### 安裝步驟 ```sh # 1. 三個 repo side-by-side git clone https://github.com/greatscottgadgets/luna.git git clone https://github.com/greatscottgadgets/cynthion.git git clone https://github.com/Oliver0804/cynthion-mcp.git # 2. 共用一個 venv python3 -m venv .venv ./.venv/bin/pip install -e ./luna ./.venv/bin/pip install -e ./cynthion/cynthion/python ./.venv/bin/pip install -e ./cynthion-mcp # 3. 從 PyPI wheel 把預編 bitstream + Moondancer 韌體解到 source 樹(source clone 沒附二進位) ./.venv/bin/pip download cynthion --no-deps -d /tmp/cw unzip /tmp/cw/cynthion-*.whl 'cynthion/assets/*' -d /tmp/ext cp -r /tmp/ext/cynthion/assets/* ./cynthion/cynthion/python/assets/ # 4. 註冊 MCP server 到 Claude Code claude mcp add -s user cynthion ./.venv/bin/cynthion-mcp ``` 之後重啟 Claude Code,17 個 tool 出現在 `mcp__cynthion__*` namespace。完整安裝步驟跟 troubleshooting 在 [Oliver0804/cynthion-mcp 的 README](https://github.com/Oliver0804/cynthion-mcp/blob/main/README.md) 。 ### Troubleshooting | 症狀 | 可能原因 | 解法 | |---|---|---| | `emulator_diagnose` 回 `LIBUSB_ERROR_TIMEOUT` | facedancer 版本太新 / 太舊 | `pip install 'facedancer==3.1.1'` | | 燒 bitstream 時 `bitstream provides data past the device's SRAM array (fffffff8)` | JTAG TAP stuck | 先 `cynthion run analyzer`(小 bitstream 重設 TAP),再 Apollo soft_reset,再燒目標 applet | | `Apollo stub interface found but not requested to be forced offline` | 嘗試在 stub mode 開啟 Apollo 不帶 force_offline | 開 `ApolloDebugger(force_offline=True)` 或先實體 replug | | `MCP error -32000: Connection closed` | 早期版本沒包 try/except 的 bug,現在 0.0.2 之後應該不會了 | 升級 cynthion-mcp,重啟 Claude Code 把 server 拉起來 | | 抓不到任何 packet 但 `bytes_written` 一直長 | speed 選錯了(HS 抓 FS 等等) | 用 `"auto"`,或對應目標裝置的真實速度 | --- ## 結語:還沒解的問題,跟下一步 整套 cynthion-mcp 在一個下午從零到 production,最關鍵的兩個架構決定是: 1. **不重新發明 USB decoder** —— 用 Cynthion → pcap → tshark 鏈,把專業領域知識外包給已存在的二十年累積工具 2. **MCP tool 設計為動詞**,給 LLM 可組合的查詢能力,而不是把 GUI 用文字描述出來 還沒解的問題: - **完整 Bluetooth dongle clone**:需要實作 HCI command/event 處理。目前 `emulate_from_descriptor` 對 vendor-specific 簡單裝置 OK,對 class-driver 嚴格的裝置(BT、UVC、MSC 部分情境)需要寫 class-specific handler。 - **Packetry 相容性**:我吐的 pcap tshark 吃得很好,但 Packetry 對 [`LINKTYPE_USB_2_0`](https://www.tcpdump.org/linktypes.html) header 有比較嚴格的要求,還沒完全對齊;目前 Packetry 開不開得了我的 capture 是時好時壞。 - **USB 3.x SuperSpeed**:Cynthion analyzer 不 tee SS 線對,這是上游硬體限制。要做 SS 得換另一套硬體。 - **連續多次 emulate**:原本「single-shot per applet」的著名 bug 已經透過 watcher coroutine 修了,但還沒回歸測試「同一個 applet load 後做 N 次 emulate→disconnect 循環不需要 reset」這個 case。下次 session 補。 如果你也有 Cynthion 想玩,repo 在 [Oliver0804/cynthion-mcp](https://github.com/Oliver0804/cynthion-mcp) ,硬體測試完整紀錄在 [`docs/HARDWARE-TEST-LOG.md`](https://github.com/Oliver0804/cynthion-mcp/blob/main/docs/HARDWARE-TEST-LOG.md) 。歡迎 issue / PR / 偷我的點子做自己版本的硬體 MCP server。 --- ## 參考資料 - **硬體與韌體** - [Cynthion · Great Scott Gadgets](https://greatscottgadgets.com/cynthion/) — 官方產品頁 - [greatscottgadgets/cynthion](https://github.com/greatscottgadgets/cynthion) — Cynthion source + Moondancer 韌體 - [greatscottgadgets/luna](https://github.com/greatscottgadgets/luna) — Amaranth HDL USB gateware - [greatscottgadgets/facedancer](https://github.com/greatscottgadgets/facedancer) — USB 裝置模擬框架(v3 是 ground-up rewrite) - [greatscottgadgets/packetry](https://github.com/greatscottgadgets/packetry) — Cynthion 的 GUI viewer - [Cynthion on Crowd Supply](https://www.crowdsupply.com/great-scott-gadgets/cynthion) — 完整產品描述跟特性說明 - **Model Context Protocol** - [Introducing the Model Context Protocol](https://www.anthropic.com/news/model-context-protocol) — Anthropic 官方介紹 - [Specification - Model Context Protocol](https://modelcontextprotocol.io/specification/2025-11-25) — 協定規格 - [Model Context Protocol — Wikipedia](https://en.wikipedia.org/wiki/Model_Context_Protocol) — 歷史與生態概覽 - **USB 協定** - [USB Defined Class Codes - USB-IF](https://www.usb.org/defined-class-codes) — 官方 class code 列表 - [USB in a NutShell](https://www.beyondlogic.org/usbnutshell/usb1.shtml) — Beyond Logic 經典 USB 教學 - [Bluetooth Core Specification - USB Transport Layer](https://www.bluetooth.com/wp-content/uploads/Files/Specification/HTML/Core-54/out/en/host-controller-interface/usb-transport-layer.html) — Class 0xE0/01/01 規格依據 - [IEEE OUI Database](https://standards-oui.ieee.org/) — MAC OUI 反查 - **本文 source code** - [Oliver0804/cynthion-mcp](https://github.com/Oliver0804/cynthion-mcp) — 完整 MCP server source - [Hardware test log](https://github.com/Oliver0804/cynthion-mcp/blob/main/docs/HARDWARE-TEST-LOG.md) — 端到端硬體驗證紀錄