AI 에이전트에게 블루투스 기능을 부여하기: Kotlin/Native 기반 MCP 서버 구축기
요약
본 글은 AI 에이전트에게 실제 블루투스 하드웨어와 상호작용할 수 있는 능력을 부여하는 Kotlin/Native 기반의 MCP 서버 구축 과정을 다룹니다. BLE 장치 스캔, 연결, 특성 쓰기 등 복잡한 기능을 자연어 도구 호출을 통해 구현했습니다. 특히, 비동기적이고 반응형인 블루투스 라이브러리 상태를 LLM이 요구하는 단일 요청/단일 응답의 무상태(stateless) 도구 호출 흐름에 맞추는 것이 핵심 과제였으며, 장기간 연결 상태 관리가 중요함을 강조합니다.
핵심 포인트
- Kotlin/Native 기반 MCP 서버로 에이전트에게 블루투스 기능 부여 가능
- 비동기 스트림을 단일 답변으로 폴드(fold)하여 LLM 호출에 적합하게 만듦
- BLE와 같은 상태적 리소스는 서버가 생명주기를 소유하고 일관성을 유지해야 함
- 장기간 연결 도구 구현 시, 클라이언트 객체 재설정 및 옵저버 취소 로직이 필수
거의 모든 Model Context Protocol (MCP) 서버는 웹 API(GitHub, 데이터베이스, SaaS 도구 등)를 감싸고 있습니다. 이들은 언어 모델에 새로운 '정보'를 제공합니다. 저는 여기에 새로운 '감각과 손', 즉 실제 블루투스 하드웨어에 접근하고 상호작용할 수 있는 능력을 부여하고 싶었습니다. 그래서 에이전트가 BLE 장치를 스캔하고, 연결하며, 특성(characteristics)을 쓰고, 전체 암호화된 동기화 핸드셰이크를 실행할 수 있도록 Kotlin/Native 기반의 MCP 서버를 구축했습니다. 이 모든 것이 자연어 도구 호출을 통해 이루어집니다.
이 게시물에서는 작동 방식, 가장 까다로운 부분(상태 비저장(stateless) 도구 호출 전반에 걸친 장치 상태 관리), 그리고 왜 'MCP + 네이티브'가 아직 충분히 탐구되지 않은 방향이라고 생각하는지에 대해 설명합니다.
에이전트가 실제로 할 수 있는 것들
서버는 열 가지 도구를 노출합니다. Claude에게
LLM 에이전트 ⇄ MCP (JSON-RPC/stdio) ⇄ McpServer ⇄ bluetooth-lib (KMP) ⇄ CoreBluetooth
│
코루틴 옵저버
...
흥미로운 지점은 중간 부분입니다. bluetooth-lib는 반응형(reactive)이며 비동기적(asynchronous)입니다. 스캔 결과, 연결 상태, 그리고 수신 패킷 모두 Kotlin Flow로 도착하며, 작업들은 suspend 함수입니다. 하지만 도구 호출은 단일 요청이며 단일 결과를 반환해야 합니다. 따라서 각 도구는 비동기 스트림을 하나의 답변으로 _폴드(fold)_합니다. ble_scan이 가장 명확한 예시입니다:
client.startScanning()
val devices = withTimeoutOrNull(5_000) {
client.scannedDevices.filter { it.isNotEmpty() }.first()
...
시작하고, 첫 번째 유용한 방출(emission)을 기다리거나 (또는 시간 초과가 발생하거나), 중지하고, 직렬화합니다. 코루틴은 이 과정을 거의 동기적으로 읽히게 만들며, 이는 LLM이 호출자일 때 정확히 원하는 바입니다.
어려운 부분: 상태를 가진 무상태(stateless) 호출들 사이의 상태 유지
MCP 도구 호출들은 개별적으로는 무상태이지만, BLE 연결은 매우 상태적(stateful)입니다. 살아있는 CBCentralManager, 연결된 주변 기기(peripheral), 발견된 서비스, 그리고 그 호출들 뒤에 자리 잡고 있는 실행 중인 동기화 세션이 존재합니다. 이 상태를 여러 호출에 걸쳐 유지하는 것이 까다로웠습니다.
이것을 깨닫게 해준 버그가 있습니다: ble_disconnect 후, 기본 클라이언트 객체는 사실상 죽은(dead) 상태였지만, 제 옵저버 코루틴들은 여전히 그것에 바인딩되어 있었습니다. 이후의 모든 ble_sync_* 도구 호출은 유령(corpse)을 대상으로 조용히 작동했습니다—오류가 발생하지 않았고, 그저 아무 일도 일어나지 않을 뿐이었습니다. 해결책은 클라이언트를 교체 가능한 리소스로 취급하고 라이프사이클이 재설정될 때 모든 것을 다시 연결하는 것이었습니다:
private fun rebuildClient() {
observerJobs.forEach { it.cancel() } // 이전 옵저버 분리(detach)
client.close()
...
이 교훈은 장기간 연결(데이터베이스 세션, 브라우저, 소켓 등)을 소유하는 모든 에이전트 도구에 일반화됩니다. 모델은 여러분이 계획하지 않은 순서로 도구를 호출할 것이므로, _서버_가 생명 주기(lifecycle)를 소유하고 내부적으로 일관성을 유지해야 합니다. 자신이 캐시한 핸들(handle)을 언제 신뢰해서는 안 되는지 아는 것이 어떤 단일 도구보다 더 중요합니다.
에이전트가 하드웨어에 안전하게 접근하도록 허용하기
LLM에게 물리적 장치에 쓰기 액세스 권한을 부여하는 것은 보호 장치가 필요합니다. 이 과정에서 중요했던 두 가지는 다음과 같습니다:
- 표면(Surface) 범위를 제한합니다.
ble_update_gatt_config는 어떤 서비스와 특성(characteristic)이 사용되는지 정확하게 정의하며,ble_write는 임의의 핸들(handle)이 아닌 연결된 장치의 쓰기 가능한 특성을 대상으로 합니다. 에이전트는 GATT 테이블을 마음대로 돌아다닐 수 없습니다. - 서버가 주변 장치가 아닌 중앙 허브입니다. 서버는 알려진 장치로 아웃바운드(outbound) 연결을 수행하며, 광고하거나 인바운드(inbound) 연결을 수락하지 않습니다 (이러한 인터페이스들은 의도적으로 스텁 처리되었습니다). 공격 표면은 작고 아웃바운드에만 집중됩니다.
저는 여전히 제가 소유한 장치만을 대상으로 삼겠지만 — 세상에서 행동할 수 있는 모든 도구에도 동일하게 적용되는 원칙입니다.
'MCP + 네이티브'를 탐색할 가치가 있는 이유
에이전트 생태계는 모든 웹 API를 감싸기 위해 경쟁하고 있습니다. 훨씬 더 적은 사람이 MCP 서버가 네이티브 및 크로스 플랫폼 기능 — 하드웨어, 센서, 온디바이스 데이터 엔진, 플랫폼별 API — 을 노출할 때 무슨 일이 발생하는지에 대해 질문합니다. Kotlin Multiplatform은 훌륭한 조합입니다: 하나의 반응형 코어(reactive core)와 실제 네이티브 바인딩(여기서는 CoreBluetooth, 다른 곳에서는 Android BLE), 그리고 동일한 코드가 서버 및 앱을 지원할 수 있습니다.
또한 이는 구성(compose)도 가능합니다. 저는 스키마 기반 설정 UI를 렌더링하는 컴패니언 Compose Multiplatform 프로젝트를 가지고 있는데, 이것이 바로 이와 같은 MCP 서버들을 Android, iOS, 데스크톱 전반에 걸쳐 구성하기 위한 자연스러운 프론트 엔드입니다. 물리적 세계에서 행동하는 서버와 이를 관리하기 위한 크로스 플랫폼 UI: 이것이 제가 구축하려는 방향입니다.
만약 에이전트와 장치가 교차하는 지점에서 어떤 작업을 하고 계시다면, 기꺼이 의견을 나누고 싶습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기