mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Resolve PR comments
This commit is contained in:
@@ -1,94 +1,27 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Invoke Foundry Toolbox MCP sample — combines an MCP server tool and a
|
||||
Foundry built-in tool through a single Foundry **toolbox** endpoint.
|
||||
"""Invoke a Foundry toolbox MCP endpoint from a declarative workflow.
|
||||
|
||||
A Foundry toolbox bundles multiple tool definitions (MCP servers, built-in
|
||||
Foundry tools such as ``web_search``, etc.) behind a single MCP-compatible
|
||||
proxy URL. Calling MCP-server-backed tools through the toolbox returns
|
||||
results namespaced as ``<server_label>___<tool_name>``; calling built-in
|
||||
tools (e.g. ``web_search``) returns the tool under its plain name.
|
||||
The workflow lists the toolbox's tools, queries Microsoft Learn Docs
|
||||
and ``web_search`` through the toolbox, and summarises the combined
|
||||
results with a Foundry agent. The reserved ``tools/list`` tool name is
|
||||
intercepted natively by ``DefaultMCPToolHandler``.
|
||||
|
||||
This sample mirrors the .NET sample
|
||||
``dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/`` and
|
||||
focuses on the **workflow execution path**:
|
||||
Required env vars:
|
||||
FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_MODEL.
|
||||
|
||||
1. Build a bearer-authenticated ``httpx.AsyncClient`` for the toolbox
|
||||
MCP proxy and hand it to :class:`DefaultMCPToolHandler` so the YAML
|
||||
can call MCP tools (and introspect the toolbox tool list via the
|
||||
reserved ``"tools/list"`` tool name handled natively by the
|
||||
framework, matching .NET
|
||||
``DefaultMcpToolHandler.ListToolsToolName``).
|
||||
2. Configure a :class:`WorkflowFactory` with that handler plus a local
|
||||
:class:`Agent` registered by name so the YAML's ``InvokeAzureAgent``
|
||||
action can summarise the combined tool output.
|
||||
3. Drive the workflow with a user question and render per-action
|
||||
progress markers plus the final agent summary.
|
||||
|
||||
One-off **toolbox administration** (delete + create_version) is delegated
|
||||
to :mod:`toolbox_provisioning` so this file stays focused on the workflow.
|
||||
|
||||
Security note:
|
||||
The default ``DefaultMCPToolHandler`` performs no URL allowlisting or
|
||||
SSRF protection. This sample uses a project-scoped ``client_provider``
|
||||
that pins outbound requests to ``Authorization: Bearer …`` via Azure
|
||||
AD AND fails closed (raises) when the YAML resolves a different
|
||||
``serverUrl``, so a tampered ``=Env.*`` value cannot redirect the
|
||||
bearer token to an attacker-controlled URL. MCP outputs flow back
|
||||
into agent conversations and share the prompt-injection risk
|
||||
surface of any other tool output.
|
||||
Optional env vars:
|
||||
FOUNDRY_TOOLBOX_NAME, FOUNDRY_TOOLBOX_API_VERSION,
|
||||
FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL,
|
||||
FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME, FOUNDRY_TOOLBOX_ENDPOINT.
|
||||
|
||||
Run with:
|
||||
python samples/03-workflows/declarative/invoke_foundry_toolbox_mcp/main.py
|
||||
|
||||
Required environment variables:
|
||||
FOUNDRY_PROJECT_ENDPOINT
|
||||
Azure AI Foundry project endpoint.
|
||||
FOUNDRY_MODEL
|
||||
Deployed Foundry model name used by ``FoundryChatClient``.
|
||||
|
||||
Optional environment variables:
|
||||
FOUNDRY_TOOLBOX_NAME
|
||||
Name of the toolbox to (re)create. Defaults to
|
||||
``declarative_foundry_toolbox_mcp``.
|
||||
FOUNDRY_TOOLBOX_API_VERSION
|
||||
Toolbox MCP API version used when building the endpoint URL.
|
||||
Defaults to ``v1``.
|
||||
FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL
|
||||
The ``server_label`` registered for the Microsoft Learn Docs MCP
|
||||
server in the toolbox. Tool names from that server get the
|
||||
``<server_label>___`` prefix on the toolbox MCP proxy.
|
||||
Defaults to ``microsoft_docs``.
|
||||
FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME
|
||||
Name of the Foundry built-in web-search tool surfaced by the
|
||||
toolbox. Defaults to ``web_search``.
|
||||
FOUNDRY_TOOLBOX_ENDPOINT
|
||||
Explicit toolbox MCP endpoint URL. When set, overrides the URL
|
||||
computed from ``FOUNDRY_PROJECT_ENDPOINT``,
|
||||
``FOUNDRY_TOOLBOX_NAME``, and ``FOUNDRY_TOOLBOX_API_VERSION``.
|
||||
|
||||
Sample output:
|
||||
|
||||
============================================================
|
||||
Invoke Foundry Toolbox MCP Workflow Demo
|
||||
============================================================
|
||||
Provisioning toolbox 'declarative_foundry_toolbox_mcp' in Foundry...
|
||||
Toolbox endpoint: https://<account>.services.ai.azure.com/api/projects/<project>/toolboxes/declarative_foundry_toolbox_mcp/mcp?api-version=v1
|
||||
|
||||
Ask one question that benefits from both Microsoft Learn docs and a web search.
|
||||
|
||||
You: How do I configure logging in the Agent Framework?
|
||||
[Listing toolbox tools...]
|
||||
[Searching Microsoft Learn docs...]
|
||||
[Searching the web...]
|
||||
[Summarizing results...]
|
||||
|
||||
Agent: The Agent Framework declarative workflow runtime ...
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
@@ -102,48 +35,13 @@ from agent_framework.foundry import FoundryChatClient
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.identity import AzureCliCredential, get_bearer_token_provider
|
||||
from toolbox_provisioning import (
|
||||
AZ_CLI_PROCESS_TIMEOUT_SECONDS,
|
||||
FOUNDRY_FEATURES_HEADERS,
|
||||
build_toolbox_mcp_server_url,
|
||||
create_sample_toolbox,
|
||||
)
|
||||
|
||||
DEFAULT_TOOLBOX_NAME = "declarative_foundry_toolbox_mcp"
|
||||
DEFAULT_TOOLBOX_API_VERSION = "v1"
|
||||
DEFAULT_DOCS_SERVER_LABEL = "microsoft_docs"
|
||||
DEFAULT_WEB_SEARCH_TOOL_NAME = "web_search"
|
||||
|
||||
AGENT_NAME = "FoundryToolboxMcpAgent"
|
||||
|
||||
# YAML action ids — kept in sync with ``workflow.yaml`` so the host can
|
||||
# render progress markers as each step starts. Long-running MCP calls
|
||||
# and a slow Foundry agent invocation can otherwise look like a hang.
|
||||
LIST_TOOLS_ACTION_ID = "list_toolbox_tools"
|
||||
DOCS_SEARCH_ACTION_ID = "search_docs_with_toolbox"
|
||||
WEB_SEARCH_ACTION_ID = "search_web_with_toolbox"
|
||||
SUMMARIZE_ACTION_ID = "summarize_toolbox_result"
|
||||
|
||||
_ACTION_PROGRESS_LABELS: dict[str, str] = {
|
||||
LIST_TOOLS_ACTION_ID: "Listing toolbox tools...",
|
||||
DOCS_SEARCH_ACTION_ID: "Searching Microsoft Learn docs...",
|
||||
WEB_SEARCH_ACTION_ID: "Searching the web...",
|
||||
SUMMARIZE_ACTION_ID: "Summarizing results...",
|
||||
}
|
||||
|
||||
# AAD audience for the toolbox MCP proxy. Same scope used by the existing
|
||||
# Foundry hosted-toolbox samples.
|
||||
TOOLBOX_AAD_SCOPE = "https://ai.azure.com/.default"
|
||||
|
||||
# Match the MCP-recommended httpx timeouts (``mcp.shared._httpx_utils``:
|
||||
# 30s connect/write/pool, 5min SSE read). httpx's default ``Timeout(5.0)``
|
||||
# is far too aggressive for MCP streaming responses — long-running
|
||||
# tool calls through the Foundry toolbox MCP proxy (e.g. the built-in
|
||||
# ``web_search``) can take longer than 5s, and a read-timeout fired
|
||||
# mid-stream leaves the upper-level ``call_tool`` awaiting a future that
|
||||
# never resolves, surfacing as an indefinite hang.
|
||||
MCP_CONNECT_TIMEOUT_SECONDS = 30.0
|
||||
MCP_READ_TIMEOUT_SECONDS = 300.0
|
||||
|
||||
AGENT_INSTRUCTIONS = """\
|
||||
You combine results from two tool calls in the conversation:
|
||||
|
||||
@@ -159,37 +57,28 @@ result set contains an answer, say so plainly rather than guessing.
|
||||
|
||||
|
||||
class _BearerAuth(httpx.Auth):
|
||||
"""Inject a fresh Azure AD bearer token on every request.
|
||||
|
||||
``httpx.Auth.auth_flow`` is a sync generator and works for both sync
|
||||
and async clients. ``get_bearer_token_provider`` caches/refreshes the
|
||||
token internally, so calling it per request is cheap.
|
||||
"""
|
||||
"""Inject a fresh Azure AD bearer token on every request."""
|
||||
|
||||
def __init__(self, credential: TokenCredential) -> None:
|
||||
self._get_token = get_bearer_token_provider(credential, TOOLBOX_AAD_SCOPE)
|
||||
self._get_token = get_bearer_token_provider(credential, "https://ai.azure.com/.default")
|
||||
|
||||
def auth_flow(self, request: httpx.Request) -> Iterator[httpx.Request]:
|
||||
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
|
||||
request.headers["Authorization"] = f"Bearer {self._get_token()}"
|
||||
yield request
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run the Foundry toolbox MCP workflow."""
|
||||
# 1. Read configuration. ``FOUNDRY_PROJECT_ENDPOINT`` and
|
||||
# ``FOUNDRY_MODEL`` are required; everything else has defaults.
|
||||
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
|
||||
model = os.environ["FOUNDRY_MODEL"]
|
||||
toolbox_name = os.environ.get("FOUNDRY_TOOLBOX_NAME", DEFAULT_TOOLBOX_NAME)
|
||||
toolbox_api_version = os.environ.get("FOUNDRY_TOOLBOX_API_VERSION", DEFAULT_TOOLBOX_API_VERSION)
|
||||
docs_server_label = os.environ.get("FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL", DEFAULT_DOCS_SERVER_LABEL)
|
||||
web_search_tool_name = os.environ.get("FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME", DEFAULT_WEB_SEARCH_TOOL_NAME)
|
||||
toolbox_name = os.environ.get("FOUNDRY_TOOLBOX_NAME", "declarative_foundry_toolbox_mcp")
|
||||
toolbox_api_version = os.environ.get("FOUNDRY_TOOLBOX_API_VERSION", "v1")
|
||||
docs_server_label = os.environ.get("FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL", "microsoft_docs")
|
||||
web_search_tool_name = os.environ.get("FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME", "web_search")
|
||||
|
||||
print("=" * 60)
|
||||
print("Invoke Foundry Toolbox MCP Workflow Demo")
|
||||
print("=" * 60)
|
||||
|
||||
# 2. Provision the toolbox in Foundry. Idempotent: delete-then-create.
|
||||
print(f"Provisioning toolbox '{toolbox_name}' in Foundry...")
|
||||
create_sample_toolbox(
|
||||
name=toolbox_name,
|
||||
@@ -197,16 +86,14 @@ async def main() -> None:
|
||||
project_endpoint=project_endpoint,
|
||||
)
|
||||
|
||||
# 3. Resolve the toolbox MCP proxy URL. The workflow YAML references
|
||||
# these values via ``=Env.FOUNDRY_TOOLBOX_*``; we publish them
|
||||
# through ``WorkflowFactory(configuration=...)`` so the values stay scoped to
|
||||
# this workflow.
|
||||
toolbox_endpoint = os.environ.get("FOUNDRY_TOOLBOX_ENDPOINT") or build_toolbox_mcp_server_url(
|
||||
project_endpoint=project_endpoint,
|
||||
name=toolbox_name,
|
||||
api_version=toolbox_api_version,
|
||||
)
|
||||
workflow_configuration: dict[str, str] = {
|
||||
# Values exposed to ``=Env.*`` in workflow.yaml. Passing them via
|
||||
# ``configuration`` keeps the symbol table scoped to this workflow.
|
||||
workflow_configuration = {
|
||||
"FOUNDRY_TOOLBOX_MCP_SERVER_URL": toolbox_endpoint,
|
||||
"FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL": docs_server_label,
|
||||
"FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME": web_search_tool_name,
|
||||
@@ -214,64 +101,27 @@ async def main() -> None:
|
||||
print(f"Toolbox endpoint: {toolbox_endpoint}")
|
||||
print()
|
||||
|
||||
# 4. Build the Foundry chat client + the summarising agent. The agent
|
||||
# is registered with the factory by name, matching the sibling
|
||||
# ``invoke_mcp_tool/`` sample.
|
||||
credential = AzureCliCredential(process_timeout=AZ_CLI_PROCESS_TIMEOUT_SECONDS)
|
||||
chat_client = FoundryChatClient(
|
||||
project_endpoint=project_endpoint,
|
||||
model=model,
|
||||
credential=credential,
|
||||
)
|
||||
summary_agent = Agent(
|
||||
client=chat_client,
|
||||
name=AGENT_NAME,
|
||||
instructions=AGENT_INSTRUCTIONS,
|
||||
)
|
||||
credential = AzureCliCredential()
|
||||
chat_client = FoundryChatClient(project_endpoint=project_endpoint, model=model, credential=credential)
|
||||
summary_agent = Agent(client=chat_client, name=AGENT_NAME, instructions=AGENT_INSTRUCTIONS)
|
||||
|
||||
# 5. Build a bearer-authenticated httpx client. The same client is
|
||||
# reused for every MCP request: the LRU cache inside
|
||||
# ``DefaultMCPToolHandler`` keeps a single MCP session alive
|
||||
# for the toolbox URL, and ``tools/list`` reuses that same
|
||||
# cached session for full transport-level consistency.
|
||||
#
|
||||
# Key configuration choices:
|
||||
# * ``headers=FOUNDRY_FEATURES_HEADERS`` attaches the
|
||||
# ``Foundry-Features: Toolboxes=V1Preview`` flag to EVERY
|
||||
# outbound request — including the MCP ``initialize`` handshake
|
||||
# during ``connect()``. The YAML's per-action ``headers:`` block
|
||||
# also sets this value but only takes effect during
|
||||
# ``call_tool`` (the ``MCPStreamableHTTPTool`` header_provider
|
||||
# contextvar is empty during connect — see
|
||||
# ``python/packages/core/agent_framework/_mcp.py:1639-1645``).
|
||||
# Without the client-level default the toolbox MCP proxy rejects
|
||||
# the session handshake and surfaces "unhandled errors in a
|
||||
# TaskGroup".
|
||||
# * ``timeout=Timeout(30.0, read=300.0)`` matches the MCP
|
||||
# recommended defaults (``mcp.shared._httpx_utils``: 30s
|
||||
# connect/write/pool, 5min SSE read). The httpx defaults of 5s
|
||||
# EVERYWHERE break long-running MCP tool calls — the Foundry
|
||||
# built-in ``web_search``, for instance, can take longer than
|
||||
# 5s to return through the toolbox SSE stream and would
|
||||
# otherwise leave the client waiting on a future that never
|
||||
# resolves (i.e. visibly hang on the host).
|
||||
# * ``follow_redirects=True`` also mirrors the MCP defaults so
|
||||
# proxy redirects don't surface as broken streams.
|
||||
# ``headers=`` attaches the Foundry-Features preview flag on every
|
||||
# request, including the MCP ``initialize`` handshake (the YAML's
|
||||
# per-action ``headers`` only takes effect during ``call_tool``).
|
||||
# ``timeout=`` matches the MCP-recommended values; httpx's 5s
|
||||
# default breaks long-running tool calls like ``web_search``.
|
||||
http_client = httpx.AsyncClient(
|
||||
auth=_BearerAuth(credential),
|
||||
headers=FOUNDRY_FEATURES_HEADERS,
|
||||
timeout=httpx.Timeout(MCP_CONNECT_TIMEOUT_SECONDS, read=MCP_READ_TIMEOUT_SECONDS),
|
||||
timeout=httpx.Timeout(30.0, read=300.0),
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
async def _client_provider(invocation: MCPToolInvocation) -> httpx.AsyncClient | None:
|
||||
# Pin the bearer-authenticated client to the resolved toolbox URL.
|
||||
# The Foundry AAD bearer token is scoped to ``https://ai.azure.com``
|
||||
# but we still refuse to attach it to any URL we did not provision —
|
||||
# if the YAML resolves a different ``serverUrl`` (e.g. via a tampered
|
||||
# ``Env.*`` value or a config injection), fail closed by raising so
|
||||
# ``DefaultMCPToolHandler`` cannot fall back to an unauthenticated
|
||||
# client that silently leaks the request shape.
|
||||
# Fail closed when the YAML resolves a different ``serverUrl``
|
||||
# so the bearer-bound client cannot be reused against an
|
||||
# unexpected endpoint and ``DefaultMCPToolHandler`` cannot
|
||||
# silently fall back to an unauthenticated client.
|
||||
if invocation.server_url.casefold() != toolbox_endpoint.casefold():
|
||||
raise ValueError(
|
||||
f"Refusing to attach Foundry bearer token to unexpected MCP URL: "
|
||||
@@ -286,55 +136,35 @@ async def main() -> None:
|
||||
factory = WorkflowFactory(
|
||||
agents={AGENT_NAME: summary_agent},
|
||||
mcp_tool_handler=mcp_handler,
|
||||
# The workflow YAML references ``=Env.FOUNDRY_TOOLBOX_*`` to keep
|
||||
# the toolbox URL / tool names configurable without editing the
|
||||
# YAML. We supply those values through ``configuration`` so the
|
||||
# PowerFx ``Env`` symbol is populated from a local dict instead
|
||||
# of the process environment. ``restrict_env_to_configuration``
|
||||
# defaults to ``True`` which suppresses any ``os.environ``
|
||||
# fallback — the workflow only sees the keys explicitly listed
|
||||
# in ``workflow_configuration`` below.
|
||||
configuration=workflow_configuration,
|
||||
)
|
||||
|
||||
workflow_path = Path(__file__).parent / "workflow.yaml"
|
||||
workflow = factory.create_workflow_from_yaml_path(workflow_path)
|
||||
workflow = factory.create_workflow_from_yaml_path(Path(__file__).parent / "workflow.yaml")
|
||||
|
||||
print("Ask one question that benefits from both Microsoft Learn docs and a web search.")
|
||||
print()
|
||||
user_input = input("You: ").strip() # noqa: ASYNC250
|
||||
if not user_input:
|
||||
user_input = "How do I configure logging in the Agent Framework?"
|
||||
user_input = input("You: ").strip() or "How do I configure logging in the Agent Framework?" # noqa: ASYNC250
|
||||
|
||||
# 6. Drive the workflow with the user's question. The YAML fans
|
||||
# out three MCP calls and finishes with the InvokeAzureAgent
|
||||
# summarisation step. We render two kinds of host-visible
|
||||
# feedback:
|
||||
#
|
||||
# * Per-action progress lines via ``executor_invoked``
|
||||
# events so a slow MCP call or agent invocation cannot
|
||||
# look like a hang.
|
||||
# * The final agent summary via ``output`` events. The
|
||||
# three MCP actions use ``autoSend: false`` in the YAML
|
||||
# so only the summarising agent's text reaches this
|
||||
# branch.
|
||||
# Progress markers per YAML action so slow MCP calls or agent
|
||||
# invocations don't look like a hang. Action ids mirror
|
||||
# workflow.yaml.
|
||||
progress_labels = {
|
||||
"list_toolbox_tools": "Listing toolbox tools...",
|
||||
"search_docs_with_toolbox": "Searching Microsoft Learn docs...",
|
||||
"search_web_with_toolbox": "Searching the web...",
|
||||
"summarize_toolbox_result": "Summarizing results...",
|
||||
}
|
||||
printed_prefix = False
|
||||
produced_output = False
|
||||
agent_executor_id = SUMMARIZE_ACTION_ID
|
||||
async for event in workflow.run({"text": user_input}, stream=True):
|
||||
if event.type == "executor_invoked":
|
||||
label = _ACTION_PROGRESS_LABELS.get(event.executor_id or "")
|
||||
label = progress_labels.get(event.executor_id or "")
|
||||
if label is not None:
|
||||
print(f"[{label}]")
|
||||
continue
|
||||
|
||||
if event.type == "output" and isinstance(event.data, str):
|
||||
# Only the summarising agent action sends an output
|
||||
# event (MCP calls use ``autoSend: false``). Guard the
|
||||
# display so any future autoSend additions still print
|
||||
# under the "Agent:" prefix only when they come from
|
||||
# that action.
|
||||
if event.executor_id and event.executor_id != agent_executor_id:
|
||||
# Only the summarising agent emits ``output``; the three
|
||||
# MCP actions use ``autoSend: false`` in the YAML.
|
||||
if event.executor_id and event.executor_id != "summarize_toolbox_result":
|
||||
continue
|
||||
if not printed_prefix:
|
||||
print("\nAgent: ", end="", flush=True)
|
||||
|
||||
+24
-70
@@ -1,67 +1,31 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Foundry toolbox provisioning helper for the ``invoke_foundry_toolbox_mcp`` sample.
|
||||
"""Foundry toolbox provisioning helper for ``invoke_foundry_toolbox_mcp``.
|
||||
|
||||
This module is intentionally narrow: it covers the one-off **administrative**
|
||||
setup needed to (re)create a Foundry toolbox so the sample can be run
|
||||
end-to-end without manual portal/CLI steps. Workflow execution, MCP
|
||||
handling, and agent orchestration live in :mod:`main`.
|
||||
|
||||
Toolboxes are normally provisioned through the Foundry portal or a separate
|
||||
deployment script. Bundling the provisioning step here keeps the sample
|
||||
self-contained and re-runnable.
|
||||
|
||||
The Foundry-Features preview header is exported here as well so the
|
||||
runtime MCP client in ``main.py`` can attach it on every outbound request
|
||||
(the MCP ``initialize`` handshake also requires the flag, not just the
|
||||
toolbox administration calls).
|
||||
Toolboxes are normally provisioned through the Foundry portal or a
|
||||
separate deployment script; bundling the setup here lets the sample run
|
||||
end-to-end without manual steps. ``main.py`` owns the workflow execution
|
||||
path.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
DEFAULT_DOCS_MCP_SERVER_URL = "https://learn.microsoft.com/api/mcp"
|
||||
|
||||
# Bump the ``az.cmd`` subprocess timeout from the default 10s. On Windows
|
||||
# the Azure CLI batch wrapper can take noticeably longer than 10s to
|
||||
# return a token (cold-start + ``az`` self-update checks + AAD round-trip),
|
||||
# which surfaces as ``CredentialUnavailableError: Failed to invoke the
|
||||
# Azure CLI`` after a ``subprocess.TimeoutExpired`` from the credential's
|
||||
# internal call.
|
||||
AZ_CLI_PROCESS_TIMEOUT_SECONDS = 60
|
||||
|
||||
# Toolbox administration AND runtime MCP traffic are both gated by an
|
||||
# Azure AI Foundry preview feature flag. The .NET sample injects this
|
||||
# header via a pipeline policy on the ``AgentAdministrationClient``;
|
||||
# the Python ``AIProjectClient`` doesn't add it automatically, so we pass
|
||||
# it as a per-call header on every toolbox admin operation (delete +
|
||||
# create_version) here, and the runtime code in ``main.py`` attaches it
|
||||
# as a default header on the ``httpx.AsyncClient`` so it travels on the
|
||||
# MCP ``initialize`` handshake as well. Without this header on admin
|
||||
# calls, provisioning succeeds at the HTTP layer but the toolbox is
|
||||
# never wired up to the MCP endpoint — surfacing at runtime as "MCP
|
||||
# server failed to initialize: Session terminated" on the first
|
||||
# ``InvokeMcpTool`` call.
|
||||
FOUNDRY_FEATURES_HEADER_NAME = "Foundry-Features"
|
||||
FOUNDRY_FEATURES_HEADER_VALUE = "Toolboxes=V1Preview"
|
||||
FOUNDRY_FEATURES_HEADERS: Mapping[str, str] = {
|
||||
FOUNDRY_FEATURES_HEADER_NAME: FOUNDRY_FEATURES_HEADER_VALUE,
|
||||
}
|
||||
# Toolbox admin and MCP runtime traffic are both gated by a preview
|
||||
# feature flag. The Python ``AIProjectClient`` does not add it
|
||||
# automatically, so we attach it to every admin call here AND to the
|
||||
# ``httpx.AsyncClient`` in ``main.py`` so the MCP ``initialize``
|
||||
# handshake carries it too. Without the flag on admin calls,
|
||||
# provisioning succeeds at the HTTP layer but the toolbox is never
|
||||
# wired to the MCP endpoint — surfacing later as "MCP server failed to
|
||||
# initialize: Session terminated" on the first ``InvokeMcpTool`` call.
|
||||
FOUNDRY_FEATURES_HEADERS: Mapping[str, str] = {"Foundry-Features": "Toolboxes=V1Preview"}
|
||||
|
||||
|
||||
def build_toolbox_mcp_server_url(project_endpoint: str, name: str, api_version: str) -> str:
|
||||
"""Compose the Foundry toolbox MCP proxy URL.
|
||||
|
||||
Toolboxes provisioned via ``AIProjectClient.beta.toolboxes`` live under
|
||||
the ``/toolboxes/{name}`` resource path (the Python SDK's
|
||||
``BetaToolboxesOperations`` routes POST/GET/DELETE there — see
|
||||
``azure/ai/projects/operations/_operations.py``). Their MCP proxy URL
|
||||
is ``<project_endpoint>/toolboxes/{name}/mcp?api-version=<api_version>``,
|
||||
matching the .NET sample.
|
||||
"""
|
||||
base = project_endpoint.rstrip("/")
|
||||
return f"{base}/toolboxes/{name}/mcp?api-version={api_version}"
|
||||
"""Compose the Foundry toolbox MCP proxy URL."""
|
||||
return f"{project_endpoint.rstrip('/')}/toolboxes/{name}/mcp?api-version={api_version}"
|
||||
|
||||
|
||||
def create_sample_toolbox(
|
||||
@@ -69,27 +33,21 @@ def create_sample_toolbox(
|
||||
name: str,
|
||||
docs_server_label: str,
|
||||
project_endpoint: str,
|
||||
docs_server_url: str = DEFAULT_DOCS_MCP_SERVER_URL,
|
||||
docs_server_url: str = "https://learn.microsoft.com/api/mcp",
|
||||
) -> None:
|
||||
"""Provision a toolbox version in the Foundry project (idempotent).
|
||||
"""Provision a toolbox version (delete-then-create; idempotent).
|
||||
|
||||
Deletes any existing toolbox under ``name`` and then creates a new
|
||||
version that bundles:
|
||||
|
||||
- the Microsoft Learn Docs MCP server
|
||||
(``server_label=docs_server_label``), and
|
||||
- the Foundry built-in ``web_search`` tool.
|
||||
|
||||
Uses ``AzureCliCredential`` because the sample is meant to be run by
|
||||
a developer with ``az login`` already configured; switch to a managed
|
||||
identity / service principal credential for production deployments.
|
||||
Bundles the Microsoft Learn Docs MCP server and the Foundry built-in
|
||||
``web_search`` tool. Uses ``AzureCliCredential`` because the sample
|
||||
expects ``az login``; switch to a managed identity or service
|
||||
principal for production deployments.
|
||||
"""
|
||||
from azure.ai.projects import AIProjectClient
|
||||
from azure.ai.projects.models import MCPTool, Tool, WebSearchTool
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
|
||||
with (
|
||||
AzureCliCredential(process_timeout=AZ_CLI_PROCESS_TIMEOUT_SECONDS) as credential,
|
||||
AzureCliCredential() as credential,
|
||||
AIProjectClient(credential=credential, endpoint=project_endpoint) as project_client,
|
||||
):
|
||||
try:
|
||||
@@ -99,11 +57,7 @@ def create_sample_toolbox(
|
||||
pass
|
||||
|
||||
tools: list[Tool] = [
|
||||
MCPTool(
|
||||
server_label=docs_server_label,
|
||||
server_url=docs_server_url,
|
||||
require_approval="never",
|
||||
),
|
||||
MCPTool(server_label=docs_server_label, server_url=docs_server_url, require_approval="never"),
|
||||
WebSearchTool(),
|
||||
]
|
||||
|
||||
|
||||
@@ -40,19 +40,11 @@ trigger:
|
||||
variable: Local.SearchQuery
|
||||
value: =Workflow.Inputs.text
|
||||
|
||||
# List the tools exposed by the toolbox MCP proxy.
|
||||
#
|
||||
# We intentionally OMIT ``conversationId`` here: the tool list is
|
||||
# metadata for the demo, not useful context for the downstream
|
||||
# summarising agent. Forwarding it into the conversation only
|
||||
# inflates token usage and per-call latency. We also keep
|
||||
# ``autoSend: false`` because the Python host's streaming loop
|
||||
# prints every string ``output`` event — emitting the raw tool
|
||||
# list JSON would visually bury the agent's final answer. The
|
||||
# .NET sibling sample uses ``autoSend: true`` because the .NET
|
||||
# ``WorkflowRunner`` console helper only renders agent updates
|
||||
# (not ``WorkflowOutputEvent``s), so the same YAML value behaves
|
||||
# differently across hosts.
|
||||
# List the tools exposed by the toolbox MCP proxy. We omit
|
||||
# ``conversationId`` (the catalog is demo metadata, not useful
|
||||
# context for the downstream agent) and keep ``autoSend: false``
|
||||
# so the raw JSON catalog doesn't bury the agent's final answer in
|
||||
# the host's output stream.
|
||||
- kind: InvokeMcpTool
|
||||
id: list_toolbox_tools
|
||||
serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL
|
||||
|
||||
Reference in New Issue
Block a user