Skip to content

Bind the MCP server to loopback - #1275

Open
erts-sched wants to merge 1 commit into
elixir-lsp:masterfrom
erts-sched:mcp-bind-loopback
Open

Bind the MCP server to loopback#1275
erts-sched wants to merge 1 commit into
elixir-lsp:masterfrom
erts-sched:mcp-bind-loopback

Conversation

@erts-sched

Copy link
Copy Markdown

What

The MCP TCP server calls :gen_tcp.listen/2 without an {:ip, _} option, so it
binds 0.0.0.0. While MCP is enabled, anything that can reach the machine on
that port can call the MCP tools, with no authentication.

What is reachable is the six read-only tools — find_definition, get_docs,
get_environment, get_type_info, find_implementations,
get_module_dependencies — so the exposure is information about the project the
developer has open. I checked the handler before writing this: there is no code
execution path and no arbitrary-file-read path in those tools.

Making the server opt-in (#1230) settled whether it starts; this is about
which interface it binds once it does. In that thread the default was
described as set "on assumption that the developer fully controls the machine
running the language server" — this change makes the socket match that
assumption, so it also holds for a developer who controls their machine but
shares a network.

Not urgent: MCP is off by default, so this only affects users who turned it on.

Change

  • mcp/tcp_server.ex: add ip: {127, 0, 0, 1} to the listen options.
  • scripts/tcp_to_stdio_bridge.exs: connect to 127.0.0.1 instead of
    localhost. Not required for this script — measured on OTP 27,
    :gen_tcp.connect/4 given a hostname and no family option resolves it in the
    inet family, so the bridge would still reach an IPv4-loopback socket even
    where the name also maps to ::1. It removes the dependence on host name
    resolution and states the address third-party clients need, since those use
    getaddrinfo and may try ::1 first on macOS and Windows.
  • mcp/tcp_server.ex: the accept loop now stops when the listen socket is
    closed instead of retrying forever. It is spawned unlinked, so stopping the
    server previously left it looping and printing once a second — the new test is
    the first thing in the suite to stop a TCPServer, which is what surfaced it.
  • mcp/tcp_server.ex: a failure to listen returns :ignore instead of
    {:stop, reason}. Binding one address can fail (:eaddrnotavail) where the
    wildcard bind could not, and find_available_port/3 retries only
    :eaddrinuse; without this, that error would reach the {:ok, pid} = match in
    set_mcp_enabled/3 and take the language server down — the failure mode from
    MCP Server enabled by default prevents ElixirLS servers to start on all but the first opened projects #1227. Now MCP simply does not run and says so in the log.
  • test/mcp/tcp_server_test.exs: asserts the listening socket is bound to
    127.0.0.1. Uses start_supervised! with port: 0 so it does not depend on a
    particular port being free.
  • README and CHANGELOG notes.

Verify

Measured on Linux, OTP 27 (erts 15.2.7.13), Elixir 1.20.0, starting the server
through the same path the language server uses
(ElixirLS.LanguageServer.MCP.Supervisor.start_link(port)): on master the
listening socket reports {0,0,0,0} via :inet.sockname/1, and a client
connecting to the host's LAN address — not loopback — was served a tools/call
request and got a normal response. After the change the socket reports
{127,0,0,1} and the same LAN connection is refused.

The new test is that check in CI form. Against master it fails with:

left:  {:ok, {{127, 0, 0, 1}, _port}}
right: {:ok, {{0, 0, 0, 0}, 36523}}

and passes with the change. mix test test/mcp/ is green (24 tests) and
mix format --check-formatted is clean.

The bind and reachability claims above, and the "no IPv6 regression" and
"bridge resolves in the inet family" statements below, were also measured in
Docker bridge-network containers (own network namespace, no host firewall) on
OTP 27, 28 and 29, with a small Erlang module that reproduces the exact listen
options; script and outputs:
https://github.com/erts-sched/elixir-ls-mcp-bind-measurements. Two more checks
on the host: with a privileged port (:eacces), MCP.Supervisor.start_link/1
now returns {:ok, pid} instead of crashing, and the process count is
unchanged after stopping the server (no orphaned accept loop).

Trade-off

MCP is no longer reachable from another host. Anyone running ElixirLS in a
container or a VM and pointing an MCP client at it from outside loses access,
and there is no setting to opt back in — elixirLS.mcpPort sets the port, not
the interface. I read that as the intended outcome of the #1230 thread, but if
you would rather keep that door open, the natural shape is a sibling of
mcpPortelixirLS.mcpHost, defaulting to 127.0.0.1 — and I am happy to
add it here or as a follow-up. VS Code Remote-SSH / Remote-Containers and
ssh -L are unaffected: they dial 127.0.0.1 from inside the remote namespace.

There is no IPv6 regression. On master the listener already binds 0.0.0.0,
which is not a dual-stack bind, so a client reaching for ::1 was refused
before this change as well.

Scope

No change to what the MCP tools do or to the port-selection logic.

No change is needed in vscode-elixir-ls: src/mcp.ts registers
McpStdioServerDefinition("elixir", [bridge_script, String(port)]) and passes
only the port, so it picks up the bridge's new default host. The README's manual
mcp.json example passes only a port too.

This is the only socket ElixirLS opens on its own — the language server and the
debug adapter both speak stdio. The other listener a user can end up with is
Erlang distribution, when they follow "Connecting to debug adapter" and set
ELS_ELIXIR_OPTS with a name and cookie; OTP binds that listener to every
interface unless -kernel inet_dist_use_interface is given, and the node name
(@localhost) does not change that. I left it alone deliberately: it is
explicitly opt-in and "Attaching to remote nodes" needs network reachability to
work at all. Happy to document the local-only case separately if you want it.

The MCP TCP server called :gen_tcp.listen without an {:ip, _} option, so
it bound 0.0.0.0 and was reachable from the network, unauthenticated,
whenever a user enabled it. Making the server opt-in (elixir-lsp#1230) settled
whether it starts; this settles which interface it binds once it does.

Bind the listening socket to 127.0.0.1 and point the TCP-to-STDIO bridge
at 127.0.0.1 instead of the name localhost, so the client shipped here
names the address the server binds. This does not change IPv6
reachability: 0.0.0.0 is not a dual-stack bind, so the socket was
IPv4-only before this change as well.

Two supporting changes that binding a specific address makes necessary:
the accept loop now stops when the listen socket is closed instead of
retrying forever, and a failure to listen no longer takes the language
server down with it, since binding one address can fail where the
wildcard bind could not and only :eaddrinuse is retried.
erts-sched added a commit to erts-sched/elixir-ls-mcp-bind-measurements that referenced this pull request Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant