Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f33e88dfc | |||
| 3ad2cc630f | |||
| 64efaa6bb4 | |||
| 60f1d5aa52 | |||
| 099a03f92a | |||
| 7c1057eb52 | |||
| 4beb541c1e | |||
| 3276929fb8 | |||
| e5f0e87cd0 | |||
| 85a2adf80b |
@@ -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
|
||||
+2
-2
@@ -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}
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
agent.manifest.yaml
|
||||
agent.yaml
|
||||
.env.example
|
||||
.env
|
||||
toolbox.yaml
|
||||
./scripts
|
||||
+26
-93
@@ -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__":
|
||||
|
||||
Generated
+4496
-4500
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user