Bind the MCP server to loopback - #1275
Open
erts-sched wants to merge 1 commit into
Open
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The MCP TCP server calls
:gen_tcp.listen/2without an{:ip, _}option, so itbinds
0.0.0.0. While MCP is enabled, anything that can reach the machine onthat 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 thedeveloper 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: addip: {127, 0, 0, 1}to the listen options.scripts/tcp_to_stdio_bridge.exs: connect to127.0.0.1instead oflocalhost. Not required for this script — measured on OTP 27,:gen_tcp.connect/4given a hostname and no family option resolves it in theinetfamily, so the bridge would still reach an IPv4-loopback socket evenwhere the name also maps to
::1. It removes the dependence on host nameresolution and states the address third-party clients need, since those use
getaddrinfoand may try::1first on macOS and Windows.mcp/tcp_server.ex: the accept loop now stops when the listen socket isclosed 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:ignoreinstead of{:stop, reason}. Binding one address can fail (:eaddrnotavail) where thewildcard bind could not, and
find_available_port/3retries only:eaddrinuse; without this, that error would reach the{:ok, pid} =match inset_mcp_enabled/3and take the language server down — the failure mode fromMCP 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 to127.0.0.1. Usesstart_supervised!withport: 0so it does not depend on aparticular port being free.
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)): onmasterthelistening socket reports
{0,0,0,0}via:inet.sockname/1, and a clientconnecting to the host's LAN address — not loopback — was served a
tools/callrequest 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
masterit fails with:and passes with the change.
mix test test/mcp/is green (24 tests) andmix format --check-formattedis clean.The bind and reachability claims above, and the "no IPv6 regression" and
"bridge resolves in the
inetfamily" statements below, were also measured inDocker 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/1now returns
{:ok, pid}instead of crashing, and the process count isunchanged 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.mcpPortsets the port, notthe 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
mcpPort—elixirLS.mcpHost, defaulting to127.0.0.1— and I am happy toadd it here or as a follow-up. VS Code Remote-SSH / Remote-Containers and
ssh -Lare unaffected: they dial127.0.0.1from inside the remote namespace.There is no IPv6 regression. On
masterthe listener already binds0.0.0.0,which is not a dual-stack bind, so a client reaching for
::1was refusedbefore 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.tsregistersMcpStdioServerDefinition("elixir", [bridge_script, String(port)])and passesonly the port, so it picks up the bridge's new default host. The README's manual
mcp.jsonexample 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_OPTSwith a name and cookie; OTP binds that listener to everyinterface unless
-kernel inet_dist_use_interfaceis given, and the node name(
@localhost) does not change that. I left it alone deliberately: it isexplicitly 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.