Add desktop notification confirmation fallback, update README for PyPI

When MCP elicitation is unavailable (most clients), fall back to
org.freedesktop.Notifications with Approve/Deny action buttons.
Opt-in via MCDBUS_NOTIFY_CONFIRM=1. Silence (timeout) is denial.

Fixes signal race where NotificationClosed stomped ActionInvoked
result in the same event loop iteration.
This commit is contained in:
Ryan Malloy 2026-03-06 14:46:16 -07:00
parent 89419a36c2
commit c8a1ce0fdb
6 changed files with 483 additions and 4 deletions

112
README.md
View File

@ -2,16 +2,122 @@
D-Bus MCP server — bridge Linux IPC into the Model Context Protocol. D-Bus MCP server — bridge Linux IPC into the Model Context Protocol.
Gives Claude native tool access to session and system D-Bus services through introspection-first discovery. Gives Claude (or any MCP client) native tool access to session and system D-Bus services through introspection-first discovery. No hardcoded service knowledge — the server discovers what's available on your bus at runtime.
## Install ## Install
```bash ```bash
uv run mcdbus pip install mcdbus
# or
uvx mcdbus
``` ```
Requires Python 3.11+ and a running D-Bus daemon (standard on any Linux desktop).
## Add to Claude Code ## Add to Claude Code
```bash ```bash
claude mcp add mcdbus -- uv run --directory /path/to/mcdbus mcdbus claude mcp add mcdbus -- uvx mcdbus
``` ```
With the notification confirmation fallback enabled:
```bash
claude mcp add -e MCDBUS_NOTIFY_CONFIRM=1 mcdbus -- uvx mcdbus
```
## How it works
The server exposes D-Bus through a discovery workflow:
```
list_services → list_objects → introspect → call_method / get_property
```
Two buses are available:
- **session** — user desktop services (notifications, media players, KDE, portals)
- **system** — system services (systemd, NetworkManager, UDisks2, UPower, bluez)
## Tools
### Discovery
| Tool | Description |
|------|-------------|
| `list_services` | List well-known service names on a bus |
| `introspect` | Show interfaces, methods, properties, and signals for a D-Bus object |
| `list_objects` | Walk the object tree for a service (bounded BFS) |
### Interaction
| Tool | Description |
|------|-------------|
| `call_method` | Call any D-Bus method and return the result |
| `get_property` | Read a single property value |
| `set_property` | Set a property value (requires confirmation) |
| `get_all_properties` | Read all properties on an interface |
### Shortcuts
Pre-wired tools for common operations — no introspection needed:
| Tool | Description |
|------|-------------|
| `send_notification` | Send a desktop notification |
| `list_systemd_units` | List systemd units, with optional glob filter |
| `media_player_control` | Control MPRIS2 media players (play, pause, next, stop) |
| `network_status` | NetworkManager connection status |
| `battery_status` | Battery percentage and charging state via UPower |
| `bluetooth_devices` | Paired and discovered devices via bluez |
| `kwin_windows` | Open windows from KDE KWin |
### Resources
| URI | Description |
|-----|-------------|
| `dbus://{bus}/services` | Live list of service names |
| `dbus://{bus}/{service}/objects` | Object tree (max 200 nodes) |
| `dbus://{bus}/{service}/{path}/interfaces` | Interfaces at an object path |
### Prompts
| Prompt | Description |
|--------|-------------|
| `explore_service` | Guided walkthrough of a D-Bus service |
| `debug_service` | Diagnose issues with a service |
## Confirmation model
Sensitive operations require explicit user approval before executing:
- **System bus method calls** — confirmed before execution
- **Property mutations** (`set_property`) — confirmed on any bus
The server tries three confirmation methods in order:
1. **MCP elicitation** — native protocol support (if the client implements it)
2. **Desktop notification** — Approve/Deny buttons via `org.freedesktop.Notifications` (opt-in, see below)
3. **Proceed with warning** — logs to stderr and allows the operation
### Desktop notification fallback
When the MCP client doesn't support elicitation (most don't yet, including Claude Code), the server can fall back to desktop notifications with action buttons. The notification stays up for 60 seconds. No response is treated as a denial.
Enable with:
```bash
MCDBUS_NOTIFY_CONFIRM=1
```
## Environment variables
| Variable | Default | Description |
|----------|---------|-------------|
| `MCDBUS_NOTIFY_CONFIRM` | unset | Enable desktop notification fallback for confirmation |
| `MCDBUS_REQUIRE_ELICITATION` | unset | Hard-fail if client doesn't support MCP elicitation (overrides notification fallback) |
| `MCDBUS_TIMEOUT` | `30` | D-Bus call timeout in seconds |
## License
MIT

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "mcdbus" name = "mcdbus"
version = "2026.03.05" version = "2026.03.06"
description = "D-Bus MCP server — bridge Linux IPC into the Model Context Protocol" description = "D-Bus MCP server — bridge Linux IPC into the Model Context Protocol"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
@ -15,6 +15,9 @@ dependencies = [
"dbus-fast>=3.1.2", "dbus-fast>=3.1.2",
] ]
[project.urls]
Source = "https://git.supported.systems/warehack.ing/mcdbus"
[project.scripts] [project.scripts]
mcdbus = "mcdbus.server:main" mcdbus = "mcdbus.server:main"

View File

@ -20,6 +20,7 @@ from mcdbus._bus import (
validate_object_path, validate_object_path,
validate_signature, validate_signature,
) )
from mcdbus._notify_confirm import notify_confirm
from mcdbus._state import mcp from mcdbus._state import mcp
@ -44,6 +45,28 @@ async def _confirm_or_abort(ctx: Context, message: str, operation: str) -> None:
# Client doesn't support elicitation # Client doesn't support elicitation
if os.environ.get("MCDBUS_REQUIRE_ELICITATION"): if os.environ.get("MCDBUS_REQUIRE_ELICITATION"):
raise ToolError("Elicitation required but client does not support it") raise ToolError("Elicitation required but client does not support it")
# D-Bus notification fallback (opt-in)
if os.environ.get("MCDBUS_NOTIFY_CONFIRM"):
try:
mgr = get_mgr(ctx)
bus = await mgr.get_bus("session")
approved = await notify_confirm(
bus,
summary=f"mcdbus: Confirm {operation}",
body=message,
)
if approved:
return
raise ToolError("Operation denied via desktop notification.")
except (RuntimeError, TimeoutError) as exc:
print(
f"mcdbus: notification fallback failed ({exc}), "
f"proceeding with {operation}",
file=sys.stderr,
)
return
print(f"mcdbus: elicitation unavailable, proceeding with {operation}", file=sys.stderr) print(f"mcdbus: elicitation unavailable, proceeding with {operation}", file=sys.stderr)
return return
# User explicitly declined # User explicitly declined

View File

@ -0,0 +1,137 @@
"""D-Bus notification fallback for confirmation when MCP elicitation is unavailable."""
import asyncio
from dbus_fast import MessageType
from dbus_fast.aio import MessageBus
from dbus_fast.signature import Variant
from mcdbus._bus import call_bus_method
_NOTIFY_DEST = "org.freedesktop.Notifications"
_NOTIFY_PATH = "/org/freedesktop/Notifications"
_NOTIFY_IFACE = "org.freedesktop.Notifications"
_DBUS_DEST = "org.freedesktop.DBus"
_DBUS_PATH = "/org/freedesktop/DBus"
_DBUS_IFACE = "org.freedesktop.DBus"
async def notify_confirm(
bus: MessageBus,
summary: str,
body: str,
timeout: float = 60.0,
) -> bool:
"""Send a desktop notification with Approve/Deny actions and wait for response.
Returns True if user clicked Approve, False for deny/dismiss/expire/timeout.
Raises RuntimeError if notification service is unreachable.
"""
event = asyncio.Event()
result_box: list[bool] = [False]
nid_box: list[int] = [0]
match_action = (
"type='signal',"
f"interface='{_NOTIFY_IFACE}',"
"member='ActionInvoked'"
)
match_closed = (
"type='signal',"
f"interface='{_NOTIFY_IFACE}',"
"member='NotificationClosed'"
)
def handler(msg):
if event.is_set():
return # First signal wins — ignore NotificationClosed after ActionInvoked
if msg.message_type != MessageType.SIGNAL:
return
if msg.interface != _NOTIFY_IFACE:
return
if not nid_box[0] or not msg.body or msg.body[0] != nid_box[0]:
return
if msg.member == "ActionInvoked":
result_box[0] = msg.body[1] == "approve"
event.set()
elif msg.member == "NotificationClosed":
result_box[0] = False
event.set()
# Register match rules before sending notification — no signal can be missed.
await call_bus_method(
bus,
destination=_DBUS_DEST,
path=_DBUS_PATH,
interface=_DBUS_IFACE,
member="AddMatch",
signature="s",
body=[match_action],
)
await call_bus_method(
bus,
destination=_DBUS_DEST,
path=_DBUS_PATH,
interface=_DBUS_IFACE,
member="AddMatch",
signature="s",
body=[match_closed],
)
bus.add_message_handler(handler)
try:
reply = await call_bus_method(
bus,
destination=_NOTIFY_DEST,
path=_NOTIFY_PATH,
interface=_NOTIFY_IFACE,
member="Notify",
signature="susssasa{sv}i",
body=[
"mcdbus",
0,
"dialog-warning",
summary,
body,
["approve", "Approve", "deny", "Deny"],
{"urgency": Variant("y", 2)},
0,
],
)
nid_box[0] = reply[0]
await asyncio.wait_for(event.wait(), timeout=timeout)
return result_box[0]
except TimeoutError:
return False
finally:
bus.remove_message_handler(handler)
# Best-effort cleanup
if nid_box[0]:
try:
await call_bus_method(
bus,
destination=_NOTIFY_DEST,
path=_NOTIFY_PATH,
interface=_NOTIFY_IFACE,
member="CloseNotification",
signature="u",
body=[nid_box[0]],
)
except Exception:
pass
for rule in (match_action, match_closed):
try:
await call_bus_method(
bus,
destination=_DBUS_DEST,
path=_DBUS_PATH,
interface=_DBUS_IFACE,
member="RemoveMatch",
signature="s",
body=[rule],
)
except Exception:
pass

View File

@ -63,6 +63,73 @@ class TestConfirmOrAbort:
with pytest.raises(ToolError, match="Elicitation required"): with pytest.raises(ToolError, match="Elicitation required"):
await _confirm_or_abort(ctx, "Test message", "test_op") await _confirm_or_abort(ctx, "Test message", "test_op")
async def test_cancelled_notify_fallback_approved(self):
"""MCDBUS_NOTIFY_CONFIRM=1 + user approves → proceeds normally."""
ctx = AsyncMock()
ctx.elicit = AsyncMock(return_value=CancelledElicitation())
mock_mgr = AsyncMock()
mock_mgr.get_bus = AsyncMock(return_value=AsyncMock())
with (
patch.dict(os.environ, {"MCDBUS_NOTIFY_CONFIRM": "1"}),
patch("mcdbus._interaction.get_mgr", return_value=mock_mgr),
patch("mcdbus._interaction.notify_confirm", return_value=True),
):
await _confirm_or_abort(ctx, "Test message", "test_op")
async def test_cancelled_notify_fallback_denied(self):
"""MCDBUS_NOTIFY_CONFIRM=1 + user denies → ToolError."""
ctx = AsyncMock()
ctx.elicit = AsyncMock(return_value=CancelledElicitation())
mock_mgr = AsyncMock()
mock_mgr.get_bus = AsyncMock(return_value=AsyncMock())
with (
patch.dict(os.environ, {"MCDBUS_NOTIFY_CONFIRM": "1"}),
patch("mcdbus._interaction.get_mgr", return_value=mock_mgr),
patch("mcdbus._interaction.notify_confirm", return_value=False),
):
with pytest.raises(ToolError, match="denied via desktop notification"):
await _confirm_or_abort(ctx, "Test message", "test_op")
async def test_cancelled_notify_unavailable_proceeds(self):
"""Notification service failure → falls through with warning."""
ctx = AsyncMock()
ctx.elicit = AsyncMock(return_value=CancelledElicitation())
mock_mgr = AsyncMock()
mock_mgr.get_bus = AsyncMock(return_value=AsyncMock())
with (
patch.dict(os.environ, {"MCDBUS_NOTIFY_CONFIRM": "1"}),
patch("mcdbus._interaction.get_mgr", return_value=mock_mgr),
patch(
"mcdbus._interaction.notify_confirm",
side_effect=RuntimeError("No notification service"),
),
):
# Should proceed without raising
await _confirm_or_abort(ctx, "Test message", "test_op")
async def test_require_elicitation_overrides_notify(self):
"""MCDBUS_REQUIRE_ELICITATION takes precedence over MCDBUS_NOTIFY_CONFIRM."""
ctx = AsyncMock()
ctx.elicit = AsyncMock(return_value=CancelledElicitation())
with (
patch.dict(os.environ, {
"MCDBUS_REQUIRE_ELICITATION": "1",
"MCDBUS_NOTIFY_CONFIRM": "1",
}),
patch("mcdbus._interaction.notify_confirm") as mock_notify,
):
with pytest.raises(ToolError, match="Elicitation required"):
await _confirm_or_abort(ctx, "Test message", "test_op")
mock_notify.assert_not_called()
async def test_notify_not_attempted_without_env(self):
"""Without MCDBUS_NOTIFY_CONFIRM, notify_confirm is never called."""
ctx = AsyncMock()
ctx.elicit = AsyncMock(return_value=CancelledElicitation())
with patch("mcdbus._interaction.notify_confirm") as mock_notify:
await _confirm_or_abort(ctx, "Test message", "test_op")
mock_notify.assert_not_called()
class TestCallMethodElicitation: class TestCallMethodElicitation:
"""Verify call_method triggers elicitation on system bus but not session bus.""" """Verify call_method triggers elicitation on system bus but not session bus."""

View File

@ -0,0 +1,143 @@
"""Tests for D-Bus notification confirmation fallback."""
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from dbus_fast import MessageType
from mcdbus._notify_confirm import notify_confirm
NID = 42
IFACE = "org.freedesktop.Notifications"
def _make_signal(interface, member, body):
"""Create a mock D-Bus signal message."""
msg = MagicMock()
msg.message_type = MessageType.SIGNAL
msg.interface = interface
msg.member = member
msg.body = body
return msg
@pytest.fixture
def mock_bus():
"""Mock MessageBus with handler registration tracking."""
bus = MagicMock()
bus._handlers = []
def _add(h):
bus._handlers.append(h)
def _remove(h):
if h in bus._handlers:
bus._handlers.remove(h)
bus.add_message_handler = MagicMock(side_effect=_add)
bus.remove_message_handler = MagicMock(side_effect=_remove)
return bus
def _fire_signal(mock_bus, interface, member, body, delay=0.01):
"""Schedule a signal delivery after a brief delay."""
async def _deliver():
await asyncio.sleep(delay)
msg = _make_signal(interface, member, body)
for handler in list(mock_bus._handlers):
handler(msg)
asyncio.create_task(_deliver())
class TestNotifyConfirm:
async def test_approve_returns_true(self, mock_bus):
async def mock_call(bus, destination, path, interface, member,
signature="", body=None, timeout=30.0):
if member == "Notify":
_fire_signal(mock_bus, IFACE, "ActionInvoked", [NID, "approve"])
return [NID]
return None
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
result = await notify_confirm(mock_bus, "Test", "Confirm?")
assert result is True
async def test_deny_returns_false(self, mock_bus):
async def mock_call(bus, destination, path, interface, member,
signature="", body=None, timeout=30.0):
if member == "Notify":
_fire_signal(mock_bus, IFACE, "ActionInvoked", [NID, "deny"])
return [NID]
return None
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
result = await notify_confirm(mock_bus, "Test", "Confirm?")
assert result is False
async def test_dismissed_returns_false(self, mock_bus):
async def mock_call(bus, destination, path, interface, member,
signature="", body=None, timeout=30.0):
if member == "Notify":
_fire_signal(mock_bus, IFACE, "NotificationClosed", [NID, 2])
return [NID]
return None
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
result = await notify_confirm(mock_bus, "Test", "Confirm?")
assert result is False
async def test_timeout_returns_false(self, mock_bus):
async def mock_call(bus, destination, path, interface, member,
signature="", body=None, timeout=30.0):
if member == "Notify":
return [NID]
return None
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
result = await notify_confirm(mock_bus, "Test", "Confirm?", timeout=0.05)
assert result is False
async def test_service_unavailable_raises(self, mock_bus):
async def mock_call(bus, destination, path, interface, member,
signature="", body=None, timeout=30.0):
if member == "Notify":
raise RuntimeError("org.freedesktop.DBus.Error.ServiceUnknown")
return None
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
with pytest.raises(RuntimeError, match="ServiceUnknown"):
await notify_confirm(mock_bus, "Test", "Confirm?")
async def test_handler_ignores_wrong_nid(self, mock_bus):
async def mock_call(bus, destination, path, interface, member,
signature="", body=None, timeout=30.0):
if member == "Notify":
# Wrong nid first, correct nid after
_fire_signal(mock_bus, IFACE, "ActionInvoked", [999, "approve"], delay=0.01)
_fire_signal(mock_bus, IFACE, "ActionInvoked", [NID, "deny"], delay=0.03)
return [NID]
return None
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
result = await notify_confirm(mock_bus, "Test", "Confirm?", timeout=1.0)
# Wrong nid was ignored; correct nid with "deny" triggered
assert result is False
async def test_cleanup_called(self, mock_bus):
members_called = []
async def mock_call(bus, destination, path, interface, member,
signature="", body=None, timeout=30.0):
members_called.append(member)
if member == "Notify":
_fire_signal(mock_bus, IFACE, "ActionInvoked", [NID, "approve"])
return [NID]
return None
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
await notify_confirm(mock_bus, "Test", "Confirm?")
mock_bus.remove_message_handler.assert_called_once()
assert "CloseNotification" in members_called
assert members_called.count("RemoveMatch") == 2