Compare commits

...

10 Commits

Author SHA1 Message Date
Tao Chen 8f33e88dfc correct fail fast exceptino wording 2026-06-30 09:05:47 -07:00
Tao Chen 3ad2cc630f Remove custom http client 2026-06-30 09:02:40 -07:00
Tao Chen 64efaa6bb4 Revert "Remove custom http client"
This reverts commit 60f1d5aa52.
2026-06-30 08:55:00 -07:00
Tao Chen 60f1d5aa52 Remove custom http client 2026-06-30 08:13:55 -07:00
Tao Chen 099a03f92a Update foundry-hosting package version 2026-06-29 22:01:50 -07:00
Tao Chen 7c1057eb52 Add httpx lower and upper bound 2026-06-29 22:00:41 -07:00
Tao Chen 4beb541c1e Fix formatting 2026-06-29 21:52:35 -07:00
Tao Chen 3276929fb8 Add toolbox skills integration 2026-06-29 15:48:25 -07:00
Tao Chen e5f0e87cd0 Scope checkpoints and approval storage by user id 2026-06-29 15:02:42 -07:00
Tao Chen 85a2adf80b Upgrade to FHA protocol v2 + toolbox integration 2026-06-29 13:50:22 -07:00
13 changed files with 5254 additions and 4659 deletions
@@ -168,7 +168,6 @@ from ._middleware import (
chat_middleware,
function_middleware,
)
from ._sessions import (
AgentSession,
ContextProvider,
@@ -3267,7 +3267,7 @@ class SecureMCPToolProxy:
if url is not None:
from httpx import AsyncClient, Timeout
from ._mcp import MCPStreamableHTTPTool, MCP_DEFAULT_TIMEOUT, MCP_DEFAULT_SSE_READ_TIMEOUT
from ._mcp import MCP_DEFAULT_SSE_READ_TIMEOUT, MCP_DEFAULT_TIMEOUT, MCPStreamableHTTPTool
static_headers = dict(headers or {})
# Pass headers via an AsyncClient so they are included on ALL requests
@@ -3275,11 +3275,15 @@ class SecureMCPToolProxy:
# header_provider alone only sets headers via a ContextVar that is
# populated during call_tool() and would be empty during initialization,
# causing 401s that silently manifest as anyio cancel-scope errors.
http_client = AsyncClient(
headers=static_headers,
follow_redirects=True,
timeout=Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT),
) if static_headers else None
http_client = (
AsyncClient(
headers=static_headers,
follow_redirects=True,
timeout=Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT),
)
if static_headers
else None
)
mcp_tool = MCPStreamableHTTPTool(
name=name or "mcp",
url=url,
@@ -4,10 +4,11 @@ import importlib.metadata
from ._invocations import InvocationsHostServer
from ._responses import ResponsesHostServer
from ._toolbox import FoundryToolbox
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = ["InvocationsHostServer", "ResponsesHostServer"]
__all__ = ["FoundryToolbox", "InvocationsHostServer", "ResponsesHostServer"]
@@ -13,7 +13,7 @@ from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Se
from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress
from dataclasses import asdict, dataclass, is_dataclass
from pathlib import Path
from typing import Protocol, cast
from typing import Literal, Protocol, cast
from agent_framework import (
ChatOptions,
@@ -214,44 +214,71 @@ class FileBasedFunctionApprovalStorage:
return await asyncio.to_thread(self._load_sync, approval_request_id)
def _checkpoint_storage_for_context(root: str, context_id: str) -> FileCheckpointStorage:
def _validate_path_segment(segment: str, *, kind: Literal["context id", "user id"]) -> None:
"""Validate that ``segment`` is a single safe path component (CWE-22).
``segment`` originates from caller-controlled fields (such as
``previous_response_id``), server-generated fields (``conversation_id`` /
``response_id``), or the platform-injected per-user partition key
(``x-agent-user-id``). In every case it must be treated as an untrusted
single path segment: path separators, drive letters, parent references and
similar would otherwise let the resulting directory escape the configured
storage root.
We deliberately do not URL-decode the value here: the hosting layer never
decodes these ids before joining them, so forms such as ``%2e%2e`` are
accepted as literal directory names. Do NOT add decoding here without
re-validating after the decode -- decode-then-join is exactly the pattern
that reintroduces traversal. We also do not attempt to "sanitize" by
stripping characters because that can introduce collisions between distinct
ids.
"""
if not isinstance(segment, str) or not segment:
raise RuntimeError(f"Invalid {kind}: must be a non-empty string.")
# Reject any value that is not a single safe path component. This covers
# POSIX/Windows separators, NUL bytes, drive letters, and all-dot segments
# (``.``, ``..``, ``...``, ...).
if (
"/" in segment
or "\\" in segment
or "\x00" in segment
# All-dot segments (``.``, ``..``, ``...``, ...) reduce to "" after stripping dots.
or segment.strip(".") == ""
or os.path.isabs(segment)
or os.path.splitdrive(segment)[0]
):
raise RuntimeError(f"Invalid {kind}: {segment!r}")
def _checkpoint_storage_for_context(root: str, context_id: str, *, user_id: str | None = None) -> FileCheckpointStorage:
"""Build a ``FileCheckpointStorage`` for ``context_id`` rooted under ``root``.
``context_id`` originates from caller-controlled fields such as
``previous_response_id`` or from server-generated fields such as
``conversation_id`` / ``response_id``. In every case it must be treated as
an untrusted single path segment: path separators, drive letters, parent
references and similar would otherwise let the resulting directory escape
the configured checkpoint root (CWE-22). The check resolves the joined
path and verifies it stays under the resolved root before any directory is
created on disk.
"""
if not isinstance(context_id, str) or not context_id:
raise RuntimeError("Invalid checkpoint context id: must be a non-empty string.")
# Reject any segment that is not a single safe path component. This covers
# POSIX/Windows separators, NUL bytes, drive letters, and all-dot segments
# (``.``, ``..``, ``...``, ...). We deliberately do not URL-decode the id
# here: the hosting layer never decodes context ids before joining them, so
# forms such as ``%2e%2e`` are accepted as literal directory names. Do NOT
# add decoding here without re-validating after the decode -- decode-then-
# join is exactly the pattern that reintroduces traversal. We also do not
# attempt to "sanitize" by stripping characters because that can introduce
# collisions between distinct ids.
if (
"/" in context_id
or "\\" in context_id
or "\x00" in context_id
# All-dot segments (``.``, ``..``, ``...``, ...) reduce to "" after stripping dots.
or context_id.strip(".") == ""
or os.path.isabs(context_id)
or os.path.splitdrive(context_id)[0]
):
raise RuntimeError(f"Invalid checkpoint context id: {context_id!r}")
When the platform supplies a per-user partition key (``user_id``, from the
``x-agent-user-id`` header on container protocol v2), the per-conversation
checkpoint directory is nested under it: ``<root>/<user_id>/<context_id>``.
This isolates each tenant's workflow state so one user can never restore or
observe another user's checkpoint, even with a guessed or forged
``context_id``. An absent (``None``) or empty ``user_id`` -- local
development or protocol v1 -- falls back to the unscoped
``<root>/<context_id>`` layout.
root_path = Path(root).resolve()
storage_path = (root_path / context_id).resolve()
if not storage_path.is_relative_to(root_path):
raise RuntimeError(f"Invalid checkpoint context id: {context_id!r}")
Both ``context_id`` and ``user_id`` are validated as single safe path
segments, and each resolved directory is verified to stay under its parent
before any directory is created on disk (CWE-22).
"""
_validate_path_segment(context_id, kind="context id")
base_path = Path(root).resolve()
if user_id:
_validate_path_segment(user_id, kind="user id")
user_path = (base_path / user_id).resolve()
if not user_path.is_relative_to(base_path):
raise RuntimeError(f"Invalid user id: {user_id!r}")
base_path = user_path
storage_path = (base_path / context_id).resolve()
if not storage_path.is_relative_to(base_path):
raise RuntimeError(f"Invalid context id: {context_id!r}")
return FileCheckpointStorage(
storage_path,
# Keep this provider-specific allowlist narrow. Hosted workflow
@@ -260,6 +287,25 @@ def _checkpoint_storage_for_context(root: str, context_id: str) -> FileCheckpoin
)
def _approval_storage_path_for_user(base_path: str, user_id: str) -> str:
"""Return the per-user approval storage file path under the base directory.
Inserts the validated ``user_id`` as a directory segment between the base
directory and the file name (``<dir>/<user_id>/<file>``), mirroring the
per-user checkpoint partitioning so one tenant can never read another
tenant's saved approval requests. The user id is validated as a single safe
path segment and the resulting directory is verified to stay under the base
directory before use (CWE-22).
"""
_validate_path_segment(user_id, kind="user id")
directory, filename = os.path.split(base_path)
base_dir = Path(directory or ".").resolve()
user_dir = (base_dir / user_id).resolve()
if not user_dir.is_relative_to(base_dir):
raise RuntimeError(f"Invalid user id: {user_id!r}")
return str(user_dir / filename)
# endregion Approval Storage
# Foundry Toolbox Auth integration
@@ -406,6 +452,14 @@ class ResponsesHostServer(ResponsesAgentServerHost):
if self.config.is_hosted
else InMemoryFunctionApprovalStorage()
)
# Per-user (multi-tenant) approval stores. Hosted file-based approval
# storage is partitioned by the platform per-user partition key so one
# tenant can never read another tenant's saved approval requests.
# Instances are cached so concurrent requests for the same user share one
# lock, preserving serialized read-modify-write on the JSON file. Local
# (in-memory) dev and protocol v1 (no user id) keep the single shared
# ``self._approval_storage``.
self._approval_storages_by_user: dict[str, ApprovalStorage] = {}
# Lazy agent lifecycle: the agent (and any MCP tools it owns) is entered on
# the first request rather than at server startup, so that authentication
# failures during MCP connect can be surfaced to the client as an
@@ -443,6 +497,29 @@ class ResponsesHostServer(ResponsesAgentServerHost):
self._agent_stack = None
await stack.aclose()
def _approval_storage_for_user(self, user_id: str | None) -> ApprovalStorage:
"""Return the approval storage scoped to ``user_id`` when applicable.
For hosted multi-tenant deployments the file-based store is partitioned
by the platform per-user partition key, so one tenant can never read
another tenant's saved approval requests. Falls back to the single shared
store for local (in-memory) hosting or when no per-user partition key is
available (protocol v1 / local development). Instances are cached so
concurrent requests for the same user share one lock.
Raises:
RuntimeError: If ``user_id`` is not a safe single path segment.
"""
if not self.config.is_hosted or not user_id:
return self._approval_storage
storage = self._approval_storages_by_user.get(user_id)
if storage is None:
storage = FileBasedFunctionApprovalStorage(
_approval_storage_path_for_user(self.FUNCTION_APPROVAL_STORAGE_PATH, user_id)
)
self._approval_storages_by_user[user_id] = storage
return storage
async def _handle_response(
self,
request: CreateResponse,
@@ -450,6 +527,14 @@ class ResponsesHostServer(ResponsesAgentServerHost):
cancellation_signal: asyncio.Event,
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
"""Handle the creation of a response."""
# Fail fast if the service is on protocol v1.0.0
if self.config.is_hosted and context.platform_context.call_id is None:
raise RuntimeError(
"The hosted environment is running on protocol 1.0.0, but the agent requires protocol 2.0.0. "
"Please upgrade your agent protocol to 2.0.0 in `agent.manifest.yaml` or `agent.yaml`, or "
"downgrade the `agent-framework-foundry-hosting` package to `1.0.0a260625` or before to use 1.0.0."
)
if self._is_workflow_agent:
# Workflow agents are handled differently because they require checkpoint restoration
return self._handle_inner_workflow(request, context)
@@ -470,13 +555,15 @@ class ResponsesHostServer(ResponsesAgentServerHost):
tracker: _OutputItemTracker | None = None
try:
user_id = context.platform_context.user_id_key
approval_storage = self._approval_storage_for_user(user_id)
input_items = await context.get_input_items()
input_messages = await _items_to_messages(input_items, approval_storage=self._approval_storage)
input_messages = await _items_to_messages(input_items, approval_storage=approval_storage)
history = await context.get_history()
run_kwargs: dict[str, Any] = {
"messages": [
*(await _output_items_to_messages(history, approval_storage=self._approval_storage)),
*(await _output_items_to_messages(history, approval_storage=approval_storage)),
*input_messages,
]
}
@@ -522,7 +609,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
async for item in _to_outputs_for_messages(
response_event_stream,
response.messages,
approval_storage=self._approval_storage,
approval_storage=approval_storage,
):
yield item
else:
@@ -537,7 +624,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
async for item in _to_outputs(
response_event_stream,
content,
approval_storage=self._approval_storage,
approval_storage=approval_storage,
):
yield item
tracker.needs_async = False
@@ -566,8 +653,10 @@ class ResponsesHostServer(ResponsesAgentServerHost):
tracker: _OutputItemTracker | None = None
try:
user_id = context.platform_context.user_id_key
approval_storage = self._approval_storage_for_user(user_id)
input_items = await context.get_input_items()
input_messages = await _items_to_messages(input_items, approval_storage=self._approval_storage)
input_messages = await _items_to_messages(input_items, approval_storage=approval_storage)
is_streaming_request = request.stream is not None and request.stream is True
_, are_options_set = _to_chat_options(request)
@@ -590,6 +679,15 @@ class ResponsesHostServer(ResponsesAgentServerHost):
# any future async resources owned by the workflow are entered here.
await self._ensure_agent_ready()
# Per-user checkpoint isolation for multi-tenant hosting (container
# protocol v2): the per-user partition key computed above
# (``x-agent-user-id``) scopes every checkpoint directory for this turn,
# so one tenant can never restore or observe another tenant's workflow
# state -- even with a guessed or forged context id. The key is stable
# per user across turns, so multi-turn continuity is preserved. Absent
# (``None``)/empty in local development or protocol v1, where the
# unscoped single-tenant layout is used.
# Determine the latest checkpoint (if any) so we can resume the
# workflow's prior state for this turn. The directory is keyed by
# the inbound context id (conversation_id when set, otherwise
@@ -603,7 +701,9 @@ class ResponsesHostServer(ResponsesAgentServerHost):
latest_checkpoint_id: str | None = None
restore_storage: FileCheckpointStorage | None = None
if context_id is not None:
restore_storage = _checkpoint_storage_for_context(self._checkpoint_storage_path, context_id)
restore_storage = _checkpoint_storage_for_context(
self._checkpoint_storage_path, context_id, user_id=user_id
)
latest_checkpoint = await restore_storage.get_latest(workflow_name=self._agent.workflow.name)
if latest_checkpoint is not None:
latest_checkpoint_id = latest_checkpoint.checkpoint_id
@@ -617,7 +717,9 @@ class ResponsesHostServer(ResponsesAgentServerHost):
# supplied, restore_storage points at the *prior* response's
# directory and write_storage points at the *current* response's.
write_context_id = context.conversation_id or context.response_id
write_storage = _checkpoint_storage_for_context(self._checkpoint_storage_path, write_context_id)
write_storage = _checkpoint_storage_for_context(
self._checkpoint_storage_path, write_context_id, user_id=user_id
)
# Multi-turn pattern: when we have a prior checkpoint, restore it
# first (drive the workflow back to idle with prior state intact),
@@ -661,7 +763,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
async for item in _to_outputs_for_messages(
response_event_stream,
response.messages,
approval_storage=self._approval_storage,
approval_storage=approval_storage,
):
yield item
@@ -682,7 +784,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
yield event
if tracker.needs_async:
async for item in _to_outputs(
response_event_stream, content, approval_storage=self._approval_storage
response_event_stream, content, approval_storage=approval_storage
):
yield item
tracker.needs_async = False
@@ -0,0 +1,257 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import logging
import os
from typing import TYPE_CHECKING
from urllib.parse import urlsplit
import httpx
from agent_framework import MCPSkillsSource, MCPStreamableHTTPTool, SkillsProvider, SkillsSource
from azure.ai.agentserver.core import get_request_context
if TYPE_CHECKING:
from collections.abc import Generator
from agent_framework import Skill
from azure.core.credentials import TokenCredential
logger = logging.getLogger(__name__)
# Default Microsoft Entra scope for Foundry data-plane access.
DEFAULT_TOOLBOX_SCOPE = "https://ai.azure.com/.default"
# Default timeout (seconds) for toolbox MCP requests.
_DEFAULT_TIMEOUT = 120.0
def _resolve_toolbox_endpoint() -> str:
"""Resolve the toolbox MCP endpoint URL from the environment.
Prefers the explicit ``TOOLBOX_ENDPOINT`` env var; falls back to building the
URL from ``FOUNDRY_PROJECT_ENDPOINT`` and ``TOOLBOX_NAME``.
"""
endpoint = os.environ.get("TOOLBOX_ENDPOINT")
if endpoint is not None:
if not endpoint:
raise ValueError("TOOLBOX_ENDPOINT is set but empty.")
return endpoint
project_endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT")
toolbox_name = os.environ.get("TOOLBOX_NAME")
if not project_endpoint or not toolbox_name:
raise ValueError(
"Pass 'url', or set TOOLBOX_ENDPOINT, or set both FOUNDRY_PROJECT_ENDPOINT "
"and TOOLBOX_NAME to build the toolbox MCP endpoint."
)
return f"{project_endpoint.rstrip('/')}/toolboxes/{toolbox_name}/mcp?api-version=v1"
def _toolbox_name_from_endpoint(endpoint: str) -> str:
"""Extract the toolbox name from a toolbox MCP endpoint URL.
Handles both the versioned (``.../toolboxes/<name>/versions/<n>/mcp``) and
unversioned (``.../toolboxes/<name>/mcp``) endpoint shapes that Foundry
produces. Falls back to ``"toolbox"`` when the path has no ``toolboxes`` segment.
"""
segments = urlsplit(endpoint).path.split("/")
if "toolboxes" in segments:
idx = segments.index("toolboxes")
if idx + 1 < len(segments) and segments[idx + 1]:
return segments[idx + 1]
return "toolbox"
class _ToolboxAuth(httpx.Auth):
"""Injects a fresh bearer token and the platform call-id on every request.
``auth_flow`` runs for *every* outbound request (connection handshake as well
as tool calls), so the bearer token is always present. The per-request
``x-agent-foundry-call-id`` is read from the request-scoped context populated
by the hosting endpoint; it resolves to a fresh value on each request and is
absent (no header) for protocol ``1.0.0`` or local development.
"""
def __init__(self, credential: TokenCredential, scope: str) -> None:
self._credential = credential
self._scope = scope
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
# azure-core credentials cache the token internally and only refresh near
# expiry, so calling get_token per request is cheap.
token = self._credential.get_token(self._scope).token
request.headers["Authorization"] = f"Bearer {token}"
for key, value in get_request_context().platform_headers().items():
request.headers[key] = value
yield request
class FoundryToolbox(MCPStreamableHTTPTool):
"""A Foundry toolbox exposed as an MCP tool, with hosting wired in.
This is a thin convenience wrapper over :class:`~agent_framework.MCPStreamableHTTPTool`
that targets a Microsoft Foundry toolbox endpoint. Compared to constructing an
``MCPStreamableHTTPTool`` by hand it:
- resolves the toolbox endpoint and tool name from the environment when not given,
- authenticates every request with a bearer token from ``credential``, and
- forwards the platform per-request call-id (``x-agent-foundry-call-id``) so the
Foundry MCP proxy can resolve the caller context server-side.
The call-id forwarding is transparent: it is read from the request-scoped context
the hosting endpoint binds on each request, so no per-request wiring is needed.
Because the toolbox endpoint is a first-party Foundry service, forwarding the
opaque caller token to it is safe.
Like any MCP tool, the connection lifecycle is driven by the agent: the hosting
server enters the agent, which connects the toolbox on first use and closes it
(and the HTTP client it owns) at shutdown. Using it as an ``async with`` context
manager directly is supported but not required.
Examples:
.. code-block:: python
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
from agent_framework_foundry_hosting import FoundryToolbox, ResponsesHostServer
from azure.identity import DefaultAzureCredential
credential = DefaultAzureCredential()
# The hosting server enters the agent, which connects/closes the toolbox.
toolbox = FoundryToolbox(credential)
agent = Agent(
client=FoundryChatClient(credential=credential),
tools=toolbox,
default_options={"store": False},
)
await ResponsesHostServer(agent).run_async()
"""
def __init__(
self,
credential: TokenCredential,
*,
url: str | None = None,
name: str | None = None,
token_scope: str = DEFAULT_TOOLBOX_SCOPE,
load_prompts: bool = False,
load_tools: bool = True,
timeout: float = _DEFAULT_TIMEOUT,
) -> None:
"""Initialize a Foundry toolbox tool.
Args:
credential: A Microsoft Entra credential used to obtain bearer tokens for
the toolbox endpoint. Tokens are requested per outbound request and
cached by the credential.
Keyword Args:
url: The toolbox MCP endpoint URL. When ``None``, it is resolved from
``TOOLBOX_ENDPOINT`` or from ``FOUNDRY_PROJECT_ENDPOINT`` plus
``TOOLBOX_NAME``.
name: The local tool name. When ``None``, it is taken from ``TOOLBOX_NAME``
or derived from the endpoint path.
token_scope: The token scope to request. Defaults to the Foundry data-plane
scope.
load_prompts: Whether to load prompts from the toolbox. Defaults to ``False``
because toolboxes expose tools.
load_tools: Whether to load tools from the toolbox. Defaults to ``True``.
timeout: Request timeout in seconds for the underlying HTTP client.
"""
endpoint = url or _resolve_toolbox_endpoint()
tool_name = name or os.environ.get("TOOLBOX_NAME") or _toolbox_name_from_endpoint(endpoint)
http_client = httpx.AsyncClient(
auth=_ToolboxAuth(credential, token_scope),
timeout=timeout,
)
super().__init__(
name=tool_name,
url=endpoint,
http_client=http_client,
load_prompts=load_prompts,
load_tools=load_tools,
)
async def close(self) -> None:
"""Close the MCP session and the toolbox-owned HTTP client."""
try:
await super().close()
finally:
client = self._httpx_client
if client is not None:
self._httpx_client = None
await client.aclose()
def as_skills_provider(
self,
*,
source_id: str | None = None,
instruction_template: str | None = None,
disable_caching: bool = False,
) -> SkillsProvider:
"""Return a :class:`~agent_framework.SkillsProvider` backed by this toolbox.
A Foundry toolbox can serve Agent Skills (SEP-2640) over MCP. This discovers
them from the well-known ``skill://index.json`` resource on the toolbox's MCP
session and exposes them through a provider you can pass to an agent via
``context_providers=[...]``.
The toolbox must be **connected** before its skills are discovered (which
happens lazily on the first agent run). Connect it by passing the toolbox to
the agent via ``tools=`` -- set ``load_tools=False`` if you want skills only
and no tools -- or by entering it as an ``async with`` context manager.
Keyword Args:
source_id: Unique identifier for the provider instance.
instruction_template: Custom system-prompt template for advertising
skills; see :class:`~agent_framework.SkillsProvider`.
disable_caching: Re-query the toolbox on every agent run instead of
caching after the first discovery.
Returns:
A :class:`~agent_framework.SkillsProvider` that advertises and loads the
toolbox's skills.
Examples:
.. code-block:: python
toolbox = FoundryToolbox(credential, load_tools=False)
agent = Agent(
client=FoundryChatClient(credential=credential),
# ``tools=toolbox`` connects the MCP session; ``load_tools=False``
# keeps its tools hidden so only its skills are surfaced.
tools=toolbox,
context_providers=[toolbox.as_skills_provider()],
default_options={"store": False},
)
await ResponsesHostServer(agent).run_async()
"""
return SkillsProvider(
_FoundryToolboxSkillsSource(self),
source_id=source_id,
instruction_template=instruction_template,
disable_caching=disable_caching,
)
class _FoundryToolboxSkillsSource(SkillsSource):
"""Discovers skills from a connected :class:`FoundryToolbox` MCP session.
The toolbox's MCP ``session`` is established lazily when the toolbox connects
(via the agent or an ``async with`` block), so the session is resolved at
discovery time rather than captured at construction.
"""
def __init__(self, toolbox: FoundryToolbox) -> None:
self._toolbox = toolbox
async def get_skills(self) -> list[Skill]:
session = self._toolbox.session
if session is None:
raise RuntimeError(
"FoundryToolbox is not connected, so its skills cannot be discovered. "
"Pass the toolbox to the agent (tools=...) or enter it as an async "
"context manager before the agent runs."
)
return await MCPSkillsSource(client=session).get_skills()
@@ -4,7 +4,7 @@ description = "Foundry Hosting integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260625"
version = "1.0.0a260630"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -24,9 +24,10 @@ classifiers = [
]
dependencies = [
"agent-framework-core>=1.10.0,<2",
"azure-ai-agentserver-core>=2.0.0b3,<3",
"azure-ai-agentserver-responses>=1.0.0b7,<2",
"azure-ai-agentserver-invocations>=1.0.0b3,<2",
"azure-ai-agentserver-core>=2.0.0b7,<3",
"azure-ai-agentserver-responses>=1.0.0b8,<2",
"azure-ai-agentserver-invocations>=1.0.0b6,<2",
"httpx>=0.28,<1",
"mcp>=1.24.0,<2",
]
@@ -2942,7 +2942,7 @@ class TestCheckpointContextPathValidation:
"""
@staticmethod
def _helper() -> Callable[[str, str], FileCheckpointStorage]:
def _helper() -> Callable[..., FileCheckpointStorage]:
from agent_framework_foundry_hosting._responses import ( # pyright: ignore[reportPrivateUsage]
_checkpoint_storage_for_context,
)
@@ -3134,7 +3134,7 @@ class TestCheckpointContextPathValidation:
def test_non_string_context_id_is_rejected(self, tmp_path: Any) -> None:
helper = self._helper()
with pytest.raises(RuntimeError):
helper(str(tmp_path), None) # type: ignore[arg-type] # ty: ignore[invalid-argument-type]
helper(str(tmp_path), None)
def test_url_encoded_traversal_is_treated_as_literal_segment(self, tmp_path: Any) -> None:
"""URL-encoded traversal should not decode to traversal at the filesystem layer.
@@ -3149,6 +3149,59 @@ class TestCheckpointContextPathValidation:
assert storage.storage_path.parent == root.resolve()
assert storage.storage_path.name == "%2e%2e"
def test_user_id_scopes_storage_under_user_partition(self, tmp_path: Any) -> None:
"""A per-user partition key nests the context dir under ``<root>/<user_id>``."""
helper = self._helper()
root = tmp_path / "root"
root.mkdir()
storage = helper(str(root), "resp_abc123", user_id="user-A")
assert storage.storage_path.is_dir()
assert storage.storage_path == (root / "user-A" / "resp_abc123").resolve()
@pytest.mark.parametrize("absent_user_id", [None, ""])
def test_absent_user_id_uses_unscoped_layout(self, tmp_path: Any, absent_user_id: str | None) -> None:
"""``None``/empty user id (local dev or protocol v1) falls back to the unscoped layout."""
helper = self._helper()
root = tmp_path / "root"
root.mkdir()
storage = helper(str(root), "resp_abc123", user_id=absent_user_id)
assert storage.storage_path == (root / "resp_abc123").resolve()
def test_distinct_users_get_isolated_storage(self, tmp_path: Any) -> None:
"""Two users sharing a context id must not resolve to the same directory."""
helper = self._helper()
root = tmp_path / "root"
root.mkdir()
a = helper(str(root), "shared_context", user_id="user-A")
b = helper(str(root), "shared_context", user_id="user-B")
assert a.storage_path != b.storage_path
assert a.storage_path.is_relative_to((root / "user-A").resolve())
assert b.storage_path.is_relative_to((root / "user-B").resolve())
@pytest.mark.parametrize(
"bad_user_id",
[
"../../escape",
"..",
".",
"/tmp/escape",
"C:\\temp\\escape",
"user/../../escape",
"with\x00null",
"a/b",
],
)
def test_malicious_user_id_is_rejected(self, tmp_path: Any, bad_user_id: str) -> None:
helper = self._helper()
root = tmp_path / "root"
root.mkdir()
before = sorted(p.name for p in tmp_path.iterdir())
with pytest.raises(RuntimeError):
helper(str(root), "resp_abc123", user_id=bad_user_id)
after = sorted(p.name for p in tmp_path.iterdir())
assert before == after, f"Unexpected filesystem artifacts created for user id {bad_user_id!r}"
assert list(root.iterdir()) == []
@pytest.mark.parametrize(
"context_field,bad_id",
[
@@ -3229,7 +3282,7 @@ class TestCheckpointContextPathValidation:
response_obj = getattr(failed[0], "response", None)
error = getattr(response_obj, "error", None) if response_obj is not None else None
assert error is not None
assert "Invalid checkpoint context id" in (error.message or "")
assert "Invalid context id" in (error.message or "")
assert before == after, f"Unexpected filesystem artifacts created for {context_field}={bad_id!r}"
assert list(root.iterdir()) == [], f"Checkpoint dir created inside root for {context_field}={bad_id!r}"
@@ -3316,6 +3369,59 @@ class TestCheckpointContextPathValidation:
assert list(root.iterdir()) == [], f"Checkpoint directory created inside root for {context_field}={bad_id!r}"
class TestApprovalStoragePathValidation:
"""Path-traversal and per-user scoping tests for function approval storage.
Mirrors the checkpoint validation: the per-user approval directory is
derived by joining the platform-injected ``x-agent-user-id`` partition key
under the base approval directory, and the user id must be a single safe
path segment (CWE-22).
"""
@staticmethod
def _helper() -> Callable[..., str]:
from agent_framework_foundry_hosting._responses import ( # pyright: ignore[reportPrivateUsage]
_approval_storage_path_for_user,
)
return _approval_storage_path_for_user
def test_user_id_scopes_path_under_base_directory(self, tmp_path: Any) -> None:
from pathlib import Path
helper = self._helper()
base = tmp_path / "approvals" / "requests.json"
scoped = Path(helper(str(base), "user-A"))
assert scoped.name == "requests.json"
assert scoped.parent.name == "user-A"
assert scoped.parent.parent == (tmp_path / "approvals").resolve()
def test_distinct_users_get_isolated_paths(self, tmp_path: Any) -> None:
helper = self._helper()
base = tmp_path / "approvals" / "requests.json"
assert helper(str(base), "user-A") != helper(str(base), "user-B")
@pytest.mark.parametrize(
"bad_user_id",
[
"../../escape",
"..",
".",
"/tmp/escape",
"C:\\temp\\escape",
"user/../../escape",
"with\x00null",
"a/b",
"",
],
)
def test_malicious_user_id_is_rejected(self, tmp_path: Any, bad_user_id: str) -> None:
helper = self._helper()
base = tmp_path / "approvals" / "requests.json"
with pytest.raises(RuntimeError):
helper(str(base), bad_user_id)
# region Agent lifecycle (lazy entry & OAuth consent surfacing)
@@ -0,0 +1,190 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for FoundryToolbox."""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import AsyncMock
import httpx
import pytest
from agent_framework import SkillsProvider
from azure.ai.agentserver.core import (
FoundryAgentRequestContext,
reset_request_context,
set_request_context,
)
from agent_framework_foundry_hosting import FoundryToolbox
from agent_framework_foundry_hosting._toolbox import ( # pyright: ignore[reportPrivateUsage]
_FoundryToolboxSkillsSource,
_resolve_toolbox_endpoint,
_toolbox_name_from_endpoint,
_ToolboxAuth,
)
class _FakeAccessToken:
def __init__(self, token: str) -> None:
self.token = token
self.expires_on = int(datetime.now(timezone.utc).timestamp()) + 3600
class _FakeCredential:
"""Minimal stand-in for azure.core.credentials.TokenCredential."""
def __init__(self, token: str = "fake-token") -> None:
self._token = token
self.scopes: list[str] = []
def get_token(self, *scopes: str, **kwargs: object) -> _FakeAccessToken:
self.scopes.extend(scopes)
return _FakeAccessToken(self._token)
def test_resolve_endpoint_prefers_explicit_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TOOLBOX_ENDPOINT", "https://host/toolboxes/tb/mcp?api-version=v1")
assert _resolve_toolbox_endpoint() == "https://host/toolboxes/tb/mcp?api-version=v1"
def test_resolve_endpoint_builds_from_project_and_name(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("TOOLBOX_ENDPOINT", raising=False)
monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", "https://proj.example.com/")
monkeypatch.setenv("TOOLBOX_NAME", "mybox")
assert _resolve_toolbox_endpoint() == "https://proj.example.com/toolboxes/mybox/mcp?api-version=v1"
def test_resolve_endpoint_empty_explicit_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TOOLBOX_ENDPOINT", "")
with pytest.raises(ValueError, match="empty"):
_resolve_toolbox_endpoint()
def test_resolve_endpoint_missing_inputs_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("TOOLBOX_ENDPOINT", raising=False)
monkeypatch.delenv("FOUNDRY_PROJECT_ENDPOINT", raising=False)
monkeypatch.delenv("TOOLBOX_NAME", raising=False)
with pytest.raises(ValueError, match="TOOLBOX_ENDPOINT"):
_resolve_toolbox_endpoint()
@pytest.mark.parametrize(
("endpoint", "expected"),
[
("https://h/toolboxes/alpha/mcp?api-version=v1", "alpha"),
("https://h/toolboxes/beta/versions/3/mcp", "beta"),
("https://h/something/else", "toolbox"),
],
)
def test_toolbox_name_from_endpoint(endpoint: str, expected: str) -> None:
assert _toolbox_name_from_endpoint(endpoint) == expected
def test_init_derives_name_and_defaults() -> None:
toolbox = FoundryToolbox(
_FakeCredential(), # type: ignore
url="https://h/toolboxes/sales/mcp?api-version=v1",
)
assert toolbox.name == "sales"
assert toolbox.url == "https://h/toolboxes/sales/mcp?api-version=v1"
# Toolboxes expose tools, not prompts.
assert toolbox.load_prompts_flag is False
def test_auth_flow_injects_bearer_token() -> None:
cred = _FakeCredential("abc123")
auth = _ToolboxAuth(cred, "https://ai.azure.com/.default") # type: ignore
request = httpx.Request("POST", "https://h/toolboxes/tb/mcp")
flow = auth.auth_flow(request)
prepared = next(flow)
assert prepared.headers["Authorization"] == "Bearer abc123"
assert cred.scopes == ["https://ai.azure.com/.default"]
def test_auth_flow_forwards_call_id_when_present() -> None:
auth = _ToolboxAuth(_FakeCredential(), "scope") # type: ignore
request = httpx.Request("POST", "https://h/toolboxes/tb/mcp")
token = set_request_context(FoundryAgentRequestContext(call_id="call-xyz"))
try:
prepared = next(auth.auth_flow(request))
finally:
reset_request_context(token)
assert prepared.headers["x-agent-foundry-call-id"] == "call-xyz"
def test_auth_flow_omits_call_id_when_absent() -> None:
auth = _ToolboxAuth(_FakeCredential(), "scope") # type: ignore
request = httpx.Request("POST", "https://h/toolboxes/tb/mcp")
prepared = next(auth.auth_flow(request))
assert "x-agent-foundry-call-id" not in prepared.headers
async def test_close_closes_owned_http_client() -> None:
toolbox = FoundryToolbox(
_FakeCredential(), # type: ignore
url="https://h/toolboxes/tb/mcp",
)
client = toolbox._httpx_client # pyright: ignore[reportPrivateUsage]
assert client is not None
client.aclose = AsyncMock() # ty: ignore # zuban: ignore
await toolbox.close()
client.aclose.assert_awaited_once() # ty: ignore
# Idempotent: a second close does not re-close the client.
await toolbox.close()
client.aclose.assert_awaited_once() # ty: ignore
def test_as_skills_provider_returns_provider() -> None:
toolbox = FoundryToolbox(
_FakeCredential(), # type: ignore
url="https://h/toolboxes/tb/mcp",
)
provider = toolbox.as_skills_provider(source_id="toolbox-skills")
assert isinstance(provider, SkillsProvider)
assert provider.source_id == "toolbox-skills"
async def test_skills_source_requires_connection() -> None:
toolbox = FoundryToolbox(
_FakeCredential(), # type: ignore
url="https://h/toolboxes/tb/mcp",
)
# The toolbox has not been connected, so there is no MCP session yet.
assert toolbox.session is None
source = _FoundryToolboxSkillsSource(toolbox)
with pytest.raises(RuntimeError, match="not connected"):
await source.get_skills()
async def test_skills_source_uses_connected_session(monkeypatch: pytest.MonkeyPatch) -> None:
toolbox = FoundryToolbox(
_FakeCredential(), # type: ignore
url="https://h/toolboxes/tb/mcp",
)
sentinel_session = object()
toolbox.session = sentinel_session # type: ignore
captured: dict[str, object] = {}
class _StubSkillsSource:
def __init__(self, *, client: object) -> None:
captured["client"] = client
async def get_skills(self) -> list[str]:
return ["skill-a"]
monkeypatch.setattr("agent_framework_foundry_hosting._toolbox.MCPSkillsSource", _StubSkillsSource)
result = await _FoundryToolboxSkillsSource(toolbox).get_skills()
assert result == ["skill-a"]
assert captured["client"] is sentinel_session
@@ -13,11 +13,11 @@ template:
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
version: 2.0.0
environment_variables:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
resources:
- kind: model
id: gpt-4.1-mini
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
@@ -3,10 +3,10 @@ kind: hosted
name: agent-framework-agent-basic-responses
protocols:
- protocol: responses
version: 1.0.0
version: 2.0.0
resources:
cpu: '0.25'
memory: '0.5Gi'
cpu: "0.25"
memory: "0.5Gi"
environment_variables:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
@@ -0,0 +1,6 @@
agent.manifest.yaml
agent.yaml
.env.example
.env
toolbox.yaml
./scripts
@@ -2,113 +2,46 @@
import asyncio
import os
from collections.abc import Callable
from urllib.parse import urlsplit
import httpx
from agent_framework import Agent, MCPStreamableHTTPTool
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
from agent_framework_foundry_hosting import ResponsesHostServer
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
from agent_framework_foundry_hosting import FoundryToolbox, ResponsesHostServer
from azure.identity import DefaultAzureCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
def resolve_toolbox_endpoint() -> str:
"""Resolve the toolbox MCP endpoint URL.
Prefers the explicit ``TOOLBOX_ENDPOINT`` env var (set in ``agent.yaml`` or
``agent.manifest.yaml`` and via ``azd env set TOOLBOX_ENDPOINT`` after the toolbox
is created); falls back to constructing the URL from ``FOUNDRY_PROJECT_ENDPOINT``
and ``TOOLBOX_NAME``.
"""
if (endpoint := os.environ.get("TOOLBOX_ENDPOINT")) is not None:
if not endpoint:
raise ValueError("TOOLBOX_ENDPOINT is set but empty")
return endpoint
try:
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"].rstrip("/")
toolbox_name = os.environ["TOOLBOX_NAME"]
except KeyError as e:
raise ValueError(
"Either set TOOLBOX_ENDPOINT, or set both FOUNDRY_PROJECT_ENDPOINT "
"and TOOLBOX_NAME to build the toolbox MCP endpoint."
) from e
return f"{project_endpoint}/toolboxes/{toolbox_name}/mcp?api-version=v1"
def _toolbox_name_from_endpoint(endpoint: str) -> str:
"""Extract the toolbox name from a toolbox MCP endpoint URL.
Handles both the versioned (``.../toolboxes/<name>/versions/<n>/mcp``) and
unversioned (``.../toolboxes/<name>/mcp``) endpoint shapes that Foundry
produces. Falls back to ``"toolbox"`` when the path has no ``toolboxes``
segment.
"""
segments = urlsplit(endpoint).path.split("/")
if "toolboxes" in segments:
idx = segments.index("toolboxes")
if idx + 1 < len(segments) and segments[idx + 1]:
return segments[idx + 1]
return "toolbox"
class ToolboxAuth(httpx.Auth):
"""Injects a fresh bearer token on every request."""
def __init__(self, token_provider: Callable[[], str]):
self._get_token = token_provider
def auth_flow(self, request: httpx.Request):
request.headers["Authorization"] = f"Bearer {self._get_token()}"
yield request
async def main():
credential = DefaultAzureCredential()
# Create the toolbox
token_provider = get_bearer_token_provider(credential, "https://ai.azure.com/.default")
# FoundryToolbox resolves the toolbox endpoint from the environment
# (TOOLBOX_ENDPOINT, or FOUNDRY_PROJECT_ENDPOINT + TOOLBOX_NAME), authenticates
# every request with the credential, and transparently forwards the platform
# per-request call-id to the toolbox. The hosting server enters the agent, which
# connects the toolbox on first use and closes it at shutdown.
toolbox = FoundryToolbox(credential)
# Resolve the endpoint once and derive a friendly tool name from it. When
# ``TOOLBOX_NAME`` isn't set, extract the toolbox name from the URL path so
# the tool's local name matches the upstream toolbox.
toolbox_endpoint = resolve_toolbox_endpoint()
toolbox_name = os.environ.get("TOOLBOX_NAME") or _toolbox_name_from_endpoint(toolbox_endpoint)
# Create the chat client
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
credential=credential,
)
async with httpx.AsyncClient(
auth=ToolboxAuth(token_provider),
headers={"Foundry-Features": "Toolboxes=V1Preview"},
timeout=120.0,
) as http_client:
toolbox = MCPStreamableHTTPTool(
name=toolbox_name,
url=toolbox_endpoint,
http_client=http_client,
load_prompts=False,
)
agent = Agent(
client=client,
instructions="You are a friendly assistant. Keep your answers brief.",
tools=toolbox,
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
)
# Create the chat client
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
credential=credential,
)
agent = Agent(
client=client,
instructions="You are a friendly assistant. Keep your answers brief.",
tools=toolbox,
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
)
server = ResponsesHostServer(agent)
await server.run_async()
server = ResponsesHostServer(agent)
await server.run_async()
if __name__ == "__main__":
+4496 -4500
View File
File diff suppressed because it is too large Load Diff