Ryan Malloy 7f3b096c83 Add BlueZ Agent1 implementation for pairing
Implements the org.bluez.Agent1 D-Bus interface to handle Bluetooth
pairing operations with three modes:
- elicit: MCP elicitation for PIN/confirmation (if client supports)
- interactive: Returns pending status for bt_pair_confirm calls
- auto: Auto-accepts pairings (for trusted environments)

Changes:
- New agent.py with BlueZAgent ServiceInterface
- Updated bt_pair to use agent with configurable timeout
- Updated bt_pair_confirm to respond to pending agent requests
- Added bt_pairing_status tool to check pending requests
- Removed PEP 563 future import from monitor.py for FastMCP compat
2026-02-02 11:57:58 -07:00

389 lines
12 KiB
Python

"""BlueZ pairing agent implementation.
This module implements the org.bluez.Agent1 D-Bus interface for handling
Bluetooth pairing operations. The agent supports multiple pairing modes:
- elicit: Use MCP elicitation to request PIN/confirmation from user (preferred)
- interactive: Return pending status, wait for bt_pair_confirm tool call
- auto: Auto-accept all pairings (use only in trusted environments)
The agent is registered with BlueZ's AgentManager1 and handles callbacks
for PIN codes, passkeys, and confirmations during the pairing process.
"""
import asyncio
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any
from dbus_fast import BusType, DBusError
from dbus_fast.aio import MessageBus
from dbus_fast.service import ServiceInterface, method
# Agent constants
AGENT_PATH = "/mcbluetooth/agent"
AGENT_CAPABILITY = "KeyboardDisplay" # Can display and enter PINs
BLUEZ_SERVICE = "org.bluez"
AGENT_MANAGER_IFACE = "org.bluez.AgentManager1"
class PairingRequestType(Enum):
"""Types of pairing requests the agent can receive."""
PIN_CODE = "pin_code"
PASSKEY = "passkey"
CONFIRMATION = "confirmation"
AUTHORIZATION = "authorization"
SERVICE_AUTH = "service_authorization"
class PairingMode(Enum):
"""Pairing behavior modes."""
ELICIT = "elicit" # Use MCP elicitation
INTERACTIVE = "interactive" # Wait for bt_pair_confirm
AUTO = "auto" # Auto-accept everything
@dataclass
class PairingRequest:
"""A pending pairing request from BlueZ."""
request_type: PairingRequestType
device_path: str
device_address: str
passkey: int | None = None # For confirmation requests
uuid: str | None = None # For service authorization
timestamp: datetime = field(default_factory=datetime.now)
response_event: asyncio.Event = field(default_factory=asyncio.Event)
response_value: str | int | bool | None = None
response_error: str | None = None
class BlueZAgentError(DBusError):
"""Base class for agent D-Bus errors."""
pass
class Rejected(BlueZAgentError):
"""Pairing was rejected by user."""
def __init__(self):
super().__init__("org.bluez.Error.Rejected", "Pairing rejected")
class Canceled(BlueZAgentError):
"""Pairing was canceled."""
def __init__(self):
super().__init__("org.bluez.Error.Canceled", "Pairing canceled")
def path_to_address(device_path: str) -> str:
"""Extract Bluetooth address from D-Bus object path."""
# /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX -> XX:XX:XX:XX:XX:XX
parts = device_path.split("/")
if len(parts) >= 5 and parts[-1].startswith("dev_"):
return parts[-1][4:].replace("_", ":")
return device_path
class BlueZAgent(ServiceInterface):
"""D-Bus service implementing org.bluez.Agent1 interface.
This agent handles pairing requests from BlueZ and coordinates
with the MCP tools for user interaction.
"""
def __init__(self, mode: PairingMode = PairingMode.INTERACTIVE):
super().__init__("org.bluez.Agent1")
self.mode = mode
self.pending_requests: dict[str, PairingRequest] = {} # keyed by device_path
self._timeout = 60.0 # Seconds to wait for user response
def set_mode(self, mode: PairingMode) -> None:
"""Change the pairing mode."""
self.mode = mode
def get_pending_request(self, device_address: str) -> PairingRequest | None:
"""Get a pending request by device address."""
for req in self.pending_requests.values():
if req.device_address.upper() == device_address.upper():
return req
return None
def respond_to_request(
self,
device_address: str,
accept: bool,
pin_or_passkey: str | int | None = None,
) -> bool:
"""Respond to a pending pairing request.
Returns True if a request was found and responded to.
"""
request = self.get_pending_request(device_address)
if not request:
return False
if accept:
request.response_value = pin_or_passkey
request.response_error = None
else:
request.response_error = "rejected"
request.response_event.set()
return True
async def _wait_for_response(self, request: PairingRequest) -> Any:
"""Wait for user response to a pairing request."""
try:
await asyncio.wait_for(
request.response_event.wait(), timeout=self._timeout
)
except TimeoutError:
request.response_error = "timeout"
raise Canceled() from None
finally:
# Clean up
if request.device_path in self.pending_requests:
del self.pending_requests[request.device_path]
if request.response_error:
raise Rejected()
return request.response_value
def _create_request(
self,
request_type: PairingRequestType,
device_path: str,
passkey: int | None = None,
uuid: str | None = None,
) -> PairingRequest:
"""Create and store a new pairing request."""
address = path_to_address(device_path)
request = PairingRequest(
request_type=request_type,
device_path=device_path,
device_address=address,
passkey=passkey,
uuid=uuid,
)
self.pending_requests[device_path] = request
return request
# ==================== Agent1 Interface Methods ====================
@method()
def Release(self) -> None:
"""Called when the agent is unregistered."""
# Clear any pending requests
for req in self.pending_requests.values():
req.response_error = "released"
req.response_event.set()
self.pending_requests.clear()
@method()
async def RequestPinCode(self, device: "o") -> "s": # noqa: F821
"""Request PIN code for pairing.
Legacy pairing method - returns a string PIN (usually 4-6 digits).
"""
if self.mode == PairingMode.AUTO:
return "0000" # Default PIN for auto mode
request = self._create_request(PairingRequestType.PIN_CODE, device)
# For interactive/elicit, wait for response
response = await self._wait_for_response(request)
return str(response) if response else "0000"
@method()
def DisplayPinCode(self, device: "o", pincode: "s") -> None: # noqa: F821
"""Display PIN code to the user.
Called when the remote device generated the PIN.
"""
# Store as info - the MCP client can retrieve this
self._create_request(PairingRequestType.PIN_CODE, device)
self.pending_requests[device].response_value = pincode
# For display, we don't wait - just inform
# The MCP logging will show this
@method()
async def RequestPasskey(self, device: "o") -> "u": # noqa: F821
"""Request numeric passkey for pairing.
Returns a 6-digit numeric passkey (0-999999).
"""
if self.mode == PairingMode.AUTO:
return 0 # Accept any passkey in auto mode
request = self._create_request(PairingRequestType.PASSKEY, device)
response = await self._wait_for_response(request)
return int(response) if response else 0
@method()
def DisplayPasskey(self, device: "o", passkey: "u", entered: "q") -> None: # noqa: F821
"""Display passkey with progress indicator.
Called to show the passkey being entered on the remote device.
"""
# Store for display purposes
if device not in self.pending_requests:
self._create_request(PairingRequestType.PASSKEY, device, passkey=passkey)
self.pending_requests[device].passkey = passkey
# entered shows how many digits have been entered
@method()
async def RequestConfirmation(self, device: "o", passkey: "u") -> None: # noqa: F821
"""Request confirmation of a passkey.
User should confirm the displayed passkey matches the remote device.
Raise Rejected() to reject, return normally to accept.
"""
if self.mode == PairingMode.AUTO:
return # Auto-accept
request = self._create_request(
PairingRequestType.CONFIRMATION, device, passkey=passkey
)
await self._wait_for_response(request)
# If we get here without exception, confirmation accepted
@method()
async def RequestAuthorization(self, device: "o") -> None: # noqa: F821
"""Request authorization for pairing.
Called when the remote device wants to pair without PIN.
"""
if self.mode == PairingMode.AUTO:
return # Auto-accept
request = self._create_request(PairingRequestType.AUTHORIZATION, device)
await self._wait_for_response(request)
@method()
async def AuthorizeService(self, device: "o", uuid: "s") -> None: # noqa: F821
"""Authorize a service connection.
Called when a device wants to connect to a specific service.
"""
if self.mode == PairingMode.AUTO:
return # Auto-accept
request = self._create_request(
PairingRequestType.SERVICE_AUTH, device, uuid=uuid
)
await self._wait_for_response(request)
@method()
def Cancel(self) -> None:
"""Cancel any ongoing pairing operation."""
# Cancel all pending requests
for req in self.pending_requests.values():
req.response_error = "canceled"
req.response_event.set()
# Global agent instance and bus
_agent: BlueZAgent | None = None
_agent_bus: MessageBus | None = None
_agent_registered: bool = False
async def get_agent() -> BlueZAgent:
"""Get or create the global agent instance."""
global _agent, _agent_bus, _agent_registered
if _agent is None:
_agent = BlueZAgent(PairingMode.INTERACTIVE)
if _agent_bus is None:
_agent_bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
# Export the agent interface
_agent_bus.export(AGENT_PATH, _agent)
if not _agent_registered:
try:
# Get AgentManager1 interface
introspection = await _agent_bus.introspect(BLUEZ_SERVICE, "/org/bluez")
proxy = _agent_bus.get_proxy_object(
BLUEZ_SERVICE, "/org/bluez", introspection
)
agent_manager = proxy.get_interface(AGENT_MANAGER_IFACE)
# Register our agent
await agent_manager.call_register_agent(AGENT_PATH, AGENT_CAPABILITY)
await agent_manager.call_request_default_agent(AGENT_PATH)
_agent_registered = True
except Exception as e:
# Agent might already be registered or other error
# Log but don't fail - pairing might still work with default agent
print(f"Warning: Could not register agent: {e}")
return _agent
async def unregister_agent() -> None:
"""Unregister the agent from BlueZ."""
global _agent, _agent_bus, _agent_registered
if _agent_bus and _agent_registered:
try:
introspection = await _agent_bus.introspect(BLUEZ_SERVICE, "/org/bluez")
proxy = _agent_bus.get_proxy_object(
BLUEZ_SERVICE, "/org/bluez", introspection
)
agent_manager = proxy.get_interface(AGENT_MANAGER_IFACE)
await agent_manager.call_unregister_agent(AGENT_PATH)
except Exception:
pass # Ignore errors during cleanup
_agent_registered = False
if _agent_bus:
_agent_bus.disconnect()
_agent_bus = None
_agent = None
def get_pending_requests() -> list[dict]:
"""Get all pending pairing requests as dicts for MCP response."""
if _agent is None:
return []
return [
{
"device_address": req.device_address,
"request_type": req.request_type.value,
"passkey": req.passkey,
"uuid": req.uuid,
"timestamp": req.timestamp.isoformat(),
}
for req in _agent.pending_requests.values()
]
async def respond_to_pairing(
device_address: str,
accept: bool,
pin_or_passkey: str | int | None = None,
) -> bool:
"""Respond to a pending pairing request."""
if _agent is None:
return False
return _agent.respond_to_request(device_address, accept, pin_or_passkey)
async def set_pairing_mode(mode: str) -> None:
"""Set the pairing mode for the agent."""
agent = await get_agent()
agent.set_mode(PairingMode(mode))