From f1fb2034d05b1f14326c27136285bbd362e93871 Mon Sep 17 00:00:00 2001 From: J Smith Date: Fri, 6 Mar 2026 11:26:18 -0400 Subject: [PATCH 1/3] Add `allowed_tools` and `require_approval` to option transforms --- .../providers/anthropic/transforms.rb | 39 +++- .../providers/open_ai/responses/transforms.rb | 8 + test/providers/anthropic/transforms_test.rb | 190 ++++++++++++++++++ .../open_ai/responses/transforms_test.rb | 157 +++++++++++++++ 4 files changed, 390 insertions(+), 4 deletions(-) diff --git a/lib/active_agent/providers/anthropic/transforms.rb b/lib/active_agent/providers/anthropic/transforms.rb index ff173a8f..9f0ceec2 100644 --- a/lib/active_agent/providers/anthropic/transforms.rb +++ b/lib/active_agent/providers/anthropic/transforms.rb @@ -38,10 +38,18 @@ def normalize_params(params) end # Handle mcps parameter (common format) -> transforms to mcp_servers (provider format) - if params[:mcps] - params[:mcp_servers] = normalize_mcp_servers(params.delete(:mcps)) - elsif params[:mcp_servers] - params[:mcp_servers] = normalize_mcp_servers(params[:mcp_servers]) + if params[:mcps] || params[:mcp_servers] + mcps = if params[:mcps] + params.delete(:mcps) + else + params[:mcp_servers] + end + + params[:mcp_servers] = normalize_mcp_servers(mcps) + + if params[:tools].nil? # If tools not already provided, extract from mcps + params[:tools] = normalize_mcp_tools(mcps) + end end params @@ -111,6 +119,29 @@ def normalize_mcp_servers(mcp_servers) end end + def normalize_mcp_tools(mcp_servers) + return [] unless mcp_servers.is_a?(Array) + + result = mcp_servers.map do |server| + server_hash = server.is_a?(Hash) ? server.deep_symbolize_keys : server + + if server_hash[:allowed_tools].present? + { + type: "mcp_toolset", + mcp_server_name: server_hash[:name], + default_config: { + enabled: false + }, + configs: server_hash[:allowed_tools].to_h { |tool| + [ tool[:name], { enabled: true } ] + } + } + end + end + + result.compact.presence + end + # Normalizes tool_choice from common format to Anthropic gem model objects. # # The Anthropic gem expects tool_choice to be a model object (ToolChoiceAuto, diff --git a/lib/active_agent/providers/open_ai/responses/transforms.rb b/lib/active_agent/providers/open_ai/responses/transforms.rb index fca00a57..0457c18c 100644 --- a/lib/active_agent/providers/open_ai/responses/transforms.rb +++ b/lib/active_agent/providers/open_ai/responses/transforms.rb @@ -96,6 +96,14 @@ def normalize_mcp_servers(mcp_servers) server_url: server_hash[:url] || server_hash[:server_url] } + if server_hash[:require_approval] + result[:require_approval] = server_hash[:require_approval] + end + + if server_hash[:allowed_tools] + result[:allowed_tools] = server_hash[:allowed_tools] + end + # Keep authorization field (OpenAI uses 'authorization', not 'authorization_token') if server_hash[:authorization] result[:authorization] = server_hash[:authorization] diff --git a/test/providers/anthropic/transforms_test.rb b/test/providers/anthropic/transforms_test.rb index 3c223a7b..5a252fb5 100644 --- a/test/providers/anthropic/transforms_test.rb +++ b/test/providers/anthropic/transforms_test.rb @@ -691,6 +691,196 @@ def transforms assert_equal "not an array", result end + + # normalize_mcp_tools tests + test "normalize_mcp_tools converts allowed_tools to mcp_toolset format" do + mcp_servers = [ + { + name: "stripe", + url: "https://mcp.stripe.com", + allowed_tools: [ + { name: "create_payment" }, + { name: "get_payment" } + ] + } + ] + + result = transforms.normalize_mcp_tools(mcp_servers) + + assert_equal 1, result.size + assert_equal "mcp_toolset", result[0][:type] + assert_equal "stripe", result[0][:mcp_server_name] + assert_equal false, result[0][:default_config][:enabled] + assert_equal 2, result[0][:configs].size + assert_equal true, result[0][:configs]["create_payment"][:enabled] + assert_equal true, result[0][:configs]["get_payment"][:enabled] + end + + test "normalize_mcp_tools handles multiple servers with allowed_tools" do + mcp_servers = [ + { + name: "stripe", + url: "https://mcp.stripe.com", + allowed_tools: [ + { name: "create_payment" } + ] + }, + { + name: "github", + url: "https://api.github.com", + allowed_tools: [ + { name: "search_repos" }, + { name: "create_issue" } + ] + } + ] + + result = transforms.normalize_mcp_tools(mcp_servers) + + assert_equal 2, result.size + assert_equal "stripe", result[0][:mcp_server_name] + assert_equal 1, result[0][:configs].size + assert_equal "github", result[1][:mcp_server_name] + assert_equal 2, result[1][:configs].size + end + + test "normalize_mcp_tools skips servers without allowed_tools" do + mcp_servers = [ + { + name: "stripe", + url: "https://mcp.stripe.com", + allowed_tools: [ + { name: "create_payment" } + ] + }, + { + name: "public", + url: "https://public.api.com" + # No allowed_tools + } + ] + + result = transforms.normalize_mcp_tools(mcp_servers) + + assert_equal 1, result.size + assert_equal "stripe", result[0][:mcp_server_name] + end + + test "normalize_mcp_tools handles empty allowed_tools array" do + mcp_servers = [ + { + name: "stripe", + url: "https://mcp.stripe.com", + allowed_tools: [] + } + ] + + result = transforms.normalize_mcp_tools(mcp_servers) + + assert_nil result + end + + test "normalize_mcp_tools returns nil for servers all without allowed_tools" do + mcp_servers = [ + { + name: "public", + url: "https://public.api.com" + } + ] + + result = transforms.normalize_mcp_tools(mcp_servers) + + assert_nil result + end + + test "normalize_mcp_tools returns empty array for nil input" do + result = transforms.normalize_mcp_tools(nil) + + assert_equal [], result + end + + test "normalize_mcp_tools returns empty array for empty array" do + result = transforms.normalize_mcp_tools([]) + + assert_nil result + end + + test "normalize_mcp_tools returns empty array for non-array input" do + result = transforms.normalize_mcp_tools("not an array") + + assert_equal [], result + end + + test "normalize_mcp_tools handles single tool" do + mcp_servers = [ + { + name: "stripe", + url: "https://mcp.stripe.com", + allowed_tools: [ + { name: "create_payment" } + ] + } + ] + + result = transforms.normalize_mcp_tools(mcp_servers) + + assert_equal 1, result.size + assert_equal 1, result[0][:configs].size + assert_equal true, result[0][:configs]["create_payment"][:enabled] + end + + # Integration test for normalize_params with allowed_tools + test "normalize_params extracts mcp_tools from mcps with allowed_tools" do + params = { + mcps: [ + { + name: "stripe", + url: "https://mcp.stripe.com", + authorization: "sk_test_123", + allowed_tools: [ + { name: "create_payment" }, + { name: "get_payment" } + ] + } + ] + } + + result = transforms.normalize_params(params) + + # Should have mcp_servers + assert_equal 1, result[:mcp_servers].size + assert_equal "stripe", result[:mcp_servers][0][:name] + + # Should have extracted tools from allowed_tools + assert result[:tools].present? + assert_equal 1, result[:tools].size + assert_equal "mcp_toolset", result[:tools][0][:type] + assert_equal "stripe", result[:tools][0][:mcp_server_name] + assert_equal 2, result[:tools][0][:configs].size + end + + test "normalize_params does not override existing tools when extracting from mcps" do + params = { + tools: [ + { name: "existing_tool", input_schema: { type: "object" } } + ], + mcps: [ + { + name: "stripe", + url: "https://mcp.stripe.com", + allowed_tools: [ + { name: "create_payment" } + ] + } + ] + } + + result = transforms.normalize_params(params) + + # Should keep existing tools, not override with mcp tools + assert_equal 1, result[:tools].size + assert_equal "existing_tool", result[:tools][0][:name] + end end end end diff --git a/test/providers/open_ai/responses/transforms_test.rb b/test/providers/open_ai/responses/transforms_test.rb index fe3d5df0..31b69e84 100644 --- a/test/providers/open_ai/responses/transforms_test.rb +++ b/test/providers/open_ai/responses/transforms_test.rb @@ -460,6 +460,163 @@ def serializable.serialize assert_equal [], result end + + # require_approval tests + test "normalize_mcp_servers handles require_approval always" do + servers = [ + { name: "stripe", url: "https://mcp.stripe.com", require_approval: "always" } + ] + + result = transforms.normalize_mcp_servers(servers) + + assert_equal 1, result.size + assert_equal "always", result[0][:require_approval] + end + + test "normalize_mcp_servers handles require_approval never" do + servers = [ + { name: "stripe", url: "https://mcp.stripe.com", require_approval: "never" } + ] + + result = transforms.normalize_mcp_servers(servers) + + assert_equal 1, result.size + assert_equal "never", result[0][:require_approval] + end + + test "normalize_mcp_servers handles require_approval with hash" do + servers = [ + { + name: "stripe", + url: "https://mcp.stripe.com", + require_approval: { + always: ["payment_methods"], + never: ["read_operations"] + } + } + ] + + result = transforms.normalize_mcp_servers(servers) + + assert_equal 1, result.size + assert_equal ["payment_methods"], result[0][:require_approval][:always] + assert_equal ["read_operations"], result[0][:require_approval][:never] + end + + test "normalize_mcp_servers without require_approval omits field" do + servers = [ + { name: "stripe", url: "https://mcp.stripe.com" } + ] + + result = transforms.normalize_mcp_servers(servers) + + assert_equal 1, result.size + assert_nil result[0][:require_approval] + end + + # allowed_tools tests + test "normalize_mcp_servers handles allowed_tools array" do + servers = [ + { + name: "stripe", + url: "https://mcp.stripe.com", + allowed_tools: ["create_payment", "get_payment"] + } + ] + + result = transforms.normalize_mcp_servers(servers) + + assert_equal 1, result.size + assert_equal ["create_payment", "get_payment"], result[0][:allowed_tools] + end + + test "normalize_mcp_servers handles empty allowed_tools array" do + servers = [ + { name: "stripe", url: "https://mcp.stripe.com", allowed_tools: [] } + ] + + result = transforms.normalize_mcp_servers(servers) + + assert_equal 1, result.size + assert_equal [], result[0][:allowed_tools] + end + + test "normalize_mcp_servers without allowed_tools omits field" do + servers = [ + { name: "stripe", url: "https://mcp.stripe.com" } + ] + + result = transforms.normalize_mcp_servers(servers) + + assert_equal 1, result.size + assert_nil result[0][:allowed_tools] + end + + # Combined require_approval and allowed_tools tests + test "normalize_mcp_servers handles both require_approval and allowed_tools" do + servers = [ + { + name: "stripe", + url: "https://mcp.stripe.com", + authorization: "sk_test_123", + require_approval: "always", + allowed_tools: ["create_payment", "get_payment"] + } + ] + + result = transforms.normalize_mcp_servers(servers) + + assert_equal 1, result.size + assert_equal "mcp", result[0][:type] + assert_equal "stripe", result[0][:server_label] + assert_equal "https://mcp.stripe.com", result[0][:server_url] + assert_equal "sk_test_123", result[0][:authorization] + assert_equal "always", result[0][:require_approval] + assert_equal ["create_payment", "get_payment"], result[0][:allowed_tools] + end + + test "normalize_mcp_servers handles different require_approval for multiple servers" do + servers = [ + { + name: "stripe", + url: "https://mcp.stripe.com", + require_approval: "always", + allowed_tools: ["create_payment"] + }, + { + name: "github", + url: "https://api.githubcopilot.com/mcp/", + require_approval: "never", + allowed_tools: ["search_repos", "create_issue"] + } + ] + + result = transforms.normalize_mcp_servers(servers) + + assert_equal 2, result.size + assert_equal "always", result[0][:require_approval] + assert_equal ["create_payment"], result[0][:allowed_tools] + assert_equal "never", result[1][:require_approval] + assert_equal ["search_repos", "create_issue"], result[1][:allowed_tools] + end + + test "normalize_mcp_servers preserves require_approval and allowed_tools in already normalized format" do + servers = [ + { + type: "mcp", + server_label: "stripe", + server_url: "https://mcp.stripe.com", + require_approval: "always", + allowed_tools: ["create_payment"] + } + ] + + result = transforms.normalize_mcp_servers(servers) + + assert_equal servers, result + assert_equal "always", result[0][:require_approval] + assert_equal ["create_payment"], result[0][:allowed_tools] + end end end end From 414821c2261b9a2ec6c7e225e565fe479f055b49 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 01:24:18 +0000 Subject: [PATCH 2/3] fix: don't inject nil tools into Anthropic requests when extracting MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The allowed_tools extraction in PR #328 broke 50 integration tests because Request#apply_defaults sets mcp_servers: [] on every request, and an empty array is truthy — so the MCP branch in normalize_params ran unconditionally and assigned params[:tools] = nil (via .presence), which the Anthropic gem then serialized as "tools": null on every request body. - Only assign params[:tools] when toolsets were actually extracted - Support allowed_tools as tool-name strings/symbols as well as {name: ...} hashes, ignoring entries in any other format - Make normalize_mcp_tools consistently return nil when nothing is extracted, and align test names with that behavior - Fix Layout/SpaceInsideArrayLiteralBrackets rubocop offenses Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BgneVvQLw5Uc9jQxRHz6Zj --- .../providers/anthropic/transforms.rb | 55 +++++++++----- test/providers/anthropic/transforms_test.rb | 75 +++++++++++++++++-- .../open_ai/responses/transforms_test.rb | 28 +++---- 3 files changed, 121 insertions(+), 37 deletions(-) diff --git a/lib/active_agent/providers/anthropic/transforms.rb b/lib/active_agent/providers/anthropic/transforms.rb index 9f0ceec2..1900cacc 100644 --- a/lib/active_agent/providers/anthropic/transforms.rb +++ b/lib/active_agent/providers/anthropic/transforms.rb @@ -48,7 +48,8 @@ def normalize_params(params) params[:mcp_servers] = normalize_mcp_servers(mcps) if params[:tools].nil? # If tools not already provided, extract from mcps - params[:tools] = normalize_mcp_tools(mcps) + mcp_tools = normalize_mcp_tools(mcps) + params[:tools] = mcp_tools if mcp_tools.present? end end @@ -119,27 +120,47 @@ def normalize_mcp_servers(mcp_servers) end end + # Builds Anthropic mcp_toolset tool entries from MCP server allowed_tools. + # + # Accepts allowed_tools entries as tool-name strings/symbols or hashes + # with a :name key; entries in any other format are ignored. + # + # @param mcp_servers [Array] + # @return [Array, nil] toolset entries, or nil when none were extracted def normalize_mcp_tools(mcp_servers) - return [] unless mcp_servers.is_a?(Array) + return nil unless mcp_servers.is_a?(Array) - result = mcp_servers.map do |server| - server_hash = server.is_a?(Hash) ? server.deep_symbolize_keys : server + result = mcp_servers.filter_map do |server| + next unless server.is_a?(Hash) - if server_hash[:allowed_tools].present? - { - type: "mcp_toolset", - mcp_server_name: server_hash[:name], - default_config: { - enabled: false - }, - configs: server_hash[:allowed_tools].to_h { |tool| - [ tool[:name], { enabled: true } ] - } - } - end + server_hash = server.deep_symbolize_keys + allowed_tools = server_hash[:allowed_tools] + next unless allowed_tools.is_a?(Array) + + configs = allowed_tools.filter_map { |tool| + name = case tool + when String, Symbol + tool.to_s + when Hash + (tool[:name] || tool["name"]).to_s + end + + [ name, { enabled: true } ] if name.present? + }.to_h + + next if configs.empty? + + { + type: "mcp_toolset", + mcp_server_name: server_hash[:name], + default_config: { + enabled: false + }, + configs: configs + } end - result.compact.presence + result.presence end # Normalizes tool_choice from common format to Anthropic gem model objects. diff --git a/test/providers/anthropic/transforms_test.rb b/test/providers/anthropic/transforms_test.rb index 5a252fb5..a39b51da 100644 --- a/test/providers/anthropic/transforms_test.rb +++ b/test/providers/anthropic/transforms_test.rb @@ -766,7 +766,44 @@ def transforms assert_equal "stripe", result[0][:mcp_server_name] end - test "normalize_mcp_tools handles empty allowed_tools array" do + test "normalize_mcp_tools handles allowed_tools as array of strings" do + mcp_servers = [ + { + name: "stripe", + url: "https://mcp.stripe.com", + allowed_tools: [ "create_payment", "get_payment" ] + } + ] + + result = transforms.normalize_mcp_tools(mcp_servers) + + assert_equal 1, result.size + assert_equal "mcp_toolset", result[0][:type] + assert_equal "stripe", result[0][:mcp_server_name] + assert_equal 2, result[0][:configs].size + assert_equal true, result[0][:configs]["create_payment"][:enabled] + assert_equal true, result[0][:configs]["get_payment"][:enabled] + end + + test "normalize_mcp_tools handles mixed string and hash allowed_tools" do + mcp_servers = [ + { + name: "stripe", + url: "https://mcp.stripe.com", + allowed_tools: [ "create_payment", { name: "get_payment" }, :list_payments, 42 ] + } + ] + + result = transforms.normalize_mcp_tools(mcp_servers) + + assert_equal 1, result.size + assert_equal 3, result[0][:configs].size + assert_equal true, result[0][:configs]["create_payment"][:enabled] + assert_equal true, result[0][:configs]["get_payment"][:enabled] + assert_equal true, result[0][:configs]["list_payments"][:enabled] + end + + test "normalize_mcp_tools returns nil for empty allowed_tools array" do mcp_servers = [ { name: "stripe", @@ -793,22 +830,22 @@ def transforms assert_nil result end - test "normalize_mcp_tools returns empty array for nil input" do + test "normalize_mcp_tools returns nil for nil input" do result = transforms.normalize_mcp_tools(nil) - assert_equal [], result + assert_nil result end - test "normalize_mcp_tools returns empty array for empty array" do + test "normalize_mcp_tools returns nil for empty array" do result = transforms.normalize_mcp_tools([]) assert_nil result end - test "normalize_mcp_tools returns empty array for non-array input" do + test "normalize_mcp_tools returns nil for non-array input" do result = transforms.normalize_mcp_tools("not an array") - assert_equal [], result + assert_nil result end test "normalize_mcp_tools handles single tool" do @@ -859,6 +896,32 @@ def transforms assert_equal 2, result[:tools][0][:configs].size end + test "normalize_params does not set tools key when mcps have no allowed_tools" do + params = { + mcps: [ + { + name: "stripe", + url: "https://mcp.stripe.com" + } + ] + } + + result = transforms.normalize_params(params) + + assert_equal 1, result[:mcp_servers].size + assert_not result.key?(:tools) + end + + test "normalize_params does not set tools key for empty mcp_servers default" do + params = { + mcp_servers: [] + } + + result = transforms.normalize_params(params) + + assert_not result.key?(:tools) + end + test "normalize_params does not override existing tools when extracting from mcps" do params = { tools: [ diff --git a/test/providers/open_ai/responses/transforms_test.rb b/test/providers/open_ai/responses/transforms_test.rb index 31b69e84..486bddfa 100644 --- a/test/providers/open_ai/responses/transforms_test.rb +++ b/test/providers/open_ai/responses/transforms_test.rb @@ -490,8 +490,8 @@ def serializable.serialize name: "stripe", url: "https://mcp.stripe.com", require_approval: { - always: ["payment_methods"], - never: ["read_operations"] + always: [ "payment_methods" ], + never: [ "read_operations" ] } } ] @@ -499,8 +499,8 @@ def serializable.serialize result = transforms.normalize_mcp_servers(servers) assert_equal 1, result.size - assert_equal ["payment_methods"], result[0][:require_approval][:always] - assert_equal ["read_operations"], result[0][:require_approval][:never] + assert_equal [ "payment_methods" ], result[0][:require_approval][:always] + assert_equal [ "read_operations" ], result[0][:require_approval][:never] end test "normalize_mcp_servers without require_approval omits field" do @@ -520,14 +520,14 @@ def serializable.serialize { name: "stripe", url: "https://mcp.stripe.com", - allowed_tools: ["create_payment", "get_payment"] + allowed_tools: [ "create_payment", "get_payment" ] } ] result = transforms.normalize_mcp_servers(servers) assert_equal 1, result.size - assert_equal ["create_payment", "get_payment"], result[0][:allowed_tools] + assert_equal [ "create_payment", "get_payment" ], result[0][:allowed_tools] end test "normalize_mcp_servers handles empty allowed_tools array" do @@ -560,7 +560,7 @@ def serializable.serialize url: "https://mcp.stripe.com", authorization: "sk_test_123", require_approval: "always", - allowed_tools: ["create_payment", "get_payment"] + allowed_tools: [ "create_payment", "get_payment" ] } ] @@ -572,7 +572,7 @@ def serializable.serialize assert_equal "https://mcp.stripe.com", result[0][:server_url] assert_equal "sk_test_123", result[0][:authorization] assert_equal "always", result[0][:require_approval] - assert_equal ["create_payment", "get_payment"], result[0][:allowed_tools] + assert_equal [ "create_payment", "get_payment" ], result[0][:allowed_tools] end test "normalize_mcp_servers handles different require_approval for multiple servers" do @@ -581,13 +581,13 @@ def serializable.serialize name: "stripe", url: "https://mcp.stripe.com", require_approval: "always", - allowed_tools: ["create_payment"] + allowed_tools: [ "create_payment" ] }, { name: "github", url: "https://api.githubcopilot.com/mcp/", require_approval: "never", - allowed_tools: ["search_repos", "create_issue"] + allowed_tools: [ "search_repos", "create_issue" ] } ] @@ -595,9 +595,9 @@ def serializable.serialize assert_equal 2, result.size assert_equal "always", result[0][:require_approval] - assert_equal ["create_payment"], result[0][:allowed_tools] + assert_equal [ "create_payment" ], result[0][:allowed_tools] assert_equal "never", result[1][:require_approval] - assert_equal ["search_repos", "create_issue"], result[1][:allowed_tools] + assert_equal [ "search_repos", "create_issue" ], result[1][:allowed_tools] end test "normalize_mcp_servers preserves require_approval and allowed_tools in already normalized format" do @@ -607,7 +607,7 @@ def serializable.serialize server_label: "stripe", server_url: "https://mcp.stripe.com", require_approval: "always", - allowed_tools: ["create_payment"] + allowed_tools: [ "create_payment" ] } ] @@ -615,7 +615,7 @@ def serializable.serialize assert_equal servers, result assert_equal "always", result[0][:require_approval] - assert_equal ["create_payment"], result[0][:allowed_tools] + assert_equal [ "create_payment" ], result[0][:allowed_tools] end end end From aee2f01319060a76a0d8c94ccb9111d2db7ee726 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 01:05:36 +0000 Subject: [PATCH 3/3] Add an MCP server's toolset beside the request's own tools With function tools already declared, the Anthropic transform dropped a server's allowed_tools rather than send them, so the restriction was silently lost exactly when a request used both. The mcp_toolset entry is now appended to the request's tools. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KfFzc97pARf86bj8LNR59B --- CHANGELOG.md | 5 +++++ lib/active_agent/providers/anthropic/transforms.rb | 9 +++++---- test/providers/anthropic/transforms_test.rb | 9 ++++++--- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39ef2642..cebc3440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The dashboard's object lists hold their metric columns in place: a trace, interaction or evaluation run with nothing in a column prints a dash there rather than sliding its neighbours over (`MetaStrip`). +- MCP servers take `allowed_tools` and `require_approval` in the common + format. OpenAI's Responses API receives both as given; Anthropic receives + `allowed_tools` as an `mcp_toolset` entry in `tools` (every other tool of + the server disabled), beside any tools the request already declares + (#328, by @dark-panda). ### Changed diff --git a/lib/active_agent/providers/anthropic/transforms.rb b/lib/active_agent/providers/anthropic/transforms.rb index 1900cacc..32d026b4 100644 --- a/lib/active_agent/providers/anthropic/transforms.rb +++ b/lib/active_agent/providers/anthropic/transforms.rb @@ -47,10 +47,11 @@ def normalize_params(params) params[:mcp_servers] = normalize_mcp_servers(mcps) - if params[:tools].nil? # If tools not already provided, extract from mcps - mcp_tools = normalize_mcp_tools(mcps) - params[:tools] = mcp_tools if mcp_tools.present? - end + # A server's allowed_tools become an mcp_toolset entry beside the + # request's own tools. Added only when there is one: an empty + # list would send `"tools": null` on every MCP request. + mcp_tools = normalize_mcp_tools(mcps) + params[:tools] = Array(params[:tools]) + mcp_tools if mcp_tools.present? end params diff --git a/test/providers/anthropic/transforms_test.rb b/test/providers/anthropic/transforms_test.rb index a39b51da..5f694221 100644 --- a/test/providers/anthropic/transforms_test.rb +++ b/test/providers/anthropic/transforms_test.rb @@ -922,7 +922,7 @@ def transforms assert_not result.key?(:tools) end - test "normalize_params does not override existing tools when extracting from mcps" do + test "normalize_params keeps existing tools and adds the mcp toolset beside them" do params = { tools: [ { name: "existing_tool", input_schema: { type: "object" } } @@ -940,9 +940,12 @@ def transforms result = transforms.normalize_params(params) - # Should keep existing tools, not override with mcp tools - assert_equal 1, result[:tools].size + # The request's own tools stay, and the server's allowed_tools still + # restrict what it exposes rather than being dropped. + assert_equal 2, result[:tools].size assert_equal "existing_tool", result[:tools][0][:name] + assert_equal "mcp_toolset", result[:tools][1][:type] + assert_equal({ "create_payment" => { enabled: true } }, result[:tools][1][:configs]) end end end