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 5c08893f89..39f5d48872 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py @@ -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.`` (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.``; + ``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 = {} diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_builder.py b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_builder.py index 400ee48896..6dad15f95b 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_builder.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_builder.py @@ -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): diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py index f43be8094d..10206b13c5 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py @@ -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.``; 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.``; 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 diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_mcp_handler.py b/python/packages/declarative/agent_framework_declarative/_workflows/_mcp_handler.py index 8daa60dc9c..4a26e74c88 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_mcp_handler.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_mcp_handler.py @@ -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 diff --git a/python/packages/declarative/tests/test_default_mcp_tool_handler.py b/python/packages/declarative/tests/test_default_mcp_tool_handler.py index 027158e096..58ce3144ee 100644 --- a/python/packages/declarative/tests/test_default_mcp_tool_handler.py +++ b/python/packages/declarative/tests/test_default_mcp_tool_handler.py @@ -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" 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 index becd956b58..9592919e33 100644 --- a/python/samples/03-workflows/declarative/invoke_foundry_toolbox_mcp/main.py +++ b/python/samples/03-workflows/declarative/invoke_foundry_toolbox_mcp/main.py @@ -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__": diff --git a/python/samples/03-workflows/declarative/invoke_foundry_toolbox_mcp/toolbox_provisioning.py b/python/samples/03-workflows/declarative/invoke_foundry_toolbox_mcp/toolbox_provisioning.py index 8d3fc7e023..73cd792346 100644 --- a/python/samples/03-workflows/declarative/invoke_foundry_toolbox_mcp/toolbox_provisioning.py +++ b/python/samples/03-workflows/declarative/invoke_foundry_toolbox_mcp/toolbox_provisioning.py @@ -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, ) 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 index 12319c6f78..755bf7b18c 100644 --- a/python/samples/03-workflows/declarative/invoke_foundry_toolbox_mcp/workflow.yaml +++ b/python/samples/03-workflows/declarative/invoke_foundry_toolbox_mcp/workflow.yaml @@ -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 ``___``. # -# 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 ``___``. -# 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