DEV Community

Cover image for Giving an AI Agent Hands on Bluetooth: an MCP Server in Kotlin/Native
The AX code
The AX code

Posted on

Giving an AI Agent Hands on Bluetooth: an MCP Server in Kotlin/Native

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)) }
    }
}
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_config defines exactly which services and characteristics are in play; ble_write targets 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)

Collapse
 
mnemehq profile image
Theo Valmis

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.