From f8fc3176d84cd3d0c24a723ebc393f76722a4ca3 Mon Sep 17 00:00:00 2001 From: Monte Brown Date: Sun, 25 Jan 2026 14:04:46 -0500 Subject: [PATCH] Add dynamic client support (PIDs, via tuples, global tuples) Enable MCP adapters to work with dynamically started clients instead of only module names. This unlocks per-job client patterns needed for scenarios like Playwright browser automation where each job requires an isolated browser context to prevent race conditions between concurrent jobs. Why this matters: - Shared MCP clients cause race conditions. - Single persistent connections defeat load balancer distribution. - Per-job clients enable true horizontal scaling across server pools. - Memory stays bounded (contexts cleaned up when jobs complete). Changes: - Config: Accept PIDs (with alive check), {:via, Registry, _}, {:global, _} - ToolExecutor: Dispatch based on client type, resolve supervisor PIDs to Anubis.Client.Base automatically - TestSupport: Use apply/3 for Mimic calls to avoid compile-time dep - README: Document "Client Types" and "Dynamic Clients" sections Example usage: {:ok, client_pid} = MyApp.MCPClient.start_link(opts) :ok = Adapter.wait_for_server_ready(client_pid) adapter = Adapter.new(client: client_pid) #... use adapter ... Supervisor.stop(client_pid) --- README.md | 68 +++ lib/langchain_mcp/config.ex | 71 ++- lib/langchain_mcp/test_support.ex | 426 ++++++++++++++++++ lib/langchain_mcp/tool_executor.ex | 95 +++- .../mcp/adapter_integration_test.exs | 27 ++ test/langchain_mcp/mcp/config_test.exs | 79 ++++ test/langchain_mcp/mcp/tool_executor_test.exs | 61 ++- 7 files changed, 816 insertions(+), 11 deletions(-) create mode 100644 lib/langchain_mcp/test_support.ex diff --git a/README.md b/README.md index 957cf35..148ae16 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,74 @@ adapter = Adapter.new( ) ``` +### Client Types + +The `client` and `fallback_client` options accept multiple reference types: + +```elixir +# Module name (most common - for supervision tree clients) +adapter = Adapter.new(client: MyApp.MCPClient) + +# PID (for dynamically started clients) +{:ok, client_pid} = MyApp.MCPClient.start_link(transport: {:streamable_http, base_url: url}) +adapter = Adapter.new(client: client_pid) + +# Via tuple (for Registry-based lookups) +adapter = Adapter.new(client: {:via, Registry, {MyApp.Registry, "mcp_client"}}) + +# Global tuple +adapter = Adapter.new(client: {:global, :my_mcp_client}) +``` + +## Dynamic Clients + +For scenarios where you need per-request or per-job MCP clients (e.g., browser automation with Playwright), you can start clients dynamically and pass the PID to the adapter. + +### Per-Job Pattern + +```elixir +defmodule MyApp.BrowserJob do + alias LangChain.MCP.Adapter + + def run_with_browser(task) do + # Start a dedicated MCP client for this job + {:ok, client_pid} = MyApp.PlaywrightMCP.start_link( + transport: {:streamable_http, base_url: "http://localhost:3000"} + ) + + # Wait for the client to be ready + :ok = Adapter.wait_for_server_ready(client_pid) + + try do + # Create adapter with the dynamic client + adapter = Adapter.new(client: client_pid) + functions = Adapter.to_functions(adapter) + + # Use in your chain + {:ok, result} = run_chain_with_tools(task, functions) + result + after + # Clean up when done + Supervisor.stop(client_pid) + end + end +end +``` + +### With Fallback + +Dynamic clients also work with fallback support: + +```elixir +{:ok, primary_pid} = MyApp.PrimaryMCP.start_link(opts) +{:ok, fallback_pid} = MyApp.BackupMCP.start_link(opts) + +adapter = Adapter.new( + client: primary_pid, + fallback_client: fallback_pid +) +``` + ### Selective Tool Discovery ```elixir diff --git a/lib/langchain_mcp/config.ex b/lib/langchain_mcp/config.ex index 1bfa639..57a8417 100644 --- a/lib/langchain_mcp/config.ex +++ b/lib/langchain_mcp/config.ex @@ -55,12 +55,15 @@ defmodule LangChain.MCP.Config do field(:context, :map, virtual: true, default: %{}) end + @typedoc "A client reference that can be a module, PID, or GenServer-compatible name" + @type client_ref :: module() | pid() | GenServer.server() + @type t :: %__MODULE__{ - client: module(), + client: client_ref(), cache_tools: boolean(), timeout: pos_integer(), async: boolean(), - fallback_client: module() | nil, + fallback_client: client_ref() | nil, before_fallback: function() | nil, tool_filter: function() | nil, context: map() @@ -71,15 +74,23 @@ defmodule LangChain.MCP.Config do ## Options - * `:client` - Required. The Anubis.Client module + * `:client` - Required. The Anubis.Client module, PID, or via tuple * `:cache_tools` - Boolean, default true * `:timeout` - Positive integer in ms, default 30_000 * `:async` - Boolean, default false - * `:fallback_client` - Optional module + * `:fallback_client` - Optional module, PID, or via tuple * `:before_fallback` - Optional 3-arity function * `:tool_filter` - Optional 1-arity function * `:context` - Optional map + ## Client Types + + The `:client` and `:fallback_client` options accept: + * Module name (atom) - e.g., `MyApp.MCPClient` + * PID - e.g., a dynamically started client + * Via tuple - e.g., `{:via, Registry, {MyRegistry, "key"}}` + * Global tuple - e.g., `{:global, :my_client}` + ## Examples iex> Config.new!(client: MyApp.MCPClient) @@ -116,6 +127,7 @@ defmodule LangChain.MCP.Config do |> validate_number(:timeout, greater_than: 0) |> put_virtual_fields(attrs) |> validate_client() + |> validate_fallback_client() |> validate_callbacks() end @@ -146,11 +158,62 @@ defmodule LangChain.MCP.Config do add_error(changeset, :client, "module does not exist") end + client when is_pid(client) -> + # Check if PID is alive + if Process.alive?(client) do + changeset + else + add_error(changeset, :client, "PID is not alive") + end + + {:via, module, _term} when is_atom(module) -> + # Accept via tuples with structure validation only + changeset + + {:global, _name} -> + # Accept global tuples + changeset + _ -> add_error(changeset, :client, "must be a module name (atom)") end end + defp validate_fallback_client(changeset) do + case get_field(changeset, :fallback_client) do + nil -> + # Fallback client is optional + changeset + + client when is_atom(client) -> + # Check if module exists + if Code.ensure_loaded?(client) do + changeset + else + add_error(changeset, :fallback_client, "module does not exist") + end + + client when is_pid(client) -> + # Check if PID is alive + if Process.alive?(client) do + changeset + else + add_error(changeset, :fallback_client, "PID is not alive") + end + + {:via, module, _term} when is_atom(module) -> + # Accept via tuples with structure validation only + changeset + + {:global, _name} -> + # Accept global tuples + changeset + + _ -> + add_error(changeset, :fallback_client, "must be a module name (atom)") + end + end + defp validate_callbacks(changeset) do changeset |> validate_function(:before_fallback, 3) diff --git a/lib/langchain_mcp/test_support.ex b/lib/langchain_mcp/test_support.ex new file mode 100644 index 0000000..25123c5 --- /dev/null +++ b/lib/langchain_mcp/test_support.ex @@ -0,0 +1,426 @@ +defmodule LangChain.MCP.TestSupport do + @moduledoc """ + Test support helpers for mocking MCP clients using Mimic. + + This module provides utilities to easily mock MCP tool discovery and execution + in your tests without requiring a live MCP server. + + ## Setup + + Add Mimic to your test helper: + + # test/test_helper.exs + Mimic.copy(LangChain.MCP.Adapter) + Mimic.copy(LangChain.MCP.ToolExecutor) + ExUnit.start() + + ## Usage Examples + + ### Simple tool stubbing + + use ExUnit.Case, async: true + use Mimic + + test "agent uses search tool" do + # Stub tools to return from adapter + TestSupport.stub_tools(%{ + "search" => fn args -> + "Found results for: \#{args["query"]}" + end + }) + + adapter = Adapter.new(client: MyApp.MCPClient) + functions = Adapter.to_functions(adapter) + + # Functions are now available and will use stubbed responses + search_fn = Enum.find(functions, &(&1.name == "search")) + assert search_fn.function.(%{"query" => "elixir"}, %{}) =~ "Found results" + end + + ### Stubbing with multiple tools + + test "agent uses multiple tools" do + TestSupport.stub_tools(%{ + "search" => fn %{"query" => q} -> "Results: \#{q}" end, + "fetch" => fn %{"url" => u} -> "Content from \#{u}" end, + "analyze" => fn _ -> "Analysis complete" end + }) + + # Your test code here + end + + ### Stub tool discovery separately + + test "discovers tools" do + tools = [ + %{ + "name" => "search", + "description" => "Search for information", + "inputSchema" => %{ + "type" => "object", + "properties" => %{"query" => %{"type" => "string"}}, + "required" => ["query"] + } + } + ] + + TestSupport.stub_list_tools(tools) + + adapter = Adapter.new(client: MyApp.MCPClient) + {:ok, discovered} = Adapter.discover_tools(adapter) + + assert length(discovered) == 1 + end + + ### Stub tool execution with error responses + + test "handles tool errors" do + TestSupport.stub_tool_call("search", {:error, "API rate limit exceeded"}) + + # Your test code that expects error handling + end + + ### Custom response format + + test "handles multi-content responses" do + TestSupport.stub_tool_call("analyze", [ + %ContentPart{type: :text, content: "Analysis results"}, + %ContentPart{type: :image, content: "base64data"} + ]) + + # Test code here + end + """ + + alias LangChain.MCP.Adapter + alias LangChain.MCP.ToolExecutor + + # Use apply/3 to call Mimic dynamically, avoiding compile-time dependency. + # This allows the module to compile in :dev/:prod without Mimic being present. + # credo:disable-for-next-line Credo.Check.Refactor.Apply + defp mimic_stub(module, function, fun), do: apply(Mimic, :stub, [module, function, fun]) + + @doc """ + Stubs MCP tools at the Adapter level with automatic schema generation. + + This is the recommended high-level API for most tests. It automatically: + - Creates tool schemas with common patterns + - Stubs `Adapter.to_functions/2` to return stubbed functions + - Makes stubbed tools callable with your provided response functions + + ## Parameters + + * `tools_map` - Map of tool name to response function or static response + - Keys: Tool names (strings) + - Values: Either a function `(args -> result)` or a static result + + ## Examples + + # With response functions + TestSupport.stub_tools(%{ + "search" => fn args -> "Results for \#{args["query"]}" end, + "fetch" => fn args -> "Content from \#{args["url"]}" end + }) + + # With static responses + TestSupport.stub_tools(%{ + "get_time" => "2024-01-15 10:30:00 UTC", + "get_status" => "OK" + }) + + # Mixed + TestSupport.stub_tools(%{ + "dynamic" => fn args -> compute_result(args) end, + "static" => "Fixed response" + }) + """ + @spec stub_tools(map()) :: :ok + def stub_tools(tools_map) when is_map(tools_map) do + # Generate tool schemas + tools = Enum.map(tools_map, fn {name, _response} -> generate_tool_schema(name) end) + + # Stub list_tools to return the schemas + stub_list_tools(tools) + + # Stub all tool executions at once + stub_tool_calls(tools_map) + + :ok + end + + @doc """ + Stubs tool discovery to return a specific list of tools. + + Use this for fine-grained control over tool schemas, or when you need + to test tool discovery independently from execution. + + ## Parameters + + * `tools` - List of tool maps with MCP tool schema format + + ## Examples + + tools = [ + %{ + "name" => "search", + "description" => "Search for information", + "inputSchema" => %{ + "type" => "object", + "properties" => %{ + "query" => %{"type" => "string", "description" => "Search query"} + }, + "required" => ["query"] + } + }, + %{ + "name" => "fetch", + "description" => "Fetch URL content", + "inputSchema" => %{ + "type" => "object", + "properties" => %{ + "url" => %{"type" => "string"} + } + } + } + ] + + TestSupport.stub_list_tools(tools) + """ + @spec stub_list_tools([map()]) :: :ok + def stub_list_tools(tools) when is_list(tools) do + mimic_stub(ToolExecutor, :list_tools, fn _client -> + {:ok, tools} + end) + + :ok + end + + @doc """ + Stubs a specific tool's execution. + + ## Parameters + + * `tool_name` - Name of the tool to stub + * `response` - Response to return (function, static value, or error tuple) + - Function: `(args -> result)` for dynamic responses + - Static value: String or list of ContentParts + - Error: `{:error, reason}` tuple + + ## Examples + + # Dynamic response based on args + TestSupport.stub_tool_call("search", fn args -> + "Results for: \#{args["query"]}" + end) + + # Static string response + TestSupport.stub_tool_call("get_time", "2024-01-15 10:30:00") + + # ContentParts response + TestSupport.stub_tool_call("analyze", [ + %ContentPart{type: :text, content: "Results"}, + %ContentPart{type: :image, content: "data"} + ]) + + # Error response + TestSupport.stub_tool_call("failing_tool", {:error, "Service unavailable"}) + """ + @spec stub_tool_call(String.t(), function() | term() | {:error, term()}) :: :ok + def stub_tool_call(tool_name, response) when is_binary(tool_name) do + mimic_stub(ToolExecutor, :execute, fn _config, name, args, _context -> + handle_tool_call(name, tool_name, args, response) + end) + + :ok + end + + # Helper for stub_tool_call to reduce nesting + defp handle_tool_call(name, tool_name, args, response) when name == tool_name do + case response do + fun when is_function(fun, 1) -> + {:ok, fun.(args)} + + {:error, _reason} = error -> + error + + static_response -> + {:ok, static_response} + end + end + + defp handle_tool_call(name, _tool_name, _args, _response) do + {:error, "Tool '#{name}' not stubbed"} + end + + @doc """ + Stubs multiple tool executions at once. + + Convenience function for stubbing several tools with a single call. + + ## Parameters + + * `responses_map` - Map of tool name to response (same format as `stub_tool_call/2`) + + ## Examples + + TestSupport.stub_tool_calls(%{ + "search" => fn args -> "Results: \#{args["query"]}" end, + "fetch" => "Fetched content", + "error_tool" => {:error, "Failed"} + }) + """ + @spec stub_tool_calls(map()) :: :ok + def stub_tool_calls(responses_map) when is_map(responses_map) do + mimic_stub(ToolExecutor, :execute, fn _config, name, args, _context -> + case Map.get(responses_map, name) do + nil -> + {:error, "Tool '#{name}' not stubbed"} + + fun when is_function(fun, 1) -> + {:ok, fun.(args)} + + {:error, _reason} = error -> + error + + static_response -> + {:ok, static_response} + end + end) + + :ok + end + + @doc """ + Stubs tool execution with a custom function that receives all parameters. + + Use this for advanced scenarios where you need access to config, context, + or want to implement custom logic based on multiple parameters. + + ## Parameters + + * `stub_fn` - Function with signature `(config, tool_name, args, context -> result)` + + ## Examples + + # Custom logic based on tool name and args + TestSupport.stub_tool_execution(fn _config, tool_name, args, _context -> + case tool_name do + "search" -> + {:ok, "Found: \#{args["query"]}"} + + "fetch" -> + if valid_url?(args["url"]) do + {:ok, "Content"} + else + {:error, "Invalid URL"} + end + + _ -> + {:error, "Unknown tool"} + end + end) + + # Track calls + TestSupport.stub_tool_execution(fn _config, tool_name, args, _context -> + send(self(), {:tool_called, tool_name, args}) + {:ok, "Response"} + end) + """ + @spec stub_tool_execution((term(), String.t(), map(), map() -> {:ok, term()} | {:error, term()})) :: + :ok + def stub_tool_execution(stub_fn) when is_function(stub_fn, 4) do + mimic_stub(ToolExecutor, :execute, stub_fn) + :ok + end + + @doc """ + Creates a complete mock adapter that works without Mimic stubs. + + This creates an Adapter struct with pre-configured tools that can be used + directly in tests. Useful when you want to avoid Mimic or need portable + test fixtures. + + ## Parameters + + * `tools_config` - Keyword list with `:tools` and optionally `:responses` + + ## Options + + * `:tools` - List of tool schemas (same format as `stub_list_tools/1`) + * `:responses` - Map of tool name to response (optional, defaults to empty) + + ## Examples + + adapter = TestSupport.create_mock_adapter( + tools: [ + %{ + "name" => "search", + "description" => "Search", + "inputSchema" => %{"type" => "object", "properties" => %{}} + } + ], + responses: %{ + "search" => fn args -> "Results: \#{args["query"]}" end + } + ) + + # Use the adapter in tests + functions = Adapter.to_functions(adapter) + + Note: This function requires additional implementation to work without + a real client. For most tests, use `stub_tools/1` instead. + """ + @spec create_mock_adapter(keyword()) :: LangChain.MCP.Adapter.t() + def create_mock_adapter(tools_config) do + tools = Keyword.fetch!(tools_config, :tools) + responses = Keyword.get(tools_config, :responses, %{}) + + # For now, this returns a standard adapter + # In a real implementation, this would create a mock client module + # that doesn't require external stubs + + # Create a mock client module at runtime + mock_client = create_mock_client_module(tools, responses) + + Adapter.new(client: mock_client) + end + + # Private helpers + + # Generates a basic tool schema for a given tool name + defp generate_tool_schema(name) when is_binary(name) do + %{ + "name" => name, + "description" => "Test tool: #{name}", + "inputSchema" => %{ + "type" => "object", + "properties" => %{}, + "additionalProperties" => true + } + } + end + + # Creates a mock client module at runtime + # Note: This is a simplified version for demonstration + # A full implementation would need defmodule with proper callbacks + defp create_mock_client_module(_tools, _responses) do + # This would need to dynamically create a module + # For now, we'll return a placeholder + # In real implementation, you'd use Module.create or similar + LangChain.MCP.TestSupport.MockClient + end + + defmodule MockClient do + @moduledoc false + # Placeholder mock client + # This would be dynamically generated in a full implementation + + def list_tools do + {:ok, []} + end + + def call_tool(_name, _args, _opts) do + {:ok, %{is_error: false, result: %{"content" => [%{"type" => "text", "text" => "Mock"}]}}} + end + end +end diff --git a/lib/langchain_mcp/tool_executor.ex b/lib/langchain_mcp/tool_executor.ex index 495c40d..10ed884 100644 --- a/lib/langchain_mcp/tool_executor.ex +++ b/lib/langchain_mcp/tool_executor.ex @@ -108,13 +108,19 @@ defmodule LangChain.MCP.ToolExecutor do * `{:ok, result}` - Success * `{:error, reason}` - Failure """ - @spec execute_on_client(module(), String.t(), map(), pos_integer(), map()) :: + @spec execute_on_client( + module() | pid() | GenServer.server(), + String.t(), + map(), + pos_integer(), + map() + ) :: {:ok, execution_result()} | {:error, String.t()} def execute_on_client(client, tool_name, args, timeout \\ 30_000, context \\ %{}) do opts = build_call_opts(timeout) - # Call the MCP tool via Anubis.Client - case client.call_tool(tool_name, args, opts) do + # Call the MCP tool via Anubis.Client, dispatching based on client type + case call_tool(client, tool_name, args, opts) do {:ok, response} -> handle_response(response, tool_name, context) @@ -128,6 +134,63 @@ defmodule LangChain.MCP.ToolExecutor do {:error, "Tool execution exception: #{Exception.message(error)}"} end + # Dispatch call_tool based on client type + defp call_tool(client, tool_name, args, opts) when is_atom(client) do + client.call_tool(tool_name, args, opts) + end + + defp call_tool(client, tool_name, args, opts) do + # For PIDs and via tuples, resolve the base client and use Anubis.Client.Base + case resolve_base_client(client) do + {:ok, base_client} -> + Anubis.Client.Base.call_tool(base_client, tool_name, args, opts) + + {:error, reason} -> + {:error, reason} + end + end + + # Resolve the actual Anubis.Client.Base process from a supervisor PID or via tuple + defp resolve_base_client(client) when is_pid(client) do + # Check if this is a supervisor by looking for children + case get_base_client_pid(client) do + {:ok, base_pid} -> {:ok, base_pid} + {:error, _} -> {:ok, client} + end + end + + defp resolve_base_client({:via, _, _} = via_tuple) do + # For via tuples, try to resolve to a PID and then get base client + case GenServer.whereis(via_tuple) do + nil -> {:error, "Process not found for via tuple"} + pid -> resolve_base_client(pid) + end + end + + defp resolve_base_client({:global, _} = global_tuple) do + case GenServer.whereis(global_tuple) do + nil -> {:error, "Process not found for global tuple"} + pid -> resolve_base_client(pid) + end + end + + # Get the Anubis.Client.Base PID from a supervisor + defp get_base_client_pid(supervisor_pid) do + children = Supervisor.which_children(supervisor_pid) + + case Enum.find(children, fn {id, _pid, _type, _modules} -> + id == Anubis.Client.Base + end) do + {_id, base_pid, _type, _modules} when is_pid(base_pid) -> + {:ok, base_pid} + + nil -> + {:error, :not_a_supervisor} + end + catch + :exit, _ -> {:error, :not_a_supervisor} + end + # Handle successful MCP response defp handle_response(%{is_error: false, result: result}, _tool_name, context) do convert_result_to_langchain(result, context) @@ -292,15 +355,19 @@ defmodule LangChain.MCP.ToolExecutor do iex> is_list(tools) true """ - @spec list_tools(module()) :: {:ok, [map()]} | {:error, String.t()} + @spec list_tools(module() | pid() | GenServer.server()) :: {:ok, [map()]} | {:error, String.t()} def list_tools(client) do list_tools_with_retry(client, 3, 50) end - @spec list_tools_with_retry(module(), non_neg_integer(), non_neg_integer()) :: + @spec list_tools_with_retry( + module() | pid() | GenServer.server(), + non_neg_integer(), + non_neg_integer() + ) :: {:ok, [map()]} | {:error, String.t()} defp list_tools_with_retry(client, retries_left, delay_ms) when retries_left > 0 do - case client.list_tools() do + case do_list_tools(client) do {:ok, response} -> tools = response.result["tools"] || [] {:ok, tools} @@ -327,4 +394,20 @@ defmodule LangChain.MCP.ToolExecutor do defp list_tools_with_retry(_client, 0, _delay_ms) do {:error, "Failed to list tools after retries"} end + + # Dispatch list_tools based on client type + defp do_list_tools(client) when is_atom(client) do + client.list_tools() + end + + defp do_list_tools(client) do + # For PIDs and via tuples, resolve the base client and use Anubis.Client.Base + case resolve_base_client(client) do + {:ok, base_client} -> + Anubis.Client.Base.list_tools(base_client) + + {:error, reason} -> + {:error, reason} + end + end end diff --git a/test/langchain_mcp/mcp/adapter_integration_test.exs b/test/langchain_mcp/mcp/adapter_integration_test.exs index f9d522e..22db902 100644 --- a/test/langchain_mcp/mcp/adapter_integration_test.exs +++ b/test/langchain_mcp/mcp/adapter_integration_test.exs @@ -156,4 +156,31 @@ defmodule LangChain.MCP.AdapterIntegrationTest do assert "nonexistent" in missing end end + + describe "adapter with PID client" do + test "to_functions works with PID client", %{client_pid: client_pid} do + # Use the client PID from the MCPCase setup + :ok = Adapter.wait_for_server_ready(client_pid) + + # Create adapter with PID instead of module name + adapter = Adapter.new(client: client_pid) + + # Discover tools - uses ToolExecutor.list_tools which now supports PIDs + {:ok, tools} = Adapter.discover_tools(adapter) + + assert is_list(tools) + assert length(tools) > 0 + + # Convert to LangChain functions + functions = Adapter.to_functions(adapter) + + assert is_list(functions) + assert length(functions) > 0 + + func = hd(functions) + assert %Function{} = func + assert is_binary(func.name) + assert is_function(func.function, 2) + end + end end diff --git a/test/langchain_mcp/mcp/config_test.exs b/test/langchain_mcp/mcp/config_test.exs index dc7d14f..3c9bda1 100644 --- a/test/langchain_mcp/mcp/config_test.exs +++ b/test/langchain_mcp/mcp/config_test.exs @@ -165,4 +165,83 @@ defmodule LangChain.MCP.ConfigTest do assert result == :continue end end + + describe "new!/1 with PID client" do + test "accepts a PID as client" do + # Spawn a dummy process to get a valid PID + pid = spawn(fn -> Process.sleep(:infinity) end) + + config = Config.new!(client: pid) + + assert config.client == pid + + Process.exit(pid, :kill) + end + + test "rejects dead PID" do + pid = spawn(fn -> :ok end) + # Let it die + Process.sleep(10) + + assert_raise ArgumentError, ~r/not alive/, fn -> + Config.new!(client: pid) + end + end + end + + describe "new!/1 with via tuple client" do + test "accepts {:via, Registry, {name, key}}" do + config = Config.new!(client: {:via, Registry, {MyRegistry, "key"}}) + + assert config.client == {:via, Registry, {MyRegistry, "key"}} + end + + test "accepts {:global, name}" do + config = Config.new!(client: {:global, :my_client}) + + assert config.client == {:global, :my_client} + end + end + + describe "new!/1 with PID fallback_client" do + test "accepts a PID as fallback_client" do + primary_pid = spawn(fn -> Process.sleep(:infinity) end) + fallback_pid = spawn(fn -> Process.sleep(:infinity) end) + + config = Config.new!(client: primary_pid, fallback_client: fallback_pid) + + assert config.client == primary_pid + assert config.fallback_client == fallback_pid + + Process.exit(primary_pid, :kill) + Process.exit(fallback_pid, :kill) + end + + test "accepts via tuple as fallback_client" do + primary_pid = spawn(fn -> Process.sleep(:infinity) end) + + config = + Config.new!( + client: primary_pid, + fallback_client: {:via, Registry, {MyRegistry, "fallback"}} + ) + + assert config.fallback_client == {:via, Registry, {MyRegistry, "fallback"}} + + Process.exit(primary_pid, :kill) + end + + test "rejects dead PID as fallback_client" do + primary_pid = spawn(fn -> Process.sleep(:infinity) end) + fallback_pid = spawn(fn -> :ok end) + # Let it die + Process.sleep(10) + + assert_raise ArgumentError, ~r/not alive/, fn -> + Config.new!(client: primary_pid, fallback_client: fallback_pid) + end + + Process.exit(primary_pid, :kill) + end + end end diff --git a/test/langchain_mcp/mcp/tool_executor_test.exs b/test/langchain_mcp/mcp/tool_executor_test.exs index fb0b3e4..3d1f030 100644 --- a/test/langchain_mcp/mcp/tool_executor_test.exs +++ b/test/langchain_mcp/mcp/tool_executor_test.exs @@ -1,7 +1,7 @@ defmodule LangChain.MCP.ToolExecutorTest do use ExUnit.Case, async: true - alias LangChain.MCP.{Config, ToolExecutor} + alias LangChain.MCP.{Adapter, Config, ToolExecutor} defmodule MockClient do @moduledoc "Mock MCP client for testing" @@ -320,4 +320,63 @@ defmodule LangChain.MCP.ToolExecutorTest do assert is_binary(reason) end end + + describe "list_tools/1 with PID" do + @tag :live_call + test "lists tools from a client started by PID" do + # Start a real client, get its PID + {:ok, client_pid} = + LangChainMCP.TestClient.start_link( + transport: {:streamable_http, base_url: "http://localhost:5000"} + ) + + :ok = Adapter.wait_for_server_ready(client_pid) + + {:ok, tools} = ToolExecutor.list_tools(client_pid) + + assert is_list(tools) + # Test server provides get_current_time, get_timestamp, add_numbers + assert Enum.any?(tools, &(&1["name"] == "get_current_time")) + + Supervisor.stop(client_pid) + end + end + + describe "execute_on_client/5 with PID" do + @tag :live_call + test "dispatches tool call via PID to base client" do + {:ok, client_pid} = + LangChainMCP.TestClient.start_link( + transport: {:streamable_http, base_url: "http://localhost:5000"} + ) + + :ok = Adapter.wait_for_server_ready(client_pid) + + # This test verifies the PID dispatch works correctly. + # We're testing that the call is properly routed through Anubis.Client.Base, + # not the server's response (which may vary based on server state). + result = + ToolExecutor.execute_on_client( + client_pid, + "get_timestamp", + %{}, + 5_000, + %{} + ) + + # The call should either succeed or fail with an MCP error (not a dispatch error) + case result do + {:ok, response} -> + # If successful, verify we got a valid response + assert is_binary(response) or is_list(response) + + {:error, msg} -> + # If error, it should be an MCP error, not "attempted to apply a function on PID" + refute String.contains?(msg, "attempted to apply") + refute String.contains?(msg, "Modules (the first argument of apply)") + end + + Supervisor.stop(client_pid) + end + end end