diff --git a/README.md b/README.md index ea42847..bcb0853 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,20 @@ sudo usermod -aG bluetooth $USER # Or configure polkit for BlueZ D-Bus access ``` +## MCP Resources + +The server exposes dynamic resources for live state queries: + +| Resource URI | Description | +|--------------|-------------| +| `bluetooth://adapters` | All Bluetooth adapters | +| `bluetooth://devices/paired` | Paired devices | +| `bluetooth://devices/connected` | Connected devices | +| `bluetooth://devices/visible` | All known devices | +| `bluetooth://devices/trusted` | Trusted devices | +| `bluetooth://adapter/{name}` | Specific adapter details | +| `bluetooth://device/{address}` | Specific device details | + ## MCP Tools ### Adapter Tools diff --git a/src/mcbluetooth/resources.py b/src/mcbluetooth/resources.py new file mode 100644 index 0000000..f60ac2b --- /dev/null +++ b/src/mcbluetooth/resources.py @@ -0,0 +1,209 @@ +"""MCP Resources for Bluetooth state exposure. + +Resources provide a live, queryable view of Bluetooth state: +- Adapters: Available Bluetooth controllers +- Devices: Filtered by state (visible, paired, connected) + +Unlike tools which perform actions, resources are read-only snapshots +that clients can poll or subscribe to for state changes. +""" + +from __future__ import annotations + +import json +from dataclasses import asdict + +from fastmcp import FastMCP + +from mcbluetooth.dbus_client import get_client + + +def register_resources(mcp: FastMCP) -> None: + """Register Bluetooth state resources with the MCP server.""" + + @mcp.resource( + "bluetooth://adapters", + name="Bluetooth Adapters", + description="List of all Bluetooth adapters on the system with their current state", + mime_type="application/json", + ) + async def resource_adapters() -> str: + """Get all Bluetooth adapters.""" + client = await get_client() + adapters = await client.list_adapters() + return json.dumps( + { + "adapters": [ + { + "name": a.name, + "address": a.address, + "alias": a.alias, + "powered": a.powered, + "discoverable": a.discoverable, + "discovering": a.discovering, + "pairable": a.pairable, + } + for a in adapters + ] + }, + indent=2, + ) + + @mcp.resource( + "bluetooth://devices/paired", + name="Paired Devices", + description="Bluetooth devices that have been paired with this system", + mime_type="application/json", + ) + async def resource_devices_paired() -> str: + """Get all paired Bluetooth devices.""" + client = await get_client() + devices = await client.list_devices(filter_type="paired") + return json.dumps( + { + "filter": "paired", + "count": len(devices), + "devices": [ + { + "address": d.address, + "name": d.name or d.alias or "(unnamed)", + "connected": d.connected, + "trusted": d.trusted, + "adapter": d.adapter, + "uuids": d.uuids[:5], # First 5 UUIDs + } + for d in devices + ], + }, + indent=2, + ) + + @mcp.resource( + "bluetooth://devices/connected", + name="Connected Devices", + description="Bluetooth devices currently connected to this system", + mime_type="application/json", + ) + async def resource_devices_connected() -> str: + """Get all connected Bluetooth devices.""" + client = await get_client() + devices = await client.list_devices(filter_type="connected") + return json.dumps( + { + "filter": "connected", + "count": len(devices), + "devices": [ + { + "address": d.address, + "name": d.name or d.alias or "(unnamed)", + "paired": d.paired, + "trusted": d.trusted, + "adapter": d.adapter, + "services_resolved": d.services_resolved, + } + for d in devices + ], + }, + indent=2, + ) + + @mcp.resource( + "bluetooth://devices/visible", + name="Visible Devices", + description="All Bluetooth devices visible to this system (discovered or known)", + mime_type="application/json", + ) + async def resource_devices_visible() -> str: + """Get all visible/known Bluetooth devices.""" + client = await get_client() + devices = await client.list_devices(filter_type="all") + return json.dumps( + { + "filter": "all", + "count": len(devices), + "devices": [ + { + "address": d.address, + "name": d.name or d.alias or "(unnamed)", + "rssi": d.rssi, + "paired": d.paired, + "connected": d.connected, + "adapter": d.adapter, + } + for d in devices + ], + }, + indent=2, + ) + + @mcp.resource( + "bluetooth://devices/trusted", + name="Trusted Devices", + description="Bluetooth devices marked as trusted (auto-connect enabled)", + mime_type="application/json", + ) + async def resource_devices_trusted() -> str: + """Get all trusted Bluetooth devices.""" + client = await get_client() + devices = await client.list_devices(filter_type="trusted") + return json.dumps( + { + "filter": "trusted", + "count": len(devices), + "devices": [ + { + "address": d.address, + "name": d.name or d.alias or "(unnamed)", + "paired": d.paired, + "connected": d.connected, + "adapter": d.adapter, + } + for d in devices + ], + }, + indent=2, + ) + + # Dynamic resource template for per-adapter info + @mcp.resource( + "bluetooth://adapter/{adapter_name}", + name="Adapter Details", + description="Detailed information about a specific Bluetooth adapter", + mime_type="application/json", + ) + async def resource_adapter_info(adapter_name: str) -> str: + """Get detailed info for a specific adapter.""" + client = await get_client() + adapter = await client.get_adapter(adapter_name) + if not adapter: + return json.dumps({"error": f"Adapter '{adapter_name}' not found"}) + return json.dumps(asdict(adapter), indent=2) + + # Dynamic resource for per-device info + @mcp.resource( + "bluetooth://device/{address}", + name="Device Details", + description="Detailed information about a specific Bluetooth device by address", + mime_type="application/json", + ) + async def resource_device_info(address: str) -> str: + """Get detailed info for a specific device.""" + client = await get_client() + # Search across all adapters + devices = await client.list_devices() + for d in devices: + if d.address.upper() == address.upper(): + data = asdict(d) + # Convert bytes to hex strings for JSON serialization + if data.get("manufacturer_data"): + data["manufacturer_data"] = { + k: v.hex() if isinstance(v, bytes) else str(v) + for k, v in data["manufacturer_data"].items() + } + if data.get("service_data"): + data["service_data"] = { + k: v.hex() if isinstance(v, bytes) else str(v) + for k, v in data["service_data"].items() + } + return json.dumps(data, indent=2) + return json.dumps({"error": f"Device '{address}' not found"}) diff --git a/src/mcbluetooth/server.py b/src/mcbluetooth/server.py index b505a36..f74a868 100644 --- a/src/mcbluetooth/server.py +++ b/src/mcbluetooth/server.py @@ -2,6 +2,7 @@ from fastmcp import FastMCP +from mcbluetooth import resources from mcbluetooth.tools import adapter, audio, ble, device mcp = FastMCP( @@ -14,6 +15,16 @@ This server provides comprehensive control over the Linux Bluetooth stack: - Audio profiles (A2DP, HFP) with PipeWire/PulseAudio integration - BLE/GATT services (read/write characteristics, notifications) +## Resources (live state queries) +- bluetooth://adapters - All Bluetooth adapters +- bluetooth://devices/paired - Paired devices +- bluetooth://devices/connected - Connected devices +- bluetooth://devices/visible - All known devices +- bluetooth://devices/trusted - Trusted devices +- bluetooth://adapter/{name} - Specific adapter details +- bluetooth://device/{address} - Specific device details + +## Tools All tools require an explicit 'adapter' parameter (e.g., "hci0"). Use bt_list_adapters() to discover available adapters. @@ -24,6 +35,9 @@ For pairing, use pairing_mode parameter: """, ) +# Register resources (live state) +resources.register_resources(mcp) + # Register tool modules adapter.register_tools(mcp) device.register_tools(mcp) diff --git a/src/mcbluetooth/tools/device.py b/src/mcbluetooth/tools/device.py index 2b834bf..3c16a73 100644 --- a/src/mcbluetooth/tools/device.py +++ b/src/mcbluetooth/tools/device.py @@ -1,15 +1,18 @@ """Device discovery and management tools for BlueZ.""" -from __future__ import annotations - import asyncio from dataclasses import asdict from typing import Any, Literal -from fastmcp import FastMCP +from fastmcp import Context, FastMCP from mcbluetooth.dbus_client import get_client +# Type aliases for MCP tool parameters +ScanMode = Literal["classic", "ble", "both"] +DeviceFilter = Literal["all", "paired", "connected", "trusted"] +PairingMode = Literal["elicit", "interactive", "auto"] + def register_tools(mcp: FastMCP) -> None: """Register device management tools with the MCP server.""" @@ -18,7 +21,8 @@ def register_tools(mcp: FastMCP) -> None: async def bt_scan( adapter: str, timeout: int = 10, - mode: Literal["classic", "ble", "both"] = "both", + mode: ScanMode = "both", + ctx: Context | None = None, ) -> list[dict[str, Any]]: """Scan for nearby Bluetooth devices. @@ -35,29 +39,48 @@ def register_tools(mcp: FastMCP) -> None: """ client = await get_client() + # Log scan start + if ctx: + await ctx.info(f"Starting {mode} scan on {adapter} for {timeout}s") + # Set discovery filter based on mode transport = {"classic": "bredr", "ble": "le", "both": "auto"}[mode] await client.set_discovery_filter(adapter, transport=transport, duplicate_data=True) # Start discovery await client.start_discovery(adapter) + if ctx: + await ctx.debug(f"Discovery started on {adapter}") try: - # Wait for scan duration - await asyncio.sleep(timeout) + # Report progress during scan with periodic device count updates + for elapsed in range(timeout): + await asyncio.sleep(1) + if ctx: + # Report progress + await ctx.report_progress(progress=elapsed + 1, total=timeout) + # Periodically log device count + if (elapsed + 1) % 3 == 0 or elapsed == 0: + devices = await client.list_devices(adapter=adapter) + await ctx.debug(f"Scan progress: {len(devices)} devices found") finally: # Always stop discovery await client.stop_discovery(adapter) await client.remove_discovery_filter(adapter) + if ctx: + await ctx.debug("Discovery stopped") # Return discovered devices devices = await client.list_devices(adapter=adapter) + if ctx: + await ctx.info(f"Scan complete: found {len(devices)} devices") return [asdict(d) for d in devices] @mcp.tool() async def bt_list_devices( adapter: str, - filter: Literal["all", "paired", "connected", "trusted"] = "all", + filter: DeviceFilter = "all", + ctx: Context | None = None, ) -> list[dict[str, Any]]: """List known Bluetooth devices. @@ -72,11 +95,19 @@ def register_tools(mcp: FastMCP) -> None: List of devices matching the filter """ client = await get_client() + if ctx: + await ctx.debug(f"Listing {filter} devices on {adapter}") devices = await client.list_devices(adapter=adapter, filter_type=filter) + if ctx: + await ctx.info(f"Found {len(devices)} {filter} devices") return [asdict(d) for d in devices] @mcp.tool() - async def bt_device_info(adapter: str, address: str) -> dict[str, Any] | None: + async def bt_device_info( + adapter: str, + address: str, + ctx: Context | None = None, + ) -> dict[str, Any] | None: """Get detailed information about a specific device. Args: @@ -87,14 +118,19 @@ def register_tools(mcp: FastMCP) -> None: Device properties or None if not found """ client = await get_client() + if ctx: + await ctx.debug(f"Getting info for {address}") info = await client.get_device(adapter, address) + if info and ctx: + await ctx.info(f"Device: {info.name or info.alias or 'unnamed'} ({address})") return asdict(info) if info else None @mcp.tool() async def bt_pair( adapter: str, address: str, - pairing_mode: Literal["elicit", "interactive", "auto"] = "interactive", + pairing_mode: PairingMode = "interactive", + ctx: Context | None = None, ) -> dict[str, Any]: """Initiate pairing with a device. @@ -114,9 +150,14 @@ def register_tools(mcp: FastMCP) -> None: """ client = await get_client() + if ctx: + await ctx.info(f"Initiating pairing with {address} (mode: {pairing_mode})") + # Check if already paired device = await client.get_device(adapter, address) if device and device.paired: + if ctx: + await ctx.info(f"Device {address} is already paired") return {"status": "already_paired", "device": asdict(device)} if pairing_mode == "auto": @@ -124,26 +165,33 @@ def register_tools(mcp: FastMCP) -> None: try: await client.pair_device(adapter, address) device = await client.get_device(adapter, address) + if ctx: + await ctx.info(f"Successfully paired with {address}") return {"status": "paired", "device": asdict(device) if device else None} except Exception as e: + if ctx: + await ctx.error(f"Pairing failed: {e}") return {"status": "error", "error": str(e)} else: # For interactive/elicit modes, we need an agent - # Return status indicating pairing initiated try: - # Start pairing (this is async and may block for confirmation) - # In a real implementation, this would register an agent await client.pair_device(adapter, address) device = await client.get_device(adapter, address) + if ctx: + await ctx.info(f"Successfully paired with {address}") return {"status": "paired", "device": asdict(device) if device else None} except Exception as e: error_msg = str(e) if "AuthenticationFailed" in error_msg or "AuthenticationCanceled" in error_msg: + if ctx: + await ctx.warning("Pairing requires user confirmation or PIN") return { "status": "awaiting_confirmation", "message": "Pairing requires user confirmation or PIN entry", "pairing_mode": pairing_mode, } + if ctx: + await ctx.error(f"Pairing failed: {error_msg}") return {"status": "error", "error": error_msg} @mcp.tool() @@ -152,6 +200,7 @@ def register_tools(mcp: FastMCP) -> None: address: str, pin: str | None = None, accept: bool = True, + ctx: Context | None = None, ) -> dict[str, Any]: """Confirm or reject a pairing request. @@ -169,23 +218,36 @@ def register_tools(mcp: FastMCP) -> None: client = await get_client() if not accept: + if ctx: + await ctx.info(f"Rejecting pairing with {address}") try: await client.cancel_pairing(adapter, address) return {"status": "pairing_cancelled"} except Exception as e: + if ctx: + await ctx.error(f"Failed to cancel pairing: {e}") return {"status": "error", "error": str(e)} - # Re-attempt pairing - agent would handle PIN - # For now, this is a simplified implementation + if ctx: + await ctx.info(f"Confirming pairing with {address}") + try: await client.pair_device(adapter, address) device = await client.get_device(adapter, address) + if ctx: + await ctx.info(f"Pairing confirmed with {address}") return {"status": "paired", "device": asdict(device) if device else None} except Exception as e: + if ctx: + await ctx.error(f"Pairing confirmation failed: {e}") return {"status": "error", "error": str(e)} @mcp.tool() - async def bt_unpair(adapter: str, address: str) -> dict[str, str]: + async def bt_unpair( + adapter: str, + address: str, + ctx: Context | None = None, + ) -> dict[str, str]: """Remove pairing with a device. This removes the device from the list of known devices and @@ -199,14 +261,24 @@ def register_tools(mcp: FastMCP) -> None: Status of the operation """ client = await get_client() + if ctx: + await ctx.info(f"Removing device {address}") try: await client.remove_device(adapter, address) + if ctx: + await ctx.info(f"Device {address} removed successfully") return {"status": "removed", "address": address} except Exception as e: + if ctx: + await ctx.error(f"Failed to remove device: {e}") return {"status": "error", "error": str(e)} @mcp.tool() - async def bt_connect(adapter: str, address: str) -> dict[str, Any]: + async def bt_connect( + adapter: str, + address: str, + ctx: Context | None = None, + ) -> dict[str, Any]: """Connect to a paired device. Establishes an active connection to a previously paired device. @@ -220,15 +292,25 @@ def register_tools(mcp: FastMCP) -> None: Connection status and device info """ client = await get_client() + if ctx: + await ctx.info(f"Connecting to {address}") try: await client.connect_device(adapter, address) device = await client.get_device(adapter, address) + if ctx: + await ctx.info(f"Connected to {device.name or address}") return {"status": "connected", "device": asdict(device) if device else None} except Exception as e: + if ctx: + await ctx.error(f"Connection failed: {e}") return {"status": "error", "error": str(e)} @mcp.tool() - async def bt_disconnect(adapter: str, address: str) -> dict[str, Any]: + async def bt_disconnect( + adapter: str, + address: str, + ctx: Context | None = None, + ) -> dict[str, Any]: """Disconnect from a device. Terminates the active connection but preserves pairing. @@ -241,15 +323,26 @@ def register_tools(mcp: FastMCP) -> None: Status of the operation """ client = await get_client() + if ctx: + await ctx.info(f"Disconnecting from {address}") try: await client.disconnect_device(adapter, address) device = await client.get_device(adapter, address) + if ctx: + await ctx.info(f"Disconnected from {address}") return {"status": "disconnected", "device": asdict(device) if device else None} except Exception as e: + if ctx: + await ctx.error(f"Disconnect failed: {e}") return {"status": "error", "error": str(e)} @mcp.tool() - async def bt_trust(adapter: str, address: str, trusted: bool) -> dict[str, Any]: + async def bt_trust( + adapter: str, + address: str, + trusted: bool, + ctx: Context | None = None, + ) -> dict[str, Any]: """Set device trust status. Trusted devices can connect automatically without explicit @@ -264,15 +357,27 @@ def register_tools(mcp: FastMCP) -> None: Updated device info """ client = await get_client() + action = "Trusting" if trusted else "Untrusting" + if ctx: + await ctx.info(f"{action} device {address}") try: await client.set_device_trusted(adapter, address, trusted) device = await client.get_device(adapter, address) + if ctx: + await ctx.info(f"Device {address} {'trusted' if trusted else 'untrusted'}") return {"status": "updated", "device": asdict(device) if device else None} except Exception as e: + if ctx: + await ctx.error(f"Failed to update trust: {e}") return {"status": "error", "error": str(e)} @mcp.tool() - async def bt_block(adapter: str, address: str, blocked: bool) -> dict[str, Any]: + async def bt_block( + adapter: str, + address: str, + blocked: bool, + ctx: Context | None = None, + ) -> dict[str, Any]: """Block or unblock a device. Blocked devices cannot connect to this adapter. @@ -286,15 +391,27 @@ def register_tools(mcp: FastMCP) -> None: Updated device info """ client = await get_client() + action = "Blocking" if blocked else "Unblocking" + if ctx: + await ctx.info(f"{action} device {address}") try: await client.set_device_blocked(adapter, address, blocked) device = await client.get_device(adapter, address) + if ctx: + await ctx.info(f"Device {address} {'blocked' if blocked else 'unblocked'}") return {"status": "updated", "device": asdict(device) if device else None} except Exception as e: + if ctx: + await ctx.error(f"Failed to update block status: {e}") return {"status": "error", "error": str(e)} @mcp.tool() - async def bt_device_set_alias(adapter: str, address: str, alias: str) -> dict[str, Any]: + async def bt_device_set_alias( + adapter: str, + address: str, + alias: str, + ctx: Context | None = None, + ) -> dict[str, Any]: """Set a friendly name for a device. Args: @@ -306,9 +423,15 @@ def register_tools(mcp: FastMCP) -> None: Updated device info """ client = await get_client() + if ctx: + await ctx.info(f"Setting alias for {address} to '{alias}'") try: await client.set_device_alias(adapter, address, alias) device = await client.get_device(adapter, address) + if ctx: + await ctx.info(f"Alias set for {address}") return {"status": "updated", "device": asdict(device) if device else None} except Exception as e: + if ctx: + await ctx.error(f"Failed to set alias: {e}") return {"status": "error", "error": str(e)}