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

@jspv

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

bugSomething isn't workingpythonv1.0Features being tracked for the version 1.0 GA

Type

Projects

Status

Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions