mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Revamped sample to address PR comments.
This commit is contained in:
+41
-36
@@ -63,24 +63,19 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_ENV_REFERENCE_RE = re.compile(r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)")
|
||||
|
||||
_EMPTY_ENV_VALUES: Mapping[str, str] = MappingProxyType({})
|
||||
_EMPTY_ENV_REFERENCES: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeclarativeEnvConfig:
|
||||
"""Configuration that populates the PowerFx ``Env`` symbol for a workflow.
|
||||
|
||||
Mirrors the .NET ``IConfiguration``-driven environment binding in
|
||||
``Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs``.
|
||||
The configuration values are always exposed under ``Env.<name>`` (matching
|
||||
``IConfiguration`` semantics), and ``os.environ`` is consulted only when
|
||||
``restrict_to_configuration`` is ``False`` and the YAML literally
|
||||
references the name in a PowerFx expression.
|
||||
Configuration values are always exposed under ``Env.<name>``;
|
||||
``os.environ`` is consulted only when ``restrict_to_configuration``
|
||||
is ``False`` AND the YAML literally references the name in a PowerFx
|
||||
expression (the allowlist enforced via ``referenced_names``).
|
||||
|
||||
Attributes:
|
||||
values: Caller-supplied configuration values resolved by name when
|
||||
the workflow YAML references ``=Env.NAME``. Always exposed in
|
||||
values: Caller-supplied configuration resolved by name when the
|
||||
workflow YAML references ``=Env.NAME``. Always exposed in
|
||||
the ``Env`` symbol regardless of ``restrict_to_configuration``.
|
||||
restrict_to_configuration: When ``True`` (default), the ``Env``
|
||||
symbol is populated exclusively from ``values``; ``os.environ``
|
||||
@@ -93,12 +88,35 @@ class DeclarativeEnvConfig:
|
||||
unrelated environment variables never enter the PowerFx scope.
|
||||
"""
|
||||
|
||||
values: Mapping[str, str] = field(default_factory=lambda: _EMPTY_ENV_VALUES)
|
||||
values: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({}))
|
||||
restrict_to_configuration: bool = True
|
||||
referenced_names: frozenset[str] = _EMPTY_ENV_REFERENCES
|
||||
referenced_names: frozenset[str] = field(default_factory=lambda: frozenset[str]())
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Defensive snapshots so the frozen guarantee extends to the
|
||||
# contents of ``values`` / ``referenced_names``: caller mutations
|
||||
# to the original objects after construction cannot leak into
|
||||
# ``resolve()``.
|
||||
object.__setattr__(self, "values", MappingProxyType(dict(self.values)))
|
||||
object.__setattr__(self, "referenced_names", frozenset(self.referenced_names))
|
||||
|
||||
_EMPTY_ENV_CONFIG = DeclarativeEnvConfig()
|
||||
def resolve(self) -> dict[str, str]:
|
||||
"""Return the resolved ``Env`` symbol mapping for the workflow.
|
||||
|
||||
Configuration values are always included (stringified).
|
||||
``os.environ`` is consulted only when ``restrict_to_configuration``
|
||||
is ``False`` and the name appears in ``referenced_names``, so
|
||||
unrelated environment variables never enter the PowerFx scope.
|
||||
Configuration values always win over the environment fallback.
|
||||
"""
|
||||
resolved = {name: str(value) for name, value in self.values.items()}
|
||||
if self.restrict_to_configuration:
|
||||
return resolved
|
||||
for name in self.referenced_names.difference(resolved):
|
||||
env_value = os.environ.get(name)
|
||||
if env_value is not None:
|
||||
resolved[name] = env_value
|
||||
return resolved
|
||||
|
||||
|
||||
def discover_env_references(node: Any) -> set[str]:
|
||||
@@ -248,7 +266,7 @@ class DeclarativeWorkflowState:
|
||||
- Conversation: Conversation history
|
||||
"""
|
||||
|
||||
def __init__(self, state: State, env_config: DeclarativeEnvConfig = _EMPTY_ENV_CONFIG):
|
||||
def __init__(self, state: State, env_config: DeclarativeEnvConfig | None = None):
|
||||
"""Initialize with a State instance.
|
||||
|
||||
Args:
|
||||
@@ -259,7 +277,7 @@ class DeclarativeWorkflowState:
|
||||
matching the safe default of the :class:`WorkflowFactory`.
|
||||
"""
|
||||
self._state = state
|
||||
self._env_config = env_config
|
||||
self._env_config = env_config if env_config is not None else DeclarativeEnvConfig()
|
||||
|
||||
def initialize(self, inputs: Mapping[str, Any] | None = None) -> None:
|
||||
"""Initialize the declarative state with inputs.
|
||||
@@ -798,25 +816,12 @@ class DeclarativeWorkflowState:
|
||||
# Custom namespaces
|
||||
**state_data.get("Custom", {}),
|
||||
}
|
||||
# Populate the ``Env`` symbol from the workflow-level
|
||||
# :class:`DeclarativeEnvConfig`. Caller-supplied configuration
|
||||
# values are always exposed (matching .NET ``IConfiguration``
|
||||
# semantics); ``os.environ`` is consulted only when
|
||||
# ``restrict_to_configuration`` is ``False`` and the YAML explicitly
|
||||
# references the name in a PowerFx expression. When both sources
|
||||
# produce no values the ``Env`` symbol is omitted entirely so
|
||||
# ``=Env.X`` resolves to the literal expression string (preserving
|
||||
# the legacy "unbound identifier" fallback behaviour).
|
||||
env_bound: dict[str, str] = {}
|
||||
for name, value in self._env_config.values.items():
|
||||
env_bound[name] = str(value)
|
||||
if not self._env_config.restrict_to_configuration:
|
||||
for name in self._env_config.referenced_names:
|
||||
if name in env_bound:
|
||||
continue
|
||||
env_value = os.environ.get(name)
|
||||
if env_value is not None:
|
||||
env_bound[name] = env_value
|
||||
# Resolve the ``Env`` symbol from the workflow-level
|
||||
# :class:`DeclarativeEnvConfig`. When both ``values`` and the
|
||||
# ``os.environ`` allowlist produce no entries the symbol is
|
||||
# omitted so ``=Env.X`` falls back to the literal expression
|
||||
# string (preserving the legacy "unbound identifier" behaviour).
|
||||
env_bound = self._env_config.resolve()
|
||||
if env_bound:
|
||||
symbols["Env"] = env_bound
|
||||
# Debug log the Local symbols to help diagnose type issues
|
||||
@@ -976,7 +981,7 @@ class DeclarativeActionExecutor(Executor):
|
||||
# executor by :class:`DeclarativeWorkflowBuilder` after construction.
|
||||
# Defaults to an empty configuration so direct ``DeclarativeActionExecutor``
|
||||
# construction (e.g. in unit tests) doesn't expose ``os.environ``.
|
||||
self._declarative_env_config: DeclarativeEnvConfig = _EMPTY_ENV_CONFIG
|
||||
self._declarative_env_config: DeclarativeEnvConfig = DeclarativeEnvConfig()
|
||||
|
||||
# Manually register handlers after initialization
|
||||
self._handlers = {}
|
||||
|
||||
+1
-2
@@ -22,7 +22,6 @@ from agent_framework import (
|
||||
)
|
||||
|
||||
from ._declarative_base import (
|
||||
_EMPTY_ENV_CONFIG, # type: ignore[reportPrivateUsage]
|
||||
ConditionResult,
|
||||
DeclarativeActionExecutor,
|
||||
DeclarativeEnvConfig,
|
||||
@@ -178,7 +177,7 @@ class DeclarativeWorkflowBuilder:
|
||||
self._seen_explicit_ids: set[str] = set() # Track explicit IDs for duplicate detection
|
||||
self._http_request_handler = http_request_handler
|
||||
self._mcp_tool_handler = mcp_tool_handler
|
||||
self._env_config: DeclarativeEnvConfig = env_config if env_config is not None else _EMPTY_ENV_CONFIG
|
||||
self._env_config: DeclarativeEnvConfig = env_config if env_config is not None else DeclarativeEnvConfig()
|
||||
# Resolve max_iterations: explicit arg > YAML maxTurns > core default
|
||||
resolved = max_iterations if max_iterations is not None else yaml_definition.get("maxTurns")
|
||||
if resolved is not None and (not isinstance(resolved, int) or resolved <= 0):
|
||||
|
||||
@@ -124,14 +124,12 @@ class WorkflowFactory:
|
||||
or auth/connection resolution.
|
||||
configuration: Optional mapping that populates the PowerFx ``Env``
|
||||
symbol referenced from workflow YAML expressions (e.g.
|
||||
``=Env.MY_KEY``). Mirrors the .NET ``IConfiguration``-based
|
||||
pattern in
|
||||
``dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs``.
|
||||
Keys supplied here are always exposed under ``Env.<key>``; the
|
||||
process ``os.environ`` is consulted only when
|
||||
``restrict_env_to_configuration`` is ``False``. When neither
|
||||
source produces a value the ``Env`` symbol is omitted so
|
||||
``=Env.X`` evaluates to the literal expression string.
|
||||
``=Env.MY_KEY``). Keys supplied here are always exposed
|
||||
under ``Env.<key>``; the process ``os.environ`` is consulted
|
||||
only when ``restrict_env_to_configuration`` is ``False``.
|
||||
When neither source produces a value the ``Env`` symbol is
|
||||
omitted so ``=Env.X`` evaluates to the literal expression
|
||||
string.
|
||||
restrict_env_to_configuration: When ``True`` (default), the
|
||||
``Env`` PowerFx symbol is populated exclusively from
|
||||
``configuration``; ``os.environ`` is never consulted. Set to
|
||||
|
||||
+13
-15
@@ -198,11 +198,12 @@ class DefaultMCPToolHandler:
|
||||
"""Reserved ``tool_name`` that maps an :class:`MCPToolHandler` invocation
|
||||
to the MCP protocol ``tools/list`` discovery operation.
|
||||
|
||||
Mirrors the .NET ``DefaultMcpToolHandler.ListToolsToolName`` public
|
||||
constant for cross-language discoverability. When this handler receives
|
||||
an invocation with this name it pages through ``session.list_tools()``
|
||||
and returns the catalog as a single ``TextContent`` containing JSON of
|
||||
shape ``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
|
||||
The constant matches the underlying MCP method name so a single
|
||||
string travels unchanged through host code, YAML, and the protocol
|
||||
wire. When this handler receives an invocation with this name it
|
||||
pages through ``session.list_tools()`` and returns the catalog as a
|
||||
single ``TextContent`` containing JSON of shape
|
||||
``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
|
||||
Workflows can reference this name from an ``InvokeMcpTool`` declarative
|
||||
action to introspect a server's tool surface without an extra round-trip
|
||||
from host code.
|
||||
@@ -237,9 +238,7 @@ class DefaultMCPToolHandler:
|
||||
intercepted client-side: instead of being forwarded as a tool call,
|
||||
it is translated to an MCP ``session.list_tools()`` discovery
|
||||
operation (paginated automatically) and returned as a single
|
||||
``TextContent`` containing a JSON tool catalog. Matches the .NET
|
||||
``DefaultMcpToolHandler`` behaviour so the same YAML works
|
||||
cross-language.
|
||||
``TextContent`` containing a JSON tool catalog.
|
||||
"""
|
||||
from agent_framework import Content
|
||||
from agent_framework.exceptions import ToolExecutionException
|
||||
@@ -328,13 +327,12 @@ class DefaultMCPToolHandler:
|
||||
full catalog as a single ``TextContent`` containing JSON of shape
|
||||
``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
|
||||
|
||||
The output shape, property names, and property order match
|
||||
``DefaultMcpToolHandler.SerializeToolsList`` in the .NET reference
|
||||
implementation so the same workflow YAML can consume the result
|
||||
cross-language. ``indent=2`` matches ``Utf8JsonWriter`` ``Indented``
|
||||
mode; ``allow_nan=False`` guards against producing non-conformant
|
||||
JSON ``NaN``/``Infinity`` tokens if a misbehaving server returns
|
||||
such values in a schema.
|
||||
The output shape, property names, and property order are stable so
|
||||
downstream PowerFx expressions can rely on the schema. ``indent=2``
|
||||
produces human-readable JSON for the conversation log;
|
||||
``allow_nan=False`` guards against producing non-conformant JSON
|
||||
``NaN``/``Infinity`` tokens if a misbehaving server returns such
|
||||
values in a schema.
|
||||
"""
|
||||
from agent_framework import Content
|
||||
|
||||
|
||||
@@ -644,8 +644,8 @@ class TestListTools:
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_property_order_matches_dotnet(self) -> None:
|
||||
"""The JSON property order must match .NET SerializeToolsList: name, description, inputSchema, outputSchema."""
|
||||
async def test_list_tools_property_order_is_stable(self) -> None:
|
||||
"""JSON property order is stable: name, description, inputSchema, outputSchema."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
@@ -662,7 +662,7 @@ class TestListTools:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_indented_output(self) -> None:
|
||||
"""Output is indented with 2-space indent (matches .NET ``Indented=true``)."""
|
||||
"""Output is JSON with a 2-space indent so the conversation log is human-readable."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
@@ -774,6 +774,6 @@ class TestListTools:
|
||||
assert result.is_error is False
|
||||
|
||||
def test_class_attribute_value(self) -> None:
|
||||
# Constant name MUST be the MCP protocol method name for cross-language parity
|
||||
# with .NET ``DefaultMcpToolHandler.ListToolsToolName``.
|
||||
# Constant must equal the MCP protocol method name so a single
|
||||
# string travels unchanged through host code, YAML, and the wire.
|
||||
assert DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME == "tools/list"
|
||||
|
||||
@@ -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__":
|
||||
|
||||
+16
-24
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user