mcp-ble-bridge
by kumavulp
README.md
# MCP → BLE Bridge
Control Bluetooth Low Energy (BLE) toys from AI assistants over the cloud.
This is a working reference implementation that bridges the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) to BLE devices via Web Bluetooth, letting an AI chatbot send commands to a physical device in real time.
**Tested with:** SVAKOM "Bera" (ST462A) / "口口甜" — but the architecture works for any BLE device you can write bytes to.
## Architecture
```
AI Assistant (Claude, AstrBot, etc.)
│ MCP (Streamable HTTP or SSE)
▼
Cloud Server (Node.js, HTTPS)
│ WebSocket
▼
Phone Browser (Bluefy on iOS / Chrome on Android)
│ Web Bluetooth API
▼
BLE Device
```
**Why this stack?**
- iOS has no native Web Bluetooth in Safari. [Bluefy](https://apps.apple.com/app/bluefy-web-ble-browser/id1492822055) is a third-party browser that supports it.
- Claude mobile / claude.ai has no local MCP support — you need a cloud relay.
- Most BLE toys use proprietary protocols. buttplug.io covers some, but many (like SVAKOM's newer models) need manual reverse engineering.
- This approach needs **zero native apps** on the phone beyond a BLE-capable browser. No Intiface, no custom iOS app, no jailbreak.
## Prerequisites
- A VPS with a public IP (any cloud provider, 1 CPU / 512MB RAM is enough)
- A domain pointing to your VPS (or use [DuckDNS](https://www.duckdns.org/) for free)
- HTTPS certificate (Let's Encrypt via [acme.sh](https://github.com/acmesh-official/acme.sh))
- Node.js 18+ and pm2
- iOS: [Bluefy browser](https://apps.apple.com/app/bluefy-web-ble-browser/id1492822055) ($2.99) / Android: Chrome (free, Web Bluetooth built-in)
## Quick Start
### 1. Get a domain and certificate
```bash
# Install acme.sh if you haven't
curl https://get.acme.sh | sh
# Example with DuckDNS (replace with your token and subdomain)
export DuckDNS_Token="your-duckdns-token"
acme.sh --issue --dns dns_duckdns -d yourdomain.duckdns.org --keylength ec-256
# Note where certs are stored, e.g.:
# ~/.acme.sh/yourdomain.duckdns.org_ecc/
```
### 2. Clone and configure
```bash
git clone https://github.com/kumavulp/mcp-ble-bridge.git
cd mcp-ble-bridge
npm install
```
Edit `config.js`:
```javascript
module.exports = {
domain: 'yourdomain.duckdns.org',
port: 8443,
certDir: '/home/youruser/.acme.sh/yourdomain.duckdns.org_ecc',
keyFile: 'yourdomain.duckdns.org.key',
certFile: 'fullchain.cer'
};
```
### 3. Open firewall
```bash
sudo ufw allow 8443/tcp
# Also open it in your cloud provider's security group if applicable
```
### 4. Start server
```bash
# With pm2 (recommended)
npm install -g pm2
pm2 start server.js --name mcp-ble-bridge
pm2 save
# Or directly
node server.js
```
### 5. Connect from phone
Open Bluefy (iOS) or Chrome (Android):
```
https://yourdomain.duckdns.org:8443/bridge
```
Tap **Connect Server**, then **Connect Device** → pick your BLE toy from the popup.
### 6. Connect your AI assistant
**AstrBot (recommended — works now):**
```json
{
"transport": "streamable_http",
"url": "https://yourdomain.duckdns.org:8443/mcp",
"headers": {},
"timeout": 120,
"sse_read_timeout": 300
}
```
**claude.ai Custom Connector:**
The server includes a full OAuth stub at `/.well-known/oauth-*`, `/register`, `/authorize`, `/token`. Add as a custom connector with URL `https://yourdomain.duckdns.org:8443/sse`.
> ⚠️ **Known Issue (as of Sep 2026):** claude.ai custom connectors have a bug where OAuth completes successfully but no SSE connection is ever established afterward. This has been reported since March 2026 (GitHub issues #600+) and remains unresolved. The server code is ready — it will work the day Anthropic fixes it.
**Claude Desktop (local):**
If your computer has Bluetooth and the BLE device is in range, you can skip the cloud relay entirely — add the MCP server to Claude Desktop's config pointing to localhost.
## Server Endpoints
| Path | Method | Purpose |
|------|--------|---------|
| `/mcp` | POST | Streamable HTTP MCP endpoint |
| `/sse` | GET | SSE MCP transport |
| `/message` | POST | SSE message endpoint |
| `/ws` | WS | WebSocket relay to phone bridge |
| `/bridge` | GET | Serves bridge.html |
| `/health` | GET | Health check JSON |
| `/.well-known/oauth-*` | GET | OAuth discovery (for claude.ai) |
| `/register` | POST | OAuth client registration |
| `/authorize` | GET | OAuth authorization |
| `/token` | POST | OAuth token exchange |
## MCP Tools
The server exposes these tools to the AI:
| Tool | Parameters | Description |
|------|-----------|-------------|
| `lick` | `mode` (1-10), `intensity` (0-1), `duration`, `reason` | Tongue licking patterns |
| `vibrate` | `mode` (1-10), `intensity` (0-1), `duration`, `reason` | Vibration patterns |
| `suck` | `intensity` (0-1), `duration`, `reason` | Suction |
| `pulse` | `low`, `high`, `interval_ms`, `duration`, `reason` | Alternating pulse |
| `stop` | — | Stop everything |
| `status` | — | Connection status |
| `check` | — | Check beg count (see below) |
**`duration`**: seconds before auto-stop. The controlling AI decides how long, not the user.
**`reason`**: why the AI is doing this — logged server-side, visible in the bridge UI.
**`check`**: the bridge page has a "beg" button. Pressing it doesn't stop the device — it sends a signal back to the server that the AI can query. The AI decides whether to show mercy.
## SVAKOM BLE Protocol (Bera / ST462A / 口口甜)
Reverse-engineered protocol for SVAKOM's newer Bera-series devices.
**BLE Service:** `0000ffe0-0000-1000-8000-00805f9b34fb`
**Characteristic:** `0000ffe1-0000-1000-8000-00805f9b34fb`
⚠️ **Never write to the `AE00` characteristic — it can brick the device.**
### Command format (7 bytes)
```
55 [cmd] 00 00 [mode] [intensity] [suffix]
```
| Function | cmd byte | mode | intensity | suffix | Example |
|----------|----------|------|-----------|--------|---------|
| Vibrate | `03` | 1-10 (pattern) | 1-5 | `00` | `55 03 00 00 03 05 00` (pattern 3, max) |
| Tongue lick | `14` | 1-10 (pattern) | 1-5 | `00` | `55 14 00 00 01 05 00` (pattern 1, max) |
| Suction | `04` | — | 0-255 | `AA` | `55 04 00 00 01 80 AA` (medium) |
### Important notes
- **Intensity 1-5 is the device's native range.** The bridge maps 0.0-1.0 from the AI to 1-5 for vibrate/lick, and 0-255 for suction.
- **Tongue lick below intensity 4 (0.8 in 0-1 scale) is very slow.** Intensity 5 (1.0) matches the normal speed in the official app.
- **Keepalive required:** the device auto-stops after ~2 seconds of silence. The bridge re-sends the last command every 1.5 seconds.
- **Modes 1-10** are different rhythm patterns. Most are unexplored — experiment.
- Suction combines tongue + vibration + rhythmic sucking. It's its own thing, not "stronger vibration."
### How we figured this out
1. Started with buttplug.io's generic SVAKOM 6-byte format (`55 03 00 XX XX XX`) — didn't work on Bera.
2. Tried 7-byte format `55 04 00 00 01 [0-255] AA` — vibration responded.
3. Found a Chinese reverse-engineering post (小红书) confirming the full protocol for Bera-series:
- `03` = vibrate, `14` = tongue lick, `04` = suction
- Mode byte and intensity byte positions differ from older SVAKOM models
4. Verified all three functions work through the bridge.
If you're working with a different SVAKOM model, the `raw` tool (in older versions of the server) lets you send arbitrary hex bytes for protocol probing.
## Bridge Page Features
The bridge page (`bridge.html`) runs in the phone browser and:
- Connects to the cloud server via WebSocket
- Connects to the BLE device via Web Bluetooth
- Translates MCP tool calls into BLE writes
- Sends keepalive packets every 1.5s
- Has a **"beg" button** — doesn't stop the device, just signals back to the AI
- Has a **long-press emergency stop** (3 seconds) — actually stops everything, overrides all commands
- Auto-stops after `duration` seconds when specified
- Requests a wake lock to prevent the screen from turning off
## Adapting for Other Devices
To use this with a different BLE toy:
1. **Find the BLE service/characteristic UUIDs** — use a BLE scanner app (nRF Connect, LightBlue)
2. **Figure out the command protocol** — check buttplug.io's [device config](https://github.com/buttplugio/buttplug/tree/master/buttplug/buttplug-device-config), search for teardowns, or probe with raw hex bytes
3. **Edit `bridge.html`** — change `SVC`, `CHR`, and the `tx()` command builders
4. **Edit `server.js`** — update tool names and descriptions to match your device's capabilities
The server ↔ bridge WebSocket protocol is device-agnostic. The server sends `{requestId, tool, args}`, the bridge sends back `{requestId, result, error}`. All device-specific logic lives in bridge.html.
## Troubleshooting
**AstrBot says "Failed to test MCP connection"**
→ Check `curl -sk https://localhost:8443/health` returns valid JSON with `"tools":7`. If the schema is malformed, AstrBot can't parse it.
**BLE scan shows nothing in Bluefy**
→ Make sure the device is powered on and not connected to another app. Only one thing can hold a BLE connection at a time.
**Device stops after 2 seconds**
→ Keepalive isn't running. Check the bridge console log — it should show `TX` lines every 1.5 seconds while a command is active.
**claude.ai connector completes OAuth but never sends requests**
→ Known Anthropic bug. Use AstrBot or Claude Desktop instead.
**WebSocket connects but tool calls timeout**
→ Check if the bridge page is in the foreground. iOS suspends JS in background tabs. Bluefy must stay visible.
## Security Note
This server has **no authentication** beyond HTTPS. Anyone who knows the URL can send commands. For personal use on an obscure subdomain this is fine. For anything more, add auth middleware.
The OAuth endpoints are stubs that accept anything — they exist solely to satisfy claude.ai's connector flow.
## License
MIT. Do whatever you want with it.
## Credits
- SVAKOM BLE protocol info: community reverse engineering efforts
- [buttplug.io](https://buttplug.io/) for pioneering open-source toy control
- [Bluefy](https://nicpopp.com/bluefy/) for making Web Bluetooth possible on iOS
- [MCP](https://modelcontextprotocol.io/) for the protocol spec