Skip to content

Architecture

mcdbus is built on FastMCP 3.0 with dbus-fast for async D-Bus communication. This page explains how the pieces fit together.

The entry point is server.py. It imports every module that registers tools, resources, and prompts with the FastMCP instance, then calls mcp.run():

import mcdbus._discovery
import mcdbus._interaction
import mcdbus._prompts
import mcdbus._resources
import mcdbus._shortcuts
from mcdbus._state import mcp
def main():
mcp.run()

The pyproject.toml declares a script entry point (mcdbus = "mcdbus.server:main"), so running mcdbus on the command line calls this function directly.

Each module has a single responsibility:

ModuleRoleRegisters
_state.pyFastMCP instance and lifespan context managerThe mcp server object
_bus.pyBusManager, D-Bus connection handling, type serializationNothing (utility)
_discovery.pyService and object discovery3 tools: list_services, introspect, list_objects
_interaction.pyMethod calls and property access4 tools: call_method, get_property, set_property, get_all_properties
_shortcuts.pyHigh-level convenience tools7 tools: send_notification, list_systemd_units, media_player_control, network_status, battery_status, bluetooth_devices, kwin_windows
_resources.pyDynamic MCP resources for browsing3 resources: dbus://{bus}/services, dbus://{bus}/{service}/objects, dbus://{bus}/{service}/{path}/interfaces
_prompts.pyGuided exploration templates2 prompts: explore_service, debug_service
_notify_confirm.pyDesktop notification fallback for confirmationNothing (called by _interaction.py)

Every module that registers tools imports the mcp instance from _state.py and uses the @mcp.tool() decorator. The imports in server.py ensure all decorators run before mcp.run() is called.

The BusManager class in _bus.py is the central connection manager. It maintains at most two D-Bus connections: one for the session bus and one for the system bus. Connections are lazy — they are established on first use, not at startup.

class BusManager:
def __init__(self):
self._buses: dict[str, MessageBus] = {}
self._locks: dict[str, asyncio.Lock] = {
"session": asyncio.Lock(),
"system": asyncio.Lock(),
}
async def get_bus(self, bus_type: str) -> MessageBus:
...

Key properties:

Lazy connection. The first call to get_bus("session") connects to the session bus. Subsequent calls return the cached connection. The system bus is connected independently on first use.

Reconnection. If a cached connection’s connected property is False (the bus daemon dropped the connection, or the process was idle too long), BusManager drops the stale connection and establishes a new one.

Asyncio locks. Each bus type has its own lock. Multiple concurrent tool calls that need the same bus will wait for the connection to be established by the first caller, then share it.

Cleanup. On server shutdown, the lifespan context calls disconnect_all(), which iterates over all cached connections and disconnects them.

FastMCP supports a lifespan context manager that runs setup/teardown code around the server’s lifetime. mcdbus uses this to create the BusManager and make it available to all tools:

@asynccontextmanager
async def lifespan(server: FastMCP):
mgr = BusManager()
try:
yield mgr
finally:
await mgr.disconnect_all()

The yielded mgr object becomes ctx.lifespan_context inside every tool function. The helper get_mgr(ctx) extracts it:

def get_mgr(ctx: Context) -> BusManager:
return ctx.lifespan_context

This design means tools never create their own connections. They all share the same BusManager, which maintains at most two connections total.

The discovery tools (list_services, list_objects, introspect) contain no hardcoded service knowledge. They work entirely through D-Bus introspection — the same mechanism that tools like d-feet and busctl use. When you call list_services, mcdbus asks the bus daemon for its list of registered names. When you call introspect, mcdbus asks the target service to describe its own interfaces.

The shortcut tools (battery_status, network_status, etc.) do encode knowledge about specific services, but only at the level of which service name, object path, and interface to query. The data they return is still read from the live bus, not from static definitions.

D-Bus has its own type system (integers, strings, booleans, arrays, dicts, variants, structs) that does not map directly to JSON. Two functions in _bus.py handle the conversion:

serialize_variant converts D-Bus response values into JSON-safe Python types. It uses dbus-fast’s unpack_variants to strip Variant wrappers, then recursively converts bytes to strings (or integer lists if not valid UTF-8), tuples to lists, and dict keys to strings.

deserialize_args goes the other direction. It parses a JSON string into a list of Python values, then matches each value against the D-Bus type signature. For Variant arguments (signature v), it either auto-infers the type from the Python value (string, int, float, bool, list) or accepts an explicit {"signature": "...", "value": ...} dict for complex cases.

All D-Bus communication goes through call_bus_method in _bus.py. This function constructs a dbus_fast.Message, sends it through the bus connection with asyncio.wait_for for timeout handling, checks the reply type for errors, and passes the response body through serialize_variant.

The timeout defaults to 30 seconds and can be overridden with the MCDBUS_TIMEOUT environment variable. If the timeout expires, a TimeoutError is raised with a descriptive message including the service, interface, member, and path.

Every tool validates its D-Bus inputs before sending them over the wire:

  • Bus names and interface names are checked against the D-Bus specification regex: must start with a letter or underscore, contain at least two dot-separated components, and use only alphanumeric characters and underscores.
  • Object paths must be / or a sequence of /-separated alphanumeric-and-underscore segments.
  • Signatures are checked for valid characters, maximum length (255), and structural validity by passing them through dbus-fast’s SignatureTree parser.

Invalid inputs raise ValueError before any D-Bus message is sent.