用 Cynthion + MCP 讓 LLM 逆向 USB 裝置 — 從零到端對端實作
用 Cynthion + MCP 讓 LLM 逆向 USB 裝置 — 從零到端對端實作

TL;DR:把 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 。
起點:一個 7 個字的 prompt
「TARA 是什麼裝置?」
我把一顆陌生的 USB 裝置插到 Cynthion 的 TARGET-A 埠,然後在 Claude Code 對話框打了這 7 個字。三十秒後 Claude 回我:
Edimax Bluetooth Adapter(Realtek RTL chip),VID
7392:C611,藍牙 MAC00: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 跟 Cynthion 兩個 repo 搞清楚誰幹什麼,到設計一個能讓 LLM 操作硬體的 MCP server,到撞到 3 個 frame 格式的 bug、9 個軟體層的 bug,最後跑通 sniffer → decoder → 逆向 → emulator 全鏈路。
如果你對嵌入式 + USB 協定 + LLM tooling 三者的交叉感興趣,這篇是寫給你的。
前置條件
| 項目 | 版本 / 說明 |
|---|---|
| 硬體 | Cynthion r1.4 (FPGA:Lattice LFE5U-12F,ECP5 系列 12K LUT;輔助 MCU:SAMD11) |
| Host | macOS(Linux 應該也行,沒在這篇實測) |
| Python | 3.12 |
| 關鍵套件 | cynthion<mark>0.2.4、facedancer</mark>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 |
工具拆解:LUNA、Cynthion、Facedancer、Packetry 誰幹什麼
開始實作之前先花 30 分鐘讀 GitHub 上四個 repo 的 README,搞清楚這個生態裡誰是引擎、誰是車、誰是儀表板。沒搞清楚會把資源放錯位置。

| 專案 | 角色 | 一句話 |
|---|---|---|
| LUNA | 引擎 | Amaranth HDL 寫的 USB gateware building blocks(USB 2.0 / 3.0 device/host)。是函式庫,不是產品。 |
| Cynthion | 車 | 用 LUNA 寫出來的成品 applet(analyzer.bit / facedancer.bit / selftest.bit)+ 硬體設計 + host 端 Python CLI。 |
| Apollo | 引擎室 | Cynthion 板上的 debug MCU 韌體;負責 JTAG configure FPGA、SPI flash 上傳 bitstream。 |
| Facedancer | 駕駛艙 | Python 框架,讓你寫 class MyUSBDevice(USBDevice) 來模擬 USB 裝置;後端可用 Cynthion 或舊版 GreatFET。3.0 是 ground-up rewrite 。 |
| Packetry | 儀表板 | Rust 寫的 GUI 應用,讀 LINKTYPE_USB_2_0 格式的 .pcap 顯示 USB 流量。只是 viewer,沒有 CLI 模式。 |
| 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 模式 。所以 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
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 ;協定本身的規格在 modelcontextprotocol.io 。
為什麼要用 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 文件庫 裡 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。
# 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 的編碼是(其中 USBSpeed.HIGH = 0):
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 ):把 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 的建構子預設 msb_first=True。每個 16-bit 值在 wire 上是 high byte 先送 —— big-endian。我的 decoder 讀的是 little-endian:
# 錯
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,下一輪讀錯位然後整串都歪。
修法:
# 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 -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 。device 16 跟 20 是 host 在 poll 的兩個 HID 裝置,全部回 NAK 表示「我沒事報告」—— 對應「滑鼠沒在動、鍵盤沒在敲」的真實 idle 狀態。
實戰:從 wire 還原一顆 Bluetooth dongle 的完整指紋

接下來把一顆陌生的 USB-A 裝置插到 TARGET-A,並在 capture 中段拔插一次觸發完整 enumeration:
mcp__cynthion__capture_start(speed="auto")
# (使用者拔插 TARGET-A 上的目標裝置)
mcp__cynthion__capture_stop()
# → 647,660 bytes / 28.5 s
convert_to_pcap 之後跑 transaction_summary:
{
"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 ,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 回送):
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 結構 ):
| 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 |
| 5 | 01 |
bDeviceSubClass | RF Controller |
| 6 | 01 |
bDeviceProtocol | Bluetooth Programming Interface (Bluetooth Core spec §3 ) |
| 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 編碼 ,前兩個 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 資料庫 對應 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 規定的: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、藍牙 MAC00: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 模板測就直接成功:
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<mark>3.1.2 是當前 PyPI 上的最新版(2025-12-05 釋出),但 cynthion</mark>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' —— 這是 2025-08-01 跟 cynthion 0.2.3 同步釋出的版本,protocol 跟 0.2.4 內附韌體匹配。我把這個 pin 寫進 cynthion-mcp 的 pyproject.toml,加上 README 警告:
# 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 是這個故事的主角:
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 驗證:
>>> 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 表 、查 OUI、把結果交叉驗證。LLM 在這條工作流裡的角色是「會看 protocol spec 的工程師」,不是「會操作 GUI 的人」—— 後者它做不來,前者它做得比人快。
幾個觀察:
- LLM 是天然的 protocol 解碼器。USB descriptor 規格幾百頁但結構固定(length-type-value),一次就能把 18 個 byte 解出 vendor / product / class 並 cross-check 字串 cross-check OUI。真正花時間的部分是「讀文件、對照」,這部分 LLM 做得比人快。
- 「動詞」比「畫面」適合 LLM。Packetry 是好工具但畫面是給人看的;tshark + display filter 提供的是「找某類 packet」這種動詞接口,LLM 可以自己拼 query 探索資料,不需要被 GUI 限制。
- MCP 把 expert tool 變大眾化。原本要會用 Cynthion 你得熟 Apollo / Facedancer / Packetry / Wireshark / Amaranth 全套;現在一個非硬體背景的 user 只要會問問題就能用。
- 硬體比預期耐操。整個下午燒了 N 次 bitstream、JTAG TAP wedge 好幾次、SoC 死掉幾次 —— 每次都靠 Apollo soft_reset 或物理 replug 救回來,硬體本身完全沒事。
安裝步驟
# 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 。
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,最關鍵的兩個架構決定是:
- 不重新發明 USB decoder —— 用 Cynthion → pcap → tshark 鏈,把專業領域知識外包給已存在的二十年累積工具
- 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_0header 有比較嚴格的要求,還沒完全對齊;目前 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 ,硬體測試完整紀錄在 docs/HARDWARE-TEST-LOG.md 。歡迎 issue / PR / 偷我的點子做自己版本的硬體 MCP server。
參考資料
- 硬體與韌體
- Cynthion · Great Scott Gadgets — 官方產品頁
- greatscottgadgets/cynthion — Cynthion source + Moondancer 韌體
- greatscottgadgets/luna — Amaranth HDL USB gateware
- greatscottgadgets/facedancer — USB 裝置模擬框架(v3 是 ground-up rewrite)
- greatscottgadgets/packetry — Cynthion 的 GUI viewer
- Cynthion on Crowd Supply — 完整產品描述跟特性說明
- Model Context Protocol
- Introducing the Model Context Protocol — Anthropic 官方介紹
- Specification - Model Context Protocol — 協定規格
- Model Context Protocol — Wikipedia — 歷史與生態概覽
- USB 協定
- USB Defined Class Codes - USB-IF — 官方 class code 列表
- USB in a NutShell — Beyond Logic 經典 USB 教學
- Bluetooth Core Specification - USB Transport Layer — Class 0xE0/01/01 規格依據
- IEEE OUI Database — MAC OUI 反查
- 本文 source code
- Oliver0804/cynthion-mcp — 完整 MCP server source
- Hardware test log — 端到端硬體驗證紀錄
本文最初發布於 HackMD @BASHCAT。
留言
張貼留言