|
1 | 1 | # Copyright (c) Microsoft. All rights reserved. |
2 | 2 |
|
3 | 3 | import asyncio |
| 4 | +import inspect |
4 | 5 | import logging |
5 | 6 | from collections.abc import AsyncIterable |
6 | 7 | from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar, cast |
|
64 | 65 | get_function_call_contents, |
65 | 66 | ) |
66 | 67 | from semantic_kernel.agents.azure_ai.azure_ai_agent_utils import AzureAIAgentUtils |
| 68 | +from semantic_kernel.agents.azure_ai.mcp_tool_approval import MCPToolApprovalRequest |
67 | 69 | from semantic_kernel.agents.open_ai.assistant_content_generation import merge_streaming_function_results |
68 | 70 | from semantic_kernel.agents.open_ai.function_action_result import FunctionActionResult |
69 | 71 | from semantic_kernel.agents.open_ai.run_polling_options import RunPollingOptions |
@@ -280,16 +282,12 @@ async def invoke( |
280 | 282 | yield False, generate_mcp_call_content(agent_name=agent.name, mcp_tool_calls=mcp_tool_calls) |
281 | 283 |
|
282 | 284 | # 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 | + ) |
293 | 291 |
|
294 | 292 | await agent.client.agents.runs.submit_tool_outputs( |
295 | 293 | run_id=run.id, |
@@ -757,17 +755,12 @@ async def _process_stream_events( |
757 | 755 | output_messages.append(content) |
758 | 756 |
|
759 | 757 | # 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 | + ) |
771 | 764 |
|
772 | 765 | handler: BaseAsyncAgentEventHandler = AsyncAgentEventHandler() # type: ignore |
773 | 766 | await agent.client.agents.runs.submit_tool_outputs_stream( |
@@ -1177,6 +1170,71 @@ async def _invoke_function_calls( |
1177 | 1170 | ], |
1178 | 1171 | ) |
1179 | 1172 |
|
| 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 | + |
1180 | 1238 | @classmethod |
1181 | 1239 | def _format_tool_outputs( |
1182 | 1240 | cls: type[_T], fccs: list["FunctionCallContent"], chat_history: "ChatHistory" |
|
0 commit comments