You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Security: Require a bearer token on the MCP HTTP server
The MCP server binds 127.0.0.1 but accepted requests with no Origin
header, so any local process that read the world-readable
`<data_dir>/mcp.port` could POST `delete`/`move`/`copy` with
`autoConfirm:true` and bypass the user's confirmation dialog. Origin
validation is browser-CSRF defense only — a non-browser process sets any
header it likes — so it was no barrier to a local attacker. macOS
doesn't isolate loopback between processes.
- Generate a fresh per-instance bearer token (122-bit getrandom UUID) at
server start; require it on every `/mcp` request via `validate_token`,
using a constant-time comparison. `GET /mcp/health` stays open. Fails
closed when no token is set. `validate_origin` stays as a secondary
browser layer.
- Write the token to `<data_dir>/mcp.token` at 0o600, and harden the
`mcp.port` (and the wrapper-written `tauri-mcp.port`) to 0o600 too —
they were 0o644.
- `autoConfirm` on destructive tools is now transitively gated by the
server token (the caller had to read the 0o600 token to connect).
- Expose `get_mcp_token` IPC; the bundled `scripts/mcp-call.sh` and the
E2E `mcp-client.ts` now send `Authorization: Bearer <token>`.
The app's own frontend talks to the separate Tauri MCP bridge, not this
HTTP server, so no frontend change. External MCP clients must now read
`mcp.token` and present it (intended contract change).
TDD: token-validation and 0o600-permission tests written red-first.
Copy file name to clipboardExpand all lines: apps/desktop/src-tauri/src/mcp/CLAUDE.md
+9-4Lines changed: 9 additions & 4 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -10,7 +10,8 @@ Expose Cmdr functionality to AI agents via the Model Context Protocol (MCP). Age
10
10
11
11
- Runs in a background tokio task spawned at app startup
12
12
- Binds `127.0.0.1` only (localhost). Port defaults to ephemeral: when `developer.mcpPort` (the user setting) is 0 (the new default), the server binds `127.0.0.1:0` and asks the kernel for an unused port. When the setting (or `CMDR_MCP_PORT` env) is non-zero, the server binds that port and probes upward up to 100 ports on collision. The `resolve_bind_strategy` helper turns the resolved port into `BindStrategy::Ephemeral` or `BindStrategy::Pinned(port)` and is unit-tested in `server.rs`. The legacy fixed defaults (`19224` prod / `19225` dev) still live in `config.rs::DEFAULT_PORT` as the fallback for `CMDR_MCP_PORT` parse failures and are mirrored in the FE registry for users who want to pin a port.
13
-
- Writes the actual bound port to `<data_dir>/mcp.port` atomically (tempfile + fsync + rename) after `bind`. External readers (the `scripts/mcp-call.sh` CLI, E2E fixtures, agent helpers) discover the port from that file; the FE still uses the `get_mcp_port` IPC to read the same in-process atomic. On clean shutdown the file is removed; on crash it stays and readers discover staleness via `ECONNREFUSED` on the contained port. See `port_file.rs` for the read/write/remove API and typed `PortDiscoveryError`.
13
+
- Writes the actual bound port to `<data_dir>/mcp.port` atomically (tempfile + fsync + rename, mode 0o600) after `bind`, plus a fresh per-instance bearer token to `<data_dir>/mcp.token` (same atomic write, 0o600). External readers (the `scripts/mcp-call.sh` CLI, E2E fixtures, agent helpers) discover the port and token from those files and send `Authorization: Bearer <token>` on every request; the FE still uses the `get_mcp_port` / `get_mcp_token` IPC to read the same in-process state. On clean shutdown both files are removed and the in-process token is cleared; on crash they stay and readers discover staleness via `ECONNREFUSED`. See `port_file.rs` for the `write_port_file` / `write_secret_file` / read / remove API and typed `PortDiscoveryError`.
14
+
-**Auth**: every `/mcp` request (POST + GET) is gated by `validate_origin` (browser layer) then `validate_token` (the real local-process gate). `validate_token` reads `Authorization: Bearer <token>`, compares against the in-process token in constant time, and rejects missing/empty/mismatched with 401. It fails closed if no token is set. `GET /mcp/health` is intentionally unauthenticated.
@@ -107,9 +108,13 @@ Without state, resources would need to query the frontend on every read (slow, a
107
108
108
109
Security via parity: agents can only do what users can do. Giving agents `fs.read`/`fs.write` would violate this. Agents navigate the UI just like users, using `move_cursor`, `open_under_cursor`, etc.
109
110
110
-
### Why localhost only?
111
+
### Why localhost only, and why a bearer token on top?
111
112
112
-
Binding to `0.0.0.0` would expose the server to the network. An attacker could quit the app, change settings, or navigate to sensitive directories. Localhost binding ensures only local processes can connect.
113
+
Binding to `0.0.0.0` would expose the server to the network, so we bind `127.0.0.1` only. But localhost binding alone is **not** a security boundary against the real threat: a local non-Cmdr process. macOS doesn't isolate loopback between local processes, and a non-browser process can set any HTTP header (or none), so `validate_origin` (a browser-CSRF / DNS-rebinding defense) is no barrier to it. Without more, any local process that read the (formerly world-readable) `mcp.port` could POST a destructive `delete`/`move`/`copy` with `autoConfirm: true` and bypass the user's confirmation dialog.
114
+
115
+
The real gate is a **per-instance bearer token**: every `/mcp` request (POST and GET) must carry `Authorization: Bearer <token>`, validated in constant time against the in-process token (`validate_token` in `server.rs`). The token is a fresh CSPRNG value (`Uuid::new_v4`, 122 random bits) generated on every server start and written to `<data_dir>/mcp.token` at mode 0o600 (owner-only). Reading it therefore requires the user's own filesystem access — the same access an attacker would need to do damage directly. The port file (`mcp.port`) is also written 0o600 now. `GET /mcp/health` stays open (no token) so liveness probes work. With the whole server token-gated, `autoConfirm` is transitively protected: the caller already proved filesystem access by reading the token.
116
+
117
+
Legit clients send the token: `scripts/mcp-call.sh` reads `<data_dir>/mcp.token` (or `CMDR_MCP_TOKEN`); the E2E harness fetches it via the `get_mcp_token` Tauri IPC. The app's own frontend does NOT talk to this HTTP server (it uses the separate Tauri MCP bridge), so it needs no token.
113
118
114
119
### Why separate state stores?
115
120
@@ -119,7 +124,7 @@ Binding to `0.0.0.0` would expose the server to the network. An attacker could q
119
124
120
125
### Server lifecycle is managed at runtime
121
126
122
-
`start_mcp_server()` binds the port and spawns a tokio task, storing the `JoinHandle` in a static `MCP_HANDLE`. Port-binding strategy comes from `resolve_bind_strategy(port)`: a 0 setting (the new default) means `BindStrategy::Ephemeral`, which binds `127.0.0.1:0` and reads the assigned port via `local_addr()`. A non-zero setting means `BindStrategy::Pinned(port)`, which uses `bind_with_probe()` to try tokio `TcpListener::bind` directly and retry up to 100 ports on collision (avoids the TOCTOU race of checking with a sync listener then re-binding async). The actual bound port is stored in `MCP_ACTUAL_PORT`. After `bind`, `write_port_file` lands `<data_dir>/mcp.port` atomically (tempfile + fsync + rename, see `port_file.rs`) so external readers can discover the port without IPC; the data dir is cached in `MCP_PORT_FILE_DIR` for the shutdown path. The frontend queries the in-process atomic via `get_mcp_port()` and shows "(ephemeral)" when the setting was 0 or "(port N was in use)" when the pinned port differs from the bound one. The server can be started/stopped live via `set_mcp_enabled` and `set_mcp_port` Tauri commands, no app restart needed. `stop_mcp_server()` aborts the task, resets `MCP_ACTUAL_PORT` to 0, and removes `<data_dir>/mcp.port` (best-effort: stale file is not a correctness bug). `is_mcp_running()` checks whether the handle exists. At startup, `start_mcp_server_background()` wraps the async start in a fire-and-forget spawn. If the server crashes mid-serve, `MCP_ACTUAL_PORT` resets to 0 but the on-disk file may linger; external readers retry on `ECONNREFUSED`. Check logs for "MCP server crashed" errors.
127
+
`start_mcp_server()` binds the port and spawns a tokio task, storing the `JoinHandle` in a static `MCP_HANDLE`. Port-binding strategy comes from `resolve_bind_strategy(port)`: a 0 setting (the new default) means `BindStrategy::Ephemeral`, which binds `127.0.0.1:0` and reads the assigned port via `local_addr()`. A non-zero setting means `BindStrategy::Pinned(port)`, which uses `bind_with_probe()` to try tokio `TcpListener::bind` directly and retry up to 100 ports on collision (avoids the TOCTOU race of checking with a sync listener then re-binding async). The actual bound port is stored in `MCP_ACTUAL_PORT`. After `bind`, `write_port_file` lands `<data_dir>/mcp.port` (0o600) atomically (tempfile + fsync + rename, see `port_file.rs`) so external readers can discover the port without IPC; the data dir is cached in `MCP_PORT_FILE_DIR` for the shutdown path. A fresh CSPRNG bearer token is also generated and stored in the `MCP_TOKEN` static and written to `<data_dir>/mcp.token` (0o600) via `write_secret_file`, regenerated on every start. `stop_mcp_server()` and the crash path both clear `MCP_TOKEN` to None (so `validate_token` fails closed) and remove the token file alongside the port file. The frontend queries the in-process atomic via `get_mcp_port()` and shows "(ephemeral)" when the setting was 0 or "(port N was in use)" when the pinned port differs from the bound one. The server can be started/stopped live via `set_mcp_enabled` and `set_mcp_port` Tauri commands, no app restart needed. `stop_mcp_server()` aborts the task, resets `MCP_ACTUAL_PORT` to 0, and removes `<data_dir>/mcp.port` (best-effort: stale file is not a correctness bug). `is_mcp_running()` checks whether the handle exists. At startup, `start_mcp_server_background()` wraps the async start in a fire-and-forget spawn. If the server crashes mid-serve, `MCP_ACTUAL_PORT` resets to 0 but the on-disk file may linger; external readers retry on `ECONNREFUSED`. Check logs for "MCP server crashed" errors.
123
128
124
129
### Live MCP control only works from the settings window
0 commit comments