Skip to content

[Python] Add a MCP endpoint for workflows #37

Description

Expose Python durable workflows as MCP tools in Azure Functions

Context and Problem Statement

The Python AgentFunctionApp (package agent_framework_azurefunctions) can expose a hosted
agent as a Model Context Protocol (MCP) tool via enable_mcp_tool_trigger
(_setup_mcp_tool_trigger), but it cannot expose a hosted workflow the same way. Workflows
only get HTTP triggers (workflow/{name}/run, .../status/{id}, .../respond/{id}/{reqId}).

The .NET package Microsoft.Agents.AI.Hosting.AzureFunctions already has this capability:
AddWorkflow(workflow, exposeStatusEndpoint, exposeMcpToolTrigger: true) registers an
mcpToolTrigger function that, when invoked, starts the durable orchestration, waits for it to
finish, and returns the result. This ADR proposes bringing the same capability to Python.

Scope note: an MCP endpoint is inherently Azure Functions-specific. It relies on the Azure
Functions MCP extension (Microsoft.Azure.Functions.Extensions.Mcp), which serves an MCP endpoint
at /runtime/webhooks/mcp and turns any function carrying an mcpToolTrigger binding into a tool.
The standalone durable-task worker host has no HTTP/MCP server, so it has no MCP exposure in .NET
either. This work therefore belongs in agent_framework_azurefunctions, not
agent_framework_durabletask.

Decision Drivers

  • Parity with .NET. Match exposeMcpToolTrigger: true so both stacks can expose workflows as MCP tools.
  • Consistency with existing Python patterns. Reuse the proven agent MCP path (df.DFApp.mcp_tool_trigger) and mirror add_agent(...).
  • Per-workflow opt-in. Exposing a workflow as a tool is a deliberate choice, like in .NET.
  • Honest HITL behavior. MCP tools/call is a single request/response; human-in-the-loop (HITL) workflows are stateful pause/resume and do not fit that shape. The design must not silently hang (as .NET does).
  • Small, reviewable v1 with a clear forward path for richer HITL support.

Considered Options

Two decision axes.

Axis 1 — how a developer opts a workflow into an MCP tool trigger:

  • A. Public add_workflow(workflow, *, enable_mcp_tool_trigger=False, ...) mirroring add_agent(...); constructor workflows=/workflow= becomes sugar for it.
  • B. App-level enable_workflow_mcp_tool_trigger: bool plus a workflow_mcp_tool_names selector set.
  • C. Reuse the existing enable_mcp_tool_trigger flag for both agents and workflows.

Axis 2 — behavior when an exposed workflow needs HITL (cannot complete in one call):

  • H1. Run-to-completion only (fail-fast / warn+skip). Detect request-ports at registration; refuse (or skip) the MCP trigger for HITL workflows.
  • H2. Bounded timeout + error. Poll to a timeout; return an error if not terminal. (Safety net, not HITL support.)
  • H3. Single tool returns pending info. On pause, return {instance_id, status: "waiting", pending: [...]}; caller resumes via the HTTP respond endpoint.
  • H4. Two-tool run + respond pattern. run_<name> returns result or pending; respond_<name> resumes; caller drives the loop over MCP.
  • H5. MCP elicitation. Server elicits input mid-call and bridges it into a durable raise_event. (Depends on Functions MCP extension support.)

Decision Outcome

Axis 1: chosen option "A — public add_workflow(...)", because it gives per-workflow opt-in
(matching .NET), is consistent with the existing add_agent(...) surface (which already carries
enable_mcp_tool_trigger), and keeps the constructor as a thin convenience over the same code path.

Axis 2: chosen option "H1 (run-to-completion only) with H2 as a safety net" for v1, and
H4 (two-tool run + respond) documented as the planned HITL follow-up. Rationale: a one-shot
MCP tool cannot faithfully model pause/resume, so v1 should support the case that does fit
(workflows that run to completion) and be explicit about the case that does not — rather than
silently hanging until an invocation timeout like .NET. A bounded completion timeout guarantees the
handler never hangs. H4 is the coherent way to add real HITL later; Python already exposes the
needed primitives (DurableWorkflowClient.get_pending_hitl_requests, orchestration custom_status).

OPEN QUESTION (for deciders): confirm the Axis-2 direction. The alternative is to build H3/H4 now
if first-class HITL-over-MCP is a v1 requirement. Also confirm whether Axis-2 v1 should
fail-fast (raise at startup) or warn+skip (register everything else, skip the MCP trigger
for the HITL workflow).

Proposed shape (v1)

Public API (mirrors add_agent):

app = AgentFunctionApp()
app.add_workflow(translate_workflow, enable_mcp_tool_trigger=True)
# constructor sugar:
app = AgentFunctionApp(workflows=[translate_workflow], enable_workflow_mcp_tool_trigger=True)

Registration (new _setup_workflow_mcp_tool_trigger, mirroring _setup_mcp_tool_trigger):

  • Tool name = workflow.name; description = workflow.description or f"Run the {name} workflow".
  • One required tool property input: string (matches .NET; workflows have no thread continuity, so no threadId).
  • Reuse df.DFApp.mcp_tool_trigger + durable_client_input; function name via _build_function_name(workflow.name, "mcptool").
  • If the workflow graph contains request-ports and enable_mcp_tool_trigger=True: fail-fast (or warn+skip — see open question).

Handler (mirrors _handle_mcp_tool_invocation, targeting an orchestration not an entity):

  1. Parse the MCP context JSON; read input.
  2. instance_id = await client.start_new(workflow_orchestrator_name(name), client_input=input).
  3. Poll client.get_status(instance_id) until a terminal runtime_status, bounded by a timeout.
  4. deserialize_workflow_output(status.output); return str as-is, otherwise json.dumps(..., default=_json_default) (matching the HTTP status route and .NET's POCO behavior).

Metadata: introduce WorkflowMetadata(mcp_tool_enabled: bool, http_endpoints_enabled: bool)
(parallel to AgentMetadata) and surface workflows in the health endpoint.

Consequences

  • Good, because it reaches feature parity with .NET for run-to-completion workflows.
  • Good, because it reuses an already-proven binding and mirrors an existing, reviewed code path.
  • Good, because it never hangs (bounded timeout) — an improvement over .NET's behavior.
  • Neutral, because full HITL-over-MCP is deferred to a follow-up (H4).
  • Bad, because HITL workflows are only partially served in v1 (rejected/skipped rather than supported).

Validation

  • Unit tests: add_workflow(enable_mcp_tool_trigger=True) registers an mcpToolTrigger function with the expected tool name / input schema; HITL workflow triggers the chosen fail-fast/warn-skip path.
  • Integration test (Functions host + MCP inspector or client): list tools shows the workflow; calling it with input runs the orchestration and returns the deserialized output (mirrors the .NET 04_WorkflowMcpTool sample: Translate string result and an OrderLookup-style POCO result).

Pros and Cons of the Options

A. Public add_workflow(...) mirroring add_agent(...)

  • Good, because per-workflow opt-in matches .NET's exposeMcpToolTrigger: true.
  • Good, because it is symmetric with add_agent(...), which already has enable_mcp_tool_trigger.
  • Good, because it introduces a proper public workflow registration surface (today only a private _register_workflow exists).
  • Neutral, because it adds one new public method (plus a constructor convenience flag).
  • Bad, because attaching per-workflow flags to the existing constructor workflows=[...] list still needs an app-level default flag for the sugar path.

B. App-level flag + name selector set

  • Good, because minimal new surface.
  • Neutral, because it works without a public add_workflow.
  • Bad, because a second parallel knob (enable_workflow_mcp_tool_trigger + workflow_mcp_tool_names) is clunky and diverges from the agent pattern.

C. Reuse enable_mcp_tool_trigger for both agents and workflows

  • Good, because it is the least code.
  • Bad, because it couples two independent concerns and would blindly expose every workflow — including HITL workflows — as MCP tools.
  • Bad, because it removes per-workflow control.

H1. Run-to-completion only (fail-fast / warn+skip)

  • Good, because it is honest about the one-shot MCP contract and is the smallest correct v1.
  • Good, because it prevents the .NET foot-gun (silent hang on HITL).
  • Neutral, because detection is static (a workflow either contains request-ports or not).
  • Bad, because it offers no HITL support at all in v1.

H2. Bounded timeout + error

  • Good, because it guarantees the handler never hangs (covers slow and HITL workflows).
  • Neutral, because it is a safety net, best combined with H1/H3/H4 rather than used alone.
  • Bad, because a bare timeout conflates "slow" with "needs input" and leaves the started instance running in the task hub.

H3. Single tool returns pending info

  • Good, because it returns promptly with actionable info (request id, prompt, respond URL).
  • Good, because Python already has the pending-request machinery.
  • Bad, because the caller cannot answer through MCP — resume requires the out-of-band HTTP endpoint, so the MCP loop is only half-closed.
  • Bad, because a "successful" tool call that returns a non-answer can confuse callers/LLMs.

H4. Two-tool run + respond pattern

  • Good, because it fully closes the HITL loop over MCP (agentic pause/resume across tool calls).
  • Good, because it builds directly on existing pending-request + raise_event primitives.
  • Neutral, because it pushes instance_id correlation onto the caller.
  • Bad, because it is more surface/code and diverges from .NET (which has neither tool).

H5. MCP elicitation

  • Good, because it is the most natural, MCP-native HITL model (server asks, client answers, in one logical call).
  • Bad, because it depends on Azure Functions MCP extension support for elicitation, which is not available today.
  • Bad, because it requires the durable handler to stay alive and bridge the elicited response into raise_event.

More Information

  • .NET reference: dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions
    DurableWorkflowOptionsExtensions.AddWorkflow(..., exposeMcpToolTrigger),
    DurableWorkflowsFunctionMetadataTransformer, FunctionMetadataFactory.CreateWorkflowMcpToolTrigger,
    BuiltInFunctions.RunWorkflowMcpToolAsync. .NET waits with
    WaitForInstanceCompletionAsync(..., cancellation: functionContext.CancellationToken) and does not
    detect request-ports for the MCP path, so HITL workflows block until the invocation is cancelled.
  • .NET sample: dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool
    (exposes only run-to-completion workflows: Translate returns a string, OrderLookup returns a POCO).
  • Python reference: agent_framework_azurefunctions._app.AgentFunctionApp_setup_mcp_tool_trigger,
    _handle_mcp_tool_invocation, _register_workflow_routes, add_agent; plus
    agent_framework_durabletask._workflowsplan_workflow_registration, workflow_orchestrator_name,
    deserialize_workflow_output, DurableWorkflowClient.get_pending_hitl_requests.
  • Revisit this decision if the Azure Functions MCP extension adds elicitation or long-running task
    support, which would enable H5 (or a task-handle variant of H3).

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions