3 Commits
Author SHA1 Message Date
gronod 02d9449a34 feat: add OpenAI Codex Proxy add-on (1.0.2)
Local OpenAI-compatible endpoint backed by a ChatGPT account's Codex
OAuth login (wraps EvanZhouDev/openai-oauth + @openai/codex on
node:alpine). Upstream has no client auth, so a Node entrypoint binds it
to loopback and enforces a Bearer/x-api-key check on the published port;
the key is auto-generated into addon_config on first start and printed
in the log. Options map to upstream controls: api_key, models
(--models), log_level/log_requests (CODEX_OPENAI_SERVER_LOG_REQUESTS).
auth.json is read via --oauth-file /share/auth.json so token refreshes
persist across restarts.
2026-09-22 14:03:59 +01:00
gronod ea91207196 docs: convert to multi-add-on repository layout
Rename the repository to Gronod's Home Assistant Add-Ons
(ha-gronod-addons). Root README becomes a repo-level overview; each
add-on's README carries decision content and DOCS.md carries the full
configuration/usage reference, matching mainline HA add-on conventions.
Emby MCP conversation-agent URLs updated for the new repo hash
(8e663231-emby-mcp) with a note for pre-rename c5cb4244 installs.
2026-09-22 14:03:37 +01:00
gronod af5fd8feea fix: update config test/comment for 0.0.0.0:8085 listen default
The default MCP_LISTEN_ADDR changed to 0.0.0.0 in 1.0.5 but the
TestLoadDefaults expectation and the field comment were never updated.
2026-09-22 14:03:32 +01:00
17 changed files with 1075 additions and 504 deletions
+37
View File
@@ -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.
+18 -241
View File
@@ -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**.
[![Add this repository to your Home Assistant instance.](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgit.i3omb.com%2Fgronod%2Fha-gronod-addons)
[![Add this repository to your Home Assistant instance.](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](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:
- 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
## Install this repository
1. Home Assistant → **Settings → Add-ons → Add-on Store → ⋮ → Repositories**
2. Add `https://git.i3omb.com/gronod/ha-emby-mcp`
3. Install **Emby MCP**, set `emby_server_url` plus username/password or API key
4. Leave **Restrict to localhost** enabled and start the add-on
2. Add `https://git.i3omb.com/gronod/ha-gronod-addons`
3. Install the add-on you want from the store and follow its documentation
## 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 |
|---|---|---|
| `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 |
## Support
## Conversation agent
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.
Open an issue on [the repository](https://git.i3omb.com/gronod/ha-gronod-addons/issues).
## License
© 2026 Gordon Bolton. Based on Emby.MCP; this is a complete rewrite.
GPL v3 — see `emby-mcp/LICENCE.md`.
© 2026 Gordon Bolton. GPL v3 — see `LICENCE.md`.
+459 -18
View File
@@ -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
@@ -12,13 +58,15 @@
## 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: Bearer <emby-token-or-api-key>`
- 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
@@ -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
`"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 |
| `emby_username` | Stored Emby username (optional if the client sends Basic/Bearer) |
| `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 |
| `https://git.i3omb.com/gronod/ha-gronod-addons` | `8e663231-emby-mcp` |
| `https://git.i3omb.com/gronod/ha-emby-mcp` (pre-rename) | `c5cb4244-emby-mcp` |
When `restrict_to_localhost` is enabled (default), the add-on uses
`host_network` and listens on the HA machine loopback only. Local REST/MCP
calls from Home Assistant do not need an `Authorization` header if username
+ password or an API key is set in the add-on options.
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).
When the switch is off, the server listens on all interfaces and **does not**
apply stored credentials to incoming requests. Remote clients must send
`Authorization` themselves so the LAN cannot use the add-on as an open proxy.
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: 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
View File
@@ -20,7 +20,7 @@ LABEL \
io.hass.name="Emby MCP" \
io.hass.description="Emby Model Context Protocol server with REST bridge" \
io.hass.type="addon" \
io.hass.version="1.0.10" \
org.opencontainers.image.title="ha-emby-mcp" \
org.opencontainers.image.source="https://git.i3omb.com/gronod/ha-emby-mcp"
io.hass.version="1.0.14" \
org.opencontainers.image.title="emby-mcp" \
org.opencontainers.image.source="https://git.i3omb.com/gronod/ha-gronod-addons"
CMD [ "/run.sh" ]
+35 -238
View File
@@ -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
Protocol (MCP) server that connects an Emby media server to an AI client such
as Claude Desktop.
<img src="logo.png" alt="Emby MCP" width="128" height="128">
The original 20 tools match the Python version's parameter names; JSON output
shapes are slimmed (empty fields are omitted, low-value metadata dropped).
Additional Go-only tools add library browsing, next-episode resolution, and
subtitle/audio-track control.
Emby Model Context Protocol (MCP) server with a Home Assistant REST bridge,
packaged as a Home Assistant add-on.
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
* Log on to / log out of an Emby media server (username/password, or API key)
* List libraries, select a library, list genres
* Search items by title/album, artist, genre, release years, and
item type (`item_types`, e.g. `Series`) — with chunked results for large
* Streamable MCP endpoint at `/mcp` for Claude, ChatGPT, and other MCP clients
* REST tool bridge at `/call/{tool}` designed for Home Assistant conversation
agents (works with Assist functions)
* Search films, TV series, episodes, and music with chunked results for large
libraries
* Browse an item's children, a series' seasons, and a series' episodes
(`retrieve_item_children`, `retrieve_season_list`, `retrieve_episode_list`),
including per-user played state and resume positions
* Resolve the next episode to play (`retrieve_next_episode`, modes
`next_unplayed`/`latest`) without a library selection
* Create/modify playlists, add/remove/reorder items, share playlists
* List player sessions, retrieve play queues, control playback
(play/pause/seek/skip, PlayNow)
* 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
* Browse item children, series seasons, and episode lists with per-user played
state and resume positions
* Resolve the next episode to watch (`next_unplayed`/`latest`)
* Create and modify playlists; list player sessions and control playback
(PlayNow, pause, seek, skip)
* Inspect and switch audio/subtitle tracks mid-playback
* Localhost-restricted mode (default): only Home Assistant itself can call the
bridge, using stored credentials — no per-request auth needed
## Build
## Requirements
```
go build -o emby-mcp ./cmd/emby-mcp
```
* A running [Emby](https://emby.media) server reachable from Home Assistant
* 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):
```
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).
See [DOCS.md](DOCS.md) for the full option reference, conversation-agent
setup, troubleshooting, and standalone (non-add-on) usage.
## License
© 2026 Gordon Bolton. Based on Emby.MCP; this is a complete rewrite.
GPL v3 — see `LICENCE.md`.
© 2026 Gordon Bolton. Based on [Emby.MCP](https://github.com/angelltek/Emby.MCP);
this is a complete rewrite. GPL v3 — see `LICENCE.md`.
+1 -1
View File
@@ -28,7 +28,7 @@ type Config struct {
VerifySSL bool // EMBY_VERIFY_SSL, default true
MaxChunkSize int // LLM_MAX_ITEMS; 0 or negative means no chunking limit
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
RestrictToLocalhost bool // MCP_RESTRICT_LOCALHOST
LogLevel string // LOG_LEVEL: DEBUG, INFO, WARN
+1 -1
View File
@@ -98,7 +98,7 @@ EMBY_PASSWORD="p"`)
if cfg.Transport != TransportStdio {
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)
}
if cfg.SessionTimeout != 30*time.Minute {
+13
View File
@@ -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
+145
View File
@@ -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.
+16
View File
@@ -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"]
+56
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
1.0.2
+26
View File
@@ -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

+262
View File
@@ -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
View File
@@ -1,3 +1,3 @@
name: Emby MCP Add-ons
url: "https://git.i3omb.com/gronod/ha-emby-mcp"
name: Gronod's Home Assistant Add-Ons
url: "https://git.i3omb.com/gronod/ha-gronod-addons"
maintainer: Gordon Bolton <gordon@i3omb.com>