Bifrost MCP — embedded tool surface¶
Bifrost embeds a Model Context Protocol
server inside the Bifrost binary so an AI assistant can control the whole
home in natural language. It is the third surface over the shared service
layer, alongside the session API (/api/*) and the public API (/api/v1/*) —
the tools call the same api::{lights,audio,rooms,scenes} service
functions directly (no HTTP hop, no Bearer round-trip), so they can't drift from
the REST surface and reuse the real color math (models::Color).
- Where:
src/api/mcp.rs, mounted at/mcpinbuild_app(src/lib.rs). - Transport: Streamable HTTP only (stateless, JSON responses). There is
no stdio server; stdio-only clients bridge to
/mcpwith the standardmcp-remoteshim. See MCP-Add rationale below. - This page is the source of truth for what the server exposes — kept alongside the API it shares, so the tools listed here can't drift from the endpoints behind them.
Ultimate goal: AI-driven whole-home control. Target client: Whisperr (the voice/LLM pipeline). Tools are designed for natural-language ergonomics — resolve rooms, scenes, devices, and favorites by name, not just id.
Conventions¶
- Auth: a Bifrost API key (
bfr_…, minted in Settings), sent asAuthorization: Bearer. Identical to/api/v1: only the SHA-256 hash is stored, revocation is immediate, a missing/invalid key returns401before any MCP processing. - Name resolution: tools accept an id or a case-insensitive name/
substring. Resolution order is exact id → exact name → name substring; on no
match (or an ambiguous substring) the tool returns an error that lists the
valid options so the assistant can self-correct. Implemented once in
mcp::resolve. - Sparse writes: mirror the service layer — only the fields the user named
are sent.
set_light/set_roomdefaultonto true (asking for color or brightness implies the light should be lit). - Error shape: recoverable problems (wrong name, unreachable device, bad
argument) come back as a tool result with
isError: trueand a readable message; only infrastructure failures (DB errors) are protocol-level errors.
Tools — shipped (embedded)¶
All tools below are served natively from src/api/mcp.rs.
| Tool | Shared service fn(s) | Notes |
|---|---|---|
get_home_state |
list_public_rooms + list_all_lights + list_all_scenes + list_all_devices + list_all_power_devices |
One-call context snapshot (rooms with member ids, lights, scenes, media devices, power devices) |
list_lights |
list_all_lights |
each light carries capabilities.effects (the dynamic-effect names it supports) |
set_light |
apply_light_state |
light by id/name; hex → CIE xy via Color::from_rgb; optional effect (a name from capabilities.effects, e.g. "candle"/"breathe"/"Sunrise") supersedes color/temp |
set_room |
effective_members + apply_room_state |
room by id/name; fan-out to member lights |
activate_scene |
apply_scene_entries |
scene by id/name; re-applies the captured full state (color/temp/effect + power). A Room Scene restores its room, a Home Scene the whole house |
save_room_scene |
capture_scene(room_id) |
snapshot a room's current full state (each light's color/temp/effect + power) as a new Room Scene |
save_home_scene |
capture_scene(None) |
snapshot the whole home's current state as a new Home Scene |
set_media |
apply_media_command |
device by id/name; power/volume/mute/source/transport |
get_media_state |
get_device_live |
live read incl. now-playing |
list_media_favorites |
list_device_favorites |
Sonos Favorites; empty for Onkyo |
play_media_favorite |
list_device_favorites + play_device_favorite |
resolve favorite (id/title/substring) then play. "play my jazz favorite in the office." |
cast_media |
cast_to_device |
cast media to a TV / media player by provider-native content_id + content_type (HA media_player.play_media). For launching a streaming app prefer the remote's launch_app; to switch input/app prefer set_media's source. |
group_speakers |
group_devices (per member) |
join speakers under a coordinator. "play the kitchen and living room together." |
ungroup_speaker |
ungroup_device |
remove a speaker from its synced group |
bind_receiver |
set_media_receiver |
bind a source (TV/streamer) to a receiver that owns its volume + the input to switch to on power-on; omit receiver to unbind. "route the living room TV's sound through the AV receiver on the Game input." |
list_power_devices |
list_all_power_devices |
switches / plugs / fans / toggles with on/off state + kind |
set_power |
apply_power_state |
turn a power device on/off, by id or name. "turn off the porch switch." |
list_remotes |
list_remotes |
TVs / streamers with on state + current foreground app |
press_remote_key |
apply_remote_command (key) |
press a canonical key (up/down/…/select/back/home/play_pause/power), by id or name. "go back on the bedroom TV." |
launch_app |
apply_remote_command (launch_app) |
open an app by Play Store package id or deep-link URL. "open Netflix on the bedroom TV." |
play_on |
resolve_and_play |
open an app or play something on a TV by name. Resolves the device (TV or its remote); "open <app>" launches it from the catalog, "play <title>" searches the TV's libraries and plays the best match, falling back to opening the TV's last-used app when nothing matches. "open Netflix / play Bob's Burgers on the bedroom TV." Prefer over launch_app for natural requests. |
Target tools — not yet built¶
| Tool | Maps to | When |
|---|---|---|
list_sensors / room occupancy |
list_all_sensor_devices + rooms::room_occupancy |
Read-only sensor readings and "is anyone in the living room?" — the sensor domain is on /api/v1 but not yet surfaced as MCP tools. |
list_media_devices |
list_all_devices |
If a standalone audio list is wanted beyond get_home_state. |
| Tier-2 music search/play | a future music-service API | After the Spotify OAuth + Connect music-service work lands. |
| Audio-in-scenes awareness | scene snapshot incl. audio source/volume | After audio is captured in scenes. |
Why embedded¶
Bifrost's founding ethos is one binary, one SQLite file, one Docker image. An embedded MCP server keeps that: no separate repo, no Node runtime, no parallel deploy. CLAUDE.md already mandates that the session and public APIs delegate to the same shared service functions so they can't drift; the MCP surface is just a third caller of those functions, which is why it reuses the real Rust color math instead of re-implementing hex→CIE xy.
Transport choice — Streamable HTTP only. MCP has two transports. stdio has
the client spawn a subprocess over stdin/stdout — a long-running daemon can't own
per-client stdin/stdout, so "embedding" stdio just means shipping a second
process. Streamable HTTP is a single endpoint (one path, POST + optional SSE
upgrade) that mounts as just another Axum route — one server, many clients, no
subprocess. A network-reachable /mcp (behind Tailscale/VPN, per Bifrost's
access model) needs no per-machine install. So Bifrost serves only Streamable
HTTP; the rare stdio-only client uses the off-the-shelf mcp-remote bridge
rather than us maintaining a stdio artifact.
Tooling. Built on rmcp (the official modelcontextprotocol/rust-sdk),
which provides the Axum-mountable StreamableHttpService. Run in stateless +
JSON-response mode: every tool call is an independent request/response, so there
is no Mcp-Session-Id bookkeeping. The endpoint is Bearer-gated (not protected
by rmcp's default localhost Host-allowlist, which is disabled since Bifrost is
reached by LAN/Tailscale IP); the API key is the security boundary.