Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
```
Expand Down
23 changes: 20 additions & 3 deletions apps/language_server/lib/language_server/mcp/tcp_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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}

Expand Down Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions apps/language_server/test/mcp/tcp_server_test.exs
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions scripts/tcp_to_stdio_bridge.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down Expand Up @@ -85,4 +85,4 @@ port =
[] -> 3798
end

TcpToStdioBridge.start("localhost", port)
TcpToStdioBridge.start("127.0.0.1", port)