Hamilton remediation: validation, ToolError, elicitation, permission docs

Three-pillar fix from Hamilton review:

Code quality — validate_signature() for D-Bus spec compliance,
MCDBUS_TIMEOUT env var, replace 13 error-as-success returns with
ToolError, monotonic clock deadline on tree walks, sanitize D-Bus
error messages, fix resource connection leak via module-level
BusManager, hasattr guards in conftest.

Elicitation — ctx.elicit() confirmation for system bus call_method
and all set_property calls, graceful degradation when client lacks
elicitation support, MCDBUS_REQUIRE_ELICITATION for hard-fail mode.

Permission docs — four-layer guide (systemd sandboxing, dbus-broker
policy, polkit rules, xdg-dbus-proxy) with ready-to-deploy example
configs validated against xmllint, bash -n, and systemd-analyze.
This commit is contained in:
Ryan Malloy 2026-03-06 11:54:31 -07:00
parent 326be8d52d
commit 5fa1eb36ef
13 changed files with 1031 additions and 93 deletions

View File

@ -0,0 +1,88 @@
// polkit rules for mcdbus
//
// Drop into /etc/polkit-1/rules.d/50-mcdbus.rules and restart polkit:
// sudo systemctl restart polkit.service
//
// Users in the "mcdbus" group get read-only access to common system
// services. Everything else falls through to system defaults (which
// typically require admin authentication or deny).
//
// Create the group:
// sudo groupadd mcdbus
// sudo usermod -aG mcdbus youruser
//
// Test with:
// pkcheck --action-id org.freedesktop.systemd1.manage-units \
// --process $$ --allow-user-interaction
//
// List all actions:
// pkaction | grep -E 'systemd|NetworkManager|UPower|udisks'
polkit.addRule(function(action, subject) {
// Only apply to users in the mcdbus group
if (!subject.isInGroup("mcdbus")) {
return polkit.Result.NOT_HANDLED;
}
// Read-only systemd operations
// ListUnits, GetUnit, GetUnitProperties -- these do not change state.
var systemdReadActions = [
"org.freedesktop.systemd1.manage-units",
"org.freedesktop.systemd1.reload-daemon"
];
// NetworkManager: allow reading connection and device info
var nmReadActions = [
"org.freedesktop.NetworkManager.network-control",
"org.freedesktop.NetworkManager.settings.modify.system"
];
// UPower: allow reading battery status
var upowerActions = [
"org.freedesktop.upower.get-history",
"org.freedesktop.upower.get-statistics"
];
// UDisks2: allow reading disk info but not mounting/formatting
var udisksReadActions = [
"org.freedesktop.udisks2.ata-smart-selftest"
];
// Allow read-only systemd access
// NOTE: systemd1.manage-units covers both read and write operations.
// If you only want list/status (no start/stop/restart), you need
// Layer 2 (D-Bus bus policy) to restrict which methods can be called.
// polkit alone cannot distinguish between "list units" and "stop unit"
// under the same action ID.
if (systemdReadActions.indexOf(action.id) >= 0) {
// Only allow for local, active sessions
if (subject.local && subject.active) {
return polkit.Result.YES;
}
}
// Allow NetworkManager reads for local sessions
if (nmReadActions.indexOf(action.id) >= 0) {
if (subject.local && subject.active) {
return polkit.Result.YES;
}
}
// Allow UPower reads
if (upowerActions.indexOf(action.id) >= 0) {
return polkit.Result.YES;
}
// Allow UDisks2 reads
if (udisksReadActions.indexOf(action.id) >= 0) {
if (subject.local && subject.active) {
return polkit.Result.YES;
}
}
// Everything else: fall through to system defaults.
// Do NOT return polkit.Result.NO here -- that would override rules
// with lower priority numbers. NOT_HANDLED lets the chain continue.
return polkit.Result.NOT_HANDLED;
});

91
docs/examples/mcdbus-proxy.sh Executable file
View File

@ -0,0 +1,91 @@
#!/usr/bin/env bash
#
# mcdbus-proxy.sh -- run mcdbus through a filtered D-Bus proxy
#
# Uses xdg-dbus-proxy to create a restricted session bus socket.
# mcdbus connects to the proxy socket and can only see/talk to
# the services listed below. Everything else is invisible.
#
# Install xdg-dbus-proxy:
# Arch: pacman -S xdg-dbus-proxy
# Debian/Ubuntu: apt install xdg-dbus-proxy
# Fedora: dnf install xdg-dbus-proxy
#
# Usage:
# ./mcdbus-proxy.sh
#
# Or with a custom mcdbus command:
# MCDBUS_CMD="uv run mcdbus" ./mcdbus-proxy.sh
#
# To integrate with systemd:
# ExecStart=/usr/local/bin/mcdbus-proxy.sh
set -euo pipefail
MCDBUS_CMD="${MCDBUS_CMD:-mcdbus}"
# Bail out if xdg-dbus-proxy is not installed
if ! command -v xdg-dbus-proxy &>/dev/null; then
echo "error: xdg-dbus-proxy not found. Install it first." >&2
exit 1
fi
# Need a session bus to proxy
if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]; then
echo "error: DBUS_SESSION_BUS_ADDRESS is not set." >&2
exit 1
fi
# Create a temporary directory for the proxy socket
PROXY_DIR="$(mktemp -d /tmp/mcdbus-proxy.XXXXXX)"
PROXY_SOCKET="${PROXY_DIR}/bus"
cleanup() {
if [ -n "${PROXY_PID:-}" ]; then
kill "$PROXY_PID" 2>/dev/null || true
wait "$PROXY_PID" 2>/dev/null || true
fi
rm -rf "$PROXY_DIR"
}
trap cleanup EXIT INT TERM
# Start the proxy.
#
# --talk= Full bidirectional access (method calls, property writes, signals).
# --see= Read-only visibility (appears in ListNames, introspectable, but
# method calls are blocked).
#
# The bus daemon itself is always accessible through the proxy.
#
# Adjust these to match your deployment needs. The set below covers
# the services that mcdbus's built-in shortcut tools use.
xdg-dbus-proxy "$DBUS_SESSION_BUS_ADDRESS" "$PROXY_SOCKET" \
--filter \
--talk=org.freedesktop.Notifications \
--talk=org.mpris.MediaPlayer2 \
--see=org.freedesktop.UPower \
--see=org.freedesktop.NetworkManager \
--see=org.bluez \
--see=org.freedesktop.systemd1 \
--talk=org.kde.KWin \
&
PROXY_PID=$!
# Wait for the proxy socket to appear (up to 5 seconds)
for i in $(seq 1 50); do
if [ -e "$PROXY_SOCKET" ]; then
break
fi
sleep 0.1
done
if [ ! -e "$PROXY_SOCKET" ]; then
echo "error: proxy socket did not appear at $PROXY_SOCKET" >&2
exit 1
fi
# Point mcdbus at the proxy socket and exec
export DBUS_SESSION_BUS_ADDRESS="unix:path=${PROXY_SOCKET}"
exec $MCDBUS_CMD

129
docs/examples/mcdbus.conf Normal file
View File

@ -0,0 +1,129 @@
<!DOCTYPE busconfig PUBLIC
"-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<!--
D-Bus bus policy for mcdbus.
Drop this file into /etc/dbus-1/system.d/ for the system bus
or /etc/dbus-1/session.d/ for the session bus, then reload:
sudo systemctl reload dbus.service
# or: sudo systemctl reload dbus-broker.service
Works with both dbus-daemon and dbus-broker.
The "user" attribute below should match the user running mcdbus.
If using DynamicUser=yes in the systemd unit, the user name is
the service name ("mcdbus"). If using a real account, change it.
-->
<busconfig>
<!-- Default deny for mcdbus user -->
<policy user="mcdbus">
<deny send_destination="*"/>
<!--
Always allow talking to the bus daemon itself.
Without this, ListNames and other discovery calls fail.
-->
<allow send_destination="org.freedesktop.DBus"/>
<!--
Desktop notifications (session bus).
Allows sending notifications but nothing else on the service.
-->
<allow send_destination="org.freedesktop.Notifications"
send_interface="org.freedesktop.Notifications"/>
<allow send_destination="org.freedesktop.Notifications"
send_interface="org.freedesktop.DBus.Introspectable"/>
<!--
MPRIS media players (session bus).
Wildcard matching is not supported in D-Bus policy files, so you
must add one block per player. These cover the common ones.
-->
<allow send_destination="org.mpris.MediaPlayer2.firefox"/>
<allow send_destination="org.mpris.MediaPlayer2.chromium"/>
<allow send_destination="org.mpris.MediaPlayer2.spotify"/>
<allow send_destination="org.mpris.MediaPlayer2.vlc"/>
<allow send_destination="org.mpris.MediaPlayer2.mpv"/>
<!--
UPower battery status (system bus, read-only).
Only allow property reads via the standard Properties interface.
-->
<allow send_destination="org.freedesktop.UPower"
send_interface="org.freedesktop.DBus.Properties"
send_member="Get"/>
<allow send_destination="org.freedesktop.UPower"
send_interface="org.freedesktop.DBus.Properties"
send_member="GetAll"/>
<allow send_destination="org.freedesktop.UPower"
send_interface="org.freedesktop.UPower"
send_member="EnumerateDevices"/>
<allow send_destination="org.freedesktop.UPower"
send_interface="org.freedesktop.DBus.Introspectable"/>
<!--
bluez Bluetooth (system bus, read-only).
GetManagedObjects returns the full device tree.
-->
<allow send_destination="org.bluez"
send_interface="org.freedesktop.DBus.ObjectManager"
send_member="GetManagedObjects"/>
<allow send_destination="org.bluez"
send_interface="org.freedesktop.DBus.Properties"
send_member="Get"/>
<allow send_destination="org.bluez"
send_interface="org.freedesktop.DBus.Properties"
send_member="GetAll"/>
<allow send_destination="org.bluez"
send_interface="org.freedesktop.DBus.Introspectable"/>
<!--
NetworkManager (system bus, read-only).
Allow property reads only. No method calls that change state.
-->
<allow send_destination="org.freedesktop.NetworkManager"
send_interface="org.freedesktop.DBus.Properties"
send_member="Get"/>
<allow send_destination="org.freedesktop.NetworkManager"
send_interface="org.freedesktop.DBus.Properties"
send_member="GetAll"/>
<allow send_destination="org.freedesktop.NetworkManager"
send_interface="org.freedesktop.DBus.Introspectable"/>
<!--
systemd (system bus).
ListUnits is read-only. If you also want to allow start/stop/restart,
add rules for org.freedesktop.systemd1.Manager.StartUnit etc.
Those operations also require polkit authorization (Layer 3).
-->
<allow send_destination="org.freedesktop.systemd1"
send_interface="org.freedesktop.systemd1.Manager"
send_member="ListUnits"/>
<allow send_destination="org.freedesktop.systemd1"
send_interface="org.freedesktop.DBus.Properties"
send_member="Get"/>
<allow send_destination="org.freedesktop.systemd1"
send_interface="org.freedesktop.DBus.Properties"
send_member="GetAll"/>
<allow send_destination="org.freedesktop.systemd1"
send_interface="org.freedesktop.DBus.Introspectable"/>
<!--
To add more services, follow this pattern:
<allow send_destination="org.example.MyService"
send_interface="org.example.MyService"
send_member="MyMethod"/>
For read-only access, only allow Properties.Get, Properties.GetAll,
and Introspectable.Introspect. Omit send_member to allow all methods
on an interface.
-->
</policy>
</busconfig>

View File

@ -0,0 +1,76 @@
[Unit]
Description=mcdbus D-Bus MCP server
Documentation=https://github.com/supported-systems/mcdbus
After=dbus.service
[Service]
Type=simple
ExecStart=/usr/bin/env mcdbus
# --- Identity ---
# Ephemeral user, no persistent UID, no home directory.
# If you need supplementary groups (e.g. for polkit rules), switch
# to User=mcdbus with a real system account instead.
DynamicUser=yes
# --- Filesystem ---
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectHostname=yes
ProtectClock=yes
ProtectProc=invisible
ProcSubset=pid
ReadWritePaths=
# --- Network ---
# D-Bus uses AF_UNIX only. Block everything else.
RestrictAddressFamilies=AF_UNIX
PrivateNetwork=no
# --- Capabilities ---
# Empty = drop all capabilities.
CapabilityBoundingSet=
AmbientCapabilities=
NoNewPrivileges=yes
# --- Syscalls ---
SystemCallFilter=@system-service
SystemCallArchitectures=native
SystemCallErrorNumber=EPERM
# --- Memory ---
MemoryDenyWriteExecute=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
RemoveIPC=yes
# --- Namespaces ---
RestrictNamespaces=yes
# --- Environment ---
Environment=MCDBUS_TIMEOUT=30
# Uncomment to require user confirmation for system bus calls:
# Environment=MCDBUS_REQUIRE_ELICITATION=1
# --- Logging ---
StandardOutput=journal
StandardError=journal
SyslogIdentifier=mcdbus
# --- Resource limits ---
MemoryMax=256M
TasksMax=64
# --- Restart ---
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target

332
docs/permissions.md Normal file
View File

@ -0,0 +1,332 @@
# D-Bus Permission Model
mcdbus does not implement its own access control. By design, it delegates
all security decisions to the operating system's existing D-Bus permission
stack. This means the same mechanisms that protect D-Bus from any other
client also protect it from mcdbus.
This guide covers four independent layers that can be composed to build
the level of isolation your deployment requires. They are all optional,
all independent, and all stack on top of each other.
## Layer 1: systemd Service Sandboxing
The single most effective thing you can do. Running mcdbus as a systemd
service gives you process-level isolation using Linux namespaces, seccomp,
and capability dropping -- all with a handful of declarative directives.
An example unit file is provided at [docs/examples/mcdbus.service](examples/mcdbus.service).
### What it does
The unit file uses `DynamicUser=yes`, which creates an ephemeral Unix user
for each service invocation. No home directory, no persistent UID, no
leftover state. Combined with filesystem and network restrictions, the
mcdbus process gets a minimal sandbox:
- **Filesystem:** `ProtectSystem=strict` makes `/usr`, `/boot`, and `/efi`
read-only. `ProtectHome=yes` hides `/home`, `/root`, and `/run/user`
entirely. `PrivateTmp=yes` gives mcdbus its own `/tmp` that is invisible
to other processes.
- **Network:** `RestrictAddressFamilies=AF_UNIX` limits socket creation to
Unix domain sockets. D-Bus only needs `AF_UNIX`, so this blocks any
TCP/UDP/raw socket activity. mcdbus cannot open network connections.
- **Capabilities:** `CapabilityBoundingSet=` (empty) drops every Linux
capability. mcdbus cannot change file ownership, load kernel modules,
bind to privileged ports, or do anything else that requires elevated
privileges.
- **Syscalls:** `SystemCallFilter=@system-service` restricts the process to
the set of syscalls that a typical well-behaved service needs. Things
like `mount`, `reboot`, and `kexec_load` are blocked.
- **Memory:** `MemoryDenyWriteExecute=yes` prevents mapping memory as both
writable and executable. Stops most classes of code injection.
### Deploying it
```bash
sudo cp docs/examples/mcdbus.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now mcdbus.service
```
### Testing it
systemd ships a built-in security auditor:
```bash
systemd-analyze security mcdbus.service
```
This produces a score from 0.0 (fully exposed) to 10.0 (fully locked down).
The example unit file should score above 7.0. The output also lists each
directive and its impact, so you can see exactly what is and is not restricted.
Check the journal for any sandbox-related denials:
```bash
journalctl -u mcdbus.service -f
```
### Environment variables
The unit file sets `MCDBUS_TIMEOUT=30` by default. You can override this
and add other mcdbus environment variables via a drop-in:
```bash
sudo systemctl edit mcdbus.service
```
```ini
[Service]
Environment=MCDBUS_TIMEOUT=60
Environment=MCDBUS_REQUIRE_ELICITATION=1
```
## Layer 2: D-Bus Bus Policy
D-Bus itself has a message filtering layer that operates at the bus daemon
(or bus broker) level. This is independent of what mcdbus does -- it
controls which messages any client can send or receive based on the
sender's Unix identity.
An example policy file is provided at [docs/examples/mcdbus.conf](examples/mcdbus.conf).
### How it works
D-Bus policy files are XML documents dropped into `/etc/dbus-1/system.d/`
(for the system bus) or `/etc/dbus-1/session.d/` (for the session bus).
Both `dbus-daemon` and `dbus-broker` read them.
The policy engine evaluates rules top to bottom. The last matching rule
wins. The general pattern is:
1. Default deny everything for the mcdbus user/group.
2. Selectively allow specific destinations, interfaces, and methods.
### Filter attributes
Each `<allow>` or `<deny>` element can filter on:
- `send_destination` -- the bus name being called (e.g. `org.freedesktop.Notifications`)
- `send_interface` -- the interface being invoked
- `send_member` -- the specific method name
- `send_type` -- message type (`method_call`, `signal`, etc.)
You can combine these. An `<allow>` with both `send_destination` and
`send_interface` only permits calls to that specific interface on that
specific service.
### Deploying it
For the system bus:
```bash
sudo cp docs/examples/mcdbus.conf /etc/dbus-1/system.d/
sudo systemctl reload dbus.service # dbus-daemon
# or: sudo systemctl reload dbus-broker.service
```
For the session bus, copy to `/etc/dbus-1/session.d/` instead. Session bus
policies apply to all users. If you only want to restrict a specific user,
use `user="mcdbus"` in the policy context.
### Testing it
Denied messages show up in the bus daemon's journal:
```bash
# dbus-broker
journalctl -u dbus-broker.service --since "5 min ago" | grep -i deny
# dbus-daemon
journalctl -u dbus.service --since "5 min ago" | grep -i deny
```
You can also use `dbus-monitor` to watch traffic in real time:
```bash
dbus-monitor --system "destination='org.freedesktop.UPower'"
```
## Layer 3: PolicyKit (polkit)
polkit provides per-action authorization. Where D-Bus bus policy controls
which messages can be *sent*, polkit controls whether a specific *action*
is *authorized* for a specific user. Many system services (systemd,
NetworkManager, UPower, udisks2) check polkit before performing privileged
operations, regardless of what the D-Bus bus policy says.
An example rules file is provided at [docs/examples/50-mcdbus.rules](examples/50-mcdbus.rules).
### How it works
polkit actions are identified by reverse-domain strings like
`org.freedesktop.systemd1.manage-units`. When a D-Bus service receives
a method call that requires authorization, it asks polkit whether the
calling user is allowed to perform that action.
polkit rules are JavaScript files in `/etc/polkit-1/rules.d/`. They are
evaluated in filename order (hence the `50-` prefix). Each rule function
receives an `action` and a `subject` and returns one of:
- `polkit.Result.YES` -- allow without prompting
- `polkit.Result.AUTH_ADMIN` -- require admin password
- `polkit.Result.NO` -- deny
### What the example does
The provided rules file checks if the calling user is in the `mcdbus`
Unix group. If so, it allows a curated set of read-only actions. Everything
else falls through to the system defaults (which typically require admin
authentication or deny outright).
### Creating the group
```bash
sudo groupadd mcdbus
sudo usermod -aG mcdbus $(whoami)
# Log out and back in for the group membership to take effect
```
If you are running mcdbus under systemd with `DynamicUser=yes`, the
dynamic user will not be in any supplementary groups by default. You can
either:
- Use `SupplementaryGroups=mcdbus` in the unit file, or
- Use `User=mcdbus` with a real system user instead of `DynamicUser=yes`
### Deploying it
```bash
sudo cp docs/examples/50-mcdbus.rules /etc/polkit-1/rules.d/
sudo systemctl restart polkit.service
```
### Testing it
List all registered polkit actions:
```bash
pkaction
```
Check whether a specific action would be allowed for a user:
```bash
pkcheck --action-id org.freedesktop.systemd1.manage-units \
--process $$ --allow-user-interaction
```
The `--allow-user-interaction` flag lets polkit prompt for a password if
the rules say `AUTH_ADMIN`. Without it, `AUTH_ADMIN` results are treated
as denial.
## Layer 4: xdg-dbus-proxy
`xdg-dbus-proxy` is a Flatpak utility that creates a filtered D-Bus
socket. You point it at the real bus socket, tell it which services to
expose, and it creates a new socket that only passes through matching
messages. The client (mcdbus) connects to the proxy socket and never
sees the rest of the bus.
An example wrapper script is provided at [docs/examples/mcdbus-proxy.sh](examples/mcdbus-proxy.sh).
### Policy levels
xdg-dbus-proxy supports three levels of access per service:
- `--see=NAME` -- the service appears in `ListNames` and can be introspected,
but method calls are blocked. Read-only visibility.
- `--talk=NAME` -- full bidirectional communication with the service.
Method calls, property reads/writes, and signals all pass through.
- `--own=NAME` -- the client can register (own) the given bus name.
mcdbus does not need this.
You can also filter at the interface and path level:
```bash
--talk=org.freedesktop.Notifications
--call=org.freedesktop.UPower=org.freedesktop.DBus.Properties.GetAll@/org/freedesktop/UPower/*
```
### Installing it
xdg-dbus-proxy ships with Flatpak:
```bash
# Arch
sudo pacman -S xdg-dbus-proxy
# Debian/Ubuntu
sudo apt install xdg-dbus-proxy
# Fedora
sudo dnf install xdg-dbus-proxy
```
### How the wrapper works
The script:
1. Creates a temporary directory for the proxy socket.
2. Starts `xdg-dbus-proxy` in the background, pointing at `$DBUS_SESSION_BUS_ADDRESS`.
3. Waits for the proxy socket to appear.
4. Sets `DBUS_SESSION_BUS_ADDRESS` to the proxy socket.
5. `exec`s mcdbus, so mcdbus runs with PID 1 semantics and gets signals correctly.
6. Cleans up the proxy socket and process on exit via a trap.
### Deploying it
```bash
chmod +x docs/examples/mcdbus-proxy.sh
./docs/examples/mcdbus-proxy.sh
```
Or integrate it into the systemd unit file:
```ini
[Service]
ExecStart=/usr/local/bin/mcdbus-proxy.sh
```
## Choosing Layers
All four layers are independent. You can use any combination.
**Layer 1 (systemd sandboxing)** is always recommended. It costs nothing
to deploy and provides broad process-level isolation. Even if mcdbus or
dbus-fast had a vulnerability, the sandbox limits what an attacker could
do with it.
**Layer 2 (D-Bus bus policy)** is useful when you want to restrict which
services mcdbus can talk to at the bus level. This is the coarsest filter
but also the most reliable -- it operates inside the bus daemon itself,
so there is no way for the client to bypass it.
**Layer 3 (polkit)** matters when the services mcdbus talks to perform
their own polkit authorization checks. Most system services do this.
If you want mcdbus to be able to read battery status but not manage
systemd units, polkit rules are where you express that.
**Layer 4 (xdg-dbus-proxy)** is the most granular option. It is useful
for session bus filtering (where D-Bus bus policy is less commonly
configured) and for deployments where you want per-interface or per-path
control. The trade-off is that it adds a proxy process.
For most deployments, Layer 1 alone is sufficient. For high-security
environments or shared systems, stacking Layers 1 + 2 + 3 provides
defense in depth. Layer 4 is there when you need fine-grained session
bus filtering that the other layers cannot express.
### Quick reference
| Layer | Scope | Granularity | Bus |
|-------|-------|-------------|-----|
| systemd | Process isolation | Filesystem, network, syscalls | Both |
| D-Bus policy | Message filtering | Service, interface, method | Both |
| polkit | Action authorization | Per-action, per-user/group | System (mostly) |
| xdg-dbus-proxy | Socket filtering | Service, interface, path | Session (mostly) |

View File

@ -2,6 +2,7 @@
import asyncio import asyncio
import json import json
import os
import re import re
import sys import sys
from typing import Any from typing import Any
@ -11,7 +12,7 @@ from dbus_fast.aio import MessageBus
from dbus_fast.unpack import unpack_variants from dbus_fast.unpack import unpack_variants
from fastmcp import Context from fastmcp import Context
DBUS_CALL_TIMEOUT = float(30) # seconds; override via env MCDBUS_TIMEOUT DBUS_CALL_TIMEOUT = float(os.environ.get("MCDBUS_TIMEOUT", "30"))
# D-Bus spec: https://dbus.freedesktop.org/doc/dbus-specification.html # D-Bus spec: https://dbus.freedesktop.org/doc/dbus-specification.html
_DBUS_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*(\.[A-Za-z_][A-Za-z0-9_]*)+$") _DBUS_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*(\.[A-Za-z_][A-Za-z0-9_]*)+$")
@ -36,6 +37,24 @@ def validate_object_path(path: str) -> None:
) )
_DBUS_SIG_CHARS = re.compile(r"^[ybnqiuxtdsogav(){}\s]*$")
MAX_SIGNATURE_LEN = 255
def validate_signature(sig: str) -> None:
"""Validate a D-Bus type signature string."""
if len(sig) > MAX_SIGNATURE_LEN:
raise ValueError(f"Signature too long ({len(sig)} > {MAX_SIGNATURE_LEN})")
if not _DBUS_SIG_CHARS.match(sig):
raise ValueError(f"Invalid D-Bus signature characters: {sig!r}")
from dbus_fast.signature import SignatureTree
try:
SignatureTree(sig)
except Exception as exc:
raise ValueError(f"Malformed D-Bus signature: {exc}") from exc
class BusManager: class BusManager:
"""Lazy-connecting, caching manager for session and system D-Bus connections.""" """Lazy-connecting, caching manager for session and system D-Bus connections."""
@ -122,6 +141,7 @@ def deserialize_args(args_json: str, signature: str) -> list[Any]:
if not signature: if not signature:
return args return args
validate_signature(signature)
tree = SignatureTree(signature) tree = SignatureTree(signature)
expected = len(tree.types) expected = len(tree.types)
if len(args) != expected: if len(args) != expected:
@ -146,6 +166,7 @@ def _auto_wrap_variant(val: Any, variant_cls: type) -> Any:
""" """
# Explicit variant specification # Explicit variant specification
if isinstance(val, dict) and "signature" in val and "value" in val and len(val) == 2: if isinstance(val, dict) and "signature" in val and "value" in val and len(val) == 2:
validate_signature(val["signature"])
return variant_cls(val["signature"], val["value"]) return variant_cls(val["signature"], val["value"])
# Auto-infer from Python type (bool must come before int — bool is a subclass of int) # Auto-infer from Python type (bool must come before int — bool is a subclass of int)
if isinstance(val, bool): if isinstance(val, bool):
@ -190,7 +211,9 @@ async def call_bus_method(
f"{destination} {interface}.{member} at {path}" f"{destination} {interface}.{member} at {path}"
) from None ) from None
if reply.message_type == MessageType.ERROR: if reply.message_type == MessageType.ERROR:
raise RuntimeError(f"D-Bus error: {reply.error_name}: {reply.body}") print(f"mcdbus: D-Bus error: {reply.error_name}: {reply.body}", file=sys.stderr)
detail = reply.body[0] if reply.body and isinstance(reply.body[0], str) else ""
raise RuntimeError(f"D-Bus error ({reply.error_name}): {detail}")
if reply.body: if reply.body:
return serialize_variant(reply.body) return serialize_variant(reply.body)
return None return None

View File

@ -1,14 +1,17 @@
"""Discovery tools — list services, introspect objects, walk object trees.""" """Discovery tools — list services, introspect objects, walk object trees."""
import time
from collections import deque from collections import deque
from fastmcp import Context from fastmcp import Context
from fastmcp.exceptions import ToolError
from mcdbus._bus import call_bus_method, get_mgr, validate_bus_name, validate_object_path from mcdbus._bus import call_bus_method, get_mgr, validate_bus_name, validate_object_path
from mcdbus._state import mcp from mcdbus._state import mcp
MAX_TREE_DEPTH = 20 MAX_TREE_DEPTH = 20
MAX_TREE_NODES = 500 MAX_TREE_NODES = 500
TOTAL_WALK_TIMEOUT = 60 # seconds — monotonic clock deadline for tree walks
@mcp.tool() @mcp.tool()
@ -27,6 +30,7 @@ async def list_services(
await ctx.info(f"Connecting to {bus} bus...") await ctx.info(f"Connecting to {bus} bus...")
connection = await mgr.get_bus(bus) connection = await mgr.get_bus(bus)
try:
result = await call_bus_method( result = await call_bus_method(
connection, connection,
destination="org.freedesktop.DBus", destination="org.freedesktop.DBus",
@ -34,6 +38,8 @@ async def list_services(
interface="org.freedesktop.DBus", interface="org.freedesktop.DBus",
member="ListNames", member="ListNames",
) )
except (RuntimeError, TimeoutError) as exc:
raise ToolError(f"Error listing services on {bus} bus: {exc}") from exc
names = sorted(result[0]) if result else [] names = sorted(result[0]) if result else []
if not include_unique: if not include_unique:
@ -158,8 +164,14 @@ async def list_objects(
visited: set[str] = set() visited: set[str] = set()
queue: deque[tuple[str, int]] = deque([(root_path, 0)]) queue: deque[tuple[str, int]] = deque([(root_path, 0)])
truncated = False truncated = False
deadline = time.monotonic() + TOTAL_WALK_TIMEOUT
while queue: while queue:
if time.monotonic() > deadline:
truncated = True
await ctx.warning(f"Tree walk timed out after {TOTAL_WALK_TIMEOUT}s")
break
path, depth = queue.popleft() path, depth = queue.popleft()
if path in visited: if path in visited:

View File

@ -1,10 +1,16 @@
"""Interaction tools — method calls, property get/set.""" """Interaction tools — method calls, property get/set."""
import json import json
import os
import sys import sys
from dbus_fast.signature import Variant from dbus_fast.signature import Variant
from fastmcp import Context from fastmcp import Context
from fastmcp.exceptions import ToolError
from fastmcp.server.elicitation import (
AcceptedElicitation,
CancelledElicitation,
)
from mcdbus._bus import ( from mcdbus._bus import (
call_bus_method, call_bus_method,
@ -12,10 +18,32 @@ from mcdbus._bus import (
get_mgr, get_mgr,
validate_bus_name, validate_bus_name,
validate_object_path, validate_object_path,
validate_signature,
) )
from mcdbus._state import mcp from mcdbus._state import mcp
async def _confirm_or_abort(ctx: Context, message: str, operation: str) -> None:
"""Elicit user confirmation; raise ToolError or return silently to proceed."""
result = await ctx.elicit(
message,
{
"confirm": {"title": "Yes, proceed"},
"deny": {"title": "No, cancel"},
},
)
if isinstance(result, AcceptedElicitation) and result.data == "confirm":
return
if isinstance(result, CancelledElicitation):
# Client doesn't support elicitation
if os.environ.get("MCDBUS_REQUIRE_ELICITATION"):
raise ToolError("Elicitation required but client does not support it")
print(f"mcdbus: elicitation unavailable, proceeding with {operation}", file=sys.stderr)
return
# User explicitly declined
raise ToolError("Operation cancelled by user.")
@mcp.tool() @mcp.tool()
async def call_method( async def call_method(
bus: str, bus: str,
@ -48,10 +76,19 @@ async def call_method(
try: try:
body = deserialize_args(args, signature) body = deserialize_args(args, signature)
except (ValueError, json.JSONDecodeError) as exc: except (ValueError, json.JSONDecodeError) as exc:
return f"Error parsing arguments: {exc}" raise ToolError(f"Error parsing arguments: {exc}") from exc
# Log system bus writes to stderr for audit trail # Elicit confirmation for system bus calls (session bus = user-scope, no prompt)
if bus == "system": if bus == "system":
await _confirm_or_abort(
ctx,
f"Confirm system bus method call:\n"
f" Service: {service}\n"
f" Method: {interface}.{method}\n"
f" Path: {object_path}\n"
f" Args: {args}",
f"{interface}.{method}",
)
print( print(
f"mcdbus: system bus call {service} {interface}.{method} " f"mcdbus: system bus call {service} {interface}.{method} "
f"path={object_path} sig={signature!r}", f"path={object_path} sig={signature!r}",
@ -71,8 +108,7 @@ async def call_method(
body=body, body=body,
) )
except (RuntimeError, TimeoutError) as exc: except (RuntimeError, TimeoutError) as exc:
await ctx.error(str(exc)) raise ToolError(str(exc)) from exc
return f"Error: {exc}"
if result is None: if result is None:
return "Method returned no data (void)." return "Method returned no data (void)."
@ -120,7 +156,7 @@ async def get_property(
body=[interface, property_name], body=[interface, property_name],
) )
except (RuntimeError, TimeoutError) as exc: except (RuntimeError, TimeoutError) as exc:
return f"Error reading {interface}.{property_name}: {exc}" raise ToolError(f"Error reading {interface}.{property_name}: {exc}") from exc
if result: if result:
value = result[0] value = result[0]
@ -160,10 +196,22 @@ async def set_property(
try: try:
parsed_value = json.loads(value) parsed_value = json.loads(value)
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
return f"Error parsing value JSON: {exc}" raise ToolError(f"Error parsing value JSON: {exc}") from exc
validate_signature(signature)
variant = Variant(signature, parsed_value) variant = Variant(signature, parsed_value)
# Elicit confirmation for all property mutations (always state-changing)
await _confirm_or_abort(
ctx,
f"Confirm property change:\n"
f" Service: {service}\n"
f" Property: {interface}.{property_name}\n"
f" New value: {value}\n"
f" Bus: {bus}",
f"set {interface}.{property_name}",
)
# Audit log for all set_property calls # Audit log for all set_property calls
print( print(
f"mcdbus: set_property {bus} {service} {interface}.{property_name} " f"mcdbus: set_property {bus} {service} {interface}.{property_name} "
@ -182,7 +230,7 @@ async def set_property(
body=[interface, property_name, variant], body=[interface, property_name, variant],
) )
except (RuntimeError, TimeoutError) as exc: except (RuntimeError, TimeoutError) as exc:
return f"Error setting {interface}.{property_name}: {exc}" raise ToolError(f"Error setting {interface}.{property_name}: {exc}") from exc
return f"Set {interface}.{property_name} = {value}" return f"Set {interface}.{property_name} = {value}"
@ -221,7 +269,7 @@ async def get_all_properties(
body=[interface], body=[interface],
) )
except (RuntimeError, TimeoutError) as exc: except (RuntimeError, TimeoutError) as exc:
return f"Error reading properties on {interface}: {exc}" raise ToolError(f"Error reading properties on {interface}: {exc}") from exc
if not result or not result[0]: if not result or not result[0]:
return "No properties found." return "No properties found."

View File

@ -3,18 +3,18 @@
from collections import deque from collections import deque
from urllib.parse import unquote from urllib.parse import unquote
from mcdbus._bus import BusManager, call_bus_method from mcdbus._bus import BusManager, call_bus_method, validate_bus_name, validate_object_path
from mcdbus._state import mcp from mcdbus._state import mcp
MAX_RESOURCE_NODES = 200 MAX_RESOURCE_NODES = 200
_resource_mgr = BusManager()
@mcp.resource("dbus://{bus}/services") @mcp.resource("dbus://{bus}/services")
async def bus_services(bus: str) -> str: async def bus_services(bus: str) -> str:
"""Live list of well-known service names on a D-Bus bus.""" """Live list of well-known service names on a D-Bus bus."""
mgr = BusManager() connection = await _resource_mgr.get_bus(bus)
try:
connection = await mgr.get_bus(bus)
result = await call_bus_method( result = await call_bus_method(
connection, connection,
destination="org.freedesktop.DBus", destination="org.freedesktop.DBus",
@ -24,16 +24,13 @@ async def bus_services(bus: str) -> str:
) )
names = sorted(n for n in (result[0] if result else []) if not n.startswith(":")) names = sorted(n for n in (result[0] if result else []) if not n.startswith(":"))
return "\n".join(names) return "\n".join(names)
finally:
await mgr.disconnect_all()
@mcp.resource("dbus://{bus}/{service}/objects") @mcp.resource("dbus://{bus}/{service}/objects")
async def service_objects(bus: str, service: str) -> str: async def service_objects(bus: str, service: str) -> str:
"""Object tree for a D-Bus service (bounded BFS walk).""" """Object tree for a D-Bus service (bounded BFS walk)."""
mgr = BusManager() validate_bus_name(service)
try: connection = await _resource_mgr.get_bus(bus)
connection = await mgr.get_bus(bus)
paths: list[str] = [] paths: list[str] = []
visited: set[str] = set() visited: set[str] = set()
queue: deque[tuple[str, int]] = deque([("/", 0)]) queue: deque[tuple[str, int]] = deque([("/", 0)])
@ -56,8 +53,6 @@ async def service_objects(bus: str, service: str) -> str:
queue.append((child_path, depth + 1)) queue.append((child_path, depth + 1))
return "\n".join(sorted(paths)) return "\n".join(sorted(paths))
finally:
await mgr.disconnect_all()
@mcp.resource("dbus://{bus}/{service}/{path}/interfaces") @mcp.resource("dbus://{bus}/{service}/{path}/interfaces")
@ -67,10 +62,9 @@ async def object_interfaces(bus: str, service: str, path: str) -> str:
if not decoded_path.startswith("/"): if not decoded_path.startswith("/"):
decoded_path = "/" + decoded_path decoded_path = "/" + decoded_path
mgr = BusManager() validate_bus_name(service)
try: validate_object_path(decoded_path)
connection = await mgr.get_bus(bus)
connection = await _resource_mgr.get_bus(bus)
node = await connection.introspect(service, decoded_path) node = await connection.introspect(service, decoded_path)
return "\n".join(i.name for i in node.interfaces) return "\n".join(i.name for i in node.interfaces)
finally:
await mgr.disconnect_all()

View File

@ -3,6 +3,7 @@
import fnmatch import fnmatch
from fastmcp import Context from fastmcp import Context
from fastmcp.exceptions import ToolError
from mcdbus._bus import call_bus_method, get_mgr from mcdbus._bus import call_bus_method, get_mgr
from mcdbus._state import mcp from mcdbus._state import mcp
@ -50,7 +51,7 @@ async def send_notification(
], ],
) )
except (RuntimeError, TimeoutError) as exc: except (RuntimeError, TimeoutError) as exc:
return f"Error sending notification: {exc}" raise ToolError(f"Error sending notification: {exc}") from exc
nid = result[0] if result else "unknown" nid = result[0] if result else "unknown"
return f"Notification sent (id: {nid})" return f"Notification sent (id: {nid})"
@ -84,7 +85,7 @@ async def list_systemd_units(
member="ListUnits", member="ListUnits",
) )
except (RuntimeError, TimeoutError) as exc: except (RuntimeError, TimeoutError) as exc:
return f"Error listing units: {exc}" raise ToolError(f"Error listing units: {exc}") from exc
if not result or not result[0]: if not result or not result[0]:
return "No units found." return "No units found."
@ -168,7 +169,7 @@ async def media_player_control(
member=dbus_method, member=dbus_method,
) )
except (RuntimeError, TimeoutError) as exc: except (RuntimeError, TimeoutError) as exc:
return f"Error controlling player: {exc}" raise ToolError(f"Error controlling player: {exc}") from exc
# Read current playback status # Read current playback status
try: try:
@ -245,7 +246,7 @@ async def network_status(ctx: Context) -> str:
body=[_NM_IFACE], body=[_NM_IFACE],
) )
except (RuntimeError, TimeoutError) as exc: except (RuntimeError, TimeoutError) as exc:
return f"NetworkManager not available: {exc}" raise ToolError(f"NetworkManager not available: {exc}") from exc
if not state_result or not state_result[0]: if not state_result or not state_result[0]:
return "NetworkManager returned no properties." return "NetworkManager returned no properties."
@ -318,7 +319,7 @@ async def battery_status(ctx: Context) -> str:
member="EnumerateDevices", member="EnumerateDevices",
) )
except (RuntimeError, TimeoutError) as exc: except (RuntimeError, TimeoutError) as exc:
return f"UPower not available: {exc}" raise ToolError(f"UPower not available: {exc}") from exc
if not devices_result or not devices_result[0]: if not devices_result or not devices_result[0]:
return "No power devices found." return "No power devices found."
@ -403,7 +404,7 @@ async def bluetooth_devices(ctx: Context) -> str:
member="GetManagedObjects", member="GetManagedObjects",
) )
except (RuntimeError, TimeoutError) as exc: except (RuntimeError, TimeoutError) as exc:
return f"bluez not available: {exc}" raise ToolError(f"bluez not available: {exc}") from exc
if not objects_result or not objects_result[0]: if not objects_result or not objects_result[0]:
return "No Bluetooth objects found." return "No Bluetooth objects found."
@ -468,7 +469,7 @@ async def kwin_windows(ctx: Context) -> str:
body=[""], body=[""],
) )
except (RuntimeError, TimeoutError) as exc: except (RuntimeError, TimeoutError) as exc:
return f"KWin not available: {exc}" raise ToolError(f"KWin not available: {exc}") from exc
if not result or not result[0]: if not result or not result[0]:
return "No windows found (KWin WindowsRunner returned empty)." return "No windows found (KWin WindowsRunner returned empty)."

View File

@ -18,6 +18,13 @@ def _reset_mcp_singleton():
dead loop and test 2 would crash. Re-creating these objects keeps every dead loop and test 2 would crash. Re-creating these objects keeps every
test isolated. test isolated.
""" """
_PRIVATE_ATTRS = ("_started", "_lifespan_result", "_lifespan_result_set")
for attr in _PRIVATE_ATTRS:
if not hasattr(mcp, attr):
raise AttributeError(
f"FastMCP removed {attr!r} — fixture needs update "
f"(fastmcp version may have changed)"
)
mcp._started = asyncio.Event() mcp._started = asyncio.Event()
mcp._lifespan_result = None mcp._lifespan_result = None
mcp._lifespan_result_set = False mcp._lifespan_result_set = False

83
tests/test_elicitation.py Normal file
View File

@ -0,0 +1,83 @@
"""Tests for elicitation (human-in-the-loop confirmation) on mutation tools."""
import os
from unittest.mock import AsyncMock, patch
import pytest
from fastmcp.exceptions import ToolError
from fastmcp.server.elicitation import (
AcceptedElicitation,
CancelledElicitation,
DeclinedElicitation,
)
from mcdbus._interaction import _confirm_or_abort
class TestConfirmOrAbort:
"""Unit tests for the _confirm_or_abort helper."""
async def test_accepted_proceeds(self):
ctx = AsyncMock()
ctx.elicit = AsyncMock(return_value=AcceptedElicitation(data="confirm"))
# Should return without raising
await _confirm_or_abort(ctx, "Test message", "test_op")
async def test_declined_raises_tool_error(self):
ctx = AsyncMock()
ctx.elicit = AsyncMock(return_value=DeclinedElicitation())
with pytest.raises(ToolError, match="cancelled by user"):
await _confirm_or_abort(ctx, "Test message", "test_op")
async def test_accepted_wrong_key_raises(self):
ctx = AsyncMock()
ctx.elicit = AsyncMock(return_value=AcceptedElicitation(data="deny"))
with pytest.raises(ToolError, match="cancelled by user"):
await _confirm_or_abort(ctx, "Test message", "test_op")
async def test_cancelled_proceeds_by_default(self):
ctx = AsyncMock()
ctx.elicit = AsyncMock(return_value=CancelledElicitation())
# Client doesn't support elicitation — should proceed silently
await _confirm_or_abort(ctx, "Test message", "test_op")
async def test_cancelled_hard_fails_when_required(self):
ctx = AsyncMock()
ctx.elicit = AsyncMock(return_value=CancelledElicitation())
with patch.dict(os.environ, {"MCDBUS_REQUIRE_ELICITATION": "1"}):
with pytest.raises(ToolError, match="Elicitation required"):
await _confirm_or_abort(ctx, "Test message", "test_op")
class TestCallMethodElicitation:
"""Verify call_method triggers elicitation on system bus but not session bus."""
async def test_session_bus_no_elicitation(self, client):
"""Session bus calls should not trigger elicitation."""
# Ping on session bus — should succeed without any elicitation
result = await client.call_tool("call_method", {
"bus": "session",
"service": "org.freedesktop.DBus",
"object_path": "/org/freedesktop/DBus",
"interface": "org.freedesktop.DBus.Peer",
"method": "Ping",
})
text = result.content[0].text
assert "void" in text.lower()
class TestSetPropertyElicitation:
"""Verify set_property always triggers elicitation."""
async def test_invalid_property_still_validates_first(self, client):
"""Validation errors should fire before elicitation."""
with pytest.raises(ToolError, match="Invalid D-Bus"):
await client.call_tool("set_property", {
"bus": "session",
"service": "not-valid",
"object_path": "/foo",
"interface": "org.test.Iface",
"property_name": "Foo",
"value": '"bar"',
"signature": "s",
})

View File

@ -86,16 +86,15 @@ class TestCallMethod:
assert isinstance(bus_id, str) assert isinstance(bus_id, str)
assert len(bus_id) > 10 assert len(bus_id) > 10
async def test_bad_method_returns_error(self, client: Client): async def test_bad_method_raises_tool_error(self, client: Client):
result = await client.call_tool("call_method", { with pytest.raises(ToolError, match="D-Bus error"):
await client.call_tool("call_method", {
"bus": "session", "bus": "session",
"service": "org.freedesktop.DBus", "service": "org.freedesktop.DBus",
"object_path": "/org/freedesktop/DBus", "object_path": "/org/freedesktop/DBus",
"interface": "org.freedesktop.DBus", "interface": "org.freedesktop.DBus",
"method": "NoSuchMethod", "method": "NoSuchMethod",
}) })
text = result.content[0].text
assert "Error" in text or "error" in text
class TestGetProperty: class TestGetProperty:
@ -127,6 +126,48 @@ class TestGetAllProperties:
assert "Features" in text assert "Features" in text
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
class TestSignatureValidation:
async def test_invalid_signature_rejected(self, client: Client):
with pytest.raises(ToolError, match="Invalid D-Bus signature"):
await client.call_tool("call_method", {
"bus": "session",
"service": "org.freedesktop.DBus",
"object_path": "/org/freedesktop/DBus",
"interface": "org.freedesktop.DBus",
"method": "GetId",
"signature": "INVALID!@#",
"args": '["x"]',
})
async def test_signature_too_long(self, client: Client):
with pytest.raises(ToolError, match="Signature too long"):
await client.call_tool("call_method", {
"bus": "session",
"service": "org.freedesktop.DBus",
"object_path": "/org/freedesktop/DBus",
"interface": "org.freedesktop.DBus",
"method": "GetId",
"signature": "s" * 256,
"args": '["x"]',
})
async def test_malformed_signature_rejected(self, client: Client):
with pytest.raises(ToolError, match="Malformed D-Bus signature"):
await client.call_tool("call_method", {
"bus": "session",
"service": "org.freedesktop.DBus",
"object_path": "/org/freedesktop/DBus",
"interface": "org.freedesktop.DBus",
"method": "GetId",
"signature": "((",
"args": '["x"]',
})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Shortcut tools — existing # Shortcut tools — existing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -179,60 +220,73 @@ class TestMediaPlayerControl:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Shortcut tools — new # Shortcut tools — new (service may or may not be available)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestNetworkStatus: class TestNetworkStatus:
async def test_returns_status(self, client: Client): async def test_returns_status(self, client: Client):
try:
result = await client.call_tool("network_status", {}) result = await client.call_tool("network_status", {})
except ToolError:
return # NetworkManager not available on this system
text = result.content[0].text text = result.content[0].text
assert "Network Status" in text or "NetworkManager not available" in text assert "Network Status" in text
async def test_has_state(self, client: Client): async def test_has_state(self, client: Client):
try:
result = await client.call_tool("network_status", {}) result = await client.call_tool("network_status", {})
except ToolError:
return # NetworkManager not available
text = result.content[0].text text = result.content[0].text
if "not available" not in text:
assert "State:" in text assert "State:" in text
class TestBatteryStatus: class TestBatteryStatus:
async def test_returns_result(self, client: Client): async def test_returns_result(self, client: Client):
try:
result = await client.call_tool("battery_status", {}) result = await client.call_tool("battery_status", {})
except ToolError:
return # UPower not available on this system
text = result.content[0].text text = result.content[0].text
# Desktop may not have battery — both outcomes are valid
assert ( assert (
"Battery Status" in text "Battery Status" in text
or "No batteries found" in text or "No batteries found" in text
or "No power devices" in text or "No power devices" in text
or "UPower not available" in text
) )
class TestBluetoothDevices: class TestBluetoothDevices:
async def test_returns_result(self, client: Client): async def test_returns_result(self, client: Client):
try:
result = await client.call_tool("bluetooth_devices", {}) result = await client.call_tool("bluetooth_devices", {})
except ToolError:
return # bluez not available on this system
text = result.content[0].text text = result.content[0].text
assert ( assert (
"Bluetooth Devices" in text "Bluetooth Devices" in text
or "No Bluetooth devices" in text or "No Bluetooth devices" in text
or "No Bluetooth objects" in text or "No Bluetooth objects" in text
or "bluez not available" in text
) )
class TestKwinWindows: class TestKwinWindows:
async def test_returns_windows(self, client: Client): async def test_returns_windows(self, client: Client):
try:
result = await client.call_tool("kwin_windows", {}) result = await client.call_tool("kwin_windows", {})
except ToolError:
return # KWin not available on this system
text = result.content[0].text text = result.content[0].text
assert ( assert (
"KWin Windows" in text "KWin Windows" in text
or "KWin not available" in text
or "No windows found" in text or "No windows found" in text
or "No open windows" in text or "No open windows" in text
) )
async def test_has_table(self, client: Client): async def test_has_table(self, client: Client):
try:
result = await client.call_tool("kwin_windows", {}) result = await client.call_tool("kwin_windows", {})
except ToolError:
return # KWin not available
text = result.content[0].text text = result.content[0].text
if "KWin Windows" in text: if "KWin Windows" in text:
assert "| Window |" in text assert "| Window |" in text