diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py index 82a9cd9b4b..dd326f62f3 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py @@ -27,6 +27,7 @@ import locale import logging +import os import sys import uuid from collections.abc import Mapping @@ -42,6 +43,8 @@ ) from agent_framework._workflows._state import State +from .._models import _safe_mode_context # type: ignore[reportPrivateUsage] + try: from powerfx import Engine except (ImportError, RuntimeError): @@ -714,6 +717,13 @@ def _to_powerfx_symbols(self) -> dict[str, Any]: # Custom namespaces **state_data.get("Custom", {}), } + # Expose ``Env`` ONLY when the active workflow factory has explicitly + # opted out of safe mode. Matches the policy enforced by + # ``_try_powerfx_eval`` in ``_models.py`` and the AgentFactory + # ``safe_mode`` flag. Treat the default as safe even when no factory + # is in scope (e.g. in unit tests) so opt-in is required. + if not _safe_mode_context.get(): + symbols["Env"] = dict(os.environ) # Debug log the Local symbols to help diagnose type issues if local_data: for key, value in local_data.items(): diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py index 221dfec3cc..e3866933f3 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py @@ -26,6 +26,7 @@ ) from .._loader import AgentFactory +from .._models import _safe_mode_context # type: ignore[reportPrivateUsage] from ._declarative_builder import DeclarativeWorkflowBuilder from ._errors import DeclarativeWorkflowError from ._http_handler import HttpRequestHandler @@ -93,6 +94,7 @@ def __init__( max_iterations: int | None = None, http_request_handler: HttpRequestHandler | None = None, mcp_tool_handler: MCPToolHandler | None = None, + safe_mode: bool = True, ) -> None: """Initialize the workflow factory. @@ -119,6 +121,15 @@ def __init__( for a default backed by :class:`agent_framework.MCPStreamableHTTPTool`, or supply your own implementation to enforce SSRF guards, allowlisting, or auth/connection resolution. + safe_mode: Whether to run in safe mode, default is True. + When safe_mode is True, environment variables are NOT accessible from + PowerFx expressions in the workflow YAML (e.g. ``=Env.SOME_VAR`` will + fail to resolve and the original expression string is preserved). + When safe_mode is False, the full ``os.environ`` snapshot is exposed + via the ``Env`` symbol in every PowerFx evaluation. Set safe_mode to + False ONLY when you fully trust the YAML source. The flag is also + forwarded to the internally-constructed :class:`AgentFactory` so + inline agent definitions follow the same policy. Examples: .. code-block:: python @@ -152,7 +163,8 @@ def __init__( env_file=".env", ) """ - self._agent_factory = agent_factory or AgentFactory(env_file_path=env_file) + self.safe_mode = safe_mode + self._agent_factory = agent_factory or AgentFactory(env_file_path=env_file, safe_mode=safe_mode) self._agents: dict[str, SupportsAgentRun | AgentExecutor] = dict(agents) if agents else {} self._bindings: dict[str, Any] = dict(bindings) if bindings else {} self._tools: dict[str, Any] = {} # Tool registry for InvokeFunctionTool actions @@ -338,6 +350,15 @@ def create_workflow_from_definition( # Validate the workflow definition self._validate_workflow_def(workflow_def) + # Set safe_mode context before evaluating any PowerFx expressions. The + # contextvar gates ``Env`` exposure inside ``DeclarativeWorkflowState`` + # symbols and inside ``_try_powerfx_eval``; both check + # ``_safe_mode_context.get()`` at evaluation time. Because the + # contextvar propagates through asyncio tasks spawned from the current + # context, this value persists into ``workflow.run(...)`` invocations + # made on the same coroutine. + _safe_mode_context.set(self.safe_mode) + # Extract workflow metadata # Support both "name" field and trigger.id for workflow name name: str = workflow_def.get("name", "") diff --git a/python/samples/03-workflows/declarative/invoke_foundry_toolbox_mcp/main.py b/python/samples/03-workflows/declarative/invoke_foundry_toolbox_mcp/main.py new file mode 100644 index 0000000000..ceffb08393 --- /dev/null +++ b/python/samples/03-workflows/declarative/invoke_foundry_toolbox_mcp/main.py @@ -0,0 +1,538 @@ +# 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. + +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 ``___``; calling built-in +tools (e.g. ``web_search``) returns the tool under its plain name. + +This sample mirrors the .NET sample +``dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/`` and +shows how to: + + 1. Provision a toolbox in a Foundry project (delete-then-create_version, + so the sample can be re-run without manual cleanup). + 2. Configure a ``WorkflowFactory`` with a custom :class:`MCPToolHandler` + that: + * routes every MCP request through a single + :class:`httpx.AsyncClient` carrying an Azure AD bearer token + (the toolbox endpoint requires AAD auth), and + * intercepts the reserved tool name ``"tools/list"`` so the YAML + can introspect the toolbox tool set without an extra Python + round-trip (matching the .NET ``DefaultMcpToolHandler`` + behaviour). + 3. Invoke ``microsoft_docs_search`` (from the Microsoft Learn Docs MCP + server surfaced by the toolbox) and ``web_search`` (Foundry built-in) + from a single declarative workflow. + 4. Hand both result sets to a local :class:`Agent` registered with the + factory by name so the workflow's ``InvokeAzureAgent`` action can + summarise them. + +Security note: + The default ``DefaultMCPToolHandler`` performs no URL allowlisting or + SSRF protection. This sample wraps it with a project-scoped handler + that pins outbound requests to ``Authorization: Bearer …`` via Azure + AD; for production deployments, additionally constrain the workflow + YAML to a known toolbox URL and reject any other server URL before + delegating to the inner handler. MCP outputs flow back into agent + conversations and share the prompt-injection risk surface of any + other tool output. + +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 + ``___`` 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://.services.ai.azure.com/api/projects//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 contextlib +import json +import os +import sys +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import httpx +from agent_framework import Agent, Content, MCPStreamableHTTPTool +from agent_framework.declarative import ( + DefaultMCPToolHandler, + MCPToolInvocation, + MCPToolResult, + WorkflowFactory, +) +from agent_framework.foundry import FoundryChatClient +from azure.core.credentials import TokenCredential +from azure.identity import AzureCliCredential, get_bearer_token_provider + +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" +DEFAULT_DOCS_MCP_SERVER_URL = "https://learn.microsoft.com/api/mcp" + +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...", +} + +# Reserved tool name that the YAML uses to ask the handler for the toolbox +# tool list. Mirrors .NET ``DefaultMcpToolHandler.ListToolsToolName``. +LIST_TOOLS_TOOL_NAME = "tools/list" + +# 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" + +# Toolbox administration is 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) to make sure the +# toolbox is actually provisioned in the V1Preview routing path that the +# MCP proxy serves. Without this header, the calls can succeed at the +# HTTP layer but the toolbox is never wired up to the MCP endpoint — +# which surfaces 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: dict[str, str] = { + FOUNDRY_FEATURES_HEADER_NAME: FOUNDRY_FEATURES_HEADER_VALUE, +} + +# 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 + +# 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: + + - ``microsoft_docs_search`` from the Microsoft Learn Docs MCP server + (authoritative Microsoft documentation), and + - ``web_search`` (Foundry built-in) for general web context. + +Answer the user's question using ONLY the information present in the +conversation. Prefer Microsoft Learn results for any product or API +question and cite document titles or URLs when available. If neither +result set contains an answer, say so plainly rather than guessing. +""" + + +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 ``/toolboxes/{name}/mcp?api-version=``, + matching the .NET sample. + """ + base = project_endpoint.rstrip("/") + return f"{base}/toolboxes/{name}/mcp?api-version={api_version}" + + +def create_sample_toolbox( + *, + name: str, + docs_server_label: str, + project_endpoint: str, + docs_server_url: str = DEFAULT_DOCS_MCP_SERVER_URL, +) -> None: + """Provision a toolbox version in the Foundry project (idempotent). + + Toolboxes are normally provisioned through the Foundry portal or a + deployment script; this helper exists so the sample can be re-run + end-to-end without manual cleanup. It deletes any 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. + """ + 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, + AIProjectClient(credential=credential, endpoint=project_endpoint) as project_client, + ): + try: + project_client.beta.toolboxes.delete(name, headers=FOUNDRY_FEATURES_HEADERS) + print(f"Toolbox '{name}' deleted (replacing with a fresh version).") + except ResourceNotFoundError: + pass + + tools: list[Tool] = [ + MCPTool( + server_label=docs_server_label, + server_url=docs_server_url, + require_approval="never", + ), + WebSearchTool(), + ] + + created = project_client.beta.toolboxes.create_version( + name=name, + description="Sample toolbox combining Microsoft Learn Docs MCP and Foundry web search.", + tools=tools, + headers=FOUNDRY_FEATURES_HEADERS, + ) + print(f"Created toolbox {created.name}@{created.version} ({len(created.tools)} tool(s)).") + + +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. + """ + + def __init__(self, credential: TokenCredential) -> None: + self._get_token = get_bearer_token_provider(credential, TOOLBOX_AAD_SCOPE) + + def auth_flow(self, request: httpx.Request) -> Iterator[httpx.Request]: + request.headers["Authorization"] = f"Bearer {self._get_token()}" + yield request + + +class _ToolboxMcpToolHandler: + """:class:`MCPToolHandler` that adds ``tools/list`` support to the default handler. + + The reserved tool name ``"tools/list"`` is intercepted client-side: it + is translated to an MCP ``session.list_tools()`` call and the result + is returned as a single JSON-encoded ``TextContent`` matching the + shape produced by the .NET ``DefaultMcpToolHandler`` + (``{"tools": [{name, description, inputSchema, outputSchema}]}``). + + All other tool invocations delegate to the wrapped + :class:`DefaultMCPToolHandler` so the LRU client cache, error + normalisation, and approval flow remain unchanged. + + The ``tools/list`` path uses a transient :class:`MCPStreamableHTTPTool` + (``load_tools=False`` so MCP discovery only happens once via the + explicit ``session.list_tools()`` call). The same caller-supplied + ``httpx.AsyncClient`` is reused so the bearer token and any other + transport-level configuration stay consistent with the cached calls. + """ + + def __init__(self, inner: DefaultMCPToolHandler, http_client: httpx.AsyncClient) -> None: + self._inner = inner + self._http_client = http_client + + async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult: + if invocation.tool_name == LIST_TOOLS_TOOL_NAME: + return await self._list_tools(invocation) + return await self._inner.invoke_tool(invocation) + + async def _list_tools(self, invocation: MCPToolInvocation) -> MCPToolResult: + if invocation.arguments: + return MCPToolResult( + outputs=[Content.from_text("Error: 'tools/list' does not accept arguments.")], + is_error=True, + error_message="'tools/list' does not accept arguments.", + ) + + # Snapshot headers so the closure does not see later mutations. + captured_headers = dict(invocation.headers) + + def _header_provider(_kwargs: dict[str, Any]) -> dict[str, str]: + return dict(captured_headers) + + tool = MCPStreamableHTTPTool( + name=invocation.server_label or "foundry_toolbox_list", + url=invocation.server_url, + http_client=self._http_client, + header_provider=_header_provider if captured_headers else None, + load_tools=False, + load_prompts=False, + ) + + try: + await tool.connect() + tool_list = await tool.session.list_tools() # type: ignore[union-attr] + payload = { + "tools": [ + { + "name": entry.name, + "description": entry.description, + "inputSchema": entry.inputSchema, + "outputSchema": entry.outputSchema, + } + for entry in tool_list.tools + ] + } + return MCPToolResult(outputs=[Content.from_text(json.dumps(payload))]) + except Exception as exc: # noqa: BLE001 - surface as tool error per protocol contract + message = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__ + return MCPToolResult( + outputs=[Content.from_text(f"Error: {message}")], + is_error=True, + error_message=message, + ) + finally: + with contextlib.suppress(Exception): + await tool.close() + + async def aclose(self) -> None: + await self._inner.aclose() + + async def __aenter__(self) -> "_ToolboxMcpToolHandler": + return self + + async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + await self.aclose() + + +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 + ) + + 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, + docs_server_label=docs_server_label, + project_endpoint=project_endpoint, + ) + + # 3. Resolve the toolbox MCP proxy URL and publish all dynamic values + # the YAML expects via ``Env.*``. Setting them after toolbox + # creation ensures the URL points at the freshly created version. + 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, + ) + os.environ["FOUNDRY_TOOLBOX_MCP_SERVER_URL"] = toolbox_endpoint + os.environ["FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL"] = docs_server_label + os.environ["FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME"] = web_search_tool_name + 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, + ) + + # 5. Build a bearer-authenticated httpx client. The same client is + # reused for every MCP request: the LRU cache inside + # ``DefaultMCPToolHandler`` will keep a single MCP session alive + # for the toolbox URL, and the ``tools/list`` interceptor reuses + # the same httpx client so headers / auth stay consistent. + # + # 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. + http_client = httpx.AsyncClient( + auth=_BearerAuth(credential), + headers=FOUNDRY_FEATURES_HEADERS, + timeout=httpx.Timeout(MCP_CONNECT_TIMEOUT_SECONDS, read=MCP_READ_TIMEOUT_SECONDS), + 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), returning ``None`` causes + # ``DefaultMCPToolHandler`` to fall back to an unauthenticated client, + # which will fail to authenticate to the proxy instead of forwarding + # the token outbound. Mirrors the .NET sample's + # ``httpClientProvider`` URL guard. + if invocation.server_url.casefold() != toolbox_endpoint.casefold(): + print( + f"[security] Refusing to attach Foundry bearer token to unexpected MCP URL: " + f"{invocation.server_url}", + file=sys.stderr, + ) + return None + return http_client + + async with ( + http_client, + DefaultMCPToolHandler(client_provider=_client_provider) as inner_handler, + _ToolboxMcpToolHandler(inner_handler, http_client) as mcp_handler, + ): + factory = WorkflowFactory( + agents={AGENT_NAME: summary_agent}, + mcp_tool_handler=mcp_handler, + # The workflow YAML references ``=Env.FOUNDRY_TOOLBOX_*`` to keep + # the sample's toolbox URL / tool names configurable without + # editing the YAML. ``WorkflowFactory`` defaults to ``safe_mode=True`` + # which would block those expressions; this sample opts in to the + # less-safe mode because we control both the YAML and the env + # vars. Do NOT copy this flag into a workflow that loads YAML + # from untrusted sources. + safe_mode=False, + ) + + workflow_path = Path(__file__).parent / "workflow.yaml" + workflow = factory.create_workflow_from_yaml_path(workflow_path) + + 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?" + + # 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. + 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 "") + 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: + continue + if not printed_prefix: + print("\nAgent: ", end="", flush=True) + printed_prefix = True + print(event.data, end="", flush=True) + produced_output = True + + if produced_output: + print() + else: + print("\n(no response produced)") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/03-workflows/declarative/invoke_foundry_toolbox_mcp/workflow.yaml b/python/samples/03-workflows/declarative/invoke_foundry_toolbox_mcp/workflow.yaml new file mode 100644 index 0000000000..d84b915e05 --- /dev/null +++ b/python/samples/03-workflows/declarative/invoke_foundry_toolbox_mcp/workflow.yaml @@ -0,0 +1,123 @@ +# +# This workflow demonstrates the InvokeMcpTool action against a Foundry +# toolbox MCP proxy that exposes BOTH a built-in Foundry tool +# (``web_search``) and an external MCP server (Microsoft Learn Docs) +# behind a single MCP-compatible endpoint. +# +# The workflow: +# 1. Accepts a documentation / web search query as input. +# 2. Lists the tools exposed by the toolbox using the reserved +# toolName: ``tools/list``. The Python ``DefaultMCPToolHandler`` +# does not intercept this name on its own; the sample's host code +# wraps it with a small handler that translates ``tools/list`` to +# an MCP ``session.list_tools()`` call. +# 3. Invokes the Microsoft Learn ``microsoft_docs_search`` MCP tool +# surfaced by the toolbox. Tool names from MCP-server-backed +# toolbox tools are namespaced as ``___``. +# 4. Invokes the built-in ``web_search`` tool through the same +# toolbox proxy. Note: ``web_search`` expects ``search_query`` +# (not ``query``). +# 5. Asks a Foundry agent to combine the two result sets in the +# conversation and answer the user's question. +# +# Workflow inputs (set by the host via ``workflow.run({...})``): +# text: The user's question (required). +# +# Example inputs: +# How do I configure logging in the Agent Framework? +# What is Azure AI Foundry? +# +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_invoke_foundry_toolbox_mcp + actions: + + # Set the search query from the workflow input so each MCP tool + # call can pass it as an argument. + - kind: SetVariable + id: set_search_query + variable: Local.SearchQuery + value: =Workflow.Inputs.text + + # List the tools exposed by the toolbox MCP proxy. The sample's + # custom MCPToolHandler intercepts the reserved ``tools/list`` name + # and returns the toolbox tool list as JSON. + # + # 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. + - kind: InvokeMcpTool + id: list_toolbox_tools + serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL + serverLabel: foundry_toolbox + toolName: tools/list + headers: + Foundry-Features: Toolboxes=V1Preview + output: + autoSend: false + result: Local.ToolboxTools + + # Invoke ``microsoft_docs_search`` from the Microsoft Learn MCP + # server. The toolbox prefixes MCP-server tools with the server + # label declared at toolbox-creation time. + # + # ``autoSend: false`` suppresses dumping the raw JSON result to the + # workflow output stream — the result is still parsed into + # ``Local.SearchResult`` AND appended to the conversation (via + # ``conversationId``) so the downstream agent can summarise it. + - kind: InvokeMcpTool + id: search_docs_with_toolbox + serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL + serverLabel: foundry_toolbox + toolName: =Env.FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL & "___microsoft_docs_search" + conversationId: =System.ConversationId + headers: + Foundry-Features: Toolboxes=V1Preview + arguments: + query: =Local.SearchQuery + output: + autoSend: false + result: Local.SearchResult + + # Invoke the built-in ``web_search`` tool through the same toolbox + # proxy. ``web_search`` is a Foundry built-in (not an MCP server), + # so it is NOT namespaced and expects the argument + # ``search_query`` (not ``query``). See the docs_search action + # above for why ``autoSend: false`` is used here. + - kind: InvokeMcpTool + id: search_web_with_toolbox + serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL + serverLabel: foundry_toolbox + toolName: =Env.FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME + conversationId: =System.ConversationId + headers: + Foundry-Features: Toolboxes=V1Preview + arguments: + search_query: =Local.SearchQuery + output: + autoSend: false + result: Local.WebSearchResult + + # Ask the agent to summarise the two toolbox results. The agent + # reads the prior conversation (which now contains both result + # sets via ``conversationId``) and produces a single answer. + - kind: InvokeAzureAgent + id: summarize_toolbox_result + agent: + name: FoundryToolboxMcpAgent + conversationId: =System.ConversationId + input: + messages: =Concat("Combine the Microsoft Learn docs results and the Foundry web search results in the conversation to answer the query ", Local.SearchQuery) + output: + autoSend: true + messages: Local.Summary