Power Platform MCP: Flows and Copilot Studio Agents from Chat

Power Platform MCP: Flows and Copilot Studio Agents from Chat

An open-source MCP server, 28 tools in 3 local servers, that lets Claude author, run and actually debug Power Automate flows, then build the Copilot Studio agents that call them, on both harnesses.

Project details

Industry

Open Source, Developer Tooling

Timeline

2026

Tech Stack

Python, MCP Python SDK 2.x, Power Automate API, Dataverse Web API, Azure CLI, Power Platform CLI, Remotion

What It Does

power-platform-mcp (renamed from power-automate-mcp in November 2026) lets any MCP client build, run and debug your Power Automate flows, and build the Copilot Studio agents that use them. It is 3 small local servers, 28 tools in all: server.py, the 10-tool teaching core (author, bind, run, diagnose), extras.py with 9 more for real work (environments, start and stop, cancel, resubmit, solutions), and copilot_studio.py with 9 that read, export and edit agents, on the standard and the new GitHub Copilot harness.

It authenticates through the Azure CLI session you already have, so there is no app registration, no admin consent, and no device-code dance. Sign in with az login, ideally in a separate az profile for your demo tenant (AZURE_CONFIG_DIR), and the tools work against that tenant while your everyday az stays where it is.

Everything runs locally, over stdio. It borrows the Azure CLI session already on your machine, so it has to run as a local process next to that CLI. No hosting, no public endpoint, no second auth layer. Each colleague runs their own copy, signed in as themselves.

The point is not that it wraps an API. The point is the debugging loop below, which the model drives on its own because the tools tell it what they are for.

Watch it in three minutes

Generated, not recorded: voiced and captioned from the outputs of a real run, rendered with Remotion. The French cut is on the French version of this page.

.mcp.json - stdio, three local servers
{
  "mcpServers" {
    "power-platform"         { "command" "python", "args" ["C:/path/to/power-platform-mcp/server.py"] },
    "power-platform-extras"  { "command" "python", "args" ["C:/path/to/power-platform-mcp/extras.py"] },
    "power-platform-copilot" { "command" "python", "args" ["C:/path/to/power-platform-mcp/copilot_studio.py"] },
    "microsoft-learn" { "type" "http", "url" "https://learn.microsoft.com/api/mcp" }
  }
}

The same block works in claude_desktop_config.json. Use an absolute path to server.py; the server resolves its .env relative to its own file, so the working directory does not matter.

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

  create_flow  -> DEMO - nightly batch, Started
  run_flow     -> accepted
  list_runs    -> Failed

> It failed. What happened?

  explain_run  -> Compute_batches failed:
                  "Attempt to divide an integral or decimal value by zero
                   in function 'div'."
                  Load_settings emitted {"region": "westeurope", "retries": 3,
                  "batch_size": 0}

> Fix it, run it again, and compare.

  update_flow_definition   -> batch_size: 0 -> 4 (previous definition kept)
  run_flow, list_runs      -> Succeeded
  compare_runs             -> diverged at Compute_batches;
                              Load_settings batch_size: failed 0, worked 4

The Problem: A Failed Connector Action Has No Error

Power Automate does not put the error message on the action record. When a connector action fails, the API hands you this:

json
{
  "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 maker portal follows the link for you, which is exactly why the portal shows a real error and a naive API wrapper shows you Failed and nothing else. (Expression errors, like a divide by zero, are the exception: their message is inline. Connector failures are the case worth building for.)

This is the gap that makes a generic HTTP wrapper useless for debugging, and it is the reason this server exists.

explain_run: The Tool That Justifies the Exercise

explain_run does 2 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.

The result is that symptom and cause arrive together. Compute_batches is where the run died, but Load_settings is where the problem was.

explain_run as a sequence: the action record says Failed with no error, so the server follows outputsLink to the signed content link, reads the real error, and pairs it with the outputs of the actions before it.

One tool call, in place of roughly a dozen clicks through the run history view. 2 companion tools cover the cases where the error is clear but the reason is not: compare_runs diffs a failed run against a working baseline to find the point of divergence, and analyze_flow_health samples the last 50 runs to tell you whether a flow is flaky or genuinely broken, and which action is responsible.

Architecture: The 4 Layers

Every useful MCP server is 4 layers, and only 2 of them are interesting. The whole server is one file, kept that way so it can be read top to bottom in a few minutes, with the layers appearing in order.

The four layers of an MCP server: auth and transport are generated in one prompt; shaping and docstrings are where the value lives.

Layer 1, auth. Getting the token is one shell call against a Microsoft first-party app the tenant already trusts. Change the resource constant and the same 3 lines authenticate against Microsoft Graph, Dataverse, or Azure Resource Manager.

Layer 2, transport. One request helper covering the 4 things that always come up: a single 401 retry with a force-refreshed token, 429 with Retry-After honoured, exponential backoff on 503 and 504, and terminal errors re-raised carrying the API's own message, because the model can frequently act on it directly.

Layer 3, shaping. Where the judgement lives. list_flows returns 4 fields per flow where the API returns about 60, mostly GUIDs and internal plumbing. The rule of thumb: if you would not read the field while debugging, the model does not need it either.

Layer 4, docstrings. The docstrings are not documentation for humans, they are the prompt the model reads to decide what to call and how. They carry what each tool returns, which field feeds which other tool, the API's non-obvious constraints, and what each failure mode actually means.

The code is regenerable. The knowledge is the asset.

Layers 1 and 2 were generated in a single prompt, 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 whole argument for building your own MCP server rather than waiting for a vendor to ship you one. No vendor can know that your connector always fails this way in your environment.

28 Tools in 3 Local Servers

The core stays at 10 tools, the number that fits in a talk while still covering a real loop. The other 2 servers import its auth and transport, so they add tools without adding auth code.

ServerToolsWhat it covers
server.py (10)list_flows, get_flow, create_flow, update_flow_definition, bind_connection, run_flow, list_runs, explain_run, compare_runs, analyze_flow_healthAuthor, bind, run, and diagnose: resolve the real error, diff against a working run, separate flaky from broken.
extras.py (9)list_environments, set_flow_state, cancel_run, resubmit_run, get_trigger_url, delete_flow, list_solutions, get_solution_flow_clientdata, update_solution_flow_definitionFind the right environment, start and stop, cancel, replay a failing payload, and reach solution flows through Dataverse.
copilot_studio.py (9)list_agents, get_agent, export_agent, create_agent_flow, add_flow_tool, update_agent_instructions, upsert_agent_skill, remove_agent_component, publish_agentGive a Copilot Studio agent a flow as a tool, tell it when to use it, write its instructions and skills, export it to rebuild it elsewhere, and publish.

Copilot Studio: Agents Call the Flows Claude Builds

Copilot Studio agents are Dataverse rows: a bots row, plus one botcomponents row per topic, tool, knowledge source and instruction set. So the same az session that runs flows edits agents too, with a token for the environment's org URL. copilot_studio.py adds 9 tools and no auth code.

The loop it exists for: Claude builds a flow, plugs it into an agent as a tool, rewrites the agent's instructions to use it, and publishes. Every step was run against a real tenant: instructions read and rewritten, the flow added as a tool, and the agent published successfully in about 30 seconds. Two guards are written into the tools because the platform does not raise them early enough: instructions containing curly braces are refused (Copilot Studio parses them as Power Fx and the agent then fails on every message), and so is a flow the agent cannot call.

Both harnesses. Copilot Studio now builds agents on two runtimes that store them differently: the standard harness keeps the instructions in a gpt.default component and the behaviour in topics, the new GitHub Copilot harness keeps the instructions in the agent's configuration and the behaviour in skills. Every tool reads the agent's template first and does the right thing for each. export_agent reads an agent in any environment, which is how a test agent gets rebuilt in production, and remove_agent_component cleans up what a new agent can start with, such as Work IQ write access to the user's mailbox, with a dry run first.

Agents only call solution flows

A flow created through the Power Automate API is a My flows flow with no Dataverse workflow row at all. Point an agent tool at it and the tool saves, the agent even publishes, and it only fails when a user's question makes the agent call it.

So create_agent_flow writes the workflow row itself, with the definition in clientdata and an optional solution, then turns it on, and add_flow_tool refuses any flow without that row, with the reason.

Gotchas It Encodes

Each of these cost real hours to discover once. Each is now written into the relevant docstring, so the model sees it at call time and nobody pays for it again.

  • Connector flows need 2 magic parameters. If any trigger or action is an OpenApiConnection variant, the definition must declare $connections and $authentication at top level. Omit them and creation fails with an HTTP 400 blaming the trigger, which sends you hunting in entirely the wrong place. They are harmless on connector-free flows, so always include them.
  • Creating a flow does not bind its connections. A 201 Created gives you a flow whose connectionReferences is empty and which cannot be turned on. bind_connection collapses the create, update and start sequence into one call.
  • Solution flows cannot be updated through the Power Automate API. Flows built in the portal under My flows update fine; solution flows bind through Dataverse connection references, so extras.py edits their stored definition in Dataverse instead.
  • Resubmit belongs to the trigger, not to the run. The obvious /runs/{run}/resubmit route is a 404; the real one is /triggers/{trigger}/histories/{run}/resubmit, and it only replays runs the service started.
  • MCP SDK 2.x hides the text of any exception that is not a ToolError. Ported naively, every API message becomes a bare "Error executing tool". Every deliberate error here is a ToolError, and a test fails the build if that ever regresses.
  • Look connector operationIds up rather than guessing them. A guessed operationId produces a flow that creates cleanly and fails at runtime.

Pro Tip

The design rule behind bind_connection generalises: an API that requires a 3-step dance to reach a working state is an API whose tool layer should expose the destination, not the dance. Everything you get wrong twice belongs in a docstring.

Skills: The Layer Above Docstrings

Docstrings teach the model one tool at a time. They cannot teach it a procedure: 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 files whose description tells the agent when to load them.

5 ship with the repo. debug-flow and build-flow encode the diagnose and authoring loops. agent-tool walks giving a Copilot Studio agent a new tool, end to end. dataverse-schema creates a publisher, solution, tables, columns and lookups from a short JSON spec. solution-from-base exports a solution with the Power Platform CLI (or takes a colleague's .zip), unpacks it, changes it by copying a block that already works, bumps the version, packs it and imports it back. Both Power Platform skills were run end to end against a real tenant before they were committed.

The division of labour mirrors the 4 layers. 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.

Security Posture

The server holds no credential of its own. The only credential involved is the refresh token the Azure CLI already keeps on your machine, which this code never reads, writes or stores. There is no new secret to leak and nothing to rotate if you fork it.

But the server acts as you. Every call uses your delegated permissions, so it can do anything you can do in that environment. The write tools are live, and a published agent change reaches every user of that agent. Every tool carries MCP annotations, so a client can ask before the write ones, and delete_flow asks twice. Point it at a demo tenant, in its own az profile, pinned with PA_TENANT_ID.

Project Repository

The repo ships with an offline test suite (93 tests, run in CI on Windows and Linux), a 30-minute live demo script in English and French, and the explainer video above, generated from the narration file so that changing a sentence re-renders the captions and timings.

The production server this was extracted from runs 27 Power Automate tools alongside Microsoft Graph and Teams, and it is the same 4 layers throughout.

Built with
  • Python
  • MCP Python SDK 2.x
  • Power Automate API
  • Dataverse Web API
  • Copilot Studio
  • Azure CLI
  • Power Platform CLI
  • Remotion