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

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

cynthion-mcp-hero

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,藍牙 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 廠商編碼。

這篇文章是這個能力背後一個下午的工程實作筆記:從讀 LUNACynthion 兩個 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.4facedancer</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,搞清楚這個生態裡誰是引擎、誰是車、誰是儀表板。沒搞清楚會把資源放錯位置。

cynthion-mcp-architecture

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

cynthion-mcp-frame-bugs

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

cynthion-mcp-reverse-engineering

接下來把一顆陌生的 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_23device_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 MAC00:E0:4C:23:99:87。OUI 00:E0:4CIEEE 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、藍牙 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

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

cynthion-mcp-lessons

註冊到 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 的人」—— 後者它做不來,前者它做得比人快。

幾個觀察:

  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 救回來,硬體本身完全沒事。

安裝步驟

# 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_diagnoseLIBUSB_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 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 ,硬體測試完整紀錄在 docs/HARDWARE-TEST-LOG.md 。歡迎 issue / PR / 偷我的點子做自己版本的硬體 MCP server。


參考資料


本文最初發布於 HackMD @BASHCAT

留言

這個網誌中的熱門文章

Arduino 課本可能沒教的事(1)

SI4432 搭配Arduino

燒錄 Arduino mini Pro 燒錄