MCP / IPC Security Model
This page documents the security design of the localhost IPC server that backs thetranscribe MCP tool and the echophrase transcribe CLI command - the
part of Echophrase that lets an AI agent talk to the desktop app. It is
written for readers who want to evaluate the design themselves, not take our
word for it. Every claim below is checked against the current source; file
and line references are included so you can verify them too.
This describes the transport between the CLI/MCP process and the running
desktop app. The other four MCP tools (
record, stop, status,
transcode) don’t touch this server at all - they run entirely inside the
CLI process against local files. See MCP Server for the tool overview.Bound to localhost, never the network
The IPC server binds127.0.0.1 explicitly and only:
src-tauri/src/ipc/mod.rs:124-126. There is no code path that binds
0.0.0.0 or any other interface - the module’s own doc comment states the
same constraint (src-tauri/src/ipc/server.rs:3: “Binds 127.0.0.1 only
(never 0.0.0.0)”).
What that means concretely: the socket is only reachable from processes
running on the same machine, under the same OS user session (loopback
interfaces are not exposed to other hosts on your LAN or the internet, and
typical OS-level socket permissions mean another OS user account on a shared
machine cannot connect to it either). Nothing outside “software already
running as you, on your machine” can reach port 1424. This is a meaningfully
smaller attack surface than a server bound to a routable interface: there is
no port-forwarding misconfiguration, no router UPnP hole, no cloud metadata
endpoint that could accidentally expose it.
Bearer token: fresh per launch, gone when stopped
EveryPOST /v1/transcribe request must carry
Authorization: Bearer <token>. The check is constant-time, not a plain ==,
specifically to avoid a timing side channel on the comparison
(src-tauri/src/ipc/server.rs:100-135, constant_time_eq). The unauthenticated-first
GET /v1/health route only reports the app version - never anything about
the token or user - so a probe against the port learns nothing beyond “the
app is running” (src-tauri/src/ipc/server.rs:15-16, 65-71).
Token lifecycle:
- Generated fresh on every server start.
start()callswrite_fresh_token(), which draws 32 random bytes fromrand::thread_rng()and hex-encodes them, on every single call tostart()- not once at app install, not reused across restarts (src-tauri/src/ipc/mod.rs:107-133, 176-194). - Written with owner-only file permissions on Unix (
0o600) immediately after generation (src-tauri/src/ipc/mod.rs:187-191). (Windows and macOS currently rely on the per-user profile directory’s default ACL rather than an explicit chmod-equivalent call in this code path - see Limitations below.) - Lives at the same per-user data directory the CLI reads from -
<platform data dir>/echophrase-cli/run/ipc-token(src-tauri/src/ipc/paths.rs:76-105). The desktop app and the CLI resolve this path independently but identically, by design, so neither depends on the other’s crate. - Removed when the server stops, not just left to rot:
stop()callsclear_token_best_effort()before returning (src-tauri/src/ipc/mod.rs:161-166, 200-207), and the same cleanup runs on force-exit. This is the detail worth underlining: a stopped server is unauthenticatable, not merely unreachable. Even if something cached a valid token from a previous session, it stops working the moment the app (or just the toggle) is turned off, because the token file backing it is gone and the nextstart()mints an unrelated one. - The CLI’s own read path treats “no token file” and “can’t connect” as the
same case and reports one friendly message rather than a raw connection
error (
cli/src/ops.rs:316-322).
Explicit opt-in, enforced server-side
The listener does not start just because the app launches.spawn() gates
startup on two conditions, checked in this order, and only proceeds if both
hold (src-tauri/src/ipc/mod.rs:67-88):
- The session’s tier is Premium (
is_premium_tier()), and - The MCP Server setting in Settings is explicitly enabled.
false (src-tauri/src/settings/mod.rs:575, // Off by default (Pro-gated)), and the Pro check fails closed: if there is no
app handle or no session yet, is_premium_tier() returns false
(src-tauri/src/ipc/mod.rs:95-101) - “erring on the side of NOT exposing the
local server,” in the code’s own words.
Two details matter for “is this a real gate or just a UI toggle”:
- The gate is enforced in Rust, not the frontend.
set_mcp_server_enabled(the Tauri command backing the Settings toggle) re-checks Pro tier server-side before flipping the setting on, independent of what the UI believes (src-tauri/src/settings/mod.rs:840-857). - A bypass attempt via hand-editing the settings file doesn’t work either:
the generic settings-patch path (used for YAML import/reconciliation)
re-checks Pro tier again before starting the listener, specifically to
close that route (
src-tauri/src/settings/mod.rs:802-814, comment: “a free-tier user importing/editing yaml to flip this on must not actually open the port”).
What the five tools can and cannot do
The MCP server (echophrase mcp) exposes exactly five tools, defined with
#[tool_router] over a fixed set of methods
(cli/src/commands/mcp.rs:93-156): record, stop, status, transcode,
transcribe. A sixth subcommand, worker, exists internally to run the
background recording process but is never registered as a tool
(cli/src/commands/mcp.rs:6-9, cli/README.md:150) - there is no tool an
agent can call that maps to it.
Verified boundaries of that surface:
- No arbitrary file read.
transcribeaccepts awav_pathstring, but the HTTP handler validates it before touching the transcription pipeline: it must resolve viacanonicalize()(which also collapses any symlink tricks), must exist, must be a file, and must have a.wavextension - or the request is rejected with400 Bad Requestbefore any file content is read by the model (src-tauri/src/ipc/server.rs:163-186,validate_wav_path). The response contains only the transcript text, never file contents, directory listings, or anything else about the filesystem. - No code execution and no shell-out. Neither the HTTP router
(
src-tauri/src/ipc/server.rs) nor the tool router (cli/src/commands/mcp.rs) contains a code path that runs a subprocess, evaluates a string as code, or passes user input to a shell. The only external-process interaction in the CLI is the recording worker it spawns itself forrecord/stop, which takes no untrusted input from the network. record/stop/status/transcodenever touch the network. They run entirely against local files and the localcpalaudio device throughcrate::ops(cli/src/ops.rs), independent of whether the desktop app or its IPC server is even running. Onlytranscribetalks to the desktop app at all, and it does so over the bearer-token-gated localhost connection described above.transcribe’s request body is a file path, not audio. The CLI reads the bearer token, then POSTs{"wav_path": "<local path>"}tohttp://127.0.0.1:1424/v1/transcribe(cli/src/ops.rs:315-329). The desktop process reads the WAV bytes off disk itself; audio content never serializes across the socket in either direction, only a path string in and a transcript string out.
Where the audio and the transcript go
Transcription runs inside the already-running desktop app process, using whichever backend it already loaded (Candle, ONNX/parakeet-rs) for local GPU/CPU inference - the same code path the desktop UI’s own dictation feature uses. There is no HTTP client in any of the transcription backend implementations (src-tauri/src/transcription/{mod,candle_whisper,onnx_backend,parakeet}.rs
contain no networking calls beyond doc-comment URLs), so nothing about the
audio, its path, or the resulting text is sent to any Echophrase server as
part of this flow. The only network traffic involved is the loopback HTTP
request between the CLI/MCP process and the desktop app on the same machine.
This matches the product’s broader design principle (unrelated to MCP
specifically): voice processing happens locally, and the server side of
Echophrase exists only for auth and subscription management, never audio.
Limitations, honestly
- The token file is only as protected as your OS account. Its permission
bits (
0600on Unix) stop other users on a shared machine from reading it, but any process running as you - a misbehaving browser extension, a compromised dependency in some other app, malware, another AI agent you’ve granted broad filesystem access to - can read the same file your terminal can. This is the same trust boundary as your OS user account, not a stronger one. If you don’t trust arbitrary local processes running as you, you shouldn’t trust this token file to be secret from them either - and that’s true of basically every local dev tool with a bearer-token-over- loopback design, not something unique to Echophrase. - We did not verify Windows/macOS file ACLs. The
0o600permission call is#[cfg(unix)]-gated (src-tauri/src/ipc/mod.rs:187-191); on Windows and macOS the token file inherits whatever default permissions your OS applies to files in your per-user data directory, which we have not independently audited here. - A stale
"running"state file can outlive a hard crash. The state file used to report why the server is down (not_pro/disabled/running) is only cleared on graceful shutdown; a hard crash can leave it saying"running"when the process is actually gone. This has no security consequence - the token file is what gates requests, and a dead process can’t answer them regardless of what the state file says - but it’s worth naming since we said we’d be upfront about the gaps rather than only the guarantees (src-tauri/src/ipc/mod.rs:252-265). - This page describes the code we read on 2026-07-30. The IPC module was under active development at the time of writing (a state-file addition for friendlier CLI error messages was in progress); the design-level claims here (localhost-only, token-per-start, removed-on-stop, Pro-gated, fail-closed) are the parts we expect to remain stable, but treat specific line numbers as a snapshot, not a permanent contract.
MCP Server
Back to the tool overview and setup instructions.