Skip to content

Python: [Bug]: AgentFrameworkAgent + MCPStdioTool: MCP functions duplicated on second turn when client tools are present leading to "tools array too long" (>128) when you have 64 tools instead of 128 #4381

Description

@jspv

Description

Description

When AgentFrameworkAgent (from agent-framework-ag-ui) is used with an Agent that has an
MCPStdioTool, and the AG-UI request includes client-side tools, the MCP tool functions are
expanded twice on the second and subsequent turns — once by collect_server_tools in
agent_framework_ag_ui._orchestration._tooling (which pre-expands mcp_server.functions now
that the server is connected) and again unconditionally inside _prepare_run_context in
_agents.py — producing a tool list with every MCP tool name duplicated. With a 64-tool MCP
server, even a single client-side tool pushes the total to 129 on the second turn, triggering a
400 from the OpenAI API.

The bug is invisible on turn 1 because collect_server_tools checks mcp_tool.is_connected
before expanding functions. On the first run the MCP server has not yet connected, so
server_tools = [], merge_tools returns None, no tools= kwarg is passed to agent.run(),
and the expansion happens only once inside _prepare_run_context. On turn 2 the server is
connected, collect_server_tools returns the full function list, merge_tools returns a
non-None merged list, that list is passed as tools= to agent.run(), and
_prepare_run_context expands self.mcp_tools a second time — duplicating every MCP function.

Root Cause

_agent_run.py calls collect_server_tools(agent) which, when the MCP server is already
connected, pre-expands mcp_server.functions into FunctionTool objects. These are merged with
client tools via merge_tools and passed as the tools= kwarg to agent.run(). Inside
_prepare_run_context (_agents.py ~line 1037–1040), the code also iterates
self.mcp_tools and calls final_tools.extend(mcp_server.functions) unconditionally —
regardless of whether those functions are already present in the runtime tools kwarg.

The deduplication in _merge_options (_agents.py ~line 98–102) does not protect against this
because it only fires when result.get("tools") is truthy. Since default_options["tools"] is
agent_tools = [] at construction time, chat_options["tools"] is set to [] (falsy), so the
dedup branch is never reached and the doubled list is used as-is.

Minimal Reproduction

minimal_mcp_server.py

from mcp.server.fastmcp import FastMCP
app = FastMCP("test")

@app.tool()
def tool_a() -> str:
    return "a"

@app.tool()
def tool_b() -> str:
    return "b"

if __name__ == "__main__":
    app.run(transport="stdio")

repro.py

  import asyncio
  from unittest.mock import patch

  from agent_framework import Agent, MCPStdioTool
  from agent_framework.openai import OpenAIChatClient
  from agent_framework.openai._chat_client import RawOpenAIChatClient
  from agent_framework.ag_ui import AgentFrameworkAgent

  call_count = 0

  original_prepare = RawOpenAIChatClient._prepare_options

  def capturing_prepare(self, messages, options):
      global call_count
      call_count += 1
      names = [getattr(t, "name", "?") for t in options.get("tools", [])]
      print(f"[API call #{call_count}] {len(names)} tools: {names}")
      raise ValueError("captured")

  tool = MCPStdioTool(name="test_mcp", command="python", args=["minimal_mcp_server.py"])
  agent = Agent(OpenAIChatClient(model_id="gpt-4o", api_key="sk-fake"), tools=[tool])
  agui_agent = AgentFrameworkAgent(agent=agent, name="test")

  # Note: client tool uses top-level "name" key, as expected by
  # convert_agui_tools_to_agent_framework
  input_data = {
      "messages": [{"role": "user", "content": "hello"}],
      "tools": [{
          "name": "client_tool",
          "description": "A client-side tool",
          "parameters": {"type": "object", "properties": {}},
      }],
  }

  async def main():
      with patch.object(RawOpenAIChatClient, "_prepare_options", capturing_prepare):
          # Turn 1 — MCP not yet connected, collect_server_tools returns []
          try:
              async for _ in agui_agent.run(input_data):
                  pass
          except Exception:
              pass

          # Turn 2 — same agent reused, MCP now connected
          try:
              async for _ in agui_agent.run(input_data):
                  pass
          except Exception:
              pass

  asyncio.run(main())

Output

  [API call #1] 3 tools: ['client_tool', 'tool_a', 'tool_b']
  [API call #2] 5 tools: ['tool_a', 'tool_b', 'client_tool', 'tool_a', 'tool_b']

Turn 1 succeeds because the MCP server is not yet connected when collect_server_tools runs,
so server_tools = [], merge_tools returns None, no tools= kwarg is passed to
agent.run(), and the expansion happens only once. Turn 2 duplicates every MCP tool. With a
real 64-tool MCP server and one client-side tool, turn 2 sends 129 tools and produces:

400 Bad Request: Invalid 'tools': array too long. Expected an array with maximum length 128

Key Files

File Issue
agent_framework_ag_ui/_orchestration/_tooling.py collect_server_tools pre-expands mcp_server.functions into FunctionTool objects when server is connected
agent_framework/_agents.py ~line 1037 _prepare_run_context unconditionally expands self.mcp_tools regardless of whether those functions are already in the runtime tools kwarg
agent_framework/_agents.py ~line 98 Dedup in _merge_options is bypassed when chat_options["tools"] == [] (falsy)

Suggested Fix

Either:

  • In _prepare_run_context, skip the self.mcp_tools expansion when the runtime tools kwarg
    already contains FunctionTool instances whose names match those in mcp_server.functions.
  • Or fix the dedup in _merge_options to fire even when result["tools"] is an empty list:
    change result.get("tools") to result.get("tools") is not None.

Versions

  • agent-framework-core: 1.0.0rc2
  • agent-framework-ag-ui: 1.0.0b260225
  • Python 3.12.12

Code Sample

Error Messages / Stack Traces

Package Versions

agent-framework-core: 1.0.0rc2, agent-framework-ag-ui: 1.0.0b260225

Python Version

3.12

Additional Context

No response

Metadata

Metadata

Assignees

Labels

bugUsage: [Issues], Target: all issues (Legacy, prefer issue type: bug)pythonUsage: [Issues, PRs], Target: Python

Type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions