앞선 편들의 하드웨어는 전부 시뮬레이터였다. C로 짜서 실제로 컴파일되고 실행되는 시뮬이었지만, 유리도 흙도 차도 없었다. 매번 그렇게 적어 두었다.
이번엔 아니다. 책상 위에 보드 두 대가 실제로 꽂혀 있다. 하나는 USB 케이블로, 하나는 Wi-Fi 너머에.
그리고 이 편의 논지는 그 둘을 붙이는 방법이 얼마나 다르고, 붙인 다음이 얼마나 같은가다.
결과부터 — 두 보드가 건넨 화면
USB 시리얼로 붙은 WeAct H723 (STM32H7) 이 건넨 화면.

mDNS로 찾아 Wi-Fi TCP로 붙은 ESP32 가 건넨 화면. 같은 클라이언트인데 훨씬 많다.

두 화면 어느 쪽도 이 저장소에 없다. 보드가 건넸다.
전체 그림
STM32H723 ──UART 115200──▶ serial_bridge (C) ──┐
├──stdio──▶ mcp_client + 런타임
ESP32 ──Wi-Fi TCP:6270──▶ tcp_bridge (C) ──┘ (양쪽 동일)
▲
└─ mDNS `_mcp._tcp` 로 발견 (주소를 사람이 안 적는다)
① 전송은 프로세스다
MCP 클라이언트는 명령을 실행하고 그 stdio로 말하는 법을 이미 안다. 그러면 전송을 프로세스로 만들면 클라이언트에 시리얼 지원도 소켓 지원도 필요 없어진다.
시리얼 쪽 브리지의 핵심은 이렇다.
struct termios tio;
tcgetattr(fd, &tio);
cfmakeraw(&tio); /* 에코 없음, 줄편집 없음, CR/LF 변환 없음 */
cfsetispeed(&tio, speed);
cfsetospeed(&tio, speed);
tio.c_cflag |= (CLOCAL | CREAD);
tio.c_cflag &= (tcflag_t)~CRTSCTS;
tcsetattr(fd, TCSANOW, &tio);
cfmakeraw 가 없으면 터미널 드라이버가 줄을 편집하고 CR을 끼워 넣어 JSON이 깨진다. 임베디드에서 "왜 가끔 파싱이 실패하지"의 상당수가 여기다.
TCP 쪽은 다른 곳에서 딱 한 줄이 중요하다.
/* 요청은 짧은 한 줄이고 답은 바로 필요하다. Nagle이 세그먼트를 채우려고
* 붙들어 두면 얻는 것 없이 지연만 는다. */
int one = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
그리고 양쪽이 똑같이 하는 일이 하나 있다. 보드는 사람이 읽는 로그를 프로토콜과 같은 선에 흘린다.
/* 보드는 JSON-RPC 응답과 같은 줄에 사람이 읽는 로그를 찍는다. 그건
* 프로토콜이 아니고 클라이언트 파서를 화나게 하므로, JSON처럼 생긴
* 줄만 넘긴다. 나머지는 stderr 로 — 눈에는 보이되 응답으로 오해되지
* 않는 곳으로. */
if (up[0] == '{') {
printf("%s\n", up);
fflush(stdout);
} else {
fprintf(stderr, "[board] %s\n", up);
}
실제로 ESP32의 프로비저닝 콘솔은 지금도 같은 UART에 W (45475251) console_prov: ... 같은 줄을 찍는다. 이 세 줄이 없으면 클라이언트가 그 줄을 응답으로 받아 죽는다.
② 붙은 다음은 같다
브리지 둘의 차이는 위가 전부다. 그 위 클라이언트는 이렇게 생겼다.
final result = await McpClient.createAndConnect(
config: McpClient.simpleConfig(name: 'Board Probe', version: '1.0.0'),
transportConfig: TransportConfig.stdio(
command: link.command, // serial_bridge 또는 tcp_bridge
arguments: link.arguments, // [포트, 보드레이트] 또는 [호스트, 포트]
),
);
전송을 고르는 일이 어느 프로그램을 띄울지 고르는 일이 된다. 그 아래로는 코드가 갈라지지 않는다.
③ 보드마다 화면의 모양이 다르다
여기서 실제로 처리가 필요했다. 두 보드가 ui://app 에 서로 다른 것을 담아 준다.
STM32는 페이지 하나를 그대로 준다(656 B).
{"type":"page","title":"WeAct H723 MCP Node","content":{ ... }}
ESP32는 애플리케이션을 준다(158 B). 화면이 아니라 화면 지도다.
{"type":"application","title":"ESP32 MCP Node",
"routes":{"/":"ui://page/main"},"initialRoute":"/",
"lifecycle":{"onReady":[{"type":"tool","tool":"sys.info"}]}}
그래서 클라이언트가 한 갈래를 갖는다.
// 보드마다 모양이 다르다. 하나는 페이지를 그대로 주고, 다른 하나는
// 라우트를 가진 애플리케이션을 주며 첫 화면은 그 뒤에 있다.
Map<String, dynamic> screen = def;
if (def['type'] == 'application') {
final routes = (def['routes'] as Map).cast<String, dynamic>();
final initial = def['initialRoute'] as String? ?? '/';
final uri = routes[initial] as String;
final page = await client.readResource(uri);
screen = jsonDecode(page.contents.first.text!) as Map<String, dynamic>;
}
ESP32의 그 페이지는 1894 B이고, 자기 생명주기에 구독을 걸어 둔다.
"lifecycle":{
"onReady":[{"type":"resource","action":"subscribe","uri":"sensor://uptime","binding":"uptime"}],
"onDestroy":[{"type":"resource","action":"unsubscribe","uri":"sensor://uptime"}]
}
화면이 열리면 센서를 구독하고, 닫히면 구독을 푼다. 그 선언이 보드 안에 들어 있다.
④ 주소를 사람이 적지 않는다
ESP32에 붙을 때 IP를 손으로 넣지 않았다. 보드가 자기를 광고한다.
$ dns-sd -B _mcp._tcp
Timestamp A/R Flags if Domain Service Type Instance Name
14:04:49.297 Add 2 15 local. _mcp._tcp. ESP32 MCP Node
$ dns-sd -L "ESP32 MCP Node" _mcp._tcp
ESP32 MCP Node._mcp._tcp.local. can be reached at mcp-esp32.local.:6270
v=0.1.0 id=esp32.node proto=ndjson
TXT 레코드가 proto=ndjson 이라고 말한다 — 줄 단위 JSON-RPC. UART로 오던 것과 똑같은 바이트다. 그래서 소켓만 열리면 번역할 게 남지 않는다.
검증 스크립트가 이걸 그대로 쓴다. 하드코딩된 주소가 없다.
RESOLVED=$(timeout 6 dns-sd -B _mcp._tcp 2>/dev/null | awk 'NR>4 {...}')
DETAIL=$(timeout 6 dns-sd -L "$RESOLVED" _mcp._tcp 2>/dev/null | grep "can be reached at")
HOSTPORT=$(echo "$DETAIL" | sed -n 's/.*can be reached at \([^ ]*\).*/\1/p' | sed 's/\.$//;s/\.:/:/')
⑤ 실행·검증 로그
한 번의 실행에서 두 링크를 연달아 돌린 원문이다.
[+ 2ms] links to probe: serial, tcp
[+ 33ms] [serial] connected via ../serial_bridge/serial_bridge /dev/cu.usbmodem365D395E33331 115200
[+ 53ms] [serial] tools: led.set, sys.info
[+ 65ms] [serial] resources: ui://app, ui://app/info, bundle://manifest.json
[+ 79ms] [serial] ui://app — 656 B, type "page", title "WeAct H723 MCP Node"
[+ 426ms] [serial] led.set({"on":true}) -> "LED on" (1 ms)
[+ 438ms] [serial] sys.info({}) -> "LED=on uptime=185582085ms" (11 ms)
[+ 450ms] [serial] led.set({"on":false}) -> "LED off" (10 ms)
[+ 462ms] [serial] sys.info({}) -> "LED=off uptime=185582110ms" (11 ms)
[+ 463ms] [serial] uptime advanced 185582085 -> 185582110 ms
[+ 5980ms] [tcp] connected via ../tcp_bridge/tcp_bridge mcp-esp32.local 6270
[+ 6044ms] [tcp] tools: led.set, sys.info
[+ 6110ms] [tcp] resources: ui://app, ui://page/main, ui://app/info, bundle://manifest.json, sensor://uptime
[+ 6169ms] [tcp] ui://app — 158 B, type "application", title "ESP32 MCP Node"
[+ 6169ms] [tcp] application — initialRoute "/" -> ui://page/main
[+ 6476ms] [tcp] ui://page/main — 1894 B
[+ 6512ms] [tcp] sensor://uptime read once -> {"uptime_s":47095} (bound as "uptime", not streamed)
[+ 6622ms] [tcp] led.set({"on":true}) -> "LED on" (24 ms)
[+ 6692ms] [tcp] sys.info({}) -> "LED=on uptime=47095369ms" (69 ms)
[+ 6714ms] [tcp] led.set({"on":false}) -> "LED off" (21 ms)
[+ 6757ms] [tcp] sys.info({}) -> "LED=off uptime=47095451ms" (43 ms)
[+ 6758ms] [tcp] uptime advanced 47095369 -> 47095451 ms
[+ 6759ms] done — 2 link(s) probed
빌드·통과는 이렇다.
$ cc -O2 -o serial_bridge serial_bridge.c
$ cc -O2 -o tcp_bridge tcp_bridge.c
$ flutter analyze
No issues found!
$ bash verify.sh
/dev/cu.usbmodem365D395E33331 -> WeAct H723 MCP Node
discovered "ESP32 MCP Node" at mcp-esp32.local:6270
2 link(s) probed · screens rendered from the boards' own definitions · LED round-tripped on each
실측치
| UART (STM32H723) | Wi-Fi TCP (ESP32) | |
|---|---|---|
led.set 왕복 | 1 · 10 ms | 24 · 21 ms |
sys.info 왕복 | 11 · 11 ms | 69 · 43 ms |
| 연결부터 화면 수신까지 | 46 ms | 189 ms |
ui://app 크기 | 656 B (page) | 158 B (application) |
| 첫 화면 | — | 1,894 B (ui://page/main) |
크기와 시간은 성질이 다르다. 크기는 파일이라 몇 번을 재도 같지만, 시간은 실행마다 달라진다. 위 표는 이 글과 함께 실린 run.log 한 번의 실행에서 뽑은 값이고, 다른 실행에서는 Wi-Fi 쪽이 이보다 두 배 넘게 나온 적도 있다. 그러니 여기서 읽을 것은 정확한 숫자가 아니라 자릿수의 차이다 — UART는 한 자릿수에서 십 몇 밀리초, Wi-Fi는 수십 밀리초. UI를 설계할 때 "누르면 바로"를 기대해도 되는 쪽과 응답 대기를 화면에 그려야 하는 쪽이 그 선에서 갈린다.
LED가 실제로 켜지고 꺼진다. 그리고 상태를 지역 변수가 아니라 보드에게 되물어 확인했다. 검증 스크립트가 양방향을 다 요구한다 — 한쪽만 통과하는 건 그냥 메아리일 수 있어서다. 거기에 uptime이 두 번 읽는 사이에 전진했는지까지 본다. 고정 응답이라면 못 넘는 검사다.
재지 못한 것
- ESP32 화면의
Live uptime은 스트리밍이 아니라 한 번 읽은 값이다. 보드의 실제 uptime(45,745 s)이지만, 이 하니스는 생명주기 액션을 구동하지 않아 구독이 걸리지 않았다. 로그에도read once … (bound as "uptime", not streamed)로 남겼다. 구독 스트림의 실동작은 이 편에서 재지 않았다. - Wi-Fi 지연은 한 번의 실행, 한 대의 공유기, 한 방 안에서 잰 값이다. 표본이 적고 환경이 하나다. 실제로 다시 돌릴 때마다 값이 흔들렸고, 특히 mDNS 조회가 포함된 실행에서는 연결까지 몇 초가 걸리기도 했다 — 시간 값은 재현되지 않는다는 것 자체가 관측이다.
- BLE·HTTP·USB CDC 는 이 편에서 안 붙였다. 보드는 지원하지만 이번 실행에 넣지 않았다.
- 두 보드의 펌웨어는 내가 쓰지 않았다. 이 글은 붙는 쪽을 만들었다.