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)