Skip to content

Commit 3da48a3

Browse files
Python: [Breaking] Add MCP tool approval callback for Azure AI Agent (#14210)
## Summary - Add an Azure AI Agent callback for MCP tool approval requests. - Route MCP tool approval submissions through a shared helper in thread actions. - Add unit coverage for approval, denial, callback failures, and per-call decisions. ## Testing - python -m pytest tests/unit/agents/azure_ai_agent --------- Copilot-Session: 3e190979-228b-4a82-8e63-5ad14bd4879f
1 parent e78a8f5 commit 3da48a3

6 files changed

Lines changed: 395 additions & 21 deletions

File tree

‎python/samples/concepts/agents/azure_ai_agent/azure_ai_agent_mcp_streaming.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ async def main() -> None:
4040

4141
# Optionally you may configure to require approval
4242
# Allowed values are "never" or "always"
43+
# When set to "always", Semantic Kernel asks the `mcp_tool_approval_callback` configured on the
44+
# agent before each MCP tool call, and denies the call if no callback is configured.
4345
mcp_tool.set_approval_mode("never")
4446

4547
# 2. Create an agent with the MCP tool on the Azure AI agent service

‎python/semantic_kernel/agents/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
"AzureAIAgent": ".azure_ai.azure_ai_agent",
1616
"AzureAIAgentSettings": ".azure_ai.azure_ai_agent_settings",
1717
"AzureAIAgentThread": ".azure_ai.azure_ai_agent",
18+
"MCPToolApprovalCallback": ".azure_ai.mcp_tool_approval",
19+
"MCPToolApprovalRequest": ".azure_ai.mcp_tool_approval",
1820
"AzureAssistantAgent": ".open_ai.azure_assistant_agent",
1921
"AssistantAgentThread": ".open_ai.openai_assistant_agent",
2022
"AzureResponsesAgent": ".open_ai.azure_responses_agent",

‎python/semantic_kernel/agents/azure_ai/agent_thread_actions.py‎

Lines changed: 79 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

33
import asyncio
4+
import inspect
45
import logging
56
from collections.abc import AsyncIterable
67
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar, cast
@@ -64,6 +65,7 @@
6465
get_function_call_contents,
6566
)
6667
from semantic_kernel.agents.azure_ai.azure_ai_agent_utils import AzureAIAgentUtils
68+
from semantic_kernel.agents.azure_ai.mcp_tool_approval import MCPToolApprovalRequest
6769
from semantic_kernel.agents.open_ai.assistant_content_generation import merge_streaming_function_results
6870
from semantic_kernel.agents.open_ai.function_action_result import FunctionActionResult
6971
from semantic_kernel.agents.open_ai.run_polling_options import RunPollingOptions
@@ -280,16 +282,12 @@ async def invoke(
280282
yield False, generate_mcp_call_content(agent_name=agent.name, mcp_tool_calls=mcp_tool_calls)
281283

282284
# Create tool approvals for MCP calls
283-
tool_approvals = []
284-
for mcp_call in mcp_tool_calls:
285-
tool_approvals.append(
286-
ToolApproval(
287-
tool_call_id=mcp_call.id,
288-
# TODO(evmattso): we don't support manual tool calling yet
289-
# so we always approve
290-
approve=True,
291-
)
292-
)
285+
tool_approvals = await cls._build_mcp_tool_approvals(
286+
agent=agent,
287+
thread_id=thread_id,
288+
run_id=run.id,
289+
mcp_tool_calls=mcp_tool_calls,
290+
)
293291

294292
await agent.client.agents.runs.submit_tool_outputs(
295293
run_id=run.id,
@@ -757,17 +755,12 @@ async def _process_stream_events(
757755
output_messages.append(content)
758756

759757
# Create tool approvals for MCP calls
760-
tool_approvals = []
761-
for mcp_call in mcp_tool_calls:
762-
tool_approvals.append(
763-
ToolApproval(
764-
tool_call_id=mcp_call.id,
765-
approve=True,
766-
# Note: headers would need to be provided by the MCP tool configuration
767-
# This is a simplified implementation
768-
headers={},
769-
)
770-
)
758+
tool_approvals = await cls._build_mcp_tool_approvals(
759+
agent=agent,
760+
thread_id=thread_id,
761+
run_id=run.id,
762+
mcp_tool_calls=mcp_tool_calls,
763+
)
771764

772765
handler: BaseAsyncAgentEventHandler = AsyncAgentEventHandler() # type: ignore
773766
await agent.client.agents.runs.submit_tool_outputs_stream(
@@ -1177,6 +1170,71 @@ async def _invoke_function_calls(
11771170
],
11781171
)
11791172

1173+
@classmethod
1174+
async def _build_mcp_tool_approvals(
1175+
cls: type[_T],
1176+
agent: "AzureAIAgent",
1177+
thread_id: str,
1178+
run_id: str,
1179+
mcp_tool_calls: list[RequiredMcpToolCall],
1180+
) -> list[ToolApproval]:
1181+
"""Approve or deny the MCP tool calls the Azure AI Foundry Agent Service is waiting on.
1182+
1183+
Each pending call is passed to the agent's `mcp_tool_approval_callback`. When no callback is
1184+
configured the call is denied, so that a compromised or untrusted MCP server cannot run tools
1185+
without the developer explicitly opting in.
1186+
"""
1187+
approvals: list[ToolApproval] = []
1188+
for mcp_call in mcp_tool_calls:
1189+
approved = await cls._resolve_mcp_tool_approval(
1190+
agent,
1191+
MCPToolApprovalRequest(
1192+
agent_name=agent.name,
1193+
thread_id=thread_id,
1194+
run_id=run_id,
1195+
tool_call_id=mcp_call.id,
1196+
server_label=mcp_call.server_label,
1197+
function_name=mcp_call.name,
1198+
arguments=mcp_call.arguments,
1199+
),
1200+
)
1201+
logger.debug(
1202+
f"MCP tool call `{mcp_call.name}` from server `{mcp_call.server_label}` was "
1203+
f"{'approved' if approved else 'denied'} for agent `{agent.name}` and thread `{thread_id}`"
1204+
)
1205+
approvals.append(ToolApproval(tool_call_id=mcp_call.id, approve=approved, headers={}))
1206+
1207+
return approvals
1208+
1209+
@classmethod
1210+
async def _resolve_mcp_tool_approval(cls: type[_T], agent: "AzureAIAgent", request: MCPToolApprovalRequest) -> bool:
1211+
"""Resolve the approval decision for a single MCP tool call.
1212+
1213+
The agent's callback may be synchronous or asynchronous and returns True to approve the call.
1214+
When no callback is configured, or when the callback fails, the call is denied so that an
1215+
untrusted MCP server cannot execute tools without the developer opting in.
1216+
"""
1217+
if agent.mcp_tool_approval_callback is None:
1218+
logger.warning(
1219+
f"MCP tool call `{request.function_name}` from server `{request.server_label}` was denied because "
1220+
"no `mcp_tool_approval_callback` was configured on the agent. Provide a callback to approve or "
1221+
"deny MCP tool calls."
1222+
)
1223+
return False
1224+
1225+
try:
1226+
result = agent.mcp_tool_approval_callback(request)
1227+
if inspect.isawaitable(result):
1228+
result = await result
1229+
except Exception:
1230+
logger.exception(
1231+
f"MCP tool approval callback raised an error for tool call `{request.function_name}` from server "
1232+
f"`{request.server_label}`. Denying the call.",
1233+
)
1234+
return False
1235+
1236+
return result is True
1237+
11801238
@classmethod
11811239
def _format_tool_outputs(
11821240
cls: type[_T], fccs: list["FunctionCallContent"], chat_history: "ChatHistory"

‎python/semantic_kernel/agents/azure_ai/azure_ai_agent.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
)
3939
from semantic_kernel.agents.azure_ai.agent_thread_actions import AgentThreadActions
4040
from semantic_kernel.agents.azure_ai.azure_ai_channel import AzureAIChannel
41+
from semantic_kernel.agents.azure_ai.mcp_tool_approval import MCPToolApprovalCallback
4142
from semantic_kernel.agents.channels.agent_channel import AgentChannel
4243
from semantic_kernel.agents.open_ai.run_polling_options import RunPollingOptions
4344
from semantic_kernel.connectors.ai.function_calling_utils import kernel_function_metadata_to_function_call_format
@@ -355,6 +356,7 @@ class AzureAIAgent(DeclarativeSpecMixin, Agent):
355356
client: AIProjectClient
356357
definition: AzureAIAgentModel
357358
polling_options: RunPollingOptions = Field(default_factory=RunPollingOptions)
359+
mcp_tool_approval_callback: MCPToolApprovalCallback | None = None
358360

359361
channel_type: ClassVar[type[AgentChannel]] = AzureAIChannel
360362

@@ -368,6 +370,7 @@ def __init__(
368370
plugins: list[KernelPlugin | object] | dict[str, KernelPlugin | object] | None = None,
369371
polling_options: RunPollingOptions | None = None,
370372
prompt_template_config: "PromptTemplateConfig | None" = None,
373+
mcp_tool_approval_callback: MCPToolApprovalCallback | None = None,
371374
**kwargs: Any,
372375
) -> None:
373376
"""Initialize the Azure AI Agent.
@@ -384,6 +387,10 @@ def __init__(
384387
polling_options: The polling options for the agent.
385388
prompt_template_config: The prompt template configuration. If this is provided along with
386389
instructions, the prompt template will be used in place of the instructions.
390+
mcp_tool_approval_callback: A callback invoked for every MCP tool call that the Azure AI Foundry
391+
Agent Service asks the caller to approve. It receives an `MCPToolApprovalRequest` and returns
392+
True to approve the call, either synchronously or asynchronously. When no callback is
393+
provided, such MCP tool calls are denied.
387394
**kwargs: Additional keyword arguments
388395
"""
389396
args: dict[str, Any] = {
@@ -423,6 +430,8 @@ def __init__(
423430
args["instructions"] = prompt_template_config.template
424431
if polling_options is not None:
425432
args["polling_options"] = polling_options
433+
if mcp_tool_approval_callback is not None:
434+
args["mcp_tool_approval_callback"] = mcp_tool_approval_callback
426435
if kwargs:
427436
args.update(kwargs)
428437

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
from collections.abc import Awaitable, Callable
4+
from dataclasses import dataclass
5+
from typing import Union
6+
7+
from semantic_kernel.utils.feature_stage_decorator import experimental
8+
9+
__all__ = [
10+
"MCPToolApprovalCallback",
11+
"MCPToolApprovalRequest",
12+
]
13+
14+
15+
@experimental
16+
@dataclass
17+
class MCPToolApprovalRequest:
18+
"""A request to approve or deny a single MCP tool call.
19+
20+
The Azure AI Foundry Agent Service pauses a run and asks the caller to approve
21+
MCP tool calls when the MCP tool is registered with `require_approval="always"`.
22+
One instance of this class is passed to the configured approval callback per
23+
pending tool call.
24+
"""
25+
26+
agent_name: str
27+
thread_id: str
28+
run_id: str
29+
tool_call_id: str
30+
server_label: str
31+
function_name: str
32+
arguments: str
33+
34+
35+
MCPToolApprovalCallback = Callable[[MCPToolApprovalRequest], Union[bool, Awaitable[bool]]]

0 commit comments

Comments
 (0)