Add tool-level tests via Client(mcp) and 4 new shortcut tools

Fix get_mgr() to use ctx.lifespan_context (works in both request
and standalone Client mode). Add autouse fixture to reset
event-loop-bound singleton state per test, preventing cross-test
asyncio.Event contamination.

21 new tests exercise all 14 tools through FastMCP's in-memory
transport. New shortcuts: network_status (NetworkManager),
battery_status (UPower), bluetooth_devices (bluez),
kwin_windows (KRunner WindowsRunner). 53 tests pass.
This commit is contained in:
Ryan Malloy 2026-03-05 23:12:28 -07:00
parent fa41ffbf80
commit 326be8d52d
4 changed files with 579 additions and 1 deletions

View File

@ -76,7 +76,7 @@ class BusManager:
def get_mgr(ctx: Context) -> "BusManager":
"""Extract the BusManager from a FastMCP tool context."""
return ctx.request_context.lifespan_context
return ctx.lifespan_context
def serialize_variant(value: Any) -> Any:

View File

@ -7,6 +7,9 @@ from fastmcp import Context
from mcdbus._bus import call_bus_method, get_mgr
from mcdbus._state import mcp
# ---------------------------------------------------------------------------
# Notifications
# ---------------------------------------------------------------------------
@mcp.tool()
async def send_notification(
@ -53,6 +56,10 @@ async def send_notification(
return f"Notification sent (id: {nid})"
# ---------------------------------------------------------------------------
# Systemd
# ---------------------------------------------------------------------------
@mcp.tool()
async def list_systemd_units(
ctx: Context,
@ -98,6 +105,10 @@ async def list_systemd_units(
return "\n".join(lines)
# ---------------------------------------------------------------------------
# MPRIS Media Player
# ---------------------------------------------------------------------------
@mcp.tool()
async def media_player_control(
action: str,
@ -194,3 +205,306 @@ async def media_player_control(
title, artist = "Unknown", "Unknown"
return f"Player: {player}\nAction: {action}\nStatus: {status}\nNow: {artist}{title}"
# ---------------------------------------------------------------------------
# NetworkManager
# ---------------------------------------------------------------------------
_NM_DEST = "org.freedesktop.NetworkManager"
_NM_PATH = "/org/freedesktop/NetworkManager"
_NM_IFACE = "org.freedesktop.NetworkManager"
_NM_STATE_MAP = {
0: "Unknown", 10: "Asleep", 20: "Disconnected",
30: "Disconnecting", 40: "Connecting",
50: "Connected (local)", 60: "Connected (site)",
70: "Connected (global)",
}
_NM_CONN_STATE_MAP = {
0: "Unknown", 1: "Activating", 2: "Activated",
3: "Deactivating", 4: "Deactivated",
}
@mcp.tool()
async def network_status(ctx: Context) -> str:
"""Show NetworkManager connection status and active connections."""
mgr = get_mgr(ctx)
connection = await mgr.get_bus("system")
try:
state_result = await call_bus_method(
connection,
destination=_NM_DEST,
path=_NM_PATH,
interface="org.freedesktop.DBus.Properties",
member="GetAll",
signature="s",
body=[_NM_IFACE],
)
except (RuntimeError, TimeoutError) as exc:
return f"NetworkManager not available: {exc}"
if not state_result or not state_result[0]:
return "NetworkManager returned no properties."
props = state_result[0]
state = _NM_STATE_MAP.get(props.get("State", 0), "Unknown")
wireless = "enabled" if props.get("WirelessEnabled") else "disabled"
lines = [
"## Network Status\n",
f"- **State:** {state}",
f"- **Wireless:** {wireless}",
]
# Active connections
active_paths = props.get("ActiveConnections", [])
if active_paths:
lines.append("\n| Connection | Type | State |")
lines.append("|------------|------|-------|")
for ac_path in active_paths:
try:
ac_result = await call_bus_method(
connection,
destination=_NM_DEST,
path=ac_path,
interface="org.freedesktop.DBus.Properties",
member="GetAll",
signature="s",
body=["org.freedesktop.NetworkManager.Connection.Active"],
)
if ac_result and ac_result[0]:
ac = ac_result[0]
name = ac.get("Id", "?")
conn_type = ac.get("Type", "?")
conn_state = _NM_CONN_STATE_MAP.get(ac.get("State", 0), "?")
lines.append(f"| {name} | {conn_type} | {conn_state} |")
except (RuntimeError, TimeoutError):
continue
return "\n".join(lines)
# ---------------------------------------------------------------------------
# UPower (Battery)
# ---------------------------------------------------------------------------
_UPOWER_DEST = "org.freedesktop.UPower"
_UPOWER_PATH = "/org/freedesktop/UPower"
_UPOWER_STATE_MAP = {
0: "Unknown", 1: "Charging", 2: "Discharging",
3: "Empty", 4: "Fully charged", 5: "Pending charge",
6: "Pending discharge",
}
@mcp.tool()
async def battery_status(ctx: Context) -> str:
"""Show battery status from UPower (percentage, charging state, time remaining)."""
mgr = get_mgr(ctx)
connection = await mgr.get_bus("system")
try:
devices_result = await call_bus_method(
connection,
destination=_UPOWER_DEST,
path=_UPOWER_PATH,
interface=_UPOWER_DEST,
member="EnumerateDevices",
)
except (RuntimeError, TimeoutError) as exc:
return f"UPower not available: {exc}"
if not devices_result or not devices_result[0]:
return "No power devices found."
device_paths = devices_result[0]
batteries = []
for dev_path in device_paths:
try:
dev_result = await call_bus_method(
connection,
destination=_UPOWER_DEST,
path=dev_path,
interface="org.freedesktop.DBus.Properties",
member="GetAll",
signature="s",
body=["org.freedesktop.UPower.Device"],
)
except (RuntimeError, TimeoutError):
continue
if not dev_result or not dev_result[0]:
continue
props = dev_result[0]
# Type 2 = Battery
if props.get("Type") != 2:
continue
pct = props.get("Percentage", 0)
state = _UPOWER_STATE_MAP.get(props.get("State", 0), "Unknown")
tte = props.get("TimeToEmpty", 0)
ttf = props.get("TimeToFull", 0)
rate = props.get("EnergyRate", 0)
model = props.get("Model", "Battery")
time_str = ""
if tte > 0:
h, m = divmod(int(tte) // 60, 60)
time_str = f"{h}h {m}m remaining"
elif ttf > 0:
h, m = divmod(int(ttf) // 60, 60)
time_str = f"{h}h {m}m to full"
batteries.append({
"model": model, "percentage": pct, "state": state,
"time": time_str, "rate": rate,
})
if not batteries:
return "No batteries found."
lines = ["## Battery Status\n"]
for bat in batteries:
lines.append(f"### {bat['model']}")
lines.append(f"- **Charge:** {bat['percentage']:.0f}%")
lines.append(f"- **State:** {bat['state']}")
if bat["time"]:
lines.append(f"- **Time:** {bat['time']}")
if bat["rate"] > 0:
lines.append(f"- **Power draw:** {bat['rate']:.1f} W")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Bluetooth (bluez)
# ---------------------------------------------------------------------------
@mcp.tool()
async def bluetooth_devices(ctx: Context) -> str:
"""List discovered and paired Bluetooth devices from bluez."""
mgr = get_mgr(ctx)
connection = await mgr.get_bus("system")
try:
objects_result = await call_bus_method(
connection,
destination="org.bluez",
path="/",
interface="org.freedesktop.DBus.ObjectManager",
member="GetManagedObjects",
)
except (RuntimeError, TimeoutError) as exc:
return f"bluez not available: {exc}"
if not objects_result or not objects_result[0]:
return "No Bluetooth objects found."
managed = objects_result[0]
devices = []
for _obj_path, interfaces in managed.items():
dev_props = interfaces.get("org.bluez.Device1")
if not dev_props:
continue
devices.append({
"name": dev_props.get("Name", dev_props.get("Alias", "Unknown")),
"address": dev_props.get("Address", "?"),
"connected": dev_props.get("Connected", False),
"paired": dev_props.get("Paired", False),
"trusted": dev_props.get("Trusted", False),
})
if not devices:
return "No Bluetooth devices found."
devices.sort(key=lambda d: (not d["connected"], not d["paired"], d["name"]))
lines = [
f"## Bluetooth Devices — {len(devices)} found\n",
"| Name | Address | Connected | Paired | Trusted |",
"|------|---------|-----------|--------|---------|",
]
for d in devices:
conn = "" if d["connected"] else ""
pair = "" if d["paired"] else ""
trust = "" if d["trusted"] else ""
lines.append(f"| {d['name']} | `{d['address']}` | {conn} | {pair} | {trust} |")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# KWin Windows (KDE Plasma)
# ---------------------------------------------------------------------------
@mcp.tool()
async def kwin_windows(ctx: Context) -> str:
"""List open windows from KDE KWin via the KRunner window runner.
Returns window titles, application names, and virtual desktop assignments.
Requires a running KDE Plasma session.
"""
mgr = get_mgr(ctx)
connection = await mgr.get_bus("session")
# KRunner's WindowsRunner lists all windows when queried with empty string
try:
result = await call_bus_method(
connection,
destination="org.kde.KWin",
path="/WindowsRunner",
interface="org.kde.krunner1",
member="Match",
signature="s",
body=[""],
)
except (RuntimeError, TimeoutError) as exc:
return f"KWin not available: {exc}"
if not result or not result[0]:
return "No windows found (KWin WindowsRunner returned empty)."
matches = result[0]
# Each match: (id, caption, icon, type, relevance, properties)
# type=0 → window, type=8 → virtual desktop
windows = []
desktops = []
seen_ids = set()
for match in matches:
match_id, caption, icon = match[0], match[1], match[2]
match_type = match[3]
if match_id in seen_ids:
continue
seen_ids.add(match_id)
if match_type == 8:
desktops.append(caption)
else:
windows.append({"caption": caption, "app": icon or "unknown"})
if not windows:
return "No open windows found."
# Group by app
lines = [f"## KWin Windows — {len(windows)} open\n"]
lines.append("| Window | Application |")
lines.append("|--------|-------------|")
for w in sorted(windows, key=lambda x: (x["app"], x["caption"])):
lines.append(f"| {w['caption']} | {w['app']} |")
if desktops:
lines.append(f"\n**Virtual desktops:** {', '.join(sorted(desktops))}")
return "\n".join(lines)

View File

@ -1,8 +1,34 @@
"""Test fixtures for mcdbus."""
import asyncio
import pytest
from fastmcp import Client
from mcdbus._bus import BusManager
from mcdbus.server import mcp
@pytest.fixture(autouse=True)
def _reset_mcp_singleton():
"""Reset event-loop-bound state on the mcp singleton.
FastMCP creates an asyncio.Event() at construction time. pytest-asyncio
creates a fresh loop per test, so after test 1 the Event is bound to a
dead loop and test 2 would crash. Re-creating these objects keeps every
test isolated.
"""
mcp._started = asyncio.Event()
mcp._lifespan_result = None
mcp._lifespan_result_set = False
yield
@pytest.fixture
async def client():
"""MCP client connected via in-memory transport (no HTTP)."""
async with Client(mcp) as c:
yield c
@pytest.fixture

238
tests/test_tools.py Normal file
View File

@ -0,0 +1,238 @@
"""Tool-level tests — every MCP tool exercised through Client(mcp) in-memory transport."""
import json
import pytest
from fastmcp import Client
from fastmcp.exceptions import ToolError
# ---------------------------------------------------------------------------
# Discovery tools
# ---------------------------------------------------------------------------
class TestListServices:
async def test_returns_services(self, client: Client):
result = await client.call_tool("list_services", {"bus": "session"})
text = result.content[0].text
assert "org.freedesktop.DBus" in text
assert "D-Bus session bus" in text
async def test_include_unique(self, client: Client):
result = await client.call_tool(
"list_services", {"bus": "session", "include_unique": True}
)
text = result.content[0].text
assert ":1." in text
class TestIntrospect:
async def test_dbus_daemon(self, client: Client):
result = await client.call_tool("introspect", {
"bus": "session",
"service": "org.freedesktop.DBus",
"object_path": "/org/freedesktop/DBus",
})
text = result.content[0].text
assert "org.freedesktop.DBus" in text
assert "ListNames" in text
async def test_invalid_path(self, client: Client):
with pytest.raises(ToolError, match="Invalid D-Bus object path"):
await client.call_tool("introspect", {
"bus": "session",
"service": "org.freedesktop.DBus",
"object_path": "not-a-path",
})
class TestListObjects:
async def test_dbus_tree(self, client: Client):
result = await client.call_tool("list_objects", {
"bus": "session",
"service": "org.freedesktop.DBus",
})
text = result.content[0].text
assert "Object tree" in text
assert "/org/freedesktop/DBus" in text
# ---------------------------------------------------------------------------
# Interaction tools
# ---------------------------------------------------------------------------
class TestCallMethod:
async def test_ping_void(self, client: Client):
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()
async def test_get_id_returns_hex(self, client: Client):
result = await client.call_tool("call_method", {
"bus": "session",
"service": "org.freedesktop.DBus",
"object_path": "/org/freedesktop/DBus",
"interface": "org.freedesktop.DBus",
"method": "GetId",
})
text = result.content[0].text
# GetId returns a hex string, JSON-encoded with quotes
bus_id = json.loads(text)
assert isinstance(bus_id, str)
assert len(bus_id) > 10
async def test_bad_method_returns_error(self, client: Client):
result = await client.call_tool("call_method", {
"bus": "session",
"service": "org.freedesktop.DBus",
"object_path": "/org/freedesktop/DBus",
"interface": "org.freedesktop.DBus",
"method": "NoSuchMethod",
})
text = result.content[0].text
assert "Error" in text or "error" in text
class TestGetProperty:
async def test_read_property(self, client: Client):
# Read the Features property from the DBus daemon
result = await client.call_tool("get_property", {
"bus": "session",
"service": "org.freedesktop.DBus",
"object_path": "/org/freedesktop/DBus",
"interface": "org.freedesktop.DBus",
"property_name": "Features",
})
text = result.content[0].text
# Features returns an array of strings
features = json.loads(text)
assert isinstance(features, list)
class TestGetAllProperties:
async def test_dbus_daemon(self, client: Client):
result = await client.call_tool("get_all_properties", {
"bus": "session",
"service": "org.freedesktop.DBus",
"object_path": "/org/freedesktop/DBus",
"interface": "org.freedesktop.DBus",
})
text = result.content[0].text
assert "Properties of" in text
assert "Features" in text
# ---------------------------------------------------------------------------
# Shortcut tools — existing
# ---------------------------------------------------------------------------
class TestSendNotification:
async def test_sends(self, client: Client):
result = await client.call_tool("send_notification", {
"summary": "mcdbus test",
"body": "automated test notification",
"timeout": 1000,
})
text = result.content[0].text
assert "Notification sent" in text
assert "id:" in text
class TestListSystemdUnits:
async def test_returns_table(self, client: Client):
result = await client.call_tool("list_systemd_units", {})
text = result.content[0].text
assert "Systemd units" in text
assert "| Unit |" in text
async def test_pattern_filter(self, client: Client):
result = await client.call_tool(
"list_systemd_units", {"pattern": "dbus*"}
)
text = result.content[0].text
assert "dbus" in text.lower()
class TestMediaPlayerControl:
async def test_no_players_graceful(self, client: Client):
# With no explicit player, it auto-discovers; if none found, returns message
result = await client.call_tool(
"media_player_control", {"action": "play"}
)
text = result.content[0].text
# Either "No MPRIS media players" or a player response — both are valid
assert "Player:" in text or "No MPRIS" in text
async def test_bad_action(self, client: Client):
result = await client.call_tool(
"media_player_control", {"action": "invalid_action"}
)
text = result.content[0].text
# auto-discovers player first; if none found, "No MPRIS" message
# if player found, "Unknown action" message
assert "Unknown action" in text or "No MPRIS" in text
# ---------------------------------------------------------------------------
# Shortcut tools — new
# ---------------------------------------------------------------------------
class TestNetworkStatus:
async def test_returns_status(self, client: Client):
result = await client.call_tool("network_status", {})
text = result.content[0].text
assert "Network Status" in text or "NetworkManager not available" in text
async def test_has_state(self, client: Client):
result = await client.call_tool("network_status", {})
text = result.content[0].text
if "not available" not in text:
assert "State:" in text
class TestBatteryStatus:
async def test_returns_result(self, client: Client):
result = await client.call_tool("battery_status", {})
text = result.content[0].text
# Desktop may not have battery — both outcomes are valid
assert (
"Battery Status" in text
or "No batteries found" in text
or "No power devices" in text
or "UPower not available" in text
)
class TestBluetoothDevices:
async def test_returns_result(self, client: Client):
result = await client.call_tool("bluetooth_devices", {})
text = result.content[0].text
assert (
"Bluetooth Devices" in text
or "No Bluetooth devices" in text
or "No Bluetooth objects" in text
or "bluez not available" in text
)
class TestKwinWindows:
async def test_returns_windows(self, client: Client):
result = await client.call_tool("kwin_windows", {})
text = result.content[0].text
assert (
"KWin Windows" in text
or "KWin not available" in text
or "No windows found" in text
or "No open windows" in text
)
async def test_has_table(self, client: Client):
result = await client.call_tool("kwin_windows", {})
text = result.content[0].text
if "KWin Windows" in text:
assert "| Window |" in text