Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion python/packages/foundry/agent_framework_foundry/_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 43 additions & 11 deletions python/packages/foundry/agent_framework_foundry/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
105 changes: 105 additions & 0 deletions python/packages/foundry/tests/foundry/test_foundry_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,12 @@ 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 MCPTool, Tool
from azure.ai.projects.models import CodeInterpreterTool, MCPTool, Tool, WebSearchTool
from azure.core.exceptions import ResourceNotFoundError

with (
Expand All @@ -67,6 +68,9 @@ def create_sample_toolbox(name: str) -> str:
)
]

tools.append(WebSearchTool(name="web_search"))
tools.append(CodeInterpreterTool(name="code_interpreter"))

Comment thread
moonbox3 marked this conversation as resolved.
created = project_client.beta.toolboxes.create_version(
name=name,
description="Toolbox version with MCP require_approval set to 'never'.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
import os
import subprocess
from random import randint
from typing import Annotated

from agent_framework import Agent, tool
from agent_framework.foundry import FoundryChatClient
from agent_framework_foundry_hosting import ResponsesHostServer
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()
Expand Down
Loading