diff --git a/python/packages/core/agent_framework/declarative/__init__.py b/python/packages/core/agent_framework/declarative/__init__.py index 90c73ef8bd..b5e9c9ef9e 100644 --- a/python/packages/core/agent_framework/declarative/__init__.py +++ b/python/packages/core/agent_framework/declarative/__init__.py @@ -37,6 +37,8 @@ _IMPORTS = [ "MCPToolResult", "ProviderLookupError", "ProviderTypeMapping", + "ToolApprovalRequest", + "ToolApprovalResponse", "WorkflowFactory", "WorkflowState", ] diff --git a/python/packages/core/agent_framework/declarative/__init__.pyi b/python/packages/core/agent_framework/declarative/__init__.pyi index bd6bf73fba..c64e730441 100644 --- a/python/packages/core/agent_framework/declarative/__init__.pyi +++ b/python/packages/core/agent_framework/declarative/__init__.pyi @@ -20,6 +20,8 @@ from agent_framework_declarative import ( MCPToolResult, ProviderLookupError, ProviderTypeMapping, + ToolApprovalRequest, + ToolApprovalResponse, WorkflowFactory, WorkflowState, ) @@ -44,6 +46,8 @@ __all__ = [ "MCPToolResult", "ProviderLookupError", "ProviderTypeMapping", + "ToolApprovalRequest", + "ToolApprovalResponse", "WorkflowFactory", "WorkflowState", ] diff --git a/python/packages/declarative/agent_framework_declarative/__init__.py b/python/packages/declarative/agent_framework_declarative/__init__.py index ad639fb521..84bc404d5d 100644 --- a/python/packages/declarative/agent_framework_declarative/__init__.py +++ b/python/packages/declarative/agent_framework_declarative/__init__.py @@ -19,6 +19,8 @@ from ._workflows import ( MCPToolHandler, MCPToolInvocation, MCPToolResult, + ToolApprovalRequest, + ToolApprovalResponse, WorkflowFactory, WorkflowState, ) @@ -48,6 +50,8 @@ __all__ = [ "MCPToolResult", "ProviderLookupError", "ProviderTypeMapping", + "ToolApprovalRequest", + "ToolApprovalResponse", "WorkflowFactory", "WorkflowState", "__version__", diff --git a/python/samples/03-workflows/declarative/invoke_mcp_tool/main.py b/python/samples/03-workflows/declarative/invoke_mcp_tool/main.py index c95b0c4691..5d08cd5bf0 100644 --- a/python/samples/03-workflows/declarative/invoke_mcp_tool/main.py +++ b/python/samples/03-workflows/declarative/invoke_mcp_tool/main.py @@ -11,6 +11,12 @@ This sample shows how to: 3. Bind the parsed tool result to a workflow variable and mirror it into the conversation via ``conversationId`` so a downstream Foundry agent can answer questions using only that context. + 4. Optionally pause the MCP tool call for human approval. The YAML reads + ``requireApproval`` from ``Workflow.Inputs.requireApproval`` so the + host can flip the behaviour without editing the workflow definition. + Set the ``MCP_REQUIRE_APPROVAL`` environment variable (``1`` / ``true`` + / ``yes``) to enable the approval flow; leave it unset for the + "fire-and-forget" default. Security note: ``DefaultMCPToolHandler`` connects to whatever MCP server URL the @@ -21,8 +27,16 @@ Security note: therefore share the same prompt-injection risk surface as ``HttpRequestAction``: only invoke MCP servers you trust. + The approval flow is also a defence-in-depth control: even with a + trusted server, requiring human approval lets a reviewer inspect + tool name, arguments, and outbound header NAMES (never values) + before any network call is made. + Run with: python -m samples.03-workflows.declarative.invoke_mcp_tool.main + +Run with approval prompts: + MCP_REQUIRE_APPROVAL=1 python -m samples.03-workflows.declarative.invoke_mcp_tool.main """ import asyncio @@ -32,6 +46,8 @@ from pathlib import Path from agent_framework import Agent from agent_framework.declarative import ( DefaultMCPToolHandler, + MCPToolApprovalRequest, + ToolApprovalResponse, WorkflowFactory, ) from agent_framework.foundry import FoundryChatClient @@ -44,6 +60,44 @@ not contained in the conversation, say so plainly rather than guessing. Be concise and cite the relevant document title or URL when possible. """ +_TRUTHY = {"1", "true", "yes", "on"} + + +def _read_require_approval_flag() -> bool: + """Return True when the MCP_REQUIRE_APPROVAL env var requests approval.""" + return os.environ.get("MCP_REQUIRE_APPROVAL", "").strip().lower() in _TRUTHY + + +def _prompt_for_approval(request: MCPToolApprovalRequest) -> ToolApprovalResponse: + """Render the pending MCP call to stdout and read approve/reject from the user.""" + print() + print("-" * 60) + print("MCP tool approval required") + print("-" * 60) + print(f" tool: {request.tool_name}") + print(f" server label: {request.server_label or '(unset)'}") + print(f" server url: {request.server_url}") + if request.arguments: + print(" arguments:") + for key, value in request.arguments.items(): + print(f" {key}: {value!r}") + if request.header_names: + # Only NAMES are surfaced; values are intentionally withheld because + # they typically carry authentication secrets. + print(f" outbound header names: {', '.join(request.header_names)}") + else: + print(" outbound header names: (none)") + print("-" * 60) + + while True: + answer = input("Approve this MCP call? [y/N] ").strip().lower() # noqa: ASYNC250 + if answer in {"y", "yes"}: + return ToolApprovalResponse(approved=True) + if answer in {"", "n", "no"}: + reason = input("Reason for rejection (optional): ").strip() # noqa: ASYNC250 + return ToolApprovalResponse(approved=False, reason=reason or None) + print("Please answer 'y' or 'n'.") + async def main() -> None: """Run the invoke MCP tool workflow.""" @@ -63,6 +117,8 @@ async def main() -> None: agents = {"DocsAgent": docs_agent} + require_approval = _read_require_approval_flag() + # The default MCPToolHandler is sufficient for this sample because the # Microsoft Learn Docs MCP server is public and unauthenticated. For # authenticated servers, supply a ``client_provider`` callback to route @@ -80,6 +136,10 @@ async def main() -> None: print("=" * 60) print("Invoke MCP Tool Workflow Demo") + if require_approval: + print("(MCP_REQUIRE_APPROVAL is set — you will be prompted before the tool runs)") + else: + print("(set MCP_REQUIRE_APPROVAL=1 to enable the human-approval flow)") print("=" * 60) print() print("Ask one question that can be answered from the Microsoft Learn docs or provide a keyword to search.") @@ -89,11 +149,52 @@ async def main() -> None: if not user_input: user_input = "What is the Agent Framework declarative workflow runtime?" - print("\nAgent: ", end="", flush=True) - async for event in workflow.run(user_input, stream=True): - if event.type == "output" and isinstance(event.data, str): - print(event.data, end="", flush=True) - print() + # Drive the workflow via dict-shaped inputs so the YAML can read + # both the user's question (``Workflow.Inputs.text``) and the + # approval toggle (``Workflow.Inputs.requireApproval``) without + # any Python-side mutation of the workflow definition. + workflow_inputs: dict[str, object] = { + "text": user_input, + "requireApproval": require_approval, + } + + # The request_info loop below handles the MCP approval flow when + # the YAML requests it. When ``requireApproval`` is false the + # workflow never emits an ``MCPToolApprovalRequest`` event, so + # the loop runs exactly once and exits cleanly — both modes share + # the same code path. + pending: tuple[str, MCPToolApprovalRequest] | None = None + produced_output = False + printed_agent_prefix = False + + while True: + if pending is None: + stream = workflow.run(workflow_inputs, stream=True) + else: + pending_id, pending_request = pending + response = _prompt_for_approval(pending_request) + stream = workflow.run(stream=True, responses={pending_id: response}) + pending = None + + async for event in stream: + if event.type == "output" and isinstance(event.data, str): + if not printed_agent_prefix: + print("\nAgent: ", end="", flush=True) + printed_agent_prefix = True + print(event.data, end="", flush=True) + produced_output = True + elif event.type == "request_info" and isinstance(event.data, MCPToolApprovalRequest): + pending = (event.request_id, event.data) + + if pending is None: + if not produced_output: + # Workflow finished without producing any agent output + # (e.g. the user rejected the MCP tool call and the + # downstream agent had nothing to summarise). + print("\n(no response produced)") + else: + print() + break if __name__ == "__main__": diff --git a/python/samples/03-workflows/declarative/invoke_mcp_tool/workflow.yaml b/python/samples/03-workflows/declarative/invoke_mcp_tool/workflow.yaml index b83dc052ff..55f9f0754d 100644 --- a/python/samples/03-workflows/declarative/invoke_mcp_tool/workflow.yaml +++ b/python/samples/03-workflows/declarative/invoke_mcp_tool/workflow.yaml @@ -19,6 +19,12 @@ # How do I configure logging in the Agent Framework? # Gpt-5.4-mini # +# Workflow inputs (set by the host via ``workflow.run({...})``): +# text: The user's question (required). +# requireApproval: Optional bool. When true, the MCP tool call pauses for +# human approval before contacting the server. Defaults +# to false when omitted. +# kind: Workflow trigger: @@ -31,18 +37,23 @@ trigger: - kind: SetVariable id: capture_query variable: Local.SearchQuery - value: =System.LastMessage.Text + value: =Workflow.Inputs.text # Invoke microsoft_docs_search on the Microsoft Learn Docs MCP server. # The result is parsed into Local.SearchResults and also added to the # conversation (via conversationId) so the agent below can answer the # user's question based on it. + # + # ``requireApproval`` reads from Workflow.Inputs so the host can toggle + # the human-approval flow without editing this YAML. When the input is + # absent or evaluates to a falsy value, the tool runs without pausing. - kind: InvokeMcpTool id: search_docs conversationId: =System.ConversationId serverUrl: https://learn.microsoft.com/api/mcp serverLabel: MicrosoftLearnDocs toolName: microsoft_docs_search + requireApproval: =Workflow.Inputs.requireApproval arguments: query: =Local.SearchQuery output: @@ -51,14 +62,16 @@ trigger: # Use the agent to answer the user's question using the conversation # context (which now contains the MCP search results). The user's - # original message is already in the conversation as System.LastMessage, - # and the executor's input fallback chain extracts its ``Text`` field - # automatically when ``input.messages`` is omitted. + # question is supplied via ``input.messages`` (sourced from the workflow + # inputs), and the prior conversation history is bound via + # ``conversationId``. - kind: InvokeAzureAgent id: answer_question conversationId: =System.ConversationId agent: name: DocsAgent + input: + messages: =Workflow.Inputs.text output: autoSend: true messages: Local.AgentResponse