Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02d9449a34 | ||
|
|
ea91207196 | ||
|
|
af5fd8feea |
@@ -0,0 +1,37 @@
|
|||||||
|
# Repository guidance
|
||||||
|
|
||||||
|
This is **Gronod's Home Assistant Add-Ons** — a Home Assistant add-on
|
||||||
|
repository. Each top-level directory is one add-on.
|
||||||
|
|
||||||
|
## Layout conventions
|
||||||
|
|
||||||
|
- Add-on directory names use hyphens and mirror the add-on slug:
|
||||||
|
`emby-mcp/` ↔ slug `emby_mcp`, `openai-codex-proxy/` ↔ slug
|
||||||
|
`openai_codex_proxy` (config.yaml slugs allow `[a-z0-9_]` only).
|
||||||
|
- Each add-on contains: `config.yaml`, `Dockerfile`, `README.md`, `DOCS.md`,
|
||||||
|
`CHANGELOG.md`, `icon.png`, `logo.png`; `build.yaml`/`rootfs/` as needed.
|
||||||
|
- `README.md` = decision content (what it is, features, requirements,
|
||||||
|
install summary). `DOCS.md` = installation, configuration, usage,
|
||||||
|
troubleshooting — the file shown in the add-on's Documentation tab.
|
||||||
|
- `repository.yaml` at the root defines the repo display name/URL.
|
||||||
|
|
||||||
|
## Hostname rule
|
||||||
|
|
||||||
|
Supervisor names add-ons `{sha1(repo_url)[:8]}-{slug-with-hyphens}`.
|
||||||
|
For `https://git.i3omb.com/gronod/ha-gronod-addons` the prefix is
|
||||||
|
`8e663231` (e.g. `8e663231-emby-mcp`). If the repo URL changes, the hash
|
||||||
|
changes and every `{hash}-{slug}` reference in docs must be updated.
|
||||||
|
(Pre-rename installs using `ha-emby-mcp` keep hash `c5cb4244`.)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- emby-mcp (Go): `cd emby-mcp && go build ./... && go vet ./... && go test ./...`
|
||||||
|
(see `emby-mcp/AGENTS.md`; the `internal/mcphttp` end-to-end tests are
|
||||||
|
environment-sensitive and may time out on some machines)
|
||||||
|
- YAML files: `ruby -ryaml -e 'YAML.load_file(ARGV[0])' <file>`
|
||||||
|
- openai-codex-proxy: `node --check openai-codex-proxy/rootfs/entrypoint.js`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The Go module `git.i3omb.com/gronod/emby-mcp` and its container image are
|
||||||
|
published from the separate `gronod/emby-mcp` repo — do not repoint them.
|
||||||
@@ -1,254 +1,31 @@
|
|||||||
# ha-emby-mcp
|
# Gronod's Home Assistant Add-Ons
|
||||||
|
|
||||||
<img src="logo.png" alt="Emby MCP" width="128" height="128">
|
Home Assistant custom add-on repository maintained by Gordon Bolton.
|
||||||
|
|
||||||
Home Assistant custom add-on repository for **Emby MCP**.
|
[](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgit.i3omb.com%2Fgronod%2Fha-gronod-addons)
|
||||||
|
|
||||||
[](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgit.i3omb.com%2Fgronod%2Fha-emby-mcp)
|
## Add-ons
|
||||||
|
|
||||||
|
| Add-on | Description | Documentation |
|
||||||
|
|---|---|---|
|
||||||
|
| **Emby MCP** | Emby Model Context Protocol server with a REST bridge — search and control an Emby media library from MCP clients and Home Assistant conversation agents | [README](emby-mcp/README.md) · [DOCS](emby-mcp/DOCS.md) |
|
||||||
|
| **OpenAI Codex Proxy** | OpenAI-compatible local endpoint backed by a ChatGPT account (Codex OAuth) — use ChatGPT Plus/Pro models from OpenAI-compatible integrations | [README](openai-codex-proxy/README.md) · [DOCS](openai-codex-proxy/DOCS.md) |
|
||||||
|
|
||||||
The add-on runs the Go Emby.MCP server in one container and exposes:
|
## Install this repository
|
||||||
|
|
||||||
- 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`
|
|
||||||
|
|
||||||
Username, password and API key can be set in the add-on configuration.
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
1. Home Assistant → **Settings → Add-ons → Add-on Store → ⋮ → Repositories**
|
1. Home Assistant → **Settings → Add-ons → Add-on Store → ⋮ → Repositories**
|
||||||
2. Add `https://git.i3omb.com/gronod/ha-emby-mcp`
|
2. Add `https://git.i3omb.com/gronod/ha-gronod-addons`
|
||||||
3. Install **Emby MCP**, set `emby_server_url` plus username/password or API key
|
3. Install the add-on you want from the store and follow its documentation
|
||||||
4. Leave **Restrict to localhost** enabled and start the add-on
|
|
||||||
|
|
||||||
## Options
|
> **Note:** This repository was previously published as
|
||||||
|
> `https://git.i3omb.com/gronod/ha-emby-mcp`. The old URL still works (it
|
||||||
|
> redirects), but if you switch an existing installation to the new URL the
|
||||||
|
> add-on hostnames change — see each add-on's DOCS for details.
|
||||||
|
|
||||||
| Option | Default | Description |
|
## Support
|
||||||
|---|---|---|
|
|
||||||
| `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` |
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
## Conversation agent
|
Open an issue on [the repository](https://git.i3omb.com/gronod/ha-gronod-addons/issues).
|
||||||
|
|
||||||
Home Assistant Supervisor always names a custom-repo add-on
|
|
||||||
`{repo-hash}-{slug}`. For this repository that DNS name is
|
|
||||||
`c5cb4244-emby-mcp` (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://c5cb4244-emby-mcp:8085`; no `Authorization` header is required.
|
|
||||||
|
|
||||||
### System prompt
|
|
||||||
|
|
||||||
Add this line to the conversation agent's instructions:
|
|
||||||
|
|
||||||
```text
|
|
||||||
- Media & Emby: 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`). Use `emby_list_players` to find active playback sessions, and `emby_playback_control` to send playback commands (PlayNow, Pause, Unpause, Stop, NextTrack). 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://c5cb4244-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 active Emby media players, active clients, and their session IDs.
|
|
||||||
parameters:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
media_type:
|
|
||||||
type: string
|
|
||||||
description: Filter by player type ('Video', 'Audio', 'Photo'), or leave empty for all.
|
|
||||||
function:
|
|
||||||
type: rest
|
|
||||||
resource_template: "http://c5cb4244-emby-mcp:8085/call/retrieve_player_list"
|
|
||||||
method: POST
|
|
||||||
payload_template: >-
|
|
||||||
{{ { "media_type": media_type | 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://c5cb4244-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://c5cb4244-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://c5cb4244-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://c5cb4244-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://c5cb4244-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.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
© 2026 Gordon Bolton. Based on Emby.MCP; this is a complete rewrite.
|
© 2026 Gordon Bolton. GPL v3 — see `LICENCE.md`.
|
||||||
GPL v3 — see `emby-mcp/LICENCE.md`.
|
|
||||||
|
|||||||
+459
-18
@@ -1,4 +1,50 @@
|
|||||||
# Emby MCP add-on
|
# 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` |
|
||||||
|
| `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
|
## Endpoints
|
||||||
|
|
||||||
@@ -12,13 +58,15 @@
|
|||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
Send Emby credentials on every `/mcp` and `/call/...` request:
|
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: Basic <base64(user:pass)>`
|
||||||
- `Authorization: Bearer <emby-token-or-api-key>`
|
- `Authorization: Bearer <emby-token-or-api-key>`
|
||||||
- Optional: `X-Emby-User-Id` or `X-Emby-Username`
|
- Optional: `X-Emby-User-Id` or `X-Emby-Username`
|
||||||
|
|
||||||
Do not put username/password in the add-on options.
|
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
|
## Tool names
|
||||||
|
|
||||||
@@ -29,23 +77,416 @@ Arguments not declared in the tool's current input schema are dropped before
|
|||||||
the call is forwarded; the JSON response then includes
|
the call is forwarded; the JSON response then includes
|
||||||
`"dropped_arguments": ["name", ...]` listing what was ignored.
|
`"dropped_arguments": ["name", ...]` listing what was ignored.
|
||||||
|
|
||||||
|
## Conversation agent
|
||||||
|
|
||||||
## Configuration
|
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:
|
||||||
|
|
||||||
| Option | Purpose |
|
| Repository URL | Add-on hostname |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `emby_server_url` | Emby base URL |
|
| `https://git.i3omb.com/gronod/ha-gronod-addons` | `8e663231-emby-mcp` |
|
||||||
| `emby_username` | Stored Emby username (optional if the client sends Basic/Bearer) |
|
| `https://git.i3omb.com/gronod/ha-emby-mcp` (pre-rename) | `c5cb4244-emby-mcp` |
|
||||||
| `emby_password` | Stored Emby password |
|
|
||||||
| `emby_api_key` | Stored Emby API key (alternative to user/pass) |
|
|
||||||
| `emby_user_id` | Required with an API key if the key is not user-scoped |
|
|
||||||
| `restrict_to_localhost` | Bind `127.0.0.1` and reject non-loopback peers |
|
|
||||||
|
|
||||||
When `restrict_to_localhost` is enabled (default), the add-on uses
|
The examples below use `8e663231-emby-mcp`. If you added the repository before
|
||||||
`host_network` and listens on the HA machine loopback only. Local REST/MCP
|
the rename, replace it with `c5cb4244-emby-mcp` throughout (it cannot be
|
||||||
calls from Home Assistant do not need an `Authorization` header if username
|
changed to a custom hostname).
|
||||||
+ password or an API key is set in the add-on options.
|
|
||||||
|
|
||||||
When the switch is off, the server listens on all interfaces and **does not**
|
These steps assume the add-on has Emby credentials configured and
|
||||||
apply stored credentials to incoming requests. Remote clients must send
|
**Restrict to localhost** is on. Home Assistant then calls
|
||||||
`Authorization` themselves so the LAN cannot use the add-on as an open proxy.
|
`http://8e663231-emby-mcp:8085`; no `Authorization` header is required.
|
||||||
|
|
||||||
|
### System prompt
|
||||||
|
|
||||||
|
Add this line to the conversation agent's instructions:
|
||||||
|
|
||||||
|
```text
|
||||||
|
- Media & Emby: 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`). Use `emby_list_players` to find active playback sessions, and `emby_playback_control` to send playback commands (PlayNow, Pause, Unpause, Stop, NextTrack). 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 active Emby media players, active clients, and their session IDs.
|
||||||
|
parameters:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
media_type:
|
||||||
|
type: string
|
||||||
|
description: Filter by player type ('Video', 'Audio', 'Photo'), or leave empty for all.
|
||||||
|
function:
|
||||||
|
type: rest
|
||||||
|
resource_template: "http://8e663231-emby-mcp:8085/call/retrieve_player_list"
|
||||||
|
method: POST
|
||||||
|
payload_template: >-
|
||||||
|
{{ { "media_type": media_type | 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.
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|||||||
+3
-3
@@ -20,7 +20,7 @@ LABEL \
|
|||||||
io.hass.name="Emby MCP" \
|
io.hass.name="Emby MCP" \
|
||||||
io.hass.description="Emby Model Context Protocol server with REST bridge" \
|
io.hass.description="Emby Model Context Protocol server with REST bridge" \
|
||||||
io.hass.type="addon" \
|
io.hass.type="addon" \
|
||||||
io.hass.version="1.0.10" \
|
io.hass.version="1.0.14" \
|
||||||
org.opencontainers.image.title="ha-emby-mcp" \
|
org.opencontainers.image.title="emby-mcp" \
|
||||||
org.opencontainers.image.source="https://git.i3omb.com/gronod/ha-emby-mcp"
|
org.opencontainers.image.source="https://git.i3omb.com/gronod/ha-gronod-addons"
|
||||||
CMD [ "/run.sh" ]
|
CMD [ "/run.sh" ]
|
||||||
|
|||||||
+35
-238
@@ -1,252 +1,49 @@
|
|||||||
# emby-mcp (Go)
|
# Emby MCP
|
||||||
|
|
||||||
Go rewrite of the Python [Emby.MCP](https://github.com/angelltek/Emby.MCP) server — a Model Context
|
<img src="logo.png" alt="Emby MCP" width="128" height="128">
|
||||||
Protocol (MCP) server that connects an Emby media server to an AI client such
|
|
||||||
as Claude Desktop.
|
|
||||||
|
|
||||||
The original 20 tools match the Python version's parameter names; JSON output
|
Emby Model Context Protocol (MCP) server with a Home Assistant REST bridge,
|
||||||
shapes are slimmed (empty fields are omitted, low-value metadata dropped).
|
packaged as a Home Assistant add-on.
|
||||||
Additional Go-only tools add library browsing, next-episode resolution, and
|
|
||||||
subtitle/audio-track control.
|
Connect your Emby media server to AI clients and Home Assistant voice/conversation
|
||||||
|
agents — search the library, browse shows and episodes, manage playlists, see
|
||||||
|
what's playing, and control playback, all through natural-language tool calls.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* Log on to / log out of an Emby media server (username/password, or API key)
|
* Streamable MCP endpoint at `/mcp` for Claude, ChatGPT, and other MCP clients
|
||||||
* List libraries, select a library, list genres
|
* REST tool bridge at `/call/{tool}` designed for Home Assistant conversation
|
||||||
* Search items by title/album, artist, genre, release years, and
|
agents (works with Assist functions)
|
||||||
item type (`item_types`, e.g. `Series`) — with chunked results for large
|
* Search films, TV series, episodes, and music with chunked results for large
|
||||||
libraries
|
libraries
|
||||||
* Browse an item's children, a series' seasons, and a series' episodes
|
* Browse item children, series seasons, and episode lists with per-user played
|
||||||
(`retrieve_item_children`, `retrieve_season_list`, `retrieve_episode_list`),
|
state and resume positions
|
||||||
including per-user played state and resume positions
|
* Resolve the next episode to watch (`next_unplayed`/`latest`)
|
||||||
* Resolve the next episode to play (`retrieve_next_episode`, modes
|
* Create and modify playlists; list player sessions and control playback
|
||||||
`next_unplayed`/`latest`) without a library selection
|
(PlayNow, pause, seek, skip)
|
||||||
* Create/modify playlists, add/remove/reorder items, share playlists
|
* Inspect and switch audio/subtitle tracks mid-playback
|
||||||
* List player sessions, retrieve play queues, control playback
|
* Localhost-restricted mode (default): only Home Assistant itself can call the
|
||||||
(play/pause/seek/skip, PlayNow)
|
bridge, using stored credentials — no per-request auth needed
|
||||||
* Inspect the now-playing item's audio/subtitle tracks
|
|
||||||
(`retrieve_now_playing`) and switch tracks (`set_subtitle`,
|
|
||||||
`set_audio_track`) — mid-playback where the player supports it, else
|
|
||||||
restart-at-position
|
|
||||||
|
|
||||||
## Build
|
## Requirements
|
||||||
|
|
||||||
```
|
* A running [Emby](https://emby.media) server reachable from Home Assistant
|
||||||
go build -o emby-mcp ./cmd/emby-mcp
|
* Emby credentials: username + password, or an API key (+ user ID)
|
||||||
```
|
|
||||||
|
|
||||||
Requires Go 1.27+.
|
## Installation
|
||||||
|
|
||||||
## Configuration
|
1. Add this repository to Home Assistant
|
||||||
|
(**Settings → Add-ons → Add-on Store → ⋮ → Repositories**):
|
||||||
|
`https://git.i3omb.com/gronod/ha-gronod-addons`
|
||||||
|
2. Install **Emby MCP** from the add-on store
|
||||||
|
3. Set `emby_server_url` plus `emby_username`/`emby_password` (or
|
||||||
|
`emby_api_key` + `emby_user_id`), leave **Restrict to localhost** on, and
|
||||||
|
start the add-on
|
||||||
|
|
||||||
Create a `.env` file (in the working directory or next to the binary):
|
See [DOCS.md](DOCS.md) for the full option reference, conversation-agent
|
||||||
|
setup, troubleshooting, and standalone (non-add-on) usage.
|
||||||
```
|
|
||||||
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 below).
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
Startup checks only (login + list libraries, then exit):
|
|
||||||
|
|
||||||
```
|
|
||||||
./emby-mcp -check
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the MCP server on stdio:
|
|
||||||
|
|
||||||
```
|
|
||||||
./emby-mcp
|
|
||||||
```
|
|
||||||
|
|
||||||
### HTTP mode
|
|
||||||
|
|
||||||
```
|
|
||||||
MCP_TRANSPORT=http MCP_LISTEN_ADDR=0.0.0.0:8080 ./emby-mcp
|
|
||||||
```
|
|
||||||
|
|
||||||
Serves the MCP [streamable HTTP](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports)
|
|
||||||
transport (SSE streaming) at `POST http://<addr>/mcp`. `/healthz` is an
|
|
||||||
unauthenticated liveness endpoint.
|
|
||||||
|
|
||||||
Every request must carry Emby credentials:
|
|
||||||
|
|
||||||
* `Authorization: Basic base64(user:pass)` — exchanged for an Emby access
|
|
||||||
token via `AuthenticateByName` (cached ~15 min).
|
|
||||||
* `Authorization: Bearer <token>` — an Emby access token or API key used
|
|
||||||
directly. The token's owning user is resolved automatically via
|
|
||||||
`GET /Sessions`; server-wide API keys and other ambiguous tokens require a
|
|
||||||
disambiguation header:
|
|
||||||
* `X-Emby-User-Id: <id>` — the Emby user to act as (validated), or
|
|
||||||
* `X-Emby-Username: <name>` — looked up via the public user list.
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
```
|
|
||||||
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 container's `/mcp` endpoint. Every request must
|
|
||||||
carry Emby credentials in headers (see HTTP mode 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
|
|
||||||
|
|
||||||
```
|
|
||||||
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).
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
© 2026 Gordon Bolton. Based on Emby.MCP; this is a complete rewrite.
|
© 2026 Gordon Bolton. Based on [Emby.MCP](https://github.com/angelltek/Emby.MCP);
|
||||||
|
this is a complete rewrite. GPL v3 — see `LICENCE.md`.
|
||||||
GPL v3 — see `LICENCE.md`.
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ type Config struct {
|
|||||||
VerifySSL bool // EMBY_VERIFY_SSL, default true
|
VerifySSL bool // EMBY_VERIFY_SSL, default true
|
||||||
MaxChunkSize int // LLM_MAX_ITEMS; 0 or negative means no chunking limit
|
MaxChunkSize int // LLM_MAX_ITEMS; 0 or negative means no chunking limit
|
||||||
Transport string // MCP_TRANSPORT: "stdio" (default) or "http"
|
Transport string // MCP_TRANSPORT: "stdio" (default) or "http"
|
||||||
ListenAddr string // MCP_LISTEN_ADDR, default "127.0.0.1:8085"
|
ListenAddr string // MCP_LISTEN_ADDR, default "0.0.0.0:8085"
|
||||||
SessionTimeout time.Duration // MCP_SESSION_TIMEOUT, default 30m; <=0 disables
|
SessionTimeout time.Duration // MCP_SESSION_TIMEOUT, default 30m; <=0 disables
|
||||||
RestrictToLocalhost bool // MCP_RESTRICT_LOCALHOST
|
RestrictToLocalhost bool // MCP_RESTRICT_LOCALHOST
|
||||||
LogLevel string // LOG_LEVEL: DEBUG, INFO, WARN
|
LogLevel string // LOG_LEVEL: DEBUG, INFO, WARN
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ EMBY_PASSWORD="p"`)
|
|||||||
if cfg.Transport != TransportStdio {
|
if cfg.Transport != TransportStdio {
|
||||||
t.Errorf("Transport = %q", cfg.Transport)
|
t.Errorf("Transport = %q", cfg.Transport)
|
||||||
}
|
}
|
||||||
if cfg.ListenAddr != "127.0.0.1:8085" {
|
if cfg.ListenAddr != "0.0.0.0:8085" {
|
||||||
t.Errorf("ListenAddr = %q", cfg.ListenAddr)
|
t.Errorf("ListenAddr = %q", cfg.ListenAddr)
|
||||||
}
|
}
|
||||||
if cfg.SessionTimeout != 30*time.Minute {
|
if cfg.SessionTimeout != 30*time.Minute {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.2
|
||||||
|
|
||||||
|
- First release in Gronod's Home Assistant Add-Ons repository
|
||||||
|
- Wraps `openai-oauth` + `@openai/codex` (node:alpine image)
|
||||||
|
- Local Bearer API key required on port 10531; auto-generated to
|
||||||
|
`/config/.api_key` on first start and printed in the add-on log
|
||||||
|
(override with the `api_key` option)
|
||||||
|
- `log_level` and `log_requests` options mapped to upstream
|
||||||
|
`CODEX_OPENAI_SERVER_LOG_REQUESTS`
|
||||||
|
- `models` option passes an allowlist to `openai-oauth --models`
|
||||||
|
- `auth.json` read directly from `/share` so OAuth token refreshes persist
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# OpenAI Codex Proxy — documentation
|
||||||
|
|
||||||
|
Full setup, configuration, and troubleshooting for the **OpenAI Codex Proxy**
|
||||||
|
Home Assistant add-on.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
The add-on runs [openai-oauth](https://github.com/EvanZhouDev/openai-oauth)
|
||||||
|
(by [@EvanZhouDev](https://github.com/EvanZhouDev)), a local reverse proxy
|
||||||
|
that speaks the same authenticated Codex API as OpenAI's
|
||||||
|
[@openai/codex](https://github.com/openai/codex) CLI
|
||||||
|
(`chatgpt.com/backend-api/codex`). It exposes an OpenAI-compatible `/v1` API
|
||||||
|
backed by your ChatGPT account instead of API credits.
|
||||||
|
|
||||||
|
Because `openai-oauth` itself has no client authentication, this add-on binds
|
||||||
|
it to loopback and publishes a small token-protected proxy on port `10531`.
|
||||||
|
Every request must send `Authorization: Bearer <key>` (or `x-api-key`).
|
||||||
|
|
||||||
|
## Step 1 — generate `auth.json`
|
||||||
|
|
||||||
|
On a desktop machine with Node.js installed (macOS, Linux, or Windows):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx @openai/codex login
|
||||||
|
```
|
||||||
|
|
||||||
|
A browser opens for the ChatGPT sign-in. Afterwards your credentials are at:
|
||||||
|
|
||||||
|
| OS | Path |
|
||||||
|
|---|---|
|
||||||
|
| macOS / Linux | `~/.codex/auth.json` |
|
||||||
|
| Windows | `%USERPROFILE%\.codex\auth.json` |
|
||||||
|
|
||||||
|
> Treat `auth.json` like a password — it grants access to your ChatGPT
|
||||||
|
> account. Don't commit it, post it, or leave it world-readable.
|
||||||
|
|
||||||
|
## Step 2 — copy `auth.json` to Home Assistant
|
||||||
|
|
||||||
|
The add-on reads `/share/auth.json`. Copy the file into the `share` folder on
|
||||||
|
your Home Assistant machine using either:
|
||||||
|
|
||||||
|
* the **Samba share** add-on — drop `auth.json` into `\\<host>\share\`, or
|
||||||
|
* **SSH / Terminal** — e.g. `scp ~/.codex/auth.json root@<ha-host>:/share/auth.json`
|
||||||
|
|
||||||
|
## Step 3 — install and start
|
||||||
|
|
||||||
|
1. Add this repository in Home Assistant
|
||||||
|
(**Settings → Add-ons → Add-on Store → ⋮ → Repositories**):
|
||||||
|
`https://git.i3omb.com/gronod/ha-gronod-addons`
|
||||||
|
2. Install **OpenAI Codex Proxy** and start it
|
||||||
|
3. Open the add-on **Log** — it prints the generated local API key:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[INFO] Generated a new local API key (stored at /config/.api_key)
|
||||||
|
[INFO] Local API key: <your-key>
|
||||||
|
```
|
||||||
|
|
||||||
|
If `/share/auth.json` is missing the add-on logs instructions and retries
|
||||||
|
every 60 seconds — just copy the file and wait for the restart.
|
||||||
|
|
||||||
|
## Step 4 — connect an integration
|
||||||
|
|
||||||
|
Configure your OpenAI-compatible integration with:
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---|---|
|
||||||
|
| Base URL | `http://<home-assistant-host>:10531/v1` |
|
||||||
|
| API key | the key from the add-on log (or your `api_key` option) |
|
||||||
|
|
||||||
|
From another add-on or a supervised Home Assistant container you can also use
|
||||||
|
the internal DNS name `http://8e663231-openai-codex-proxy:10531/v1`.
|
||||||
|
|
||||||
|
Quick check from a shell:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl http://<ha-host>:10531/v1/models \
|
||||||
|
-H "Authorization: Bearer <your-key>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `api_key` | _(empty)_ | Local API key clients must send. Leave empty to auto-generate one on first start (persisted in the add-on config and printed in the log). |
|
||||||
|
| `models` | _(empty)_ | Comma-separated allowlist passed to `openai-oauth --models`. Empty = expose every model your account can use. |
|
||||||
|
| `log_level` | `INFO` | `DEBUG`, `INFO`, or `WARN` — verbosity of the add-on wrapper. `DEBUG` also enables upstream request logging. |
|
||||||
|
| `log_requests` | `false` | Set upstream `CODEX_OPENAI_SERVER_LOG_REQUESTS=1`: emits one JSON line per request (path, status, duration, token usage). |
|
||||||
|
|
||||||
|
The host-side port mapping (`10531`) can be changed or disabled in the
|
||||||
|
add-on's **Network** section; the container-internal port stays `10531`.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Path | Method | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `/v1/responses` | POST | OpenAI Responses API |
|
||||||
|
| `/v1/chat/completions` | POST | Chat Completions API |
|
||||||
|
| `/v1/models` | GET | Models available to your account |
|
||||||
|
| `/v1/images/generations`, `/v1/images/edits` | POST | Image generation/editing |
|
||||||
|
| `/health` | GET | Unauthenticated liveness check |
|
||||||
|
|
||||||
|
All `/v1/*` routes require `Authorization: Bearer <key>`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Log shows `ERROR: /share/auth.json not found!`**
|
||||||
|
The file isn't in the `share` folder yet, or has a different name. Copy it as
|
||||||
|
described in Step 2 and wait for the automatic restart (60 s).
|
||||||
|
|
||||||
|
**Requests return `401 authentication_error`**
|
||||||
|
Wrong or missing API key. Use the key printed in the add-on log, or set the
|
||||||
|
`api_key` option explicitly and restart.
|
||||||
|
|
||||||
|
**Requests return `502 Upstream not ready`**
|
||||||
|
`openai-oauth` is still starting (it resolves your account's model list from
|
||||||
|
ChatGPT) or crashed — check the add-on log above the proxy messages.
|
||||||
|
|
||||||
|
**Responses fail with auth errors after weeks/months**
|
||||||
|
OAuth tokens normally refresh automatically and are written back to
|
||||||
|
`/share/auth.json`. If the refresh token itself has expired (or you replaced
|
||||||
|
`auth.json` with an older copy), re-run `npx @openai/codex login` on your
|
||||||
|
desktop and copy the fresh file to `/share` again.
|
||||||
|
|
||||||
|
**Rate limits / missing models**
|
||||||
|
Only models your ChatGPT plan supports appear, and Codex account rate limits
|
||||||
|
apply. `openai-oauth` is an unofficial community project; OpenAI may change
|
||||||
|
the underlying endpoints at any time.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
* The published port is protected by the local API key — anyone on your LAN
|
||||||
|
still needs it to use your ChatGPT account. Keep it secret like a password.
|
||||||
|
* To restrict access further, disable the host port mapping and reach the
|
||||||
|
add-on only via its internal hostname `8e663231-openai-codex-proxy` from
|
||||||
|
other add-ons/Home Assistant.
|
||||||
|
* `/share/auth.json` is visible to anything with Samba/SSH access to the
|
||||||
|
`share` folder.
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
* [openai-oauth](https://github.com/EvanZhouDev/openai-oauth) by
|
||||||
|
[@EvanZhouDev](https://github.com/EvanZhouDev) (Apache-2.0) — the proxy and
|
||||||
|
Codex OAuth session handling.
|
||||||
|
* [@openai/codex](https://github.com/openai/codex) (Apache-2.0) — OpenAI's
|
||||||
|
Codex CLI, used to produce `auth.json` and for model discovery.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM node:alpine
|
||||||
|
|
||||||
|
RUN npm install -g openai-oauth @openai/codex
|
||||||
|
|
||||||
|
COPY rootfs /
|
||||||
|
|
||||||
|
LABEL \
|
||||||
|
io.hass.name="OpenAI Codex Proxy" \
|
||||||
|
io.hass.description="Local reverse proxy for ChatGPT Plus Codex OAuth" \
|
||||||
|
io.hass.type="addon" \
|
||||||
|
io.hass.version="1.0.2" \
|
||||||
|
org.opencontainers.image.title="openai-codex-proxy" \
|
||||||
|
org.opencontainers.image.source="https://git.i3omb.com/gronod/ha-gronod-addons"
|
||||||
|
|
||||||
|
CMD ["node", "/entrypoint.js"]
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# OpenAI Codex Proxy
|
||||||
|
|
||||||
|
<img src="logo.png" alt="OpenAI Codex Proxy" width="128" height="128">
|
||||||
|
|
||||||
|
Local OpenAI-compatible API endpoint backed by your ChatGPT account's Codex
|
||||||
|
OAuth login — packaged as a Home Assistant add-on.
|
||||||
|
|
||||||
|
Point any OpenAI-compatible integration (for example an OpenAI conversation
|
||||||
|
integration in Home Assistant) at this add-on and it answers through your
|
||||||
|
ChatGPT Plus/Pro account instead of paid API credits.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
* OpenAI-compatible endpoints: `/v1/responses`, `/v1/chat/completions`,
|
||||||
|
`/v1/models`, and `/v1/images/*`
|
||||||
|
* Exposes the Codex models your ChatGPT account can actually use
|
||||||
|
* Streaming responses, tool calls, and reasoning traces
|
||||||
|
* Local API key required on every request — generated automatically on first
|
||||||
|
start (no open proxy on your LAN)
|
||||||
|
* Optional per-request JSON logging for troubleshooting
|
||||||
|
* OAuth tokens refresh automatically and persist in `/share/auth.json`
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
* A ChatGPT account (Plus or Pro recommended for Codex access)
|
||||||
|
* A Codex `auth.json` file, generated on a desktop machine with
|
||||||
|
`npx @openai/codex login` and copied to this Home Assistant machine's
|
||||||
|
`/share` folder
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Add this repository to Home Assistant
|
||||||
|
(**Settings → Add-ons → Add-on Store → ⋮ → Repositories**):
|
||||||
|
`https://git.i3omb.com/gronod/ha-gronod-addons`
|
||||||
|
2. Copy your `auth.json` to `/share/auth.json` (Samba or SSH add-on)
|
||||||
|
3. Install **OpenAI Codex Proxy** and start it
|
||||||
|
4. Copy the generated local API key from the add-on log and configure your
|
||||||
|
OpenAI-compatible integration with base URL
|
||||||
|
`http://<home-assistant-host>:10531/v1`
|
||||||
|
|
||||||
|
See [DOCS.md](DOCS.md) for the step-by-step guide, option reference, and
|
||||||
|
troubleshooting.
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
This add-on wraps [openai-oauth](https://github.com/EvanZhouDev/openai-oauth)
|
||||||
|
by [@EvanZhouDev](https://github.com/EvanZhouDev) — an unofficial,
|
||||||
|
community-maintained project that exposes ChatGPT Codex OAuth sessions as an
|
||||||
|
OpenAI-compatible API. Login uses OpenAI's
|
||||||
|
[@openai/codex](https://github.com/openai/codex) CLI.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
© 2026 Gordon Bolton. GPL v3 — see `../LICENCE.md`. Upstream components are
|
||||||
|
licensed separately: openai-oauth (Apache-2.0), @openai/codex (Apache-2.0).
|
||||||
|
Use is subject to OpenAI's Terms of Use and your ChatGPT plan's rate limits.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1.0.2
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: "OpenAI Codex Proxy"
|
||||||
|
description: "Local reverse proxy for ChatGPT Plus Codex OAuth"
|
||||||
|
version: "1.0.2"
|
||||||
|
slug: "openai_codex_proxy"
|
||||||
|
init: false
|
||||||
|
startup: application
|
||||||
|
boot: auto
|
||||||
|
arch:
|
||||||
|
- aarch64
|
||||||
|
- amd64
|
||||||
|
ports:
|
||||||
|
10531/tcp: 10531
|
||||||
|
map:
|
||||||
|
- share:rw
|
||||||
|
- addon_config:rw
|
||||||
|
options:
|
||||||
|
api_key: ""
|
||||||
|
models: ""
|
||||||
|
log_level: INFO
|
||||||
|
log_requests: false
|
||||||
|
schema:
|
||||||
|
api_key: password?
|
||||||
|
models: str?
|
||||||
|
log_level: list(DEBUG|INFO|WARN)
|
||||||
|
log_requests: bool
|
||||||
|
panel_icon: mdi:api
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 868 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 868 KiB |
@@ -0,0 +1,262 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// OpenAI Codex Proxy add-on entrypoint.
|
||||||
|
//
|
||||||
|
// openai-oauth performs no authentication on its local endpoint, so this
|
||||||
|
// wrapper binds it to loopback and publishes a small Bearer-token proxy on
|
||||||
|
// the add-on port. The local API key is either taken from the `api_key`
|
||||||
|
// option or generated once and persisted under /config (addon_config).
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const fs = require("fs");
|
||||||
|
const http = require("http");
|
||||||
|
const { spawn } = require("child_process");
|
||||||
|
const { Readable } = require("stream");
|
||||||
|
|
||||||
|
const OPTIONS_FILE = "/data/options.json";
|
||||||
|
const AUTH_FILE = "/share/auth.json";
|
||||||
|
const KEY_FILE = "/config/.api_key";
|
||||||
|
const PUBLIC_PORT = 10531;
|
||||||
|
const UPSTREAM_PORT = 10532;
|
||||||
|
const UPSTREAM_URL = `http://127.0.0.1:${UPSTREAM_PORT}`;
|
||||||
|
|
||||||
|
// ---- options ---------------------------------------------------------------
|
||||||
|
|
||||||
|
let options = {};
|
||||||
|
try {
|
||||||
|
options = JSON.parse(fs.readFileSync(OPTIONS_FILE, "utf8")) || {};
|
||||||
|
} catch {
|
||||||
|
options = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_LEVEL = String(options.log_level || "INFO").toUpperCase();
|
||||||
|
const LEVELS = { DEBUG: 0, INFO: 1, WARN: 2 };
|
||||||
|
const threshold = LEVELS[LOG_LEVEL] ?? LEVELS.INFO;
|
||||||
|
|
||||||
|
const log = (level, msg) => {
|
||||||
|
if (LEVELS[level] >= threshold) {
|
||||||
|
console.log(`[${level}] ${msg}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- auth.json guard -------------------------------------------------------
|
||||||
|
|
||||||
|
if (!fs.existsSync(AUTH_FILE)) {
|
||||||
|
console.error(
|
||||||
|
`ERROR: ${AUTH_FILE} not found!\n` +
|
||||||
|
"Generate it on a desktop with `npx @openai/codex login`, then copy\n" +
|
||||||
|
"~/.codex/auth.json (macOS/Linux) or %USERPROFILE%\\.codex\\auth.json\n" +
|
||||||
|
"(Windows) to the /share folder on this Home Assistant machine.\n" +
|
||||||
|
"Retrying in 60 seconds..."
|
||||||
|
);
|
||||||
|
setTimeout(() => process.exit(1), 60_000);
|
||||||
|
} else {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- main ------------------------------------------------------------------
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const apiKey = resolveApiKey();
|
||||||
|
const upstream = startUpstream();
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
handleRequest(req, res, apiKey).catch((err) => {
|
||||||
|
log("WARN", `request error: ${err.message}`);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
sendJson(res, 502, {
|
||||||
|
error: { message: "Upstream not ready.", type: "upstream_error" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on("error", (err) => {
|
||||||
|
console.error(`ERROR: cannot listen on 0.0.0.0:${PUBLIC_PORT}: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PUBLIC_PORT, "0.0.0.0", () => {
|
||||||
|
log(
|
||||||
|
"INFO",
|
||||||
|
`OpenAI Codex Proxy listening on 0.0.0.0:${PUBLIC_PORT} ` +
|
||||||
|
`(upstream 127.0.0.1:${UPSTREAM_PORT})`
|
||||||
|
);
|
||||||
|
log("INFO", "Base URL for clients: http://<this-host>:10531/v1");
|
||||||
|
log("INFO", `Local API key: ${apiKey}`);
|
||||||
|
waitForUpstream();
|
||||||
|
});
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
const shutdown = (signal) => {
|
||||||
|
shuttingDown = true;
|
||||||
|
log("INFO", `${signal} received, shutting down`);
|
||||||
|
upstream.kill("SIGTERM");
|
||||||
|
server.close(() => process.exit(0));
|
||||||
|
setTimeout(() => process.exit(0), 5_000).unref();
|
||||||
|
};
|
||||||
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||||
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||||
|
|
||||||
|
upstream.on("exit", (code, signal) => {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
console.error(
|
||||||
|
`ERROR: openai-oauth exited (code=${code} signal=${signal}); exiting so the add-on restarts`
|
||||||
|
);
|
||||||
|
process.exit(code ?? 1);
|
||||||
|
});
|
||||||
|
upstream.on("error", (err) => {
|
||||||
|
console.error(`ERROR: failed to start openai-oauth: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveApiKey() {
|
||||||
|
const fromOption = String(options.api_key || "").trim();
|
||||||
|
if (fromOption) {
|
||||||
|
log("INFO", "Using API key from the api_key option");
|
||||||
|
return fromOption;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stored = fs.readFileSync(KEY_FILE, "utf8").trim();
|
||||||
|
if (stored) return stored;
|
||||||
|
} catch {
|
||||||
|
// not generated yet
|
||||||
|
}
|
||||||
|
const generated = crypto.randomBytes(32).toString("base64url");
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(require("path").dirname(KEY_FILE), { recursive: true });
|
||||||
|
fs.writeFileSync(KEY_FILE, generated + "\n", { mode: 0o600 });
|
||||||
|
log("INFO", `Generated a new local API key (stored at ${KEY_FILE})`);
|
||||||
|
} catch (err) {
|
||||||
|
log("WARN", `could not persist generated key to ${KEY_FILE}: ${err.message}`);
|
||||||
|
}
|
||||||
|
return generated;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startUpstream() {
|
||||||
|
const args = [
|
||||||
|
"openai-oauth",
|
||||||
|
"--host",
|
||||||
|
"127.0.0.1",
|
||||||
|
"--port",
|
||||||
|
String(UPSTREAM_PORT),
|
||||||
|
"--oauth-file",
|
||||||
|
AUTH_FILE,
|
||||||
|
];
|
||||||
|
const models = String(options.models || "").trim();
|
||||||
|
if (models) args.push("--models", models);
|
||||||
|
|
||||||
|
const env = { ...process.env };
|
||||||
|
env.OPENAI_OAUTH_INTERNAL_RUNTIME_DIR = "/data/openai-oauth";
|
||||||
|
const wantRequestLogs =
|
||||||
|
options.log_requests === true || LOG_LEVEL === "DEBUG";
|
||||||
|
env.CODEX_OPENAI_SERVER_LOG_REQUESTS = wantRequestLogs ? "1" : "0";
|
||||||
|
log(
|
||||||
|
"DEBUG",
|
||||||
|
`spawning: npx ${args.join(" ")} (request logs ${wantRequestLogs ? "on" : "off"})`
|
||||||
|
);
|
||||||
|
|
||||||
|
return spawn("npx", args, { stdio: "inherit", env });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForUpstream() {
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${UPSTREAM_URL}/health`);
|
||||||
|
if (res.ok) {
|
||||||
|
log("INFO", "upstream openai-oauth is ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// not up yet
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- request handling ------------------------------------------------------
|
||||||
|
|
||||||
|
const HOP_BY_HOP = new Set([
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"proxy-authorization",
|
||||||
|
"te",
|
||||||
|
"trailer",
|
||||||
|
"transfer-encoding",
|
||||||
|
"upgrade",
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function handleRequest(req, res, apiKey) {
|
||||||
|
const url = new URL(req.url, `http://localhost:${PUBLIC_PORT}`);
|
||||||
|
|
||||||
|
if (req.method === "GET" && url.pathname === "/health") {
|
||||||
|
sendJson(res, 200, { ok: true, service: "openai-codex-proxy" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthorized(req, apiKey)) {
|
||||||
|
sendJson(res, 401, {
|
||||||
|
error: {
|
||||||
|
message: "Missing or invalid API key. Send 'Authorization: Bearer <key>'.",
|
||||||
|
type: "authentication_error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {};
|
||||||
|
for (const [k, v] of Object.entries(req.headers)) {
|
||||||
|
const key = k.toLowerCase();
|
||||||
|
if (HOP_BY_HOP.has(key)) continue;
|
||||||
|
if (key === "host" || key === "authorization" || key === "x-api-key") continue;
|
||||||
|
if (key === "content-length") continue;
|
||||||
|
headers[key] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
const started = Date.now();
|
||||||
|
const upstreamRes = await fetch(UPSTREAM_URL + req.url, {
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
body: ["GET", "HEAD"].includes(req.method) ? undefined : req,
|
||||||
|
duplex: "half",
|
||||||
|
});
|
||||||
|
|
||||||
|
const resHeaders = {};
|
||||||
|
upstreamRes.headers.forEach((v, k) => {
|
||||||
|
if (!HOP_BY_HOP.has(k)) resHeaders[k] = v;
|
||||||
|
});
|
||||||
|
res.writeHead(upstreamRes.status, resHeaders);
|
||||||
|
if (upstreamRes.body) {
|
||||||
|
Readable.fromWeb(upstreamRes.body).pipe(res);
|
||||||
|
} else {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
log(
|
||||||
|
"INFO",
|
||||||
|
`${req.method} ${url.pathname} -> ${upstreamRes.status} ${Date.now() - started}ms`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthorized(req, apiKey) {
|
||||||
|
const bearer = req.headers.authorization || "";
|
||||||
|
const presented = bearer.startsWith("Bearer ")
|
||||||
|
? bearer.slice(7)
|
||||||
|
: req.headers["x-api-key"] || "";
|
||||||
|
if (!presented) return false;
|
||||||
|
const a = crypto.createHash("sha256").update(String(presented)).digest();
|
||||||
|
const b = crypto.createHash("sha256").update(apiKey).digest();
|
||||||
|
return crypto.timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res, status, obj) {
|
||||||
|
const body = JSON.stringify(obj);
|
||||||
|
res.writeHead(status, {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"content-length": Buffer.byteLength(body),
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
name: Emby MCP Add-ons
|
name: Gronod's Home Assistant Add-Ons
|
||||||
url: "https://git.i3omb.com/gronod/ha-emby-mcp"
|
url: "https://git.i3omb.com/gronod/ha-gronod-addons"
|
||||||
maintainer: Gordon Bolton <gordon@i3omb.com>
|
maintainer: Gordon Bolton <gordon@i3omb.com>
|
||||||
|
|||||||
Reference in New Issue
Block a user