---
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 裝置 — 從零到端對端實作

> **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,搞清楚這個生態裡誰是引擎、誰是車、誰是儀表板。沒搞清楚會把資源放錯位置。

| 專案 | 角色 | 一句話 |
|---|---|---|
| [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 的格式陷阱

寫 `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 的完整指紋

接下來把一顆陌生的 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 上。

但這次的實驗結果是:**只給 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 的工具長什麼樣,怎麼用

註冊到 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) — 端到端硬體驗證紀錄