Skip to content

Commit 0cac2b1

Browse files
committed
Improvements to MCP server
aded ability to disable it added configurable port find another available port
1 parent 5a42233 commit 0cac2b1

File tree

5 files changed

+110
-13
lines changed

5 files changed

+110
-13
lines changed

README.md

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -389,13 +389,24 @@ Analyze comprehensive module dependency relationships including direct/reverse d
389389

390390
### Setup and Configuration
391391

392+
The MCP server starts automatically when ElixirLS launches (unless disabled). The server uses a predictable port assignment:
393+
394+
- **Default behavior**: Port is calculated as `3789 + hash(workspace_path)` to ensure different workspaces use different ports
395+
- **Custom port**: Can be set via the `elixirLS.mcpPort` setting
396+
- **Port discovery**: If the calculated/configured port is busy, the server automatically finds the next available port
397+
398+
**Finding the actual port**: Check the ElixirLS output logs for a message like:
399+
```
400+
[MCP] Server listening on port 4328
401+
```
402+
392403
#### TCP-to-STDIO Bridge
393404

394405
ElixirLS includes a TCP-to-STDIO bridge script located at `scripts/tcp_to_stdio_bridge.exs`. This bridge enables LLMs like Claude to communicate with the ElixirLS MCP server by converting between STDIO (used by LLMs) and TCP (used by the MCP server).
395406

396407
The bridge:
397-
- Connects to the ElixirLS MCP server running on TCP port 3798
398-
- Forwards messages bidirectionally between STDIO and TCP
408+
- Connects to the ElixirLS MCP server running on the discovered/configured TCP port
409+
- Forwards messages bidirectionally between STDIO and TCP
399410
- Uses binary mode with latin1 encoding for proper communication
400411
- Handles connection lifecycle and error conditions
401412

@@ -409,14 +420,22 @@ To use ElixirLS with Claude Code or other MCP-compatible tools, create an `mcp.j
409420
"elixir-ls-bridge": {
410421
"command": "elixir",
411422
"args": [
412-
"/absolute/path/to/elixir-ls/scripts/tcp_to_stdio_bridge.exs"
423+
"/absolute/path/to/elixir-ls/scripts/tcp_to_stdio_bridge.exs",
424+
"4328"
413425
]
414426
}
415427
}
416428
}
417429
```
418430

419-
Replace `/absolute/path/to/elixir-ls/` with the actual path to your ElixirLS installation.
431+
Replace `/absolute/path/to/elixir-ls/` with the actual path to your ElixirLS installation and `4328` with the actual port number from the ElixirLS logs.
432+
433+
#### MCP Settings
434+
435+
The MCP server can be configured via ElixirLS settings:
436+
437+
- **`elixirLS.mcpEnabled`** (boolean, default: `true`): Enable or disable the MCP server
438+
- **`elixirLS.mcpPort`** (integer, optional): Set a specific port for the MCP server. If not set, uses `3789 + hash(workspace_path)` for predictable port assignment per workspace
420439

421440
## Automatic builds and error reporting
422441

@@ -491,6 +510,8 @@ Below is a list of configuration options supported by the ElixirLS language serv
491510
<dt>elixirLS.languageServerOverridePath</dt><dd>Absolute path to an alternative ElixirLS release that will override the packaged release</dd>
492511
<dt>elixirLS.stdlibSrcDir</dt><dd>Path to Elixir's std lib source code. See [here](https://github.com/elixir-lsp/elixir_sense/pull/277) for more info</dd>
493512
<dt>elixirLS.dotFormatter</dt><dd>Path to a custom <code>.formatter.exs</code> file used when formatting documents</dd>
513+
<dt>elixirLS.mcpEnabled</dt><dd>Enable or disable the MCP (Model Context Protocol) server - Defaults to <code>true</code></dd>
514+
<dt>elixirLS.mcpPort</dt><dd>Set a specific TCP port for the MCP server - If not set, uses <code>3789 + hash(workspace_path)</code> for predictable port assignment per workspace</dd>
494515
</dl>
495516

496517
## Debug Adapter configuration options

apps/language_server/lib/language_server/application.ex

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@ defmodule ElixirLS.LanguageServer.Application do
1616
{LanguageServer.Tracer, []},
1717
{LanguageServer.MixProjectCache, []},
1818
{LanguageServer.Parser, []},
19-
{LanguageServer.ExUnitTestTracer, []},
20-
{ElixirLS.LanguageServer.MCP.TCPServer, port: 3798}
19+
{LanguageServer.ExUnitTestTracer, []}
2120
]
2221
|> Enum.reject(&is_nil/1)
2322

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
defmodule ElixirLS.LanguageServer.MCP.Supervisor do
2+
use Supervisor
3+
4+
def start_link(parent \\ self(), name \\ nil, port) do
5+
Supervisor.start_link(__MODULE__, {parent, port}, name: name || __MODULE__)
6+
end
7+
8+
@impl Supervisor
9+
def init({_parent, port}) do
10+
Supervisor.init(
11+
[
12+
{ElixirLS.LanguageServer.MCP.TCPServer, port: port}
13+
],
14+
strategy: :one_for_one
15+
)
16+
end
17+
end

apps/language_server/lib/language_server/mcp/tcp_server.ex

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,16 @@ defmodule ElixirLS.LanguageServer.MCP.TCPServer do
2424

2525
@impl true
2626
def init(port) do
27-
IO.puts("[MCP] Starting TCP Server on port #{port}")
27+
IO.puts("[MCP] Starting TCP Server, trying port #{port}")
2828

29-
case :gen_tcp.listen(port, [:binary, packet: :line, active: false, reuseaddr: true]) do
30-
{:ok, listen_socket} ->
31-
IO.puts("[MCP] Server listening on port #{port}")
29+
case find_available_port(port) do
30+
{:ok, actual_port, listen_socket} ->
31+
IO.puts("[MCP] Server listening on port #{actual_port}")
3232
send(self(), :accept)
33-
{:ok, %{listen: listen_socket, clients: %{}}}
33+
{:ok, %{listen: listen_socket, port: actual_port, clients: %{}}}
3434

3535
{:error, reason} ->
36-
IO.puts("[MCP] Failed to listen on port #{port}: #{inspect(reason)}")
36+
IO.puts("[MCP] Failed to find available port starting from #{port}: #{inspect(reason)}")
3737
{:stop, reason}
3838
end
3939
end
@@ -128,6 +128,27 @@ defmodule ElixirLS.LanguageServer.MCP.TCPServer do
128128

129129
# Private functions
130130

131+
defp find_available_port(start_port, max_attempts \\ 100) do
132+
find_available_port(start_port, start_port, max_attempts)
133+
end
134+
135+
defp find_available_port(current_port, start_port, attempts_left) when attempts_left > 0 do
136+
case :gen_tcp.listen(current_port, [:binary, packet: :line, active: false, reuseaddr: true]) do
137+
{:ok, listen_socket} ->
138+
{:ok, current_port, listen_socket}
139+
140+
{:error, :eaddrinuse} ->
141+
find_available_port(current_port + 1, start_port, attempts_left - 1)
142+
143+
{:error, reason} ->
144+
{:error, reason}
145+
end
146+
end
147+
148+
defp find_available_port(_current_port, start_port, 0) do
149+
{:error, "No available ports found starting from #{start_port}"}
150+
end
151+
131152
defp accept_connection(parent, listen_socket) do
132153
IO.puts("[MCP] Waiting for connection...")
133154

apps/language_server/lib/language_server/server.ex

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ defmodule ElixirLS.LanguageServer.Server do
5959
:server_instance_id,
6060
:build_ref,
6161
:dialyzer_sup,
62+
:mcp_sup,
6263
:root_uri,
6364
:project_dir,
6465
:settings,
@@ -2365,6 +2366,11 @@ defmodule ElixirLS.LanguageServer.Server do
23652366
Application.put_env(:language_server, :elixir_src, stdlib_src_dir)
23662367
end
23672368

2369+
enable_mcp =
2370+
Map.get(settings, "mcpEnabled", true) == true
2371+
2372+
mcp_port = calculate_mcp_port(settings, state.root_uri)
2373+
23682374
state =
23692375
state
23702376
|> maybe_set_env_vars(env_vars)
@@ -2373,6 +2379,7 @@ defmodule ElixirLS.LanguageServer.Server do
23732379
|> set_project_dir(project_dir)
23742380
|> Map.put(:settings, settings)
23752381
|> set_dialyzer_enabled(enable_dialyzer)
2382+
|> set_mcp_enabled(enable_mcp, mcp_port)
23762383

23772384
add_watched_extensions(state.server_instance_id, additional_watched_extensions)
23782385

@@ -2407,7 +2414,9 @@ defmodule ElixirLS.LanguageServer.Server do
24072414
if(Map.get(settings, "dialyzerEnabled", true),
24082415
do: Map.get(settings, "dialyzerFormat", "dialyxir_long"),
24092416
else: ""
2410-
)
2417+
),
2418+
"elixir_ls.mcpEnabled" => to_string(Map.get(settings, "mcpEnabled", true)),
2419+
"elixir_ls.mcpPort" => to_string(mcp_port)
24112420
},
24122421
%{}
24132422
)
@@ -2512,6 +2521,36 @@ defmodule ElixirLS.LanguageServer.Server do
25122521
end
25132522
end
25142523

2524+
defp set_mcp_enabled(state = %__MODULE__{}, enable_mcp, port) do
2525+
cond do
2526+
enable_mcp and state.mcp_sup == nil ->
2527+
{:ok, pid} = ElixirLS.LanguageServer.MCP.Supervisor.start_link(port)
2528+
%{state | mcp_sup: pid}
2529+
2530+
not enable_mcp and state.mcp_sup != nil ->
2531+
Process.exit(state.mcp_sup, :normal)
2532+
%{state | mcp_sup: nil}
2533+
2534+
true ->
2535+
state
2536+
end
2537+
end
2538+
2539+
defp calculate_mcp_port(settings, root_uri) do
2540+
case Map.get(settings, "mcpPort") do
2541+
port when is_integer(port) and port > 0 -> port
2542+
_ -> 3789 + hash_root_uri(root_uri)
2543+
end
2544+
end
2545+
2546+
defp hash_root_uri(nil), do: 0
2547+
2548+
defp hash_root_uri(root_uri) when is_binary(root_uri) do
2549+
:crypto.hash(:md5, root_uri)
2550+
|> :binary.decode_unsigned()
2551+
|> rem(1000)
2552+
end
2553+
25152554
defp maybe_set_env_vars(state = %__MODULE__{}, nil), do: state
25162555

25172556
defp maybe_set_env_vars(state = %__MODULE__{}, env) do

0 commit comments

Comments
 (0)