From c0138dc47d7bfdfdcb2fc233325a36db5363a44f Mon Sep 17 00:00:00 2001 From: Guilherme Silva <239806034+erts-sched@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:39:35 -0300 Subject: [PATCH] Bind the MCP server to loopback 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 (#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. --- CHANGELOG.md | 4 ++++ README.md | 1 + .../lib/language_server/mcp/tcp_server.ex | 23 ++++++++++++++++--- .../test/mcp/tcp_server_test.exs | 16 +++++++++++++ scripts/tcp_to_stdio_bridge.exs | 4 ++-- 5 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 apps/language_server/test/mcp/tcp_server_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index b94e11b09..e232ae6e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ### Unreleased +#### Fixes + +- MCP server listens on `127.0.0.1` instead of every interface; the TCP-to-STDIO bridge connects to `127.0.0.1` instead of `localhost` [Guilherme Silva](https://github.com/erts-sched) + ### v0.31.1: 11 June 2026 #### Fixes diff --git a/README.md b/README.md index d68bc7919..aba03ddac 100644 --- a/README.md +++ b/README.md @@ -399,6 +399,7 @@ The MCP server starts automatically when ElixirLS launches (if enabled). The ser - **Default behavior**: Port is calculated as `3789 + hash(workspace_path)` to ensure different workspaces use different ports - **Custom port**: Can be set via the `elixirLS.mcpPort` setting - **Port discovery**: If the calculated/configured port is busy, the server automatically finds the next available port +- **Interface**: The server listens on `127.0.0.1` only, so it is reachable from the machine running ElixirLS and not from the network. Connect to it as `127.0.0.1` rather than as `localhost`, which on some systems resolves to the IPv6 loopback address `::1` **Finding the actual port**: Check the ElixirLS output logs for a message like: ``` diff --git a/apps/language_server/lib/language_server/mcp/tcp_server.ex b/apps/language_server/lib/language_server/mcp/tcp_server.ex index 791b091c7..28d9c1734 100644 --- a/apps/language_server/lib/language_server/mcp/tcp_server.ex +++ b/apps/language_server/lib/language_server/mcp/tcp_server.ex @@ -32,8 +32,13 @@ defmodule ElixirLS.LanguageServer.MCP.TCPServer do {:ok, %{listen: listen_socket, port: actual_port, clients: %{}}} {:error, reason} -> - IO.puts("[MCP] Failed to find available port starting from #{port}: #{inspect(reason)}") - {:stop, reason} + IO.puts( + "[MCP] Failed to listen on 127.0.0.1 starting from port #{port}: #{inspect(reason)}" + ) + + # Do not take the language server down: binding one address can fail + # where the wildcard bind could not, and only :eaddrinuse is retried. + :ignore end end @@ -132,7 +137,13 @@ defmodule ElixirLS.LanguageServer.MCP.TCPServer do end defp find_available_port(current_port, start_port, attempts_left) when attempts_left > 0 do - case :gen_tcp.listen(current_port, [:binary, packet: :line, active: false, reuseaddr: true]) do + case :gen_tcp.listen(current_port, [ + :binary, + ip: {127, 0, 0, 1}, + packet: :line, + active: false, + reuseaddr: true + ]) do {:ok, listen_socket} -> {:ok, current_port, listen_socket} @@ -161,6 +172,12 @@ defmodule ElixirLS.LanguageServer.MCP.TCPServer do # Continue accepting accept_connection(parent, listen_socket) + {:error, reason} when reason in [:closed, :einval] -> + # The server stopped and closed the listen socket. Without this clause + # the loop retries forever, since it is not linked to the GenServer. + IO.puts("[MCP] Listen socket closed, stopping accept loop") + :ok + {:error, reason} -> IO.puts("[MCP] Accept error: #{inspect(reason)}") Process.sleep(1000) diff --git a/apps/language_server/test/mcp/tcp_server_test.exs b/apps/language_server/test/mcp/tcp_server_test.exs new file mode 100644 index 000000000..0d72592d1 --- /dev/null +++ b/apps/language_server/test/mcp/tcp_server_test.exs @@ -0,0 +1,16 @@ +defmodule ElixirLS.LanguageServer.MCP.TCPServerTest do + use ExUnit.Case, async: false + + alias ElixirLS.LanguageServer.MCP.TCPServer + + setup do + # port: 0 lets the OS assign a free port, so the test does not depend on a + # particular port being free on the machine running it. + pid = start_supervised!({TCPServer, port: 0}) + %{listen: :sys.get_state(pid).listen} + end + + test "binds the listening socket to loopback, not to every interface", %{listen: listen} do + assert {:ok, {{127, 0, 0, 1}, _port}} = :inet.sockname(listen) + end +end diff --git a/scripts/tcp_to_stdio_bridge.exs b/scripts/tcp_to_stdio_bridge.exs index e2fb6e046..4220c4daa 100644 --- a/scripts/tcp_to_stdio_bridge.exs +++ b/scripts/tcp_to_stdio_bridge.exs @@ -4,7 +4,7 @@ # This bridges between LLM like claude (using stdio) and ElixirLS MCP server (using TCP) defmodule TcpToStdioBridge do - def start(host \\ "localhost", port \\ 3798) do + def start(host \\ "127.0.0.1", port \\ 3798) do # Set stdio to binary mode with latin1 encoding :io.setopts(:standard_io, [:binary, encoding: :latin1]) @@ -85,4 +85,4 @@ port = [] -> 3798 end -TcpToStdioBridge.start("localhost", port) +TcpToStdioBridge.start("127.0.0.1", port)