Skip to main content
Glama
OwnOptic

power-automate-mcp

by OwnOptic

power-automate-mcp

An MCP server that lets Claude create, run, and debug your Power Automate flows. One file. Ten tools. Built as a teaching artifact for a community talk on why you should build your own MCP servers instead of waiting for someone to ship you one.

> Create a flow from demo-flow.json and run it.

  create_flow  -> DEMO - nightly batch (8a3f...), Started
  run_flow     -> accepted

> It failed. What happened?

  list_runs    -> 08d8...: Failed
  explain_run  -> Compute_batches failed:
                  "The template language function 'div' was invoked with a
                   divisor of zero."
                  Load_settings emitted {"region": "westeurope", "retries": 3,
                  "batch_size": 0} and Compute_batches divides 120 by batch_size.

> Fix it and run it again.

  get_flow                 -> definition retrieved
  update_flow_definition   -> batch_size: 0 -> 4
  run_flow                 -> accepted
  list_runs                -> 08d8...: Succeeded, output 30

That entire loop is four tool calls the model chose on its own, because the tools tell it what they are for.

And when the error is clear but the reason is not:

> This flow works most days. Why did it fail last night?

  compare_runs        -> diverged at Compute_batches, but Load_settings emitted
                         batch_size: 0 where the working run emitted 4.
                         Symptom and cause are different actions.
  analyze_flow_health -> 18% failure rate over 50 runs, 5 of 5 sampled failures
                         all in Get_items. Flaky and concentrated, not broken.

Table of contents


Related MCP server: Glance

Why this exists

Every useful MCP server is four layers. Only two of them are interesting.

Layer

What it does

Lines here

Who writes it

1. Auth

Get a token

~45

Claude, in one prompt

2. Transport

Call, retry, paginate

~50

Claude, in one prompt

3. Shaping

Turn API JSON into model-readable JSON

~160

You. This is the work.

4. Docstrings

Teach the model what the API will not

~200

You. This is the moat.

flowchart TB
    subgraph gen["Generated in one prompt"]
        direction TB
        L1["Layer 1 - Auth<br/>one az CLI call, plus caching and errors<br/>~45 lines"]
        L2["Layer 2 - Transport<br/>retry 401 / 429 / 5xx, follow nextLink<br/>~50 lines"]
        L1 --> L2
    end
    subgraph own["Where your value lives"]
        direction TB
        L3["Layer 3 - Shaping<br/>~60 API fields down to the 4 worth reading<br/>~160 lines"]
        L4["Layer 4 - Docstrings<br/>what the API will never tell you<br/>~200 lines"]
        L3 --> L4
    end
    gen --> own

    classDef cheap fill:#eef2f6,stroke:#94a3b8,color:#2A3B4E
    classDef dear fill:#F26F21,stroke:#c2551a,color:#ffffff
    class L1,L2 cheap
    class L3,L4 dear
    style gen fill:#ffffff,stroke:#cbd5e1,color:#64748b
    style own fill:#fff7f0,stroke:#F26F21,color:#2A3B4E

Wrapping an API is not the point, and it is precisely the part you can automate. The value is in deciding what to hand the model, and in writing down what you learned so that you never learn it twice.

Two concrete examples from this repo.

Shaping. list_flows returns four fields per flow. The API returns about sixty, mostly GUIDs and internal plumbing. Handing the raw payload to a model burns context, buries the signal, and makes the model slower and less accurate. Deciding what to drop is a judgement call that no code generator can make for you, because it depends on what you actually do with flows.

Docstrings. create_flow documents that a connector flow will fail with HTTP 400 unless the definition declares $connections and $authentication, and that the error message blames the trigger, which sends you hunting in entirely the wrong place. That cost real hours to discover once. It now costs nobody anything, forever, including every future model that reads this docstring.

The code is regenerable. The accumulated knowledge in the docstrings is the asset.


The tool that justifies the exercise

explain_run is the tool a generic HTTP wrapper cannot give you, and it is worth understanding why before you read any other code here.

For connector failures, Power Automate does not put the error message on the action record. A failed connector action comes back looking like this - measured, against a real Teams action posting to a channel that does not exist:

{
  "name": "Post_message",
  "properties": {
    "status": "Failed",
    "code": "NotFound",
    "outputsLink": {
      "uri": "https://<env>.environment.api.powerplatformusercontent.com/.../ActionOutputs?...&sig=...",
      "contentSize": 285
    }
  }
}

There is no error property at all. The real message lives inside a blob behind that short-lived signed URL. The portal follows the link for you, which is exactly why the portal shows you a real error and a naive API wrapper shows you Failed and nothing else.

Which failures actually behave this way. This matters, and getting it wrong sends you chasing the wrong demo:

failure

error on the action

outputsLink

expression / InvalidTemplate (divide by zero, bad reference)

inline, complete

absent

connector action (HTTP 4xx/5xx from Teams, SharePoint, Outlook)

absent

present

So an expression error needs no second hop, and a connector-free flow can never demonstrate this problem. demo-flow.json is the first kind; demo-flow-connector.json is the second, and it exists precisely to exercise this path.

And which field inside the blob. error.message is routinely just the code restated - a real Teams 404 gives {"code": "NotFound", "message": "NotFound"}, which tells you nothing. The diagnosis is one level down:

"innerError": {
  "message": "LocationLookupFailed-Location lookup failed for thread 19:...@thread.tacv2"
}

That names the offending value outright. _resolve_error prefers innerError, prefixes the HTTP status, and drops the code when it merely repeats the message, so you get one line: 404 NotFound: LocationLookupFailed-Location lookup failed for thread 19:...@thread.tacv2.

explain_run does two things a wrapper does not:

  1. It follows the link. For every failed action, it fetches the outputs blob and digs the message out, so the model receives resolved error text rather than null.

  2. It supplies upstream context. A failure is rarely explained by the failing action alone. The cause is almost always in what an earlier action produced. So the response pairs each failed action with the outputs of the actions that succeeded before it, in execution order.

sequenceDiagram
    autonumber
    actor You
    participant Claude
    participant MCP as pa-demo-mcp
    participant PA as Power Automate API
    participant Blob as SAS-signed blob

    You->>Claude: "It failed. What happened?"
    Claude->>MCP: explain_run(flow_id, run_id)
    MCP->>PA: GET /runs/{run_id}/actions
    PA-->>MCP: Compute_batches - Failed, error: null
    rect rgb(255, 235, 220)
        Note over MCP,PA: A naive wrapper stops here<br/>and reports "Failed" with no reason
    end
    MCP->>Blob: GET outputsLink.uri
    Blob-->>MCP: "div was invoked with a divisor of zero"
    MCP->>MCP: pair failure with upstream outputs
    MCP-->>Claude: failed action + resolved error + Load_settings outputs
    Claude-->>You: Compute_batches divided by batch_size,<br/>which Load_settings set to 0

One tool call. The equivalent of about a dozen clicks through the run history view. That gap is the entire argument for building your own MCP server.


Quick start

Let Claude install it

If you already have Claude Code or Claude Desktop, paste the prompt below and it will do the whole setup: check your prerequisites, clone, install, authenticate, wire the server into your MCP client, verify it, and optionally walk the demo debug loop.

It is written to stop and ask before anything that touches your tenant, and it never prints a token.

Set up the power-automate-mcp server on my machine, from
https://github.com/OwnOptic/power-automate-mcp

Work through the phases in order. After each phase, report what you found in one or
two lines. If a check fails in a way not covered below, stop and tell me rather than
improvising.

RULES THAT OVERRIDE EVERYTHING ELSE
- Never print, echo, log or write an access token anywhere. Pipe tokens into
  variables, never to stdout.
- Never run `az login --service-principal`, and never ask me for a password or a
  client secret. This setup needs neither.
- Do not create, update, run or delete any Power Automate flow until I have
  explicitly confirmed the tenant in PHASE 2 and said yes in PHASE 6.
- If a command fails, show me its actual error text. Do not paraphrase it.

PHASE 0 - Inspect only, change nothing
1. Detect my OS and shell.
2. Run `python --version` (and `python3 --version` if that fails). Need 3.10 or later.
   If it is older or missing, stop and tell me.
3. Run `az version`. If the Azure CLI is missing, stop and give me the install link
   for my OS: https://learn.microsoft.com/cli/azure/install-azure-cli
4. Report OS, Python version, az version, and the directory you propose to clone into.

PHASE 1 - Code and dependencies
5. Clone the repo into that directory. If it already exists, `git pull` instead.
6. Create a virtual environment inside it and use it for everything that follows.
   Tell me the exact interpreter path, I will need it in PHASE 4.
7. `pip install -r requirements.txt`. That file is a lockfile, so expect around
   thirty pinned packages, not three - `mcp`, `httpx` and `python-dotenv` are the
   direct ones, the rest are their transitives. Confirm the install succeeded.
8. Sanity check the code imports and registers its tools without any environment
   variables set. It should report 10 tools:
   `python -c "import asyncio, server; print(len(asyncio.run(server.mcp.list_tools())))"`
   Run it from the repo directory. If this fails, stop.

PHASE 2 - Authenticate, then STOP for my confirmation
9. Run `az account show`. If it errors, or reports an expired session
   (AADSTS70043), run `az login` and let me complete it in the browser.
10. Show me the tenant id and the signed-in user, and ASK ME TO CONFIRM this is the
    tenant I want. Do not continue until I say yes.
    This matters: the server creates, edits and runs REAL flows with my delegated
    permissions. It can do anything I can do. If the account looks like a production
    or client tenant, say so explicitly and recommend I switch with
    `az login --tenant <id>`.
11. Once I confirm, check the Azure CLI is consented for the Flow audience WITHOUT
    printing the token:
    `az account get-access-token --resource "https://service.flow.microsoft.com/" --query expiresOn -o tsv`
    - A timestamp means it works. Continue.
    - AADSTS65001 (consent) means my tenant has not consented the Azure CLI for this
      audience. Stop and tell me. This server deliberately has no app-registration
      fallback, so setup cannot continue.
    - AADSTS70043 means the session aged out under Conditional Access. Run
      `az login` again.

PHASE 3 - Target environment
12. By default the server uses `Default-<tenantId>`, resolved automatically from the
    Azure CLI. Ask whether I want a different environment.
13. Only if I do: create a `.env` in the repo root containing a single line,
    `PA_ENV_ID=<the environment GUID>`. Otherwise create no .env at all. There is
    nothing else to configure and no secret to store.

PHASE 4 - Wire it into my MCP client
14. Ask which client I use, or detect it.
15. For Claude Code, add to `.mcp.json` in my project root. For Claude Desktop, use
    `%APPDATA%\Claude\claude_desktop_config.json` on Windows or
    `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS.
16. Back up the file before editing if it already exists.
17. Add this entry, with the venv interpreter from step 6 and an ABSOLUTE path to
    server.py:

    {
      "mcpServers": {
        "power-automate": {
          "command": "<absolute path to the venv python>",
          "args": ["<absolute path to server.py>"]
        }
      }
    }

18. Show me the exact file path you changed and the exact block you added. If the
    file already had other MCP servers, merge into the existing mcpServers object
    rather than replacing it.

PHASE 5 - Verify, read-only
19. Tell me to fully restart my MCP client, and wait for me to confirm I have.
20. Ask me to run: "List my flows."
21. Expect flow_id, display_name, state for each flow. Interpret the result:
    - Flows listed: setup is working, all four layers are live.
    - Empty list: auth works but the environment has no flows, or it is the wrong
      environment.
    - 404: wrong environment. Get the GUID from the make.powerautomate.com URL and
      set PA_ENV_ID as in step 13.
    - "Run `az login` first": the CLI session died. Re-run `az login`.

PHASE 6 - Optional end-to-end demo. ASK BEFORE STARTING.
22. Explain to me first, then wait for a yes:
    This creates a REAL flow called "DEMO - nightly batch" in the tenant I confirmed,
    from demo-flow.json in the repo. That flow is DELIBERATELY BROKEN: Load_settings
    emits batch_size 0 and Compute_batches divides 120 by it. The run is SUPPOSED to
    fail. That failure is the whole point of the demo.
    It is connector-free (button trigger plus two Compose actions), so it needs no
    connection binding and touches no business data.
23. On my yes, in order: create_flow from demo-flow.json, run_flow, list_runs,
    then explain_run on the failed run.
24. Expected result, tell me whether it matches: explain_run names Compute_batches as
    the failed action, resolves the error to "The template language function 'div'
    was invoked with a divisor of zero", and shows Load_settings outputting
    batch_size 0. Symptom and cause are different actions, which is the point.
25. Then offer to close the loop: update_flow_definition setting batch_size to 4,
    run_flow again, list_runs. Expect Succeeded with output 30.
26. Finally, remind me the demo flow still exists and this server has no delete tool
    on purpose, so I should remove "DEMO - nightly batch" from the maker portal when
    I am done.

When everything is done, give me a short summary: where the repo lives, which
interpreter runs it, which tenant and environment it is pointed at, which config
file you edited, and anything that needed a workaround.

Prerequisites

  • Python 3.10 or later

  • A Power Platform environment you can create flows in

  • The Azure CLI, signed in with az login. That is the entire auth story: no app registration, no admin consent, no client id, no secret

  • An MCP client: Claude Code, Claude Desktop, or anything else that speaks MCP

Use a demo or development tenant. This server creates, edits, and runs real flows with your delegated permissions. It can do anything you can do.

1. Sign in

az login

That is the entire setup.

The server needs a token for the https://service.flow.microsoft.com audience. The Azure CLI is itself a Microsoft first-party app that your tenant already trusts, so it will hand you one. No app registration, no admin consent, no device code, no client id, no secret. The server reads your tenant and default environment from whatever the CLI is signed in to.

This is the same trick the Microsoft.PowerApps.PowerShell module uses when Add-PowerAppsAccount followed by Get-Flow lists your flows without you having registered anything.

Check you are pointed at the right place before going further:

az account show --query "{tenant:tenantId, user:user.name}"

One caveat, worth knowing up front. Being first-party is not automatically sufficient in every tenant. Conditional Access and pre-authorization policies vary, and Microsoft's own Work IQ CLI app, for instance, does not carry service.flow.microsoft.com in its allowed resources at all. If your tenant refuses the Azure CLI for this audience you will see a consent error such as AADSTS65001, and you would need your own app registration - which this server deliberately does not implement, to keep Layer 1 down to one shell call.

2. Install

git clone https://github.com/OwnOptic/power-automate-mcp.git
cd power-automate-mcp
pip install -r requirements.txt

Three direct dependencies - mcp, httpx, python-dotenv - plus the Azure CLI.

requirements.txt is a lockfile: every package pinned to an exact version, transitives included, so you get the same tree this was tested against rather than whatever resolves today. Edit requirements.in to change a dependency, then regenerate:

uv pip compile requirements.in --universal --python-version 3.10 -o requirements.txt

--universal resolves for every OS at once and emits environment markers, so one lockfile covers Windows, macOS and Linux.

mcp is capped below 2.0 on purpose. The 2.x line removed mcp.server.fastmcp, which server.py imports, so an unpinned mcp>=1.0.0 resolves to 2.0.0 and fails immediately with ModuleNotFoundError. Lifting the cap means porting server.py to the 2.x API first.

3. Configure

There is nothing to configure. The server resolves your tenant and default environment from the Azure CLI, and there is no client id, no secret and no .env required. Skip to step 4.

The single reason to create a .env is targeting a specific environment rather than the tenant default:

# Optional. Find the GUID in the make.powerautomate.com URL after switching environment.
PA_ENV_ID=Default-00000000-0000-0000-0000-000000000000

There is no secret anywhere in this design. The only credential involved is the refresh token the Azure CLI already holds on your machine, which this repo never reads or writes.

4. Connect it to your client

This is a stdio server: your client launches server.py as a local child process and speaks MCP over stdin/stdout. There is nothing to host and no port to open. See Transport: run it over stdio for why that is the right default here.

Claude Code - add to .mcp.json in your project root, or to your user config:

{
  "mcpServers": {
    "power-automate": {
      "command": "python",
      "args": ["C:/path/to/power-automate-mcp/server.py"]
    }
  }
}

Claude Desktop - same block, in claude_desktop_config.json:

  • Windows: %APPDATA%\Claude\claude_desktop_config.json

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json

Use an absolute path to server.py. The server resolves .env relative to its own file, so the working directory does not matter.

5. Verify

Ask your client:

List my flows.

You should get back flow IDs, display names, and states. If you do, all four layers are working.

Then run the full demo:

Create a flow called "DEMO - nightly batch" using the definition in
demo-flow.json, then run it and tell me what happened.

That flow is meant to fail. See The demo flow.


Architecture: the four layers

The whole server is server.py, deliberately kept in one file so it can be read top to bottom in a few minutes. The four layers appear in order.

Transport: run it over stdio

Run this as a stdio MCP server. Your client spawns server.py as a local child process and talks to it over stdin/stdout. Every config block in this README is a stdio config, and that is a recommendation rather than an accident of the example.

It follows directly from Layer 1. The server has no credential of its own - it borrows the Azure CLI session already sitting on your machine. A token in your local CLI keyring can only be read by a process running as you, on that machine. So the transport has to be local, and stdio is the local transport.

What you get by not putting this behind HTTP:

  • Nothing to host. No container, no TLS certificate, no public endpoint, no tunnel.

  • No second auth layer. A remote server needs its own authentication in front of it, plus a way to map callers back to Power Platform identities. Over stdio the process already runs as you and the API call already carries your delegated permissions.

  • Nothing new to leak. The token never crosses a network boundary.

  • Lifecycle for free. The client starts the server when you need it and stops it when the session ends.

Reach for a hosted transport (Streamable HTTP) only when something genuinely remote has to call you - a cloud-hosted agent, Copilot Studio, a shared team service. The moment you do, Layer 1 changes completely: you own an app registration, a secret and a consent flow, because there is no local CLI session to borrow. That is a real project, not a config change.

Layer 1: Auth (_az, _token, env_id)

Getting the token is one shell call. That is the single most transferable idea in this repo. The layer around it is another forty lines of caching, error handling and lazy environment resolution, but none of that is the interesting part.

PA_RESOURCE = "https://service.flow.microsoft.com"

token = _az(["account", "get-access-token", "--resource", PA_RESOURCE,
             "--query", "accessToken", "-o", "tsv"])

You are borrowing a Microsoft first-party app your tenant already trusts, which removes the app registration, the admin consent and the device-code dance in one move. Change PA_RESOURCE and the same three lines authenticate you against Microsoft Graph, Dataverse, or Azure Resource Manager. When you build an MCP server against any Azure-fronted API, try this before you go near the Entra portal.

Two implementation notes worth copying:

  • shell=True is not laziness. On Windows az is a .cmd shim that CreateProcess will not resolve on its own. Every argument passed here is an internal constant, never user-supplied text.

  • env_id() resolves lazily, not at import. If tenant lookup ran at import, a logged-out CLI would kill the server before it registered a single tool and your client would report nothing more useful than "server failed to start". Resolved on first use, the same failure arrives as a readable error inside a tool result telling you to run az login.

The trade-off, stated plainly: the CLI's session lives under your tenant's Conditional Access policy, so it can expire mid-session. You get AADSTS70043 and az login fixes it. That is the price of not owning an app registration, and for a demo server it is obviously worth paying.

Layer 2: Transport (_call, _list)

One request helper handling the four things that always come up:

  • 401 once, with a force-refreshed token, then retry

  • 429 with Retry-After honoured

  • 503 / 504 with exponential backoff

  • Terminal errors re-raised carrying the API's own message, because the model can frequently act on it directly

Plus _list, which follows nextLink for collection endpoints up to a cap.

BASE        https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple
api-version 2016-11-01

Layer 3: Shaping (_flow_summary, _run_summary, _resolve_error, _trim)

Where the judgement lives. Every tool returns a hand-picked subset of the API response.

The rule of thumb: if you would not read the field while debugging, the model does not need it either.

_trim caps any single field at 2000 characters so one enormous action payload cannot blow up the context window. _resolve_error is the SAS-blob second hop described above.

Layer 4: Tools and docstrings

Ten @mcp.tool() functions. The docstrings are not documentation for humans, they are the prompt the model reads to decide what to call and how. They carry:

  • what the tool returns and which field feeds which other tool

  • the API's non-obvious constraints

  • the failure modes and what they actually mean

  • explicit instructions such as "look connector operationIds up on Microsoft Learn rather than guessing"

Everything you get wrong twice belongs in a docstring. That is the practice this repo is arguing for.


Tool reference

Ten tools in three groups.

Group

Tools

Author

list_flows, get_flow, create_flow, update_flow_definition, bind_connection

Operate

run_flow, list_runs

Diagnose

explain_run, compare_runs, analyze_flow_health

Which one to reach for:

flowchart TD
    Q{"What are you trying to do?"}

    Q -->|"See what exists"| T1["list_flows<br/>get_flow"]
    Q -->|"Build something"| T2["create_flow"]
    Q -->|"Something is wrong"| D{"Do you have a failed run?"}

    T2 --> C{"Does it use a connector?"}
    C -->|No| R["run_flow"]
    C -->|Yes| B["bind_connection<br/>then it can start"]
    B --> R

    D -->|"Not yet"| L["list_runs<br/>find the failed run_id"]
    L --> E
    D -->|Yes| E["explain_run<br/>which action, what error,<br/>on what inputs"]

    E --> S{"Is the cause clear?"}
    S -->|Yes| F["update_flow_definition<br/>then run_flow"]
    S -->|"No - it works other days"| CR["compare_runs<br/>diff against the last good run"]
    S -->|"No - it fails a lot"| AH["analyze_flow_health<br/>flaky or broken? which action?"]
    CR --> F
    AH --> F

    classDef hero fill:#F26F21,stroke:#c2551a,color:#ffffff
    classDef tool fill:#eef2f6,stroke:#94a3b8,color:#2A3B4E
    classDef ask fill:#2A3B4E,stroke:#1b2733,color:#ffffff
    class E,CR,AH hero
    class T1,T2,R,B,L,F tool
    class Q,C,D,S ask

list_flows(state="", top=25)

List flows in the environment.

Parameter

Type

Default

Notes

state

str

""

Filter on Started or Stopped. Empty returns all.

top

int

25

Maximum flows to return.

Returns a list of {flow_id, display_name, state, modified}. Use flow_id with every other tool.

get_flow(flow_id)

Get one flow with its complete definition, the JSON behind the designer's Code view.

Returns {flow_id, display_name, state, modified, triggers, actions, definition} where triggers and actions are name lists for quick scanning and definition is the full dict.

Call this before update_flow_definition: the API has no partial update semantics, so you must send the entire definition back with your modification applied.

create_flow(display_name, definition, start=True)

Create a flow from a workflow-definition dict.

Parameter

Type

Default

Notes

display_name

str

required

Name shown in the portal.

definition

dict

required

Needs at least triggers and actions.

start

bool

True

False creates it stopped, required for connector flows.

See Gotchas for the two rules that make the difference between a 201 and an afternoon of confusion.

update_flow_definition(flow_id, definition, connection_references=None)

Replace a flow's definition. Send the complete definition, not a fragment.

connection_references is required for connector flows and is shaped like:

{
  "shared_office365": {
    "connectionName": "shared-office365-8f3a...",
    "source": "Embedded",
    "id": "/providers/Microsoft.PowerApps/apis/shared_office365"
  }
}

Does not work on portal-bound flows. See Gotchas.

bind_connection(flow_id, connector, connection_name="", start=True)

Bind an existing connection to a flow and start it. The missing step of create_flow.

Parameter

Type

Default

Notes

flow_id

str

required

The flow to bind.

connector

str

required

Logical name, e.g. shared_office365, shared_teams.

connection_name

str

""

Concrete connection id. Empty means auto-resolve.

start

bool

True

Start the flow once bound.

create_flow returns 201 but leaves connectionReferences empty, so a connector flow cannot be started. Binding is a separate PATCH that must carry both the full definition and the connection reference. This tool does the whole sequence in one call: resolve the connection, PATCH definition plus reference, start the flow.

Returns one of four statuses:

status

Meaning

bound

Success. Check connections_on_flow is at least 1.

ambiguous

Several connections match this connector. Candidates returned; re-call with connection_name.

not_found

No connection for this connector is visible. Create it in the portal first.

(raises)

Portal-bound flow, or the flow does not exist.

It deliberately does not guess when several connections match, because binding the wrong account is a silent failure you would discover in production.

The connection must already exist. No API reachable with a Flow token can create and authenticate a new connection. That is a portal action, permanently.

run_flow(flow_id, trigger_name="manual", inputs=None)

Trigger a flow immediately rather than waiting for its schedule. Requires flow ownership.

trigger_name is the trigger's internal name from get_flow. It is manual for button flows, which is why the demo flow uses a button trigger.

Returns immediately. The run is asynchronous, so poll list_runs for the outcome.

For Request / HTTP-trigger flows this management endpoint does not forward a body, so @triggerBody() evaluates to null. Call the flow's real HTTP URL if it depends on its payload.

list_runs(flow_id, status="", top=10)

Recent runs, newest first.

Parameter

Type

Default

Notes

status

str

""

Succeeded, Failed, Cancelled, Running.

top

int

10

Capped at 100 by the API.

Returns {run_id, status, start_time, end_time, error}. Feed a failed run_id straight into explain_run.

explain_run(flow_id, run_id)

Diagnose a run. The reason this repo exists.

Returns:

{
  "run_id": "08d8...",
  "status": "Failed",
  "started": "2026-08-04T14:22:01Z",
  "failed_actions": [
    {
      "name": "Compute_batches",
      "status": "Failed",
      "error": "The template language function 'div' was invoked with a divisor of zero.",
      "inputs": "..."
    }
  ],
  "succeeded": [
    {
      "name": "Load_settings",
      "status": "Succeeded",
      "outputs": {"region": "westeurope", "retries": 3, "batch_size": 0}
    }
  ],
  "hint": "Compute_batches failed. Its error is resolved above; check the outputs of the succeeded actions for the value that caused it."
}

If failed_actions is empty while status is Failed, the failure was in the trigger, not the body. Inspect the trigger's condition and inputs.

SAS URLs expire. Debugging a run from several days ago may return [error blob unavailable]. Re-run the flow to produce a fresh failure.

compare_runs(flow_id, failed_run_id, baseline_run_id="")

Diff a failed run against a working one.

Use this when explain_run gives you an error that is technically clear but does not explain why this run differed: intermittent failures, "it worked yesterday", data-dependent bugs. baseline_run_id is optional; left empty the tool finds the most recent Succeeded run itself.

{
  "failed_run": "08d8...",
  "baseline_run": "08d7...",
  "diverged_at": "Compute_batches",
  "status_changes": [
    { "action": "Compute_batches", "baseline": "Succeeded", "failed": "Failed" }
  ],
  "output_changes": [
    { "action": "Load_settings", "baseline": {"batch_size": 4}, "failed": {"batch_size": 0} }
  ],
  "only_in_failed": [],
  "only_in_baseline": []
}

Read it in this order: diverged_at tells you where the run broke, and output_changes usually tells you why. Above, the run diverged at Compute_batches but the cause is upstream in Load_settings, whose output changed from 4 to 0. Symptom and cause are different actions, which is the normal case.

only_in_failed and only_in_baseline being non-empty means a condition or branch evaluated differently between the two runs.

If output_changes is empty and the same action failed in both runs, the failure is deterministic. The fix is in the definition, not the data.

Output comparison uses contentSize when outputs are behind a outputsLink URI, since the URIs themselves differ per run by design and would otherwise always compare as changed.

analyze_flow_health(flow_id, last_n=50, sample_failures=5)

Analyse recent run history: reliability, failure patterns, duration.

Parameter

Type

Default

Notes

last_n

int

50

Runs included in the statistics.

sample_failures

int

5

Failed runs opened for action-level attribution.

{
  "runs_analysed": 50,
  "Succeeded": 41, "Failed": 9, "Cancelled": 0, "Running": 0,
  "failure_rate": 0.18,
  "duration_seconds": { "mean": 4.21, "p95": 11.80, "max": 14.02 },
  "failures_sampled": 5,
  "failing_actions": [
    { "action": "Get_items", "count": 5, "sample_error": "The response is not in a JSON format." }
  ],
  "verdict": "Flaky, concentrated in 'Get_items' - that one action explains almost every failure."
}

Action-level detail costs one request per run, so only sample_failures of the failed runs are opened. failing_actions is a sample, not an exhaustive tally, and the response states how many runs it came from so you can see that for yourself.

The verdict distinguishes the two cases that call for different responses: failures concentrated in one action mean a targeted fix, while failures spread across many actions usually mean the trigger data or a connection rather than the logic.


Gotchas this server encodes

These are the hours this repo saves you. Each one is also written into the relevant docstring so the model sees it at call time, which is the entire point.

1. Failed actions carry error: null

Covered above. The message is in a SAS-signed blob at outputsLink.uri. explain_run follows it.

2. Connector flows need the two magic parameters

If any trigger or action is an OpenApiConnection, OpenApiConnectionWebhook, or OpenApiConnectionNotification, the definition must declare, at top level alongside triggers and actions:

"parameters": {
  "$connections":    { "defaultValue": {}, "type": "Object" },
  "$authentication": { "defaultValue": {}, "type": "SecureObject" }
}

Omit them and creation fails with HTTP 400 stating that the trigger is missing $authentication. That message is misleading: there is no trigger-versus-action asymmetry, connector triggers and connector actions both fail identically without the block. Add only $authentication and it then complains about $connections.

They are harmless on connector-free flows, so just always include them.

3. Creating a flow does not bind its connections

A 201 Created gives you a flow whose connectionReferences is {}, whose /connections endpoint is empty, and which cannot be turned on:

CannotStartUnpublishedSolutionFlow: Please authenticate the flow connections
and save the flow to enable activation.

Passing connectionReferences on the create call does not help. The service rewrites it into a solution-style connectionReferenceLogicalName binding that stays unstartable.

The working headless sequence for a non-solution flow reusing a connection that already exists in the environment:

  1. create_flow(..., start=False)

  2. update_flow_definition(flow_id, definition, connection_references={...})

  3. Start the flow

bind_connection does all three in one call, which is exactly the kind of thing your own MCP server should absorb. An API that requires a three-step dance to reach a working state is an API whose tool layer should expose the destination, not the dance.

flowchart LR
    subgraph naive["What create_flow alone gives you"]
        direction TB
        A["create_flow<br/>(definition using a connector)"] --> B["201 Created"]
        B --> C["connectionReferences: { }<br/>/connections is empty"]
        C --> D["Start &rarr; CannotStartUnpublishedSolutionFlow<br/>passing connectionReferences on<br/>create does not help either"]
    end

    subgraph fix["bind_connection does all three"]
        direction TB
        E["1. resolve the connectionName"] --> F["2. PATCH definition<br/>+ connectionReferences"]
        F --> G["3. POST /start"]
    end

    naive -.->|"blocked"| fix
    fix --> H["Running"]

    classDef bad fill:#fdecea,stroke:#d93025,color:#7f1d1d
    classDef good fill:#F26F21,stroke:#c2551a,color:#ffffff
    classDef plain fill:#eef2f6,stroke:#94a3b8,color:#2A3B4E
    class C,D bad
    class H good
    class A,B,E,F,G plain
    style naive fill:#fff5f5,stroke:#d93025,color:#7f1d1d
    style fix fill:#fff7f0,stroke:#F26F21,color:#2A3B4E

Nothing in any of these APIs can create and authenticate a brand new connection. Make that one in the portal first.

4. Portal-bound flows cannot be updated through this API

Once the maker portal binds a flow's connections they become Dataverse connection references, and there is a read/write shape mismatch that cannot be reconciled from here:

  • get_flow reports the host as connectionName

  • the PATCH wants connectionReferenceName

  • supplying connectionReferenceName fails with "connection reference could not be found", because minting the Dataverse reference is not possible through this endpoint

Edit those flows in the portal. Headless creation plus binding works only for flows whose connections you also created here.

5. There is no environment-wide connections endpoint you can reach

This one is worth reading even if you never touch Power Automate, because it is the purest example of why a hand-built tool layer beats a generated one.

To bind a connection you need its connectionName. The obvious way to get it is to list the environment's connections. You cannot:

  • /environments/{env}/connections returns 404 under the Microsoft.ProcessSimple provider and under Microsoft.PowerApps

  • the route that does work lives on a different host entirely, https://api.powerapps.com/providers/Microsoft.PowerApps/environments/{env}/connections

  • calling it with a service.flow.microsoft.com token returns 403 InvalidPath, because it needs an aud=service.powerapps.com token, meaning a second app registration and a second admin consent

The per-flow route /environments/{env}/flows/{flow_id}/connections does work. So this server discovers connections by walking the environment's flows and unioning what they reference.

The trade-off is real and is documented rather than hidden: a connection that no flow uses yet is invisible. For the question that actually matters, "is connector X already connected here and what is its connectionName", any bindable connection is normally referenced by at least one flow.

flowchart TB
    N["You need a connectionName to bind a connection"]
    N --> A["GET /environments/{env}/connections<br/>Microsoft.ProcessSimple &rarr; 404 &nbsp;&nbsp;|&nbsp;&nbsp; Microsoft.PowerApps &rarr; 404"]
    A --> C["GET api.powerapps.com/.../connections &mdash; the route that does exist<br/>403 InvalidPath: needs aud=service.powerapps.com,<br/>i.e. a second app registration and a second consent"]
    C -.->|"so this server does this instead"| E["GET /flows &rarr; per flow GET /flows/{id}/connections &rarr; union the results"]
    E --> H["Trade-off, documented not hidden:<br/>a connection that no flow uses yet is invisible"]

    classDef bad fill:#fdecea,stroke:#d93025,color:#7f1d1d
    classDef good fill:#F26F21,stroke:#c2551a,color:#ffffff
    classDef plain fill:#eef2f6,stroke:#94a3b8,color:#2A3B4E
    class A,C bad
    class E good
    class N,H plain

No code generator produces that workaround. It only exists because someone hit the 404, then hit the 403, then found the flow-scoped route.

6. Never inline a secret in a definition

Flow definitions are stored in plaintext on the flow artifact. An API key written into a definition is readable by anyone with access to the flow. Use a Power Platform environment variable or a Key Vault reference and resolve it at runtime.

7. Look connector operationIds up, do not guess them

Connector actions need the exact operationId and the exact parameter names. Guessing produces a flow that saves cleanly and fails at runtime, which is the worst possible failure mode. Search Microsoft Learn connector reference for the connector, or read the definition of a working flow built in the portal.

A representative example of how non-obvious these get: the Teams "post adaptive card and wait for a response" action is PostCardAndWaitForResponse, its parameters use a doubled prefix (body/body/messageBody), and the submitActionId it returns is the title of the button the user clicked rather than the button's data payload.


The demo flow

demo-flow.json is deliberately broken, and deliberately connector-free.

"Load_settings":   { "type": "Compose", "inputs": { "batch_size": 0, ... } },
"Compute_batches": { "type": "Compose", "inputs": "@div(120, outputs('Load_settings')['batch_size'])" }

Load_settings emits batch_size: 0. Compute_batches divides by it. The run fails at the second action, and the reason is visible only in the first action's output, which is precisely the shape explain_run is built to handle.

It uses a button trigger plus two Compose actions, so it involves no connector at all. That means it creates and starts headlessly with no connection binding, sidestepping gotchas 2 and 3 entirely. If you are building your own demo, copy that choice: connector-free flows are the only ones you can reliably create end to end from an API.

The second demo flow, and why it had to exist

demo-flow-connector.json posts to a Teams channel that does not exist.

It exists because demo-flow.json cannot demonstrate the tool this repository is built around. A divide-by-zero is an expression error, and Power Automate returns those inline: the failed action carries a complete error and no outputsLink at all, so _resolve_error returns on its first branch and the blob hop never happens. The headline feature was unexercised by the headline demo, and nothing short of running it against a live tenant would have revealed that.

The connector flow produces the other shape - no error property, everything in the blob - and it is the one to run when you want to see explain_run earn its keep. It costs you a connection that must already exist in the environment, and therefore a bind_connection call, which is a fair demonstration of gotchas 2 and 3 rather than a way around them.

Keep both. They prove different things.


Extending it

Adding a tool takes three steps.

  1. Find the endpoint. The Power Automate management API is the Microsoft.ProcessSimple provider. Browser devtools on make.powerautomate.com is an effective way to discover the exact shape of a call the portal makes.

  2. Write the shaping function. Call the endpoint once, look at the response, and decide what is worth the model's context. Be aggressive. You can always add a field back.

  3. Write the docstring like a prompt. State what it returns, which field feeds which other tool, and every constraint you discovered while building it.

@mcp.tool()
def resubmit_run(run_id: str, flow_id: str) -> dict:
    """Re-run a failed run with its ORIGINAL trigger payload.

    Different from run_flow: this replays the exact data that caused the failure,
    which is what you want after fixing a definition. run_flow starts a fresh run
    with no payload and will not reproduce the case you just fixed.

    Returns a new run_id. Poll list_runs, then explain_run if it fails again.
    """
    return _call("POST", f"/environments/{ENV_ID}/flows/{flow_id}/runs/{run_id}/resubmit")

Note what that docstring spends its words on: not what the tool does, but when to use it instead of the tool next to it. Disambiguating two similar tools is the highest-value sentence you can write, because choosing wrong between them is the mistake a model actually makes.

Natural next additions, roughly in order of usefulness: resubmit_run, list_environments, get_trigger_url, delete_flow, list_solutions.


Skills: the layer above docstrings

Docstrings teach the model one tool at a time. They cannot teach it the loop: which tool to reach for first, when to move to the next one, and when to stop. That knowledge lives one layer up, in skills - markdown playbooks a coding agent loads when the task matches.

Two ship in .claude/skills/:

  • debug-flow - the diagnose loop: list_runs -> explain_run, then compare_runs when the error is clear but the cause is not, then analyze_flow_health when it recurs, then fix and verify.

  • build-flow - the authoring sequence: the definition rules, create then bind_connection then start, and closing the loop with a run after every change.

If you open this repo in Claude Code they load automatically. If you installed the server into another project, copy the skill folders into that project's .claude/skills/ (or ~/.claude/skills/ to have them everywhere).

The division of labour is the same one the four layers made: put per-tool constraints in the docstring, put cross-tool workflow in a skill. When the model gets a single call wrong, fix the docstring; when it picks the wrong tool or gives up too early, fix the skill.


Troubleshooting

Symptom

Cause

Fix

AADSTS70043 token_expired

Azure CLI session aged out under a Conditional Access sign-in-frequency policy

az login again

Azure CLI call failed ... 'az' is not recognized

Azure CLI not installed or not on PATH

Install it

Please run 'az login'

Never signed in on this machine

az login

Tools work but hit the wrong tenant

az is signed in somewhere else

az account show to check, az login --tenant <id> to move

AADSTS65001 consent error

Your tenant has not consented the Azure CLI for the Flow audience

You need your own app registration; this server does not implement that path

404 on a flow you can see in the portal

Wrong environment

Set PA_ENV_ID to the environment GUID from the maker portal URL

Create returns 400 about $authentication

Magic parameters missing

See gotcha 2

CannotStartUnpublishedSolutionFlow

Connections not bound

See gotcha 3

explain_run returns [error blob unavailable]

SAS URL expired

Re-run the flow and debug the fresh failure

bind_connection returns not_found

Connection does not exist, or no flow references it yet

Create and authenticate it in the portal, or use it on one flow first

bind_connection returns ambiguous

Several connections for that connector

Re-call with connection_name set to one of the returned candidates

bind_connection succeeds but connections_on_flow is 0

Flow is solution or portal-bound

Edit that flow in the portal, see gotcha 4

analyze_flow_health returns duration_seconds: null

No successful runs to measure

Expected on a flow that has never succeeded

@triggerBody() is null when using run_flow

Management endpoint does not forward bodies

Call the flow's real HTTP trigger URL instead


Security

  • No secret, and no credential of its own. The only credential involved is the refresh token the Azure CLI already holds on your machine. This repo never reads, writes or stores it, which means there is no new secret to leak and nothing to rotate if you fork this.

  • The server acts as you. Every call uses your delegated permissions, so it can do anything you can do in that environment, including deleting work. Point it at a demo tenant.

  • .env holds no secrets by design. A client ID and tenant ID are public identifiers. It is gitignored anyway, because environment IDs leak tenant structure.

  • Write tools are live. create_flow, update_flow_definition, and run_flow change real state with no confirmation step. If you want this in a production tenant, split the read tools and write tools into two servers and connect the write server only when you mean it.

  • Never inline secrets in flow definitions. See gotcha 6.


What is deliberately missing

This is a teaching artifact, not a complete client. Left out of server.py on purpose: environment discovery, desktop flows, approvals, and cancel. Solution-bound flow editing, resubmit, trigger URLs and delete moved into extras.py, which runs as a second server so the teaching core stays at ten tools.

Ten tools is about the number that fits in a talk while still covering a real loop: author, bind, run, diagnose. The production server this was extracted from runs twenty-four Power Automate tools alongside Microsoft Graph and Teams, and it is the same four layers throughout.


Compared with Microsoft's plugin

Microsoft ships power-platform-skills, whose power-automate plugin bundles a 56-tool MCP server (flowagent) plus ten skills. Install it: it is good, and for most work it is more useful than this.

Where theirs is straightforwardly better: connection lifecycle management, search_operations over connector metadata, surgical action-level edit_flow, a real backup and restore subsystem, templates, desktop flows, environment routing, a deprecated-operations table, and genuinely strong $connections / $authentication reference documentation. Their install is two slash commands; this one is a clone and a pip install. They ship MCP tool annotations and they handle solution clientdata. Their validateDefinition rule set is better than the one this repo had, so _validate_definition is a port of theirs.

Where this one wins, measured on the same failed run in the same environment on 2026-08-07 (a Teams post to a channel that does not exist):

flowagent (56 tools)

pa-demo-mcp (10)

which action failed

yes

yes

error text

"message": ""

404 NotFound: LocationLookupFailed-Location lookup failed for thread 19:...

remediation

"Target resource not found. Verify the item/list/folder still exists"

-

the failing action's inputs

not returned

resolved from the content link

upstream value that caused it

unreachable by any of the 56 tools

{"team_id": "0000...", "channel_id": "19:...dead"}

diff against a working run

no such tool

compare_runs, resolved both sides

Their diagnoseRun reads a.properties?.error?.message ?? "" straight off the action record and never follows a link, so on a connector failure the message is empty and the remediation is generic. Their get_run_actions drops action outputs entirely, get_run_details is run-level, and get_run_action_repetitions is loop-only - so there is no path from a failure to the value that caused it.

They tell you where it broke and what the error says. They cannot tell you why.

That is not a claim of general superiority; it is one tool, built by someone who hit that specific wall repeatedly, beating a much larger surface at the one question it was built to answer. Which is the entire argument of this repository.

Verified, and not

Everything in the table above was run live, not read from source. Ten of ten tools have been exercised against a real tenant. Not verified: update_solution_flow_definition against an actual managed-solution flow, and anything in an environment without a Dataverse instance.


FAQ

Why not just use an official Power Platform MCP server? Often you should - Microsoft's is 56 tools and covers far more ground. See Compared with Microsoft's plugin for a measured head-to-head. The reason to build your own is layer 4: no vendor can know that your connector always fails this way in your environment. The gap in that comparison is not a gap in their engineering, it is a gap in what a generic tool can know. You also frequently want three different APIs behind one server, which nobody ships for you.

Did Claude write this? Layers 1 and 2, yes, essentially first try. Layers 3 and 4 are hand-written, because they encode things the API does not document and a model could not know. That split is the entire argument.

How long did it take? The ten tools are an evening. The docstrings are months of hitting the same walls repeatedly. That is the honest answer, and it is the more useful one.

Can I use this against a production tenant? Technically yes, and you should think hard first. See Security.

Does this work with Copilot Studio, Dataverse, or Azure DevOps? Same four layers, different base URL and scope. Swap those two constants and the structure holds unchanged. That is why the file is organised the way it is.

One caveat on transport. Pointing this server at Dataverse or Azure DevOps is the two-constant change above, and it stays a stdio server. Making it callable from Copilot Studio is a different job: a cloud-hosted agent cannot spawn a process on your laptop, so you need Streamable HTTP, which means hosting, your own app registration and a consent flow - there is no local CLI session out there to borrow. See Transport: run it over stdio.

Why one file instead of a package? So it can be read top to bottom in five minutes. A real server should be split into modules. This one is optimised for being understood, not extended.


Built by Elliot Margot - Microsoft MVP, M365 Copilot and Copilot Studio. Licensed MIT.

A
license - permissive license
-
quality - not tested
B
maintenance

Maintenance

–Maintainers
–Response time
–Release cycle
–Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • A
    license
    A
    quality
    C
    maintenance
    An MCP server that enables Claude Code to visually test and control iOS simulators, Android emulators, and real devices through 22 automation tools. It automatically generates test reports with screenshots for mobile app testing directly from the terminal.
    24
    10
    1
    MIT
  • A
    license
    -
    quality
    D
    maintenance
    An MCP server that provides Claude Code with persistent memory across sessions, including session checkpoints, image persistence, and bidirectional sync with claude.ai projects.
    1
    MIT

View all related MCP servers

Related MCP Connectors

  • The MCP server for Azure DevOps, bringing the power of Azure DevOps directly to your agents.

  • MCP server for AI agents to plan, verify, and deploy Cloudflare-native apps.

  • Augments MCP Server - A comprehensive framework documentation provider for Claude Code

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/OwnOptic/power-automate-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server