diff --git a/docs-site/.dockerignore b/docs-site/.dockerignore
new file mode 100644
index 0000000..0c3d169
--- /dev/null
+++ b/docs-site/.dockerignore
@@ -0,0 +1,6 @@
+node_modules
+dist
+.astro
+.env
+*.log
+.git
diff --git a/docs-site/.gitignore b/docs-site/.gitignore
new file mode 100644
index 0000000..0564e93
--- /dev/null
+++ b/docs-site/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+dist/
+.astro/
+.env
diff --git a/docs-site/Dockerfile b/docs-site/Dockerfile
new file mode 100644
index 0000000..f95ccee
--- /dev/null
+++ b/docs-site/Dockerfile
@@ -0,0 +1,49 @@
+FROM node:22-slim AS base
+ENV ASTRO_TELEMETRY_DISABLED=1
+WORKDIR /app
+
+# Install dependencies
+FROM base AS deps
+COPY package.json package-lock.json* ./
+RUN npm ci
+
+# Build static site
+FROM deps AS build
+COPY . .
+RUN npm run build
+
+# Production: serve from Caddy
+FROM caddy:2-alpine AS prod
+COPY --from=build /app/dist /srv
+COPY <<'CADDYFILE' /etc/caddy/Caddyfile
+:80 {
+ root * /srv
+ file_server
+ encode gzip
+
+ @hashed path /_astro/*
+ header @hashed Cache-Control "public, max-age=31536000, immutable"
+
+ @unhashed not path /_astro/*
+ header @unhashed Cache-Control "public, max-age=3600"
+
+ handle_errors {
+ @404 expression {err.status_code} == 404
+ handle @404 {
+ rewrite * /404.html
+ file_server
+ }
+ }
+
+ try_files {path} {path}/ /index.html
+}
+CADDYFILE
+
+EXPOSE 80
+
+# Development: Node with HMR
+FROM deps AS dev
+ENV ASTRO_TELEMETRY_DISABLED=1
+COPY . .
+EXPOSE 4321
+CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
diff --git a/docs-site/Makefile b/docs-site/Makefile
new file mode 100644
index 0000000..ec237bb
--- /dev/null
+++ b/docs-site/Makefile
@@ -0,0 +1,11 @@
+prod:
+ docker compose up -d --build
+
+dev:
+ docker compose --profile dev up --build
+
+down:
+ docker compose down
+
+logs:
+ docker compose logs -f
diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs
new file mode 100644
index 0000000..f2782ef
--- /dev/null
+++ b/docs-site/astro.config.mjs
@@ -0,0 +1,101 @@
+import { defineConfig } from "astro/config";
+import starlight from "@astrojs/starlight";
+import tailwindcss from "@tailwindcss/vite";
+import sitemap from "@astrojs/sitemap";
+
+export default defineConfig({
+ site: "https://mcdbus.warehack.ing",
+ telemetry: false,
+ devToolbar: { enabled: false },
+ integrations: [
+ starlight({
+ title: "mcdbus",
+ description:
+ "D-Bus MCP server — give Claude access to your Linux desktop bus",
+ favicon: "/favicon.svg",
+ social: [
+ {
+ icon: "seti:git",
+ label: "Source",
+ href: "https://git.supported.systems/warehack.ing/mcdbus",
+ },
+ ],
+ customCss: ["./src/styles/global.css"],
+ sidebar: [
+ {
+ label: "Start Here",
+ items: [
+ { label: "What is mcdbus?", slug: "start-here/overview" },
+ { label: "Installation", slug: "start-here/installation" },
+ { label: "First Steps", slug: "start-here/first-steps" },
+ ],
+ },
+ {
+ label: "How-To Guides",
+ items: [
+ { label: "Control Media Players", slug: "guides/media-players" },
+ { label: "Manage Systemd Units", slug: "guides/systemd" },
+ { label: "Send Notifications", slug: "guides/notifications" },
+ {
+ label: "Check Battery & Network",
+ slug: "guides/system-status",
+ },
+ { label: "Browse Bluetooth Devices", slug: "guides/bluetooth" },
+ {
+ label: "Explore Unknown Services",
+ slug: "guides/explore-services",
+ },
+ { label: "Lock Down Permissions", slug: "guides/permissions" },
+ ],
+ },
+ {
+ label: "Reference",
+ items: [
+ { label: "Discovery Tools", slug: "reference/discovery-tools" },
+ {
+ label: "Interaction Tools",
+ slug: "reference/interaction-tools",
+ },
+ { label: "Shortcut Tools", slug: "reference/shortcut-tools" },
+ { label: "Resources", slug: "reference/resources" },
+ { label: "Prompts", slug: "reference/prompts" },
+ {
+ label: "Environment Variables",
+ slug: "reference/environment-variables",
+ },
+ ],
+ },
+ {
+ label: "Explanation",
+ collapsed: true,
+ items: [
+ { label: "Architecture", slug: "explanation/architecture" },
+ {
+ label: "Session vs System Bus",
+ slug: "explanation/buses",
+ },
+ {
+ label: "Confirmation Flow",
+ slug: "explanation/confirmation-flow",
+ },
+ { label: "Security Layers", slug: "explanation/security-layers" },
+ ],
+ },
+ ],
+ }),
+ sitemap(),
+ ],
+ vite: {
+ plugins: [tailwindcss()],
+ server: {
+ host: "0.0.0.0",
+ ...(process.env.VITE_HMR_HOST && {
+ hmr: {
+ host: process.env.VITE_HMR_HOST,
+ protocol: "wss",
+ clientPort: 443,
+ },
+ }),
+ },
+ },
+});
diff --git a/docs-site/docker-compose.yml b/docs-site/docker-compose.yml
new file mode 100644
index 0000000..187a83b
--- /dev/null
+++ b/docs-site/docker-compose.yml
@@ -0,0 +1,41 @@
+services:
+ docs:
+ build:
+ context: .
+ target: prod
+ restart: unless-stopped
+ networks:
+ - caddy
+ labels:
+ caddy: ${DOMAIN}
+ caddy.reverse_proxy: "{{upstreams 80}}"
+
+ docs-dev:
+ build:
+ context: .
+ target: dev
+ profiles:
+ - dev
+ volumes:
+ - ./src:/app/src
+ - ./public:/app/public
+ - ./astro.config.mjs:/app/astro.config.mjs
+ networks:
+ - caddy
+ environment:
+ - VITE_HMR_HOST=${DOMAIN}
+ labels:
+ caddy: ${DOMAIN}
+ caddy.reverse_proxy: "{{upstreams 4321}}"
+ caddy.reverse_proxy.flush_interval: "-1"
+ caddy.reverse_proxy.transport: "http"
+ caddy.reverse_proxy.transport.read_timeout: "0"
+ caddy.reverse_proxy.transport.write_timeout: "0"
+ caddy.reverse_proxy.transport.keepalive: "5m"
+ caddy.reverse_proxy.transport.keepalive_idle_conns: "10"
+ caddy.reverse_proxy.stream_timeout: "24h"
+ caddy.reverse_proxy.stream_close_delay: "5s"
+
+networks:
+ caddy:
+ external: true
diff --git a/docs-site/package.json b/docs-site/package.json
new file mode 100644
index 0000000..a96454a
--- /dev/null
+++ b/docs-site/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "mcdbus-docs",
+ "type": "module",
+ "version": "0.0.1",
+ "scripts": {
+ "dev": "astro dev",
+ "build": "astro build",
+ "preview": "astro preview",
+ "astro": "astro"
+ },
+ "dependencies": {
+ "@astrojs/sitemap": "^3.3.1",
+ "@astrojs/starlight": "^0.37.6",
+ "@tailwindcss/vite": "^4.1.3",
+ "astro": "^5.7.10",
+ "sharp": "^0.33.0",
+ "tailwindcss": "^4.1.3"
+ }
+}
diff --git a/docs-site/public/favicon.svg b/docs-site/public/favicon.svg
new file mode 100644
index 0000000..99b2df2
--- /dev/null
+++ b/docs-site/public/favicon.svg
@@ -0,0 +1,16 @@
+
diff --git a/docs-site/public/robots.txt b/docs-site/public/robots.txt
new file mode 100644
index 0000000..f912a5b
--- /dev/null
+++ b/docs-site/public/robots.txt
@@ -0,0 +1,4 @@
+User-agent: *
+Allow: /
+
+Sitemap: https://mcdbus.warehack.ing/sitemap-index.xml
diff --git a/docs-site/src/content.config.ts b/docs-site/src/content.config.ts
new file mode 100644
index 0000000..a4eec59
--- /dev/null
+++ b/docs-site/src/content.config.ts
@@ -0,0 +1,6 @@
+import { defineCollection } from "astro:content";
+import { docsSchema } from "@astrojs/starlight/schema";
+
+export const collections = {
+ docs: defineCollection({ schema: docsSchema() }),
+};
diff --git a/docs-site/src/content/docs/explanation/architecture.mdx b/docs-site/src/content/docs/explanation/architecture.mdx
new file mode 100644
index 0000000..383a691
--- /dev/null
+++ b/docs-site/src/content/docs/explanation/architecture.mdx
@@ -0,0 +1,127 @@
+---
+title: Architecture
+description: Internal structure of the mcdbus D-Bus MCP server, covering module organization, the BusManager, lifespan context, and type serialization.
+---
+
+import { Aside } from '@astrojs/starlight/components';
+
+mcdbus is built on [FastMCP 3.0](https://gofastmcp.com/) with [dbus-fast](https://github.com/Bluetooth-Devices/dbus-fast) for async D-Bus communication. This page explains how the pieces fit together.
+
+## Entry point
+
+The entry point is `server.py`. It imports every module that registers tools, resources, and prompts with the FastMCP instance, then calls `mcp.run()`:
+
+```python
+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.
+
+## Module organization
+
+Each module has a single responsibility:
+
+| Module | Role | Registers |
+|--------|------|-----------|
+| `_state.py` | FastMCP instance and lifespan context manager | The `mcp` server object |
+| `_bus.py` | BusManager, D-Bus connection handling, type serialization | Nothing (utility) |
+| `_discovery.py` | Service and object discovery | 3 tools: `list_services`, `introspect`, `list_objects` |
+| `_interaction.py` | Method calls and property access | 4 tools: `call_method`, `get_property`, `set_property`, `get_all_properties` |
+| `_shortcuts.py` | High-level convenience tools | 7 tools: `send_notification`, `list_systemd_units`, `media_player_control`, `network_status`, `battery_status`, `bluetooth_devices`, `kwin_windows` |
+| `_resources.py` | Dynamic MCP resources for browsing | 3 resources: `dbus://{bus}/services`, `dbus://{bus}/{service}/objects`, `dbus://{bus}/{service}/{path}/interfaces` |
+| `_prompts.py` | Guided exploration templates | 2 prompts: `explore_service`, `debug_service` |
+| `_notify_confirm.py` | Desktop notification fallback for confirmation | Nothing (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.
+
+## BusManager
+
+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.
+
+```python
+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.
+
+## Lifespan context
+
+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:
+
+```python
+@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:
+
+```python
+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.
+
+## Introspection-first design
+
+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.
+
+## Type serialization
+
+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.
+
+
+
+## D-Bus method calls
+
+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.
+
+## Input validation
+
+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.
diff --git a/docs-site/src/content/docs/explanation/buses.mdx b/docs-site/src/content/docs/explanation/buses.mdx
new file mode 100644
index 0000000..0110323
--- /dev/null
+++ b/docs-site/src/content/docs/explanation/buses.mdx
@@ -0,0 +1,93 @@
+---
+title: Session vs System Bus
+description: The two D-Bus buses on Linux, what lives on each, and how mcdbus treats them differently.
+---
+
+import { Aside } from '@astrojs/starlight/components';
+
+Linux systems running D-Bus have two distinct message buses. Every mcdbus tool takes a `bus` parameter -- either `"session"` or `"system"` -- that selects which one to talk to. The choice affects what services are available, what permissions are required, and whether mcdbus prompts for confirmation.
+
+## The session bus
+
+The session bus is per-user, per-login-session. It starts when you log in (typically managed by `dbus-daemon --session` or `dbus-broker --scope user`) and dies when you log out. Its address is stored in the `DBUS_SESSION_BUS_ADDRESS` environment variable.
+
+Desktop applications and user services register here. The session bus is where you find:
+
+- Notification daemons (`org.freedesktop.Notifications`)
+- Media players via MPRIS2 (`org.mpris.MediaPlayer2.*`)
+- Desktop environment components (KDE KWin, GNOME Shell, etc.)
+- XDG portals (`org.freedesktop.portal.*`)
+- User-level systemd (`org.freedesktop.systemd1` on the session bus manages `systemctl --user` services)
+- Input methods, screen savers, file managers
+
+Because the session bus is scoped to your user session, any process running as your user can send messages to any service on it. There is no authentication step -- if you can reach the socket, you can talk. This is by design: desktop applications need to communicate freely within a single user's session.
+
+**mcdbus treats session bus operations as low-risk.** Method calls on the session bus execute without a confirmation prompt. Property mutations (via `set_property`) still prompt because they change state, but the prompt is about intent rather than privilege.
+
+## The system bus
+
+The system bus is system-wide. There is exactly one instance, started at boot by the init system. Its socket lives at `/var/run/dbus/system_bus_socket` (or equivalent). Every user on the machine can connect to it, but access is tightly controlled.
+
+System-level services register here:
+
+- systemd manager (`org.freedesktop.systemd1`)
+- NetworkManager (`org.freedesktop.NetworkManager`)
+- UPower battery management (`org.freedesktop.UPower`)
+- bluez Bluetooth stack (`org.bluez`)
+- udisks2 disk management (`org.freedesktop.UDisks2`)
+- Avahi mDNS/DNS-SD (`org.freedesktop.Avahi`)
+- CUPS printing (`org.cups.cupsd`)
+- Accounts service (`org.freedesktop.Accounts`)
+
+The system bus has stricter access control than the session bus. D-Bus bus policy files (XML configs in `/etc/dbus-1/system.d/`) define which users can send messages to which services. Many services also perform polkit authorization checks before executing privileged operations.
+
+**mcdbus treats system bus method calls as sensitive.** Calling `call_method` with `bus="system"` triggers a confirmation prompt (via the [confirmation flow](/explanation/confirmation-flow/)) before the message is sent. Read-only operations through the shortcut tools (`battery_status`, `network_status`, `bluetooth_devices`, `list_systemd_units`) do not trigger confirmation because they only read properties -- they never call service-specific methods that could change state.
+
+## What lives where
+
+| Service | Session bus | System bus | Notes |
+|---------|:-----------:|:----------:|-------|
+| Notifications | Yes | -- | Desktop notifications are per-user |
+| MPRIS2 media players | Yes | -- | Media players are user applications |
+| KDE KWin | Yes | -- | Window manager is per-session |
+| XDG portals | Yes | -- | Sandboxed app integration |
+| systemd | Yes (user units) | Yes (system units) | Different manager instances |
+| NetworkManager | -- | Yes | System-wide network config |
+| UPower | -- | Yes | Hardware battery status |
+| bluez | -- | Yes | Bluetooth adapter and devices |
+| udisks2 | -- | Yes | Disk and partition management |
+| Avahi | -- | Yes | mDNS service discovery |
+| Accounts | -- | Yes | User account management |
+
+
+
+## Security implications
+
+The distinction between the two buses is a security boundary. The session bus assumes all participants are the same user and can be trusted at a basic level. The system bus assumes participants may be different users with different privilege levels and enforces access control accordingly.
+
+For mcdbus, this means:
+
+- **Session bus:** Low friction. Services are user-scoped. The worst case for an unintended call is usually a skipped track or a stray notification.
+- **System bus:** Higher friction. Services can affect the entire machine. An unintended call could restart a system service, change network configuration, or trigger a disk operation. The confirmation prompt exists to prevent this.
+
+The [Lock Down Permissions](/guides/permissions/) guide covers how to add additional restrictions beyond mcdbus's built-in confirmation behavior, using the operating system's own D-Bus permission stack.
+
+## The bus parameter
+
+Every mcdbus tool accepts the `bus` parameter as either `"session"` or `"system"`. There are no other valid values. The parameter is a string, not an enum, because MCP tool arguments are JSON-typed.
+
+Shortcut tools that target a specific bus have sensible defaults:
+
+| Tool | Default bus | Reason |
+|------|-------------|--------|
+| `send_notification` | session (hardcoded) | Notifications are a session bus service |
+| `media_player_control` | session (hardcoded) | MPRIS2 players are session bus services |
+| `battery_status` | system (hardcoded) | UPower is a system bus service |
+| `network_status` | system (hardcoded) | NetworkManager is a system bus service |
+| `bluetooth_devices` | system (hardcoded) | bluez is a system bus service |
+| `kwin_windows` | session (hardcoded) | KWin is a session bus service |
+| `list_systemd_units` | system (default, overridable) | System units are the common case, but `bus="session"` shows user units |
+
+Discovery tools (`list_services`, `list_objects`, `introspect`) and interaction tools (`call_method`, `get_property`, `set_property`, `get_all_properties`) require the `bus` parameter explicitly. There is no default because assuming the wrong bus leads to confusing errors.
diff --git a/docs-site/src/content/docs/explanation/confirmation-flow.mdx b/docs-site/src/content/docs/explanation/confirmation-flow.mdx
new file mode 100644
index 0000000..02cc229
--- /dev/null
+++ b/docs-site/src/content/docs/explanation/confirmation-flow.mdx
@@ -0,0 +1,119 @@
+---
+title: Confirmation Flow
+description: How mcdbus decides when to ask for user confirmation and the three-tier fallback cascade for delivering the prompt.
+---
+
+import { Aside } from '@astrojs/starlight/components';
+
+mcdbus asks for user confirmation before performing operations that could change system state. This page explains when confirmation triggers, how the three-tier fallback cascade works, and how environment variables control the behavior.
+
+## When confirmation triggers
+
+Two conditions trigger a confirmation prompt:
+
+1. **System bus method calls.** Any call to `call_method` with `bus="system"`. The system bus can affect the entire machine, so every method call requires explicit approval.
+
+2. **Property mutations on any bus.** Any call to `set_property`, regardless of which bus. Changing a property is always a state mutation, even on the session bus.
+
+Read-only operations never trigger confirmation. This includes `list_services`, `introspect`, `list_objects`, `get_property`, `get_all_properties`, and all the shortcut tools (`battery_status`, `network_status`, `bluetooth_devices`, `list_systemd_units`, `send_notification`, `media_player_control`). The shortcut tools either read properties only or operate on the session bus.
+
+
+
+## The three-tier cascade
+
+When confirmation is required, mcdbus tries three methods in sequence. If one succeeds, the remaining tiers are skipped. If all three are exhausted, the behavior depends on configuration.
+
+### Tier 1: MCP elicitation
+
+The preferred method. mcdbus calls `ctx.elicit()`, which is a native MCP protocol feature that presents the user with a choice dialog in their MCP client. The prompt shows the operation details (service, method, path, arguments) and offers two options: "Yes, proceed" and "No, cancel."
+
+If the MCP client supports elicitation and the user selects "Yes, proceed", the operation continues. If the user selects "No, cancel", the operation is aborted with a `ToolError`.
+
+If the client does not implement elicitation at the protocol level, `ctx.elicit()` either returns a `CancelledElicitation` or throws an exception. In either case, mcdbus catches this and falls through to Tier 2.
+
+### Tier 2: Desktop notification fallback
+
+This tier is opt-in. It activates only when the `MCDBUS_NOTIFY_CONFIRM` environment variable is set to a non-empty value (e.g., `MCDBUS_NOTIFY_CONFIRM=1`).
+
+When active, mcdbus sends a desktop notification through `org.freedesktop.Notifications` on the session bus. The notification has the urgency set to critical and presents two action buttons: "Approve" and "Deny."
+
+The implementation registers D-Bus signal match rules for `ActionInvoked` and `NotificationClosed` before sending the notification, ensuring no signal can be missed due to a race condition. It then waits up to 60 seconds for the user to respond.
+
+If the user clicks "Approve", the operation proceeds. If they click "Deny", dismiss the notification, or let it expire, the operation is aborted. If the notification service itself is unreachable, mcdbus logs the failure and falls through to Tier 3.
+
+
+
+### Tier 3: Proceed with warning
+
+If both Tier 1 and Tier 2 fail or are unavailable, mcdbus logs a warning to stderr and allows the operation to proceed.
+
+This is the default behavior when no confirmation method is available. The rationale is that the operation was initiated by Claude at the user's request, and blocking it entirely when no confirmation channel exists would make the tool unusable in environments that lack elicitation support.
+
+The warning is written to stderr (and to the systemd journal if running as a service), creating an audit trail even when no interactive confirmation was possible.
+
+## The decision tree
+
+```
+Confirmation required?
+|
++-- No --> Execute immediately
+|
++-- Yes --> Try MCP elicitation (ctx.elicit)
+ |
+ +-- User approved --> Execute
+ |
+ +-- User denied --> Abort (ToolError)
+ |
+ +-- Elicitation unavailable
+ |
+ +-- MCDBUS_REQUIRE_ELICITATION set?
+ | |
+ | +-- Yes --> Abort (ToolError: "Elicitation required
+ | | but client does not support it")
+ | |
+ | +-- No --> Continue to fallbacks
+ |
+ +-- MCDBUS_NOTIFY_CONFIRM set?
+ |
+ +-- Yes --> Send desktop notification
+ | |
+ | +-- User clicked Approve --> Execute
+ | |
+ | +-- User clicked Deny / dismissed / timed out --> Abort
+ | |
+ | +-- Notification service unreachable --> Proceed with warning
+ |
+ +-- No --> Proceed with warning (log to stderr)
+```
+
+## Environment variables
+
+Two environment variables control the confirmation flow:
+
+**`MCDBUS_REQUIRE_ELICITATION`** -- When set to any non-empty value, mcdbus refuses to proceed if the MCP client does not support elicitation. No fallback to notifications or warn-and-proceed. The operation fails immediately with a `ToolError`. Use this in high-security deployments where you need a guarantee that every sensitive operation was explicitly approved through the MCP protocol.
+
+**`MCDBUS_NOTIFY_CONFIRM`** -- When set to any non-empty value, enables the desktop notification fallback (Tier 2). Without this, mcdbus skips directly from Tier 1 failure to Tier 3 (proceed with warning). Use this when your MCP client does not support elicitation but you have a graphical session with a notification daemon.
+
+Both can be set in the systemd unit file:
+
+```ini
+[Service]
+Environment=MCDBUS_REQUIRE_ELICITATION=1
+Environment=MCDBUS_NOTIFY_CONFIRM=1
+```
+
+If both are set, `MCDBUS_REQUIRE_ELICITATION` takes priority. The notification fallback is never reached because elicitation failure immediately aborts the operation.
+
+## Audit logging
+
+Regardless of which tier handles confirmation, mcdbus logs every system bus method call and every property mutation to stderr. The log message includes the bus type, service name, interface, method (or property name), object path, and signature. When running under systemd, these messages go to the journal and can be reviewed with:
+
+```bash
+journalctl -u mcdbus.service | grep "mcdbus:"
+```
+
+This audit trail exists independently of the confirmation mechanism. Even Tier 3 (proceed with warning) produces a log entry, so you can always reconstruct what operations were performed.
diff --git a/docs-site/src/content/docs/explanation/security-layers.mdx b/docs-site/src/content/docs/explanation/security-layers.mdx
new file mode 100644
index 0000000..e50cf89
--- /dev/null
+++ b/docs-site/src/content/docs/explanation/security-layers.mdx
@@ -0,0 +1,115 @@
+---
+title: Security Layers
+description: The four-layer security model that mcdbus delegates to the operating system, and how the layers compose.
+---
+
+import { Aside } from '@astrojs/starlight/components';
+
+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. The same mechanisms that protect D-Bus from any other client also protect it from mcdbus.
+
+This page explains the rationale and the four independent layers. For concrete deployment steps, see [Lock Down Permissions](/guides/permissions/).
+
+## Design philosophy
+
+When an LLM agent talks to system services through D-Bus, the temptation is to build access control into the bridge itself -- an allowlist of services, a set of blocked methods, a permission database. mcdbus deliberately does not do this, for three reasons.
+
+First, the operating system already has a mature, well-tested permission stack for D-Bus. Reimplementing it inside mcdbus would be redundant and likely less thorough.
+
+Second, the OS permission stack is transparent to system administrators. They already know how to write D-Bus bus policy files, polkit rules, and systemd unit hardening. An mcdbus-specific config format would be one more thing to learn and audit.
+
+Third, the OS permission stack applies regardless of what client is connecting. If you lock down D-Bus policy for the mcdbus user, those restrictions apply whether the user is running mcdbus, `busctl`, `dbus-send`, or any other D-Bus client. There is no way to bypass the restrictions by using a different tool.
+
+The one thing mcdbus does add is the [confirmation flow](/explanation/confirmation-flow/) -- an interactive prompt before system bus method calls and property mutations. This is not access control in the traditional sense. It is an intent verification step between the LLM and the user, not between the process and the kernel.
+
+## Layer 1: systemd service sandboxing
+
+Scope: process isolation. Granularity: filesystem paths, network families, syscalls, capabilities.
+
+Running mcdbus as a systemd service with hardening directives gives you a sandbox around the entire process. The sandbox uses Linux kernel features -- namespaces, seccomp, capability bounding sets -- that are enforced by the kernel itself. There is no userspace component to bypass.
+
+What systemd sandboxing controls:
+
+- Which parts of the filesystem the process can see and write to
+- Which socket address families (AF_UNIX, AF_INET, AF_INET6, etc.) the process can create
+- Which Linux capabilities the process has (none, in the recommended configuration)
+- Which syscalls the process can invoke
+- Whether the process can map memory as writable and executable simultaneously
+- Resource limits on memory and tasks
+
+What it does not control: which D-Bus services the process can talk to. As long as the process can reach the D-Bus socket (which requires AF_UNIX), the bus daemon handles message routing. To restrict D-Bus communication specifically, you need Layer 2.
+
+## Layer 2: D-Bus bus policy
+
+Scope: message filtering. Granularity: service name, interface, method name, message type.
+
+D-Bus bus policy operates inside the bus daemon (or bus broker). Every message that passes through the bus is checked against the policy rules. If a rule denies a message, the bus daemon drops it silently (or with an error reply). The sending process never knows the message was filtered -- it just gets an access denied error.
+
+Policy files are XML documents installed in `/etc/dbus-1/system.d/` or `/etc/dbus-1/session.d/`. They match on the sender's Unix user or group and filter by destination service name, interface, method name, and message type.
+
+This is the most reliable layer for controlling D-Bus access because it operates at the message transport level. Even if mcdbus or dbus-fast had a vulnerability that allowed arbitrary D-Bus messages, the bus daemon would still enforce the policy.
+
+
+
+## Layer 3: polkit
+
+Scope: action authorization. Granularity: per-action, per-user, per-group, per-session.
+
+polkit sits above D-Bus in the stack. When a system service receives a D-Bus method call that requires authorization, the service asks polkit whether the calling user is allowed to perform that action. This check happens inside the service -- it is the service's choice to consult polkit, not something the bus daemon enforces.
+
+Most system services on a modern Linux desktop use polkit for their authorization decisions: systemd checks polkit before starting or stopping units, NetworkManager checks polkit before changing network configuration, udisks2 checks polkit before mounting filesystems.
+
+polkit rules are JavaScript files that can make decisions based on the action ID, the subject's user name, group membership, whether the session is local, and whether the session is active. This allows fine-grained policies like "allow users in the mcdbus group to read systemd unit status but not start or stop units."
+
+The limitation is that polkit action IDs are often coarser than the actual operations. For example, systemd uses `org.freedesktop.systemd1.manage-units` for both reading unit status and stopping units. To distinguish between those two operations, you need Layer 2 (D-Bus bus policy) to filter at the method level.
+
+## Layer 4: xdg-dbus-proxy
+
+Scope: socket-level filtering. Granularity: service name, interface, object path.
+
+`xdg-dbus-proxy` is a Flatpak utility that creates a filtered D-Bus socket. It sits between the client and the real bus socket, inspecting every message and only passing through those that match the configured rules.
+
+This is the most granular layer. You can allow access to a specific method on a specific interface at a specific object path on a specific service. You can also set different access levels per service: `--see` for read-only visibility (introspectable but no method calls), `--talk` for full communication, and `--own` for name registration.
+
+xdg-dbus-proxy is most useful for the session bus, where D-Bus bus policy is less commonly configured. For the system bus, Layers 2 and 3 are usually sufficient. The trade-off is that xdg-dbus-proxy adds a proxy process and a temporary socket, which adds a small amount of latency and operational complexity.
+
+## How layers compose
+
+Each layer is independent. They do not know about each other and do not interfere with each other. When a D-Bus message travels from mcdbus to a service, it passes through whatever layers are active:
+
+```
+mcdbus process
+ |
+ | (Layer 1: systemd sandbox -- can the process create a Unix socket?)
+ |
+ v
+xdg-dbus-proxy (if Layer 4 is deployed)
+ |
+ | (Layer 4: proxy filtering -- does the message match the allow rules?)
+ |
+ v
+D-Bus bus daemon
+ |
+ | (Layer 2: bus policy -- is this user allowed to send to this destination?)
+ |
+ v
+Target service
+ |
+ | (Layer 3: polkit -- is this user authorized for this action?)
+ |
+ v
+Operation executes (or is denied)
+```
+
+A message must pass every active layer to succeed. Blocking at any layer stops the operation. This means the layers stack: adding a layer can only make the system more restrictive, never less.
+
+## Choosing layers
+
+**Layer 1 alone** covers the vast majority of deployments. It is free to set up (a single systemd unit file), has zero runtime overhead, and provides broad isolation. Even if mcdbus or any of its dependencies had a vulnerability, the sandbox limits what an attacker could do.
+
+**Layers 1 + 2** add service-level filtering when you want to restrict which D-Bus services mcdbus can communicate with. This is the right combination when the threat model includes mcdbus being used to talk to services it should not (e.g., udisks2 for disk operations when only battery and network status are needed).
+
+**Layers 1 + 2 + 3** add per-action authorization for system services that check polkit. This is appropriate for shared systems or environments where multiple users have access and you want different privilege levels.
+
+**Layer 4** fills a niche: fine-grained session bus filtering. Use it when you need to restrict which session bus services mcdbus can see, or when you need per-path filtering that the bus policy cannot express.
diff --git a/docs-site/src/content/docs/guides/bluetooth.mdx b/docs-site/src/content/docs/guides/bluetooth.mdx
new file mode 100644
index 0000000..834266a
--- /dev/null
+++ b/docs-site/src/content/docs/guides/bluetooth.mdx
@@ -0,0 +1,132 @@
+---
+title: Browse Bluetooth Devices
+description: List paired and discovered Bluetooth devices using mcdbus's bluetooth_devices tool and the bluez D-Bus interface.
+---
+
+import { Aside, Code } from '@astrojs/starlight/components';
+
+mcdbus provides a `bluetooth_devices` shortcut tool that reads from bluez on the system bus. It uses the ObjectManager interface to get all known Bluetooth devices in a single call, then formats them into a sorted table.
+
+## Listing devices
+
+> "What Bluetooth devices are nearby?"
+
+```
+bluetooth_devices()
+```
+
+The tool returns a table sorted with connected devices first, then paired devices, then everything else alphabetically:
+
+```markdown
+## Bluetooth Devices -- 5 found
+
+| Name | Address | Connected | Paired | Trusted |
+|------|---------|-----------|--------|---------|
+| WH-1000XM4 | 38:18:4C:XX:XX:XX | yes | yes | yes |
+| Keyboard K380 | 34:88:5D:XX:XX:XX | yes | yes | yes |
+| Galaxy Buds2 | A0:C5:F2:XX:XX:XX | no | yes | yes |
+| iPhone | 7C:04:D0:XX:XX:XX | no | no | no |
+| [unknown] | 48:A4:93:XX:XX:XX | no | no | no |
+```
+
+### What it reads
+
+The tool calls `org.freedesktop.DBus.ObjectManager.GetManagedObjects` on `org.bluez` at the root path `/`. This returns every managed object in the bluez hierarchy. The tool filters for objects that have the `org.bluez.Device1` interface and extracts:
+
+| Column | Source property | Notes |
+|--------|---------------|-------|
+| Name | `Name` or `Alias` | Falls back to "Unknown" if neither is set |
+| Address | `Address` | Bluetooth MAC address |
+| Connected | `Connected` | Boolean -- active link to this device |
+| Paired | `Paired` | Boolean -- bonding keys exchanged |
+| Trusted | `Trusted` | Boolean -- auto-connect permitted |
+
+## Reading detailed device properties
+
+Each Bluetooth device has an object path under `/org/bluez/hci0/` (or whichever adapter is active). The path encodes the MAC address with underscores replacing colons:
+
+```
+/org/bluez/hci0/dev_38_18_4C_XX_XX_XX
+```
+
+To read all properties on a specific device:
+
+> "Tell me everything about my WH-1000XM4 headphones."
+
+```
+get_all_properties(
+ bus="system",
+ service="org.bluez",
+ object_path="/org/bluez/hci0/dev_38_18_4C_XX_XX_XX",
+ interface="org.bluez.Device1"
+)
+```
+
+This returns additional properties beyond what the shortcut tool shows:
+
+| Property | What it contains |
+|----------|-----------------|
+| `RSSI` | Signal strength in dBm (only present during discovery) |
+| `Appearance` | GAP appearance value indicating device category |
+| `Icon` | Icon hint string (e.g., "audio-headphones", "input-keyboard") |
+| `Adapter` | Object path of the Bluetooth adapter this device was seen by |
+| `ServicesResolved` | Whether service discovery has completed |
+| `UUIDs` | List of Bluetooth service UUIDs the device advertises |
+| `Modalias` | USB-style modalias string for driver matching |
+
+### Battery level
+
+Some Bluetooth devices report their battery level through the `org.bluez.Battery1` interface. To check:
+
+```
+get_all_properties(
+ bus="system",
+ service="org.bluez",
+ object_path="/org/bluez/hci0/dev_38_18_4C_XX_XX_XX",
+ interface="org.bluez.Battery1"
+)
+```
+
+If the device supports it, this returns a `Percentage` property (0-100). Not all devices report battery level over Bluetooth -- it depends on the device firmware and profile support.
+
+
+
+## Inspecting the adapter
+
+The Bluetooth adapter itself (typically `hci0`) has its own set of properties at `/org/bluez/hci0`:
+
+```
+get_all_properties(
+ bus="system",
+ service="org.bluez",
+ object_path="/org/bluez/hci0",
+ interface="org.bluez.Adapter1"
+)
+```
+
+This shows the adapter's name, MAC address, power state, discoverability, and supported features.
+
+## Finding device paths
+
+If you are unsure of the exact object path for a device, walk the bluez object tree:
+
+```
+list_objects(bus="system", service="org.bluez")
+```
+
+This returns paths like:
+
+```
+/org/bluez
+/org/bluez/hci0
+/org/bluez/hci0/dev_38_18_4C_XX_XX_XX
+/org/bluez/hci0/dev_34_88_5D_XX_XX_XX
+```
+
+Each `dev_` path corresponds to a known device. The MAC address in the path matches the `Address` property with colons replaced by underscores.
+
+
diff --git a/docs-site/src/content/docs/guides/explore-services.mdx b/docs-site/src/content/docs/guides/explore-services.mdx
new file mode 100644
index 0000000..7225aea
--- /dev/null
+++ b/docs-site/src/content/docs/guides/explore-services.mdx
@@ -0,0 +1,182 @@
+---
+title: Explore Unknown Services
+description: Walk through the full discovery workflow for investigating any D-Bus service from scratch using mcdbus tools and prompts.
+---
+
+import { Aside, Steps, Code } from '@astrojs/starlight/components';
+
+One of the most valuable uses of mcdbus is exploring D-Bus services you have never worked with before. The discovery tools let you walk the entire object hierarchy, read interfaces, and test methods -- all through conversation with Claude. No prior knowledge of the service is required.
+
+This guide walks through the full workflow using a real example: discovering what the Flatpak system helper exposes on D-Bus.
+
+## The five-step workflow
+
+
+1. **Find the service.** List everything on the bus and look for the service name.
+
+2. **Walk the object tree.** See what object paths the service exposes.
+
+3. **Introspect an object.** Read its interfaces, methods, properties, and signals.
+
+4. **Read properties.** Get the current state without changing anything.
+
+5. **Call methods.** Interact with the service.
+
+
+## Step 1: List services
+
+Start by listing all well-known service names on the bus you want to explore:
+
+> "What services are on the system bus?"
+
+```
+list_services(bus="system")
+```
+
+This returns every well-known service name. On a typical desktop system, there are 30-80 services on the system bus. Claude can scan the list and highlight anything relevant to what you are looking for.
+
+For the session bus, the same approach works:
+
+```
+list_services(bus="session")
+```
+
+Session bus services tend to include desktop environment components, media players, file managers, and application portals.
+
+
+
+## Step 2: Walk the object tree
+
+Once you have a service name, see what object paths it exposes:
+
+> "Show me the object tree for org.freedesktop.Flatpak.SystemHelper."
+
+```
+list_objects(bus="system", service="org.freedesktop.Flatpak.SystemHelper")
+```
+
+The tool performs a breadth-first walk starting from `/`, introspecting each node to find children. The result is a flat list of all object paths with their non-standard interfaces:
+
+```
+- `/` -- (no interfaces)
+- `/org/freedesktop/Flatpak/SystemHelper` -- `org.freedesktop.Flatpak.SystemHelper`
+```
+
+Some services have deep trees with hundreds of objects (bluez, systemd). Others, like this one, have just one or two meaningful paths.
+
+The walk is bounded: it stops at 500 objects or 60 seconds, whichever comes first. You can also scope it to a subtree:
+
+```
+list_objects(
+ bus="system",
+ service="org.freedesktop.systemd1",
+ root_path="/org/freedesktop/systemd1/unit"
+)
+```
+
+## Step 3: Introspect
+
+Pick an object path and look at its interfaces in detail:
+
+> "Introspect the Flatpak system helper."
+
+```
+introspect(
+ bus="system",
+ service="org.freedesktop.Flatpak.SystemHelper",
+ object_path="/org/freedesktop/Flatpak/SystemHelper"
+)
+```
+
+The response shows every interface, its methods (with argument names, types, and directions), properties (with type and read/write access), and signals. Standard interfaces like `org.freedesktop.DBus.Peer` and `org.freedesktop.DBus.Introspectable` are filtered out by default. To include them:
+
+```
+introspect(
+ bus="system",
+ service="org.freedesktop.Flatpak.SystemHelper",
+ object_path="/org/freedesktop/Flatpak/SystemHelper",
+ include_standard=true
+)
+```
+
+## Step 4: Read properties
+
+Before calling any methods, read the service's current state:
+
+> "Read all properties on the Flatpak SystemHelper interface."
+
+```
+get_all_properties(
+ bus="system",
+ service="org.freedesktop.Flatpak.SystemHelper",
+ object_path="/org/freedesktop/Flatpak/SystemHelper",
+ interface="org.freedesktop.Flatpak.SystemHelper"
+)
+```
+
+Properties are returned as a markdown table with name-value pairs. Values longer than 80 characters are truncated for readability. For the full value of a specific property, use `get_property`:
+
+```
+get_property(
+ bus="system",
+ service="org.freedesktop.Flatpak.SystemHelper",
+ object_path="/org/freedesktop/Flatpak/SystemHelper",
+ interface="org.freedesktop.Flatpak.SystemHelper",
+ property_name="version"
+)
+```
+
+## Step 5: Call methods
+
+Once you understand the interface, you can call methods. On the session bus, this happens immediately. On the system bus, mcdbus asks for confirmation first.
+
+> "Call GetServerInformation on the notification daemon."
+
+```
+call_method(
+ bus="session",
+ service="org.freedesktop.Notifications",
+ object_path="/org/freedesktop/Notifications",
+ interface="org.freedesktop.Notifications",
+ method="GetServerInformation"
+)
+```
+
+For methods that take arguments, you need to provide the D-Bus type signature and a JSON array of arguments. The introspect output shows the expected types for each method parameter.
+
+## Using the explore_service prompt
+
+Instead of manually stepping through each stage, you can activate the `explore_service` prompt to get a guided walkthrough:
+
+> "Use the explore_service prompt for org.freedesktop.Flatpak.SystemHelper on the system bus."
+
+This loads a step-by-step guide into the conversation context, directing Claude through the same workflow automatically. The prompt accepts two parameters:
+
+- `bus`: "session" or "system" (default: "session")
+- `service`: the service name to explore (optional -- if omitted, starts from listing all services)
+
+## Using the debug_service prompt
+
+When a service is not behaving as expected -- method calls failing, properties returning unexpected values, or the service not appearing at all -- use the `debug_service` prompt:
+
+> "Use the debug_service prompt for org.freedesktop.Notifications on the session bus."
+
+This loads a diagnostic workflow that checks:
+
+1. Whether the service is registered on the bus
+2. Whether the root path is introspectable
+3. The full object tree
+4. Current property values on key interfaces
+5. Basic connectivity via the Peer interface's Ping method
+
+## Tips for exploring unfamiliar services
+
+**Start with properties, not methods.** Properties are read-only by default and give you a snapshot of the service's state without risk of changing anything.
+
+**Use `include_standard=false` (the default).** Standard D-Bus interfaces are the same on every object. Filtering them out makes the output much easier to read.
+
+**Check both buses.** Some services register on both the session and system bus with different capabilities. NetworkManager, for example, has a presence on the system bus (full control) and sometimes the session bus (limited status).
+
+**Look for ObjectManager.** Services that manage a collection of objects (bluez for Bluetooth devices, UPower for power devices, udisks2 for storage) typically implement `org.freedesktop.DBus.ObjectManager` at their root path. Calling `GetManagedObjects` on that interface returns the entire device tree in one call, which is much faster than walking the tree node by node.
diff --git a/docs-site/src/content/docs/guides/media-players.mdx b/docs-site/src/content/docs/guides/media-players.mdx
new file mode 100644
index 0000000..b51ee49
--- /dev/null
+++ b/docs-site/src/content/docs/guides/media-players.mdx
@@ -0,0 +1,139 @@
+---
+title: Control Media Players
+description: Use mcdbus to discover and control MPRIS2 media players like Spotify, Firefox, VLC, and mpv through D-Bus.
+---
+
+import { Aside, Steps, Code, Tabs, TabItem } from '@astrojs/starlight/components';
+
+MPRIS2 (Media Player Remote Interfacing Specification) is the standard D-Bus interface for media players on Linux. Any application that plays audio or video and follows this spec -- Spotify, Firefox, VLC, mpv, Chromium, Rhythmbox, and many others -- exposes the same set of methods and properties on the session bus. mcdbus can talk to all of them.
+
+## How MPRIS2 works on D-Bus
+
+Every MPRIS2 player registers a well-known service name following the pattern `org.mpris.MediaPlayer2.{player}`. The player object lives at `/org/mpris/MediaPlayer2` and exposes the `org.mpris.MediaPlayer2.Player` interface with transport controls (Play, Pause, Next, Previous, Stop) and properties like Metadata, PlaybackStatus, and Volume.
+
+Because this is a session bus service, no confirmation prompt is needed -- all operations run in your user session.
+
+## Quick control with `media_player_control`
+
+The fastest way to control a player is the dedicated shortcut tool. It auto-discovers MPRIS players if you do not specify one.
+
+> "Pause whatever is playing."
+
+Claude calls:
+
+```
+media_player_control(action="pause")
+```
+
+The tool finds the first MPRIS player on the session bus, sends the Pause command, and reports back the player name, current status, and the track that was playing.
+
+Available actions: `play`, `pause`, `play-pause`, `next`, `previous`, `stop`.
+
+### Targeting a specific player
+
+When multiple players are active (say Spotify and Firefox both have audio), specify the full service name:
+
+> "Skip to the next track on Spotify."
+
+```
+media_player_control(action="next", player="org.mpris.MediaPlayer2.spotify")
+```
+
+## Discovering active players
+
+If you want to see which players are running before taking action, list session bus services and filter for the MPRIS prefix:
+
+> "What media players are running right now?"
+
+```
+list_services(bus="session")
+```
+
+Claude scans the results for service names starting with `org.mpris.MediaPlayer2.` and reports what it finds. Common names include:
+
+- `org.mpris.MediaPlayer2.spotify`
+- `org.mpris.MediaPlayer2.firefox.instance_1_42`
+- `org.mpris.MediaPlayer2.vlc`
+- `org.mpris.MediaPlayer2.mpv`
+
+## Reading playback metadata
+
+To find out what is currently playing without changing anything, read the properties on the Player interface:
+
+> "What song is Spotify playing?"
+
+```
+get_all_properties(
+ bus="session",
+ service="org.mpris.MediaPlayer2.spotify",
+ object_path="/org/mpris/MediaPlayer2",
+ interface="org.mpris.MediaPlayer2.Player"
+)
+```
+
+The response includes a property table with:
+
+| Property | What it contains |
+|----------|-----------------|
+| `PlaybackStatus` | "Playing", "Paused", or "Stopped" |
+| `Metadata` | Dict with `xesam:title`, `xesam:artist`, `xesam:album`, `mpris:artUrl`, `mpris:length` |
+| `Volume` | Float from 0.0 to 1.0 |
+| `Position` | Current playback position in microseconds |
+| `CanNext` | Whether the player supports skipping forward |
+| `CanPrevious` | Whether the player supports skipping backward |
+
+## Example workflow
+
+A typical conversation might go:
+
+
+1. **Discover players.** Ask Claude to list session bus services. Claude finds `org.mpris.MediaPlayer2.spotify` and `org.mpris.MediaPlayer2.firefox.instance_1_12`.
+
+2. **Check what is playing.** Ask Claude to read the Metadata and PlaybackStatus properties on the Spotify player. Claude reports the current track, artist, album, and that playback is active.
+
+3. **Skip to the next track.** Ask Claude to send the Next command to Spotify. Claude calls `media_player_control(action="next", player="org.mpris.MediaPlayer2.spotify")` and confirms the new track.
+
+
+
+
+## Adjusting volume
+
+Volume is a writable property on the Player interface. To change it, use `set_property`:
+
+> "Set Spotify's volume to 50%."
+
+```
+set_property(
+ bus="session",
+ service="org.mpris.MediaPlayer2.spotify",
+ object_path="/org/mpris/MediaPlayer2",
+ interface="org.mpris.MediaPlayer2.Player",
+ property_name="Volume",
+ value="0.5",
+ signature="d"
+)
+```
+
+
+
+## Going deeper with raw method calls
+
+For actions beyond the shortcut tool -- like setting the playback position, opening a specific URI, or reading the top-level `org.mpris.MediaPlayer2` interface (which has properties like Identity, DesktopEntry, and SupportedMimeTypes) -- use `introspect` to see what is available, then `call_method` to invoke it directly.
+
+> "Open this Spotify URI in the player."
+
+```
+call_method(
+ bus="session",
+ service="org.mpris.MediaPlayer2.spotify",
+ object_path="/org/mpris/MediaPlayer2",
+ interface="org.mpris.MediaPlayer2.Player",
+ method="OpenUri",
+ args='["spotify:track:4PTG3Z6ehGkBFwjybzWkR8"]',
+ signature="s"
+)
+```
diff --git a/docs-site/src/content/docs/guides/notifications.mdx b/docs-site/src/content/docs/guides/notifications.mdx
new file mode 100644
index 0000000..1a55c6a
--- /dev/null
+++ b/docs-site/src/content/docs/guides/notifications.mdx
@@ -0,0 +1,150 @@
+---
+title: Send Notifications
+description: Send desktop notifications from Claude using mcdbus's send_notification tool and the freedesktop Notifications interface.
+---
+
+import { Aside, Code } from '@astrojs/starlight/components';
+
+mcdbus provides a `send_notification` shortcut tool that sends desktop notifications through the standard `org.freedesktop.Notifications` interface on the session bus. Any notification daemon that implements this spec (dunst, mako, GNOME Shell, KDE Plasma) will display them.
+
+## Basic usage
+
+The only required parameter is the summary (title):
+
+> "Send me a notification that says the build is done."
+
+```
+send_notification(summary="Build complete")
+```
+
+This sends a notification with the title "Build complete", no body text, the default icon, and a 5-second timeout.
+
+## Adding body text
+
+The `body` parameter accepts plain text or basic HTML markup (depending on your notification daemon):
+
+> "Notify me that the deploy finished with details about what was deployed."
+
+```
+send_notification(
+ summary="Deploy finished",
+ body="Pushed v2026.03.06 to production. All health checks passing."
+)
+```
+
+## Choosing an icon
+
+The `icon` parameter follows the freedesktop icon naming specification. Your desktop theme provides the actual icon files. Common names include:
+
+| Icon name | Typical use |
+|-----------|------------|
+| `dialog-information` | General information |
+| `dialog-warning` | Warning conditions |
+| `dialog-error` | Error conditions |
+| `process-completed` | Task completion |
+| `network-wireless` | Network-related |
+| `battery-low` | Battery alerts |
+
+> "Send a warning notification about high memory usage."
+
+```
+send_notification(
+ summary="High memory usage",
+ body="System memory at 92%. Consider closing unused applications.",
+ icon="dialog-warning"
+)
+```
+
+You can also pass an absolute file path to use a custom icon image:
+
+```
+send_notification(
+ summary="Screenshot saved",
+ icon="/tmp/screenshot-thumbnail.png"
+)
+```
+
+## Controlling display duration
+
+The `timeout` parameter sets how long the notification stays visible, in milliseconds. The default is 5000 (5 seconds).
+
+```
+send_notification(
+ summary="Quick heads-up",
+ body="This disappears fast.",
+ timeout=2000
+)
+```
+
+A timeout of `0` tells the notification server to use its own default duration. Some servers treat `0` as "never expire" -- the notification stays until the user dismisses it.
+
+
+
+## What happens under the hood
+
+The `send_notification` tool calls `org.freedesktop.Notifications.Notify` on the session bus with the D-Bus signature `susssasa{sv}i`. The parameters map to:
+
+| Position | Signature | Parameter | Value |
+|----------|-----------|-----------|-------|
+| 0 | `s` | app_name | `"mcdbus"` |
+| 1 | `u` | replaces_id | `0` (new notification) |
+| 2 | `s` | app_icon | Your `icon` value |
+| 3 | `s` | summary | Your `summary` value |
+| 4 | `s` | body | Your `body` value |
+| 5 | `as` | actions | `[]` (empty) |
+| 6 | `a{sv}` | hints | `{}` (empty) |
+| 7 | `i` | expire_timeout | Your `timeout` value |
+
+The server returns a notification ID (an unsigned integer). The tool reports this ID in its response.
+
+## Replacing an existing notification
+
+The shortcut tool always sends `replaces_id=0`, which creates a new notification. To update or replace an existing notification, use `call_method` directly and pass the notification ID you received from a previous call:
+
+> "Update notification 42 with new progress information."
+
+```
+call_method(
+ bus="session",
+ service="org.freedesktop.Notifications",
+ object_path="/org/freedesktop/Notifications",
+ interface="org.freedesktop.Notifications",
+ method="Notify",
+ args='["mcdbus", 42, "", "Build progress", "Step 3 of 5 complete", [], {}, 5000]',
+ signature="susssasa{sv}i"
+)
+```
+
+## Closing a notification
+
+To programmatically close a notification by its ID:
+
+```
+call_method(
+ bus="session",
+ service="org.freedesktop.Notifications",
+ object_path="/org/freedesktop/Notifications",
+ interface="org.freedesktop.Notifications",
+ method="CloseNotification",
+ args='[42]',
+ signature="u"
+)
+```
+
+## Querying the notification server
+
+To find out which features your notification daemon supports:
+
+```
+call_method(
+ bus="session",
+ service="org.freedesktop.Notifications",
+ object_path="/org/freedesktop/Notifications",
+ interface="org.freedesktop.Notifications",
+ method="GetServerInformation"
+)
+```
+
+This returns the server name, vendor, version, and spec version. You can also call `GetCapabilities` to see which features (like `body-markup`, `actions`, `persistence`) are supported.
diff --git a/docs-site/src/content/docs/guides/permissions.mdx b/docs-site/src/content/docs/guides/permissions.mdx
new file mode 100644
index 0000000..afa544e
--- /dev/null
+++ b/docs-site/src/content/docs/guides/permissions.mdx
@@ -0,0 +1,468 @@
+---
+title: Lock Down Permissions
+description: Deploy mcdbus with systemd sandboxing, D-Bus bus policy, polkit rules, and xdg-dbus-proxy for layered security.
+---
+
+import { Aside, Steps, Code, Tabs, TabItem } from '@astrojs/starlight/components';
+
+mcdbus delegates all security to the operating system. It does not implement its own access control. This guide covers four independent layers you can deploy to restrict what mcdbus can do. They are all optional, all independent, and all stack on top of each other.
+
+For the design rationale behind this approach, see [Security Layers](/explanation/security-layers/).
+
+## 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) |
+
+## 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 declarative directives.
+
+### The unit file
+
+```ini
+[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
+DynamicUser=yes
+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=
+RestrictAddressFamilies=AF_UNIX
+PrivateNetwork=no
+CapabilityBoundingSet=
+AmbientCapabilities=
+NoNewPrivileges=yes
+SystemCallFilter=@system-service
+SystemCallArchitectures=native
+SystemCallErrorNumber=EPERM
+MemoryDenyWriteExecute=yes
+LockPersonality=yes
+RestrictRealtime=yes
+RestrictSUIDSGID=yes
+RemoveIPC=yes
+RestrictNamespaces=yes
+Environment=MCDBUS_TIMEOUT=30
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=mcdbus
+MemoryMax=256M
+TasksMax=64
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=default.target
+```
+
+### What it does
+
+`DynamicUser=yes` creates an ephemeral Unix user for each invocation. No home directory, no persistent UID, no leftover state.
+
+**Filesystem restrictions:** `ProtectSystem=strict` makes `/usr`, `/boot`, and `/efi` read-only. `ProtectHome=yes` hides `/home`, `/root`, and `/run/user` entirely. `PrivateTmp=yes` gives the process its own `/tmp`.
+
+**Network restrictions:** `RestrictAddressFamilies=AF_UNIX` limits socket creation to Unix domain sockets. D-Bus only needs `AF_UNIX`, so this blocks any TCP, UDP, or raw socket activity.
+
+**Capabilities:** `CapabilityBoundingSet=` (empty) drops every Linux capability. The process cannot change file ownership, load kernel modules, bind privileged ports, or perform any other capability-gated operation.
+
+**Syscall filtering:** `SystemCallFilter=@system-service` restricts the process to the syscall set that a typical well-behaved service needs. Things like `mount`, `reboot`, and `kexec_load` are blocked. Blocked syscalls return `EPERM`.
+
+**Memory protection:** `MemoryDenyWriteExecute=yes` prevents mapping memory as both writable and executable, which stops most classes of code injection.
+
+**Resource limits:** `MemoryMax=256M` and `TasksMax=64` prevent runaway resource consumption.
+
+### Deploy it
+
+```bash
+sudo cp mcdbus.service /etc/systemd/system/
+sudo systemctl daemon-reload
+sudo systemctl enable --now mcdbus.service
+```
+
+### Test 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 unit file above should score above 7.0. The output lists each directive and its impact.
+
+Check the journal for sandbox-related denials:
+
+```bash
+journalctl -u mcdbus.service -f
+```
+
+### Environment variables
+
+The unit file sets `MCDBUS_TIMEOUT=30` by default. To override or add other variables, create 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 at the bus daemon (or bus broker) level. Policy files are XML documents that control which messages any client can send or receive based on the sender's Unix identity.
+
+### The policy file
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### How it works
+
+The policy engine evaluates rules top to bottom. The last matching rule wins. The pattern is: default deny everything, then selectively allow specific destinations, interfaces, and methods.
+
+Each `` or `` element can filter on `send_destination` (service name), `send_interface`, `send_member` (method name), and `send_type` (message type). Combining attributes narrows the filter -- an allow with both `send_destination` and `send_member` only permits that specific method on that specific service.
+
+
+
+### Deploy it
+
+For the system bus:
+
+```bash
+sudo cp mcdbus.conf /etc/dbus-1/system.d/
+sudo systemctl reload dbus.service
+```
+
+For `dbus-broker` instead of `dbus-daemon`:
+
+```bash
+sudo systemctl reload dbus-broker.service
+```
+
+### Test it
+
+Denied messages appear in the bus daemon journal:
+
+```bash
+journalctl -u dbus-broker.service --since "5 min ago" | grep -i deny
+```
+
+---
+
+## Layer 3: polkit rules
+
+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.
+
+### The rules file
+
+```javascript
+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
+ 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
+ var udisksReadActions = [
+ "org.freedesktop.udisks2.ata-smart-selftest"
+ ];
+
+ if (systemdReadActions.indexOf(action.id) >= 0) {
+ if (subject.local && subject.active) {
+ return polkit.Result.YES;
+ }
+ }
+
+ if (nmReadActions.indexOf(action.id) >= 0) {
+ if (subject.local && subject.active) {
+ return polkit.Result.YES;
+ }
+ }
+
+ if (upowerActions.indexOf(action.id) >= 0) {
+ return polkit.Result.YES;
+ }
+
+ if (udisksReadActions.indexOf(action.id) >= 0) {
+ if (subject.local && subject.active) {
+ return polkit.Result.YES;
+ }
+ }
+
+ return polkit.Result.NOT_HANDLED;
+});
+```
+
+### Create the group and deploy
+
+```bash
+sudo groupadd mcdbus
+sudo usermod -aG mcdbus $(whoami)
+# Log out and back in for the group membership to take effect
+
+sudo cp 50-mcdbus.rules /etc/polkit-1/rules.d/
+sudo systemctl restart polkit.service
+```
+
+
+
+### Test it
+
+```bash
+pkcheck --action-id org.freedesktop.systemd1.manage-units \
+ --process $$ --allow-user-interaction
+```
+
+List all registered polkit actions to find the ones relevant to your deployment:
+
+```bash
+pkaction | grep -E 'systemd|NetworkManager|UPower|udisks'
+```
+
+---
+
+## Layer 4: xdg-dbus-proxy
+
+`xdg-dbus-proxy` (part of Flatpak) creates a filtered D-Bus socket. You point it at the real bus socket, declare which services to expose, and it creates a new socket that only passes through matching messages. mcdbus connects to the proxy socket and never sees the rest of the bus.
+
+### The proxy script
+
+```bash
+#!/usr/bin/env bash
+set -euo pipefail
+
+MCDBUS_CMD="${MCDBUS_CMD:-mcdbus}"
+
+if ! command -v xdg-dbus-proxy &>/dev/null; then
+ echo "error: xdg-dbus-proxy not found. Install it first." >&2
+ exit 1
+fi
+
+if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]; then
+ echo "error: DBUS_SESSION_BUS_ADDRESS is not set." >&2
+ exit 1
+fi
+
+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
+
+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=$!
+
+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
+
+export DBUS_SESSION_BUS_ADDRESS="unix:path=${PROXY_SOCKET}"
+exec $MCDBUS_CMD
+```
+
+### Policy levels
+
+xdg-dbus-proxy supports three levels of access per service:
+
+| Level | Effect |
+|-------|--------|
+| `--see=NAME` | Service appears in `ListNames` and can be introspected, but method calls are blocked. Read-only visibility. |
+| `--talk=NAME` | Full bidirectional communication. Method calls, property reads and 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
+--call=org.freedesktop.UPower=org.freedesktop.DBus.Properties.GetAll@/org/freedesktop/UPower/*
+```
+
+### Install and deploy
+
+
+
+```bash
+sudo pacman -S xdg-dbus-proxy
+```
+
+
+```bash
+sudo apt install xdg-dbus-proxy
+```
+
+
+```bash
+sudo dnf install xdg-dbus-proxy
+```
+
+
+
+```bash
+chmod +x mcdbus-proxy.sh
+./mcdbus-proxy.sh
+```
+
+Or integrate it into the systemd unit:
+
+```ini
+[Service]
+ExecStart=/usr/local/bin/mcdbus-proxy.sh
+```
+
+---
+
+## Choosing layers
+
+**Layer 1 alone** is sufficient for most deployments. It costs nothing to deploy and provides broad process-level isolation.
+
+**Layers 1 + 2 + 3** together provide defense in depth for high-security environments or shared systems. The bus policy restricts which services mcdbus can talk to, and polkit controls which privileged operations are authorized.
+
+**Layer 4** fills the gap when you need fine-grained session bus filtering that the other layers cannot express. The trade-off is an additional proxy process.
+
+All four layers are independent. Deploy whichever combination matches your threat model.
diff --git a/docs-site/src/content/docs/guides/system-status.mdx b/docs-site/src/content/docs/guides/system-status.mdx
new file mode 100644
index 0000000..8c2878f
--- /dev/null
+++ b/docs-site/src/content/docs/guides/system-status.mdx
@@ -0,0 +1,121 @@
+---
+title: Check Battery and Network
+description: Read battery charge level and network connection status using mcdbus's shortcut tools for UPower and NetworkManager.
+---
+
+import { Aside, Code } from '@astrojs/starlight/components';
+
+mcdbus includes two read-only shortcut tools for checking hardware status: `battery_status` reads from UPower on the system bus, and `network_status` reads from NetworkManager on the system bus. Both tools only read properties -- they do not change any state and require no confirmation prompt.
+
+## Battery status
+
+> "What's my battery level?"
+
+```
+battery_status()
+```
+
+The tool enumerates all power devices through UPower, filters for battery-type devices (type 2 in the UPower spec), and returns a summary for each battery found:
+
+```markdown
+## Battery Status
+
+### DELL XXXX
+- **Charge:** 73%
+- **State:** Discharging
+- **Time:** 2h 14m remaining
+- **Power draw:** 12.3 W
+```
+
+### What it reads
+
+The tool calls `org.freedesktop.UPower.EnumerateDevices` to find all power devices, then reads `org.freedesktop.UPower.Device` properties on each one. The fields reported are:
+
+| Field | Source property | Notes |
+|-------|---------------|-------|
+| Charge | `Percentage` | 0-100 float |
+| State | `State` | Mapped from integer: 1=Charging, 2=Discharging, 3=Empty, 4=Fully charged, 5=Pending charge, 6=Pending discharge |
+| Time remaining | `TimeToEmpty` | Seconds, shown as hours and minutes when discharging |
+| Time to full | `TimeToFull` | Seconds, shown when charging |
+| Power draw | `EnergyRate` | Watts |
+| Model | `Model` | Battery model string from the hardware |
+
+### Reading additional battery properties
+
+The shortcut tool shows the most useful fields. For deeper information (voltage, energy capacity, charge cycles, technology), use `get_all_properties` on the battery device path:
+
+```
+get_all_properties(
+ bus="system",
+ service="org.freedesktop.UPower",
+ object_path="/org/freedesktop/UPower/devices/battery_BAT0",
+ interface="org.freedesktop.UPower.Device"
+)
+```
+
+
+
+### No battery found
+
+On desktop machines without a battery, `battery_status` returns "No batteries found." This is normal -- UPower may still report other device types like line power or UPS units, but the tool filters for batteries specifically.
+
+## Network status
+
+> "Am I connected to the internet?"
+
+```
+network_status()
+```
+
+The tool reads properties from `org.freedesktop.NetworkManager` on the system bus and returns overall connectivity state, wireless status, and a table of active connections:
+
+```markdown
+## Network Status
+
+- **State:** Connected (global)
+- **Wireless:** enabled
+
+| Connection | Type | State |
+|------------|------|-------|
+| Ethernet 1 | 802-3-ethernet | Activated |
+| MyWiFi | 802-11-wireless | Activated |
+```
+
+### What it reads
+
+The tool calls `Properties.GetAll` on the `org.freedesktop.NetworkManager` interface to get the top-level state, then iterates over `ActiveConnections` to read each connection's properties.
+
+**Top-level properties:**
+
+| Field | Source property | Notes |
+|-------|---------------|-------|
+| State | `State` | Integer mapped to: 0=Unknown, 10=Asleep, 20=Disconnected, 30=Disconnecting, 40=Connecting, 50=Connected (local), 60=Connected (site), 70=Connected (global) |
+| Wireless | `WirelessEnabled` | Boolean |
+
+**Per-connection properties** (read from `org.freedesktop.NetworkManager.Connection.Active` on each active connection object path):
+
+| Field | Source property |
+|-------|---------------|
+| Connection | `Id` |
+| Type | `Type` |
+| State | `State` (mapped: 1=Activating, 2=Activated, 3=Deactivating, 4=Deactivated) |
+
+### Reading more network details
+
+For detailed information about a specific network device (IP addresses, DNS servers, signal strength for wireless), use `get_all_properties` on the device's object path. Network devices live under `/org/freedesktop/NetworkManager/Devices/`:
+
+```
+list_objects(
+ bus="system",
+ service="org.freedesktop.NetworkManager",
+ root_path="/org/freedesktop/NetworkManager/Devices"
+)
+```
+
+Then introspect a specific device to see which interfaces it exposes (Wired, Wireless, Statistics) and read their properties.
+
+
diff --git a/docs-site/src/content/docs/guides/systemd.mdx b/docs-site/src/content/docs/guides/systemd.mdx
new file mode 100644
index 0000000..6f4502f
--- /dev/null
+++ b/docs-site/src/content/docs/guides/systemd.mdx
@@ -0,0 +1,148 @@
+---
+title: Manage Systemd Units
+description: List and inspect systemd services, timers, and sockets using mcdbus's list_systemd_units tool and raw D-Bus introspection.
+---
+
+import { Aside, Steps, Code } from '@astrojs/starlight/components';
+
+systemd exposes its manager interface on the system D-Bus bus at `org.freedesktop.systemd1`. The `list_systemd_units` shortcut tool wraps the `ListUnits` method and returns a markdown table you can filter with glob patterns.
+
+## Listing units
+
+The simplest call lists every loaded unit:
+
+> "Show me all systemd services."
+
+```
+list_systemd_units(bus="system")
+```
+
+This returns a table like:
+
+| Unit | Load | Active | Sub | Description |
+|------|------|--------|-----|-------------|
+| `dbus.service` | loaded | active | running | D-Bus System Message Bus |
+| `docker.service` | loaded | active | running | Docker Application Container Engine |
+| `sshd.service` | loaded | active | running | OpenSSH Daemon |
+
+### Filtering with glob patterns
+
+The `pattern` parameter accepts standard glob syntax. It matches against the unit name column.
+
+> "Which Docker-related units are loaded?"
+
+```
+list_systemd_units(bus="system", pattern="docker*")
+```
+
+> "Show me all timer units."
+
+```
+list_systemd_units(bus="system", pattern="*.timer")
+```
+
+> "Are there any failed services?"
+
+```
+list_systemd_units(bus="system", pattern="*.service")
+```
+
+Claude reads the returned table and filters the Active column for "failed" entries.
+
+### User session units
+
+systemd also manages per-user services on the session bus. Pass `bus="session"` to see them:
+
+> "List my user-level systemd services."
+
+```
+list_systemd_units(bus="session", pattern="*.service")
+```
+
+This shows units managed by `systemctl --user` -- things like Pipewire, the XDG desktop portal, and any user-installed services.
+
+## Reading unit properties
+
+Each systemd unit has an object path under `/org/freedesktop/systemd1/unit/`. To get detailed properties for a specific unit, use `get_all_properties` on its object path.
+
+
+1. **Find the unit's object path.** The path encodes the unit name with underscores replacing special characters. For `docker.service`, the path is `/org/freedesktop/systemd1/unit/docker_2eservice`.
+
+2. **Read its properties.** Ask Claude to read all properties on the `org.freedesktop.systemd1.Unit` interface:
+
+ ```
+ get_all_properties(
+ bus="system",
+ service="org.freedesktop.systemd1",
+ object_path="/org/freedesktop/systemd1/unit/docker_2eservice",
+ interface="org.freedesktop.systemd1.Unit"
+ )
+ ```
+
+3. **Interpret the results.** The properties include `ActiveState`, `SubState`, `LoadState`, `Description`, `ActiveEnterTimestamp`, `InactiveEnterTimestamp`, `MainPID`, memory and CPU accounting values, and the full dependency graph (`Requires`, `After`, `WantedBy`).
+
+
+
+
+## Inspecting the manager interface
+
+The systemd manager itself sits at `/org/freedesktop/systemd1` and exposes methods for starting, stopping, restarting, and reloading units. To see everything available:
+
+> "What methods does the systemd manager expose?"
+
+```
+introspect(
+ bus="system",
+ service="org.freedesktop.systemd1",
+ object_path="/org/freedesktop/systemd1",
+ interface_filter="org.freedesktop.systemd1.Manager"
+)
+```
+
+This returns the full list of methods (StartUnit, StopUnit, RestartUnit, ReloadUnit, ListUnitFiles, and many more), properties (Version, Features, Virtualization), and signals (UnitNew, UnitRemoved, JobNew).
+
+## Calling systemd methods
+
+Because systemd lives on the system bus, `call_method` triggers a confirmation prompt before execution. This protects against accidental service disruption.
+
+> "Restart the nginx service."
+
+```
+call_method(
+ bus="system",
+ service="org.freedesktop.systemd1",
+ object_path="/org/freedesktop/systemd1",
+ interface="org.freedesktop.systemd1.Manager",
+ method="RestartUnit",
+ args='["nginx.service", "replace"]',
+ signature="ss"
+)
+```
+
+Claude presents the confirmation prompt. After you approve, systemd restarts the unit and returns the job object path.
+
+
+
+## Common patterns
+
+**Find all failed services and read their status:**
+
+Ask Claude to list all `.service` units, identify any with `Active=failed`, then read their properties to see the `Result`, `ExecMainStatus`, and log output hints.
+
+**Check when a service last restarted:**
+
+Read the `ActiveEnterTimestamp` property on the unit's object path. systemd reports this as a microsecond Unix timestamp.
+
+**List all enabled timers and their next trigger time:**
+
+List units matching `*.timer`, then for each timer read the `NextElapseUSecRealtime` property from the `org.freedesktop.systemd1.Timer` interface on the timer's object path.
diff --git a/docs-site/src/content/docs/index.mdx b/docs-site/src/content/docs/index.mdx
new file mode 100644
index 0000000..243ea9e
--- /dev/null
+++ b/docs-site/src/content/docs/index.mdx
@@ -0,0 +1,55 @@
+---
+title: mcdbus
+description: D-Bus MCP server — give Claude access to your Linux desktop bus
+template: splash
+hero:
+ tagline: Bridge Linux IPC into the Model Context Protocol. Discover and interact with session and system D-Bus services through introspection-first discovery.
+ actions:
+ - text: Get Started
+ link: /start-here/overview/
+ icon: right-arrow
+ - text: View on PyPI
+ link: https://pypi.org/project/mcdbus/
+ variant: minimal
+---
+
+import { Card, CardGrid, LinkCard } from '@astrojs/starlight/components';
+
+```bash
+# Install and register with Claude Code in one shot
+claude mcp add mcdbus -- uvx mcdbus
+```
+
+No configuration files, no service definitions, no API keys. The server discovers what is available on your D-Bus at runtime.
+
+
+
+ `list_services`, `introspect`, and `list_objects` map out your bus. Start from nothing and build a complete picture of any service — its object tree, interfaces, methods, properties, and signals.
+
+
+
+ `call_method`, `get_property`, `set_property`, and `get_all_properties` let Claude read state and invoke operations on any D-Bus service. System bus calls and property mutations require confirmation.
+
+
+
+ Pre-wired tools for common operations: desktop notifications, MPRIS media player control, systemd unit listing, NetworkManager status, UPower battery state, bluez Bluetooth devices, and KDE KWin window listing.
+
+
+
+
+
+
+
+
diff --git a/docs-site/src/content/docs/reference/discovery-tools.mdx b/docs-site/src/content/docs/reference/discovery-tools.mdx
new file mode 100644
index 0000000..53b4e0f
--- /dev/null
+++ b/docs-site/src/content/docs/reference/discovery-tools.mdx
@@ -0,0 +1,148 @@
+---
+title: Discovery Tools
+description: Tools for listing D-Bus services, introspecting objects, and walking object trees.
+---
+
+The discovery tools form the first step in any D-Bus interaction. They answer the questions "what services exist?", "what does this object expose?", and "what objects does this service own?".
+
+## list_services
+
+List well-known D-Bus service names on a bus.
+
+### Parameters
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `bus` | `string` | *required* | `"session"` or `"system"` |
+| `include_unique` | `bool` | `false` | Include unique connection names like `:1.42` |
+
+### Return format
+
+Markdown list with a count header, sorted alphabetically.
+
+```markdown
+## D-Bus session bus — 47 services
+
+- `org.freedesktop.DBus`
+- `org.freedesktop.Notifications`
+- `org.freedesktop.portal.Desktop`
+- `org.kde.KWin`
+- `org.mpris.MediaPlayer2.firefox`
+```
+
+When `include_unique` is `true`, unique connection names (those starting with `:`) are included in the list alongside well-known names.
+
+### Example
+
+```
+list_services(bus="session")
+```
+
+---
+
+## introspect
+
+Introspect a D-Bus object to see its interfaces, methods, properties, and signals. Reports progress from 0 to 4 as it connects, introspects, formats, and completes.
+
+### Parameters
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `bus` | `string` | *required* | `"session"` or `"system"` |
+| `service` | `string` | *required* | Service name, e.g. `"org.freedesktop.Notifications"` |
+| `object_path` | `string` | *required* | Object path, e.g. `"/org/freedesktop/Notifications"` |
+| `include_standard` | `bool` | `false` | Include standard D-Bus interfaces (Peer, Introspectable, Properties, ObjectManager) |
+
+### Return format
+
+Markdown document listing each interface with its methods (including in/out argument signatures), properties (with D-Bus type signature and access mode), signals, and child nodes.
+
+```markdown
+## `org.freedesktop.Notifications` at `/org/freedesktop/Notifications`
+
+### Interface: `org.freedesktop.Notifications`
+
+**Methods:**
+- `GetCapabilities() -> capabilities: as`
+- `Notify(app_name: s, replaces_id: u, app_icon: s, summary: s, body: s, actions: as, hints: a{sv}, expire_timeout: i) -> id: u`
+- `CloseNotification(id: u)`
+- `GetServerInformation() -> name: s, vendor: s, version: s, spec_version: s`
+
+**Signals:**
+- `NotificationClosed(id: u, reason: u)`
+- `ActionInvoked(id: u, action_key: s)`
+```
+
+Standard interfaces (`org.freedesktop.DBus.Peer`, `org.freedesktop.DBus.Introspectable`, `org.freedesktop.DBus.Properties`, `org.freedesktop.DBus.ObjectManager`) are hidden by default. Set `include_standard` to `true` to show them.
+
+### Example
+
+```
+introspect(
+ bus="session",
+ service="org.freedesktop.Notifications",
+ object_path="/org/freedesktop/Notifications"
+)
+```
+
+---
+
+## list_objects
+
+Recursively walk the D-Bus object tree for a service using breadth-first search, bounded by depth and node count.
+
+### Parameters
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `bus` | `string` | *required* | `"session"` or `"system"` |
+| `service` | `string` | *required* | Service name |
+| `root_path` | `string` | `"/"` | Starting path for the walk |
+| `max_depth` | `int` | `20` | Maximum tree depth to descend |
+
+### Bounds
+
+The walk enforces two safety limits:
+
+| Limit | Value | Behavior when hit |
+|-------|-------|-------------------|
+| `MAX_TREE_NODES` | 500 | Walk stops, output marked as truncated |
+| `TOTAL_WALK_TIMEOUT` | 60 seconds | Walk stops with a warning logged via context |
+
+Progress is reported as `(nodes_visited, None)` during the walk, so clients that support progress tracking can display a counter.
+
+### Return format
+
+Sorted list of object paths. Each path shows its non-standard interfaces (standard `org.freedesktop.DBus.*` interfaces are omitted for clarity). When limits are hit, the header indicates truncation.
+
+```markdown
+## Object tree for `org.freedesktop.systemd1` on system bus — 412 objects
+
+- `/` -- `org.freedesktop.systemd1.Manager`
+- `/org/freedesktop/systemd1/job/12345`
+- `/org/freedesktop/systemd1/unit/bluetooth_2eservice` -- `org.freedesktop.systemd1.Unit`, `org.freedesktop.systemd1.Service`
+- `/org/freedesktop/systemd1/unit/docker_2eservice` -- `org.freedesktop.systemd1.Unit`, `org.freedesktop.systemd1.Service`
+```
+
+When truncated:
+
+```markdown
+## Object tree for `org.bluez` on system bus — 500 objects (truncated at 500)
+```
+
+### Example
+
+```
+list_objects(bus="system", service="org.freedesktop.systemd1")
+```
+
+Walk a subtree to narrow results:
+
+```
+list_objects(
+ bus="system",
+ service="org.freedesktop.systemd1",
+ root_path="/org/freedesktop/systemd1/unit",
+ max_depth=2
+)
+```
diff --git a/docs-site/src/content/docs/reference/environment-variables.mdx b/docs-site/src/content/docs/reference/environment-variables.mdx
new file mode 100644
index 0000000..ec72d59
--- /dev/null
+++ b/docs-site/src/content/docs/reference/environment-variables.mdx
@@ -0,0 +1,161 @@
+---
+title: Environment Variables
+description: Configuration variables that control mcdbus server behavior, confirmation flow, and timeouts.
+---
+
+import { Tabs, TabItem } from '@astrojs/starlight/components';
+
+## Summary
+
+| Variable | Default | Purpose |
+|----------|---------|---------|
+| `MCDBUS_NOTIFY_CONFIRM` | unset | Enable desktop notification fallback for confirmations |
+| `MCDBUS_REQUIRE_ELICITATION` | unset | Hard-fail if MCP elicitation is unavailable |
+| `MCDBUS_TIMEOUT` | `30` | Timeout in seconds for individual D-Bus method calls |
+
+---
+
+## MCDBUS_NOTIFY_CONFIRM
+
+**Default:** unset (disabled)
+
+Enables a desktop notification fallback when the MCP client does not support the elicitation protocol. When set to any non-empty value, confirmation-gated operations (system bus method calls and all property mutations) will display a desktop notification with **Approve** and **Deny** action buttons instead of failing or proceeding silently.
+
+### How it works
+
+1. A tool requests user confirmation via `ctx.elicit()`
+2. The MCP client does not support elicitation (returns `CancelledElicitation` or throws)
+3. mcdbus sends an `org.freedesktop.Notifications.Notify` call on the session bus with:
+ - Urgency level `critical` (value `2`)
+ - Two action buttons: Approve and Deny
+ - Icon: `dialog-warning`
+4. The server subscribes to `ActionInvoked` and `NotificationClosed` signals
+5. If the user clicks **Approve** within 60 seconds, the operation proceeds
+6. If the user clicks **Deny**, dismisses the notification, or 60 seconds elapse, the operation is denied
+7. Match rules and the notification are cleaned up regardless of outcome
+
+If the notification service itself is unreachable (e.g. headless server, no desktop session), the fallback fails gracefully: a warning is logged to stderr and the operation proceeds.
+
+### Configuration
+
+
+
+
+```sh
+claude mcp add -e MCDBUS_NOTIFY_CONFIRM=1 mcdbus -- uvx mcdbus
+```
+
+
+
+
+```json
+{
+ "mcpServers": {
+ "mcdbus": {
+ "command": "uvx",
+ "args": ["mcdbus"],
+ "env": {
+ "MCDBUS_NOTIFY_CONFIRM": "1"
+ }
+ }
+ }
+}
+```
+
+
+
+
+---
+
+## MCDBUS_REQUIRE_ELICITATION
+
+**Default:** unset (disabled)
+
+When set to any non-empty value, mcdbus will raise a `ToolError` if the MCP client does not support the elicitation protocol. No fallback methods are attempted -- not desktop notifications, not silent proceed.
+
+This variable takes precedence over `MCDBUS_NOTIFY_CONFIRM`. If both are set, `MCDBUS_REQUIRE_ELICITATION` wins and the notification fallback is never tried.
+
+### Use case
+
+Deployments where unconfirmed state-changing operations must never proceed. If you are running mcdbus in an environment where a human must explicitly approve every system bus call and property mutation, set this variable to guarantee that requirement is enforced at the protocol level.
+
+### Configuration
+
+
+
+
+```sh
+claude mcp add -e MCDBUS_REQUIRE_ELICITATION=1 mcdbus -- uvx mcdbus
+```
+
+
+
+
+```json
+{
+ "mcpServers": {
+ "mcdbus": {
+ "command": "uvx",
+ "args": ["mcdbus"],
+ "env": {
+ "MCDBUS_REQUIRE_ELICITATION": "1"
+ }
+ }
+ }
+}
+```
+
+
+
+
+### Precedence
+
+| `MCDBUS_REQUIRE_ELICITATION` | `MCDBUS_NOTIFY_CONFIRM` | Behavior when elicitation unavailable |
+|-----|-----|-----|
+| unset | unset | Warning logged to stderr, operation proceeds |
+| unset | set | Desktop notification fallback attempted |
+| set | unset | `ToolError` raised |
+| set | set | `ToolError` raised (require wins) |
+
+---
+
+## MCDBUS_TIMEOUT
+
+**Default:** `30` (seconds)
+
+Sets the timeout for individual D-Bus method calls made through `call_bus_method`. This applies to all tool invocations that make D-Bus calls, including discovery, interaction, and shortcut tools.
+
+The value is parsed as a float, so fractional seconds are supported (e.g. `"2.5"`).
+
+### Relationship to other timeouts
+
+The `list_objects` tool enforces its own `TOTAL_WALK_TIMEOUT` of 60 seconds for the entire breadth-first walk. This is a wall-clock deadline for the full tree traversal, separate from the per-call timeout. Each individual `introspect` call within the walk still respects `MCDBUS_TIMEOUT`. A slow service that takes 25 seconds per introspection call would hit the walk timeout after two or three nodes even though no single call timed out.
+
+### Configuration
+
+
+
+
+```sh
+claude mcp add -e MCDBUS_TIMEOUT=10 mcdbus -- uvx mcdbus
+```
+
+
+
+
+```json
+{
+ "mcpServers": {
+ "mcdbus": {
+ "command": "uvx",
+ "args": ["mcdbus"],
+ "env": {
+ "MCDBUS_TIMEOUT": "10"
+ }
+ }
+ }
+}
+```
+
+
+
diff --git a/docs-site/src/content/docs/reference/interaction-tools.mdx b/docs-site/src/content/docs/reference/interaction-tools.mdx
new file mode 100644
index 0000000..67d9b3d
--- /dev/null
+++ b/docs-site/src/content/docs/reference/interaction-tools.mdx
@@ -0,0 +1,241 @@
+---
+title: Interaction Tools
+description: Tools for calling D-Bus methods, reading properties, and setting property values.
+---
+
+import { Tabs, TabItem } from '@astrojs/starlight/components';
+
+The interaction tools operate on specific D-Bus objects once you know their service name, object path, and interface. Use the [discovery tools](/reference/discovery-tools/) to find those coordinates first.
+
+## call_method
+
+Call a D-Bus method and return the result.
+
+### Parameters
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `bus` | `string` | *required* | `"session"` or `"system"` |
+| `service` | `string` | *required* | Service name, e.g. `"org.freedesktop.Notifications"` |
+| `object_path` | `string` | *required* | Object path, e.g. `"/org/freedesktop/Notifications"` |
+| `interface` | `string` | *required* | Interface name, e.g. `"org.freedesktop.Notifications"` |
+| `method` | `string` | *required* | Method name, e.g. `"Notify"` |
+| `args` | `string` | `"[]"` | JSON array of arguments |
+| `signature` | `string` | `""` | D-Bus type signature string; leave empty for no-arg methods |
+
+### Confirmation
+
+System bus calls require user confirmation before execution. Session bus calls proceed without prompting (they operate within user scope). See [Confirmation Flow](/explanation/confirmation-flow/) for details on how confirmation is resolved.
+
+### Argument parsing
+
+The `args` parameter accepts a JSON array string. Each element is deserialized according to the corresponding type in the `signature` string.
+
+For Variant (`v`) signatures, two modes are supported:
+
+
+
+
+Simple Python types are automatically wrapped into D-Bus Variants:
+
+| Python type | Inferred signature |
+|-------------|--------------------|
+| `bool` | `"b"` |
+| `str` | `"s"` |
+| `int` | `"i"` |
+| `float` | `"d"` |
+| `list` | `"as"` (array of strings) |
+| `dict` | `"a{sv}"` (dict of string to variant) |
+
+```json
+["hello", true, 42]
+```
+
+
+
+
+For complex variant values where auto-inference would produce the wrong type, pass an object with `signature` and `value` keys:
+
+```json
+[{"signature": "ai", "value": [1, 2, 3]}]
+```
+
+This wraps the array as `Variant("ai", [1, 2, 3])` rather than the auto-inferred `Variant("as", [1, 2, 3])`.
+
+
+
+
+### Return format
+
+- Single-element responses are unwrapped from their containing array for cleaner output
+- Multi-element responses are returned as a JSON array
+- Void methods return the string `"Method returned no data (void)."`
+- All values are JSON-formatted with 2-space indentation
+
+### Examples
+
+Send a desktop notification:
+
+```
+call_method(
+ bus="session",
+ service="org.freedesktop.Notifications",
+ object_path="/org/freedesktop/Notifications",
+ interface="org.freedesktop.Notifications",
+ method="Notify",
+ args='["mcdbus", 0, "", "Hello", "World", [], {}, 5000]',
+ signature="susssasa{sv}i"
+)
+```
+
+Call a no-arg method:
+
+```
+call_method(
+ bus="session",
+ service="org.freedesktop.Notifications",
+ object_path="/org/freedesktop/Notifications",
+ interface="org.freedesktop.Notifications",
+ method="GetServerInformation"
+)
+```
+
+---
+
+## get_property
+
+Read a single D-Bus property value.
+
+### Parameters
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `bus` | `string` | *required* | `"session"` or `"system"` |
+| `service` | `string` | *required* | Service name |
+| `object_path` | `string` | *required* | Object path |
+| `interface` | `string` | *required* | Interface that owns the property |
+| `property_name` | `string` | *required* | Property name to read |
+
+### How it works
+
+Calls `org.freedesktop.DBus.Properties.Get` with the specified interface and property name. The returned Variant is unwrapped and JSON-formatted.
+
+### Return format
+
+JSON-formatted property value with 2-space indentation. Returns `"No value returned."` if the property read yields no data.
+
+### Example
+
+```
+get_property(
+ bus="session",
+ service="org.mpris.MediaPlayer2.firefox",
+ object_path="/org/mpris/MediaPlayer2",
+ interface="org.mpris.MediaPlayer2.Player",
+ property_name="PlaybackStatus"
+)
+```
+
+Returns:
+
+```json
+"Playing"
+```
+
+---
+
+## set_property
+
+Set a D-Bus property value.
+
+### Parameters
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `bus` | `string` | *required* | `"session"` or `"system"` |
+| `service` | `string` | *required* | Service name |
+| `object_path` | `string` | *required* | Object path |
+| `interface` | `string` | *required* | Interface that owns the property |
+| `property_name` | `string` | *required* | Property name to set |
+| `value` | `string` | *required* | JSON-encoded value |
+| `signature` | `string` | *required* | D-Bus type signature of the property value (e.g. `"b"` for boolean) |
+
+### Confirmation
+
+This tool **always** requires user confirmation regardless of which bus is targeted. Property mutations are state-changing operations and the confirmation prompt is not skippable. See [Confirmation Flow](/explanation/confirmation-flow/) for the full resolution chain.
+
+### How it works
+
+1. The `value` JSON string is parsed
+2. The parsed value is wrapped in a `Variant` with the provided `signature`
+3. After confirmation, `org.freedesktop.DBus.Properties.Set` is called
+4. The operation is audit-logged to stderr with the bus, service, interface, property name, value, and signature
+
+### Return format
+
+Confirmation string: `Set {interface}.{property_name} = {value}`
+
+### Example
+
+```
+set_property(
+ bus="session",
+ service="org.mpris.MediaPlayer2.firefox",
+ object_path="/org/mpris/MediaPlayer2",
+ interface="org.mpris.MediaPlayer2.Player",
+ property_name="Volume",
+ value="0.5",
+ signature="d"
+)
+```
+
+---
+
+## get_all_properties
+
+Read all properties on a D-Bus interface.
+
+### Parameters
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `bus` | `string` | *required* | `"session"` or `"system"` |
+| `service` | `string` | *required* | Service name |
+| `object_path` | `string` | *required* | Object path |
+| `interface` | `string` | *required* | Interface to read properties from |
+
+### How it works
+
+Calls `org.freedesktop.DBus.Properties.GetAll` with the specified interface name. Returns all properties as a formatted table.
+
+### Return format
+
+Markdown table with Property and Value columns, sorted alphabetically by property name. Values are JSON-serialized and truncated at 80 characters to keep the table readable.
+
+```markdown
+## Properties of `org.mpris.MediaPlayer2.Player` at `/org/mpris/MediaPlayer2`
+
+| Property | Value |
+|----------|-------|
+| `CanControl` | `true` |
+| `CanGoNext` | `true` |
+| `CanGoPrevious` | `true` |
+| `CanPause` | `true` |
+| `CanPlay` | `true` |
+| `Metadata` | `{"xesam:title": "Starman", "xesam:artist": ["David Bowie"], "mpris:le...` |
+| `PlaybackStatus` | `"Playing"` |
+| `Volume` | `1.0` |
+```
+
+Returns `"No properties found."` if the interface exposes no properties.
+
+### Example
+
+```
+get_all_properties(
+ bus="session",
+ service="org.mpris.MediaPlayer2.firefox",
+ object_path="/org/mpris/MediaPlayer2",
+ interface="org.mpris.MediaPlayer2.Player"
+)
+```
diff --git a/docs-site/src/content/docs/reference/prompts.mdx b/docs-site/src/content/docs/reference/prompts.mdx
new file mode 100644
index 0000000..dea67a2
--- /dev/null
+++ b/docs-site/src/content/docs/reference/prompts.mdx
@@ -0,0 +1,67 @@
+---
+title: Prompts
+description: MCP prompt templates for guided D-Bus exploration and debugging workflows.
+---
+
+mcdbus provides two prompt templates that generate step-by-step instructions for common workflows. Prompts do not execute tools directly -- they produce text that guides the model through a sequence of tool calls.
+
+---
+
+## explore_service
+
+Generate a walkthrough for exploring a D-Bus service or an entire bus.
+
+### Parameters
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `bus` | `string` | `"session"` | `"session"` or `"system"` |
+| `service` | `string` | `""` | Service name to explore; omit for a full bus walkthrough |
+
+### Without a service (5 steps)
+
+When `service` is empty, the prompt generates a full exploration starting from the bus level:
+
+1. `list_services(bus="{bus}")` to see available services
+2. Pick a service and use `list_objects()` to see its object tree
+3. `introspect()` on interesting objects to see methods, properties, and signals
+4. `get_all_properties()` to read current state
+5. `call_method()` to interact with methods
+
+### With a service (4 steps)
+
+When a specific `service` is provided, the prompt skips service listing and starts from the object tree:
+
+1. `list_objects(bus="{bus}", service="{service}")` to see the object tree
+2. `introspect()` on interesting objects to see methods, properties, and signals
+3. `get_all_properties()` to read current state
+4. `call_method()` to interact with methods
+
+### When to use
+
+First encounter with an unfamiliar D-Bus service. The prompt provides a structured approach to understanding what a service exposes before interacting with it.
+
+---
+
+## debug_service
+
+Generate a diagnostic walkthrough for troubleshooting a D-Bus service.
+
+### Parameters
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `service` | `string` | *required* | Service name to debug |
+| `bus` | `string` | `"session"` | `"session"` or `"system"` |
+
+### Generated steps (5)
+
+1. **Check registration** -- `list_services(bus="{bus}")` to verify the service appears on the bus
+2. **Introspect root** -- `introspect(bus="{bus}", service="{service}", object_path="/")` to see the top-level structure
+3. **Walk the object tree** -- find all endpoints the service exposes
+4. **Read properties** -- read all properties on key interfaces to check state
+5. **Ping** -- call `Ping` on `org.freedesktop.DBus.Peer` to verify the service is responsive
+
+### When to use
+
+Troubleshooting a service that is not responding as expected, returning errors, or that you suspect may not be running. The diagnostic sequence moves from coarse (is it registered?) to fine (does it respond to pings?), helping isolate where the problem lies.
diff --git a/docs-site/src/content/docs/reference/resources.mdx b/docs-site/src/content/docs/reference/resources.mdx
new file mode 100644
index 0000000..5c4240b
--- /dev/null
+++ b/docs-site/src/content/docs/reference/resources.mdx
@@ -0,0 +1,127 @@
+---
+title: Resources
+description: MCP resource templates for browsing D-Bus services, object trees, and interfaces.
+---
+
+mcdbus exposes three MCP resource templates that provide read-only snapshots of D-Bus structure. Resources use a `dbus://` URI scheme and return plain-text, newline-separated data.
+
+## When to use resources vs tools
+
+**Resources** are best for loading context into a conversation. An MCP client can attach a resource URI and the server returns the current state as a text block. Resources are stateless, read-only, and produce compact output suitable for context windows.
+
+**Tools** are better for interactive exploration where you need formatted output, progress reporting, filtering options, or write operations. The `list_services` tool, for example, returns a Markdown-formatted list with a count header, while the `dbus://{bus}/services` resource returns raw service names one per line.
+
+Use resources when you want to seed a conversation with structural context. Use tools when you need to drill into specifics or take action.
+
+---
+
+## dbus://\{bus\}/services
+
+Live list of well-known service names on a D-Bus bus.
+
+### Template parameters
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `bus` | `string` | `"session"` or `"system"` |
+
+### Behavior
+
+Queries `org.freedesktop.DBus.ListNames` on each access. Unique connection names (those starting with `:`) are filtered out. The result is sorted alphabetically.
+
+### Return format
+
+Newline-separated list of well-known service names.
+
+```
+org.freedesktop.DBus
+org.freedesktop.Notifications
+org.freedesktop.portal.Desktop
+org.kde.KWin
+org.mpris.MediaPlayer2.firefox
+```
+
+### Example URI
+
+```
+dbus://session/services
+dbus://system/services
+```
+
+---
+
+## dbus://\{bus\}/\{service\}/objects
+
+Object tree for a D-Bus service, produced by a bounded breadth-first walk.
+
+### Template parameters
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `bus` | `string` | `"session"` or `"system"` |
+| `service` | `string` | Well-known service name |
+
+### Bounds
+
+| Limit | Value |
+|-------|-------|
+| Maximum nodes | 200 |
+| Maximum depth | 15 |
+
+These limits are tighter than the `list_objects` tool (which allows 500 nodes and depth 20) because resources are intended for context loading, not exhaustive enumeration.
+
+### Return format
+
+Newline-separated, sorted list of object paths.
+
+```
+/
+/org/freedesktop/Notifications
+```
+
+### Example URI
+
+```
+dbus://session/org.freedesktop.Notifications/objects
+dbus://system/org.freedesktop.systemd1/objects
+```
+
+---
+
+## dbus://\{bus\}/\{service\}/\{path\}/interfaces
+
+List of interfaces available at a specific D-Bus object path.
+
+### Template parameters
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `bus` | `string` | `"session"` or `"system"` |
+| `service` | `string` | Well-known service name |
+| `path` | `string` | URL-encoded object path |
+
+### Path encoding
+
+The `path` segment must be URL-encoded. Forward slashes become `%2F`. The server automatically URL-decodes the path and prepends a leading `/` if one is missing after decoding.
+
+| Object path | Encoded `path` segment |
+|-------------|----------------------|
+| `/org/freedesktop/Notifications` | `%2Forg%2Ffreedesktop%2FNotifications` |
+| `/` | `%2F` |
+
+### Return format
+
+Newline-separated list of interface names, including standard D-Bus interfaces.
+
+```
+org.freedesktop.DBus.Peer
+org.freedesktop.DBus.Introspectable
+org.freedesktop.DBus.Properties
+org.freedesktop.Notifications
+```
+
+### Example URI
+
+```
+dbus://session/org.freedesktop.Notifications/%2Forg%2Ffreedesktop%2FNotifications/interfaces
+```
diff --git a/docs-site/src/content/docs/reference/shortcut-tools.mdx b/docs-site/src/content/docs/reference/shortcut-tools.mdx
new file mode 100644
index 0000000..d393923
--- /dev/null
+++ b/docs-site/src/content/docs/reference/shortcut-tools.mdx
@@ -0,0 +1,228 @@
+---
+title: Shortcut Tools
+description: Pre-wired convenience tools for common D-Bus operations that skip the discovery step.
+---
+
+Shortcut tools wrap common D-Bus interactions into single calls with friendly parameters. They target specific well-known services and handle the service name, object path, interface, and method details internally.
+
+Each shortcut could be replicated using `call_method`, `get_property`, and `get_all_properties` from the [interaction tools](/reference/interaction-tools/), but shortcuts save the discovery round-trip and produce formatted output.
+
+---
+
+## send_notification
+
+Send a desktop notification via the freedesktop Notifications service on the session bus.
+
+### Parameters
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `summary` | `string` | *required* | Notification title |
+| `body` | `string` | `""` | Notification body text |
+| `icon` | `string` | `""` | Icon name or file path (empty for default) |
+| `timeout` | `int` | `5000` | Display duration in milliseconds |
+
+### D-Bus details
+
+Calls `org.freedesktop.Notifications.Notify` on the session bus with `app_name` set to `"mcdbus"` and `replaces_id` of `0` (always creates a new notification).
+
+### Return format
+
+```
+Notification sent (id: 42)
+```
+
+The returned ID can be used with `call_method` to call `CloseNotification` if needed.
+
+---
+
+## list_systemd_units
+
+List systemd units, optionally filtered by a glob pattern.
+
+### Parameters
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `bus` | `string` | `"system"` | `"session"` for user units, `"system"` for system units |
+| `pattern` | `string` | `""` | Glob filter using `fnmatch` syntax (e.g. `"docker*"`, `"*.service"`, `"ssh*"`) |
+
+### D-Bus details
+
+Calls `org.freedesktop.systemd1.Manager.ListUnits` and extracts the first five fields from each unit tuple: name, description, load state, active state, and sub state.
+
+### Return format
+
+Markdown table sorted alphabetically by unit name.
+
+```markdown
+## Systemd units on system bus -- 3 matches
+
+| Unit | Load | Active | Sub | Description |
+|------|------|--------|-----|-------------|
+| `docker.service` | loaded | active | running | Docker Application Container Engine |
+| `docker.socket` | loaded | active | listening | Docker Socket for the API |
+| `docker-compose@media.service` | loaded | active | running | Docker Compose stack: media |
+```
+
+---
+
+## media_player_control
+
+Control an MPRIS2-compatible media player on the session bus.
+
+### Parameters
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `action` | `string` | *required* | One of: `play`, `pause`, `next`, `previous`, `stop`, `play-pause` |
+| `player` | `string` | `""` | Full MPRIS service name; auto-discovers the first available player if empty |
+
+### Auto-discovery
+
+When `player` is empty, the tool lists all session bus services matching `org.mpris.MediaPlayer2.*`, sorts them alphabetically, and uses the first one. If multiple players are found, the others are logged via context info.
+
+### Return format
+
+```
+Player: org.mpris.MediaPlayer2.firefox
+Action: play-pause
+Status: Paused
+Now: David Bowie -- Starman
+```
+
+The current track is read from the player's `Metadata` property (`xesam:artist` and `xesam:title` fields). If metadata is unavailable, both fields show `"Unknown"`.
+
+Returns `"No MPRIS media players found on session bus."` if no players are discovered.
+
+---
+
+## network_status
+
+Show NetworkManager connection status and active connections. Takes no parameters.
+
+### D-Bus details
+
+Reads all properties from `org.freedesktop.NetworkManager` on the system bus, then iterates over each active connection path to read its `Id`, `Type`, and `State` properties.
+
+### State mapping
+
+The numeric `State` property is mapped to a human-readable string:
+
+| Code | State |
+|------|-------|
+| 0 | Unknown |
+| 10 | Asleep |
+| 20 | Disconnected |
+| 30 | Disconnecting |
+| 40 | Connecting |
+| 50 | Connected (local) |
+| 60 | Connected (site) |
+| 70 | Connected (global) |
+
+### Return format
+
+```markdown
+## Network Status
+
+- **State:** Connected (global)
+- **Wireless:** enabled
+
+| Connection | Type | State |
+|------------|------|-------|
+| Wired connection 1 | 802-3-ethernet | Activated |
+| MyWiFi | 802-11-wireless | Activated |
+```
+
+---
+
+## battery_status
+
+Show battery status from UPower on the system bus. Takes no parameters.
+
+### D-Bus details
+
+Calls `org.freedesktop.UPower.EnumerateDevices`, then reads properties from each device via `org.freedesktop.UPower.Device`. Only devices with `Type=2` (Battery) are included.
+
+### State mapping
+
+| Code | State |
+|------|-------|
+| 0 | Unknown |
+| 1 | Charging |
+| 2 | Discharging |
+| 3 | Empty |
+| 4 | Fully charged |
+| 5 | Pending charge |
+| 6 | Pending discharge |
+
+### Return format
+
+```markdown
+## Battery Status
+
+### BAT0
+- **Charge:** 73%
+- **State:** Discharging
+- **Time:** 4h 12m remaining
+- **Power draw:** 8.3 W
+```
+
+Time remaining is calculated from `TimeToEmpty` (when discharging) or `TimeToFull` (when charging). Power draw is shown only when `EnergyRate` is greater than zero. Returns `"No batteries found."` if no battery-type devices are present.
+
+---
+
+## bluetooth_devices
+
+List discovered and paired Bluetooth devices from bluez on the system bus. Takes no parameters.
+
+### D-Bus details
+
+Calls `org.freedesktop.DBus.ObjectManager.GetManagedObjects` on `org.bluez` at `/`, then filters for objects that implement the `org.bluez.Device1` interface.
+
+### Return format
+
+Markdown table sorted by connection priority: connected devices first, then paired devices, then alphabetically by name.
+
+```markdown
+## Bluetooth Devices -- 4 found
+
+| Name | Address | Connected | Paired | Trusted |
+|------|---------|-----------|--------|---------|
+| WH-1000XM4 | `AA:BB:CC:DD:EE:01` | yes | yes | yes |
+| Keyboard K380 | `AA:BB:CC:DD:EE:02` | no | yes | yes |
+| MX Master 3 | `AA:BB:CC:DD:EE:03` | no | yes | no |
+| [Unknown] | `AA:BB:CC:DD:EE:04` | no | no | no |
+```
+
+Returns `"No Bluetooth devices found."` if no `org.bluez.Device1` objects exist, or raises a `ToolError` if bluez is not available.
+
+---
+
+## kwin_windows
+
+List open windows from KDE KWin via the KRunner window runner on the session bus. Takes no parameters. Requires a running KDE Plasma session.
+
+### D-Bus details
+
+Calls `org.kde.krunner1.Match` on `org.kde.KWin` at `/WindowsRunner` with an empty query string, which returns all windows. Each match contains an ID, caption, icon name, type code, relevance score, and properties. Type code `8` indicates a virtual desktop entry; all other types are treated as windows.
+
+### Return format
+
+Window table sorted by application name then caption, followed by virtual desktop names if present.
+
+```markdown
+## KWin Windows -- 5 open
+
+| Window | Application |
+|--------|-------------|
+| mcdbus reference -- Firefox | firefox |
+| ~/projects/mcdbus -- Konsole | konsole |
+| server.py -- Kate | kate |
+| Dolphin | dolphin |
+| System Settings | systemsettings |
+
+**Virtual desktops:** Desktop 1, Desktop 2
+```
+
+Returns `"No windows found (KWin WindowsRunner returned empty)."` if KWin reports no matches, or raises a `ToolError` if KWin is not available.
diff --git a/docs-site/src/content/docs/start-here/first-steps.mdx b/docs-site/src/content/docs/start-here/first-steps.mdx
new file mode 100644
index 0000000..4f6c491
--- /dev/null
+++ b/docs-site/src/content/docs/start-here/first-steps.mdx
@@ -0,0 +1,137 @@
+---
+title: First Steps
+description: "Tutorial: your first D-Bus exploration with mcdbus"
+---
+
+This tutorial walks through the discovery workflow hands-on. You will list services, introspect an object, call a method, and send a desktop notification -- all through mcdbus tools in a Claude conversation.
+
+Make sure you have [installed mcdbus and registered it with Claude Code](/start-here/installation/) before continuing.
+
+## 1. List services on the session bus
+
+Start by seeing what is available. Ask Claude to list services, or it will call the tool directly:
+
+```
+> What D-Bus services are running on my session bus?
+```
+
+Claude calls `list_services(bus="session")` and gets back something like:
+
+```
+## D-Bus session bus — 42 services
+
+- `org.freedesktop.DBus`
+- `org.freedesktop.Notifications`
+- `org.freedesktop.portal.Desktop`
+- `org.kde.KWin`
+- `org.kde.StatusNotifierWatcher`
+- `org.kde.kglobalaccel`
+- `org.kde.plasmashell`
+- `org.mpris.MediaPlayer2.firefox`
+...
+```
+
+The exact list depends on your desktop environment and running applications. Every Linux desktop will have `org.freedesktop.Notifications` available -- that is the one we will explore next.
+
+## 2. Introspect the Notifications service
+
+Now look at what the notification service can do:
+
+```
+> Introspect the org.freedesktop.Notifications service at /org/freedesktop/Notifications
+```
+
+Claude calls:
+
+```
+introspect(
+ bus="session",
+ service="org.freedesktop.Notifications",
+ object_path="/org/freedesktop/Notifications"
+)
+```
+
+The output shows the interface, its methods, properties, and signals:
+
+```
+## `org.freedesktop.Notifications` at `/org/freedesktop/Notifications`
+
+### Interface: `org.freedesktop.Notifications`
+
+**Methods:**
+- `Notify(app_name: s, replaces_id: u, app_icon: s, summary: s, body: s, actions: as, hints: a{sv}, expire_timeout: i) -> id: u`
+- `CloseNotification(id: u)`
+- `GetCapabilities() -> capabilities: as`
+- `GetServerInformation() -> name: s, vendor: s, version: s, spec_version: s`
+```
+
+This is the D-Bus introspection XML rendered into a readable format. You can see the `Notify` method takes 8 arguments (application name, icon, summary, body, etc.) and returns a notification ID. The `GetServerInformation` method takes no arguments and returns four strings.
+
+## 3. Call GetServerInformation
+
+Try a simple method call first -- one that takes no arguments and has no side effects:
+
+```
+> Call GetServerInformation on the Notifications service
+```
+
+Claude calls:
+
+```
+call_method(
+ bus="session",
+ service="org.freedesktop.Notifications",
+ object_path="/org/freedesktop/Notifications",
+ interface="org.freedesktop.Notifications",
+ method="GetServerInformation"
+)
+```
+
+The response depends on your notification daemon. On KDE Plasma:
+
+```json
+["Plasma", "KDE", "2.0", "1.2"]
+```
+
+On a system running dunst:
+
+```json
+["dunst", "knopwob", "1.11.0", "1.2"]
+```
+
+The four values are the server name, vendor, version, and the notification spec version it implements.
+
+## 4. Send a test notification
+
+Now use the shortcut tool to send a notification without having to specify all 8 arguments to the `Notify` method manually:
+
+```
+> Send me a test notification saying "Hello from mcdbus"
+```
+
+Claude calls:
+
+```
+send_notification(
+ summary="Hello from mcdbus",
+ body="Your D-Bus bridge is working!"
+)
+```
+
+A notification appears on your desktop. The tool returns:
+
+```
+Notification sent (id: 47)
+```
+
+The notification ID can be used to replace or close the notification later through the raw `call_method` tool.
+
+## 5. What to do next
+
+You have completed the core discovery workflow: list services, introspect an object, call a method. From here:
+
+- **[Control Media Players](/guides/media-players/)** -- pause, play, skip tracks on any MPRIS2-compatible player
+- **[Manage Systemd Units](/guides/systemd/)** -- list and filter systemd services and timers
+- **[Check Battery and Network](/guides/system-status/)** -- read system state through UPower and NetworkManager
+- **[Explore Unknown Services](/guides/explore-services/)** -- use the discovery workflow to map out any D-Bus service on your system
+- **[Tool Reference](/reference/discovery-tools/)** -- full documentation for every tool, resource, and prompt
diff --git a/docs-site/src/content/docs/start-here/installation.mdx b/docs-site/src/content/docs/start-here/installation.mdx
new file mode 100644
index 0000000..5f59696
--- /dev/null
+++ b/docs-site/src/content/docs/start-here/installation.mdx
@@ -0,0 +1,79 @@
+---
+title: Installation
+description: Install mcdbus and register it as an MCP server with Claude Code.
+---
+
+## Prerequisites
+
+- **Python 3.11 or later**
+- **A running D-Bus daemon** -- standard on any Linux desktop (GNOME, KDE Plasma, Sway, Hyprland, etc.). If you are on a headless server without a session bus, the system bus is still available for systemd, NetworkManager, and similar services.
+
+## Install methods
+
+### From PyPI (recommended)
+
+```bash
+pip install mcdbus
+```
+
+### Run without installing
+
+`uvx` downloads and runs the package in an isolated environment. Nothing is installed globally.
+
+```bash
+uvx mcdbus
+```
+
+### From source
+
+```bash
+git clone https://git.supported.systems/warehack.ing/mcdbus.git
+cd mcdbus
+uv sync
+uv run mcdbus
+```
+
+## Register with Claude Code
+
+The simplest registration — Claude Code will start the server automatically when it needs D-Bus access:
+
+```bash
+claude mcp add mcdbus -- uvx mcdbus
+```
+
+To enable the desktop notification confirmation fallback (recommended if your MCP client does not support elicitation):
+
+```bash
+claude mcp add -e MCDBUS_NOTIFY_CONFIRM=1 mcdbus -- uvx mcdbus
+```
+
+See the [Confirmation Flow](/explanation/confirmation-flow/) page for details on what this enables and when it triggers.
+
+## Environment variables
+
+| Variable | Default | Description |
+|---|---|---|
+| `MCDBUS_NOTIFY_CONFIRM` | unset | Enable desktop notification fallback for confirmation dialogs. When set to `1`, the server sends a notification with Approve/Deny buttons if the MCP client does not support elicitation. |
+| `MCDBUS_REQUIRE_ELICITATION` | unset | Hard-fail if the client does not support MCP elicitation. Overrides the notification fallback -- operations that need confirmation will be rejected outright. |
+| `MCDBUS_TIMEOUT` | `30` | D-Bus call timeout in seconds. Increase this if you interact with services that take a long time to respond (some systemd operations, large object tree walks). |
+
+Pass environment variables during registration with `-e`:
+
+```bash
+claude mcp add \
+ -e MCDBUS_NOTIFY_CONFIRM=1 \
+ -e MCDBUS_TIMEOUT=60 \
+ mcdbus -- uvx mcdbus
+```
+
+## Verify it works
+
+Run the server directly to confirm it starts without errors:
+
+```bash
+uvx mcdbus
+```
+
+You should see `mcdbus: D-Bus MCP server starting` printed to stderr. The server is now listening for MCP messages on stdin/stdout. Press `Ctrl+C` to stop it.
+
+If the D-Bus daemon is not reachable, you will see a connection error immediately. On a headless system without a session bus, the server will still start -- it connects lazily when a tool is called, so a session bus error only appears when you try to use session bus tools.
diff --git a/docs-site/src/content/docs/start-here/overview.mdx b/docs-site/src/content/docs/start-here/overview.mdx
new file mode 100644
index 0000000..32780bf
--- /dev/null
+++ b/docs-site/src/content/docs/start-here/overview.mdx
@@ -0,0 +1,51 @@
+---
+title: What is mcdbus?
+description: A D-Bus MCP server that bridges Linux desktop IPC into the Model Context Protocol, giving Claude native access to session and system bus services.
+---
+
+## D-Bus in 30 seconds
+
+D-Bus is the interprocess communication system that ties a Linux desktop together. It provides a message bus where services register under well-known names (like `org.freedesktop.Notifications` or `org.freedesktop.NetworkManager`), expose objects at hierarchical paths, and publish interfaces with methods, properties, and signals. Nearly every desktop component speaks D-Bus: the notification daemon, media players, systemd, NetworkManager, UPower, bluez, KDE Plasma, GNOME Shell, and hundreds of others.
+
+Two separate bus instances run on a typical Linux system:
+
+- **Session bus** -- scoped to the logged-in user. Desktop services live here: notifications, media players, file managers, portals, KDE/GNOME shell interfaces. No elevated privileges required.
+- **System bus** -- shared across all users, managed by the system. Services like systemd, NetworkManager, UDisks2, UPower, and bluez register here. Access is governed by D-Bus security policies (polkit).
+
+The [Session vs System Bus](/explanation/buses/) page covers the security and scoping differences in detail.
+
+## Why an MCP bridge matters
+
+D-Bus already has CLI tools. `dbus-send` and `busctl` let you list services, introspect objects, and call methods from a terminal. But they are designed for humans typing one-off commands -- they do not compose well, and they require you to already know the service name, object path, interface, and method signature before you can do anything useful.
+
+mcdbus exposes the same D-Bus capabilities as MCP tools that an LLM client can chain together. Instead of memorizing the incantation to check your battery level through UPower, you ask Claude "what is my battery status?" and the server handles the rest: discovering the right service, walking the object tree, reading the correct properties, and formatting the response.
+
+The key difference is **introspection-first discovery**. The server carries no hardcoded knowledge of specific D-Bus services. It discovers what is available on your bus at runtime, introspects the interfaces, and figures out how to interact with them. If you install a new service that exposes a D-Bus interface, mcdbus can work with it immediately.
+
+## The discovery workflow
+
+Every interaction with mcdbus follows the same pattern, whether you are doing it explicitly or the server's shortcut tools are doing it under the hood:
+
+```
+list_services --> list_objects --> introspect --> call_method / get_property
+```
+
+1. **`list_services`** -- enumerate the well-known names registered on a bus. This tells you what is running and reachable.
+2. **`list_objects`** -- walk the object tree for a specific service. D-Bus objects are arranged in a filesystem-like hierarchy (e.g., `/org/freedesktop/UPower/devices/battery_BAT0`).
+3. **`introspect`** -- examine a specific object to see its interfaces, methods, properties, and signals. This is where you learn what you can actually do.
+4. **`call_method`** / **`get_property`** -- invoke a method or read a property once you know the service name, object path, interface, and member name.
+
+The shortcut tools (`send_notification`, `battery_status`, `network_status`, and others) skip this workflow by hardcoding the well-known paths for common operations. They exist for convenience, but the generic discovery tools can reach anything on the bus.
+
+## How mcdbus compares to dbus-send and busctl
+
+| | dbus-send / busctl | mcdbus |
+|---|---|---|
+| Interface | Command line | MCP tools (used by Claude or any MCP client) |
+| Discovery | Manual -- you must know the service name | `list_services` enumerates available services |
+| Introspection | `busctl introspect` output is tabular text | `introspect` returns structured Markdown |
+| Composition | Shell pipes | Claude chains tools together in a single conversation |
+| Confirmation | None (you own the terminal) | System bus calls and property mutations require user approval |
+| Shortcuts | None -- every call is fully qualified | Pre-wired tools for notifications, media, systemd, network, battery, bluetooth, KDE |
+
+mcdbus does not replace these CLI tools. It serves a different audience: LLM clients that need programmatic, composable access to D-Bus without prior knowledge of the bus layout.
diff --git a/docs-site/src/styles/global.css b/docs-site/src/styles/global.css
new file mode 100644
index 0000000..5a109c6
--- /dev/null
+++ b/docs-site/src/styles/global.css
@@ -0,0 +1,32 @@
+@import "tailwindcss";
+
+/* Terminal-green + slate — infrastructure aesthetic */
+:root {
+ --sl-color-accent-low: #0d2818;
+ --sl-color-accent: #4ead6b;
+ --sl-color-accent-high: #b8e6c8;
+
+ --sl-color-white: #f8fafc;
+ --sl-color-gray-1: #e2e8f0;
+ --sl-color-gray-2: #cbd5e1;
+ --sl-color-gray-3: #94a3b8;
+ --sl-color-gray-4: #64748b;
+ --sl-color-gray-5: #334155;
+ --sl-color-gray-6: #1e293b;
+ --sl-color-black: #0f172a;
+}
+
+:root[data-theme="light"] {
+ --sl-color-accent-low: #dcfce7;
+ --sl-color-accent: #16803d;
+ --sl-color-accent-high: #0d3320;
+
+ --sl-color-white: #0f172a;
+ --sl-color-gray-1: #1e293b;
+ --sl-color-gray-2: #334155;
+ --sl-color-gray-3: #64748b;
+ --sl-color-gray-4: #94a3b8;
+ --sl-color-gray-5: #e2e8f0;
+ --sl-color-gray-6: #f1f5f9;
+ --sl-color-black: #f8fafc;
+}
diff --git a/docs-site/tsconfig.json b/docs-site/tsconfig.json
new file mode 100644
index 0000000..bcbf8b5
--- /dev/null
+++ b/docs-site/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "astro/tsconfigs/strict"
+}