Update sample to support require approval to be toggled by environment variable.

This commit is contained in:
Peter Ibekwe
2026-05-04 13:47:24 -07:00
Unverified
parent 55e665ade0
commit 92cd194122
5 changed files with 133 additions and 9 deletions
@@ -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__":
@@ -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