-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Description
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 theself.mcp_toolsexpansion when the runtimetoolskwarg
already containsFunctionToolinstances whose names match those inmcp_server.functions. - Or fix the dedup in
_merge_optionsto fire even whenresult["tools"]is an empty list:
changeresult.get("tools")toresult.get("tools") is not None.
Versions
agent-framework-core: 1.0.0rc2agent-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
Type
Projects
Status