Python: Add Python parity sample for invoking Foundry Toolbox tools from declarative workflows (#5933)

* Add Python parity sample for invoking Foundry Toolbox tools from declarative workflows

* Python: address PR review on declarative toolbox sample

Two security fixes for PR #5933:

1. Add safe_mode flag to WorkflowFactory (default True) mirroring
   AgentFactory. Gates =Env.* exposure inside DeclarativeWorkflowState
   PowerFx symbols via _safe_mode_context, so workflow YAML loaded from
   untrusted sources no longer leaks the host's full os.environ snapshot
   into PowerFx evaluation. The flag is also forwarded to the
   internally-constructed AgentFactory so inline agent definitions
   follow the same policy.

2. Pin the invoke_foundry_toolbox_mcp sample's _client_provider to the
   resolved toolbox endpoint. The bearer-authenticated httpx client is
   now only returned when MCPToolInvocation.server_url matches the
   toolbox URL case-insensitively; any other URL gets None (the default
   unauthenticated path), preventing the Foundry AAD bearer token from
   being attached to a mis-configured or injected server URL. Mirrors
   the .NET sample's httpClientProvider guard.

The sample is updated to opt in to safe_mode=False because its YAML
intentionally uses =Env.FOUNDRY_TOOLBOX_* to keep configuration in env
vars under the developer's control.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix pyright issues.

* Addressed PR comments.

* Fix CI pipelines.

* Resolve PR comments

* Revamped sample to address PR comments.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Peter Ibekwe
2026-05-26 08:36:33 -07:00
committed by GitHub
Unverified
parent bd4fc64b4d
commit 200488cb08
9 changed files with 764 additions and 5 deletions
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,139 @@
# Copyright (c) Microsoft. All rights reserved.
"""Invoke a Foundry toolbox MCP endpoint from a declarative workflow.
The workflow calls ``microsoft_docs_search`` (the Microsoft Learn Docs
MCP server, bundled into a sample toolbox by ``toolbox_provisioning``)
through the toolbox proxy and asks a Foundry agent to summarise the
result.
Required env vars:
FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_MODEL.
Run with:
python samples/03-workflows/declarative/invoke_foundry_toolbox_mcp/main.py
"""
import asyncio
import os
from collections.abc import Generator
from pathlib import Path
import httpx
from agent_framework import Agent
from agent_framework.declarative import (
DefaultMCPToolHandler,
MCPToolInvocation,
WorkflowFactory,
)
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 FOUNDRY_FEATURES_HEADERS, create_sample_toolbox
AGENT_NAME = "FoundryToolboxMcpAgent"
TOOLBOX_NAME = "declarative_foundry_toolbox_mcp"
DOCS_SERVER_LABEL = "microsoft_docs"
AGENT_INSTRUCTIONS = """\
Answer the user's question using ONLY the Microsoft Learn docs search
result already present in the conversation. Cite document titles or
URLs when available. If the result does not contain an answer, say so
plainly rather than guessing.
"""
class _BearerAuth(httpx.Auth):
"""Inject a fresh Azure AD bearer token on every request."""
def __init__(self, credential: TokenCredential) -> None:
self._get_token = get_bearer_token_provider(credential, "https://ai.azure.com/.default")
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."""
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
model = os.environ["FOUNDRY_MODEL"]
print("=" * 60)
print("Invoke Foundry Toolbox MCP Workflow Demo")
print("=" * 60)
print(f"Provisioning toolbox '{TOOLBOX_NAME}' in Foundry...")
create_sample_toolbox(
name=TOOLBOX_NAME,
docs_server_label=DOCS_SERVER_LABEL,
project_endpoint=project_endpoint,
)
toolbox_endpoint = f"{project_endpoint.rstrip('/')}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1"
print(f"Toolbox endpoint: {toolbox_endpoint}")
print()
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)
# ``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.
http_client = httpx.AsyncClient(
auth=_BearerAuth(credential),
headers=FOUNDRY_FEATURES_HEADERS,
timeout=httpx.Timeout(30.0, read=300.0),
follow_redirects=True,
)
async def _client_provider(invocation: MCPToolInvocation) -> httpx.AsyncClient | None:
# 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: "
f"{invocation.server_url!r}. Expected: {toolbox_endpoint!r}."
)
return http_client
async with (
http_client,
DefaultMCPToolHandler(client_provider=_client_provider) as mcp_handler,
):
factory = WorkflowFactory(
agents={AGENT_NAME: summary_agent},
mcp_tool_handler=mcp_handler,
configuration={
"FOUNDRY_TOOLBOX_MCP_SERVER_URL": toolbox_endpoint,
"FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL": DOCS_SERVER_LABEL,
},
)
workflow = factory.create_workflow_from_yaml_path(Path(__file__).parent / "workflow.yaml")
print("Ask a question that can be answered from the Microsoft Learn docs.")
print()
user_input = input("You: ").strip() or "How do I configure logging in the Agent Framework?" # noqa: ASYNC250
printed_prefix = False
async for event in workflow.run({"text": user_input}, stream=True):
if event.type == "executor_invoked":
if event.executor_id == "search_docs_with_toolbox":
print("[Searching Microsoft Learn docs...]")
elif event.executor_id == "summarize_toolbox_result":
print("[Summarizing results...]")
elif event.type == "output" and isinstance(event.data, str):
if not printed_prefix:
print("\nAgent: ", end="", flush=True)
printed_prefix = True
print(event.data, end="", flush=True)
print()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,62 @@
# Copyright (c) Microsoft. All rights reserved.
"""Foundry toolbox provisioning helper for ``invoke_foundry_toolbox_mcp``.
Toolboxes are normally created through the Foundry portal or a separate
deployment script. Bundling the one-off 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
# 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 create_sample_toolbox(*, name: str, docs_server_label: str, project_endpoint: str) -> None:
"""Provision a toolbox version (delete-then-create; idempotent).
Bundles the Microsoft Learn Docs MCP server under ``docs_server_label``.
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
from azure.core.exceptions import ResourceNotFoundError
with (
AzureCliCredential() 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="https://learn.microsoft.com/api/mcp",
require_approval="never",
),
]
created = project_client.beta.toolboxes.create_version(
name=name,
description="Sample toolbox exposing the Microsoft Learn Docs MCP server.",
tools=tools,
headers=FOUNDRY_FEATURES_HEADERS,
)
print(f"Created toolbox {created.name}@{created.version} ({len(created.tools)} tool(s)).")
@@ -0,0 +1,48 @@
#
# Calls the Microsoft Learn Docs MCP server through a Foundry toolbox
# proxy from a declarative workflow, then asks a Foundry agent to
# summarise the result. The toolbox surfaces MCP-server-backed tools
# as ``<server_label>___<tool_name>``.
#
# Workflow inputs:
# text: The user's question (required).
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_invoke_foundry_toolbox_mcp
actions:
- kind: SetVariable
id: set_search_query
variable: Local.SearchQuery
value: =Workflow.Inputs.text
# ``autoSend: false`` so the raw JSON tool result is not echoed to
# the host's output stream; ``conversationId`` still appends it to
# the conversation so the summarising agent can read 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
- kind: InvokeAzureAgent
id: summarize_toolbox_result
agent:
name: FoundryToolboxMcpAgent
conversationId: =System.ConversationId
input:
messages: '=Concat("Answer the query using the Microsoft Learn docs result already in the conversation: ", Local.SearchQuery)'
output:
autoSend: true
messages: Local.Summary