agent-framework-declarative 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,549 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ """Executor for the ``InvokeMcpTool`` declarative action.
4
+
5
+ Mirrors the .NET ``InvokeMcpToolExecutor``: dispatches an MCP tool call through
6
+ the configured :class:`MCPToolHandler`, parses tool outputs, and routes
7
+ results to the configured ``output.{result, messages, autoSend}`` paths and
8
+ optional conversation history. Supports a human-in-loop approval flow via
9
+ ``ctx.request_info()`` / :func:`@response_handler` for ``requireApproval=true``.
10
+
11
+ Security notes:
12
+
13
+ - Approval requests surface header NAMES only; header values are not echoed,
14
+ matching the posture of :mod:`._executors_http`.
15
+ - :class:`MCPToolApprovalRequest` carries the values the resume handler will
16
+ use; header values are re-evaluated on resume to keep secrets out of
17
+ checkpoint state.
18
+ - Tool outputs flow back into agent conversations through ``conversationId``
19
+ and through Tool-role messages emitted to ``output.messages``. They share
20
+ the same prompt-injection risk surface as ``HttpRequestAction``: workflow
21
+ authors must trust the MCP server they invoke.
22
+ """
23
+
24
+ import json
25
+ import logging
26
+ import uuid
27
+ from collections.abc import Mapping
28
+ from dataclasses import dataclass, field
29
+ from typing import Any
30
+
31
+ import httpx
32
+ from agent_framework import (
33
+ Content,
34
+ Message,
35
+ WorkflowContext,
36
+ handler,
37
+ response_handler,
38
+ )
39
+ from agent_framework.exceptions import ToolExecutionException
40
+
41
+ from ._declarative_base import (
42
+ ActionComplete,
43
+ DeclarativeActionExecutor,
44
+ DeclarativeWorkflowState,
45
+ )
46
+ from ._executors_tools import ToolApprovalResponse
47
+ from ._mcp_handler import MCPToolHandler, MCPToolInvocation, MCPToolResult
48
+
49
+ __all__ = [
50
+ "MCP_ACTION_EXECUTORS",
51
+ "InvokeMcpToolActionExecutor",
52
+ "MCPToolApprovalRequest",
53
+ ]
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Request / state types
60
+ # ---------------------------------------------------------------------------
61
+
62
+
63
+ @dataclass
64
+ class MCPToolApprovalRequest:
65
+ """Approval request emitted before invoking an MCP tool.
66
+
67
+ Attributes:
68
+ request_id: Identifier matching the framework's pending-request key.
69
+ tool_name: Evaluated tool name.
70
+ server_url: Evaluated MCP server URL.
71
+ server_label: Optional human-readable label.
72
+ arguments: Evaluated tool arguments.
73
+ header_names: Outbound header names (values withheld).
74
+ connection_name: Connection identifier the invocation will use.
75
+ metadata: Internal routing data pinned at approval-request time
76
+ (e.g. ``conversation_id``) for use by the resume handler.
77
+ """
78
+
79
+ request_id: str
80
+ tool_name: str
81
+ server_url: str
82
+ server_label: str | None
83
+ arguments: dict[str, Any]
84
+ header_names: list[str] = field(default_factory=lambda: [])
85
+ connection_name: str | None = None
86
+ metadata: dict[str, Any] = field(default_factory=lambda: {})
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Helpers
91
+ # ---------------------------------------------------------------------------
92
+
93
+
94
+ def _evaluate_conversation_id(state: DeclarativeWorkflowState, conversation_id_expr: Any) -> str | None:
95
+ """Return the evaluated ``conversationId`` string, or None when empty/unset."""
96
+ if not isinstance(conversation_id_expr, str) or not conversation_id_expr:
97
+ return None
98
+ evaluated = state.eval_if_expression(conversation_id_expr)
99
+ if evaluated is None:
100
+ return None
101
+ text = str(evaluated)
102
+ return text or None
103
+
104
+
105
+ def _get_output_path(action_def: Mapping[str, Any], key: str) -> str | None:
106
+ """Extract a state path from ``output.{key}`` field.
107
+
108
+ Supports two YAML shapes:
109
+
110
+ - ``output: { result: Local.MyVar }`` — plain string.
111
+ - ``output: { result: { path: Local.MyVar } }`` — object form.
112
+ """
113
+ output: Any = action_def.get("output")
114
+ if not isinstance(output, Mapping):
115
+ return None
116
+ value: Any = output.get(key) # type: ignore[reportUnknownMemberType]
117
+ if isinstance(value, str):
118
+ return value or None
119
+ if isinstance(value, Mapping):
120
+ path: Any = value.get("path") # type: ignore[reportUnknownMemberType]
121
+ return path if isinstance(path, str) and path else None
122
+ return None
123
+
124
+
125
+ def _format_outputs_for_send(parsed_results: list[Any]) -> str:
126
+ """Render parsed MCP outputs to a string for ``ctx.yield_output(...)``.
127
+
128
+ - Empty list → ``""``.
129
+ - All-string list → newline-joined.
130
+ - Single element (any type — scalar, dict, list) → JSON-dumped element.
131
+ This avoids surprising ``"[42]"`` / ``"[true]"`` / ``"[null]"`` when
132
+ an MCP tool returns a single scalar JSON value.
133
+ - Multi-element non-string list → JSON-dump the whole list.
134
+ """
135
+ if not parsed_results:
136
+ return ""
137
+ if all(isinstance(item, str) for item in parsed_results):
138
+ return "\n".join(parsed_results)
139
+ if len(parsed_results) == 1:
140
+ return json.dumps(parsed_results[0], ensure_ascii=False)
141
+ return json.dumps(parsed_results, ensure_ascii=False)
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Executor
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
150
+ """Executor for the ``InvokeMcpTool`` declarative action.
151
+
152
+ Dispatches through the supplied :class:`MCPToolHandler` and:
153
+
154
+ - Evaluates ``serverUrl`` / ``toolName`` / ``serverLabel`` / ``arguments``
155
+ / ``headers`` / ``connection.name`` from the action definition.
156
+ - When ``requireApproval=true``: emits a :class:`MCPToolApprovalRequest`
157
+ via ``ctx.request_info()`` and yields. On resume, the response is
158
+ checked; on rejection, ``output.result`` is set to ``"Error: ..."`` and
159
+ no tool call is made.
160
+ - On success: parses each :class:`agent_framework.Content` output (text →
161
+ JSON-first / data / uri → URI string) and assigns the parsed list to
162
+ ``output.result``. Builds a single Tool-role :class:`Message`
163
+ containing all output contents and assigns it to ``output.messages``.
164
+ When ``output.autoSend`` is true (default), emits the rendered string
165
+ via ``ctx.yield_output(...)``. When ``conversationId`` is configured,
166
+ appends an Assistant-role :class:`Message` with the same contents to
167
+ ``System.conversations.{id}.messages``.
168
+ - On error returned by the handler (``is_error=True``): assigns
169
+ ``"Error: <message>"`` to ``output.result`` and completes normally
170
+ (parity with .NET ``AssignErrorAsync``).
171
+
172
+ .. note::
173
+
174
+ ``output.messages`` receives a SINGLE Tool-role :class:`Message`
175
+ (containing the full tool output as ``contents``), unlike
176
+ :class:`agent_framework_declarative.InvokeFunctionToolExecutor` which
177
+ writes a list of two messages (assistant call + tool result). This
178
+ matches the .NET ``InvokeMcpToolExecutor`` output contract.
179
+ """
180
+
181
+ def __init__(
182
+ self,
183
+ action_def: dict[str, Any],
184
+ *,
185
+ id: str | None = None,
186
+ mcp_tool_handler: MCPToolHandler,
187
+ ) -> None:
188
+ """Create an MCP tool action executor.
189
+
190
+ Args:
191
+ action_def: Parsed ``InvokeMcpTool`` YAML dict.
192
+ id: Optional executor id (defaults to action id or generated).
193
+ mcp_tool_handler: Handler used to dispatch MCP tool calls.
194
+ Required: the builder enforces presence at workflow-build
195
+ time.
196
+ """
197
+ super().__init__(action_def, id=id)
198
+ self._mcp_tool_handler = mcp_tool_handler
199
+
200
+ # ----- Main handler --------------------------------------------------------
201
+
202
+ @handler
203
+ async def handle_action(
204
+ self,
205
+ trigger: Any,
206
+ ctx: WorkflowContext[ActionComplete, str],
207
+ ) -> None:
208
+ """Execute the MCP tool action."""
209
+ state = await self._ensure_state_initialized(ctx, trigger)
210
+
211
+ server_url = self._get_server_url(state)
212
+ tool_name = self._get_tool_name(state)
213
+ server_label = self._get_server_label(state)
214
+ arguments = self._get_arguments(state)
215
+ headers = self._get_headers(state)
216
+ connection_name = self._get_connection_name(state)
217
+ require_approval = self._get_require_approval(state)
218
+ auto_send = self._get_auto_send(state)
219
+ conversation_id_expr = self._action_def.get("conversationId")
220
+ output_messages_path = _get_output_path(self._action_def, "messages")
221
+ output_result_path = _get_output_path(self._action_def, "result")
222
+
223
+ if require_approval:
224
+ request_id = str(uuid.uuid4())
225
+ conversation_id = _evaluate_conversation_id(state, conversation_id_expr)
226
+ request = MCPToolApprovalRequest(
227
+ request_id=request_id,
228
+ tool_name=tool_name,
229
+ server_url=server_url,
230
+ server_label=server_label,
231
+ arguments=arguments,
232
+ header_names=sorted(headers.keys()),
233
+ connection_name=connection_name,
234
+ metadata={"conversation_id": conversation_id},
235
+ )
236
+ logger.info(
237
+ "%s: requesting approval for MCP tool '%s' on '%s'",
238
+ self.__class__.__name__,
239
+ tool_name,
240
+ server_url,
241
+ )
242
+ await ctx.request_info(request, ToolApprovalResponse, request_id=request_id)
243
+ return
244
+
245
+ # No approval required - invoke directly.
246
+ invocation = MCPToolInvocation(
247
+ server_url=server_url,
248
+ tool_name=tool_name,
249
+ server_label=server_label,
250
+ arguments=arguments,
251
+ headers=headers,
252
+ connection_name=connection_name,
253
+ )
254
+ result = await self._invoke_with_narrow_catch(invocation)
255
+ await self._process_result(
256
+ ctx=ctx,
257
+ state=state,
258
+ result=result,
259
+ auto_send=auto_send,
260
+ conversation_id=_evaluate_conversation_id(state, conversation_id_expr),
261
+ output_messages_path=output_messages_path,
262
+ output_result_path=output_result_path,
263
+ )
264
+ await ctx.send_message(ActionComplete())
265
+
266
+ # ----- Approval response handler ------------------------------------------
267
+
268
+ @response_handler
269
+ async def handle_approval_response(
270
+ self,
271
+ original_request: MCPToolApprovalRequest,
272
+ response: ToolApprovalResponse,
273
+ ctx: WorkflowContext[ActionComplete, str],
274
+ ) -> None:
275
+ """Resume the invocation using the values pinned on ``original_request``."""
276
+ state = self._get_state(ctx.state)
277
+
278
+ tool_name = original_request.tool_name
279
+ metadata: dict[str, Any] = getattr(original_request, "metadata", None) or {}
280
+ raw_conversation_id = metadata.get("conversation_id")
281
+ conversation_id = raw_conversation_id if isinstance(raw_conversation_id, str) and raw_conversation_id else None
282
+
283
+ auto_send = self._get_auto_send(state)
284
+ output_messages_path = _get_output_path(self._action_def, "messages")
285
+ output_result_path = _get_output_path(self._action_def, "result")
286
+
287
+ if not response.approved:
288
+ logger.info(
289
+ "%s: MCP tool '%s' rejected: %s",
290
+ self.__class__.__name__,
291
+ tool_name,
292
+ response.reason,
293
+ )
294
+ self._assign_error(state, output_result_path, "MCP tool invocation was not approved by user.")
295
+ await ctx.send_message(ActionComplete())
296
+ return
297
+
298
+ invocation = MCPToolInvocation(
299
+ server_url=original_request.server_url,
300
+ tool_name=tool_name,
301
+ server_label=original_request.server_label,
302
+ arguments=original_request.arguments,
303
+ headers=self._evaluate_headers(state, self._action_def.get("headers")),
304
+ connection_name=getattr(original_request, "connection_name", None),
305
+ )
306
+ result = await self._invoke_with_narrow_catch(invocation)
307
+ await self._process_result(
308
+ ctx=ctx,
309
+ state=state,
310
+ result=result,
311
+ auto_send=auto_send,
312
+ conversation_id=conversation_id,
313
+ output_messages_path=output_messages_path,
314
+ output_result_path=output_result_path,
315
+ )
316
+ await ctx.send_message(ActionComplete())
317
+
318
+ # ----- Field resolution ----------------------------------------------------
319
+
320
+ def _get_server_url(self, state: DeclarativeWorkflowState) -> str:
321
+ raw = self._action_def.get("serverUrl")
322
+ if raw is None:
323
+ raise ValueError("InvokeMcpTool requires a 'serverUrl' field.")
324
+ evaluated = state.eval_if_expression(raw)
325
+ if not isinstance(evaluated, str) or not evaluated:
326
+ raise ValueError("InvokeMcpTool 'serverUrl' evaluated to an empty value.")
327
+ return evaluated
328
+
329
+ def _get_tool_name(self, state: DeclarativeWorkflowState) -> str:
330
+ raw = self._action_def.get("toolName")
331
+ if raw is None:
332
+ raise ValueError("InvokeMcpTool requires a 'toolName' field.")
333
+ evaluated = state.eval_if_expression(raw)
334
+ if not isinstance(evaluated, str) or not evaluated:
335
+ raise ValueError("InvokeMcpTool 'toolName' evaluated to an empty value.")
336
+ return evaluated
337
+
338
+ def _get_server_label(self, state: DeclarativeWorkflowState) -> str | None:
339
+ raw = self._action_def.get("serverLabel")
340
+ if raw is None:
341
+ return None
342
+ evaluated = state.eval_if_expression(raw)
343
+ if evaluated is None:
344
+ return None
345
+ text = str(evaluated)
346
+ return text or None
347
+
348
+ def _get_arguments(self, state: DeclarativeWorkflowState) -> dict[str, Any]:
349
+ """Evaluate ``arguments`` map. Preserves ``None`` values (parity with .NET)."""
350
+ raw = self._action_def.get("arguments")
351
+ if raw is None:
352
+ return {}
353
+ if not isinstance(raw, Mapping) or not raw:
354
+ return {}
355
+ result: dict[str, Any] = {}
356
+ for key, value in raw.items(): # type: ignore[reportUnknownVariableType]
357
+ if not isinstance(key, str) or not key:
358
+ continue
359
+ result[key] = state.eval_if_expression(value)
360
+ return result
361
+
362
+ def _get_headers(self, state: DeclarativeWorkflowState) -> dict[str, str]:
363
+ return self._evaluate_headers(state, self._action_def.get("headers"))
364
+
365
+ @staticmethod
366
+ def _evaluate_headers(state: DeclarativeWorkflowState, headers_def: Any) -> dict[str, str]:
367
+ """Evaluate the ``headers`` map. Empty string values are skipped."""
368
+ if not isinstance(headers_def, Mapping) or not headers_def:
369
+ return {}
370
+ result: dict[str, str] = {}
371
+ for key, value in headers_def.items(): # type: ignore[reportUnknownVariableType]
372
+ if not isinstance(key, str) or not key:
373
+ continue
374
+ evaluated = state.eval_if_expression(value)
375
+ if evaluated is None:
376
+ continue
377
+ text = str(evaluated)
378
+ if not text:
379
+ continue
380
+ result[key] = text
381
+ return result
382
+
383
+ def _get_connection_name(self, state: DeclarativeWorkflowState) -> str | None:
384
+ connection = self._action_def.get("connection")
385
+ if not isinstance(connection, Mapping):
386
+ return None
387
+ name_expr: Any = connection.get("name") # type: ignore[reportUnknownMemberType]
388
+ if name_expr is None:
389
+ return None
390
+ evaluated = state.eval_if_expression(name_expr)
391
+ if evaluated is None:
392
+ return None
393
+ text = str(evaluated)
394
+ return text or None
395
+
396
+ def _get_require_approval(self, state: DeclarativeWorkflowState) -> bool:
397
+ raw = self._action_def.get("requireApproval")
398
+ if raw is None:
399
+ return False
400
+ evaluated = state.eval_if_expression(raw)
401
+ if isinstance(evaluated, bool):
402
+ return evaluated
403
+ if isinstance(evaluated, str):
404
+ return evaluated.strip().lower() in {"true", "1", "yes"}
405
+ return bool(evaluated)
406
+
407
+ def _get_auto_send(self, state: DeclarativeWorkflowState) -> bool:
408
+ output: Any = self._action_def.get("output")
409
+ if not isinstance(output, Mapping):
410
+ return True
411
+ raw: Any = output.get("autoSend") # type: ignore[reportUnknownMemberType]
412
+ if raw is None:
413
+ return True
414
+ evaluated = state.eval_if_expression(raw)
415
+ if isinstance(evaluated, bool):
416
+ return evaluated
417
+ if isinstance(evaluated, str):
418
+ return evaluated.strip().lower() in {"true", "1", "yes"}
419
+ return bool(evaluated)
420
+
421
+ # ----- Invocation + error handling ----------------------------------------
422
+
423
+ async def _invoke_with_narrow_catch(self, invocation: MCPToolInvocation) -> MCPToolResult:
424
+ """Invoke the handler with a narrow exception catch.
425
+
426
+ Only known transport / tool exceptions are normalised to an error
427
+ result. Programmer bugs (TypeError, ValueError from misuse, etc.)
428
+ propagate so they fail loudly.
429
+
430
+ ``asyncio.CancelledError`` is a ``BaseException``, not ``Exception``,
431
+ so it is not caught here and propagates unchanged for workflow
432
+ cancellation.
433
+ """
434
+ try:
435
+ return await self._mcp_tool_handler.invoke_tool(invocation)
436
+ except ToolExecutionException as exc:
437
+ message = str(exc) or type(exc).__name__
438
+ return MCPToolResult(
439
+ outputs=[Content.from_text(f"Error: {message}")],
440
+ is_error=True,
441
+ error_message=message,
442
+ )
443
+ except httpx.HTTPError as exc:
444
+ message = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
445
+ return MCPToolResult(
446
+ outputs=[Content.from_text(f"Error: {message}")],
447
+ is_error=True,
448
+ error_message=message,
449
+ )
450
+ except Exception as exc:
451
+ try:
452
+ from mcp.shared.exceptions import McpError
453
+ except ImportError: # pragma: no cover - mcp is a hard dep
454
+ raise
455
+ if isinstance(exc, McpError):
456
+ message = str(exc) or type(exc).__name__
457
+ return MCPToolResult(
458
+ outputs=[Content.from_text(f"Error: {message}")],
459
+ is_error=True,
460
+ error_message=message,
461
+ )
462
+ raise
463
+
464
+ # ----- Result handling -----------------------------------------------------
465
+
466
+ async def _process_result(
467
+ self,
468
+ *,
469
+ ctx: WorkflowContext[ActionComplete, str],
470
+ state: DeclarativeWorkflowState,
471
+ result: MCPToolResult,
472
+ auto_send: bool,
473
+ conversation_id: str | None,
474
+ output_messages_path: str | None,
475
+ output_result_path: str | None,
476
+ ) -> None:
477
+ """Apply ``result`` to workflow state per the configured output paths."""
478
+ if result.is_error:
479
+ # Error path mirrors .NET ``AssignErrorAsync`` — only the result
480
+ # path is touched; messages / autoSend / conversation are not.
481
+ self._assign_error(
482
+ state,
483
+ output_result_path,
484
+ result.error_message or "MCP tool invocation failed.",
485
+ )
486
+ return
487
+
488
+ parsed_results = _parse_outputs(result.outputs)
489
+ if output_result_path is not None and parsed_results:
490
+ state.set(output_result_path, parsed_results)
491
+
492
+ # Single Tool-role message (matches .NET line 178 contract). Differs
493
+ # from InvokeFunctionTool's two-message [assistant call, tool result]
494
+ # convention.
495
+ tool_message = Message(role="tool", contents=list(result.outputs))
496
+ if output_messages_path is not None:
497
+ state.set(output_messages_path, tool_message)
498
+
499
+ if auto_send and parsed_results:
500
+ await ctx.yield_output(_format_outputs_for_send(parsed_results))
501
+
502
+ if conversation_id:
503
+ messages_path = f"System.conversations.{conversation_id}.messages"
504
+ assistant_message = Message(role="assistant", contents=list(result.outputs))
505
+ state.append(messages_path, assistant_message)
506
+
507
+ @staticmethod
508
+ def _assign_error(
509
+ state: DeclarativeWorkflowState,
510
+ output_result_path: str | None,
511
+ error_message: str,
512
+ ) -> None:
513
+ """Mirror .NET ``AssignErrorAsync``: store ``"Error: <msg>"`` at the result path."""
514
+ if output_result_path is None:
515
+ return
516
+ state.set(output_result_path, f"Error: {error_message}")
517
+
518
+
519
+ def _parse_outputs(outputs: list[Content]) -> list[Any]:
520
+ """Parse :class:`Content` outputs into Python values for ``output.result``.
521
+
522
+ Mirrors .NET ``AssignResultAsync``:
523
+
524
+ - ``TextContent`` → JSON-parse text; on failure use the raw text.
525
+ - ``DataContent`` / ``UriContent`` → ``content.uri``.
526
+ - Other content kinds → ``str(content)``.
527
+ """
528
+ parsed: list[Any] = []
529
+ for content in outputs:
530
+ kind = getattr(content, "type", None)
531
+ if kind == "text":
532
+ text_value = getattr(content, "text", None)
533
+ text_str = "" if text_value is None else str(text_value)
534
+ try:
535
+ parsed.append(json.loads(text_str))
536
+ except (json.JSONDecodeError, ValueError):
537
+ parsed.append(text_str)
538
+ continue
539
+ if kind in ("data", "uri"):
540
+ uri_value = getattr(content, "uri", None)
541
+ parsed.append("" if uri_value is None else str(uri_value))
542
+ continue
543
+ parsed.append(str(content))
544
+ return parsed
545
+
546
+
547
+ MCP_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
548
+ "InvokeMcpTool": InvokeMcpToolActionExecutor,
549
+ }