Repository: tidewave-ai/tidewave_phoenix
Pull request #82 (2025-05-24 14:25:39)

support including & excluding tools via config
Supersedes https://github.com/tidewave-ai/tidewave_phoenix/pull/78.


Patch:
diff --git a/README.md b/README.md
index 6f35cb4..ae9ca6a 100644
--- a/README.md
+++ b/README.md
@@ -81,6 +81,12 @@ The following options are available:
 
   * `:inspect_opts` - Custom options passed to `Kernel.inspect/2` when formatting some tool results. Defaults to: `[charlists: :as_lists, limit: 50, pretty: true]`
 
+  * `:tools` - Configuration for the tools that are included/excluded from the MCP server.
+
+    * `:include` - A list of tools to be included in the MCP. Defaults to `nil`, meaning all tools are included.
+
+    * `:exclude` - A list of tools to be excluded from the MCP. Defaults to `[]`. Supersedes `:include` if both are set.
+
 ## License
 
 Copyright (c) 2025 Dashbit
diff --git a/lib/tidewave.ex b/lib/tidewave.ex
index d097004..f7d91cc 100644
--- a/lib/tidewave.ex
+++ b/lib/tidewave.ex
@@ -10,7 +10,9 @@ defmodule Tidewave do
       allow_remote_access: Keyword.get(opts, :allow_remote_access, false),
       phoenix_endpoint: nil,
       inspect_opts:
-        Keyword.get(opts, :inspect_opts, charlists: :as_lists, limit: 50, pretty: true)
+        Keyword.get(opts, :inspect_opts, charlists: :as_lists, limit: 50, pretty: true),
+      exclude_tools: Enum.map(opts[:tools][:exclude] || [], &to_string/1),
+      include_tools: opts[:tools][:include] && Enum.map(opts[:tools][:include], &to_string/1)
     }
   end
 
diff --git a/lib/tidewave/mcp/connection.ex b/lib/tidewave/mcp/connection.ex
index e01e292..e4b7446 100644
--- a/lib/tidewave/mcp/connection.ex
+++ b/lib/tidewave/mcp/connection.ex
@@ -86,8 +86,8 @@ defmodule Tidewave.MCP.Connection do
     GenServer.call(pid, {:handle_result, id})
   end
 
-  def connect_params(pid) do
-    GenServer.call(pid, :connect_params)
+  def connect_params_and_assigns(pid) do
+    GenServer.call(pid, :connect_params_and_assigns)
   end
 
   def dispatch(pid, callback, args) do
@@ -173,8 +173,8 @@ defmodule Tidewave.MCP.Connection do
     {:reply, result, %{state | requests: Map.delete(state.requests, id)}}
   end
 
-  def handle_call(:connect_params, _from, state) do
-    {:reply, state.conn.query_params, state}
+  def handle_call(:connect_params_and_assigns, _from, state) do
+    {:reply, {state.conn.query_params, state.assigns}, state}
   end
 
   def handle_call({:dispatch, callback, args}, _from, state) do
diff --git a/lib/tidewave/mcp/server.ex b/lib/tidewave/mcp/server.ex
index 4f81f5b..7809241 100644
--- a/lib/tidewave/mcp/server.ex
+++ b/lib/tidewave/mcp/server.ex
@@ -37,7 +37,8 @@ defmodule Tidewave.MCP.Server do
     |> List.flatten()
   end
 
-  defp tools(connect_params) do
+  @doc false
+  def tools({connect_params, assigns}) do
     {tools, _} = tools_and_dispatch()
 
     listable? = fn
@@ -48,7 +49,10 @@ defmodule Tidewave.MCP.Server do
         true
     end
 
-    for tool <- tools, listable?.(tool) do
+    for tool <- tools,
+        listable?.(tool),
+        is_nil(assigns.include_tools) or tool.name in assigns.include_tools,
+        tool.name not in assigns.exclude_tools do
       tool
       |> Map.put(:description, String.trim(tool.description))
       |> Map.drop([:callback, :listable])
@@ -113,7 +117,7 @@ defmodule Tidewave.MCP.Server do
                name: "Tidewave MCP Server",
                version: @vsn
              },
-             tools: tools(Connection.connect_params(state_pid))
+             tools: tools(Connection.connect_params_and_assigns(state_pid))
            }
          }}
 
@@ -123,7 +127,10 @@ defmodule Tidewave.MCP.Server do
   end
 
   def handle_list_tools(request_id, _params, state_pid) do
-    result_or_error(request_id, {:ok, %{tools: tools(Connection.connect_params(state_pid))}})
+    result_or_error(
+      request_id,
+      {:ok, %{tools: tools(Connection.connect_params_and_assigns(state_pid))}}
+    )
   end
 
   def handle_call_tool(request_id, %{"name" => name} = params, state_pid) do
diff --git a/lib/tidewave/mcp/sse.ex b/lib/tidewave/mcp/sse.ex
index 27cdc9e..319786a 100644
--- a/lib/tidewave/mcp/sse.ex
+++ b/lib/tidewave/mcp/sse.ex
@@ -185,6 +185,7 @@ defmodule Tidewave.MCP.SSE do
       Connection.init({session_id, conn})
     catch
       :exit, :normal -> conn
+      :exit, :shutdown -> conn
       :exit, {:shutdown, _} -> conn
     after
       # Bandit re-uses the same process for new requests,


Test patch:
diff --git a/test/mcp/tools_test.exs b/test/mcp/tools_test.exs
index fd4c68c..719a6ef 100644
--- a/test/mcp/tools_test.exs
+++ b/test/mcp/tools_test.exs
@@ -10,6 +10,27 @@ defmodule Tidewave.MCP.ToolsTest do
     end
   end
 
+  test "tools are hidden from the list when excluded" do
+    refute Enum.any?(
+             Tidewave.MCP.Server.tools(
+               {%{}, %{exclude_tools: ["project_eval"], include_tools: nil}}
+             ),
+             &(&1.name == "project_eval")
+           )
+
+    assert Enum.any?(
+             Tidewave.MCP.Server.tools({%{}, %{exclude_tools: [], include_tools: nil}}),
+             &(&1.name == "project_eval")
+           )
+
+    assert Enum.map(
+             Tidewave.MCP.Server.tools(
+               {%{}, %{include_tools: ["project_eval"], exclude_tools: []}}
+             ),
+             & &1.name
+           ) == ["project_eval"]
+  end
+
   test "fs tools have listable callback" do
     tools = Tidewave.MCP.Tools.FS.tools()
 
diff --git a/test/mcp_integration_test.exs b/test/mcp_integration_test.exs
index 86655aa..dfc9e7a 100644
--- a/test/mcp_integration_test.exs
+++ b/test/mcp_integration_test.exs
@@ -6,15 +6,22 @@ defmodule Tidewave.MCPIntegrationTest do
 
   @moduletag :capture_log
 
-  setup_all do
-    start_supervised!({Bandit, plug: Tidewave, port: 9100, startup_log: false},
-      shutdown: :brutal_kill
+  setup context do
+    start_supervised!(
+      {Bandit, plug: {Tidewave, context[:plug_opts] || []}, port: 9100, startup_log: false},
+      shutdown: 10
     )
 
-    :ok
-  end
+    assert Stream.interval(10)
+           |> Stream.take(10)
+           |> Enum.reduce_while(nil, fn _, _ ->
+             case Req.post("http://127.0.0.1:9100") do
+               {:ok, _} -> {:halt, true}
+               _ -> {:cont, false}
+             end
+           end),
+           "server not listening"
 
-  setup context do
     %{request: connect_and_initialize(context[:base_url] || @base_url)}
   end
 
@@ -36,6 +43,15 @@ defmodule Tidewave.MCPIntegrationTest do
     assert "list_project_files" in tool_names
   end
 
+  @tag plug_opts: [tools: [exclude: [:write_project_file]]]
+  test "can exclude tools via plug opts", %{request: request} do
+    assert %{tools: tools} = request
+    assert is_list(tools)
+    assert tool_names = Enum.map(tools, & &1["name"])
+    assert "list_project_files" in tool_names
+    refute "write_project_file" in tool_names
+  end
+
   test "write to file needs read first", %{request: request} do
     assert %{resp: resp, endpoint_url: endpoint_url, tools: tools} = request
     assert "write_project_file" in Enum.map(tools, & &1["name"])