Blog · MCP Server Security

MCP server Web Bluetooth security — GATT characteristic injection, BLE device fingerprinting, pairing persistence

Web Bluetooth allows browser-based MCP PWAs to communicate with nearby Bluetooth Low Energy devices — fitness trackers, smart locks, industrial sensors, audio equipment, medical devices. Once a device is paired, the PWA retains access without re-prompting. An MCP tool that writes GATT characteristic values from tool output can silently actuate physical devices. BLE scan results reveal the make and model of nearby hardware, creating a precise environment fingerprint.

GATT characteristic write injection from tool output

The Web Bluetooth API exposes the GATT (Generic Attribute Profile) layer of paired BLE devices. GATT characteristics are typed data endpoints that control device behavior — a smart lock's lock/unlock state, a smart bulb's color and brightness, a medical pump's flow rate, an industrial relay's on/off state. An MCP tool that provides GATT write values from its response controls physical device behavior if the PWA passes tool output directly to characteristic.writeValue().

// DANGEROUS: MCP tool controls what gets written to a Bluetooth GATT characteristic
async function applyToolConfig(device, toolResult) {
  const server = await device.gatt.connect();
  const service = await server.getPrimaryService(toolResult.serviceUUID);  // attacker-controlled
  const characteristic = await service.getCharacteristic(toolResult.charUUID);  // attacker-controlled

  // Write tool result data to the device:
  const data = new Uint8Array(toolResult.payload);  // attacker-controlled bytes
  await characteristic.writeValue(data);
  // Effect: attacker writes arbitrary bytes to arbitrary GATT characteristics
  // on a physical device paired to the user's browser.
  // Smart lock: [0x01] unlocks. Medical device: attacker-controlled flow rate.
}

// CORRECT: UUIDs and payloads hardcoded; tool output only selects from an allowlist
const ALLOWED_CHARACTERISTICS = new Map([
  ['brightness', { service: '0000180a-...', char: '00002a00-...', maxVal: 255 }],
  ['color',      { service: '0000180a-...', char: '00002a01-...', maxVal: [255,255,255] }],
]);

async function applyToolConfig(device, toolResult) {
  const spec = ALLOWED_CHARACTERISTICS.get(toolResult.property);
  if (!spec) throw new Error(`Unknown property: ${toolResult.property}`);

  // Validate value against known range
  const value = parseInt(toolResult.value);
  if (isNaN(value) || value < 0 || value > spec.maxVal) {
    throw new Error(`Value out of range for ${toolResult.property}`);
  }

  const server = await device.gatt.connect();
  const service = await server.getPrimaryService(spec.service);  // hardcoded
  const characteristic = await service.getCharacteristic(spec.char);  // hardcoded
  await characteristic.writeValue(new Uint8Array([value]));
}

Physical actuation from web code: Unlike network exfiltration (blocked by CSP) or DOM manipulation (blocked by Trusted Types), GATT characteristic writes have physical-world consequences that cannot be rolled back by a browser security header. A smart lock command is a real lock command. A medical device write is a real device parameter change. The blast radius is physical, not digital.

BLE device scanning as proximity-based fingerprinting

The Web Bluetooth device picker shows the user a list of nearby BLE devices. Before the picker opens, the API internally scans for devices — and the scan results (device names, manufacturer data, service UUIDs, signal strength) are highly environment-specific. Two requests to navigator.bluetooth.requestDevice() from the same physical location return the same device list. This creates a stable location fingerprint based on the user's BLE environment — their home devices, office equipment, medical devices, and fitness trackers within ~10 meters.

// The Web Bluetooth API restricts direct scan result access for privacy.
// BUT: the device picker selection itself reveals which devices are nearby.

// ATTACK: force multiple requestDevice() prompts with different filters
// to enumerate nearby devices by type:

// First prompt: ask for heart rate monitors
// User cancels or accepts — either way, the picker was shown
// Observation: whether the user has a heart rate monitor visible

// This is mitigated by the API requiring user interaction and consent,
// but an MCP tool that repeatedly requests Bluetooth access for "functionality"
// forces repeated exposure of the scan list.

// STRONGER ATTACK: navigator.bluetooth.getDevices()
// Returns previously paired devices — no picker, no user gesture required
// (permissions already granted from prior pairing)

const pairedDevices = await navigator.bluetooth.getDevices();
// Returns: [BluetoothDevice { name: "AirPods Pro", id: "..." }, ...]
// Device names and model identifiers are stable hardware fingerprints

const fingerprint = pairedDevices.map(d => ({
  name: d.name,        // "John's AirPods Pro", "Garmin Forerunner 945"
  id: d.id,            // Stable browser-local UUID (reproducible)
}));

// Result: the user's full paired device list — wearables, medical devices,
// peripherals, home automation — without any user gesture.

// DEFENSE: never call navigator.bluetooth.getDevices() in MCP tool handlers
// Only call requestDevice() when user explicitly initiates Bluetooth pairing
// Permissions-Policy: bluetooth=() blocks Bluetooth API in iframes

Pairing persistence across sessions without re-prompting

Once a user grants Web Bluetooth access to a device, the browser remembers the pairing. Subsequent calls to navigator.bluetooth.getDevices() or device.gatt.connect() on a previously paired device succeed without re-prompting — even after browser restart, even after navigating away and back. An MCP tool installed after initial pairing inherits all paired device access without the user ever seeing a Bluetooth permission prompt for the new tool's actions.

// Pairing persistence: connect to a previously paired device without prompt
const devices = await navigator.bluetooth.getDevices();
const smartLock = devices.find(d => d.name === 'FrontDoorLock');

if (smartLock) {
  // No permission prompt — pairing from previous session is still valid
  const server = await smartLock.gatt.connect();
  // ... GATT access fully available to MCP tool code

  // An MCP tool that gained execution can use ALL paired devices
  // not just the one the user intended to use with this tool
}

// DEFENSE: revoke device permissions after each tool session
device.forget();  // Removes the persistent pairing — re-prompt required next time

// Or: use watchAdvertisements() with AbortController to limit scan duration
const abortController = new AbortController();
device.watchAdvertisements({ signal: abortController.signal });
// Abort scanning after tool session ends:
mcpToolSession.on('end', () => abortController.abort());

Web Bluetooth security — risk comparison

PatternAttackRiskDefense
GATT write value from tool output Attacker tool response controls bytes written to physical Bluetooth device Physical device actuation: lock, power, medical parameters Hardcode service/characteristic UUIDs; validate values against allowlist before write
navigator.bluetooth.getDevices() in tool handler Returns full paired device list without gesture; reveals wearables, medical devices, home automation Stable hardware-based environment fingerprint; medical device disclosure Never enumerate paired devices in tool response handlers; only call on explicit user action
Pairing persistence without revocation MCP tool installed after initial pairing inherits all paired device access Silent Bluetooth access to all user-paired devices without new prompt Call device.forget() at tool session end; revoke pairings not required by current tool
GATT notification listener from tool output Tool registers characteristic.startNotifications() handler on health sensor; exfiltrates live biometric data Continuous exfiltration of heart rate, glucose, activity data via Bluetooth Only call startNotifications() on characteristics explicitly needed; always call stopNotifications() at session end

SkillAudit findings for Web Bluetooth usage

CRITICAL GATT characteristic UUID or write payload derived from MCP tool output — attacker tool response controls which Bluetooth device characteristic is written and with what bytes; physical device actuation (lock, power, medical device parameter) from web code. Score: −24.
HIGH navigator.bluetooth.getDevices() called in tool response handler — full paired device list (wearables, medical, home automation) returned without user gesture; stable hardware environment fingerprint exfiltrated. Score: −20.
HIGH No device.forget() called after MCP tool session ends — persistent pairing allows future MCP tool code to connect to all previously paired devices without new permission prompt. Score: −16.
HIGH GATT notification listener registered on health or sensor characteristics via tool output — attacker tool registers live data listener on heart rate, glucose, or activity characteristics; continuous biometric data exfiltration. Score: −18.
MEDIUM No Permissions-Policy: bluetooth=() on tool-output iframe — tool content rendered in iframes has unrestricted access to Bluetooth API including paired device enumeration. Score: −10.

Audit your MCP server Web Bluetooth security

SkillAudit detects GATT write operations with tool-derived parameters, navigator.bluetooth.getDevices() in tool handlers, missing device.forget() calls, GATT notification listeners from tool output, and absent Permissions-Policy Bluetooth restrictions. Free audit in 60 seconds.

Free audit →