Revamped sample to address PR comments.

This commit is contained in:
Peter Ibekwe
2026-05-22 08:58:19 -07:00
Unverified
parent 894b2c6ce0
commit 6ea7b45290
8 changed files with 118 additions and 233 deletions
@@ -2,19 +2,14 @@
"""Invoke a Foundry toolbox MCP endpoint from a declarative workflow.
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``.
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.
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
"""
@@ -34,25 +29,17 @@ from agent_framework.declarative import (
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,
build_toolbox_mcp_server_url,
create_sample_toolbox,
)
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 = """\
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.
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.
"""
@@ -71,33 +58,18 @@ async def main() -> None:
"""Run the Foundry toolbox MCP workflow."""
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
model = os.environ["FOUNDRY_MODEL"]
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)
print(f"Provisioning toolbox '{toolbox_name}' in Foundry...")
print(f"Provisioning toolbox '{TOOLBOX_NAME}' in Foundry...")
create_sample_toolbox(
name=toolbox_name,
docs_server_label=docs_server_label,
name=TOOLBOX_NAME,
docs_server_label=DOCS_SERVER_LABEL,
project_endpoint=project_endpoint,
)
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,
)
# 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,
}
toolbox_endpoint = f"{project_endpoint.rstrip('/')}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1"
print(f"Toolbox endpoint: {toolbox_endpoint}")
print()
@@ -109,7 +81,7 @@ async def main() -> None:
# 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``.
# default breaks long-running tool calls.
http_client = httpx.AsyncClient(
auth=_BearerAuth(credential),
headers=FOUNDRY_FEATURES_HEADERS,
@@ -136,46 +108,31 @@ async def main() -> None:
factory = WorkflowFactory(
agents={AGENT_NAME: summary_agent},
mcp_tool_handler=mcp_handler,
configuration=workflow_configuration,
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 one question that benefits from both Microsoft Learn docs and a web search.")
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
# 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
async for event in workflow.run({"text": user_input}, stream=True):
if event.type == "executor_invoked":
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 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 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)
produced_output = True
if produced_output:
print()
else:
print("\n(no response produced)")
print()
if __name__ == "__main__":
@@ -2,10 +2,10 @@
"""Foundry toolbox provisioning helper for ``invoke_foundry_toolbox_mcp``.
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.
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
@@ -23,27 +23,16 @@ from azure.identity import AzureCliCredential
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."""
return f"{project_endpoint.rstrip('/')}/toolboxes/{name}/mcp?api-version={api_version}"
def create_sample_toolbox(
*,
name: str,
docs_server_label: str,
project_endpoint: str,
docs_server_url: str = "https://learn.microsoft.com/api/mcp",
) -> None:
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 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.
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, WebSearchTool
from azure.ai.projects.models import MCPTool, Tool
from azure.core.exceptions import ResourceNotFoundError
with (
@@ -57,13 +46,16 @@ def create_sample_toolbox(
pass
tools: list[Tool] = [
MCPTool(server_label=docs_server_label, server_url=docs_server_url, require_approval="never"),
WebSearchTool(),
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 combining Microsoft Learn Docs MCP and Foundry web search.",
description="Sample toolbox exposing the Microsoft Learn Docs MCP server.",
tools=tools,
headers=FOUNDRY_FEATURES_HEADERS,
)
@@ -1,31 +1,12 @@
#
# 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.
# 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>``.
#
# 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``. ``DefaultMCPToolHandler`` intercepts
# this reserved name natively and translates it
# to an MCP ``session.list_tools()`` call, returning a JSON catalog.
# 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 ``<server_label>___<tool_name>``.
# 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({...})``):
# Workflow inputs:
# 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:
@@ -33,37 +14,14 @@ trigger:
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. 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
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.
# ``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
@@ -78,35 +36,13 @@ trigger:
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)
messages: '=Concat("Answer the query using the Microsoft Learn docs result already in the conversation: ", Local.SearchQuery)'
output:
autoSend: true
messages: Local.Summary