Files
gronod 7bcf5322e1 feat: emby-mcp 1.0.16 player owner filter and HA links
Expose session/last-used user and device IP on player rows. Filter
retrieve_player_list by users, merge GET /Devices when include_offline
is set, and resolve spoken names to HA media_player entities via
player_links (device_id or IP). PlayNow still requires a live session.
2026-09-25 19:58:58 +00:00

528 lines
19 KiB
Markdown

# Emby MCP — documentation
Full configuration and usage documentation for the **Emby MCP** Home Assistant
add-on, plus standalone (non-add-on) usage of the underlying Go server.
## Installation
1. Add the repository to Home Assistant
(**Settings → Add-ons → Add-on Store → ⋮ → Repositories**):
`https://git.i3omb.com/gronod/ha-gronod-addons`
2. Install **Emby MCP**
3. Set `emby_server_url` plus username/password or an API key
4. Leave **Restrict to localhost** enabled and start the add-on
The add-on runs the Go Emby.MCP server in one container and exposes:
- Streamable MCP at `/mcp` (Claude, ChatGPT, and other MCP clients)
- REST tool calls at `/call/{tool}` (Home Assistant REST / conversation tools)
- Health checks at `/healthz` and `/health`
## Configuration
| Option | Default | Description |
|---|---|---|
| `emby_server_url` | `http://homeassistant.local:8096` | Emby base URL |
| `emby_username` | _(empty)_ | Stored Emby username |
| `emby_password` | _(empty)_ | Stored Emby password |
| `emby_api_key` | _(empty)_ | Stored Emby API key |
| `emby_user_id` | _(empty)_ | User id when using an API key |
| `restrict_to_localhost` | `true` | Allow only loopback + HA container network; apply stored creds |
| `emby_verify_ssl` | `true` | Verify TLS when talking to Emby |
| `llm_max_items` | `100` | Max items per search chunk |
| `mcp_transport` | `http` | Keep `http` |
| `mcp_listen_addr` | `0.0.0.0:8085` | Bind address |
| `mcp_session_timeout` | `30m` | MCP session lifetime |
| `log_level` | `INFO` | `DEBUG`, `INFO`, or `WARN` |
| `player_users` | `[]` | Default include-list of Emby usernames/ids applied when a tool call omits `users` |
| `player_links` | `[]` | Maps spoken names to HA `media_player` + Emby device. `names` is comma-separated. Set `device_ip` when the TV's LAN IP is known (Emby `device_ip_address` on a live session). WebOS often does **not** put that IP on the HA entity — copy it from the router or from an Emby session while the app is open. `emby_device_id` is the most stable key. |
| `debug.rest` | _(optional)_ | Shown under optional options. At DEBUG, log REST bodies |
| `debug.mcp` | _(optional)_ | Shown under optional options. At DEBUG, log `/mcp` bodies |
| `debug.emby` | _(optional)_ | Shown under optional options. At DEBUG, log Emby API bodies |
With **Restrict to localhost** on (default), only processes on the Home
Assistant machine can connect, and those local calls use the stored
credentials when no `Authorization` header is present.
Turn the switch off only if you need LAN/remote MCP clients. In that mode
stored credentials are **not** applied automatically; each client must send
its own `Authorization` header.
## Endpoints
| Path | Method | Purpose |
|---|---|---|
| `/mcp` | POST | Streamable MCP |
| `/call/{tool}` | POST | REST bridge → MCP tool |
| `/tools` | GET | List tools |
| `/healthz` | GET | Liveness |
| `/health` | GET | Bridge liveness |
## Auth
Send Emby credentials on every `/mcp` and `/call/...` request when
`restrict_to_localhost` is off (or when calling remotely):
- `Authorization: Basic <base64(user:pass)>`
- `Authorization: Bearer <emby-token-or-api-key>`
- Optional: `X-Emby-User-Id` or `X-Emby-Username`
Username/password can also be stored in the add-on options; they are applied
automatically to local calls while `restrict_to_localhost` is on.
## Tool names
Use the MCP tool names, for example `search_for_item`, `retrieve_player_list`,
`retrieve_now_playing`, `control_media_player`, `retrieve_next_episode`.
Arguments not declared in the tool's current input schema are dropped before
the call is forwarded; the JSON response then includes
`"dropped_arguments": ["name", ...]` listing what was ignored.
## Conversation agent
Home Assistant Supervisor names a custom-repo add-on `{repo-hash}-{slug}`.
The repo hash is derived from the repository URL, so it depends on which URL
was added:
| Repository URL | Add-on hostname |
|---|---|
| `https://git.i3omb.com/gronod/ha-gronod-addons` | `8e663231-emby-mcp` |
| `https://git.i3omb.com/gronod/ha-emby-mcp` (pre-rename) | `c5cb4244-emby-mcp` |
The examples below use `8e663231-emby-mcp`. If you added the repository before
the rename, replace it with `c5cb4244-emby-mcp` throughout (it cannot be
changed to a custom hostname).
These steps assume the add-on has Emby credentials configured and
**Restrict to localhost** is on. Home Assistant then calls
`http://8e663231-emby-mcp:8085`; no `Authorization` header is required.
### System prompt
Add this line to the conversation agent's instructions:
```text
- Media & Emby: Never use execute_services for Emby search or playback. Use `emby_search` to find films, TV series, episodes, or music tracks. Use `emby_episode_list` to list or count a series' episodes (seasons via `emby_season_list`). Each episode item includes `premiere_date` (YYYY-MM-DD first air date) when Emby has that metadata; use it for airdate questions. Use `emby_resolve_player` when the user names a room or TV ("play on the lounge"). If `wake_required` is true, use Home Assistant `media_player.turn_on` on `ha_entity` and `media_player.select_source` with `ha_source` (Emby), wait, then resolve again. Only then use `emby_playback_control` PlayNow with that `session_id`. Use `emby_list_players` with `users` set to the household include-list to hide other people's clients. Use `emby_next_episode` when asked what episode to watch next.
```
### Functions
Paste the following into the conversation agent's **Functions** list
(Settings → Voice assistants → your agent → Functions):
```yaml
- spec:
name: emby_search
description: Search for films, TV series, episodes, or music tracks in the Emby media library.
parameters:
type: object
properties:
title_or_album:
type: string
description: Title of the media item, film, track, or album.
artist_name:
type: string
description: Name of the artist or band (optional).
genre_name:
type: string
description: Genre of the media (optional).
item_types:
type: string
description: "Comma-separated filter, e.g. 'Movie', 'Series', 'Episode', or 'Audio'. Leave empty for all."
required:
- title_or_album
function:
type: rest
resource_template: "http://8e663231-emby-mcp:8085/call/search_for_item"
method: POST
payload_template: >-
{{ {
"title_or_album": title_or_album,
"artist_name": artist_name | default(""),
"genre_name": genre_name | default(""),
"broadcast_release_years": "",
"item_types": item_types | default("")
} | to_json }}
value_template: "{{ value_json.result }}"
- spec:
name: emby_list_players
description: List Emby media players. Each row includes user_name, user_id, device_ip_address, and online. Pass users as a comma-separated include-list of Emby usernames so other household accounts are omitted. Set include_offline true to include last-used devices with no live session.
parameters:
type: object
properties:
media_type:
type: string
description: Filter by player type ('Video', 'Audio', 'Photo'), or leave empty for all.
users:
type: string
description: Comma-separated Emby usernames or user ids to include (household include-list).
include_offline:
type: boolean
description: Also list known Emby devices that have no live session (last-used user).
function:
type: rest
resource_template: "http://8e663231-emby-mcp:8085/call/retrieve_player_list"
method: POST
payload_template: >-
{{ { "media_type": media_type | default(""), "users": users | default(""), "include_offline": include_offline | default(false) } | to_json }}
value_template: "{{ value_json.result }}"
- spec:
name: emby_resolve_player
description: Resolve a room name, person, HA entity, Emby device name, device id, or IP to ha_entity/ha_source plus a live session_id when the Emby client is running. If wake_required is true, turn on the HA media_player and launch Emby before PlayNow.
parameters:
type: object
properties:
query:
type: string
description: Spoken name (lounge), media_player entity_id, Emby device name, device id, or IP.
users:
type: string
description: Comma-separated Emby usernames or user ids to include.
required:
- query
function:
type: rest
resource_template: "http://8e663231-emby-mcp:8085/call/resolve_media_player"
method: POST
payload_template: >-
{{ { "query": query, "users": users | default("") } | to_json }}
value_template: "{{ value_json.result }}"
- spec:
name: emby_now_playing
description: Get currently playing media details, audio tracks, and subtitle options for an active player session.
parameters:
type: object
properties:
session_id:
type: string
description: The player session ID obtained from emby_list_players.
required:
- session_id
function:
type: rest
resource_template: "http://8e663231-emby-mcp:8085/call/retrieve_now_playing"
method: POST
payload_template: >-
{{ { "session_id": session_id } | to_json }}
value_template: "{{ value_json.result }}"
- spec:
name: emby_playback_control
description: Send playback commands to an Emby player session (e.g. PlayNow, Pause, Unpause, Stop, NextTrack, PreviousTrack).
parameters:
type: object
properties:
session_id:
type: string
description: The target player session ID.
command:
type: string
description: "Command: 'PlayNow', 'Stop', 'Pause', 'Unpause', 'NextTrack', 'PreviousTrack', 'Seek', 'Rewind', 'FastForward'."
item_ids:
type: string
description: Comma-separated item IDs to queue or play immediately (required for PlayNow).
time_milliseconds:
type: integer
description: Position in milliseconds for Seek/Rewind/FastForward, or start position for PlayNow (e.g. 246000 for 4:06). 0 when unused.
required:
- session_id
- command
function:
type: rest
resource_template: "http://8e663231-emby-mcp:8085/call/control_media_player"
method: POST
payload_template: >-
{{ {
"session_id": session_id,
"command": command,
"item_ids": item_ids | default(""),
"time_milliseconds": time_milliseconds | default(0)
} | to_json }}
value_template: "{{ value_json.result }}"
- spec:
name: emby_season_list
description: List the seasons of a TV series. Each result's item_id can be passed to emby_episode_list as season_id.
parameters:
type: object
properties:
series_id:
type: string
description: The series item_id obtained from emby_search with item_types 'Series'.
required:
- series_id
function:
type: rest
resource_template: "http://8e663231-emby-mcp:8085/call/retrieve_season_list"
method: POST
payload_template: >-
{{ { "series_id": series_id } | to_json }}
value_template: "{{ value_json.result }}"
- spec:
name: emby_episode_list
description: List or count the episodes of a TV series, optionally restricted to one season. total_number_of_items gives the episode count.
parameters:
type: object
properties:
series_id:
type: string
description: The series item_id obtained from emby_search with item_types 'Series'.
season_id:
type: string
description: Optional season item_id from emby_season_list to restrict to one season.
required:
- series_id
function:
type: rest
resource_template: "http://8e663231-emby-mcp:8085/call/retrieve_episode_list"
method: POST
payload_template: >-
{{ {
"series_id": series_id,
"season_id": season_id | default("")
} | to_json }}
value_template: "{{ value_json.result }}"
- spec:
name: emby_next_episode
description: Retrieve the next unplayed episode for a TV series.
parameters:
type: object
properties:
series_name:
type: string
description: The title of the television show.
required:
- series_name
function:
type: rest
resource_template: "http://8e663231-emby-mcp:8085/call/retrieve_next_episode"
method: POST
payload_template: >-
{{ {
"series_name": series_name,
"series_id": "",
"mode": "next_unplayed"
} | to_json }}
value_template: "{{ value_json.result }}"
```
If you changed the add-on port, replace `8085` in every `resource_template`.
If **Restrict to localhost** is off, send an `Authorization` header on each request.
Item-shaped tool results (search, episode list, next episode, queues) include
`premiere_date` as `YYYY-MM-DD` when Emby metadata has a first air / release
date. The Functions YAML above does not declare that field — it arrives inside
`value_json.result`. Update the system prompt so the model reads it.
## Standalone usage (outside Home Assistant)
The same Go binary can run outside the add-on, e.g. on a desktop or NAS.
Build it from this directory:
```sh
go build -o emby-mcp ./cmd/emby-mcp
```
Requires Go 1.27+.
### Environment configuration
Create a `.env` file (in the working directory or next to the binary):
```
EMBY_SERVER_URL = "http://localhost:8096"
# Either username/password:
EMBY_USERNAME = "user"
EMBY_PASSWORD = "pass"
# ...or an API key plus the user ID it should act as:
# EMBY_API_KEY = "..."
# EMBY_USER_ID = "..."
# Set to False to skip SSL certificate verification (e.g. self-signed).
EMBY_VERIFY_SSL = True
# Max items per search-result chunk; 0 = no limit.
LLM_MAX_ITEMS = 100
# Transport: "stdio" (default) or "http" (streamable HTTP at /mcp).
MCP_TRANSPORT = stdio
# HTTP mode: bind address and idle-session timeout.
MCP_LISTEN_ADDR = "127.0.0.1:8080"
MCP_SESSION_TIMEOUT = 30m
```
In `http` mode the `EMBY_USERNAME`/`EMBY_PASSWORD`/`EMBY_API_KEY` variables are
not needed — every request authenticates against Emby (see Auth above).
### Run modes
Startup checks only (login + list libraries, then exit):
```sh
./emby-mcp -check
```
Run the MCP server on stdio:
```sh
./emby-mcp
```
HTTP mode (streamable HTTP transport, SSE streaming at `POST /mcp`):
```sh
MCP_TRANSPORT=http MCP_LISTEN_ADDR=0.0.0.0:8080 ./emby-mcp
```
`/healthz` is an unauthenticated liveness endpoint. Each MCP session is
isolated: it gets its own library selection and search-chunking state, and
requests to a session are rejected if they authenticate as a different user.
Plain HTTP only — put the server behind a reverse proxy for TLS.
### Docker (standalone image)
```sh
docker run -e EMBY_SERVER_URL="http://emby:8096" -p 8080:8080 \
git.i3omb.com/gronod/emby-mcp:1.0.0
```
The image defaults to `MCP_TRANSPORT=http` on `0.0.0.0:8080`.
### Docker Compose / TrueNAS SCALE
The YAML below is a Compose v2 file suitable for a TrueNAS SCALE Custom App
(Apps → Discover → Custom App / Install via YAML) as well as plain
`docker compose up -d`.
Replace the placeholder values. On SCALE, map the same keys in the app
environment UI if you prefer not to bake them into the file. Use the NAS
host/IP (or an internal container DNS name) for `EMBY_SERVER_URL` so the
app can reach Emby; `host` networking is an alternative if Emby is also
on the same TrueNAS box.
In HTTP mode (`MCP_TRANSPORT=http`, the image default) per-request Emby
auth is used and `EMBY_USERNAME` / `EMBY_PASSWORD` / `EMBY_API_KEY` are
optional. They are included so the same file also works if you switch
the container to stdio or want a default identity.
```yaml
# emby-mcp — TrueNAS SCALE Custom App / docker compose
# Placeholders: replace every CHANGE_ME_* value before starting.
services:
emby-mcp:
image: git.i3omb.com/gronod/emby-mcp:1.0.0
container_name: emby-mcp
restart: unless-stopped
ports:
- "8080:8080"
environment:
EMBY_SERVER_URL: "http://CHANGE_ME_EMBY_HOST:8096"
# Username/password login (leave empty if using an API key):
EMBY_USERNAME: "CHANGE_ME_EMBY_USERNAME"
EMBY_PASSWORD: "CHANGE_ME_EMBY_PASSWORD"
# API-key login (leave empty if using username/password).
# EMBY_USER_ID is required when EMBY_API_KEY is set:
EMBY_API_KEY: "CHANGE_ME_EMBY_API_KEY"
EMBY_USER_ID: "CHANGE_ME_EMBY_USER_ID"
EMBY_VERIFY_SSL: "true"
LLM_MAX_ITEMS: "100"
MCP_TRANSPORT: "http"
MCP_LISTEN_ADDR: "0.0.0.0:8080"
MCP_SESSION_TIMEOUT: "30m"
# Scratch image has no shell/curl — omit healthcheck, or front with a proxy.
# networks: [host] # uncomment instead of ports: if Emby is on the same NAS
```
MCP endpoint after start: `POST http://<truenas-host>:8080/mcp`
Liveness: `GET http://<truenas-host>:8080/healthz`
### Claude Desktop example (stdio)
```json
{
"mcpServers": {
"Emby": {
"command": "/path/to/emby-mcp",
"args": ["-env", "/path/to/.env"]
}
}
}
```
### Streamable HTTP example (`mcpServers`)
Point an MCP client at the server's `/mcp` endpoint. Every request must
carry Emby credentials in headers (see Auth above). Replace the
placeholders.
API key (add `X-Emby-User-Id` or `X-Emby-Username` if the key is server-wide):
```json
{
"mcpServers": {
"Emby": {
"type": "http",
"url": "http://CHANGE_ME_MCP_HOST:8080/mcp",
"headers": {
"Authorization": "Bearer CHANGE_ME_EMBY_API_KEY",
"X-Emby-User-Id": "CHANGE_ME_EMBY_USER_ID"
}
}
}
}
```
Username and password (sent as HTTP Basic; the server exchanges them for an
Emby access token):
```json
{
"mcpServers": {
"Emby": {
"type": "http",
"url": "http://CHANGE_ME_MCP_HOST:8080/mcp",
"headers": {
"Authorization": "Basic CHANGE_ME_BASE64_USER_COLON_PASS"
}
}
}
}
```
`CHANGE_ME_MCP_HOST` is the host where emby-mcp is published (TrueNAS IP or
hostname), not the Emby server. `CHANGE_ME_BASE64_USER_COLON_PASS` is
`base64("<username>:<password>")`. Clients that do not understand `"type":
"http"` may accept the same block with only `"url"` and `"headers"`.
## Development
```sh
go build ./...
go vet ./...
go test ./...
```
Layout:
* `cmd/emby-mcp` — entry point, config loading, startup checks
* `internal/emby` — Emby REST client (auth, libraries, items, playlists, sessions)
* `internal/server` — MCP tool handlers (official go-sdk)
* `internal/mcphttp` — streamable-HTTP transport with per-request Emby auth
* `internal/state` — shared session state (library selection, search chunking)
* `internal/config` — `.env` parsing
* `internal/textutil` — Unicode→ASCII folding for text matching
* `docs/api/emby_openapi.json` — Emby REST API spec (reference)
Upstream endpoint conformance is checked by `internal/emby/spec_conformance_test.go`;
see [`docs/api/endpoint_validation.md`](docs/api/endpoint_validation.md) for the
audit report. Planned features live in [`docs/ROADMAP.md`](docs/ROADMAP.md).