22 KiB
Homey, Emby, DuckDuckGo & Spotify MCP Servers — Code Audit
| Scope | homey-mcp (Python, ~1.6k lines), emby-mcp (Python, ~0.6k), duckduckgo-mcp (Python, ~0.2k), spotify-mcp-server (TypeScript, ~2.5k) |
| Method | Every file read by hand. Each finding was reproduced: offline with mocked HTTP transports, or with read-only live requests against the configured Homey, Emby and Spotify accounts. Nothing was switched, played, deleted or changed during the audit. |
| Date | 2026-10-03 |
| Companion audits | AUDIT.md (Javis), AUDIT_SLAVE.md (satellite + slave MCP) |
1. How Javis uses these servers
All four run as stdio subprocesses of the Javis MCP host (mcp_servers.json).
homey-mcp can additionally run as a LAN HTTP server (streamable-http / SSE + a REST
API) for other clients such as Odysseus.
| Server | Approval in mcp_servers.json |
Credential it holds |
|---|---|---|
| homey | ask (default) |
Homey Pro personal access token |
| emby | allow |
Emby admin API key |
| duckduckgo | allow |
— |
| spotify | ask |
OAuth client secret + refresh token |
The Javis host treats a tool result as failed only when MCP reports isError: true, which
it turns into an ERROR: prefix. Failures that are returned as ordinary text therefore count
as successes: they are cached, recorded as quick commands and reported as done.
2. Findings summary
| ID | Severity | Server | Finding | Status |
|---|---|---|---|---|
| EMB-01 | Critical | emby | All Emby tools fail with Javis' config: the scheme defaults to https while the server speaks HTTP |
Fixed |
| EMB-02 | High | emby | search_items only looks at the first 60 of 68,932 library items |
Fixed |
| EMB-03 | High | emby | play_item, get_item, playback_url call endpoints that don't exist; list_devices always returns [] |
Fixed |
| EMB-04 | High | emby / security | IDs inserted unencoded into URL paths, with the admin key (123/Delete, ../System/Restart) |
Fixed |
| HOM-01 | High | homey / security | Path injection through IDs: trigger_flow('../../system/reboot#') reboots the Homey |
Fixed |
| HOM-02 | High | homey LAN / security | AUTH_MODE=bearer leaves the SSE endpoints /sse and /messages/ unauthenticated |
Fixed |
| HOM-03 | Medium | homey LAN / security | DNS-rebinding protection disabled although the docs promise a Host check | Fixed |
| HOM-04 | Medium | homey | Realtime events never arrive (wrong Socket.IO protocol) | Fixed |
| HOM-05 | Medium | homey | Zone names resolve to the first substring hit (Wohnzimmer can pick Wohnzimmer Ecke) |
Fixed |
| HOM-06 | Medium | homey | find_devices matches ANY query word, so "esszimmer licht" returns every light |
Fixed |
| DDG-01 | Medium | duckduckgo | Blocking searches run on the server's event loop | Fixed |
| DDG-02 | Medium | duckduckgo | Search failures are returned as normal text, so Javis caches them as results | Fixed |
| SPO-01 | Medium | spotify | Failures are returned without isError in all 28 tools |
Fixed |
| SPO-02 | Medium | spotify | playMusic(uri="spotify:track:…") is sent as a context URI and rejected by Spotify |
Fixed |
| SPO-03 | Medium | spotify | Token refresh ignores rotated refresh tokens, has no expiry margin and can run in parallel | Fixed |
| EMB-05 | Medium | emby | Failures are returned as {"ok": false} data instead of tool errors |
Fixed |
| HOM-07 | Low | homey | systemd unit cannot work (homey-mcp@user enabled, but not a template; %h is /root) |
Fixed |
| HOM-08 | Low | homey | Errors returned as data; REST returns 500 on bad JSON; non-ASCII token crashes the auth check | Fixed |
| EMB-06 | Low | emby | Wrong field mapping (runtime, year, episode numbers); documented type filter is missing |
Fixed |
| DDG-03 | Low | duckduckgo | No input validation | Fixed |
| SPO-04 | Low | spotify | Credentials file written non-atomically with default permissions | Fixed |
| SPO-05 | Low | spotify | IDs unencoded in raw API paths | Fixed |
| SPO-06 | Low | spotify | adjustVolume reads one device's volume and sets another's |
Fixed |
| SPO-07 | Low | spotify | 3:60 durations, playlist search lines, saved-track numbering |
Fixed |
| X-01 | Low | all | No MCP tool annotations (e.g. control_all can switch off every socket) |
Fixed |
| X-02 | Low | Python servers | mcp>=1.2 allows mcp 2.x, which removed mcp.server.fastmcp |
Fixed (pin) |
| X-03 | Info | all | READMEs out of date (tool lists, defaults, paths) | Fixed |
| X-04 | Info | homey / emby / spotify | Broad credentials (admin Emby key, full Homey token, unused Spotify scopes) | Open (recommendation) |
| X-05 | Info | spotify | Biome reports format errors (CRLF line endings) — present before the audit | Open |
3. Detailed findings
Emby
EMB-01
All Emby tools fail with Javis' configuration — Critical
mcp_servers.json sets EMBY_HOST=10.10.10.50:9096 without a scheme. Settings.scheme
defaulted to https, so the base URL became https://10.10.10.50:9096. That port speaks
plain HTTP:
https://10.10.10.50:9096/System/Info/Public → ConnectError [SSL: WRONG_VERSION_NUMBER]
http://10.10.10.50:9096/System/Info/Public → 200 {"ServerName": …}
emby-mcp/.env (scheme https, port 443) is not read when Javis launches the server,
because pydantic-settings resolves .env relative to the working directory, which is
F:\Javis.
Fix: scheme defaults to http (Emby's own default). EMBY_HOST may now be
host:port or a full URL, and EMBY_PORT is ignored when the host already has a port.
The README explains the .env lookup.
EMB-02
Search only sees the first 60 items — High
search_items fetched limit*3 items sorted by name and filtered them in Python. The library
holds 68,932 items, so almost every title was out of reach. Fix: server-side
SearchTerm plus IncludeItemTypes. Without the type filter Emby also returns People,
Studios and other non-media items. Verified live: the fixed search finds titles across the
whole library.
EMB-03
Endpoints that don't exist — High
Checked against the server's own /openapi.json and live requests:
| Tool | Called | Reality |
|---|---|---|
get_item |
GET /Items/{id} |
only DELETE exists → 404 |
play_item |
POST /Items/{id}/Play |
no such route; remote play is POST /Sessions/{id}/Playing?ItemIds=…&PlayCommand=PlayNow |
playback_url |
GET /Items/{id}/StreamUrl |
no such route; streams are /Videos/{id}/stream, /Audio/{id}/stream |
list_devices |
GET /Devices expecting a list |
returns {"Items": [...]} → tool always [] |
Fix:
get_itemuses/Items?Ids=…with detail fields.play_itempicks a remote-controllable session bysession_idor adevicename, or the only one there is, and errors with the list of candidates otherwise.list_sessionsexposesid,supports_remote_controlandnow_playing.playback_urlbuilds the static stream URL without the API key.list_devicesunwrapsItems.
EMB-04
IDs inserted unencoded into URL paths — High (security)
The server authenticates with the admin API key, and Javis approves Emby tools
automatically (approval: allow). The spec lists POST /Items/{Id}/Delete,
/System/Restart and /System/Shutdown. An item ID like 123/Delete? in play_item
became POST /Items/123/Delete, deleting the media item. ../System/Restart# restarted the
server. Such an ID could come from prompt injection, for example via web search results or
media metadata. Fix: every path parameter is percent-encoded as a single segment, and
get_item passes the ID as a query value. Verified: 123/Delete now yields a harmless
lookup error.
EMB-05
Failures returned as data — Medium
ping, playback_url, playback_info and play_item caught exceptions and returned
{"ok": false, "error": …}. MCP reports those as successful results. Fix: they raise,
so FastMCP returns isError: true. ping keeps its ok flag because it is a probe.
EMB-06
Field mapping — Low
RunTimeNumeric, SeasonNumber, EpisodeNumber and Year are not Emby fields, so
runtime, year and episode numbers were always empty. The instructions advertised
list_items(type='Movie'), but no such parameter existed. A None name crashed the pydantic
model. Fix:
- Runtime is computed from
RunTimeTicks. - Season and episode numbers come from
ParentIndexNumberandIndexNumber. ProductionYearis requested throughFields.- New
item_typeparameter. - Aliases removed.
Homey
HOM-01
Path injection through IDs — High (security)
homey_client interpolated device_id, flow_id, variable_id and capability_id into
URL paths. httpx normalises dot segments and drops #… fragments. Reproduced offline:
trigger_flow('../../system/reboot#') → POST /api/manager/system/reboot
set_variable('../../../system/reboot#', 1) → PUT /api/system/reboot
Fix: IDs are percent-encoded single path segments (_seg).
HOM-02
Bearer mode did not protect the SSE transport — High (security, LAN mode)
The auth middleware only checked /mcp and the root alias. With
HOMEY_MCP_TRANSPORT=sse and HOMEY_MCP_AUTH_MODE=bearer, both GET /sse and
POST /messages/ went through unauthenticated. Fix: in bearer mode every route
requires the token except GET / (discovery) and /openapi.json. The token is compared as
bytes, so a non-ASCII header no longer raises inside compare_digest.
HOM-03
No DNS-rebinding protection — Medium (security, LAN mode)
FastMCP's Host/Origin validation was switched off (enable_dns_rebinding_protection=False),
yet README and UBUNTU_SETUP promise a Host check (421) via
HOMEY_MCP_ADVERTISED_HOST. In the default AUTH_MODE=none, any web page opened in any
browser on the LAN could rebind its domain to the server and control the smart home over
MCP. Without a token the REST API was open the same way. Fix: a Host-header allow-list
covers MCP and REST and answers 421 Misdirected Request. It is built from the
advertised host, localhost, 127.0.0.1 and HOMEY_MCP_ALLOWED_HOSTS (* turns it off).
If the bind address is a wildcard and nothing is configured, the server logs a warning.
HOM-04
Realtime events never arrived — Medium
The client connected with auth={"token"} and listened for device.update on the root
namespace. Athom's homey-api (SocketSession.js, SubscriptionRegistry.js) shows the real
protocol:
handshakeClient {token, homeyId}returns a namespace (/api).- Connect to that namespace.
subscribeto URIs such ashomey:manager:devicesandhomey:device:<id>.- Events arrive named by their URI, with
(event, data)as payload.
Verified live (read-only): the old code received 0 events. Fix: the client implements the
handshake and subscriptions, rebuilds them on reconnect, and buffers compact entries. The
fixed client captured 18 capability events in 25 s.
HOM-05
Zone resolution by first substring hit — Medium
control_zone, get_zone_status and the REST zones/control took the first zone whose
name contained the query. That depends on dict order: "Wohnzimmer" could switch
"Wohnzimmer Ecke". control_zone also matched devices of other zones by name substring
when the zone was embedded as an object. Fix: a shared _resolve_zone prefers an exact
match, then a prefix, then a substring (shortest name wins). Devices are matched by zone ID
only.
HOM-06
find_devices too broad — Medium
The query was split into words and a device matched if any word hit its name or zone. "esszimmer licht" therefore returned every light in the house plus every Esszimmer device, and the model then acts on one of them. Fix: all words must match. Only if nothing does are partial matches returned.
HOM-07
systemd unit — Low
homey-mcp.service uses User=%i, but setup-ubuntu.sh copies it as homey-mcp.service
and enables homey-mcp@$USER.service, which fails because the template doesn't exist. In
addition, %h resolves to /root for system units: specifiers ignore User=. Fix: the
unit is documented and installed as the template homey-mcp@.service and uses
/home/%i/….
HOM-08
Error handling — Low
The following returned errors as data instead of raising:
control_all's safety refusal ({"error": …})get_temperatures([{"error": …}])send_notification({"ok": false})
In addition, REST handlers returned 500 on invalid JSON. Fix: the tools raise, and REST returns 400.
DuckDuckGo
DDG-01
Blocking calls on the event loop — Medium
The tools were plain def functions. FastMCP calls those directly on its event loop, and a
search took 0.5–2 s (measured). During that time the server could not answer anything else,
including cancellations. Fix: the tools are async and run ddgs in
asyncio.to_thread.
DDG-02
Failures look like results — Medium
Rate limits and network errors came back as "Search failed: …" text. The Javis host
caches non-ERROR: results of cacheable tools, so the failure was replayed for later
identical queries. Fix: failures raise ToolError (isError: true).
DDG-03
Input validation — Low
An empty query raised DDGSException: query is mandatory from deep inside the library, and
safesearch accepted any value. Fix: both are validated up front.
Spotify
SPO-01
No isError — Medium
Every handler caught exceptions and returned "Error …" text as a normal result. For Javis,
"Error starting playback: 404 No active device" was a success. Fix: a single wrapper in
index.ts marks results whose text starts with Error and turns thrown errors into
isError: true. adjustVolume's "No active device" messages now follow the same
convention. verify_tools.mjs counts isError as a failure.
SPO-02
Track URIs played as context — Medium
playMusic checked only type === 'track'. With just uri: "spotify:track:…", which LLMs
often send, the track went into context_uri and Spotify rejected it. Fix: the kind is
taken from the URI; tracks and episodes go to uris.
SPO-03
Token refresh — Medium
Spotify's refresh response may contain a new refresh_token
(docs),
and the code discarded it. Refresh happened only after expiry, so a token could expire
mid-request. Concurrent tool calls each refreshed and wrote the file. An expired 6-month
refresh token (invalid_grant) produced an opaque error. Fix:
- Single-flight refresh, 60 s before expiry.
- A rotated refresh token is saved.
invalid_grantproduces the message "refresh token expired or revoked — runnpm run auth".
SPO-04 – SPO-07
- SPO-04
spotify-config.json(client secret and refresh token) is now written via temp-file + rename with mode0600. - SPO-05 Playlist and user IDs are
encodeURIComponent-ed in raw API paths. Verified:../mestays insideplaylists/…. - SPO-06
adjustVolumereads the volume of the device it is about to change. - SPO-07 Display fixes:
formatDuration(179600)used to print2:60.- Playlist search lines printed the description as a track count.
- Saved-track numbering ignored the offset for removed items.
Cross-cutting
X-01
Tool annotations — Low All tools now carry MCP annotations:
readOnlyHintfor lookups.destructiveHintfor:- Homey's
control_zone,control_allandtrigger_flow. - Spotify's removals, playlist edits and reordering.
- Homey's
openWorldHintfor web and Spotify calls.
The Javis host now asks before any tool marked destructiveHint (and not readOnlyHint),
even on servers with approval: allow. In practice this covers the satellite power tools.
Turn it off with mcp.confirm_destructive: false.
X-02
Dependency pins — Low
mcp[cli]>=1.2 would install mcp 2.x, which no longer ships mcp.server.fastmcp. All three
servers now pin mcp[cli]>=1.13,<2. ddgs>=9.0 is pinned for the renamed package.
X-03
Docs — Info
The READMEs listed 8 of 21 Homey tools, claimed DuckDuckGo "instant answers", gave Emby's
default scheme as https and referenced d:\Javis paths. All four READMEs are updated.
X-04
Least privilege — Info
- The Emby admin API key can delete media and restart or shut down the server.
- The Homey personal access token usually carries all scopes.
- Spotify requests
user-read-email, which no tool uses.
📌 See §6.
X-05
Biome formatting — Info
npx biome check reports 8 format errors caused by CRLF line endings. These existed before
the audit. biome lint and tsc are clean.
4. Reviewed and rejected
| Claim | Why rejected |
|---|---|
Spotify handleSpotifyRequest swallows real errors ("Unexpected token") |
The SDK validates the HTTP status before parsing; only successful non-JSON bodies (player endpoints) reach the parser. |
Homey ping should raise |
It is a connectivity probe; ok: false with the reason is the intended result. |
5. Verification
| Check | Result |
|---|---|
homey-mcp/.venv → pytest tests/test_homey_mcp.py |
9 passed |
emby-mcp/.venv → pytest tests/test_emby_mcp.py |
8 passed |
duckduckgo-mcp/.venv → pytest tests/test_duckduckgo_mcp.py |
4 passed |
ruff check …/src + new tests (F,E9,B) |
clean |
Spotify tsc --noEmit, biome lint, npm run build |
clean |
| Live, read-only: Emby | ping, search, get_item, playback_url (no key in URL), users, sessions, devices all OK; crafted ID 123/Delete → harmless lookup error |
| Live, read-only: Homey realtime | handshake + subscriptions, 18 events in 25 s (before: 0) |
| Live, read-only: Spotify over stdio | 28 tools (12 read-only, 5 destructive); search OK; bad and crafted playlist IDs → isError: true, path stays encoded |
The new tests:
- Homey:
- Encoded IDs.
- Zone ranking.
- All-words search.
control_zonescoped by ID.control_allrefusal.- Compact events.
- Bearer protection of
/sseand/messages/and the root alias. - Host guard (421).
- Annotations.
- Emby:
- Base-URL forms.
- Encoded IDs.
- Server-side search parameters.
get_itemvia/Items?Ids.- Session-targeted play.
- Device unwrapping.
- Key-free stream URL.
- Annotations.
- DuckDuckGo:
- Search runs in a worker thread.
- Rate limits become
ToolError. - Input validation.
- Annotations.
6. Improvements and best practices
✅ = applied, 📌 = recommendation.
| Source | Guidance | Status |
|---|---|---|
| MCP spec — Tools | Tool execution failures belong in the result with isError: true. |
✅ All four servers (EMB-05, HOM-08, DDG-02, SPO-01) |
| MCP spec — Tools | Annotations describe behaviour; a human should be able to deny invocations. | ✅ Annotations everywhere. ✅ The Javis host asks before destructiveHint tools even on approval: allow servers (mcp.confirm_destructive, default on). |
| MCP spec — Transports, security warning | Servers MUST validate Origin/Host against DNS rebinding, SHOULD bind to localhost when local, SHOULD authenticate all connections. |
✅ Host guard + auth on every MCP route (HOM-02/03). 📌 Prefer HOMEY_MCP_AUTH_MODE=bearer wherever the client can send a header. |
| MCP — Security best practices | Local servers: use stdio or require a token on HTTP. Scope minimisation: least-privilege tokens. | ✅ Javis uses stdio for all four. 📌 Homey: create a PAT with only the scopes the tools need (devices, zones, flows, logic, notifications). Emby: consider a restricted user's token instead of the admin API key, or keep Emby on approval: ask. Spotify: drop the unused user-read-email scope at the next npm run auth. |
| OWASP Injection / path traversal guidance | Never build URLs by concatenating untrusted input; encode path parameters. | ✅ Homey, Emby, Spotify (HOM-01, EMB-04, SPO-05) |
| Spotify — Refreshing tokens | Store a rotated refresh token; invalid_grant means re-authorize; refresh tokens last 6 months. |
✅ SPO-03 |
Athom homey-api (SocketSession.js, SubscriptionRegistry.js) |
Handshake → namespace → URI subscriptions; re-subscribe after reconnect. | ✅ HOM-04 |
Emby server OpenAPI (/openapi.json) |
Use documented routes (/Sessions/{Id}/Playing, /Items?Ids=, SearchTerm). |
✅ EMB-02/03 |
| Python asyncio / FastMCP | Never block the event loop; offload sync I/O to threads. | ✅ DDG-01 |
| OWASP LLM prompt-injection guidance | Treat tool output from the web as untrusted data. | ✅ DuckDuckGo server instructions say so. 📌 Same idea for media and device names returned by Emby and Homey. |
| systemd.exec(5) | Specifiers like %h describe the service manager's user, not User=. |
✅ HOM-07 |
Observations:
mcp2.x removed FastMCP, so these servers stay on 1.x until they are ported to the newMCPServerAPI.- Still open:
- Least-privilege credentials (X-04).
- CRLF line endings in the Spotify sources (X-05).