Files
javis/docs/AUDIT_MCP.md
T
2026-10-04 15:31:44 +02:00

22 KiB
Raw Blame History

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_item uses /Items?Ids=… with detail fields.
  • play_item picks a remote-controllable session by session_id or a device name, or the only one there is, and errors with the list of candidates otherwise.
  • list_sessions exposes id, supports_remote_control and now_playing.
  • playback_url builds the static stream URL without the API key.
  • list_devices unwraps Items.

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 ParentIndexNumber and IndexNumber.
  • ProductionYear is requested through Fields.
  • New item_type parameter.
  • 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:

  1. handshakeClient {token, homeyId} returns a namespace (/api).
  2. Connect to that namespace.
  3. subscribe to URIs such as homey:manager:devices and homey:device:<id>.
  4. 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_grant produces the message "refresh token expired or revoked — run npm run auth".

SPO-04 – SPO-07

  • SPO-04 spotify-config.json (client secret and refresh token) is now written via temp-file + rename with mode 0600.
  • SPO-05 Playlist and user IDs are encodeURIComponent-ed in raw API paths. Verified: ../me stays inside playlists/….
  • SPO-06 adjustVolume reads the volume of the device it is about to change.
  • SPO-07 Display fixes:
    • formatDuration(179600) used to print 2: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:

  • readOnlyHint for lookups.
  • destructiveHint for:
    • Homey's control_zone, control_all and trigger_flow.
    • Spotify's removals, playlist edits and reordering.
  • openWorldHint for 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_zone scoped by ID.
    • control_all refusal.
    • Compact events.
    • Bearer protection of /sse and /messages/ and the root alias.
    • Host guard (421).
    • Annotations.
  • Emby:
    • Base-URL forms.
    • Encoded IDs.
    • Server-side search parameters.
    • get_item via /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:

  • mcp 2.x removed FastMCP, so these servers stay on 1.x until they are ported to the new MCPServer API.
  • Still open:
    • Least-privilege credentials (X-04).
    • CRLF line endings in the Spotify sources (X-05).