mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
bd4fc64b4d
commit
200488cb08
@@ -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())
|
||||
+62
@@ -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
|
||||
Reference in New Issue
Block a user