Almost every Model Context Protocol (MCP) server you’ll find wraps a web API — GitHub, a database, a SaaS tool. They give a language model new information. I wanted to give one new senses and hands: the ability to reach out and touch real Bluetooth hardware. So I built an MCP server in Kotlin/Native that lets an agent scan for BLE devices, connect to them, write characteristics, and run a full encrypted sync handshake — all from natural-language tool calls.
This post is how it works, the one genuinely hard part (managing device state across stateless tool calls), and why I think “MCP + native” is an underexplored direction.
What the agent can actually do
The server exposes ten tools. Ask Claude to “scan for nearby devices and connect to the heart-rate monitor,” and it calls them in sequence:
Tool What it doesble_status
Bluetooth power state, connection state, scanned-device count
ble_scan
Scan for nearby devices (~5s)
ble_connect / ble_disconnect
Connect / disconnect by device ID
ble_write
Write hex data to a connected device’s writable characteristic
ble_update_gatt_config
Constrain which GATT services/characteristics the agent may touch
ble_test_sync
Automated connect → discover → test ping
ble_sync_handshake
Trigger a security handshake / key exchange
ble_sync_ping / ble_sync_command
Protocol-level ping / generic command
The last few aren’t generic BLE — they drive the same encrypted sync protocol my Kotlin Multiplatform Bluetooth library uses to keep a cluster of phones aligned to sub-millisecond accuracy for multi-camera capture. An agent can now exercise that protocol interactively.
MCP in one paragraph
MCP is just JSON-RPC 2.0 over stdio. The client sends initialize, then tools/list to discover what’s available, then tools/call to invoke one. The server answers on stdout. My whole transport loop is essentially:
fun start() = scope.launch {
while (true) {
val line = readLine() ?: break
val request = Json.decodeFromString<JsonObject>(line)
handleRequest(request)?.let { println(Json.encodeToString(it)) }
}
}
Enter fullscreen mode Exit fullscreen mode
tools/list returns each tool with a JSON inputSchema, so the model gets typed arguments for free. I wrapped that in a tiny DSL:
addTool("ble_connect", "Connect to a Bluetooth device by ID") {
put("deviceId", buildJsonObject {
put("type", "string")
put("description", "ID from ble_scan")
})
}
Enter fullscreen mode Exit fullscreen mode
The architecture
LLM agent ⇄ MCP (JSON-RPC/stdio) ⇄ McpServer ⇄ bluetooth-lib (KMP) ⇄ CoreBluetooth
│
coroutine observers
(Flows: scan results, connection state, inbound packets)
Enter fullscreen mode Exit fullscreen mode
The interesting seam is the middle. bluetooth-lib is reactive and asynchronous — scan results, connection state, and inbound packets all arrive as Kotlin Flows, and operations are suspend functions. But a tool call is a single request that must return a single result. So each tool folds an async stream into one answer. ble_scan is the clearest example:
client.startScanning()
val devices = withTimeoutOrNull(5_000) {
client.scannedDevices.filter { it.isNotEmpty() }.first()
} ?: client.scannedDevices.first()
client.stopScanning()
return devices.toToolResult()
Enter fullscreen mode Exit fullscreen mode
Start, await the first useful emission (or time out), stop, serialize. Coroutines make this read almost synchronously, which is exactly what you want when an LLM is the caller.
The hard part: state across stateless calls
MCP tool calls are individually stateless, but a BLE connection is very stateful — there’s a live CBCentralManager, a connected peripheral, discovered services, and a running sync session sitting behind those calls. Holding that across invocations is where it got tricky.
The bug that taught me this: after a ble_disconnect, the underlying client object was effectively dead, but my observer coroutines were still bound to it. Every subsequent ble_sync_* tool silently operated on a corpse — no error, just nothing happening. The fix was to treat the client as a replaceable resource and rewire everything when its lifecycle resets:
private fun rebuildClient() {
observerJobs.forEach { it.cancel() } // detach old observers
client.close()
client = BluetoothLibrary.createClient(...)
syncManager = buildSyncManager()
launchObservers() // re-bind to the live client
}
Enter fullscreen mode Exit fullscreen mode
The lesson generalizes to any agent tool that owns a long-lived connection (a database session, a browser, a socket): the model will call your tools in an order you didn’t plan for, so the server has to own lifecycle and stay internally consistent. Knowing when not to trust your own cached handle matters more than any single tool.
Letting an agent touch hardware, safely
Handing an LLM write access to physical devices deserves guardrails. Two that mattered:
-
Scope the surface.
ble_update_gatt_configdefines exactly which services and characteristics are in play;ble_writetargets the connected device’s writable characteristic, not arbitrary handles. The agent can’t wander the GATT table. - The server is a central, not a peripheral. It connects out to known devices; it doesn’t advertise or accept inbound connections (those interfaces are deliberately stubbed). The attack surface is small and outbound.
I’d still only point it at devices I own — but the same is true of any tool that can act on the world.
Why “MCP + native” is worth exploring
The agent ecosystem is racing to wrap every web API. Far fewer people are asking what happens when MCP servers expose native and cross-platform capabilities — hardware, sensors, on-device data engines, platform-specific APIs. Kotlin Multiplatform is a great fit: one reactive core, real native bindings (CoreBluetooth here, Android BLE elsewhere), and the same code can back a server and an app.
It also composes. I have a companion Compose Multiplatform project that renders schema-driven settings UIs — the natural front end for configuring a fleet of MCP servers like this one across Android, iOS, and desktop. Servers that act on the physical world, plus a cross-platform UI to manage them: that’s the direction I’m building toward.
If you’re doing anything at the intersection of agents and devices, I’d love to compare notes.
답글 남기기