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):
- Parse the MCP context JSON; read
input.
instance_id = await client.start_new(workflow_orchestrator_name(name), client_input=input).
- Poll
client.get_status(instance_id) until a terminal runtime_status, bounded by a timeout.
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._workflows — plan_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).
Expose Python durable workflows as MCP tools in Azure Functions
Context and Problem Statement
The Python
AgentFunctionApp(packageagent_framework_azurefunctions) can expose a hostedagent 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. Workflowsonly get HTTP triggers (
workflow/{name}/run,.../status/{id},.../respond/{id}/{reqId}).The .NET package
Microsoft.Agents.AI.Hosting.AzureFunctionsalready has this capability:AddWorkflow(workflow, exposeStatusEndpoint, exposeMcpToolTrigger: true)registers anmcpToolTriggerfunction that, when invoked, starts the durable orchestration, waits for it tofinish, 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 endpointat
/runtime/webhooks/mcpand turns any function carrying anmcpToolTriggerbinding 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, notagent_framework_durabletask.Decision Drivers
exposeMcpToolTrigger: trueso both stacks can expose workflows as MCP tools.df.DFApp.mcp_tool_trigger) and mirroradd_agent(...).tools/callis 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).Considered Options
Two decision axes.
Axis 1 — how a developer opts a workflow into an MCP tool trigger:
add_workflow(workflow, *, enable_mcp_tool_trigger=False, ...)mirroringadd_agent(...); constructorworkflows=/workflow=becomes sugar for it.enable_workflow_mcp_tool_trigger: boolplus aworkflow_mcp_tool_namesselector set.enable_mcp_tool_triggerflag for both agents and workflows.Axis 2 — behavior when an exposed workflow needs HITL (cannot complete in one call):
{instance_id, status: "waiting", pending: [...]}; caller resumes via the HTTP respond endpoint.run+respondpattern.run_<name>returns result or pending;respond_<name>resumes; caller drives the loop over MCP.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 carriesenable_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-shotMCP 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, orchestrationcustom_status).Proposed shape (v1)
Public API (mirrors
add_agent):Registration (new
_setup_workflow_mcp_tool_trigger, mirroring_setup_mcp_tool_trigger):workflow.name; description =workflow.description or f"Run the {name} workflow".input: string(matches .NET; workflows have no thread continuity, so nothreadId).df.DFApp.mcp_tool_trigger+durable_client_input; function name via_build_function_name(workflow.name, "mcptool").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):input.instance_id = await client.start_new(workflow_orchestrator_name(name), client_input=input).client.get_status(instance_id)until a terminalruntime_status, bounded by a timeout.deserialize_workflow_output(status.output); returnstras-is, otherwisejson.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
Validation
add_workflow(enable_mcp_tool_trigger=True)registers anmcpToolTriggerfunction with the expected tool name /inputschema; HITL workflow triggers the chosen fail-fast/warn-skip path.inputruns the orchestration and returns the deserialized output (mirrors the .NET04_WorkflowMcpToolsample:Translatestring result and anOrderLookup-style POCO result).Pros and Cons of the Options
A. Public
add_workflow(...)mirroringadd_agent(...)exposeMcpToolTrigger: true.add_agent(...), which already hasenable_mcp_tool_trigger._register_workflowexists).workflows=[...]list still needs an app-level default flag for the sugar path.B. App-level flag + name selector set
add_workflow.enable_workflow_mcp_tool_trigger+workflow_mcp_tool_names) is clunky and diverges from the agent pattern.C. Reuse
enable_mcp_tool_triggerfor both agents and workflowsH1. Run-to-completion only (fail-fast / warn+skip)
H2. Bounded timeout + error
H3. Single tool returns pending info
H4. Two-tool
run+respondpatternraise_eventprimitives.instance_idcorrelation onto the caller.H5. MCP elicitation
raise_event.More Information
dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions—DurableWorkflowOptionsExtensions.AddWorkflow(..., exposeMcpToolTrigger),DurableWorkflowsFunctionMetadataTransformer,FunctionMetadataFactory.CreateWorkflowMcpToolTrigger,BuiltInFunctions.RunWorkflowMcpToolAsync. .NET waits withWaitForInstanceCompletionAsync(..., cancellation: functionContext.CancellationToken)and does notdetect request-ports for the MCP path, so HITL workflows block until the invocation is cancelled.
dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool(exposes only run-to-completion workflows:
Translatereturns a string,OrderLookupreturns a POCO).agent_framework_azurefunctions._app.AgentFunctionApp—_setup_mcp_tool_trigger,_handle_mcp_tool_invocation,_register_workflow_routes,add_agent; plusagent_framework_durabletask._workflows—plan_workflow_registration,workflow_orchestrator_name,deserialize_workflow_output,DurableWorkflowClient.get_pending_hitl_requests.support, which would enable H5 (or a task-handle variant of H3).