Skip to content

Commit 490e019

Browse files
Python: fix(foundry_hosting): send Foundry-Features header on toolbox requests (microsoft#8722)
FoundryToolbox did not send the Foundry-Features header or incorporate flags provided by FOUNDRY_AGENT_TOOLSET_FEATURES. This caused Python toolbox requests to disagree with the .NET implementation and missing preview feature gating. Inject the mandatory Toolboxes=V1Preview feature flag on outbound toolbox requests and merge any additional flags defined in FOUNDRY_AGENT_TOOLSET_FEATURES. Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Co-authored-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com>
1 parent 16aa03b commit 490e019

2 files changed

Lines changed: 57 additions & 1 deletion

File tree

‎python/packages/foundry_hosting/agent_framework_foundry_hosting/_toolbox.py‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,22 @@
4343
DEFAULT_TOOLBOX_SCOPE = "https://ai.azure.com/.default"
4444
# Default timeout (seconds) for toolbox MCP requests.
4545
_DEFAULT_TIMEOUT = 120.0
46+
# Environment variable used to inject platform-provided toolbox feature flags.
47+
_TOOLSET_FEATURES_ENV_VAR = "FOUNDRY_AGENT_TOOLSET_FEATURES"
48+
# Mandatory preview feature flag for Foundry toolbox requests.
49+
_MANDATORY_TOOLBOX_FEATURE = "Toolboxes=V1Preview"
50+
51+
52+
def _build_toolbox_features_header(additional_features: str | None) -> str:
53+
"""Merge platform-provided features with the mandatory toolbox feature."""
54+
if additional_features is None or not additional_features.strip():
55+
return _MANDATORY_TOOLBOX_FEATURE
56+
if any(
57+
feature.strip().casefold() == _MANDATORY_TOOLBOX_FEATURE.casefold()
58+
for feature in additional_features.split(",")
59+
):
60+
return additional_features
61+
return f"{_MANDATORY_TOOLBOX_FEATURE},{additional_features}"
4662

4763

4864
def _resolve_toolbox_endpoint() -> str:
@@ -100,11 +116,14 @@ class _ToolboxAuth(httpx.Auth):
100116
def __init__(self, credential: AzureCredentialTypes, scope: str) -> None:
101117
self._credential = credential
102118
self._scope = scope
119+
# Feature flags are startup configuration, matching the .NET toolbox service.
120+
self._features_header = _build_toolbox_features_header(os.environ.get(_TOOLSET_FEATURES_ENV_VAR))
103121

104122
def _apply_headers(self, request: httpx.Request, token: AccessToken) -> None:
105123
request.headers["Authorization"] = f"Bearer {token.token}"
106124
for key, value in get_request_context().platform_headers().items():
107125
request.headers[key] = value
126+
request.headers["Foundry-Features"] = self._features_header
108127

109128
def sync_auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
110129
# azure-core credentials cache the token internally and only refresh near
@@ -139,7 +158,8 @@ class FoundryToolbox(MCPStreamableHTTPTool):
139158
``MCPStreamableHTTPTool`` by hand it:
140159
141160
- resolves the toolbox endpoint and tool name from the environment when not given,
142-
- authenticates every request with a bearer token from ``credential``, and
161+
- authenticates every request with a bearer token from ``credential``,
162+
- sends the mandatory toolbox preview feature plus platform-provided feature flags, and
143163
- forwards the platform per-request call-id (``x-agent-foundry-call-id``) so the
144164
Foundry MCP proxy can resolve the caller context server-side.
145165

‎python/packages/foundry_hosting/tests/test_toolbox.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,42 @@ async def test_auth_flow_injects_bearer_token_async_credential() -> None:
176176
assert cred.scopes == ["https://ai.azure.com/.default"]
177177

178178

179+
@pytest.mark.parametrize(
180+
("additional_features", "expected"),
181+
[
182+
(None, "Toolboxes=V1Preview"),
183+
(" ", "Toolboxes=V1Preview"),
184+
("FeatureOne=Enabled,FeatureTwo=Enabled", "Toolboxes=V1Preview,FeatureOne=Enabled,FeatureTwo=Enabled"),
185+
("FeatureOne=Enabled, toolboxes=v1preview ", "FeatureOne=Enabled, toolboxes=v1preview "),
186+
],
187+
)
188+
async def test_auth_flow_injects_foundry_features_header(
189+
monkeypatch: pytest.MonkeyPatch,
190+
additional_features: str | None,
191+
expected: str,
192+
) -> None:
193+
if additional_features is None:
194+
monkeypatch.delenv("FOUNDRY_AGENT_TOOLSET_FEATURES", raising=False)
195+
else:
196+
monkeypatch.setenv("FOUNDRY_AGENT_TOOLSET_FEATURES", additional_features)
197+
auth = _ToolboxAuth(_FakeCredential(), "scope") # type: ignore
198+
request = httpx.Request("POST", "https://h/toolboxes/tb/mcp")
199+
200+
prepared = await anext(auth.async_auth_flow(request))
201+
202+
assert prepared.headers["Foundry-Features"] == expected
203+
204+
205+
def test_sync_auth_flow_injects_foundry_features_header(monkeypatch: pytest.MonkeyPatch) -> None:
206+
monkeypatch.setenv("FOUNDRY_AGENT_TOOLSET_FEATURES", "FeatureOne=Enabled")
207+
auth = _ToolboxAuth(_FakeCredential(), "scope") # type: ignore
208+
request = httpx.Request("POST", "https://h/toolboxes/tb/mcp")
209+
210+
prepared = next(auth.sync_auth_flow(request))
211+
212+
assert prepared.headers["Foundry-Features"] == "Toolboxes=V1Preview,FeatureOne=Enabled"
213+
214+
179215
def test_sync_auth_flow_injects_bearer_token() -> None:
180216
cred = _FakeCredential("sync123")
181217
auth = _ToolboxAuth(cred, "https://ai.azure.com/.default") # type: ignore

0 commit comments

Comments
 (0)