An MCP server that finds the Google speakers
on your local network and speaks text out loud on them, in Vietnamese, built
on pychromecast and
edge-tts.
say("Cơm đã chín rồi", target="Kitchen speaker")
It also exposes the usual Cast media controls (play a URL, pause, volume, …).
- Python ≥ 3.11
uv(recommended) orpip- The machine running the server must be on the same local network as the Cast devices (discovery uses mDNS/zeroconf).
uv sync
# stdio transport (default — for Claude Desktop/Code and most MCP clients)
uv run googlecast-mcp
# streamable HTTP transport
uv run googlecast-mcp --transport http --host 0.0.0.0 --port 8000
# legacy SSE transport
uv run googlecast-mcp --transport sseOn a home server that stays on, run it as a systemd service over HTTP so clients on other machines can use it:
./scripts/service.sh install # write the unit, enable at boot, start it
./scripts/service.sh status # is it running?
./scripts/service.sh logs # follow the journal
./scripts/service.sh stop # / start / restart
./scripts/service.sh remove # stop, disable, delete the unitinstall needs sudo (it writes /etc/systemd/system/) and defaults to MCP
on port 8765 and audio on port 8766. Override with environment variables:
MCP_PORT=9000 MEDIA_PORT=9001 ./scripts/service.sh installThe service must run on a machine on the same LAN as the speakers: discovery uses mDNS, and the speakers fetch the audio back from it. Allow both ports through the firewall if one is active.
The install currently in use, serving Claude Desktop over https and a
browser-based client at http://192.168.1.99:8383:
MCP_EXTRA_ARGS="--allow-host google-cast.adrec.cloud --json-response --stateless --cors-origin http://192.168.1.99:8383" \
./scripts/service.sh installEach flag earns its place — drop one and a client breaks:
| Flag | Without it |
|---|---|
--allow-host <domain> |
Requests through the reverse proxy get 421 Misdirected Request. |
--json-response |
Replies are SSE-framed; clients that parse only JSON fail. |
--stateless |
Clients that drop Mcp-Session-Id fail after the first call. |
--cors-origin <origin> |
A browser client's preflight fails: "Failed to fetch". |
--cors-origin must match the browser's address bar exactly — scheme, host and
port, no path. Repeat the flag for more origins. A page served on port 80 or
443 sends no port in its origin (http://192.168.1.99, not …:80).
Always confirm the change took effect:
./scripts/service.sh status # check the "Running command line" linePoint the client at the service's LAN address:
http://<server-ip>:8765/mcp
Claude Desktop's Add custom connector field only accepts https URLs, so a
plain-HTTP server on the LAN cannot be pasted there. Bridge it with the
mcp-remote stdio proxy instead — add this to claude_desktop_config.json
(Settings → Developer → Edit Config) on the client machine:
{
"mcpServers": {
"googlecast": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://<server-ip>:8765/mcp", "--allow-http"]
}
}
}This needs Node.js on the client machine. Restart Claude Desktop afterwards.
By default the server answers even a single request with an SSE-framed body
(event: message) and hands out an Mcp-Session-Id that clients must echo
back. Both are legal, but simpler MCP clients — llama-server's, for one —
choke on them and report a bare "protocol error". Two flags relax it:
googlecast-mcp --transport http --json-response --stateless--json-response replies with application/json; --stateless drops the
session requirement, so a lone tools/call works with no handshake. Standard
clients (Claude Desktop, Claude Code) work in this mode too, so it is a safe
default when several kinds of client share one server:
MCP_EXTRA_ARGS="--json-response --stateless" ./scripts/service.sh installTo use the custom-connector field instead, front the server with nginx and a
real certificate. A working vhost is in
scripts/nginx-googlecast-mcp.conf.
Three things are easy to get wrong:
-
Proxy only the MCP port. The audio port stays LAN-internal — the speakers fetch from it directly by IP.
-
Turn off buffering (
proxy_buffering off) and raise the timeouts. Streamable HTTP holds the response open and pushes events; nginx would otherwise buffer them and the client just hangs. -
Trust the domain, or the server answers
421 Misdirected Request:MCP_EXTRA_ARGS="--allow-host cast.example.com" ./scripts/service.sh install
The hostname must not contain an underscore — public CAs refuse to issue
certificates for such names, so no https URL is possible for one.
The endpoint has no authentication. Anyone who can reach
/mcpcan play audio in the house. On a publicly resolvable domain, restrict it in nginx (allow 192.168.0.0/16; deny all;) unless remote access is genuinely wanted.
Add to your MCP client config (e.g. Claude Desktop claude_desktop_config.json):
{
"mcpServers": {
"googlecast": {
"command": "uv",
"args": ["run", "--directory", "/storage/apps/mcp/googlecast_mcp", "googlecast-mcp"]
}
}
}| Tool | Purpose |
|---|---|
say(text, target?, voice="female", rate="+0%") |
Speak text out loud. Omit target to be asked which speaker. |
discover_devices(timeout=5.0) |
Scan the network for Cast devices and save them. |
list_speakers() |
Saved speakers and speaker groups only (no TVs); scans if empty. |
list_devices() |
All known devices, live + saved. |
get_status(target) |
Current app + media status of a device. |
play_media(target, url, content_type?, title?) |
Cast a media URL and start playback. |
play(target) / pause(target) / stop(target) |
Playback control. |
seek(target, position_seconds) |
Seek to an absolute position. |
set_volume(target, level) |
Set volume (0.0–1.0, clamped). |
set_muted(target, muted) |
Mute / unmute. |
quit_app(target) |
Stop the running app; return device to idle. |
target is a device friendly name (e.g. "Living Room TV") or its uuid,
as returned by discover_devices.
targetaccepts one speaker, several separated by commas, or"all"/"tất cả"for every speaker."all"covers the individual speakers only — a speaker group plays through its members, so including it as well would send two streams to the same device. Name a group directly to use one.- Omitting
targetnever plays anything. The tool returns the speaker list and asks the client to have the user pick one. voiceis"female"(vi-VN-HoaiMyNeural, default),"male"(vi-VN-NamMinhNeural), or any full edge-tts voice id.- Synthesis needs internet access (Microsoft Edge neural voices, no API key). Rendered mp3s are cached, so repeating a phrase is instant.
- The speaker fetches the audio from a small HTTP server this process starts on
your LAN address. The port is random by default; pin it with
--media-port(orGOOGLECAST_MCP_MEDIA_PORT) so a firewall rule can be written once.
play_mediaURLs must be publicly reachable by the Cast device — the device fetches the media itself, not this server.content_typeis guessed from the URL extension when omitted; pass it explicitly for streams without a recognizable extension (e.g. HLS behind a query string).
| Symptom | Cause and fix |
|---|---|
Not Acceptable: Client must accept text/event-stream |
You opened /mcp in a browser. Expected — the endpoint is not browsable, and this reply means the server is healthy. |
421 Misdirected Request |
The Host header is not trusted. Add the name with --allow-host. |
Execution of wait timed out after 10 s for one device |
That device is not accepting cast connections. Check with nc -z <ip> 8009; a Nest Hub in this state comes back after a reboot. |
| Client connects but calls hang | A proxy is buffering the stream. Set proxy_buffering off. |
| A client reports a generic "protocol error" | It likely cannot parse SSE framing or does not carry the session header. Start the server with --json-response --stateless. |
say returns needs_speaker_selection |
Working as intended: no target was given, so nothing was played. |
| No speaker found | The server is not on the speakers' LAN, or mDNS is blocked between VLANs. |
| Speaker accepts the cast but stays silent | It cannot reach the audio port. Check the firewall, and that the advertised URL uses a LAN address. |
| Screen flashes, audio cuts short | Poll get_status while it plays. player_state=PLAYING for the full duration ending in idle_reason=FINISHED means the cast side is fine — suspect the device (volume, or an unstable state after a reboot). |
See docs/architecture.md for the request flow, the
design decisions behind each module, and the deployment notes.
cast_manager.py— thread-safe wrapper overpychromecast; owns discovery, the connected-device cache, and synchronous control methods.speaker_store.py— persists discovered devices to~/.googlecast-mcp/speakers.json(override withGOOGLECAST_MCP_STORE), so a fresh process still knows the speakers.tts.py— Vietnamese text → mp3 via edge-tts, cached on disk (override the cache location withGOOGLECAST_MCP_CACHE).media_server.py— background HTTP server on the LAN address; Cast devices cannot read local file paths, so the audio must be served to them.server.py— FastMCP tool definitions; offloads each blocking call to a worker thread via 56AAasyncio.to_thread.__main__.py— CLI entrypoint; selects transport, widens the SDK's trusted hosts to the LAN, and handles clean shutdown.scripts/service.sh— systemd install/remove/start/stop/restart/status/logs.
_dong-goi/method-note.md records the reasoning
behind the design and the dead ends hit along the way — most of it applies to
any MCP server that has to serve several kinds of client, not just this one.
_dong-goi/reproduction-prompt.md is
enough to rebuild an equivalent server from scratch.
MIT