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 does |
|---|---|
ble_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)) }
}
}
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")
})
}
The architecture
LLM agent ⇄ MCP (JSON-RPC/stdio) ⇄ McpServer ⇄ bluetooth-lib (KMP) ⇄ CoreBluetooth
│
coroutine observers
(Flows: scan results, connection state, inbound packets)
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()
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
}
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.
Top comments (1)
Kotlin/Native for an MCP server is a nice fit because you end up with a single binary that doesn't drag in a JVM — important when you're embedding the agent's tooling near low-level peripherals where startup time and resource budget actually matter. Most MCP examples are Python or Node and ignore the binary-size question entirely.
The more interesting design surface here is the scope of what you expose to the agent. Bluetooth as a category covers everything from 'read the battery level' to 'pair with arbitrary devices' — a long way apart on the blast-radius axis. The tools you choose to surface, and the constraints you wrap them in, end up mattering more than the model itself once the system is in the hands of a real user.