Skip to content

Commit fffd0ac

Browse files
authored
Python: fix(foundry): reconcile toolbox hosted-tool payloads with Responses API (#5414)
* fix(foundry): reconcile toolbox hosted-tool payloads with Responses API * docs(foundry): update create_sample_toolbox docstring to reflect all tools created
1 parent ea3320d commit fffd0ac

5 files changed

Lines changed: 167 additions & 16 deletions

File tree

python/packages/foundry/agent_framework_foundry/_chat_client.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,8 +455,18 @@ def get_mcp_tool(
455455
456456
Returns:
457457
An MCPTool configuration ready to pass to an Agent.
458+
459+
Raises:
460+
ValueError: If neither ``url`` nor ``project_connection_id`` is supplied
461+
— one is required by the Foundry Responses API.
458462
"""
459-
mcp = FoundryMCPTool(server_label=name.replace(" ", "_"), server_url=url or "", **kwargs)
463+
if not url and not project_connection_id:
464+
raise ValueError("MCP tool requires either 'url' or 'project_connection_id' to be specified.")
465+
466+
mcp_kwargs: dict[str, Any] = {"server_label": name.replace(" ", "_"), **kwargs}
467+
if url:
468+
mcp_kwargs["server_url"] = url
469+
mcp = FoundryMCPTool(**mcp_kwargs)
460470

461471
if description:
462472
mcp["server_description"] = description

python/packages/foundry/agent_framework_foundry/_tools.py

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -133,26 +133,55 @@ def select_toolbox_tools(
133133
return selected
134134

135135

136+
def _validate_hosted_tool_payload(sanitized: Mapping[str, Any]) -> None:
137+
"""Fail fast on hosted tool payloads that would always be rejected by the Responses API.
138+
139+
These mismatches are not injectable defaults — the caller must supply the
140+
missing information — so surfacing a clear error here points at the toolbox
141+
definition instead of letting the API return a generic 400.
142+
"""
143+
tool_type = sanitized.get("type")
144+
if tool_type == "file_search" and not sanitized.get("vector_store_ids"):
145+
raise ValueError(
146+
"'file_search' tool is missing required 'vector_store_ids'. "
147+
"If this came from a Foundry toolbox, update the toolbox definition "
148+
"to include at least one vector store ID."
149+
)
150+
if tool_type == "mcp" and not sanitized.get("server_url") and not sanitized.get("project_connection_id"):
151+
raise ValueError(
152+
"'mcp' tool is missing both 'server_url' and 'project_connection_id'. "
153+
"If this came from a Foundry toolbox, update the toolbox definition "
154+
"to include one of these."
155+
)
156+
157+
136158
@experimental(feature_id=ExperimentalFeature.TOOLBOXES)
137159
def sanitize_foundry_response_tool(tool_item: Any) -> Any:
138160
"""Return a Responses-API-safe tool payload for Foundry hosted tools.
139161
140-
Azure AI Projects toolbox reads can currently return hosted tool objects with
141-
extra read-model decoration fields such as top-level ``name`` and
142-
``description``. Azure AI Foundry rejects at least ``name`` on Responses API
143-
requests with:
144-
145-
``Unknown parameter: 'tools[0].name'``.
146-
147-
We defensively strip these decoration fields for non-function hosted tools so
148-
the round-trip
149-
``toolbox.tools -> Agent(..., tools=...) -> run()`` works, while the Azure
150-
SDK/service behavior is corrected upstream.
162+
Reconciles known mismatches between toolbox reads and the Responses API:
163+
164+
1. Toolbox reads can return hosted tool objects decorated with read-model
165+
fields such as top-level ``name`` and ``description``. The Responses API
166+
rejects at least ``name`` with ``Unknown parameter: 'tools[0].name'``.
167+
These fields are stripped from non-function hosted tool payloads.
168+
2. ``code_interpreter`` tools stored in a toolbox without a ``container``
169+
field (the Azure SDK treats it as optional) are rejected by the Responses
170+
API with ``Missing required parameter: 'tools[N].container'``. A default
171+
``{"type": "auto"}`` container is injected when absent.
172+
3. Hosted tools that are structurally incomplete in ways that cannot be
173+
defaulted (``file_search`` without ``vector_store_ids``, ``mcp`` without
174+
either ``server_url`` or ``project_connection_id``) raise ``ValueError``
175+
with a message that points at the toolbox definition.
176+
177+
These are workarounds until the toolbox/Responses proxy normalizes payloads
178+
server-side.
151179
"""
152180
if isinstance(tool_item, FoundryMCPTool):
153181
sanitized: dict[str, Any] = dict(cast("Mapping[str, Any]", tool_item))
154182
sanitized.pop("name", None)
155183
sanitized.pop("description", None)
184+
_validate_hosted_tool_payload(sanitized)
156185
return sanitized
157186

158187
if isinstance(tool_item, Mapping):
@@ -161,6 +190,9 @@ def sanitize_foundry_response_tool(tool_item: Any) -> Any:
161190
sanitized = dict(mapping)
162191
sanitized.pop("name", None)
163192
sanitized.pop("description", None)
193+
if sanitized.get("type") == "code_interpreter" and "container" not in sanitized:
194+
sanitized["container"] = {"type": "auto"}
195+
_validate_hosted_tool_payload(sanitized)
164196
return sanitized
165197

166198
return cast(Any, tool_item)

python/packages/foundry/tests/foundry/test_foundry_chat_client.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,14 @@ def test_get_mcp_tool_with_project_connection_id() -> None:
607607
assert tool_config["project_connection_id"] == "conn-123"
608608
assert tool_config["allowed_tools"] == ["search_docs"]
609609
assert tool_config["server_label"] == "Docs_MCP"
610+
# ``server_url`` should not be fabricated when only a project connection is supplied.
611+
assert "server_url" not in tool_config
612+
613+
614+
def test_get_mcp_tool_requires_url_or_project_connection_id() -> None:
615+
"""Missing both ``url`` and ``project_connection_id`` is always invalid."""
616+
with pytest.raises(ValueError, match="url.*project_connection_id"):
617+
FoundryChatClient.get_mcp_tool(name="x")
610618

611619

612620
def test_prepare_tools_for_openai_strips_extraneous_name_from_foundry_mcp_tool() -> None:
@@ -655,6 +663,103 @@ def test_prepare_tools_for_openai_strips_read_model_fields_from_toolbox_code_int
655663
assert "description" not in prepared
656664

657665

666+
def test_prepare_tools_for_openai_injects_default_container_for_code_interpreter_dict() -> None:
667+
"""Toolbox-returned code_interpreter without a container must get a default injected.
668+
669+
The Azure SDK treats ``container`` as optional, but the Responses API rejects
670+
``code_interpreter`` entries without one. The sanitizer backfills ``{"type": "auto"}``.
671+
"""
672+
project_client = MagicMock()
673+
project_client.get_openai_client.return_value = _make_mock_openai_client()
674+
client = FoundryChatClient(project_client=project_client, model="test-model")
675+
676+
tool = {
677+
"type": "code_interpreter",
678+
"name": "code_interpreter_t6bbtm",
679+
}
680+
681+
response_tools = client._prepare_tools_for_openai([tool])
682+
683+
assert len(response_tools) == 1
684+
prepared = response_tools[0]
685+
assert prepared["type"] == "code_interpreter"
686+
assert prepared["container"] == {"type": "auto"}
687+
assert "name" not in prepared
688+
689+
690+
def test_prepare_tools_for_openai_injects_default_container_for_code_interpreter_sdk_instance() -> None:
691+
"""SDK ``CodeInterpreterTool`` instances without a container must also be backfilled.
692+
693+
Reproduces the toolbox creation path that calls
694+
``CodeInterpreterTool(name="code_interpreter")`` without a container.
695+
"""
696+
from azure.ai.projects.models import CodeInterpreterTool
697+
698+
project_client = MagicMock()
699+
project_client.get_openai_client.return_value = _make_mock_openai_client()
700+
client = FoundryChatClient(project_client=project_client, model="test-model")
701+
702+
response_tools = client._prepare_tools_for_openai([CodeInterpreterTool(name="code_interpreter")])
703+
704+
assert len(response_tools) == 1
705+
prepared = response_tools[0]
706+
assert prepared["type"] == "code_interpreter"
707+
assert prepared["container"] == {"type": "auto"}
708+
assert "name" not in prepared
709+
710+
711+
def test_prepare_tools_for_openai_preserves_existing_code_interpreter_container() -> None:
712+
"""An already-populated container must not be overwritten by the sanitizer."""
713+
project_client = MagicMock()
714+
project_client.get_openai_client.return_value = _make_mock_openai_client()
715+
client = FoundryChatClient(project_client=project_client, model="test-model")
716+
717+
explicit_container = {"file_ids": ["file_123"], "type": "auto"}
718+
tool = {"type": "code_interpreter", "container": explicit_container}
719+
720+
response_tools = client._prepare_tools_for_openai([tool])
721+
722+
assert response_tools[0]["container"] == explicit_container
723+
724+
725+
def test_prepare_tools_for_openai_rejects_file_search_without_vector_store_ids() -> None:
726+
"""``file_search`` without ``vector_store_ids`` is always invalid — surface a clear error."""
727+
project_client = MagicMock()
728+
project_client.get_openai_client.return_value = _make_mock_openai_client()
729+
client = FoundryChatClient(project_client=project_client, model="test-model")
730+
731+
with pytest.raises(ValueError, match="vector_store_ids"):
732+
client._prepare_tools_for_openai([{"type": "file_search", "name": "fs"}])
733+
734+
735+
def test_prepare_tools_for_openai_rejects_mcp_without_server_destination() -> None:
736+
"""``mcp`` with neither ``server_url`` nor ``project_connection_id`` is always invalid."""
737+
project_client = MagicMock()
738+
project_client.get_openai_client.return_value = _make_mock_openai_client()
739+
client = FoundryChatClient(project_client=project_client, model="test-model")
740+
741+
tool = FoundryMCPTool(server_label="orphan")
742+
743+
with pytest.raises(ValueError, match="server_url.*project_connection_id"):
744+
client._prepare_tools_for_openai([tool])
745+
746+
747+
def test_prepare_tools_for_openai_accepts_mcp_with_only_project_connection_id() -> None:
748+
"""MCP tools backed by a Foundry connection (no ``server_url``) must still pass validation."""
749+
project_client = MagicMock()
750+
project_client.get_openai_client.return_value = _make_mock_openai_client()
751+
client = FoundryChatClient(project_client=project_client, model="test-model")
752+
753+
tool = FoundryMCPTool(server_label="githubmcp")
754+
tool["project_connection_id"] = "githubmcp"
755+
756+
response_tools = client._prepare_tools_for_openai([tool])
757+
758+
assert len(response_tools) == 1
759+
assert response_tools[0]["project_connection_id"] == "githubmcp"
760+
assert "server_url" not in response_tools[0]
761+
762+
658763
def test_prepare_tools_for_openai_strips_name_from_non_function_hosted_tool_dicts() -> None:
659764
"""All non-function hosted tool payloads should drop top-level read-model names."""
660765
project_client = MagicMock()

python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,12 @@ def create_sample_toolbox(name: str) -> str:
4242
Toolboxes are normally configured in the Foundry portal or a deployment
4343
script, not the application itself. This helper exists so the samples can
4444
be run end-to-end without first setting a toolbox up by hand — delete any
45-
existing toolbox under ``name``, then create a fresh version containing a
46-
single MCP tool. Returns the created version identifier.
45+
existing toolbox under ``name``, then create a fresh version containing an
46+
MCP tool, a web search tool, and a code interpreter tool. Returns the
47+
created version identifier.
4748
"""
4849
from azure.ai.projects import AIProjectClient
49-
from azure.ai.projects.models import MCPTool, Tool
50+
from azure.ai.projects.models import CodeInterpreterTool, MCPTool, Tool, WebSearchTool
5051
from azure.core.exceptions import ResourceNotFoundError
5152

5253
with (
@@ -67,6 +68,9 @@ def create_sample_toolbox(name: str) -> str:
6768
)
6869
]
6970

71+
tools.append(WebSearchTool(name="web_search"))
72+
tools.append(CodeInterpreterTool(name="code_interpreter"))
73+
7074
created = project_client.beta.toolboxes.create_version(
7175
name=name,
7276
description="Toolbox version with MCP require_approval set to 'never'.",

python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
import os
44
import subprocess
55
from random import randint
6+
from typing import Annotated
67

78
from agent_framework import Agent, tool
89
from agent_framework.foundry import FoundryChatClient
910
from agent_framework_foundry_hosting import ResponsesHostServer
1011
from azure.identity import AzureCliCredential
1112
from dotenv import load_dotenv
1213
from pydantic import Field
13-
from typing import Annotated
1414

1515
# Load environment variables from .env file
1616
load_dotenv()

0 commit comments

Comments
 (0)