MCP Servers

Utsuwa can connect to external MCP (Model Context Protocol) servers and use their tools during chat: read a sensor value from Home Assistant, search the web, look up a repository. The model decides when a tool helps and calls it.

MCP is plain JSON-RPC 2.0, so Utsuwa ships a small built-in client with no SDK and no extra services.

How it runs

BuildWhere tools executeTransports
Web (self-hosted, MCP_ENABLED=server)Server routes /api/mcp/*, server to server, no CORSHTTP + stdio
Web (hosted deployment)Disabled unless the operator opts inNone
Desktop (Tauri)Directly in the app via the Tauri HTTP plugin (CORS-free)HTTP only

Browser-side calls to MCP servers are blocked by CORS, mixed-content and Private Network Access rules, which is why the web build proxies through the server and the desktop build uses the Tauri HTTP plugin. stdio servers (local processes) only run in the server build. See Desktop.

Enabling MCP (web build)

MCP is off by default. Set the environment variable on the Utsuwa server:

MCP_ENABLED=server
  • server: the server-side MCP proxy is on (HTTP and stdio).
  • both: currently the same as server.
  • Unset, off, or any other value (including client): the MCP routes return 404, so a hosted deployment stays untouched by default.

Works the same for every deployment style: Docker Compose (environment: / an override file), a native install (.env next to the build, systemd Environment=MCP_ENABLED=server), or your shell. .env.example lists the variable as a commented default.

When MCP is disabled, the MCP entry is hidden from the settings navigation on web (the page probes the server once and disappears if the route answers 404). A direct link still shows a notice explaining that the administrator has not enabled MCP.

Tool confirmation, prompt hardening, and the stdio allowlist are documented under Hardening.

The MCP settings page

Open Settings > MCP.

Servers: one card per configured server, with an on/off switch, a transport badge, and edit and remove buttons. An auth badge marks HTTP servers with a bearer token.

  • The switch enables or disables a single server. Disabled servers are never contacted and their tools are removed from the chat. Turning every server off disables MCP for chat without deleting the configuration.
  • Add Server opens the form: Name, Transport (HTTP or stdio), then either URL + Auth (none or bearer + Token) for HTTP, or Command, Arguments and Env Vars for stdio.
  • Env Vars takes one KEY=value per line; blank lines and # comments are ignored.
  • Inject text tool results as user messages repeats each result as a user-side note. It helps local or SLIM models that ignore the strict tool role.

Available Tools: press Refresh to list the tools of all enabled servers. Each entry shows the tool name, its server, and the description the model sees. Per-server failures (bad token, wrong URL, spawn error) are shown right here instead of silently producing an empty list.

Tokens are stored locally (like other API keys) and are only sent to the server you configured. They never go to the LLM and are never logged.

Examples

Home Assistant (HTTP + bearer)

Home Assistant ships an official MCP Server integration that exposes the Assist API. Enable it in HA under Settings > Devices & services > Add integration > Model Context Protocol Server (it exposes http://<your-ha>:8123/api/mcp).

  1. Create a long-lived access token in HA: Profile > Security > Long-lived access tokens.
  2. Add a server in Utsuwa:
    • Name: Home Assistant
    • Transport: HTTP
    • URL: http://homeassistant.local:8123/api/mcp
    • Auth: bearer, Token: your long-lived access token
  3. Press Refresh under Available Tools. You should see tools like GetLiveContext and HassTurnOn.
  4. Ask your companion “What’s the temperature in the living room?” She calls the tool and answers with the result.

Home Assistant’s router is strict about trailing slashes (/api/mcp works, /api/mcp/ returns 404). Utsuwa probes both URL variants automatically, so either form works.

Brave Search (stdio)

Brave publishes an official MCP server (@brave/brave-search-mcp-server) that needs a Brave Search API key. It runs as a local process on the Utsuwa server:

  • Name: Brave Search
  • Transport: stdio
  • Command: npx
  • Arguments: -y @brave/brave-search-mcp-server
  • Env Vars:
    BRAVE_API_KEY=your-brave-api-key

Requires Node.js 22+ on the Utsuwa host. The first request downloads the package via npx; if the cold start exceeds the 15-second stdio timeout, press Refresh again. The cache is warm afterwards.

stdio is fail-closed: set MCP_STDIO_ALLOWED_COMMANDS=npx -y @brave/brave-search-mcp-server and MCP_STDIO_ENV_ALLOWLIST=BRAVE_API_KEY in the Utsuwa server environment, otherwise this server shows a “stdio is disabled” error.

The URL https://api.search.brave.com/res/v1/web/search is Brave’s REST API, not an MCP endpoint. Pointing an HTTP MCP server at it fails with a 422 asking for x-subscription-token, because MCP clients speak JSON-RPC with a bearer token. Use the stdio server above instead.

GitHub (HTTP + bearer)

GitHub’s official remote MCP server is a good HTTP test for both web and desktop builds:

  • Name: GitHub
  • Transport: HTTP
  • URL: https://api.githubcopilot.com/mcp/
  • Auth: bearer, Token: a GitHub personal access token

Create a fine-grained PAT at GitHub > Settings > Developer settings > Personal access tokens > Fine-grained tokens and grant read-only permissions such as Contents: Read-only (plus Issues / Pull requests if you want them). Restrict Repository access to selected repositories or all of them. Classic tokens (repo scope) also work but grant broader access.

SearXNG (stdio)

Any MCP server that speaks stdio can be spawned by the server build. Example with a SearXNG MCP server via npx:

  • Name: SearXNG
  • Transport: stdio
  • Command: npx
  • Arguments: -y mcp-searxng
  • Env Vars:
    SEARXNG_URL=http://your-searxng-host:8080

stdio servers are spawned per request with a 15-second timeout and run with a minimal slice of the server’s environment plus the variables you configure here. Only enable MCP on a deployment you control, and allowlist the command line with MCP_STDIO_ALLOWED_COMMANDS=npx -y mcp-searxng and the variable with MCP_STDIO_ENV_ALLOWLIST=SEARXNG_URL (stdio is fail-closed).

Desktop builds (Tauri)

The desktop app is the MCP host itself: there is no backend to gate, so no MCP_ENABLED variable exists there. HTTP servers connect directly through the Tauri HTTP plugin (CORS-free) and work exactly as on web; stdio servers are not available (the app cannot spawn local MCP processes yet, so stdio entries show a per-server error in the tool list). The MCP page says so in a Desktop mode notice.

The Tauri HTTP permission is intentionally unrestricted (http://*:*, https://*:*, so any host on any port): MCP servers are configured at runtime, so a static allowlist cannot know them. The plugin only issues the requests the MCP client makes. Tool results go to the model and are never rendered as HTML in the UI. If you consider the desktop app’s network surface sensitive, keep only the servers you trust enabled.

The per-server switch is the desktop off switch: turn all servers off and the companion stops using MCP tools. A local server that only offers stdio (like Brave) can still be used on desktop by running it in HTTP mode yourself and pointing Utsuwa at it:

npx -y @brave/brave-search-mcp-server --transport http --port 8080
  • Transport: HTTP, URL: http://127.0.0.1:8080/mcp, Auth: none

Brave’s HTTP mode is unauthenticated and binds to loopback only. That is fine for a local process and not suitable for remote access.

Using tools in chat

Once at least one server is enabled and its tools are listed, Utsuwa sends them to the model with every reply. The model can call several tools before answering: up to 5 tool rounds per reply, with at most 8 tool calls per round. Results are fed back automatically and the final answer streams as usual (including voice). On the last round the model is told that the tool budget is spent and answers with the results it already has. HTTP requests time out after 15 seconds, including reading the response body. Expired HTTP sessions reconnect automatically. A single tool result is capped at 8000 characters.

If a local or SLIM model ignores tool results, enable Inject text tool results as user messages on that server.

Known limitations:

  • The Anthropic provider path does not receive MCP tool definitions; use an OpenAI-compatible provider for MCP.
  • Tools from all servers share one flat namespace. If two servers expose the same tool name, the first server in the list wins, so keep names unique.
  • Authentication supports none and bearer only. Servers that need custom headers (for example X-Api-Key) are not supported yet.

Hardening

  • Ask before running tools (per server, on by default): every tool call waits for your answer. See Confirming tool calls.
  • PUBLIC_MCP_PROMPT_HARDENING (on by default; false, 0, or off opts out): adds a security layer to the system prompt on turns with MCP tools: tool results are untrusted data (never instructions), and state-changing or destructive actions require an explicit user request. On web the PUBLIC_ variables are read at runtime; in the desktop build they are fixed at build time.
  • PUBLIC_MCP_CONFIRM_TOOLS: comma-separated, case-sensitive tool names (blank entries are ignored), for example unlock_door,set_alarm. Listed tools are never executed automatically: the chat loop feeds back a “requires manual user confirmation” result so the model asks you first, and the /api/mcp/call route rejects direct calls with 403. The tools stay visible to the model, and the block applies even when prompt hardening is off.
  • MCP_STDIO_ALLOWED_COMMANDS and MCP_STDIO_ENV_ALLOWLIST (server builds only): which stdio command lines may run and which variables they may receive. See stdio allowlists.

Confirming tool calls

Each server has an Ask before running tools checkbox in its edit form. It is on for new servers, and servers saved before the option existed count as on too.

While it is on, every call the model makes to that server’s tools pauses the reply and opens a dialog titled Run tool_name?. The dialog names the server and shows the exact arguments as JSON.

  • Run executes the call and the reply continues with its result.
  • Skip, Escape, or a click outside the dialog declines it. The model receives a result saying you declined and that it should not retry unless you ask, so it answers without the tool.
  • When the model calls several tools in one round, the dialogs queue and appear one at a time.
  • The dialog appears in the main window and in the desktop overlay, wherever the chat is running.

Tools listed in PUBLIC_MCP_CONFIRM_TOOLS are blocked before this step: they never open a dialog and never run from chat.

Turn the checkbox off only for servers you trust to run unattended. Those cards show an auto-run badge.

stdio allowlists

Both variables are read by the server build only; the desktop app does not run stdio servers. Entries are separated by commas, and spaces around each entry are trimmed.

MCP_STDIO_ALLOWED_COMMANDS

stdio is fail-closed. Without this variable no stdio server runs: the tool list shows a “stdio is disabled” error for the server and /api/mcp/call answers 403.

Each entry is a full command line, split into words the way a shell would split simple arguments: spaces separate words, and single or double quotes keep a word with spaces together ("C:\Program Files\node.exe"). The first word is the command, the rest are its arguments.

A stdio server runs when some entry matches it:

  1. The server’s Command equals the entry’s first word exactly. There is no path lookup, so npx and /usr/local/bin/npx are different commands.
  2. The server’s Arguments start with the entry’s remaining words, in the same order and spelled exactly the same.
  3. The argument counts are equal, unless the entry ends with a separate * word. Then any further arguments are allowed after the listed ones.
EntryAllowsRejects
npx -y @brave/brave-search-mcp-servernpx -y @brave/brave-search-mcp-servernpx -y @brave/brave-search-mcp-server --port 1
npx -y @modelcontextprotocol/server-filesystem *the same line plus any directories after itnpx @modelcontextprotocol/server-filesystem /tmp (missing -y)
npxnpx with no argumentsnpx -y anything
*every command linenothing

A bare command name like npx allows it only with no arguments, so older npx,node,uvx-style lists no longer work. The server config comes from the browser, and node -e ... would be arbitrary code. Utsuwa logs a startup warning for each bare command entry and another when * allows everything.

Because commas separate entries, a command line that itself contains a comma cannot be listed.

MCP_STDIO_ENV_ALLOWLIST

Comma-separated variable names a stdio server’s Env Vars may set, for example BRAVE_API_KEY,SEARXNG_URL. Names must match exactly, including case. Variables not on the list are dropped silently before the process starts.

Variables that change how code loads are dropped even when listed, in any case: PATH, HOME, NODE_OPTIONS, NODE_PATH, LD_PRELOAD, LD_LIBRARY_PATH, DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH, BASH_ENV, PYTHONPATH, and anything starting with NPM_CONFIG_.

Limits

  • Listing tools (for example Refresh under Available Tools) starts at most 4 stdio servers at a time; the others wait for a free slot.
  • A server that writes more than 4 MB to stdout without a newline is killed, and its pending requests fail with an error.
  • Each stdio request times out after 15 seconds.

Troubleshooting

SymptomLikely cause
MCP entry missing in settingsMCP is disabled on the server (MCP_ENABLED is not server or both).
401 for an HTTP serverToken missing, invalid or expired.
404 for an HTTP serverWrong endpoint path. Utsuwa already retries with/without a trailing slash; check the server’s documented MCP URL.
422 asking for a specific headerThe URL points at a REST API, not at an MCP endpoint.
MCP stdio timeoutCommand too slow to start. The first npx run downloads the package; press Refresh again.
spawn ... ENOENTCommand not found on the Utsuwa host (check the command name and Node.js version).
Empty tool list, no errorThe server is reachable but exposes no tools (or all are filtered server-side).
stdio server shows “stdio is disabled”No stdio allowlist is set. Add the full command line to MCP_STDIO_ALLOWED_COMMANDS (e.g. npx -y mcp-searxng) in the Utsuwa server environment and restart it.
stdio server shows “is not allowed”The command and arguments do not match an allowlist entry exactly. Copy them into MCP_STDIO_ALLOWED_COMMANDS, or end the entry with * to allow extra arguments.
stdio server ignores its env varsThe variable names are not in MCP_STDIO_ENV_ALLOWLIST.
Tool result says “requires manual user confirmation”The tool is listed in PUBLIC_MCP_CONFIRM_TOOLS. Confirm the action with the user, or remove the name from the list.

Security notes

  • Tool results are treated as untrusted data: they are never executed, only passed to the model. Prompt hardening states the same rule to the model, Ask before running tools puts every call in front of you, and PUBLIC_MCP_CONFIRM_TOOLS can block selected tools from running at all.
  • Tokens never reach the model and are never logged by Utsuwa. They do travel to your own server inside the proxy request body, so a reverse proxy or body-logging layer in front of the deployment could capture them. Keep access logs clean.
  • On the server-side HTTP path, link-local and cloud-metadata addresses (169.254.0.0/16, fe80::/10, fd00:ec2::/32, 100.100.100.200, metadata.google.internal, also via DNS resolution) are rejected before any request. Only same-origin 307/308 redirects are followed, and every destination is checked before sending credentials or tool arguments. Cross-origin redirects, including HTTPS-to-HTTP changes, are rejected. 301/302/303 are reported as errors instead of changing the JSON-RPC POST into a GET. IPv4-mapped IPv6 addresses are checked against the same blocked ranges. Loopback and RFC1918 stay reachable by design, because self-hosted MCP servers like Home Assistant live on the local network. Never enable MCP on a deployment untrusted users can reach.
  • HTTP servers may only use http:/https: URLs; other schemes are rejected before any request is made (web proxy and desktop transport alike). The desktop transport additionally blocks literal link-local/metadata hosts; hostname resolution checks exist only where DNS is available (server build).
  • stdio servers run commands on the Utsuwa host and receive only a minimal, allowlisted slice of the app environment (PATH, HOME, LANG, temp/cert vars) plus the per-server variables named in MCP_STDIO_ENV_ALLOWLIST. App secrets like DATABASE_URL or provider keys are never inherited. They are fail-closed: nothing runs unless MCP_STDIO_ALLOWED_COMMANDS lists the exact command line (or *). Critical variables such as PATH and NODE_OPTIONS from a server config are always ignored.
  • The /api/mcp/* routes are unauthenticated, like the rest of the app: Utsuwa has no per-user accounts, so anyone who can reach the deployment can add servers and trigger tool calls. Because the client supplies the server config, the HTTP path also works as a proxy into loopback/RFC1918 (that is what makes self-hosted Home Assistant possible). Never expose an MCP-enabled deployment without an authenticating reverse proxy. Utsuwa logs a startup warning whenever MCP is enabled, and another when stdio is disabled or allows every command.
  • The model can call any tool you expose. Expose only what you are comfortable with (Home Assistant’s MCP integration lets you pick which entities are exposed).
  • Disabling a server (or all servers) takes effect immediately: no further requests are made and its tools leave the chat.