From 6439049e2b8788ff94907f6ea706c30cc85c580f Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 22 Apr 2026 09:31:06 +0900 Subject: [PATCH 1/2] fix(foundry): reconcile toolbox hosted-tool payloads with Responses API --- .../agent_framework_foundry/_chat_client.py | 12 +- .../foundry/agent_framework_foundry/_tools.py | 54 +++++++-- .../tests/foundry/test_foundry_chat_client.py | 105 ++++++++++++++++++ .../foundry_chat_client_with_toolbox.py | 5 +- .../responses/02_local_tools/main.py | 2 +- 5 files changed, 164 insertions(+), 14 deletions(-) diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 7c9eb3a68c..735ba9fb57 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -455,8 +455,18 @@ def get_mcp_tool( Returns: An MCPTool configuration ready to pass to an Agent. + + Raises: + ValueError: If neither ``url`` nor ``project_connection_id`` is supplied + — one is required by the Foundry Responses API. """ - mcp = FoundryMCPTool(server_label=name.replace(" ", "_"), server_url=url or "", **kwargs) + if not url and not project_connection_id: + raise ValueError("MCP tool requires either 'url' or 'project_connection_id' to be specified.") + + mcp_kwargs: dict[str, Any] = {"server_label": name.replace(" ", "_"), **kwargs} + if url: + mcp_kwargs["server_url"] = url + mcp = FoundryMCPTool(**mcp_kwargs) if description: mcp["server_description"] = description diff --git a/python/packages/foundry/agent_framework_foundry/_tools.py b/python/packages/foundry/agent_framework_foundry/_tools.py index 3c22872e18..4c5956fcfe 100644 --- a/python/packages/foundry/agent_framework_foundry/_tools.py +++ b/python/packages/foundry/agent_framework_foundry/_tools.py @@ -133,26 +133,55 @@ def select_toolbox_tools( return selected +def _validate_hosted_tool_payload(sanitized: Mapping[str, Any]) -> None: + """Fail fast on hosted tool payloads that would always be rejected by the Responses API. + + These mismatches are not injectable defaults — the caller must supply the + missing information — so surfacing a clear error here points at the toolbox + definition instead of letting the API return a generic 400. + """ + tool_type = sanitized.get("type") + if tool_type == "file_search" and not sanitized.get("vector_store_ids"): + raise ValueError( + "'file_search' tool is missing required 'vector_store_ids'. " + "If this came from a Foundry toolbox, update the toolbox definition " + "to include at least one vector store ID." + ) + if tool_type == "mcp" and not sanitized.get("server_url") and not sanitized.get("project_connection_id"): + raise ValueError( + "'mcp' tool is missing both 'server_url' and 'project_connection_id'. " + "If this came from a Foundry toolbox, update the toolbox definition " + "to include one of these." + ) + + @experimental(feature_id=ExperimentalFeature.TOOLBOXES) def sanitize_foundry_response_tool(tool_item: Any) -> Any: """Return a Responses-API-safe tool payload for Foundry hosted tools. - Azure AI Projects toolbox reads can currently return hosted tool objects with - extra read-model decoration fields such as top-level ``name`` and - ``description``. Azure AI Foundry rejects at least ``name`` on Responses API - requests with: - - ``Unknown parameter: 'tools[0].name'``. - - We defensively strip these decoration fields for non-function hosted tools so - the round-trip - ``toolbox.tools -> Agent(..., tools=...) -> run()`` works, while the Azure - SDK/service behavior is corrected upstream. + Reconciles known mismatches between toolbox reads and the Responses API: + + 1. Toolbox reads can return hosted tool objects decorated with read-model + fields such as top-level ``name`` and ``description``. The Responses API + rejects at least ``name`` with ``Unknown parameter: 'tools[0].name'``. + These fields are stripped from non-function hosted tool payloads. + 2. ``code_interpreter`` tools stored in a toolbox without a ``container`` + field (the Azure SDK treats it as optional) are rejected by the Responses + API with ``Missing required parameter: 'tools[N].container'``. A default + ``{"type": "auto"}`` container is injected when absent. + 3. Hosted tools that are structurally incomplete in ways that cannot be + defaulted (``file_search`` without ``vector_store_ids``, ``mcp`` without + either ``server_url`` or ``project_connection_id``) raise ``ValueError`` + with a message that points at the toolbox definition. + + These are workarounds until the toolbox/Responses proxy normalizes payloads + server-side. """ if isinstance(tool_item, FoundryMCPTool): sanitized: dict[str, Any] = dict(cast("Mapping[str, Any]", tool_item)) sanitized.pop("name", None) sanitized.pop("description", None) + _validate_hosted_tool_payload(sanitized) return sanitized if isinstance(tool_item, Mapping): @@ -161,6 +190,9 @@ def sanitize_foundry_response_tool(tool_item: Any) -> Any: sanitized = dict(mapping) sanitized.pop("name", None) sanitized.pop("description", None) + if sanitized.get("type") == "code_interpreter" and "container" not in sanitized: + sanitized["container"] = {"type": "auto"} + _validate_hosted_tool_payload(sanitized) return sanitized return cast(Any, tool_item) diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index a7c5beb822..68e7adc6fb 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -607,6 +607,14 @@ def test_get_mcp_tool_with_project_connection_id() -> None: assert tool_config["project_connection_id"] == "conn-123" assert tool_config["allowed_tools"] == ["search_docs"] assert tool_config["server_label"] == "Docs_MCP" + # ``server_url`` should not be fabricated when only a project connection is supplied. + assert "server_url" not in tool_config + + +def test_get_mcp_tool_requires_url_or_project_connection_id() -> None: + """Missing both ``url`` and ``project_connection_id`` is always invalid.""" + with pytest.raises(ValueError, match="url.*project_connection_id"): + FoundryChatClient.get_mcp_tool(name="x") 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 assert "description" not in prepared +def test_prepare_tools_for_openai_injects_default_container_for_code_interpreter_dict() -> None: + """Toolbox-returned code_interpreter without a container must get a default injected. + + The Azure SDK treats ``container`` as optional, but the Responses API rejects + ``code_interpreter`` entries without one. The sanitizer backfills ``{"type": "auto"}``. + """ + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + + tool = { + "type": "code_interpreter", + "name": "code_interpreter_t6bbtm", + } + + response_tools = client._prepare_tools_for_openai([tool]) + + assert len(response_tools) == 1 + prepared = response_tools[0] + assert prepared["type"] == "code_interpreter" + assert prepared["container"] == {"type": "auto"} + assert "name" not in prepared + + +def test_prepare_tools_for_openai_injects_default_container_for_code_interpreter_sdk_instance() -> None: + """SDK ``CodeInterpreterTool`` instances without a container must also be backfilled. + + Reproduces the toolbox creation path that calls + ``CodeInterpreterTool(name="code_interpreter")`` without a container. + """ + from azure.ai.projects.models import CodeInterpreterTool + + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + + response_tools = client._prepare_tools_for_openai([CodeInterpreterTool(name="code_interpreter")]) + + assert len(response_tools) == 1 + prepared = response_tools[0] + assert prepared["type"] == "code_interpreter" + assert prepared["container"] == {"type": "auto"} + assert "name" not in prepared + + +def test_prepare_tools_for_openai_preserves_existing_code_interpreter_container() -> None: + """An already-populated container must not be overwritten by the sanitizer.""" + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + + explicit_container = {"file_ids": ["file_123"], "type": "auto"} + tool = {"type": "code_interpreter", "container": explicit_container} + + response_tools = client._prepare_tools_for_openai([tool]) + + assert response_tools[0]["container"] == explicit_container + + +def test_prepare_tools_for_openai_rejects_file_search_without_vector_store_ids() -> None: + """``file_search`` without ``vector_store_ids`` is always invalid — surface a clear error.""" + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + + with pytest.raises(ValueError, match="vector_store_ids"): + client._prepare_tools_for_openai([{"type": "file_search", "name": "fs"}]) + + +def test_prepare_tools_for_openai_rejects_mcp_without_server_destination() -> None: + """``mcp`` with neither ``server_url`` nor ``project_connection_id`` is always invalid.""" + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + + tool = FoundryMCPTool(server_label="orphan") + + with pytest.raises(ValueError, match="server_url.*project_connection_id"): + client._prepare_tools_for_openai([tool]) + + +def test_prepare_tools_for_openai_accepts_mcp_with_only_project_connection_id() -> None: + """MCP tools backed by a Foundry connection (no ``server_url``) must still pass validation.""" + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + + tool = FoundryMCPTool(server_label="githubmcp") + tool["project_connection_id"] = "githubmcp" + + response_tools = client._prepare_tools_for_openai([tool]) + + assert len(response_tools) == 1 + assert response_tools[0]["project_connection_id"] == "githubmcp" + assert "server_url" not in response_tools[0] + + def test_prepare_tools_for_openai_strips_name_from_non_function_hosted_tool_dicts() -> None: """All non-function hosted tool payloads should drop top-level read-model names.""" project_client = MagicMock() diff --git a/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py index 8a532331ae..66d5806c6d 100644 --- a/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py +++ b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py @@ -46,7 +46,7 @@ def create_sample_toolbox(name: str) -> str: single MCP tool. Returns the created version identifier. """ from azure.ai.projects import AIProjectClient - from azure.ai.projects.models import MCPTool, Tool + from azure.ai.projects.models import CodeInterpreterTool, MCPTool, Tool, WebSearchTool from azure.core.exceptions import ResourceNotFoundError with ( @@ -67,6 +67,9 @@ def create_sample_toolbox(name: str) -> str: ) ] + tools.append(WebSearchTool(name="web_search")) + tools.append(CodeInterpreterTool(name="code_interpreter")) + created = project_client.beta.toolboxes.create_version( name=name, description="Toolbox version with MCP require_approval set to 'never'.", diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/main.py index 7cba9b821e..02433bb3ca 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/main.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/main.py @@ -3,6 +3,7 @@ import os import subprocess from random import randint +from typing import Annotated from agent_framework import Agent, tool from agent_framework.foundry import FoundryChatClient @@ -10,7 +11,6 @@ from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import Field -from typing import Annotated # Load environment variables from .env file load_dotenv() From 405a35a888dcdf2242e4936f92b0a9d4494867aa Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 22 Apr 2026 09:40:09 +0900 Subject: [PATCH 2/2] docs(foundry): update create_sample_toolbox docstring to reflect all tools created --- .../providers/foundry/foundry_chat_client_with_toolbox.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py index 66d5806c6d..2d85c12a4b 100644 --- a/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py +++ b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py @@ -42,8 +42,9 @@ def create_sample_toolbox(name: str) -> str: Toolboxes are normally configured in the Foundry portal or a deployment script, not the application itself. This helper exists so the samples can be run end-to-end without first setting a toolbox up by hand — delete any - existing toolbox under ``name``, then create a fresh version containing a - single MCP tool. Returns the created version identifier. + existing toolbox under ``name``, then create a fresh version containing an + MCP tool, a web search tool, and a code interpreter tool. Returns the + created version identifier. """ from azure.ai.projects import AIProjectClient from azure.ai.projects.models import CodeInterpreterTool, MCPTool, Tool, WebSearchTool