<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://troystaylor.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://troystaylor.com/" rel="alternate" type="text/html" /><updated>2026-10-06T19:52:27+00:00</updated><id>https://troystaylor.com/feed.xml</id><title type="html">Power Platform Integrations</title><subtitle>Sharing Is Caring - Technical insights on Power Platform custom connectors, MCP servers, Copilot Studio, and enterprise integrations.
</subtitle><author><name>Troy Taylor</name></author><entry><title type="html">Add Entra ID and PKCE sign-in to Copilot Studio A2A agents</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/2026-10-06-a2a-connector-template-copilot-studio.html" rel="alternate" type="text/html" title="Add Entra ID and PKCE sign-in to Copilot Studio A2A agents" /><published>2026-10-06T19:30:00+00:00</published><updated>2026-10-06T19:30:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/a2a-connector-template-copilot-studio</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/2026-10-06-a2a-connector-template-copilot-studio.html"><![CDATA[<p>Copilot Studio can <a href="https://learn.microsoft.com/microsoft-copilot-studio/add-agent-agent-to-agent">connect to an agent over the Agent2Agent (A2A) protocol</a> from <strong>Agents → Add agent → A2A agent</strong>. That built-in option offers three authentication choices: <strong>None</strong>, <strong>API key</strong>, and <strong>OAuth 2.0</strong>. If the agent sits behind Microsoft Entra ID, or its authorization server requires PKCE, you’re stuck.</p>

<p>The <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Connector-Code/A2A%20Connector%20Template">A2A Connector Template</a> fills that gap. It’s a two-file custom connector with no <code class="language-plaintext highlighter-rouge">script.csx</code> that you deploy with PAC CLI. Once deployed, it shows up in the same <strong>Add agent</strong> list as the built-in options, and Copilot Studio treats it as a native A2A agent.</p>

<h2 id="when-to-use-the-template">When to use the template</h2>

<p>Use the built-in <strong>A2A agent</strong> option when it covers your agent. It’s simpler. Use the template when you need any of these:</p>

<table>
  <thead>
    <tr>
      <th>Need</th>
      <th>Built-in A2A agent</th>
      <th>Template</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>No authentication or an API key</td>
      <td>Yes</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>OAuth 2.0 authorization code with a client secret</td>
      <td>Yes</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Microsoft Entra ID sign-in</td>
      <td>No</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>OAuth 2.0 with PKCE (<code class="language-plaintext highlighter-rouge">S256</code>)</td>
      <td>No</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>An agent card that requires a token to read</td>
      <td>No</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>A definition in source control, deployed to several environments</td>
      <td>No</td>
      <td>Yes</td>
    </tr>
  </tbody>
</table>

<p>The last row matters more than it looks. The built-in option creates a connector for you in one environment. The template gives you a definition you can review, commit, and deploy to dev, test, and production with the same commands.</p>

<h2 id="how-this-differs-from-the-power-a2a-template">How this differs from the Power A2A Template</h2>

<p>In May we published the <a href="/power%20platform/custom%20connectors/2026-05-05-agent-to-agent-a2a-connector-work-iq.html">Agent-to-Agent connector and Power A2A Template</a>. That pair wraps A2A as Model Context Protocol (MCP) tools and REST actions through a <code class="language-plaintext highlighter-rouge">script.csx</code>. Your Copilot Studio agent calls them as tools, and Power Automate calls them as actions.</p>

<p>This template takes the other path. It has no script and no tools. Copilot Studio adds the external agent as an agent, sends it A2A requests directly, and the orchestrator delegates to it like any other connected agent. Pick the Power A2A Template when you need Power Automate or want to inspect the response as tool output. Pick this one when you want the external agent to behave like a native Copilot Studio agent.</p>

<h2 id="one-operation-marked-as-a2a">One operation, marked as A2A</h2>

<p>The whole connector is one operation:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"paths"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
  </span><span class="nl">"/your-a2a-endpoint"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"post"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"operationId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"InvokeA2A"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"summary"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Your A2A Agent"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Describe what the agent does and which requests it should handle."</span><span class="p">,</span><span class="w">
      </span><span class="nl">"x-ms-agentic-protocol"</span><span class="p">:</span><span class="w"> </span><span class="s2">"a2a-1.0"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"x-ms-a2a-card-endpoint"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://your-agent.example.com/your-a2a-endpoint/.well-known/agent-card.json"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"x-ms-a2a-card-json"</span><span class="p">:</span><span class="w"> </span><span class="s2">"null"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"responses"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"default"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"default"</span><span class="p">,</span><span class="w"> </span><span class="nl">"schema"</span><span class="p">:</span><span class="w"> </span><span class="p">{}</span><span class="w"> </span><span class="p">}</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">x-ms-agentic-protocol: a2a-1.0</code> is what puts the connector in the <strong>Add agent</strong> list. Copilot Studio sends every A2A request to this operation.</p>

<p>The operation path is the agent’s message endpoint, the <code class="language-plaintext highlighter-rouge">url</code> in its agent card. It isn’t the agent card URL. The Microsoft Learn article makes the same point for the built-in option.</p>

<p>Microsoft doesn’t document these <code class="language-plaintext highlighter-rouge">x-ms-a2a-*</code> fields. They match what Copilot Studio generates when you add an A2A agent in the portal, which is how the template was built.</p>

<h2 id="five-authentication-options">Five authentication options</h2>

<p>Each option has its own <code class="language-plaintext highlighter-rouge">apiProperties.json</code> in the <code class="language-plaintext highlighter-rouge">auth</code> folder. Copy the one you need over <code class="language-plaintext highlighter-rouge">apiProperties.json</code>, then paste the matching <code class="language-plaintext highlighter-rouge">securityDefinitions</code> block into the API definition.</p>

<table>
  <thead>
    <tr>
      <th>Option</th>
      <th>Identity provider</th>
      <th>Use when</th>
      <th>Status</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>None</td>
      <td>—</td>
      <td>The agent doesn’t require authentication</td>
      <td>Deploys</td>
    </tr>
    <tr>
      <td>API key</td>
      <td>—</td>
      <td>The agent takes a key in a header or query parameter</td>
      <td>Deploys</td>
    </tr>
    <tr>
      <td>OAuth 2.0</td>
      <td><code class="language-plaintext highlighter-rouge">oauth2</code></td>
      <td>Authorization code with a client secret</td>
      <td>Deploys</td>
    </tr>
    <tr>
      <td>Microsoft Entra ID</td>
      <td><code class="language-plaintext highlighter-rouge">aad</code></td>
      <td>The agent’s API is protected by Entra ID</td>
      <td>Tested end to end</td>
    </tr>
    <tr>
      <td>OAuth 2.0 with PKCE</td>
      <td><code class="language-plaintext highlighter-rouge">oauth2pkcewithdcr</code></td>
      <td>The authorization server requires PKCE <code class="language-plaintext highlighter-rouge">S256</code></td>
      <td>Deploys</td>
    </tr>
  </tbody>
</table>

<p><strong>Deploys</strong> means Power Platform accepts the connector and Copilot Studio lists it. Test sign-in and a real call before you rely on it.</p>

<p>The PKCE option uses <code class="language-plaintext highlighter-rouge">oauth2pkcewithdcr</code>, the same hidden identity provider covered in the <a href="/power%20platform/custom%20connectors/mcp/2026-09-04-mcp-public-client-pkce-connector.html">MCP public-client PKCE post</a>. It sends PKCE on sign-in, but it sends the client secret only on token refresh. Validate both sign-in and refresh against your authorization server before production.</p>

<p>The Entra ID option follows the standard <a href="https://learn.microsoft.com/connectors/custom-connectors/azure-active-directory-authentication">Entra ID pattern for custom connectors</a>: an app registration with a client secret, a delegated permission on the agent’s API, and admin consent. You set the API’s Application ID URI and scope in <code class="language-plaintext highlighter-rouge">apiProperties.json</code>:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"oAuthSettings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
  </span><span class="nl">"identityProvider"</span><span class="p">:</span><span class="w"> </span><span class="s2">"aad"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"clientId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"[YOUR_CLIENT_ID]"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"scopes"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="s2">"[YOUR_RESOURCE_URI]/[YOUR_SCOPE]"</span><span class="w"> </span><span class="p">],</span><span class="w">
  </span><span class="nl">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"IsFirstParty"</span><span class="p">:</span><span class="w"> </span><span class="s2">"False"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"AzureActiveDirectoryResourceId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"[YOUR_RESOURCE_URI]"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"customParameters"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"resourceUri"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="s2">"[YOUR_RESOURCE_URI]"</span><span class="w"> </span><span class="p">},</span><span class="w">
    </span><span class="nl">"loginUri"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://login.microsoftonline.com"</span><span class="w"> </span><span class="p">},</span><span class="w">
    </span><span class="nl">"loginUriAAD"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://login.microsoftonline.com"</span><span class="w"> </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h2 id="point-the-connector-at-your-agent">Point the connector at your agent</h2>

<p><code class="language-plaintext highlighter-rouge">Set-A2AAgentCard.ps1</code> reads the agent card and rewrites the API definition. It sets <code class="language-plaintext highlighter-rouge">host</code> and the operation path from the card’s endpoint and records the card URL in <code class="language-plaintext highlighter-rouge">x-ms-a2a-card-endpoint</code>.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Card is public</span><span class="w">
</span><span class="o">.</span><span class="n">\Set-A2AAgentCard.ps1</span><span class="w"> </span><span class="nt">-CardUrl</span><span class="w"> </span><span class="s1">'https://agent.example.com/a2a/.well-known/agent-card.json'</span><span class="w">

</span><span class="c"># Card requires a token</span><span class="w">
</span><span class="o">.</span><span class="n">\Set-A2AAgentCard.ps1</span><span class="w"> </span><span class="nt">-CardUrl</span><span class="w"> </span><span class="nv">$cardUrl</span><span class="w"> </span><span class="nt">-BearerToken</span><span class="w"> </span><span class="nv">$token</span><span class="w">

</span><span class="c"># No card; you know the endpoint</span><span class="w">
</span><span class="o">.</span><span class="n">\Set-A2AAgentCard.ps1</span><span class="w"> </span><span class="nt">-Endpoint</span><span class="w"> </span><span class="s1">'https://agent.example.com/a2a'</span><span class="w">
</span></code></pre></div></div>

<p>The script handles both card shapes. It reads the top-level <code class="language-plaintext highlighter-rouge">url</code> from A2A 0.3 cards, and the first JSON-RPC entry in <code class="language-plaintext highlighter-rouge">supportedInterfaces</code> from A2A 1.0 cards. It rejects endpoints that aren’t HTTPS and warns when it drops a query string.</p>

<p>Add <code class="language-plaintext highlighter-rouge">-EmbedCard</code> to store the card in <code class="language-plaintext highlighter-rouge">x-ms-a2a-card-json</code>. Copilot Studio takes the agent’s name and description from the connector either way, so embedding is optional. Re-run the script when the card changes.</p>

<h2 id="write-the-description-for-the-orchestrator">Write the description for the orchestrator</h2>

<p>When you add the agent, Copilot Studio copies two values from the connector:</p>

<table>
  <thead>
    <tr>
      <th>Field</th>
      <th>Becomes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">info.title</code></td>
      <td>The agent’s name</td>
    </tr>
    <tr>
      <td>The operation’s <code class="language-plaintext highlighter-rouge">description</code></td>
      <td>The agent’s description</td>
    </tr>
  </tbody>
</table>

<p>The orchestrator reads the description to decide when to delegate. Say what the agent does and which requests it should handle, for example: “Answers questions about the signed-in user’s email, meetings, and files.” The Learn article links to <a href="https://learn.microsoft.com/microsoft-copilot-studio/advanced-generative-actions#best-practices">guidance on writing effective metadata</a>, and it applies here.</p>

<p>Copilot Studio copies these values once. Updating the connector later won’t change an agent you’ve already added. Edit the description on the agent’s <strong>Agents</strong> page instead.</p>

<h2 id="deploy-with-pac-cli">Deploy with PAC CLI</h2>

<p>Create the connector with <a href="https://learn.microsoft.com/power-platform/developer/cli/reference/connector"><code class="language-plaintext highlighter-rouge">pac connector create</code></a>. Don’t pass <code class="language-plaintext highlighter-rouge">--script-file</code>.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--environment</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">YOUR_ENVIRONMENT_ID</span><span class="err">&gt;</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-definition-file</span><span class="w"> </span><span class="o">.</span><span class="nx">\apiDefinition.swagger.json</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-properties-file</span><span class="w"> </span><span class="o">.</span><span class="nx">\apiProperties.json</span><span class="w">
</span></code></pre></div></div>

<p>For OAuth options, Power Platform generates a redirect URL that’s unique to this connector in this environment. Download the deployed connector to read it, then register it with your authorization server:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">download</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--environment</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">YOUR_ENVIRONMENT_ID</span><span class="err">&gt;</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--connector-id</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">CONNECTOR_ID</span><span class="err">&gt;</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--outputDirectory</span><span class="w"> </span><span class="o">.</span><span class="nx">\deployed</span><span class="w">

</span><span class="p">(</span><span class="n">Get-Content</span><span class="w"> </span><span class="o">.</span><span class="nx">\deployed\apiProperties.json</span><span class="w"> </span><span class="nt">-Raw</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="p">)</span><span class="o">.</span><span class="w">
    </span><span class="n">properties.connectionParameters.token.oAuthSettings.redirectUrl</span><span class="w">
</span></code></pre></div></div>

<p>Register each environment’s URL separately.</p>

<p>The client secret never touches your working folder. The readme writes it to a temporary copy of <code class="language-plaintext highlighter-rouge">apiProperties.json</code>, runs <code class="language-plaintext highlighter-rouge">pac connector update</code> against that copy, and deletes it. That keeps the folder safe to commit. For more on swapping identity providers this way, see <a href="/power%20platform/custom%20connectors/2026-08-07-update-custom-connector-oauth-identity-providers-pac-cli.html">Update custom connector OAuth identity providers with PAC CLI</a>.</p>

<h2 id="add-the-agent-in-copilot-studio">Add the agent in Copilot Studio</h2>

<p>Open your agent, go to <strong>Agents → Add agent</strong>, and select your connector by its <code class="language-plaintext highlighter-rouge">info.title</code>. Don’t select <strong>A2A agent</strong>, which creates a separate connector. Create a connection, sign in, and select <strong>Add and configure</strong>.</p>

<p>In the agent’s YAML, the result is an <code class="language-plaintext highlighter-rouge">AgentToAgentTool</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">kind</span><span class="pi">:</span> <span class="s">AgentToAgentTool</span>
<span class="na">connectionReference</span><span class="pi">:</span> <span class="s">&lt;your-agent-schema-name&gt;.cr.&lt;connection-reference&gt;</span>
<span class="na">connectorId</span><span class="pi">:</span> <span class="s">/providers/Microsoft.PowerApps/apis/&lt;your-connector-api-name&gt;</span>
<span class="na">operationId</span><span class="pi">:</span> <span class="s">InvokeA2A</span>
</code></pre></div></div>

<p>Because A2A connections run on the custom connector infrastructure, Learn notes you can also reach agents running on-premises or inside a virtual network.</p>

<p>When you test, ask for the task instead of naming the agent. “What meetings do I have today?” routes better than “Use My Agent to check my meetings.” Copilot Studio forwards your words to the external agent, and that agent doesn’t know the name you gave it. If the first question right after you add the agent isn’t routed, wait a minute and ask again.</p>

<h2 id="example-work-iq">Example: Work IQ</h2>

<p><a href="https://learn.microsoft.com/microsoft-365/copilot/extensibility/work-iq/a2a/quickstart">Work IQ</a> is the end-to-end tested case. It’s a Microsoft 365 A2A agent behind Entra ID, and its card requires a token.</p>

<table>
  <thead>
    <tr>
      <th>Setting</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Authentication option</td>
      <td>Microsoft Entra ID</td>
    </tr>
    <tr>
      <td>Endpoint</td>
      <td><code class="language-plaintext highlighter-rouge">https://workiq.svc.cloud.microsoft/a2a</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">[YOUR_RESOURCE_URI]</code></td>
      <td><code class="language-plaintext highlighter-rouge">api://workiq.svc.cloud.microsoft</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">[YOUR_SCOPE]</code></td>
      <td><code class="language-plaintext highlighter-rouge">WorkIQAgent.Ask</code></td>
    </tr>
    <tr>
      <td>App registration permission</td>
      <td><strong>Work IQ</strong> (under <strong>APIs my organization uses</strong>), delegated <strong>WorkIQAgent.Ask</strong>, with admin consent</td>
    </tr>
  </tbody>
</table>

<p>Since the card needs a token, run the script with <code class="language-plaintext highlighter-rouge">-Endpoint 'https://workiq.svc.cloud.microsoft/a2a'</code>, or pass <code class="language-plaintext highlighter-rouge">-CardUrl</code> with a <code class="language-plaintext highlighter-rouge">-BearerToken</code>. Your tenant must be <a href="https://learn.microsoft.com/microsoft-365/copilot/extensibility/work-iq/enable-work-iq">enabled for Work IQ</a>.</p>

<p>Work IQ also shows why protocol version matters. Copilot Studio’s documented payload uses the A2A 0.3 <code class="language-plaintext highlighter-rouge">message/send</code> method. Work IQ supports both versions and falls back to 0.3 when the request has no <code class="language-plaintext highlighter-rouge">A2A-Version</code> header, so it works without changes. If your agent accepts only A2A 1.0, expect a “method not found” error.</p>

<p>For a PKCE example, see <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Workday%20A2A">Workday A2A</a> in the same repository.</p>

<h2 id="troubleshooting">Troubleshooting</h2>

<table>
  <thead>
    <tr>
      <th>Symptom</th>
      <th>Fix</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>The connector isn’t listed under <strong>Add agent</strong></td>
      <td>Deploy to the environment that contains your agent, and check that <code class="language-plaintext highlighter-rouge">x-ms-agentic-protocol</code> is <code class="language-plaintext highlighter-rouge">a2a-1.0</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Set-A2AAgentCard.ps1</code> reports HTTP 401</td>
      <td>Pass a valid <code class="language-plaintext highlighter-rouge">-BearerToken</code> for the agent’s API, or use <code class="language-plaintext highlighter-rouge">-Endpoint</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">InvalidScriptDefinitionUrlWithNonNullOperations</code> on create</td>
      <td>Leave <code class="language-plaintext highlighter-rouge">scriptOperations</code> as <code class="language-plaintext highlighter-rouge">[]</code></td>
    </tr>
    <tr>
      <td>Sign-in fails with a redirect URI mismatch</td>
      <td>Register the redirect URL from the downloaded connector</td>
    </tr>
    <tr>
      <td>Sign-in fails with <code class="language-plaintext highlighter-rouge">AADSTS65001</code></td>
      <td>Grant admin consent for the app’s API permission</td>
    </tr>
    <tr>
      <td>The agent returns 401</td>
      <td>Check <code class="language-plaintext highlighter-rouge">[YOUR_RESOURCE_URI]</code> against the API’s Application ID URI</td>
    </tr>
    <tr>
      <td>The orchestrator never calls the agent</td>
      <td>Rewrite the description on the <strong>Agents</strong> page so it matches what users ask</td>
    </tr>
    <tr>
      <td>“Method not found” from the agent</td>
      <td>The agent accepts only A2A 1.0; check whether it can accept 0.3</td>
    </tr>
  </tbody>
</table>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Connector-Code/A2A%20Connector%20Template">A2A Connector Template source code</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-copilot-studio/add-agent-agent-to-agent">Connect an agent over the A2A protocol</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-copilot-studio/authoring-add-other-agents">Add other agents overview</a></li>
  <li><a href="https://learn.microsoft.com/connectors/custom-connectors/azure-active-directory-authentication">Authenticate a connector with Entra ID</a></li>
  <li><a href="https://learn.microsoft.com/power-platform/developer/cli/reference/connector">pac connector reference</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-365/copilot/extensibility/work-iq/a2a/quickstart">Work IQ A2A quickstart</a></li>
  <li><a href="https://a2a-protocol.org/">Agent2Agent protocol</a></li>
  <li><a href="/power%20platform/custom%20connectors/2026-05-05-agent-to-agent-a2a-connector-work-iq.html">Agent-to-Agent connector and Power A2A Template</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="A2A" /><category term="Agent-to-Agent" /><category term="Copilot Studio" /><category term="Custom Connectors" /><category term="Microsoft Entra ID" /><category term="PKCE" /><category term="OAuth" /><category term="PAC CLI" /><category term="Work IQ" /><summary type="html"><![CDATA[A script-free custom connector template that adds an external Agent2Agent (A2A) agent to Copilot Studio under Agents, with Microsoft Entra ID, OAuth 2.0 with PKCE, API key, or no authentication, deployed with PAC CLI.]]></summary></entry><entry><title type="html">Work IQ actions for Power Automate</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/2026-09-25-work-iq-for-power-automate-connector.html" rel="alternate" type="text/html" title="Work IQ actions for Power Automate" /><published>2026-09-25T22:00:00+00:00</published><updated>2026-09-25T22:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/work-iq-for-power-automate-connector</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/2026-09-25-work-iq-for-power-automate-connector.html"><![CDATA[<p>Microsoft ships nine certified Work IQ connectors, and every one of them is a raw MCP passthrough. They work well in Copilot Studio, where the agent composes the calls. In a flow you compose them yourself. <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Work%20IQ%20for%20Power%20Automate">Work IQ for Power Automate</a> adds the missing layer: named actions with typed inputs and outputs over <a href="https://learn.microsoft.com/microsoft-365/copilot/extensibility/work-iq-api-overview">Microsoft Work IQ</a> — Outlook mail, calendar, and contacts; Teams chats and channels; OneDrive and SharePoint files; people, groups, and Planner.</p>

<p>Every call is permission-trimmed to the signed-in user. Work IQ returns only data that person can already open.</p>

<h2 id="what-the-difference-looks-like-in-a-flow">What the difference looks like in a flow</h2>

<p><code class="language-plaintext highlighter-rouge">Work IQ MCP</code> does show up in the Power Automate designer — it declares <code class="language-plaintext highlighter-rouge">capabilities: actions</code>. Its one usable operation takes an untyped <code class="language-plaintext highlighter-rouge">queryRequest</code> body and returns an untyped <code class="language-plaintext highlighter-rouge">object</code>. Sending a single email means composing the envelope by hand:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"jsonrpc"</span><span class="p">:</span><span class="s2">"2.0"</span><span class="p">,</span><span class="nl">"id"</span><span class="p">:</span><span class="s2">"..."</span><span class="p">,</span><span class="nl">"method"</span><span class="p">:</span><span class="s2">"tools/call"</span><span class="p">,</span><span class="w">
 </span><span class="nl">"params"</span><span class="p">:{</span><span class="nl">"name"</span><span class="p">:</span><span class="s2">"do_action"</span><span class="p">,</span><span class="nl">"arguments"</span><span class="p">:{</span><span class="nl">"actionUrl"</span><span class="p">:</span><span class="s2">"/me/sendMail"</span><span class="p">,</span><span class="nl">"jsonBody"</span><span class="p">:{</span><span class="err">...</span><span class="p">}}}}</span><span class="w">
</span></code></pre></div></div>

<p>Then you add a <strong>Parse JSON</strong> action with a schema you wrote, and handle the Server-Sent Events framing and the <code class="language-plaintext highlighter-rouge">content</code> versus <code class="language-plaintext highlighter-rouge">structuredContent</code> split yourself.</p>

<p>This connector exposes the same capability as a <strong>Do action</strong> step with named fields, and returns a flattened result you bind to as dynamic content.</p>

<h2 id="when-to-use-something-else">When to use something else</h2>

<p>Use the first-party options where they fit. This connector complements them.</p>

<table>
  <thead>
    <tr>
      <th>You want to</th>
      <th>Use</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Give a Copilot Studio agent access to Work IQ</td>
      <td>The native Work IQ tool in Copilot Studio. One click, no app registration.</td>
    </tr>
    <tr>
      <td>Scope an agent to one data domain</td>
      <td>A granular first-party connector such as Work IQ Mail MCP</td>
    </tr>
    <tr>
      <td>Build flows against named actions with typed inputs and outputs</td>
      <td>This connector</td>
    </tr>
    <tr>
      <td>Ask a question and keep conversation context across steps</td>
      <td>This connector. No first-party connector exposes the A2A endpoint.</td>
    </tr>
  </tbody>
</table>

<p>The typed surface costs an Entra app registration and admin consent, which the first-party connectors don’t require. That’s a one-time setup cost against a per-flow authoring cost that recurs in every flow you build. The break-even is roughly two flows.</p>

<p>You gain no licensing advantage. Custom connectors are premium in Power Automate, as are eight of the nine first-party connectors. This connector doesn’t expose an MCP endpoint — the first-party connectors already do that nine times over.</p>

<h2 id="eleven-operations">Eleven operations</h2>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Ask Work IQ</td>
      <td>Ask a natural language question. Returns an answer plus a Context ID for follow-ups.</td>
    </tr>
    <tr>
      <td>Ask a Copilot agent</td>
      <td>Ask through a specific Copilot agent, optionally attaching files</td>
    </tr>
    <tr>
      <td>List Copilot agents</td>
      <td>List available agents and their IDs</td>
    </tr>
    <tr>
      <td>Search entity paths</td>
      <td>Find the entity path for a kind of data before reading or writing it</td>
    </tr>
    <tr>
      <td>Get operation schema</td>
      <td>Discover the fields an operation accepts</td>
    </tr>
    <tr>
      <td>Fetch entities</td>
      <td>Read one or more entities by path</td>
    </tr>
    <tr>
      <td>Create entity</td>
      <td>Create an entity, such as a calendar event</td>
    </tr>
    <tr>
      <td>Update entity</td>
      <td>Change fields on an existing entity</td>
    </tr>
    <tr>
      <td>Delete entity</td>
      <td>Delete an entity</td>
    </tr>
    <tr>
      <td>Do action</td>
      <td>Run an action such as <code class="language-plaintext highlighter-rouge">/me/sendMail</code></td>
    </tr>
    <tr>
      <td>Call function</td>
      <td>Call a computed function such as <code class="language-plaintext highlighter-rouge">/me/calendarView/delta</code></td>
    </tr>
  </tbody>
</table>

<p><strong>Ask Work IQ</strong> and <strong>Ask a Copilot agent</strong> differ in transport. Ask Work IQ uses the A2A endpoint and returns a Context ID you pass into a later step to continue the same conversation. Ask a Copilot agent uses the MCP <code class="language-plaintext highlighter-rouge">ask</code> tool, which targets a specific agent and attaches files.</p>

<p>Work with entities in the order <strong>Search entity paths</strong> → <strong>Get operation schema</strong> → read or write. Paths aren’t memorable and writes reject unexpected fields, so guessing tends to fail.</p>

<p><code class="language-plaintext highlighter-rouge">fetch_blob</code> isn’t exposed. It returns base64 file bytes up to 4 MB, which a dedicated file connector handles better.</p>

<h2 id="write-access-is-gated">Write access is gated</h2>

<p>Work IQ enforces a per-tenant policy allowlist over which paths a caller may write to. This is separate from the signed-in user’s own permissions and from Microsoft 365 Copilot licensing — a user who can create an event in Outlook may still be refused by Work IQ.</p>

<p>Where writes aren’t permitted, <strong>Create entity</strong>, <strong>Update entity</strong>, <strong>Delete entity</strong>, and <strong>Do action</strong> return <code class="language-plaintext highlighter-rouge">Is Error</code> true with:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Access denied for POST /me/events. Path is not in the policy allowlist.
</code></pre></div></div>

<p>Check before you build. Run <strong>Search entity paths</strong> and look at the <code class="language-plaintext highlighter-rouge">operations</code> array for each path. In a tenant with no write entitlement, every path lists <code class="language-plaintext highlighter-rouge">fetch</code> and nothing else, and all four write actions are unusable.</p>

<p>The read actions — Ask Work IQ, Ask a Copilot agent, List Copilot agents, Search entity paths, Fetch entities, and Call function — are unaffected.</p>

<h2 id="prerequisites">Prerequisites</h2>

<ul>
  <li>A Microsoft 365 Copilot license for every user who connects. Work IQ runs on the Microsoft 365 Copilot Chat API and returns <code class="language-plaintext highlighter-rouge">403</code> without one.</li>
  <li>A tenant administrator who can grant admin consent. The required permission is admin-consent only.</li>
  <li>Work IQ MCP not blocked in your tenant.</li>
  <li>For the write actions, a tenant whose Work IQ policy allowlist permits writes.</li>
</ul>

<h2 id="set-up-the-entra-app-registration">Set up the Entra app registration</h2>

<p>Work IQ requires the delegated permission <code class="language-plaintext highlighter-rouge">WorkIQAgent.Ask</code>. Power Platform needs a confidential client, so you can’t reuse the public client ID shipped with the <code class="language-plaintext highlighter-rouge">workiq</code> CLI — register your own app.</p>

<ol>
  <li>In the Azure portal, go to <strong>Microsoft Entra ID</strong> &gt; <strong>App registrations</strong> &gt; <strong>New registration</strong>.</li>
  <li>Name it, choose <strong>Accounts in this organizational directory only</strong>, and register it.</li>
  <li>Under <strong>Certificates &amp; secrets</strong>, create a client secret and copy the value.</li>
  <li>Under <strong>API permissions</strong>, select <strong>Add a permission</strong> &gt; <strong>APIs my organization uses</strong>, search for <strong>Work IQ</strong>, choose <strong>Delegated permissions</strong>, and select <strong>WorkIQAgent.Ask</strong>.</li>
  <li>Select <strong>Grant admin consent</strong>.</li>
</ol>

<p>The Work IQ API is a Microsoft first-party multitenant application, so it appears under <em>APIs my organization uses</em> rather than <em>Microsoft APIs</em>. From the CLI:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">az</span><span class="w"> </span><span class="nx">ad</span><span class="w"> </span><span class="nx">app</span><span class="w"> </span><span class="nx">permission</span><span class="w"> </span><span class="nx">add</span><span class="w"> </span><span class="nt">--id</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">your-app-id</span><span class="err">&gt;</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api</span><span class="w"> </span><span class="nx">fdcc1f02-fc51-4226-8753-f668596af7f7</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-permissions</span><span class="w"> </span><span class="nx">0b1715fd-f4bf-4c63-b16d-5be31f9847c2</span><span class="o">=</span><span class="n">Scope</span><span class="w">

</span><span class="n">az</span><span class="w"> </span><span class="nx">ad</span><span class="w"> </span><span class="nx">app</span><span class="w"> </span><span class="nx">permission</span><span class="w"> </span><span class="nx">admin-consent</span><span class="w"> </span><span class="nt">--id</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">your-app-id</span><span class="err">&gt;</span><span class="w">
</span></code></pre></div></div>

<p>Leave the redirect URI alone for now. Power Platform generates one during deployment, and you register it afterwards.</p>

<h2 id="deploy-the-connector">Deploy the connector</h2>

<p>Fill in your client ID and secret in a temporary copy of <code class="language-plaintext highlighter-rouge">apiProperties.json</code> rather than editing the committed file, which ships with placeholders:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">auth</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="nt">--environment</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">your-environment-id</span><span class="err">&gt;</span><span class="w">
</span><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-definition-file</span><span class="w"> </span><span class="s2">"./apiDefinition.swagger.json"</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-properties-file</span><span class="w"> </span><span class="s2">"./apiProperties.temp.json"</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--script-file</span><span class="w"> </span><span class="s2">"./script.csx"</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--environment</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">your-environment-id</span><span class="err">&gt;</span><span class="w">
</span></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">--script-file</code> argument is required. Every operation is implemented by the script — the <code class="language-plaintext highlighter-rouge">/tools/*</code> paths exist only there and have no counterpart on the Work IQ service. Deploy without it and the connector is created successfully but every action fails.</p>

<h2 id="register-the-generated-redirect-uri">Register the generated redirect URI</h2>

<p>Power Platform generates a redirect URI unique to this connector and environment, and OAuth consent fails until that exact URI is registered on your app. Read it back after deployment:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">download</span><span class="w"> </span><span class="nt">--connector-id</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">connector-id</span><span class="err">&gt;</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--environment</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">your-environment-id</span><span class="err">&gt;</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--outputDirectory</span><span class="w"> </span><span class="o">.</span><span class="nx">/verify</span><span class="w">
</span><span class="c"># properties.connectionParameters.token.oAuthSettings.redirectUrl</span><span class="w">
</span></code></pre></div></div>

<p>Add it to the app registration, keeping the global redirect alongside it:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">az</span><span class="w"> </span><span class="nx">ad</span><span class="w"> </span><span class="nx">app</span><span class="w"> </span><span class="nx">update</span><span class="w"> </span><span class="nt">--id</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">your-app-id</span><span class="err">&gt;</span><span class="w"> </span><span class="nt">--web-redirect-uris</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="s2">"https://global.consent.azure-apim.net/redirect"</span><span class="w"> </span><span class="s2">"&lt;per-connector-url&gt;"</span><span class="w">
</span></code></pre></div></div>

<p>Deploying the same connector to another environment produces a different redirect URI. Register each one.</p>

<h2 id="use-it-from-a-flow">Use it from a flow</h2>

<p>Add <strong>Ask Work IQ</strong>, supply a <strong>Question</strong>, and read <strong>Answer</strong> from the output. Pass the returned <strong>Context ID</strong> into a later Ask Work IQ step to continue the same conversation.</p>

<p>For deterministic reads, use <strong>Fetch entities</strong> with a path such as <code class="language-plaintext highlighter-rouge">/me/messages?$select=subject,from&amp;$top=25</code>. Keep reads small — entity paths accept OData, and a bare collection path can return very large responses.</p>

<p>To write, confirm the path is writable first:</p>

<ol>
  <li><strong>Search entity paths</strong> with a filter such as <code class="language-plaintext highlighter-rouge">events</code>. Check the <code class="language-plaintext highlighter-rouge">operations</code> listed for the path you want.</li>
  <li><strong>Get operation schema</strong> for that path to discover the fields it accepts.</li>
  <li><strong>Create entity</strong>, <strong>Update entity</strong>, <strong>Delete entity</strong>, or <strong>Do action</strong> with a body matching that schema.</li>
</ol>

<h2 id="why-a-script-rather-than-policy-templates">Why a script rather than policy templates</h2>

<p>The <code class="language-plaintext highlighter-rouge">/mcp</code> endpoint won’t return JSON. It rejects any request that doesn’t accept Server-Sent Events, and always answers with them:</p>

<table>
  <thead>
    <tr>
      <th><code class="language-plaintext highlighter-rouge">Accept</code></th>
      <th>Response</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">application/json</code></td>
      <td><code class="language-plaintext highlighter-rouge">406 Not Acceptable</code></td>
    </tr>
    <tr>
      <td><em>(none)</em></td>
      <td><code class="language-plaintext highlighter-rouge">406 Not Acceptable</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">application/json, text/event-stream</code></td>
      <td><code class="language-plaintext highlighter-rouge">200</code> — <code class="language-plaintext highlighter-rouge">text/event-stream</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">*/*</code></td>
      <td><code class="language-plaintext highlighter-rouge">200</code> — <code class="language-plaintext highlighter-rouge">text/event-stream</code></td>
    </tr>
  </tbody>
</table>

<p>Every tool response arrives as <code class="language-plaintext highlighter-rouge">data:</code> lines wrapping a JSON-RPC message. Inside that message the payload is either <code class="language-plaintext highlighter-rouge">content[0].text</code> — a JSON-encoded <em>string</em>, not an object — or <code class="language-plaintext highlighter-rouge">structuredContent</code>, depending on the tool. <code class="language-plaintext highlighter-rouge">search_paths</code> and <code class="language-plaintext highlighter-rouge">fetch</code> use the latter.</p>

<p>No policy template decodes SSE or parses a JSON string into an object. Without the script, each action returns raw event-stream text and every flow repeats the same unwrapping by hand. Policies can solve the routing — <code class="language-plaintext highlighter-rouge">routerequesttoendpoint</code> maps the <code class="language-plaintext highlighter-rouge">/tools/*</code> paths onto <code class="language-plaintext highlighter-rouge">/mcp</code> — but not the response handling.</p>

<p>The A2A endpoint is different: <code class="language-plaintext highlighter-rouge">/a2a</code> returns <code class="language-plaintext highlighter-rouge">application/json</code> and needs no <code class="language-plaintext highlighter-rouge">Accept</code> header. <strong>Ask Work IQ</strong> is scripted only to flatten the artifact tree into <strong>Answer</strong>, <strong>Context ID</strong>, <strong>Task ID</strong>, and <strong>State</strong>.</p>

<p>Application Insights logging is present but disabled. Set <code class="language-plaintext highlighter-rouge">APP_INSIGHTS_ENABLED</code> to <code class="language-plaintext highlighter-rouge">true</code> and supply an instrumentation key to turn it on.</p>

<h2 id="troubleshooting">Troubleshooting</h2>

<table>
  <thead>
    <tr>
      <th>Symptom</th>
      <th>Cause</th>
      <th>Fix</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">AADSTS500011: resource principal not found</code></td>
      <td>The connection requests a resource that isn’t a registered identifier for Work IQ</td>
      <td>The resource must be <code class="language-plaintext highlighter-rouge">api://workiq.svc.cloud.microsoft</code>. The bare <code class="language-plaintext highlighter-rouge">https://workiq.svc.cloud.microsoft</code> host isn’t valid.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">AADSTS65001</code> or a consent prompt that never completes</td>
      <td>Admin consent wasn’t granted</td>
      <td>Grant admin consent for <code class="language-plaintext highlighter-rouge">WorkIQAgent.Ask</code>. Users can’t consent to it themselves.</td>
    </tr>
    <tr>
      <td>Consent fails with a redirect URI mismatch</td>
      <td>The generated per-connector redirect URI isn’t on the app registration</td>
      <td>Register it as shown above</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">403 Forbidden</code> or “caller not entitled”</td>
      <td>The signed-in user has no Microsoft 365 Copilot license, or Work IQ isn’t enabled for them</td>
      <td>Assign a license and confirm availability in the Microsoft 365 admin center under <strong>Copilot</strong> &gt; <strong>Settings</strong></td>
    </tr>
    <tr>
      <td>Every call fails tenant-wide</td>
      <td>Work IQ MCP is blocked</td>
      <td>In the Microsoft 365 admin center, go to <strong>Agents</strong> &gt; <strong>Tools</strong> &gt; <strong>Work IQ MCP</strong> and check its availability</td>
    </tr>
    <tr>
      <td>An action returns a raw JSON-RPC envelope</td>
      <td>The connector was deployed without the script</td>
      <td>Redeploy with <code class="language-plaintext highlighter-rouge">--script-file ./script.csx</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Is Error</code> is true with an error message in <code class="language-plaintext highlighter-rouge">Text</code></td>
      <td>The tool rejected the request, usually a wrong path or a missing required field</td>
      <td>Run <strong>Search entity paths</strong> and <strong>Get operation schema</strong> and correct the input</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Is Error</code> is true with “Path is not in the policy allowlist”</td>
      <td>Work IQ restricts which paths a caller may write to, independently of the user’s permissions</td>
      <td>Check the operations listed for that path. A path that lists only <code class="language-plaintext highlighter-rouge">fetch</code> can’t be written to.</td>
    </tr>
    <tr>
      <td>Intermittent <code class="language-plaintext highlighter-rouge">-32603 An error occurred</code></td>
      <td>Service-side throttling under rapid repeated calls</td>
      <td>Retry with a delay. Add a retry policy for bulk flows.</td>
    </tr>
  </tbody>
</table>

<h2 id="preview-status">Preview status</h2>

<p>Work IQ is in public preview, and Microsoft documents it as a proprietary service whose features and APIs may change. Treat this connector as preview-grade and expect breaking changes.</p>

<p>Microsoft is expanding this area quickly. If first-party typed actions ship for Power Automate, they’ll likely be the better choice and this connector becomes unnecessary.</p>

<p>The Work IQ API also exposes <code class="language-plaintext highlighter-rouge">WorkIQSettings.Read.All</code> and <code class="language-plaintext highlighter-rouge">WorkIQSettings.ReadWrite.All</code>. Those back the administrative settings in the Microsoft 365 admin center and the Power Platform admin center, and there’s no documented HTTP endpoint for them, so this connector doesn’t use them. Manage those settings through the admin centers.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Work%20IQ%20for%20Power%20Automate">Work IQ for Power Automate source</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-365/copilot/extensibility/work-iq-api-overview">Work IQ API overview</a></li>
  <li><a href="https://learn.microsoft.com/connectors/workiqmcp/">Work IQ MCP first-party connector</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-copilot-studio/add-work-iq">Add Work IQ to a Copilot Studio agent</a></li>
  <li><a href="https://learn.microsoft.com/power-platform/admin/business-applications-work-iq/manage-ai-experience-access">Manage AI experience access to Business Applications in Work IQ</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="Work IQ" /><category term="Custom Connectors" /><category term="Power Automate" /><category term="Microsoft 365" /><category term="Copilot" /><category term="MCP" /><category term="OAuth" /><summary type="html"><![CDATA[Work IQ for Power Automate is a custom connector that turns the Work IQ MCP and A2A endpoints into named actions with typed inputs and outputs, so flows read mail, calendar, Teams, files, and people without hand-built JSON-RPC envelopes.]]></summary></entry><entry><title type="html">Ask Dataverse questions in plain language from Power Automate and Copilot Studio</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-25-dataverse-ask-custom-connector.html" rel="alternate" type="text/html" title="Ask Dataverse questions in plain language from Power Automate and Copilot Studio" /><published>2026-09-25T20:00:00+00:00</published><updated>2026-09-25T20:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/mcp/dataverse-ask-custom-connector</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-25-dataverse-ask-custom-connector.html"><![CDATA[<p>The <a href="https://learn.microsoft.com/power-apps/developer/data-platform/ask/">Dataverse Ask APIs</a> (preview) take a question in plain language and return the rows that answer it, a summary, and links to the source records. <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Dataverse%20Ask">Dataverse Ask</a> wraps them in a custom connector with typed actions for Power Automate and Power Apps, and an MCP endpoint that a Copilot Studio agent can reason with.</p>

<p>No table relationships, column logical names, or query syntax on your side. You define which tables can answer, then ask.</p>

<h2 id="not-the-web-api">Not the Web API</h2>

<p>The Ask APIs live at <code class="language-plaintext highlighter-rouge">/api/iq/v1.0/</code> and are deliberately not an OData service. There’s no metadata document, and no <code class="language-plaintext highlighter-rouge">$select</code>, <code class="language-plaintext highlighter-rouge">$filter</code>, or <code class="language-plaintext highlighter-rouge">$expand</code>. Use the Web API when you need precise create, retrieve, update, delete, or query operations. Use this connector when the input is a question.</p>

<p>The connector makes one Web API call, to read your table list for the <strong>Tables</strong> picker, because the Ask API has no table endpoint of its own. That’s the only place <code class="language-plaintext highlighter-rouge">/api/data/</code> appears.</p>

<h2 id="semantic-models-define-what-can-be-answered">Semantic models define what can be answered</h2>

<p>A semantic model names the Dataverse tables that can answer a question. Create one, then ask questions against it. The service decides how to answer from those tables, and returns only data the signed-in user is allowed to read.</p>

<p><strong>List semantic models</strong> returns more than the models you create here, and the <code class="language-plaintext highlighter-rouge">source</code> property tells them apart.</p>

<table>
  <thead>
    <tr>
      <th>Kind</th>
      <th>Created by</th>
      <th>This connector can</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>API model</td>
      <td><strong>Create semantic model</strong>, <code class="language-plaintext highlighter-rouge">source</code> of <code class="language-plaintext highlighter-rouge">SemanticModelAPI</code></td>
      <td>Create, list, delete, ask against</td>
    </tr>
    <tr>
      <td>Provisioned or app-associated model</td>
      <td>Auto-provisioned when Copilot over Dataverse is enabled, or owned by a model-driven app, bot component, or agent</td>
      <td>List, ask against</td>
    </tr>
  </tbody>
</table>

<p>You can’t delete a model you didn’t create through this API, and the maker portal can’t create a brand-new one — creation is what this connector adds. Run <strong>List semantic models</strong> with <strong>Include app and agent usage</strong> set to <code class="language-plaintext highlighter-rouge">botinfo</code> to see which apps and agents depend on a model before you change or delete it.</p>

<h2 id="five-operations-and-three-pickers">Five operations and three pickers</h2>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>Method</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Invoke Dataverse Ask MCP</td>
      <td><code class="language-plaintext highlighter-rouge">POST /mcp</code></td>
      <td>MCP endpoint for Copilot Studio</td>
    </tr>
    <tr>
      <td>List semantic models</td>
      <td><code class="language-plaintext highlighter-rouge">GET /api/iq/v1.0/semanticmodel</code></td>
      <td>The models available in this environment</td>
    </tr>
    <tr>
      <td>Create semantic model</td>
      <td><code class="language-plaintext highlighter-rouge">POST /api/iq/v1.0/semanticmodel</code></td>
      <td>Associate a unique name with one or more tables</td>
    </tr>
    <tr>
      <td>Delete semantic model</td>
      <td><code class="language-plaintext highlighter-rouge">DELETE /api/iq/v1.0/semanticmodel/{id}</code></td>
      <td>Delete a model by ID</td>
    </tr>
    <tr>
      <td>Ask a question</td>
      <td><code class="language-plaintext highlighter-rouge">POST /api/iq/v1.0/ask</code></td>
      <td>Submit a question against a model</td>
    </tr>
  </tbody>
</table>

<p>A sixth operation, <strong>List tables</strong>, is marked internal and backs the <strong>Tables</strong> picker.</p>

<table>
  <thead>
    <tr>
      <th>Field</th>
      <th>Shows</th>
      <th>Sends</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Semantic model name</strong> on Ask</td>
      <td>Model name</td>
      <td>Model name</td>
    </tr>
    <tr>
      <td><strong>Semantic model</strong> on Delete</td>
      <td>Model name</td>
      <td>Model ID</td>
    </tr>
    <tr>
      <td><strong>Tables</strong> on Create</td>
      <td><code class="language-plaintext highlighter-rouge">Account (account)</code></td>
      <td><code class="language-plaintext highlighter-rouge">account</code></td>
    </tr>
  </tbody>
</table>

<p>The Delete picker matters most: that operation is keyed by ID while everything else is keyed by name, so choosing from the list saves a lookup.</p>

<p>Table <strong>logical names</strong> are the only names Create accepts — <code class="language-plaintext highlighter-rouge">account</code>, <code class="language-plaintext highlighter-rouge">contact</code>, <code class="language-plaintext highlighter-rouge">opportunity</code>. Display names, entity set names, and collection names are rejected. The picker handles that for you, filtering out private and intersect tables, and falling back to the logical name when a table has no localized label.</p>

<p>Include only the tables relevant to the questions the model answers. Creating a model indexes those tables, and that index consumes Dataverse database storage. You can see how much in the <strong>DataverseSearch</strong> table, counted against database storage on the <strong>Summary</strong> and <strong>Dataverse</strong> tabs.</p>

<h2 id="five-tools-for-an-agent">Five tools for an agent</h2>

<p>Add the connector to a Copilot Studio agent and the MCP endpoint advertises:</p>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">list_semantic_models</code></td>
      <td>Find the available models and their exact names</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ask_dataverse</code></td>
      <td>Ask a question against a model</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">list_tables</code></td>
      <td>Find table logical names, with an optional search term</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">create_semantic_model</code></td>
      <td>Create a model over a set of tables</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">delete_semantic_model</code></td>
      <td>Delete a model created through this API</td>
    </tr>
  </tbody>
</table>

<p>The tool descriptions steer the agent to call <code class="language-plaintext highlighter-rouge">list_semantic_models</code> before <code class="language-plaintext highlighter-rouge">ask_dataverse</code>, because the model name has to match exactly, and <code class="language-plaintext highlighter-rouge">list_tables</code> before <code class="language-plaintext highlighter-rouge">create_semantic_model</code>, because table names must be logical names. Failures come back as tool results rather than protocol errors, so an agent that passes an invalid search mode is told the three valid values and retries instead of stalling.</p>

<p><code class="language-plaintext highlighter-rouge">ask_dataverse</code> returns the summary, the rows, the citation links, and a <code class="language-plaintext highlighter-rouge">hasMore</code> flag. When more rows exist it also returns the paging token and tells the agent how to use it.</p>

<p>Try prompts such as <em>Which open opportunities have the highest estimated revenue?</em> or <em>Summarize the opportunities for Contoso.</em></p>

<h2 id="working-with-the-answer">Working with the answer</h2>

<p><strong>Rows</strong> (<code class="language-plaintext highlighter-rouge">rawResult</code>) is an array of dynamic objects. Its shape depends on the question and on the query the service generated to answer it, so there’s no fixed schema and no design-time dynamic content for individual fields. Parse it with the property names you expect from the question, or run the question once and read the output to learn the shape.</p>

<p><strong>Summary</strong>, <strong>Citation links</strong>, and <strong>Rows</strong> are all nullable. Display the summary when it’s there, but don’t require it before processing the rows.</p>

<p>Results are trimmed to the caller’s Dataverse privileges. Record ownership, business unit access, sharing, and column-level security all change what comes back, so two users asking the same question can get different answers.</p>

<p>Search mode controls the tradeoff between speed and depth:</p>

<table>
  <thead>
    <tr>
      <th>Mode</th>
      <th>Use when</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Auto</code></td>
      <td>Default. Let the service choose</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">QuickResponse</code></td>
      <td>A faster answer matters more than depth</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ThinkDeeper</code></td>
      <td>The question needs deeper analysis and a longer wait is acceptable</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">ThinkDeeper</code> can take noticeably longer. In a flow, raise the action timeout; in an app, show progress.</p>

<p>Set <strong>Row count</strong> to cap the page size. When more rows exist, the response carries a <strong>Paging token</strong> — send the same question, model, search mode, and count again with that token to get the next page, and continue until the token comes back null or empty. Pass the token back exactly as returned; don’t decode, edit, or construct one.</p>

<p><strong>List</strong> and <strong>Ask</strong> are safe to retry after a transient failure. <strong>Create</strong> and <strong>Delete</strong> aren’t, because model names must be unique. List the models first to find out whether the original request actually succeeded, and treat a missing model after an uncertain delete as a completed delete. Retry <code class="language-plaintext highlighter-rouge">429</code>, <code class="language-plaintext highlighter-rouge">500</code>, <code class="language-plaintext highlighter-rouge">502</code>, <code class="language-plaintext highlighter-rouge">503</code>, and <code class="language-plaintext highlighter-rouge">504</code>, honoring <code class="language-plaintext highlighter-rouge">Retry-After</code> when present and backing off exponentially with jitter otherwise.</p>

<h2 id="indexing-takes-time">Indexing takes time</h2>

<p>A model doesn’t answer well the instant it’s created. Initial generation can take <strong>up to two hours</strong>; later incremental changes typically settle within <strong>30 minutes</strong>. An Ask sent immediately after Create can return thin results or none. Models then regenerate automatically every 12 hours to pick up schema changes.</p>

<p>Semantic models don’t support ALM. Environment copy and move operations don’t carry them, and after either one an administrator has to turn the feature off and back on in the Power Platform admin center to force a restart.</p>

<h2 id="fine-tuning-a-model">Fine-tuning a model</h2>

<p>Answer quality comes from more than the table list. The model extracts signals from metadata you already have — table relationships, public and sub-grid views, form display names, table and column description summaries, and optionally sample data rows. Sample rows are off by default. System views such as Quick Find, Advanced Find, Associated, and Lookup are excluded, as are personal views.</p>

<p>Well-written table and column descriptions are the cheapest way to improve answers, because the model reads them directly.</p>

<p>Once a model exists, tune it on the <strong>Semantic model</strong> page in <a href="https://make.powerapps.com">Power Apps</a>:</p>

<ul>
  <li><strong>Signals</strong> — turn inferred signal types on or off, exclude sensitive tables, disable views and relationships that mislead</li>
  <li><strong>Glossary</strong> — add acronyms, synonyms, and organization-specific vocabulary the system can’t infer</li>
  <li><strong>Refresh</strong> — trigger a manual regeneration to apply changes without waiting for the 12-hour cycle</li>
</ul>

<h2 id="deploy-it">Deploy it</h2>

<p>You need a Dataverse environment, and <strong>Business Applications in Work IQ</strong> turned on for it in the Power Platform admin center. Without that feature, every call returns <code class="language-plaintext highlighter-rouge">400</code>. The connection user also needs a security role that grants semantic model privileges: <strong>Dataverse Search Role</strong>, <strong>Environment Maker</strong>, <strong>System Customizer</strong>, or <strong>System Administrator</strong>. Ask API usage is billed through Copilot Credits.</p>

<p>Register an app in Microsoft Entra ID, add the delegated <strong>Dynamics CRM → user_impersonation</strong> permission, and add <code class="language-plaintext highlighter-rouge">https://global.consent.azure-apim.net/redirect</code> to its web redirect URIs. Copy the application ID and create a client secret.</p>

<p>In <code class="language-plaintext highlighter-rouge">apiDefinition.swagger.json</code>, set <code class="language-plaintext highlighter-rouge">host</code> to your environment hostname. In <code class="language-plaintext highlighter-rouge">apiProperties.json</code>, replace <code class="language-plaintext highlighter-rouge">[YOUR_CLIENT_ID]</code> with your application ID and replace <code class="language-plaintext highlighter-rouge">https://org.crm.dynamics.com</code> in both <code class="language-plaintext highlighter-rouge">AzureActiveDirectoryResourceId</code> and <code class="language-plaintext highlighter-rouge">resourceUri</code> with your environment URL. Leave <code class="language-plaintext highlighter-rouge">redirectUrl</code> as <code class="language-plaintext highlighter-rouge">[YOUR_REDIRECT_URL]</code>, since the platform substitutes the real value at deploy time.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">auth</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="nt">--environment</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">your-environment-url</span><span class="err">&gt;</span><span class="w">

</span><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--api-definition-file</span><span class="w"> </span><span class="nx">apiDefinition.swagger.json</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--api-properties-file</span><span class="w"> </span><span class="nx">apiProperties.json</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--script-file</span><span class="w"> </span><span class="nx">script.csx</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">--script-file</code> is required. Without it the connector deploys, but the MCP endpoint returns nothing usable, because the script is what answers the JSON-RPC calls.</p>

<p><code class="language-plaintext highlighter-rouge">pac</code> has no parameter for the client secret. Paste it in <strong>Power Apps → Custom connectors → Dataverse Ask → Edit → Security</strong>, or set <code class="language-plaintext highlighter-rouge">clientSecret</code> in a temporary copy of <code class="language-plaintext highlighter-rouge">apiProperties.json</code>, deploy from that copy, and delete it afterwards. Never put a secret in the file you keep.</p>

<p>The platform generates a redirect URL unique to this connector and environment. Read it back and register it:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">download</span><span class="w"> </span><span class="nt">--connector-id</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">connector-id</span><span class="err">&gt;</span><span class="w"> </span><span class="nt">--outputDirectory</span><span class="w"> </span><span class="o">.</span><span class="nx">/verify</span><span class="w">
</span></code></pre></div></div>

<p>The value sits at <code class="language-plaintext highlighter-rouge">properties.connectionParameters.token.oAuthSettings.redirectUrl</code>. Add it to the app registration alongside the generic one. OAuth consent fails until it’s registered, and a second environment produces a different URL that also needs registering.</p>

<h2 id="test-in-the-right-order">Test in the right order</h2>

<p>Create a connection, then run <strong>List semantic models</strong> with no parameters. A <code class="language-plaintext highlighter-rouge">200</code> confirms auth, host, and feature enablement in one call, and it answers immediately without depending on indexing. An empty list is still a pass; the environment simply has no models yet.</p>

<p>Then run <strong>Create semantic model</strong> with a unique name and a couple of tables from the dropdown:</p>

<table>
  <thead>
    <tr>
      <th>Field</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Unique name</td>
      <td><code class="language-plaintext highlighter-rouge">Sales pipeline</code></td>
    </tr>
    <tr>
      <td>Tables</td>
      <td><code class="language-plaintext highlighter-rouge">Account (account)</code>, <code class="language-plaintext highlighter-rouge">Opportunity (opportunity)</code></td>
    </tr>
  </tbody>
</table>

<p>A populated <strong>Tables</strong> dropdown is a second check on its own: it proves the connection can read table metadata.</p>

<p>Don’t ask a question yet. Indexing starts on creation, and initial generation can take up to two hours. Come back afterwards and run <strong>Ask a question</strong> against <code class="language-plaintext highlighter-rouge">Sales pipeline</code> with something like <em>Which open opportunities have the highest estimated revenue?</em> Rows and a summary confirm the model is live.</p>

<p>To test the MCP endpoint without waiting on indexing, add the connector to a Copilot Studio agent and ask it what semantic models exist. That exercises discovery and a tool call.</p>

<h2 id="when-something-comes-back-wrong">When something comes back wrong</h2>

<table>
  <thead>
    <tr>
      <th>Symptom</th>
      <th>Cause</th>
      <th>Fix</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">400</code> on every operation</td>
      <td>Business Applications in Work IQ isn’t enabled for the environment</td>
      <td>Turn on the feature in the Power Platform admin center</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">400</code> on Ask, other operations fine</td>
      <td>No table in the model is one the caller may read, or the request is malformed</td>
      <td>Check the caller’s read access to the model’s tables; don’t retry unchanged</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">401</code> on every call</td>
      <td>Missing <strong>Dynamics CRM → user_impersonation</strong>, or <code class="language-plaintext highlighter-rouge">resourceUri</code> doesn’t match the environment</td>
      <td>Check the permission and both URLs in <code class="language-plaintext highlighter-rouge">apiProperties.json</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">403</code> on Create or Delete</td>
      <td>The connection user has no semantic model privileges</td>
      <td>Assign Dataverse Search Role, Environment Maker, System Customizer, or System Administrator</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">404</code> on Ask</td>
      <td>The semantic model name doesn’t match</td>
      <td>Names are exact — pick one from the dropdown</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">404</code> on Delete</td>
      <td>The model is already gone, or it belongs to an app module, bot, or agent</td>
      <td>Models not created through this API can’t be deleted here</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">429</code></td>
      <td>More than 30 Ask requests per user per minute</td>
      <td>Wait for <code class="language-plaintext highlighter-rouge">Retry-After</code> seconds; avoid retrying several requests in parallel</td>
    </tr>
    <tr>
      <td>Sign-in fails during connection creation</td>
      <td>The generated redirect URL isn’t on the app registration</td>
      <td>Register the downloaded value</td>
    </tr>
    <tr>
      <td>Ask returns nothing just after Create</td>
      <td>Indexing hasn’t finished</td>
      <td>Initial generation takes up to two hours; retry later</td>
    </tr>
    <tr>
      <td>Answers ignore a business term or acronym</td>
      <td>The term can’t be inferred from metadata</td>
      <td>Add a glossary entry, then regenerate the model</td>
    </tr>
    <tr>
      <td>Answers got worse after a schema change</td>
      <td>The model hasn’t regenerated yet</td>
      <td>Wait for the 12-hour cycle or trigger a manual regeneration</td>
    </tr>
    <tr>
      <td>Models missing after an environment copy or move</td>
      <td>Semantic models have no ALM support</td>
      <td>Have an admin turn the feature off and back on to force a restart</td>
    </tr>
    <tr>
      <td>No dynamic content for row fields</td>
      <td>Rows are dynamic by design</td>
      <td>Expected. Parse by property name, or run once to learn the shape</td>
    </tr>
    <tr>
      <td>Tables dropdown is empty or errors</td>
      <td>The connection can’t read table metadata</td>
      <td>The same <strong>user_impersonation</strong> permission covers it; check the user’s security role</td>
    </tr>
    <tr>
      <td>Agent doesn’t see the tools</td>
      <td>The connector was deployed without <code class="language-plaintext highlighter-rouge">--script-file</code></td>
      <td>Redeploy including the script</td>
    </tr>
  </tbody>
</table>

<p>Telemetry is off by default. Set <code class="language-plaintext highlighter-rouge">APP_INSIGHTS_ENABLED</code> to <code class="language-plaintext highlighter-rouge">true</code> in <code class="language-plaintext highlighter-rouge">script.csx</code> and replace <code class="language-plaintext highlighter-rouge">APP_INSIGHTS_KEY</code> with your instrumentation key to log tool calls with their outcome and failures with their error code and message. Telemetry never fails a tool call.</p>

<h2 id="where-it-sits-next-to-the-others">Where it sits next to the others</h2>

<table>
  <thead>
    <tr>
      <th>Connector</th>
      <th>Use for</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Dataverse Ask</strong> (this)</td>
      <td>Natural-language questions over a defined set of Dataverse tables</td>
    </tr>
    <tr>
      <td><strong>Dataverse SQL</strong></td>
      <td>Precise read-only T-SQL queries against the Web API</td>
    </tr>
    <tr>
      <td><strong>Work IQ</strong></td>
      <td>Natural-language questions across Microsoft 365 — mail, meetings, documents, Teams, people</td>
    </tr>
  </tbody>
</table>

<p>Work IQ and Dataverse Ask complement each other. Once an administrator enables Business Applications in Work IQ, the Work IQ connector can also reach Dataverse. This connector is the direct path, and the only one that can create a semantic model.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Dataverse%20Ask">Dataverse Ask connector source</a></li>
  <li><a href="https://learn.microsoft.com/power-apps/developer/data-platform/ask/">Dataverse Ask APIs</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-365/copilot/extensibility/work-iq/api-overview">Work IQ API overview and licensing</a></li>
  <li><a href="https://learn.microsoft.com/power-platform/admin/settings-features">Manage feature settings</a></li>
  <li><a href="https://learn.microsoft.com/connectors/custom-connectors/write-code">Write code in a custom connector</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="MCP" /><category term="Dataverse" /><category term="Work IQ" /><category term="Custom Connectors" /><category term="MCP" /><category term="Copilot Studio" /><category term="Power Automate" /><category term="OAuth" /><summary type="html"><![CDATA[Dataverse Ask is a Power Platform custom connector for the Dataverse Ask APIs, with typed actions for flows and apps, table and model pickers, and an MCP endpoint that gives Copilot Studio agents five tools over your business data.]]></summary></entry><entry><title type="html">Drive Copilot Managed Runtime apps from Power Automate and Copilot Studio</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-25-copilot-managed-runtime-connector.html" rel="alternate" type="text/html" title="Drive Copilot Managed Runtime apps from Power Automate and Copilot Studio" /><published>2026-09-25T18:00:00+00:00</published><updated>2026-09-25T18:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/mcp/copilot-managed-runtime-connector</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-25-copilot-managed-runtime-connector.html"><![CDATA[<p><a href="https://www.microsoft.com/en-us/copilot/blog/copilot-studio/build-where-you-want-run-with-confidence-now-microsoft-hosts-and-manages-the-code-created-by-copilot/">Microsoft Copilot Managed Runtime</a> lets you build web apps with React, TypeScript, and Vite, then host, deploy, and share them through Power Platform. The shipped tooling is a terminal command — <code class="language-plaintext highlighter-rouge">ms</code>, from the <code class="language-plaintext highlighter-rouge">@microsoft/managed-apps-cli</code> npm package. <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Managed%20Runtime">Copilot Managed Runtime</a> wraps the same server API so you can drive that lifecycle from a flow or an agent instead.</p>

<p>The connector runs in two modes off one definition: 22 typed REST operations for Power Automate and Power Apps, and a <code class="language-plaintext highlighter-rouge">/mcp</code> endpoint that gives a Copilot Studio agent the same surface as tools.</p>

<h2 id="finding-the-api">Finding the API</h2>

<p>There’s no published REST reference for this service yet, so the surface here was derived from the shipped CLI and then confirmed against the live tenant. The confirmation came from contrasting two responses on an environment-scoped host:</p>

<table>
  <thead>
    <tr>
      <th>Request</th>
      <th>Response</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">GET /appframework/apps?api-version=1</code></td>
      <td><code class="language-plaintext highlighter-rouge">403</code> — <code class="language-plaintext highlighter-rouge">InsufficientDelegatedPermissions</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">GET /appframework/totally-not-a-route?api-version=1</code></td>
      <td><code class="language-plaintext highlighter-rouge">404</code> — <code class="language-plaintext highlighter-rouge">RouteNotFound</code></td>
    </tr>
  </tbody>
</table>

<p>An unknown path is rejected by routing. <code class="language-plaintext highlighter-rouge">/appframework/apps</code> gets past routing and fails only on authorization, so the route is real and serving traffic. The 403 body names exactly what it wants:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"code"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Forbidden"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"innererror"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"code"</span><span class="p">:</span><span class="w"> </span><span class="s2">"InsufficientDelegatedPermissions"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Authorization denied: Application missing required delegated permissions: [PowerApps.Apps.Read, ManagedApps.ReadWrite.All, All.All.ReadWrite]"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ManagedApps.ReadWrite.All</code> is a published delegated scope on the first-party Power Platform API application <code class="language-plaintext highlighter-rouge">8578e004-a5c6-46e7-913e-12f58912df43</code>, and it’s user-consentable. That makes this a supported surface rather than an internal backchannel.</p>

<h2 id="the-host-isnt-the-host">The host isn’t the host</h2>

<p>Copilot Managed Runtime calls don’t go to <code class="language-plaintext highlighter-rouge">api.powerplatform.com</code>. They go to an environment-scoped host derived from the environment ID: lowercase it, strip the dashes, then split the last two characters off into their own label.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>b5e65502-f2e4-ecc7-8e66-df670e46b100
  → b5e65502f2e4ecc78e66df670e46b1.00.environment.api.powerplatform.com
</code></pre></div></div>

<p>The swagger declares <code class="language-plaintext highlighter-rouge">api.powerplatform.com</code> to satisfy Power Platform, and <code class="language-plaintext highlighter-rouge">script.csx</code> rewrites every request to the real host. That’s why every operation takes an <code class="language-plaintext highlighter-rouge">environmentId</code> and why all of them are listed in <code class="language-plaintext highlighter-rouge">scriptOperations</code>. Default environments carry a <code class="language-plaintext highlighter-rouge">Default-</code> prefix on the ID, and the same normalization handles them.</p>

<p>In the designer the <code class="language-plaintext highlighter-rouge">environmentId</code> comes from a dynamic dropdown, so you pick an environment by name.</p>

<h2 id="what-you-get">What you get</h2>

<p>Every operation except <strong>Get environment dropdown</strong> takes an <code class="language-plaintext highlighter-rouge">environmentId</code>.</p>

<p><strong>Apps</strong></p>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ListApps</code></td>
      <td>List apps in an environment, filtered by <code class="language-plaintext highlighter-rouge">read</code> or <code class="language-plaintext highlighter-rouge">write</code> permission, with <code class="language-plaintext highlighter-rouge">$skiptoken</code> paging</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">CreateApp</code></td>
      <td>Create an app and its backing repository</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">GetApp</code></td>
      <td>Get one app by ID</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DeleteApp</code></td>
      <td>Delete an app, reporting whether it existed</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">LocateApp</code></td>
      <td>Resolve the tenant, environment, and geo hosting an app</td>
    </tr>
  </tbody>
</table>

<p><strong>Build and deploy</strong></p>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">BuildApp</code></td>
      <td>Start a server-side build from the backing repository</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">GetBuildOperation</code></td>
      <td>Poll build status and result</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">GetBuildOperationLog</code></td>
      <td>Fetch the build log for diagnosing failures</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DeployApp</code></td>
      <td>Deploy a built commit so users can reach it</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">UploadBuild</code></td>
      <td>Upload a prebuilt zip; the service commits it and returns a commit SHA</td>
    </tr>
  </tbody>
</table>

<p><strong>Sharing and access</strong></p>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ListShareLinks</code></td>
      <td>List share links granting access to an app</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">CreateShareLink</code></td>
      <td>Create a share link</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DeleteShareLink</code></td>
      <td>Revoke a share link</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ListRoleAssignments</code></td>
      <td>List users and groups assigned roles, with paging</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ModifyRoleAssignments</code></td>
      <td>Grant and revoke roles in one call</td>
    </tr>
  </tbody>
</table>

<p><strong>Connectivity</strong></p>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ListConnectors</code></td>
      <td>Connectors available as app data sources</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">GetConnector</code></td>
      <td>Metadata for one connector</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ListConnections</code></td>
      <td>The caller’s existing connections for a connector</td>
    </tr>
  </tbody>
</table>

<p><strong>Repository binding</strong></p>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">StartGitHubAuth</code></td>
      <td>Start authorization for a repository URL; returns a one-time code, verification URI, and session ID</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">GetGitHubAuthStatus</code></td>
      <td>Poll the session until it reaches a terminal state</td>
    </tr>
  </tbody>
</table>

<h2 id="two-ways-to-ship-a-build">Two ways to ship a build</h2>

<p>The normal path is <code class="language-plaintext highlighter-rouge">BuildApp</code>, then poll <code class="language-plaintext highlighter-rouge">GetBuildOperation</code>, then <code class="language-plaintext highlighter-rouge">DeployApp</code>.</p>

<p><code class="language-plaintext highlighter-rouge">UploadBuild</code> skips the server-side build. Send a zip of the built output, the service commits it and returns a <code class="language-plaintext highlighter-rouge">commitSha</code>, and you pass that SHA to <code class="language-plaintext highlighter-rouge">DeployApp</code>. It’s the one operation that writes code into the app’s repository, and it isn’t a substitute for Git — ordinary source control stays <code class="language-plaintext highlighter-rouge">git add</code>, <code class="language-plaintext highlighter-rouge">git commit</code>, <code class="language-plaintext highlighter-rouge">git push</code>, which is what the repository binding operations exist to authorize.</p>

<p>Watch the size. Power Platform caps the total payload through a custom connector at 100 MB while the service itself accepts 500 MB, so the connector ceiling is the one you hit first. The script checks before sending, so an oversized artifact fails immediately with a clear message instead of a late <code class="language-plaintext highlighter-rouge">413</code>. Over MCP the effective limit is lower still, since the zip has to be base64 encoded and base64 adds roughly a third. For anything larger, run <code class="language-plaintext highlighter-rouge">ms deploy --artifact &lt;file&gt;.zip</code>.</p>

<h2 id="binding-a-github-repository">Binding a GitHub repository</h2>

<p>Pointing an app at your own repository requires the repository owner to authorize the Managed Apps GitHub App, which runs as a device code flow across two operations.</p>

<p><code class="language-plaintext highlighter-rouge">StartGitHubAuth</code> takes a full repository URL such as <code class="language-plaintext highlighter-rouge">https://github.com/owner/repo</code> and parses out the owner, repository, and GitHub base URI. GitHub Enterprise Cloud hosts work too. Show the user the returned <code class="language-plaintext highlighter-rouge">userCode</code> and <code class="language-plaintext highlighter-rouge">verificationUri</code>, then poll <code class="language-plaintext highlighter-rouge">GetGitHubAuthStatus</code> no faster than <code class="language-plaintext highlighter-rouge">pollIntervalSeconds</code> until <code class="language-plaintext highlighter-rouge">expiresIn</code> elapses.</p>

<table>
  <thead>
    <tr>
      <th>State</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Pending</code></td>
      <td>Sign-in isn’t finished. Keep polling</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Complete</code></td>
      <td>Authorized. <code class="language-plaintext highlighter-rouge">githubLogin</code> and <code class="language-plaintext highlighter-rouge">mappingExpiresAt</code> are populated</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Denied</code></td>
      <td>The user declined. Binding can’t proceed</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">NotFound</code></td>
      <td>The session is unknown, usually a server restart. Start a new one</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Expired</code></td>
      <td>The window closed before sign-in finished. Start a new one</td>
    </tr>
  </tbody>
</table>

<p>Pass the <code class="language-plaintext highlighter-rouge">oauthBaseUri</code> from <code class="language-plaintext highlighter-rouge">StartGitHubAuth</code> straight back into <code class="language-plaintext highlighter-rouge">GetGitHubAuthStatus</code>. It defaults to <code class="language-plaintext highlighter-rouge">https://github.com</code>, which is wrong for GitHub Enterprise Cloud repositories.</p>

<h2 id="the-agent-side">The agent side</h2>

<p>The <code class="language-plaintext highlighter-rouge">/mcp</code> endpoint speaks JSON-RPC 2.0 and implements <code class="language-plaintext highlighter-rouge">initialize</code>, <code class="language-plaintext highlighter-rouge">tools/list</code>, <code class="language-plaintext highlighter-rouge">tools/call</code>, <code class="language-plaintext highlighter-rouge">resources/list</code>, <code class="language-plaintext highlighter-rouge">ping</code>, and the <code class="language-plaintext highlighter-rouge">notifications/*</code> methods. Tools carry a <code class="language-plaintext highlighter-rouge">managedapps_</code> prefix and mirror the REST operations, plus one extra: <code class="language-plaintext highlighter-rouge">managedapps_list_environments</code>, so an agent can find the <code class="language-plaintext highlighter-rouge">environmentId</code> every other tool needs.</p>

<p>A few requests an agent handles end to end:</p>

<ul>
  <li><em>“Which apps can I edit in the Contoso environment?”</em> → <code class="language-plaintext highlighter-rouge">managedapps_list_environments</code>, then <code class="language-plaintext highlighter-rouge">managedapps_list_apps</code> with <code class="language-plaintext highlighter-rouge">permission: "write"</code></li>
  <li><em>“Deploy the latest build of the expense tracker.”</em> → <code class="language-plaintext highlighter-rouge">managedapps_list_apps</code>, <code class="language-plaintext highlighter-rouge">managedapps_build_app</code>, poll <code class="language-plaintext highlighter-rouge">managedapps_get_build_operation</code>, then <code class="language-plaintext highlighter-rouge">managedapps_deploy_app</code></li>
  <li><em>“Why did that build fail?”</em> → <code class="language-plaintext highlighter-rouge">managedapps_get_build_log</code></li>
  <li><em>“Who has access to this app?”</em> → <code class="language-plaintext highlighter-rouge">managedapps_list_role_assignments</code> and <code class="language-plaintext highlighter-rouge">managedapps_list_share_links</code></li>
  <li><em>“Connect my app to github.com/contoso/expenses.”</em> → <code class="language-plaintext highlighter-rouge">managedapps_start_github_auth</code>, show the code and URI, then poll <code class="language-plaintext highlighter-rouge">managedapps_get_github_auth_status</code></li>
</ul>

<p><code class="language-plaintext highlighter-rouge">managedapps_upload_build</code> takes the zip as base64 in <code class="language-plaintext highlighter-rouge">zipContentBase64</code>.</p>

<h2 id="a-note-on-naming">A note on naming</h2>

<p>The product was called Managed Apps before the rename to Copilot Managed Runtime, and the old name survives in identifiers that aren’t safe to change:</p>

<table>
  <thead>
    <tr>
      <th>Identifier</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>npm packages</td>
      <td><code class="language-plaintext highlighter-rouge">@microsoft/managed-apps-cli</code>, <code class="language-plaintext highlighter-rouge">@microsoft/managed-apps</code></td>
    </tr>
    <tr>
      <td>API route prefix</td>
      <td><code class="language-plaintext highlighter-rouge">/appframework</code></td>
    </tr>
    <tr>
      <td>User portal domain</td>
      <td><code class="language-plaintext highlighter-rouge">managedapps.cloud.microsoft</code></td>
    </tr>
  </tbody>
</table>

<p>The connector’s operation IDs and MCP tool names keep the <code class="language-plaintext highlighter-rouge">managedapps</code> prefix for the same reason. Renaming them would break agents and flows already bound to them.</p>

<h2 id="licensing">Licensing</h2>

<p>Everyone who runs an app needs coverage, including you while you’re developing.</p>

<table>
  <thead>
    <tr>
      <th>Option</th>
      <th>Behavior</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Power Apps Premium plan</td>
      <td>Covers all app operations without consuming Copilot Credits</td>
    </tr>
    <tr>
      <td>Copilot Credits</td>
      <td>Charged per app launch and per API call, at 0.1 credit per call</td>
    </tr>
  </tbody>
</table>

<p>Without sufficient credits a user is warned first and can keep working, then blocked at 20 app operations or five minutes of use, whichever comes first. Power Apps Premium users aren’t subject to those limits.</p>

<h2 id="deploy-it">Deploy it</h2>

<p>Register an app in Microsoft Entra ID and add these delegated permissions from the Power Platform API (<code class="language-plaintext highlighter-rouge">8578e004-a5c6-46e7-913e-12f58912df43</code>):</p>

<table>
  <thead>
    <tr>
      <th>Scope</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ManagedApps.ReadWrite.All</code></td>
      <td>App lifecycle — required</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">PowerApps.Apps.Read</code></td>
      <td>Reading app metadata</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">PowerApps.Environments.Read</code></td>
      <td>Environment dropdown</td>
    </tr>
  </tbody>
</table>

<p>All three are user-consentable, so admin consent is optional unless your tenant requires it.</p>

<p>Replace <code class="language-plaintext highlighter-rouge">[YOUR_CLIENT_ID]</code> in <code class="language-plaintext highlighter-rouge">apiProperties.json</code> with your application ID. Leave <code class="language-plaintext highlighter-rouge">[YOUR_REDIRECT_URL]</code> alone — the platform generates a per-connector redirect URL on deploy and discards whatever you supply.</p>

<p>Because <code class="language-plaintext highlighter-rouge">scriptOperations</code> is non-empty, the script and the definition must always travel together. Omit <code class="language-plaintext highlighter-rouge">--script-file</code> and you get <code class="language-plaintext highlighter-rouge">InvalidScriptDefinitionUrlWithNonNullOperations</code>.</p>

<p>Create the connector in two steps. A script-enabled connector needs a function app assigned from a regional pool when it’s first created, and requesting that during <code class="language-plaintext highlighter-rouge">create</code> frequently fails:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">auth</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="nt">--environment</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">environment-id</span><span class="err">&gt;</span><span class="w">

</span><span class="c"># Step 1 - create without the script</span><span class="w">
</span><span class="nv">$p</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-Content</span><span class="w"> </span><span class="nx">apiProperties.json</span><span class="w"> </span><span class="nt">-Raw</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="w">
</span><span class="nv">$p</span><span class="o">.</span><span class="nf">properties</span><span class="o">.</span><span class="nf">PSObject</span><span class="o">.</span><span class="nf">Properties</span><span class="o">.</span><span class="nf">Remove</span><span class="p">(</span><span class="s1">'scriptOperations'</span><span class="p">)</span><span class="w">
</span><span class="nv">$p</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertTo-Json</span><span class="w"> </span><span class="nt">-Depth</span><span class="w"> </span><span class="nx">40</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Set-Content</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">TEMP</span><span class="s2">\noscript.json"</span><span class="w"> </span><span class="nt">-Encoding</span><span class="w"> </span><span class="nx">UTF8</span><span class="w">

</span><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--api-definition-file</span><span class="w"> </span><span class="nx">apiDefinition.swagger.json</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--api-properties-file</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">TEMP</span><span class="s2">\noscript.json"</span><span class="w">

</span><span class="c"># Step 2 - attach the script using the ID returned above</span><span class="w">
</span><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">update</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--connector-id</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">connector-id</span><span class="err">&gt;</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--api-definition-file</span><span class="w"> </span><span class="nx">apiDefinition.swagger.json</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--api-properties-file</span><span class="w"> </span><span class="nx">apiProperties.json</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--script-file</span><span class="w"> </span><span class="nx">script.csx</span><span class="w">
</span></code></pre></div></div>

<p>Updating an existing connector is a single <code class="language-plaintext highlighter-rouge">pac connector update</code>, since it already holds an assigned function app.</p>

<p>A success message means the row was written, not that the definition survived intact. Download it back and check for 22 operations, <code class="language-plaintext highlighter-rouge">x-ms-agentic-protocol: mcp-streamable-1.0</code> on the <code class="language-plaintext highlighter-rouge">/mcp</code> path, and the environment dropdowns:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">download</span><span class="w"> </span><span class="nt">--connector-id</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">connector-id</span><span class="err">&gt;</span><span class="w"> </span><span class="nt">--outputDirectory</span><span class="w"> </span><span class="o">.</span><span class="nx">/verify</span><span class="w">
</span></code></pre></div></div>

<p>The generated redirect URL sits at <code class="language-plaintext highlighter-rouge">properties.connectionParameters.token.oAuthSettings.redirectUrl</code> in the downloaded file. Register it, or OAuth consent fails:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">az</span><span class="w"> </span><span class="nx">ad</span><span class="w"> </span><span class="nx">app</span><span class="w"> </span><span class="nx">update</span><span class="w"> </span><span class="nt">--id</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">appId</span><span class="err">&gt;</span><span class="w"> </span><span class="nt">--web-redirect-uris</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="s2">"https://global.consent.azure-apim.net/redirect"</span><span class="w"> </span><span class="s2">"&lt;per-connector-url&gt;"</span><span class="w">
</span></code></pre></div></div>

<p>That URL encodes both the connector name and the environment, so a connector deployed to dev and prod produces two different URLs and both need registering.</p>

<h2 id="caveats">Caveats</h2>

<p>This is public preview, and explicitly volatile. The <code class="language-plaintext highlighter-rouge">microsoft/managed-apps</code> README states that APIs, templates, and tooling may change before general availability, and there’s no versioned public contract for these routes. Treat the connector as tracking a moving target.</p>

<p>Repository create and delete are excluded. The CLI builds routes under <code class="language-plaintext highlighter-rouge">/appframework/git/repositories</code>, but that path returned <code class="language-plaintext highlighter-rouge">RouteNotFound</code> during testing while <code class="language-plaintext highlighter-rouge">/appframework/apps</code> did not — feature-gated or not uniformly deployed. Those operations are left out rather than shipped broken. The GitHub device code routes under <code class="language-plaintext highlighter-rouge">/appframework/github/auth</code> are a separate surface and are included.</p>

<p>Response schemas are best-effort, reconstructed from the CLI’s own mock fixtures and type usage. The script projects responses defensively, reading both flat and <code class="language-plaintext highlighter-rouge">properties</code>-nested shapes, so a server-side shape change degrades to null fields rather than a hard failure.</p>

<p><code class="language-plaintext highlighter-rouge">api-version=1</code> is pinned. Deployment has a newer <code class="language-plaintext highlighter-rouge">manageddevops</code> backend behind a CLI feature gate; this connector uses the stable <code class="language-plaintext highlighter-rouge">appframework</code> path.</p>

<h2 id="when-something-comes-back-wrong">When something comes back wrong</h2>

<table>
  <thead>
    <tr>
      <th>Symptom</th>
      <th>Cause</th>
      <th>Fix</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pac connector create</code> fails when <code class="language-plaintext highlighter-rouge">--script-file</code> is supplied</td>
      <td>A new script-enabled connector couldn’t be assigned a function app</td>
      <td>Create without the script, then attach it with <code class="language-plaintext highlighter-rouge">pac connector update</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">InvalidScriptDefinitionUrlWithNonNullOperations</code></td>
      <td><code class="language-plaintext highlighter-rouge">scriptOperations</code> is declared but no script was uploaded</td>
      <td>Add <code class="language-plaintext highlighter-rouge">--script-file script.csx</code>, or remove <code class="language-plaintext highlighter-rouge">scriptOperations</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">401</code> on every call after deploying</td>
      <td>The generated per-connector redirect URL isn’t registered</td>
      <td>Read it back with <code class="language-plaintext highlighter-rouge">pac connector download</code> and add it</td>
    </tr>
    <tr>
      <td>A connection can’t be created at all</td>
      <td><code class="language-plaintext highlighter-rouge">clientId</code> is still <code class="language-plaintext highlighter-rouge">[YOUR_CLIENT_ID]</code></td>
      <td>Set your real app registration ID and redeploy</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">403 InsufficientDelegatedPermissions</code></td>
      <td>The app registration is missing <code class="language-plaintext highlighter-rouge">ManagedApps.ReadWrite.All</code></td>
      <td>Add the delegated permission and reconsent</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">UploadBuild</code> rejects the artifact before sending</td>
      <td>The zip exceeds 100 MB, or the content isn’t a zip</td>
      <td>Use <code class="language-plaintext highlighter-rouge">ms deploy --artifact</code> for large artifacts</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">UploadBuild</code> returns an invalid artifact error</td>
      <td>The zip doesn’t contain the built output and its configuration file</td>
      <td>Package the build output directory, not the source tree</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DeployApp</code> reports no successful build</td>
      <td>No green build exists for the commit</td>
      <td>Upload or push a commit and wait for its build to succeed</td>
    </tr>
    <tr>
      <td>GitHub authorization never leaves <code class="language-plaintext highlighter-rouge">Pending</code></td>
      <td>Sign-in isn’t finished at the verification URI</td>
      <td>Resend the code and URI; the session ends after <code class="language-plaintext highlighter-rouge">expiresIn</code> seconds</td>
    </tr>
  </tbody>
</table>

<p>Telemetry is off by default. Replace <code class="language-plaintext highlighter-rouge">APP_INSIGHTS_CONNECTION_STRING</code> in <code class="language-plaintext highlighter-rouge">script.csx</code> with your connection string to record MCP method calls, tool invocations, and tool errors. Telemetry failures are swallowed and never affect the caller.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Managed%20Runtime">Copilot Managed Runtime connector source</a></li>
  <li><a href="https://www.microsoft.com/en-us/copilot/blog/copilot-studio/build-where-you-want-run-with-confidence-now-microsoft-hosts-and-manages-the-code-created-by-copilot/">Announcing Copilot Managed Runtime</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-365/managed-apps/">What is Copilot Managed Runtime</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-365/managed-apps/developer/">Copilot Managed Runtime SDK overview</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-365/admin/manage/apps/">Copilot Managed Runtime for admins</a></li>
  <li><a href="https://github.com/microsoft/managed-apps">microsoft/managed-apps</a></li>
  <li><a href="https://www.npmjs.com/package/@microsoft/managed-apps-cli"><code class="language-plaintext highlighter-rouge">@microsoft/managed-apps-cli</code></a></li>
  <li><a href="https://learn.microsoft.com/power-platform/admin/programmability-and-extensibility/powerplatform-api-reference">Power Platform API reference</a></li>
  <li><a href="https://learn.microsoft.com/connectors/custom-connectors/write-code">Write code in a custom connector</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="MCP" /><category term="Copilot Managed Runtime" /><category term="Managed Apps" /><category term="Custom Connectors" /><category term="MCP" /><category term="Copilot Studio" /><category term="Power Automate" /><category term="OAuth" /><summary type="html"><![CDATA[Copilot Managed Runtime is a dual-mode Power Platform custom connector that wraps the same API the ms CLI uses, with 22 typed operations for Power Automate and an MCP endpoint for Copilot Studio agents.]]></summary></entry><entry><title type="html">Call Azure Functions hosted skills from Power Automate and Copilot Studio</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-25-azure-functions-hosted-skills-connector.html" rel="alternate" type="text/html" title="Call Azure Functions hosted skills from Power Automate and Copilot Studio" /><published>2026-09-25T13:00:00+00:00</published><updated>2026-09-25T13:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/mcp/azure-functions-hosted-skills-connector</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-25-azure-functions-hosted-skills-connector.html"><![CDATA[<p>An Azure Functions hosted skill is a unit of AI work defined in an <code class="language-plaintext highlighter-rouge">.agent.md</code> file: natural-language instructions, a trigger, and whatever tools you bind to it. Most hosted skills start themselves — a timer fires, a queue message arrives, a connector trigger sees new mail. <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Azure%20Functions%20Hosted%20Skills">Azure Functions Hosted Skills</a> covers the other direction. It calls a skill on demand, waits for the answer, and hands it back to a flow or an agent.</p>

<p>Hosted skills are in <a href="https://learn.microsoft.com/azure/azure-functions/functions-hosted-skills">preview</a>, so endpoint names and configuration keys can still change.</p>

<h2 id="turn-on-the-chat-api-first">Turn on the chat API first</h2>

<p>The built-in endpoints are off by default. Switch them on in each <code class="language-plaintext highlighter-rouge">.agent.md</code> file you want to call:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">Order Triage</span>
<span class="na">description</span><span class="pi">:</span> <span class="s">Classifies an order problem and drafts a reply.</span>
<span class="na">builtin_endpoints</span><span class="pi">:</span>
  <span class="na">chat_api</span><span class="pi">:</span> <span class="kc">true</span>
<span class="nn">---</span>

You classify order problems. Given a customer message, return the severity,
the responsible team, and a draft reply.
</code></pre></div></div>

<p>A skill with <code class="language-plaintext highlighter-rouge">builtin_endpoints</code> enabled needs no <code class="language-plaintext highlighter-rouge">trigger</code>. It can have both — a timer trigger for its scheduled run and a chat API for on-demand calls. <code class="language-plaintext highlighter-rouge">builtin_endpoints: true</code> is shorthand for everything: the chat API, the browser debug UI, and the runtime’s own MCP surface.</p>

<p>The skill’s slug is its file name. <code class="language-plaintext highlighter-rouge">order_triage.agent.md</code> has the slug <code class="language-plaintext highlighter-rouge">order_triage</code>, and that slug is what every operation takes. Skills can sit at the app root or in an <code class="language-plaintext highlighter-rouge">agents/</code> folder; the slug carries no folder either way.</p>

<p>Only built-in endpoints are reachable here. A skill whose <code class="language-plaintext highlighter-rouge">trigger</code> is <code class="language-plaintext highlighter-rouge">http_trigger</code> gets its own route, such as <code class="language-plaintext highlighter-rouge">/summarize</code>, and needs a plain HTTP action — or add <code class="language-plaintext highlighter-rouge">builtin_endpoints</code> alongside the trigger.</p>

<h2 id="six-operations">Six operations</h2>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>What it does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Invoke a hosted skill</td>
      <td>Sends a prompt and returns the response, the session it ran in, and the tools it called</td>
    </tr>
    <tr>
      <td>Get session transcript</td>
      <td>Returns the user and assistant turns in a session, oldest first</td>
    </tr>
    <tr>
      <td>List dynamic workflows for a session</td>
      <td>Lists durable workflows the skill started in a session</td>
    </tr>
    <tr>
      <td>Get dynamic workflow status</td>
      <td>Returns runtime status and, once finished, the output of one workflow run</td>
    </tr>
    <tr>
      <td>List hosted skills</td>
      <td>Lists the app’s skills and the endpoints each one exposes</td>
    </tr>
    <tr>
      <td>Invoke Azure Functions Hosted Skills MCP</td>
      <td>The endpoint Copilot Studio connects to</td>
    </tr>
  </tbody>
</table>

<p>Both surfaces hit the same endpoints, so a skill you expose to a flow is immediately available to an agent.</p>

<h2 id="discovery-costs-you-a-key-choice">Discovery costs you a key choice</h2>

<p><strong>Skill</strong> is a dropdown, not a free-text box, filled by <strong>List hosted skills</strong>. That operation reads the function app’s admin API, which accepts only the app’s <code class="language-plaintext highlighter-rouge">_master</code> key. So the picker works on one condition:</p>

<table>
  <thead>
    <tr>
      <th>Key on the connection</th>
      <th>Skill operations</th>
      <th>Skill list</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Host or function key</td>
      <td>Work</td>
      <td>Empty, with the reason in <code class="language-plaintext highlighter-rouge">message</code></td>
    </tr>
    <tr>
      <td>Master key</td>
      <td>Work</td>
      <td>Populated</td>
    </tr>
  </tbody>
</table>

<p>A host key is the least-privilege choice and costs you only the picker. You type slugs instead, and the slug is always the <code class="language-plaintext highlighter-rouge">.agent.md</code> file name without its extension. Nothing else changes. Discovery also stops working when the function app has admin isolation enabled, since the runtime itself publishes no catalogue endpoint.</p>

<h2 id="sessions-carry-the-conversation">Sessions carry the conversation</h2>

<p>Omit the session ID on the first call and the runtime mints one, returns it in <code class="language-plaintext highlighter-rouge">session_id</code>, and stores the transcript in the app’s <code class="language-plaintext highlighter-rouge">AzureWebJobsStorage</code> account. Pass that value back and the skill sees everything said before. Two calls without a session ID are two unrelated conversations, even against the same skill.</p>

<p>In a flow, capture <code class="language-plaintext highlighter-rouge">session_id</code> from the first response and feed it into every later call in the same run. Supplying your own is fine — session IDs accept letters, digits, dots, hyphens, and underscores up to 128 characters, so a support ticket number makes a good one because it resumes the same conversation days later.</p>

<p><strong>Get session transcript</strong> returns at most the 200 most recent turns and sets <code class="language-plaintext highlighter-rouge">truncated</code> when older ones were dropped. The skill’s own memory is unaffected; only the transcript you read back is capped.</p>

<h2 id="tool-calls-come-back-with-the-answer">Tool calls come back with the answer</h2>

<p><code class="language-plaintext highlighter-rouge">tool_calls</code> reports what the skill did to produce its response: one entry per tool, with the name, the arguments it was given, and what it returned. The <code class="language-plaintext highlighter-rouge">type</code> field reads <code class="language-plaintext highlighter-rouge">tool_start</code> even on a completed call, because the runtime merges the call and its result into a single entry on this path.</p>

<p>Arguments and results arrive as text, with structured values rendered as JSON. They map onto a single string column, so you can show them in an approval card or write them to a log table.</p>

<h2 id="two-minutes-and-what-to-do-about-it">Two minutes, and what to do about it</h2>

<p>A Power Platform request times out at roughly 120 seconds. A skill that searches the web, runs sandboxed Python, and calls three connectors can exceed that. Two ways around it:</p>

<p><strong>Let the skill start a dynamic workflow.</strong> Skills with <code class="language-plaintext highlighter-rouge">workflows.enabled: true</code> in their front matter return quickly with work running in the background. Poll it with <strong>Get dynamic workflow status</strong> until <code class="language-plaintext highlighter-rouge">runtime_status</code> leaves <code class="language-plaintext highlighter-rouge">Running</code>, then read <code class="language-plaintext highlighter-rouge">output</code>. The <strong>Workflow ID</strong> field is a dropdown, filled from the workflows the chosen skill started in the session you name, each labelled with its status and start time.</p>

<p><strong>Trigger the skill instead of calling it.</strong> If nothing in the flow needs the answer, give the skill a queue or Event Grid trigger and let the function app own the run.</p>

<p>Stopping a workflow is not an operation here. The runtime’s <code class="language-plaintext highlighter-rouge">cancel_workflow</code> and <code class="language-plaintext highlighter-rouge">terminate_workflow</code> are tools the skill holds, not HTTP endpoints, so there’s nothing for a connector to call. To stop a run, send the skill a prompt in the same session asking it to cancel that workflow.</p>

<p>A workflow that fans out to specialist skills doesn’t make those specialists visible here. Subagents are separate <code class="language-plaintext highlighter-rouge">.agent.md</code> files that usually have no <code class="language-plaintext highlighter-rouge">builtin_endpoints</code> of their own, so they won’t appear in the Skill list and can’t be invoked directly — which is the intent.</p>

<h2 id="retries-stop-at-the-skill-boundary">Retries stop at the skill boundary</h2>

<p>Reads — transcript, workflow list, workflow status, and skill discovery — are retried up to three times on 429, 502, 503, or 504, honoring <code class="language-plaintext highlighter-rouge">Retry-After</code> when the app sends one. A function app scaling from zero produces exactly these codes, and retrying is invisible to your flow.</p>

<p>Invoking a skill is never retried. A hosted skill can send mail, update a record, or start a workflow before the throttle was applied, so replaying it risks doing that work twice. A throttled invoke fails with the wait time the app asked for, and whether repeating it is safe is your call.</p>

<h2 id="five-tools-for-an-agent">Five tools for an agent</h2>

<p>Add the connector to a Copilot Studio agent as an MCP tool and it publishes:</p>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">list_skills</code></td>
      <td>Discover which hosted skills the app offers and what each supports</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">invoke_skill</code></td>
      <td>Send a prompt to a hosted skill and get its response</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">get_session_history</code></td>
      <td>Recall what was said earlier in a session</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">list_session_workflows</code></td>
      <td>Check on long-running work the skill started</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">get_workflow_status</code></td>
      <td>Read the result of one workflow run</td>
    </tr>
  </tbody>
</table>

<p>Every tool except <code class="language-plaintext highlighter-rouge">list_skills</code> takes an optional <code class="language-plaintext highlighter-rouge">agent</code> argument naming the skill. Omit it and the connector resolves the skill itself when the app hosts exactly one callable skill, which means a single-skill app needs no configuration at all. When the app hosts several, or the skills can’t be listed, the tool returns an error telling the agent to call <code class="language-plaintext highlighter-rouge">list_skills</code> and choose. Auto-resolution needs the skill list, so it works on the same terms as discovery.</p>

<p>The connector also sends a short set of instructions when Copilot Studio connects: discover skills before guessing at one, carry <code class="language-plaintext highlighter-rouge">session_id</code> between turns, and poll a dynamic workflow rather than assuming the skill finished. That guidance reaches the orchestrator without you writing it into every agent.</p>

<p><code class="language-plaintext highlighter-rouge">invoke_skill</code> returns <code class="language-plaintext highlighter-rouge">session_id</code> in its result, which lets an agent continue the same hosted-skill conversation across turns by passing that value back. The agent has to carry it deliberately — Copilot Studio’s conversation state and the hosted skill’s session are separate things.</p>

<p>This is worth doing when the hosted skill knows something the agent does not. A skill wired to a Connector Namespace can reach SAP, Salesforce, or a line-of-business SQL database, run model reasoning over what it finds, and return a conclusion. The agent gets one clean tool instead of a dozen raw API calls.</p>

<p>You don’t need <code class="language-plaintext highlighter-rouge">builtin_endpoints.mcp: true</code> for any of this. That setting exposes the skill on the runtime’s own MCP endpoint, which the Functions MCP extension owns and which requires a separate system key. This connector builds its MCP surface from the chat API instead, so one key covers everything and you get five tools rather than one.</p>

<p>Tool results are trimmed before they reach the agent. A skill that scraped a dozen pages can return more than an agent’s context window holds, so oversized results have their tool-call detail shortened first and, if that isn’t enough, are truncated with a note saying so. The skill’s own answer is preserved, and <strong>Invoke a hosted skill</strong> always returns the complete payload.</p>

<h2 id="deploy-it">Deploy it</h2>

<p>Deploy the function app with <code class="language-plaintext highlighter-rouge">chat_api</code> enabled on every skill you plan to call. Then collect two values from the Azure portal: the default domain from the function app’s <strong>Overview</strong> page, such as <code class="language-plaintext highlighter-rouge">contoso-skills.azurewebsites.net</code>, and a host key from <strong>Functions</strong> → <strong>App keys</strong>. A host key works for every skill in the app. Use the <code class="language-plaintext highlighter-rouge">_master</code> key instead if you want the Skill picker populated, or scope access to one skill by copying a key from that skill’s <code class="language-plaintext highlighter-rouge">_builtin_chat</code> function.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-definition-file</span><span class="w"> </span><span class="o">.</span><span class="nx">/apiDefinition.swagger.json</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-properties-file</span><span class="w"> </span><span class="o">.</span><span class="nx">/apiProperties.json</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--script-file</span><span class="w"> </span><span class="o">.</span><span class="nx">/script.csx</span><span class="w">
</span></code></pre></div></div>

<p>Create a connection with the hostname and the key — those are the only two settings — and run <strong>Invoke a hosted skill</strong> with a slug and a prompt. A successful call returns <code class="language-plaintext highlighter-rouge">response</code>, <code class="language-plaintext highlighter-rouge">session_id</code>, and <code class="language-plaintext highlighter-rouge">tool_calls</code>.</p>

<p>The route prefix needs no configuration. The runtime registers its endpoints under the Functions route prefix, which defaults to <code class="language-plaintext highlighter-rouge">api</code> but is empty in the official quickstart’s <code class="language-plaintext highlighter-rouge">host.json</code>. The connector tries the declared path first and retries the other layout on a 404, so both work.</p>

<p><code class="language-plaintext highlighter-rouge">script.csx</code> ships with Application Insights logging off. Set <code class="language-plaintext highlighter-rouge">APP_INSIGHTS_ENABLED</code> to <code class="language-plaintext highlighter-rouge">true</code> and replace <code class="language-plaintext highlighter-rouge">APP_INSIGHTS_KEY</code> with your instrumentation key to record skill invocations, MCP tool calls, and failures. Telemetry failures are swallowed and never affect a call.</p>

<h2 id="signing-in-with-microsoft-entra-id">Signing in with Microsoft Entra ID</h2>

<p>Import <code class="language-plaintext highlighter-rouge">apiProperties-entra.json</code> instead of <code class="language-plaintext highlighter-rouge">apiProperties.json</code> to authenticate with Microsoft Entra ID. Everything else — the Swagger definition, the script, the operations — is identical. Use it when your skills set <code class="language-plaintext highlighter-rouge">http_auth.mode: entra</code>, or when your tenant disables function keys.</p>

<p>This path is built from the runtime’s documented behavior but hasn’t been exercised against a live Entra-protected app, so treat it as a starting point rather than a verified recipe. The function-key path is tested end to end.</p>

<p>The function app must have App Service Authentication turned on. The runtime doesn’t validate tokens itself; it accepts an Entra caller only when the platform has already done that work. Register an application for the function app with an Application ID URI such as <code class="language-plaintext highlighter-rouge">api://&lt;app-id&gt;</code> and a <code class="language-plaintext highlighter-rouge">user_impersonation</code> scope, point App Service Authentication at it, then fill in the client ID and both Application ID URI placeholders in <code class="language-plaintext highlighter-rouge">apiProperties-entra.json</code>. After importing, read back the redirect URL the platform generated and add it to the app registration. That value is specific to the connector <em>and</em> the environment, so a connector in two environments produces two URLs.</p>

<p>Skill discovery is unavailable in this mode. The admin API accepts only the master key, and this configuration has no key parameter to put it in — deliberately, since the reason to run in Entra mode is usually that you don’t want master keys stored in connections. Type slugs instead, and name the skill explicitly on every MCP tool call.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Azure%20Functions%20Hosted%20Skills">Azure Functions Hosted Skills connector</a></li>
  <li><a href="https://learn.microsoft.com/azure/azure-functions/functions-hosted-skills">Azure Functions hosted skills</a></li>
  <li><a href="https://learn.microsoft.com/azure/azure-functions/functions-hosted-skills-reference">Hosted skills reference</a></li>
  <li><a href="https://learn.microsoft.com/azure/azure-functions/functions-hosted-skills-dynamic-workflows">Dynamic workflows</a></li>
  <li><a href="https://azure.github.io/azure-functions-agents-runtime/">Azure Functions Agents Runtime</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="MCP" /><category term="Azure Functions" /><category term="Hosted Skills" /><category term="Custom Connectors" /><category term="MCP" /><category term="Copilot Studio" /><category term="Power Automate" /><category term="Azure" /><summary type="html"><![CDATA[Azure Functions Hosted Skills is a Power Platform custom connector that calls a hosted skill on demand, returns its answer with the session and tool calls, and exposes five MCP tools to Copilot Studio agents.]]></summary></entry><entry><title type="html">Dataverse SQL queries from Power Automate and Copilot Studio</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-23-dataverse-sql-custom-connector.html" rel="alternate" type="text/html" title="Dataverse SQL queries from Power Automate and Copilot Studio" /><published>2026-09-23T13:00:00+00:00</published><updated>2026-09-23T13:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/mcp/dataverse-sql-custom-connector</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-23-dataverse-sql-custom-connector.html"><![CDATA[<p>The Dataverse Web API accepts a read-only T-SQL subset through the <a href="https://learn.microsoft.com/power-apps/developer/data-platform/webapi/query/sql"><code class="language-plaintext highlighter-rouge">sql</code> query option</a>. <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Dataverse%20SQL">Dataverse SQL</a> puts a usable surface on it: typed actions for flows and apps, and an MCP endpoint that gives a Copilot Studio agent discovery tools so it writes SQL against columns it has confirmed exist.</p>

<h2 id="two-rough-edges-the-connector-absorbs">Two rough edges the connector absorbs</h2>

<p>Calling the <code class="language-plaintext highlighter-rouge">sql</code> option directly means dealing with both of these yourself.</p>

<p><strong>The entity set in the URL must match the base table of the query.</strong> A query beginning <code class="language-plaintext highlighter-rouge">FROM account</code> has to go to <code class="language-plaintext highlighter-rouge">/accounts</code>, and <code class="language-plaintext highlighter-rouge">FROM msdyn_botsession</code> to <code class="language-plaintext highlighter-rouge">/msdyn_botsessions</code>. The connector reads the <code class="language-plaintext highlighter-rouge">FROM</code> clause, resolves the entity set from table metadata, and builds the URL. You write the table name and never the entity set.</p>

<p><strong>The supported subset is narrow, and a rejected statement returns a raw fault.</strong> Every statement is checked locally first. Anything unsupported comes back as a named issue — <code class="language-plaintext highlighter-rouge">select_star</code>, <code class="language-plaintext highlighter-rouge">having</code>, <code class="language-plaintext highlighter-rouge">unsupported_join</code> — describing what to write instead, with no round trip to Dataverse.</p>

<p>Everything is read-only. A statement must be a single <code class="language-plaintext highlighter-rouge">SELECT</code>, and write keywords are rejected before the request is built.</p>

<h2 id="seven-visible-operations">Seven visible operations</h2>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Execute SQL</td>
      <td>Run a <code class="language-plaintext highlighter-rouge">SELECT</code> and return rows, with a next link when more are available</td>
    </tr>
    <tr>
      <td>Get next page</td>
      <td>Follow the next link from a previous result</td>
    </tr>
    <tr>
      <td>Count rows</td>
      <td><code class="language-plaintext highlighter-rouge">COUNT(*)</code> against a table, with an optional filter</td>
    </tr>
    <tr>
      <td>Validate SQL</td>
      <td>Check a statement and resolve its base table without running it</td>
    </tr>
    <tr>
      <td>List tables</td>
      <td>Table logical names, entity set names, and display names</td>
    </tr>
    <tr>
      <td>List columns</td>
      <td>Columns of one table, for building a <code class="language-plaintext highlighter-rouge">SELECT</code> list</td>
    </tr>
    <tr>
      <td>Invoke Dataverse SQL MCP</td>
      <td>JSON-RPC 2.0 endpoint for Copilot Studio</td>
    </tr>
  </tbody>
</table>

<p>An eighth operation, <strong>Get row schema</strong>, is marked internal and backs the dynamic content below.</p>

<p><strong>Execute SQL</strong> takes the statement, a page size, an optional row budget, and a switch for formatted values. Formatted values add display text for choice, lookup, and date columns as <code class="language-plaintext highlighter-rouge">@OData.Community.Display.V1.FormattedValue</code> annotations next to the raw values.</p>

<h2 id="column-names-come-from-a-sample-row-not-a-prediction">Column names come from a sample row, not a prediction</h2>

<p>The designer resolves the columns your statement returns and offers them as typed dynamic content, so you pick <code class="language-plaintext highlighter-rouge">name</code> or <code class="language-plaintext highlighter-rouge">revenue</code> directly instead of writing <code class="language-plaintext highlighter-rouge">item()?['name']</code>. Types come from Dataverse metadata for whichever table each alias refers to, so in a join <code class="language-plaintext highlighter-rouge">c.birthdate</code> is typed from <code class="language-plaintext highlighter-rouge">contact</code> rather than the base table. Aggregates are typed from their function: <code class="language-plaintext highlighter-rouge">COUNT(*)</code> is a whole number, <code class="language-plaintext highlighter-rouge">SUM()</code> a decimal, and <code class="language-plaintext highlighter-rouge">MIN()</code>/<code class="language-plaintext highlighter-rouge">MAX()</code> inherit the type of the column they wrap.</p>

<p>Two behaviors shape how that works.</p>

<p>Alias your aggregates. Dataverse names an aggregate column after its alias, so <code class="language-plaintext highlighter-rouge">SELECT COUNT(*) AS total</code> produces a <code class="language-plaintext highlighter-rouge">total</code> field while a bare <code class="language-plaintext highlighter-rouge">SELECT COUNT(*)</code> produces a field this connector cannot name and therefore omits.</p>

<p>Dataverse also decides what comes back, not your <code class="language-plaintext highlighter-rouge">SELECT</code> list. A plain query returns the primary key and <code class="language-plaintext highlighter-rouge">@odata.etag</code> on top of what you asked for, and some tables add more — <code class="language-plaintext highlighter-rouge">systemuser</code> and <code class="language-plaintext highlighter-rouge">team</code> return <code class="language-plaintext highlighter-rouge">ownerid</code> unselected, while <code class="language-plaintext highlighter-rouge">queue</code> and <code class="language-plaintext highlighter-rouge">role</code> do not. An aggregate query returns <em>only</em> its aliased columns. That pattern isn’t derivable from metadata, so the connector runs your statement once for a single row at design time and reads the real column names:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">sampled</span> <span class="p">=</span> <span class="k">await</span> <span class="nf">SampleRowAsync</span><span class="p">(</span><span class="n">table</span><span class="p">,</span> <span class="n">validation</span><span class="p">.</span><span class="n">Statement</span><span class="p">).</span><span class="nf">ConfigureAwait</span><span class="p">(</span><span class="k">false</span><span class="p">);</span>
</code></pre></div></div>

<p>An empty table has no row to inspect, so the schema falls back to the <code class="language-plaintext highlighter-rouge">SELECT</code> list plus the primary key and etag, and the response reports <code class="language-plaintext highlighter-rouge">sampled: false</code>.</p>

<p>Resolution happens at design time against the statement in the box. Build the statement from an expression and there’s nothing to inspect yet, so <code class="language-plaintext highlighter-rouge">rows</code> stays untyped. The action still runs normally.</p>

<h2 id="six-tools-for-an-agent">Six tools for an agent</h2>

<p>Add the connector as a tool in Copilot Studio and the MCP endpoint exposes:</p>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">list_tables</code></td>
      <td>Find the logical name for a <code class="language-plaintext highlighter-rouge">FROM</code> clause</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">describe_table</code></td>
      <td>Confirm column names and types before writing a <code class="language-plaintext highlighter-rouge">SELECT</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">validate_sql</code></td>
      <td>Check a statement without running it</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">run_sql</code></td>
      <td>Execute and return rows, optionally collecting several pages</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">next_page</code></td>
      <td>Continue a paged result</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">count_rows</code></td>
      <td>Count rows, optionally filtered</td>
    </tr>
  </tbody>
</table>

<p>The tool descriptions carry the supported subset, so an agent generally writes a valid statement on the first attempt. When it doesn’t, failures come back as tool results rather than protocol errors, and the agent reads the issues and valid values and retries instead of stalling. An unknown table name returns near matches from your environment.</p>

<p>The intended path is <code class="language-plaintext highlighter-rouge">list_tables</code> → <code class="language-plaintext highlighter-rouge">describe_table</code> → <code class="language-plaintext highlighter-rouge">run_sql</code>. An agent that skips discovery and guesses column names gets corrected by <code class="language-plaintext highlighter-rouge">unknown_column</code>, but those round trips are avoidable.</p>

<h2 id="what-the-subset-covers">What the subset covers</h2>

<table>
  <thead>
    <tr>
      <th>Feature</th>
      <th>Supported</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Select</td>
      <td><code class="language-plaintext highlighter-rouge">SELECT</code>, <code class="language-plaintext highlighter-rouge">SELECT DISTINCT</code></td>
    </tr>
    <tr>
      <td>Joins</td>
      <td><code class="language-plaintext highlighter-rouge">INNER JOIN</code>, <code class="language-plaintext highlighter-rouge">LEFT JOIN</code>, across multiple tables</td>
    </tr>
    <tr>
      <td>Filtering</td>
      <td><code class="language-plaintext highlighter-rouge">WHERE</code> with <code class="language-plaintext highlighter-rouge">=</code> <code class="language-plaintext highlighter-rouge">!=</code> <code class="language-plaintext highlighter-rouge">&gt;</code> <code class="language-plaintext highlighter-rouge">&lt;</code> <code class="language-plaintext highlighter-rouge">&gt;=</code> <code class="language-plaintext highlighter-rouge">&lt;=</code> <code class="language-plaintext highlighter-rouge">LIKE</code> <code class="language-plaintext highlighter-rouge">IN</code> <code class="language-plaintext highlighter-rouge">NOT IN</code> <code class="language-plaintext highlighter-rouge">IS NULL</code> <code class="language-plaintext highlighter-rouge">IS NOT NULL</code> <code class="language-plaintext highlighter-rouge">BETWEEN</code> <code class="language-plaintext highlighter-rouge">AND</code> <code class="language-plaintext highlighter-rouge">OR</code>, and nested parentheses</td>
    </tr>
    <tr>
      <td>Aggregation</td>
      <td><code class="language-plaintext highlighter-rouge">GROUP BY</code> with <code class="language-plaintext highlighter-rouge">COUNT</code> <code class="language-plaintext highlighter-rouge">SUM</code> <code class="language-plaintext highlighter-rouge">AVG</code> <code class="language-plaintext highlighter-rouge">MIN</code> <code class="language-plaintext highlighter-rouge">MAX</code></td>
    </tr>
    <tr>
      <td>Sorting</td>
      <td><code class="language-plaintext highlighter-rouge">ORDER BY [ASC\|DESC]</code> over plain columns</td>
    </tr>
    <tr>
      <td>Functions</td>
      <td><code class="language-plaintext highlighter-rouge">DATEADD</code> and <code class="language-plaintext highlighter-rouge">GETUTCDATE</code>, in <code class="language-plaintext highlighter-rouge">WHERE</code> and <code class="language-plaintext highlighter-rouge">ON</code> only</td>
    </tr>
  </tbody>
</table>

<p>Out of scope: <code class="language-plaintext highlighter-rouge">SELECT *</code>, subqueries, CTEs, <code class="language-plaintext highlighter-rouge">HAVING</code>, <code class="language-plaintext highlighter-rouge">UNION</code>, <code class="language-plaintext highlighter-rouge">RIGHT JOIN</code>, <code class="language-plaintext highlighter-rouge">FULL JOIN</code>, <code class="language-plaintext highlighter-rouge">CROSS JOIN</code>, <code class="language-plaintext highlighter-rouge">CASE</code>, <code class="language-plaintext highlighter-rouge">COALESCE</code>, window functions, and string, date, or math functions beyond the two above. <code class="language-plaintext highlighter-rouge">WHERE</code> cannot compare two literals or two columns, and cannot apply a function to a column value. <code class="language-plaintext highlighter-rouge">ORDER BY</code> and <code class="language-plaintext highlighter-rouge">GROUP BY</code> take columns only, including date parts, so <code class="language-plaintext highlighter-rouge">GROUP BY MONTH(createdon)</code> is rejected.</p>

<p>A statement that fits looks like this:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">a</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">contact_count</span>
<span class="k">FROM</span> <span class="n">account</span> <span class="k">AS</span> <span class="n">a</span>
<span class="k">INNER</span> <span class="k">JOIN</span> <span class="n">contact</span> <span class="k">AS</span> <span class="k">c</span> <span class="k">ON</span> <span class="n">a</span><span class="p">.</span><span class="n">accountid</span> <span class="o">=</span> <span class="k">c</span><span class="p">.</span><span class="n">parentcustomerid</span>
<span class="k">WHERE</span> <span class="n">a</span><span class="p">.</span><span class="n">createdon</span> <span class="o">&gt;=</span> <span class="n">DATEADD</span><span class="p">(</span><span class="k">day</span><span class="p">,</span> <span class="o">-</span><span class="mi">30</span><span class="p">,</span> <span class="n">GETUTCDATE</span><span class="p">())</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="n">a</span><span class="p">.</span><span class="n">name</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">a</span><span class="p">.</span><span class="n">name</span>
</code></pre></div></div>

<h2 id="paging-without-a-loop">Paging without a loop</h2>

<p>Set the page size on <strong>Execute SQL</strong> and it becomes <code class="language-plaintext highlighter-rouge">Prefer: odata.maxpagesize</code>. When more rows exist, <code class="language-plaintext highlighter-rouge">hasMore</code> is true and <code class="language-plaintext highlighter-rouge">nextLink</code> carries the continuation — pass it to <strong>Get next page</strong> unchanged. Next links are checked against your environment before they’re followed.</p>

<p><strong>Get next page</strong> returns the same shape, but <code class="language-plaintext highlighter-rouge">sql</code>, <code class="language-plaintext highlighter-rouge">table</code>, and <code class="language-plaintext highlighter-rouge">entitySet</code> come back empty. A next link carries the continuation token, not the statement that produced it, so carry those values forward from the first call if you need them.</p>

<p>To collect several pages in one action, set <strong>Maximum rows</strong>. The connector follows next links itself until it has that many rows, and <code class="language-plaintext highlighter-rouge">stoppedBecause</code> reports which limit ended the run:</p>

<table>
  <thead>
    <tr>
      <th><code class="language-plaintext highlighter-rouge">stoppedBecause</code></th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">complete</code></td>
      <td>No more rows exist</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">page</code></td>
      <td>A single page was requested and more rows remain</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">maxRows</code></td>
      <td>The row budget was reached</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">timeBudget</code></td>
      <td>Paging stopped early to stay inside the script time limit</td>
    </tr>
  </tbody>
</table>

<p>The final page may carry the total slightly past <strong>Maximum rows</strong>, because pages are never split. Whenever paging stops with rows remaining, <code class="language-plaintext highlighter-rouge">nextLink</code> still comes back, so a flow resumes exactly where it left off. Leave <strong>Maximum rows</strong> at 0 for a single page.</p>

<p><code class="language-plaintext highlighter-rouge">TOP</code> and <code class="language-plaintext highlighter-rouge">OFFSET … FETCH</code> are accepted but flagged with a warning. The Dataverse reference lists them as supported syntax while its paging guidance says they aren’t honored. Prefer the page size, or page by filtering on the last id you saw:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">name</span><span class="p">,</span> <span class="n">accountid</span>
<span class="k">FROM</span> <span class="n">account</span>
<span class="k">WHERE</span> <span class="n">accountid</span> <span class="o">&gt;</span> <span class="s1">'00000000-0000-0000-0000-000000000000'</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">accountid</span>
</code></pre></div></div>

<h2 id="throttling-that-outlasts-the-script">Throttling that outlasts the script</h2>

<p>Dataverse enforces <a href="https://learn.microsoft.com/power-apps/developer/data-platform/api-limits">service protection limits</a> by returning <code class="language-plaintext highlighter-rouge">429</code> with a <code class="language-plaintext highlighter-rouge">Retry-After</code> header. The connector waits and retries automatically when that wait is short.</p>

<p>The wait scales with how demanding the previous five minutes were, so it can exceed the two minutes a connector script is allowed to run. When it does, the call returns a <code class="language-plaintext highlighter-rouge">throttled</code> error carrying <code class="language-plaintext highlighter-rouge">retryAfterSeconds</code> rather than being killed mid-wait. Handle it in a flow with a <strong>Delay</strong> of that many seconds followed by a retry.</p>

<p>Other ceilings worth knowing:</p>

<table>
  <thead>
    <tr>
      <th>Limit</th>
      <th>Value</th>
      <th>Effect</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Script execution</td>
      <td>2 minutes</td>
      <td>Bounds auto-paging and throttle retries; both stop early and hand back a next link</td>
    </tr>
    <tr>
      <td>Aggregate evaluation</td>
      <td>50,000 records</td>
      <td>Error <code class="language-plaintext highlighter-rouge">8004E023</code>, reported as <code class="language-plaintext highlighter-rouge">aggregate_limit_exceeded</code></td>
    </tr>
    <tr>
      <td>Page size</td>
      <td>5,000 rows</td>
      <td>Values above this are clamped</td>
    </tr>
    <tr>
      <td>Columns per describe</td>
      <td>250</td>
      <td>Use the search parameter on wide tables</td>
    </tr>
    <tr>
      <td>Tables per list</td>
      <td>1,000 default, 5,000 max</td>
      <td>Adjust with the limit parameter</td>
    </tr>
  </tbody>
</table>

<p>For the aggregate limit, add a filter — typically a date range or a subset of a choice column — and combine the results of several runs.</p>

<h2 id="deploy-it">Deploy it</h2>

<p>Register an app in Microsoft Entra ID, add the <strong>Dynamics CRM → user_impersonation</strong> delegated permission, grant consent, and create a client secret. Add <code class="language-plaintext highlighter-rouge">https://global.consent.azure-apim.net/redirect</code> as a web redirect URI.</p>

<p>Then point the files at your environment. In <code class="language-plaintext highlighter-rouge">apiDefinition.swagger.json</code>, set <code class="language-plaintext highlighter-rouge">host</code> to your environment hostname. In <code class="language-plaintext highlighter-rouge">apiProperties.json</code>, replace <code class="language-plaintext highlighter-rouge">[YOUR_CLIENT_ID]</code> with your application ID and replace <code class="language-plaintext highlighter-rouge">https://org.crm.dynamics.com</code> in both <code class="language-plaintext highlighter-rouge">AzureActiveDirectoryResourceId</code> and <code class="language-plaintext highlighter-rouge">resourceUri</code> with your environment URL. Leave <code class="language-plaintext highlighter-rouge">redirectUrl</code> as <code class="language-plaintext highlighter-rouge">[YOUR_REDIRECT_URL]</code>, since the platform substitutes the real value at deploy time.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">auth</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="nt">--environment</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">your-environment-url</span><span class="err">&gt;</span><span class="w">

</span><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--api-definition-file</span><span class="w"> </span><span class="nx">apiDefinition.swagger.json</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--api-properties-file</span><span class="w"> </span><span class="nx">apiProperties.json</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--script-file</span><span class="w"> </span><span class="nx">script.csx</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">pac</code> has no parameter for the client secret. Paste it in <strong>Power Apps → Custom connectors → Dataverse SQL → Edit → Security</strong>, or add it as <code class="language-plaintext highlighter-rouge">clientSecret</code> inside <code class="language-plaintext highlighter-rouge">oAuthSettings</code> in a temporary copy of <code class="language-plaintext highlighter-rouge">apiProperties.json</code>, deploy from that copy, and delete it afterwards. Never put a secret in the file you keep.</p>

<p>The platform generates a redirect URL unique to this connector and environment. Read it back and register it:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">download</span><span class="w"> </span><span class="nt">--connector-id</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">connector-id</span><span class="err">&gt;</span><span class="w"> </span><span class="nt">--outputDirectory</span><span class="w"> </span><span class="o">.</span><span class="nx">/verify</span><span class="w">
</span></code></pre></div></div>

<p>The value sits at <code class="language-plaintext highlighter-rouge">properties.connectionParameters.token.oAuthSettings.redirectUrl</code>. It encodes the publisher prefix, the connector name, and the environment, with characters escaped — <code class="language-plaintext highlighter-rouge">_</code> becomes <code class="language-plaintext highlighter-rouge">-5f</code> and a space becomes <code class="language-plaintext highlighter-rouge">-20</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://global.consent.azure-apim.net/redirect/new-5fdataverse-20sql-5f&lt;ENVIRONMENT_SUFFIX&gt;
</code></pre></div></div>

<p>Copy the whole value exactly as downloaded rather than assembling it by hand, and add it to the app registration alongside the generic one. OAuth consent fails until it’s registered, and a second environment produces a different URL that also needs registering.</p>

<p>Create a connection, then run <strong>List tables</strong> with no parameters. A list of your tables confirms auth, host, and script are all wired up.</p>

<h2 id="when-something-comes-back-wrong">When something comes back wrong</h2>

<table>
  <thead>
    <tr>
      <th>Symptom</th>
      <th>Cause</th>
      <th>Fix</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">invalid_sql</code> with an <code class="language-plaintext highlighter-rouge">issues</code> list</td>
      <td>The statement uses an unsupported construct</td>
      <td>Each issue names the rule and what to write instead</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">entity_set_in_from</code></td>
      <td>The <code class="language-plaintext highlighter-rouge">FROM</code> clause names an entity set, such as <code class="language-plaintext highlighter-rouge">accounts</code></td>
      <td>Use the table name, <code class="language-plaintext highlighter-rouge">account</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">unknown_table</code></td>
      <td>No such table in this environment</td>
      <td>Use a name from <strong>List tables</strong>; near matches come back in <code class="language-plaintext highlighter-rouge">validValues</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">unknown_column</code></td>
      <td>A column in the <code class="language-plaintext highlighter-rouge">SELECT</code> list does not exist</td>
      <td>Confirm names with <strong>List columns</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">invalid_next_link</code></td>
      <td>The next link was edited, or came from another environment</td>
      <td>Pass it back exactly as returned</td>
    </tr>
    <tr>
      <td>No dynamic content for <code class="language-plaintext highlighter-rouge">rows</code></td>
      <td>The statement is built from an expression</td>
      <td>Expected. Type a literal statement to pick up the fields</td>
    </tr>
    <tr>
      <td>Sign-in fails during connection creation</td>
      <td>The generated redirect URL is not on the app registration</td>
      <td>Register the downloaded value</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">401</code> on every call</td>
      <td>Missing <code class="language-plaintext highlighter-rouge">user_impersonation</code>, or <code class="language-plaintext highlighter-rouge">resourceUri</code> doesn’t match the environment</td>
      <td>Check the permission and both URLs in <code class="language-plaintext highlighter-rouge">apiProperties.json</code></td>
    </tr>
  </tbody>
</table>

<p>Telemetry is off by default. Set <code class="language-plaintext highlighter-rouge">APP_INSIGHTS_ENABLED</code> to <code class="language-plaintext highlighter-rouge">true</code> in <code class="language-plaintext highlighter-rouge">script.csx</code> and replace <code class="language-plaintext highlighter-rouge">APP_INSIGHTS_KEY</code> with your instrumentation key to record executed statements, MCP tool calls, validation failures, and unhandled errors. Telemetry failures never fail an operation.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Dataverse%20SQL">Dataverse SQL connector source</a></li>
  <li><a href="https://learn.microsoft.com/power-apps/developer/data-platform/webapi/query/sql">Use SQL to query data with the Dataverse Web API</a></li>
  <li><a href="https://learn.microsoft.com/power-apps/developer/data-platform/webapi/page-results">Page results</a></li>
  <li><a href="https://learn.microsoft.com/power-apps/developer/data-platform/api-limits">Service protection API limits</a></li>
  <li><a href="https://learn.microsoft.com/power-apps/developer/data-platform/query-antipatterns">Query anti-patterns</a></li>
  <li><a href="https://learn.microsoft.com/connectors/custom-connectors/write-code">Write code in a custom connector</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="MCP" /><category term="Dataverse" /><category term="SQL" /><category term="Custom Connectors" /><category term="MCP" /><category term="Copilot Studio" /><category term="Power Automate" /><category term="OAuth" /><summary type="html"><![CDATA[Dataverse SQL is a Power Platform custom connector that wraps the Dataverse Web API sql query option with typed actions, design-time column resolution, auto-paging, and an MCP endpoint for Copilot Studio agents.]]></summary></entry><entry><title type="html">Fabric ontology in one custom MCP connector</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-22-fabric-ontology-custom-connector.html" rel="alternate" type="text/html" title="Fabric ontology in one custom MCP connector" /><published>2026-09-22T13:00:00+00:00</published><updated>2026-09-22T13:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/mcp/fabric-ontology-custom-connector</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-22-fabric-ontology-custom-connector.html"><![CDATA[<p>Microsoft Fabric ontology (preview) ships two things worth connecting to: a REST API for managing ontology items and their definitions, and an MCP server that exposes one ontology to an agent. <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Fabric%20Ontology">Fabric Ontology</a> puts both in a single Power Platform custom connector, on one host, one app registration, and one connection.</p>

<table>
  <thead>
    <tr>
      <th>Surface</th>
      <th>What it does</th>
      <th>Who uses it</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>REST API</td>
      <td>Create, list, read, update, and delete ontology items and their definitions</td>
      <td>Power Automate flows, makers, ALM pipelines</td>
    </tr>
    <tr>
      <td>MCP server</td>
      <td>Exposes one ontology item to a Copilot Studio agent over Model Context Protocol</td>
      <td>Copilot Studio generative orchestration</td>
    </tr>
  </tbody>
</table>

<h2 id="why-the-two-surfaces-belong-together">Why the two surfaces belong together</h2>

<p>The ontology MCP endpoint is addressed per item:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://api.fabric.microsoft.com/v1/mcp/dataPlane/workspaces/{workspaceId}/items/{ontologyItemId}/ontologyEndpoint
</code></pre></div></div>

<p>Those GUIDs have to be literals in the swagger path. An operation carrying <code class="language-plaintext highlighter-rouge">x-ms-agentic-protocol</code> must not declare a <code class="language-plaintext highlighter-rouge">parameters</code> key, and Copilot Studio rejects the operation if one is present. The MCP side is pinned to a single ontology as a result.</p>

<p>That’s where the REST operations start paying for themselves. <code class="language-plaintext highlighter-rouge">List Workspaces</code> and <code class="language-plaintext highlighter-rouge">List Ontologies</code> hand you the two GUIDs to paste in, with dropdowns, instead of reading them out of a portal URL. After setup they keep working for definition round-trips, ALM, and provisioning.</p>

<h2 id="fourteen-operations">Fourteen operations</h2>

<p>Seven cover ontology items themselves:</p>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>Method</th>
      <th>Path</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>List ontologies</td>
      <td>GET</td>
      <td><code class="language-plaintext highlighter-rouge">/v1/workspaces/{workspaceId}/ontologies</code></td>
    </tr>
    <tr>
      <td>Create ontology</td>
      <td>POST</td>
      <td><code class="language-plaintext highlighter-rouge">/v1/workspaces/{workspaceId}/ontologies</code></td>
    </tr>
    <tr>
      <td>Get ontology</td>
      <td>GET</td>
      <td><code class="language-plaintext highlighter-rouge">/v1/workspaces/{workspaceId}/ontologies/{ontologyId}</code></td>
    </tr>
    <tr>
      <td>Update ontology</td>
      <td>PATCH</td>
      <td><code class="language-plaintext highlighter-rouge">/v1/workspaces/{workspaceId}/ontologies/{ontologyId}</code></td>
    </tr>
    <tr>
      <td>Delete ontology</td>
      <td>DELETE</td>
      <td><code class="language-plaintext highlighter-rouge">/v1/workspaces/{workspaceId}/ontologies/{ontologyId}</code></td>
    </tr>
    <tr>
      <td>Get ontology definition</td>
      <td>POST</td>
      <td><code class="language-plaintext highlighter-rouge">/v1/workspaces/{workspaceId}/ontologies/{ontologyId}/getDefinition</code></td>
    </tr>
    <tr>
      <td>Update ontology definition</td>
      <td>POST</td>
      <td><code class="language-plaintext highlighter-rouge">/v1/workspaces/{workspaceId}/ontologies/{ontologyId}/updateDefinition</code></td>
    </tr>
  </tbody>
</table>

<p>Six more exist to feed them. <code class="language-plaintext highlighter-rouge">List Workspaces</code>, <code class="language-plaintext highlighter-rouge">List Folders</code>, <code class="language-plaintext highlighter-rouge">List Lakehouses</code>, and <code class="language-plaintext highlighter-rouge">List Lakehouse Tables</code> populate dropdowns and supply data binding sources. <code class="language-plaintext highlighter-rouge">Get Operation State</code> and <code class="language-plaintext highlighter-rouge">Get Operation Result</code> poll long running operations.</p>

<p>The fourteenth is <code class="language-plaintext highlighter-rouge">Invoke Fabric Ontology MCP</code>, the JSON-RPC endpoint above.</p>

<h2 id="dropdowns-instead-of-guid-copy-paste">Dropdowns instead of GUID copy-paste</h2>

<p>Each dependent picker passes its parent through <code class="language-plaintext highlighter-rouge">x-ms-dynamic-values.parameters</code>, so choosing a workspace refilters everything below it:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>List Workspaces ──▶ workspaceId
                      ├──▶ List Ontologies  ──▶ ontologyId
                      ├──▶ List Folders     ──▶ rootFolderId
                      └──▶ List Lakehouses  ──▶ lakehouseId ──▶ List Lakehouse Tables
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">List Lakehouse Tables</code> is the exception. Table names belong in the body of an entity type data binding, at <code class="language-plaintext highlighter-rouge">sourceTableProperties.sourceTableName</code>, and Swagger 2.0 dynamic values can’t populate a nested body property inside an array. Run it as its own action and read the names off the result.</p>

<h2 id="definitions-arrive-readable">Definitions arrive readable</h2>

<p>Fabric returns and accepts ontology definitions as base64 parts:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w"> </span><span class="nl">"path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"definition.json"</span><span class="p">,</span><span class="w"> </span><span class="nl">"payload"</span><span class="p">:</span><span class="w"> </span><span class="s2">"e30="</span><span class="p">,</span><span class="w"> </span><span class="nl">"payloadType"</span><span class="p">:</span><span class="w"> </span><span class="s2">"InlineBase64"</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">script.csx</code> removes the encode and decode steps on both sides. On read, each part gains a <code class="language-plaintext highlighter-rouge">payloadText</code> field holding the decoded JSON, with the original <code class="language-plaintext highlighter-rouge">payload</code> preserved. A part whose payload isn’t valid base64 is left alone rather than failing the call. On write, supply readable JSON in <code class="language-plaintext highlighter-rouge">payloadText</code> and the script encodes it into <code class="language-plaintext highlighter-rouge">payload</code> and sets <code class="language-plaintext highlighter-rouge">payloadType</code>.</p>

<p>A definition round-trip becomes: get definition, edit <code class="language-plaintext highlighter-rouge">payloadText</code>, update definition. No base64 actions in the flow.</p>

<h2 id="a-202-with-an-empty-body-is-still-useful">A 202 with an empty body is still useful</h2>

<p><code class="language-plaintext highlighter-rouge">Create Ontology</code>, <code class="language-plaintext highlighter-rouge">Get Ontology Definition</code>, and <code class="language-plaintext highlighter-rouge">Update Ontology Definition</code> can return <strong>202</strong> with nothing in the body and everything in the headers. Power Automate can’t read those headers off a connector response, so the script builds a body from them:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Accepted"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"operationId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0acd697c-1550-43cd-b998-91bfbfbd47c6"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"location"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://api.fabric.microsoft.com/v1/operations/0acd697c-1550-43cd-b998-91bfbfbd47c6"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"retryAfter"</span><span class="p">:</span><span class="w"> </span><span class="mi">30</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Poll <code class="language-plaintext highlighter-rouge">Get Operation State</code> with that <code class="language-plaintext highlighter-rouge">operationId</code> until <code class="language-plaintext highlighter-rouge">status</code> is <code class="language-plaintext highlighter-rouge">Succeeded</code>, then call <code class="language-plaintext highlighter-rouge">Get Operation Result</code>.</p>

<p>One detail matters if you fork the script. In some regions Power Platform delivers <code class="language-plaintext highlighter-rouge">Context.OperationId</code> base64 encoded, so the script handles both forms. Drop that decoding and the definition and LRO transformations quietly stop running in affected regions while calls still return 200.</p>

<h2 id="the-scope-that-separates-ontology-from-data-agents">The scope that separates ontology from data agents</h2>

<p>Register an Entra app for a single tenant and add these delegated permissions, then grant admin consent:</p>

<table>
  <thead>
    <tr>
      <th>Scope</th>
      <th>Needed for</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Item.ReadWrite.All</code></td>
      <td>Create, update, and both definition operations</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Item.Read.All</code></td>
      <td>Get ontology, and item metadata reads during MCP <code class="language-plaintext highlighter-rouge">tools/list</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Item.Execute.All</code></td>
      <td>The ontology MCP data-plane endpoint</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Workspace.Read.All</code></td>
      <td>List workspaces, ontologies, lakehouses, and folders</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Lakehouse.Read.All</code></td>
      <td>List lakehouse tables</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">Item.Execute.All</code> is easy to miss. A Fabric data agent uses <code class="language-plaintext highlighter-rouge">DataAgent.Execute.All</code>; ontology and Power BI semantic model endpoints use <code class="language-plaintext highlighter-rouge">Item.Execute.All</code>. Leave it out and every REST operation works while the MCP endpoint returns 403.</p>

<p>Two more auth settings decide whether connections behave. Set the OAuth tenant to your tenant GUID rather than <code class="language-plaintext highlighter-rouge">common</code>, which causes intermittent per-user failures against the Fabric data-plane endpoints. For on-behalf-of single sign-on, preauthorize <strong>Azure API Connections</strong> (<code class="language-plaintext highlighter-rouge">fe053c5f-3692-4f14-aef2-ee34fc081cae</code>) under <strong>Expose an API</strong> → <strong>Authorized client applications</strong> on an <code class="language-plaintext highlighter-rouge">access_as_user</code> scope.</p>

<p>The connector requests <code class="language-plaintext highlighter-rouge">https://api.fabric.microsoft.com/.default</code>, so it inherits whatever is consented on the app registration. Adding a scope later means re-consenting <em>and</em> recreating connections, because a connection caches its token audience.</p>

<h2 id="deploy-it">Deploy it</h2>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">cd</span><span class="w"> </span><span class="s2">"Fabric Ontology"</span><span class="w">

</span><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--environment</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">YOUR_ENVIRONMENT_ID</span><span class="err">&gt;</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--api-definition-file</span><span class="w"> </span><span class="nx">apiDefinition.swagger.json</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--api-properties-file</span><span class="w"> </span><span class="nx">apiProperties.json</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--script-file</span><span class="w"> </span><span class="nx">script.csx</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">--script-file</code> is required. The connector declares <code class="language-plaintext highlighter-rouge">scriptOperations</code>, and omitting the script fails deployment with <code class="language-plaintext highlighter-rouge">InvalidScriptDefinitionUrlWithNonNullOperations</code>.</p>

<p>A returned connector ID means the record was written, not that the definition arrived intact. Download it back and confirm 14 operations, the dynamic dropdowns, and a matching <code class="language-plaintext highlighter-rouge">script.csx</code>:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">download</span><span class="w"> </span><span class="nt">--connector-id</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">id</span><span class="err">&gt;</span><span class="w"> </span><span class="nt">--outputDirectory</span><span class="w"> </span><span class="o">.</span><span class="nx">/verify</span><span class="w">
</span></code></pre></div></div>

<p>Read the generated redirect URL from that download at <code class="language-plaintext highlighter-rouge">properties.connectionParameters.token.oAuthSettings.redirectUrl</code> and register it on the app registration. The URL encodes both the connector name and the environment, so dev and prod produce two different values and both need registering.</p>

<p>Then point the MCP operation at a real ontology. Create a connection, run <code class="language-plaintext highlighter-rouge">List Workspaces</code> and <code class="language-plaintext highlighter-rouge">List Ontologies</code> in the Test tab, and replace <code class="language-plaintext highlighter-rouge">WORKSPACE_ID</code> and <code class="language-plaintext highlighter-rouge">ONTOLOGY_ITEM_ID</code> in the MCP path before redeploying. Both GUIDs also appear in the Fabric portal URL when the ontology is open:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://app.fabric.microsoft.com/groups/&lt;workspace-ID&gt;/ontologies/&lt;ontology-item-ID&gt;
</code></pre></div></div>

<h2 id="test-the-mcp-side-before-wiring-an-agent">Test the MCP side before wiring an agent</h2>

<p>In the Test tab with <strong>Raw Body</strong> on, send an initialize call:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"jsonrpc"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2.0"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
  </span><span class="nl">"method"</span><span class="p">:</span><span class="w"> </span><span class="s2">"initialize"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"params"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"protocolVersion"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2024-11-05"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"capabilities"</span><span class="p">:</span><span class="w"> </span><span class="p">{},</span><span class="w">
    </span><span class="nl">"clientInfo"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"power-platform-test"</span><span class="p">,</span><span class="w"> </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1.0.0"</span><span class="w"> </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Expect HTTP 200 with <code class="language-plaintext highlighter-rouge">result.protocolVersion</code> and <code class="language-plaintext highlighter-rouge">result.serverInfo</code>, then follow with <code class="language-plaintext highlighter-rouge">tools/list</code>. The operation ID is not an MCP method: <code class="language-plaintext highlighter-rouge">InvokeMCP</code> is the Power Platform operation, while <code class="language-plaintext highlighter-rouge">method</code> in the body must be <code class="language-plaintext highlighter-rouge">initialize</code>, <code class="language-plaintext highlighter-rouge">tools/call</code>, or <code class="language-plaintext highlighter-rouge">tools/list</code>.</p>

<p>In Copilot Studio, add the connector as a tool, select <strong>Invoke Fabric Ontology MCP</strong>, enable generative orchestration, and publish. Keep the tool on user authentication. Ontology honors Fabric RBAC, and a shared maker connection would show every user the maker’s view of the data.</p>

<h2 id="what-it-cant-do">What it can’t do</h2>

<p>One connector serves one ontology on the MCP side. Ten ontologies means ten connectors, or ten copies of the MCP operation with different literal paths. The REST operations stay fully parameterized and cover every ontology in the tenant from one connection.</p>

<p>There are no unattended MCP scenarios either. Delegated auth means an autonomous agent fired by a schedule has no signed-in user and no identity for the data-plane call. Service principals and managed identities work for the REST operations, so ALM automation is fine.</p>

<p>Ontology items, the ontology MCP server, and the lakehouse tables API are all in preview, with no SLA and endpoint shapes that can change.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Fabric%20Ontology">Fabric Ontology connector source</a></li>
  <li><a href="https://learn.microsoft.com/rest/api/fabric/ontology/items">Ontology items REST API</a></li>
  <li><a href="https://learn.microsoft.com/rest/api/fabric/articles/item-management/definitions/ontology-definition">Ontology definition structure</a></li>
  <li><a href="https://learn.microsoft.com/fabric/iq/ontology/how-to-use-ontology-mcp-server">Consume ontology as an MCP server</a></li>
  <li><a href="https://learn.microsoft.com/rest/api/fabric/articles/scopes">Fabric REST API scopes</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-copilot-studio/advanced-custom-connector-on-behalf-of">Configure OBO authentication for custom connectors</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="MCP" /><category term="Microsoft Fabric" /><category term="Ontology" /><category term="Custom Connectors" /><category term="MCP" /><category term="Copilot Studio" /><category term="Power Automate" /><category term="OAuth" /><category term="OBO" /><summary type="html"><![CDATA[Fabric Ontology is a Power Platform custom connector that pairs the ontology REST API with the ontology MCP endpoint, decodes base64 definition payloads, and turns 202 responses into readable bodies.]]></summary></entry><entry><title type="html">Read Dataverse hierarchies in one call with the Dataverse Catalog Tree MCP connector</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-18-dataverse-catalog-tree-mcp-connector.html" rel="alternate" type="text/html" title="Read Dataverse hierarchies in one call with the Dataverse Catalog Tree MCP connector" /><published>2026-09-18T13:00:00+00:00</published><updated>2026-09-18T13:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/mcp/dataverse-catalog-tree-mcp-connector</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-18-dataverse-catalog-tree-mcp-connector.html"><![CDATA[<p>Ask an agent to walk a product catalog, an org chart, or a territory hierarchy in Dataverse and it will get the first two levels right and then start making things up. That isn’t a model quality problem. It’s what happens when a tree has to be assembled through a generic query tool.</p>

<p><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Dataverse%20Catalog%20Tree">Dataverse Catalog Tree</a> is a custom MCP connector for recursive 1:N hierarchies in Dataverse. It serves two consumers from one traversal engine.</p>

<table>
  <thead>
    <tr>
      <th>Audience</th>
      <th>Surface</th>
      <th>What they get</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Copilot Studio</td>
      <td>MCP endpoint at <code class="language-plaintext highlighter-rouge">/mcp</code></td>
      <td>Nine catalog tools over JSON-RPC 2.0</td>
    </tr>
    <tr>
      <td>Power Automate, Power Apps</td>
      <td>Typed operations</td>
      <td>Named actions with schemas, IntelliSense, and dynamic dropdowns</td>
    </tr>
  </tbody>
</table>

<h2 id="why-chaining-produces-hallucinations">Why chaining produces hallucinations</h2>

<p>Traversing a tree through a generic query tool means the model chains calls, and at every hop it supplies a relationship name, a lookup column, and a GUID. Each of those is a chance to invent something plausible. By the fourth hop the model is reasoning about records it never read.</p>

<p>The worse failure is quieter. A branch that was never expanded looks exactly like a branch that ends. The agent reports a leaf node with total confidence, and nothing in the response contradicts it.</p>

<p>This connector removes the chaining. One call returns the finished subtree, assembled server-side, with explicit accounting for anything left out.</p>

<h2 id="two-shapes-both-supported">Two shapes, both supported</h2>

<table>
  <thead>
    <tr>
      <th>Shape</th>
      <th>Example</th>
      <th>Configuration</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Self-referencing, one table pointing at itself</td>
      <td><code class="language-plaintext highlighter-rouge">Category</code> with a <code class="language-plaintext highlighter-rouge">Parent Category</code> lookup</td>
      <td>None. The connector finds the self-referencing relationship in metadata.</td>
    </tr>
    <tr>
      <td>Heterogeneous levels, a different table per level</td>
      <td><code class="language-plaintext highlighter-rouge">Category</code> &gt; <code class="language-plaintext highlighter-rouge">Subcategory</code> &gt; <code class="language-plaintext highlighter-rouge">Product</code></td>
      <td>Pass <code class="language-plaintext highlighter-rouge">levels</code> as an ordered relationship path, such as <code class="language-plaintext highlighter-rouge">tst_category_subcategories&gt;tst_subcategory_products</code>.</td>
    </tr>
  </tbody>
</table>

<p>Call <code class="language-plaintext highlighter-rouge">describe_catalog</code> once and it returns the exact <code class="language-plaintext highlighter-rouge">levelsPath</code> string to use. Paste that into the agent instructions and the value is fixed from then on.</p>

<p>If you get to choose the data model, use a self-referencing table and mark the relationship hierarchical. That unlocks the single-query strategy below and makes arbitrary depth free.</p>

<h2 id="three-traversal-strategies-chosen-automatically">Three traversal strategies, chosen automatically</h2>

<p><code class="language-plaintext highlighter-rouge">strategy=auto</code> picks the cheapest correct engine. Force one when you’re testing.</p>

<table>
  <thead>
    <tr>
      <th>Strategy</th>
      <th>Requests</th>
      <th>When <code class="language-plaintext highlighter-rouge">auto</code> picks it</th>
      <th>Trade-off</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">hierarchy</code></td>
      <td>1 data call for the entire subtree, any depth</td>
      <td>Self-referencing relationship marked hierarchical, and a root id was supplied</td>
      <td>Can’t apply a <code class="language-plaintext highlighter-rouge">filter</code>. Dataverse caps hierarchical recursion at 100 levels.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">levels</code></td>
      <td>One batched call per level</td>
      <td>Everything else, the general case</td>
      <td>Costs a call per level, but keeps full control of ordering, paging, and limits.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">expand</code></td>
      <td>1 call</td>
      <td>Never automatically. Opt in.</td>
      <td>Dataverse forbids <code class="language-plaintext highlighter-rouge">$top</code> and <code class="language-plaintext highlighter-rouge">$orderby</code> anywhere in a query containing a nested one-to-many <code class="language-plaintext highlighter-rouge">$expand</code>, so result size can’t be bounded.</td>
    </tr>
  </tbody>
</table>

<p>The <code class="language-plaintext highlighter-rouge">levels</code> engine batches by level, not by node. All parents at a depth go out in one request using a chunked <code class="language-plaintext highlighter-rouge">or</code> filter, so a 500-node tree four levels deep costs about five calls instead of 500.</p>

<h2 id="what-stops-the-hallucinations">What stops the hallucinations</h2>

<p><strong>One call, no chaining.</strong> <code class="language-plaintext highlighter-rouge">get_catalog_tree</code> returns the whole subtree. The model never assembles a path across turns.</p>

<p><strong>Errors name the valid values.</strong> An unknown relationship returns the real ones, so the model corrects itself from data instead of guessing again:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"error"</span><span class="p">:</span><span class="w"> </span><span class="s2">"unknown_relationship"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"'product_lines' is not a relationship from 'tst_category'."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"providedValue"</span><span class="p">:</span><span class="w"> </span><span class="s2">"product_lines"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"validValues"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w"> </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tst_category_subcategories"</span><span class="p">,</span><span class="w"> </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Children in tst_subcategory via tst_parentcategoryid"</span><span class="w"> </span><span class="p">}</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"hint"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Retry with one of these relationship schema names."</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Unexpanded branches are counted, not hidden.</strong> Every node carries <code class="language-plaintext highlighter-rouge">childCount</code> and <code class="language-plaintext highlighter-rouge">hasMoreChildren</code>. When depth runs out, one extra batched query counts the children at the boundary, so the agent says “12 more below Seattle” instead of inferring a leaf.</p>

<p><strong>Truncation is explicit.</strong> <code class="language-plaintext highlighter-rouge">stats.truncated</code> and <code class="language-plaintext highlighter-rouge">stats.truncationReason</code> (<code class="language-plaintext highlighter-rouge">depthLimit</code>, <code class="language-plaintext highlighter-rouge">nodeBudget</code>, <code class="language-plaintext highlighter-rouge">pageLimit</code>, <code class="language-plaintext highlighter-rouge">rootLimit</code>) mean an incomplete tree can’t pass for a complete one. Exhausting the per-invocation Dataverse call budget is deliberately not a truncation. It raises a <code class="language-plaintext highlighter-rouge">request_budget_exceeded</code> error, because a tree that stopped growing for an internal reason should never be presented as data.</p>

<p><strong>Identity flows one way.</strong> Every node returns <code class="language-plaintext highlighter-rouge">id</code>, <code class="language-plaintext highlighter-rouge">table</code>, and <code class="language-plaintext highlighter-rouge">path</code>. Write operations validate GUIDs and reject anything malformed with <code class="language-plaintext highlighter-rouge">invalid_id</code>, so a fabricated identifier fails loudly instead of silently targeting nothing.</p>

<p><strong><code class="language-plaintext highlighter-rouge">search_catalog</code> grounds names in records.</strong> It resolves user-typed text to real ids with full paths, which is the step that belongs before any traversal or write.</p>

<p><strong>Cycles can’t run away.</strong> A visited-set guards traversal, <code class="language-plaintext highlighter-rouge">move_catalog_node</code> refuses a parent that sits below the node, and <code class="language-plaintext highlighter-rouge">cyclesDetected</code> reports anything skipped.</p>

<h2 id="the-outline-format-is-the-one-to-ask-for">The outline format is the one to ask for</h2>

<p>Set <code class="language-plaintext highlighter-rouge">format</code> to <code class="language-plaintext highlighter-rouge">tree</code> (default), <code class="language-plaintext highlighter-rouge">flat</code>, <code class="language-plaintext highlighter-rouge">outline</code>, or <code class="language-plaintext highlighter-rouge">all</code>. Indented text with ids costs a fraction of the tokens of nested JSON and gives the model nothing to reassemble:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Contoso Catalog Root (account, bdce4b86-dcb2-f111-aaae-70a8a5b2ff44)
  Contoso East (account, c3ce4b86-dcb2-f111-aaae-70a8a5b2ff44)
    Contoso East - Boston (account, 99005488-dcb2-f111-aaae-6045bd061c5d)
  Contoso West (account, cda82982-dcb2-f111-aaae-6045bd061c5d)
    Contoso West - Seattle (account, c8ce4b86-dcb2-f111-aaae-70a8a5b2ff44) [3 more children not expanded]
</code></pre></div></div>

<p>The full response carries the definition and the traversal stats alongside the roots:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"definition"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"mode"</span><span class="p">:</span><span class="w"> </span><span class="s2">"selfReference"</span><span class="p">,</span><span class="w"> </span><span class="nl">"hierarchical"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w"> </span><span class="nl">"levelsPath"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tst_category_parent"</span><span class="p">,</span><span class="w"> </span><span class="nl">"levels"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="p">]</span><span class="w"> </span><span class="p">},</span><span class="w">
  </span><span class="nl">"stats"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"nodeCount"</span><span class="p">:</span><span class="w"> </span><span class="mi">7</span><span class="p">,</span><span class="w"> </span><span class="nl">"maxDepthReached"</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span><span class="w"> </span><span class="nl">"depthRequested"</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="p">,</span><span class="w">
    </span><span class="nl">"truncated"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w"> </span><span class="nl">"truncationReason"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w"> </span><span class="nl">"cyclesDetected"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
    </span><span class="nl">"strategy"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hierarchy"</span><span class="p">,</span><span class="w"> </span><span class="nl">"dataverseRequests"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="nl">"elapsedMs"</span><span class="p">:</span><span class="w"> </span><span class="mi">380</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"roots"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"..."</span><span class="p">,</span><span class="w"> </span><span class="nl">"label"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Contoso Catalog Root"</span><span class="p">,</span><span class="w"> </span><span class="nl">"childCount"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="nl">"hasMoreChildren"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w"> </span><span class="nl">"children"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="p">]</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">stats</code> is there so a person debugging a flow can see which engine ran and what it cost, without turning on telemetry.</p>

<h2 id="nine-operations">Nine operations</h2>

<table>
  <thead>
    <tr>
      <th>MCP tool</th>
      <th>Typed operation</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">describe_catalog</code></td>
      <td><code class="language-plaintext highlighter-rouge">GET /catalog/describe</code></td>
      <td>Read the hierarchy shape from metadata and get the exact <code class="language-plaintext highlighter-rouge">levelsPath</code>.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">get_catalog_tree</code></td>
      <td><code class="language-plaintext highlighter-rouge">GET /catalog/tree</code></td>
      <td>Return a whole subtree. The main operation.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">get_node_children</code></td>
      <td><code class="language-plaintext highlighter-rouge">GET /catalog/children</code></td>
      <td>One level of children, for drilling into a truncated branch.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">get_node_ancestors</code></td>
      <td><code class="language-plaintext highlighter-rouge">GET /catalog/ancestors</code></td>
      <td>Breadcrumb from a node up to its root.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">search_catalog</code></td>
      <td><code class="language-plaintext highlighter-rouge">GET /catalog/search</code></td>
      <td>Find nodes by name across levels, with full paths.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">create_catalog_node</code></td>
      <td><code class="language-plaintext highlighter-rouge">POST /catalog/node</code></td>
      <td>Create a record and attach it to a parent.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">update_catalog_node</code></td>
      <td><code class="language-plaintext highlighter-rouge">POST /catalog/node/update</code></td>
      <td>Rename a node or set other columns. Parent lookups are rejected here.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">move_catalog_node</code></td>
      <td><code class="language-plaintext highlighter-rouge">POST /catalog/node/move</code></td>
      <td>Reparent a node, with a cycle guard.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">delete_catalog_node</code></td>
      <td><code class="language-plaintext highlighter-rouge">POST /catalog/node/delete</code></td>
      <td>Delete a node, with explicit child handling.</td>
    </tr>
  </tbody>
</table>

<p>Two more operations, <code class="language-plaintext highlighter-rouge">GET /metadata/tables</code> and <code class="language-plaintext highlighter-rouge">GET /metadata/childrelationships</code>, populate the dynamic dropdowns in Power Automate.</p>

<h2 id="deleting-refuses-rather-than-improvises">Deleting refuses rather than improvises</h2>

<p>A destructive operation is the worst place for an agent to fill in a blank. <code class="language-plaintext highlighter-rouge">onChildren</code> controls what happens when the node isn’t a leaf:</p>

<table>
  <thead>
    <tr>
      <th>Mode</th>
      <th>Behavior</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">refuse</code> (default)</td>
      <td>Fails with <code class="language-plaintext highlighter-rouge">node_has_children</code>, reporting the exact count and offering the other two modes in <code class="language-plaintext highlighter-rouge">validValues</code>. Nothing changes.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">reparent</code></td>
      <td>Attaches the children to the node’s own parent, or detaches them to become roots if the node was a root, then deletes the node.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cascade</code></td>
      <td>Deletes the node and every descendant, deepest first.</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">reparent</code> and <code class="language-plaintext highlighter-rouge">cascade</code> need a self-referencing catalog. For heterogeneous levels they return <code class="language-plaintext highlighter-rouge">child_handling_unavailable</code>, because the children of a node live in a different table from that node’s parent.</p>

<p>Two more refusals protect against half-finished destruction. A cascade whose subtree exceeds <code class="language-plaintext highlighter-rouge">maxNodes</code> raises <code class="language-plaintext highlighter-rouge">subtree_too_large</code>, and one that would run past the 80-call budget raises <code class="language-plaintext highlighter-rouge">delete_budget_exceeded</code>. Both are checked before any record is deleted.</p>

<h2 id="limits">Limits</h2>

<p>Defaults are conservative, and every one is a parameter.</p>

<table>
  <thead>
    <tr>
      <th>Limit</th>
      <th>Default</th>
      <th>Maximum</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">depth</code></td>
      <td>3</td>
      <td>10</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">maxNodes</code></td>
      <td>500</td>
      <td>5000</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">rootTop</code>, roots returned when no <code class="language-plaintext highlighter-rouge">id</code> is given</td>
      <td>50</td>
      <td>500</td>
    </tr>
    <tr>
      <td>Children per request</td>
      <td> </td>
      <td>500</td>
    </tr>
    <tr>
      <td>Dataverse calls per invocation</td>
      <td> </td>
      <td>80</td>
    </tr>
    <tr>
      <td>Parent ids per batched filter</td>
      <td>25</td>
      <td> </td>
    </tr>
  </tbody>
</table>

<p>Custom connector code has to finish within two minutes and the script has to stay under 1 MB. Both are comfortable here, since the <code class="language-plaintext highlighter-rouge">hierarchy</code> strategy costs two calls regardless of tree size.</p>

<h2 id="three-dataverse-web-api-behaviors-worth-recording">Three Dataverse Web API behaviors worth recording</h2>

<p>Each of these was confirmed by request rather than by documentation.</p>

<p>A nested <code class="language-plaintext highlighter-rouge">$expand</code> on a collection-valued 1:N does work, to at least three nested levels. The documented restriction to many-to-one applies to N:N relationships.</p>

<p>It’s rejected on a key-addressed URL. <code class="language-plaintext highlighter-rouge">accounts(&lt;id&gt;)?$expand=children($expand=children(...))</code> returns “Only many-to-one relationships are supported for nested expansion”, while the same expand against <code class="language-plaintext highlighter-rouge">accounts?$filter=accountid eq &lt;id&gt;</code> succeeds. The <code class="language-plaintext highlighter-rouge">expand</code> strategy uses the collection form for this reason.</p>

<p><code class="language-plaintext highlighter-rouge">$top</code> and <code class="language-plaintext highlighter-rouge">$orderby</code> are forbidden anywhere in a query containing a nested one-to-many <code class="language-plaintext highlighter-rouge">$expand</code>, including at the top level. That’s why <code class="language-plaintext highlighter-rouge">expand</code> is never the automatic choice: the result size can’t be bounded.</p>

<h2 id="deploy-it">Deploy it</h2>

<p>Set <code class="language-plaintext highlighter-rouge">host</code> in <code class="language-plaintext highlighter-rouge">apiDefinition.swagger.json</code> to your environment, such as <code class="language-plaintext highlighter-rouge">contoso.crm.dynamics.com</code>. It ships as the placeholder <code class="language-plaintext highlighter-rouge">org.crm.dynamics.com</code>. Set <code class="language-plaintext highlighter-rouge">AzureActiveDirectoryResourceId</code> and <code class="language-plaintext highlighter-rouge">resourceUri</code> in <code class="language-plaintext highlighter-rouge">apiProperties.json</code> to the matching <code class="language-plaintext highlighter-rouge">https://&lt;org&gt;.crm.dynamics.com</code> value, and replace <code class="language-plaintext highlighter-rouge">[YOUR_CLIENT_ID]</code> with your Entra app registration client id. Leave <code class="language-plaintext highlighter-rouge">[YOUR_REDIRECT_URL]</code> alone, because the platform generates and overwrites it on deploy.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-definition-file</span><span class="w"> </span><span class="o">.</span><span class="nx">\apiDefinition.swagger.json</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-properties-file</span><span class="w"> </span><span class="o">.</span><span class="nx">\apiProperties.json</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--script-file</span><span class="w"> </span><span class="o">.</span><span class="nx">\script.csx</span><span class="w">
</span></code></pre></div></div>

<p>Then read the generated redirect URL back and register it on the app registration, or OAuth consent fails:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">download</span><span class="w"> </span><span class="nt">--connector-id</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">id</span><span class="err">&gt;</span><span class="w"> </span><span class="nt">--outputDirectory</span><span class="w"> </span><span class="o">.</span><span class="nx">/verify</span><span class="w">
</span><span class="c"># properties.connectionParameters.token.oAuthSettings.redirectUrl</span><span class="w">
</span></code></pre></div></div>

<p>That URL encodes both the connector name and the environment, so dev and prod produce two different URLs and both need registering.</p>

<h2 id="using-it-from-copilot-studio">Using it from Copilot Studio</h2>

<p>Add the connector to the agent as an MCP server and Copilot Studio lists the tools automatically. Add it once. If the same connector is added both as an MCP server and as individual connector actions, the agent sees two overlapping sets of the same capability, and wrong-tool selection is the failure mode this design exists to prevent.</p>

<p>An agent instruction that matches how the tools are built:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>The catalog is stored in Dataverse. Use search_catalog to turn any name the user mentions
into a record id before doing anything else. Use get_catalog_tree with format=outline to
read structure. Never invent ids, table names or relationship names. If a tool returns
validValues, retry with one of them. If a node reports hasMoreChildren, say how many
children were not shown rather than listing or guessing them.
</code></pre></div></div>

<p>In Power Automate the typed operations show up as ordinary actions. <code class="language-plaintext highlighter-rouge">GetTree</code> with <code class="language-plaintext highlighter-rouge">format=flat</code> returns an array that feeds straight into Apply to each.</p>

<h2 id="verified-behavior">Verified behavior</h2>

<p>The connector was built and tested against a live Dataverse environment using a four-level self-referencing account hierarchy and a three-level <code class="language-plaintext highlighter-rouge">account</code> &gt; <code class="language-plaintext highlighter-rouge">contact</code> &gt; <code class="language-plaintext highlighter-rouge">task</code> chain: 76 assertions covering both shapes, all three strategies, depth and node-budget truncation, boundary child counts, search path resolution, ancestor breadcrumbs, create, rename, reparent, all three delete modes, cycle rejection on move, MCP protocol handling, and every structured error path. It was then deployed with <code class="language-plaintext highlighter-rouge">pac connector create</code> to confirm it compiles in the connector runtime.</p>

<p>Telemetry is off by default. Set <code class="language-plaintext highlighter-rouge">APP_INSIGHTS_ENABLED</code> to <code class="language-plaintext highlighter-rouge">true</code> and fill in <code class="language-plaintext highlighter-rouge">APP_INSIGHTS_KEY</code> in <code class="language-plaintext highlighter-rouge">script.csx</code> to turn it on. Events cover MCP tool calls, structured catalog errors, and unhandled exceptions. Telemetry failures are swallowed and never affect the operation.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Dataverse%20Catalog%20Tree">Dataverse Catalog Tree connector source</a></li>
  <li><a href="https://learn.microsoft.com/power-apps/developer/data-platform/query-hierarchical-data">Query hierarchical data</a></li>
  <li><a href="https://learn.microsoft.com/power-apps/maker/data-platform/define-query-hierarchical-data">Define and query hierarchically related data</a></li>
  <li><a href="https://learn.microsoft.com/connectors/custom-connectors/write-code">Write code in a custom connector</a></li>
  <li><a href="https://modelcontextprotocol.io/">Model Context Protocol specification</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="MCP" /><category term="Dataverse" /><category term="Custom Connectors" /><category term="MCP" /><category term="Copilot Studio" /><category term="Power Automate" /><category term="Hierarchy" /><summary type="html"><![CDATA[Dataverse Catalog Tree is a custom MCP connector that returns a whole Dataverse subtree in one call, with explicit accounting for anything it left out, so agents stop inventing branches.]]></summary></entry><entry><title type="html">Reach the Brightspace Valence API from Power Automate and Copilot Studio with one connector</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-16-brightspace-custom-connector.html" rel="alternate" type="text/html" title="Reach the Brightspace Valence API from Power Automate and Copilot Studio with one connector" /><published>2026-09-16T17:30:00+00:00</published><updated>2026-09-16T17:30:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/mcp/brightspace-custom-connector</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-16-brightspace-custom-connector.html"><![CDATA[<p>A learning management system holds the answers to most operational questions a school has. Who’s enrolled, who submitted, who’s failing, which course shells exist for next term. Getting at those answers from Power Automate has meant hand-rolling HTTP calls against the D2L Valence API, guessing at versions, and re-solving paging every time.</p>

<p><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Brightspace">Brightspace</a> is a custom connector that covers both audiences from one definition.</p>

<table>
  <thead>
    <tr>
      <th>Audience</th>
      <th>Surface</th>
      <th>What they get</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Power Automate, Power Apps</td>
      <td>30 typed REST actions</td>
      <td>Named actions with real schemas, IntelliSense, and field pickers</td>
    </tr>
    <tr>
      <td>Copilot Studio</td>
      <td>MCP endpoint at <code class="language-plaintext highlighter-rouge">/mcp</code></td>
      <td><code class="language-plaintext highlighter-rouge">scan</code> / <code class="language-plaintext highlighter-rouge">launch</code> / <code class="language-plaintext highlighter-rouge">sequence</code> over the entire Brightspace API, plus version and paging helpers</td>
    </tr>
  </tbody>
</table>

<p>The REST half covers the operations people build flows against. The MCP half isn’t limited to those. <code class="language-plaintext highlighter-rouge">launch_brightspace</code> calls any Brightspace route, so an agent never stalls on an operation nobody thought to declare in the Swagger.</p>

<h2 id="three-things-to-collect-before-you-deploy">Three things to collect before you deploy</h2>

<p><strong>The host name.</strong> Brightspace has no fixed tenant host pattern. It’s whatever hostname your institution runs on, such as <code class="language-plaintext highlighter-rouge">school.brightspace.com</code>.</p>

<p><strong>An OAuth 2.0 application registration.</strong> Create it in <strong>Admin Tools</strong> &gt; <strong>Manage Extensibility</strong> &gt; <strong>OAuth 2.0</strong> &gt; <strong>Register an app</strong>. Pick the <strong>Authorization grant</strong> workflow, enable <strong>Prompt for user consent</strong>, and enable <strong>refresh tokens</strong>. Skipping refresh tokens breaks every connection the moment the access token expires. Brightspace returns a Client ID and Client Secret.</p>

<p>Brightspace’s documentation never mentions PKCE, so don’t assume it’s supported. This connector uses a confidential client with a client secret, matching the documented Authorization Code Grant.</p>

<p><strong>The scopes</strong> the registration is allowed to request. More on those below.</p>

<h3 id="the-redirect-uri-is-a-chicken-and-egg-step">The redirect URI is a chicken-and-egg step</h3>

<p>Power Platform generates the redirect URL when the connector is created, so you can’t register it in Brightspace up front. Deploy first with the <code class="language-plaintext highlighter-rouge">[YOUR_REDIRECT_URL]</code> placeholder in place, then read the generated value back:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">download</span><span class="w"> </span><span class="nt">--connector-id</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">id</span><span class="err">&gt;</span><span class="w"> </span><span class="nt">--outputDirectory</span><span class="w"> </span><span class="o">.</span><span class="nx">/verify</span><span class="w">
</span><span class="c"># properties.connectionParameters.token.oAuthSettings.redirectUrl</span><span class="w">
</span></code></pre></div></div>

<p>The result looks like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://global.consent.azure-apim.net/redirect/new-5fbrightspace-5ff5dc2f63c87a6469
</code></pre></div></div>

<p>That URL encodes both the connector name and the environment, so a connector deployed to dev and prod produces two different redirect URLs. Register both on the Brightspace application.</p>

<h2 id="core-is-not-a-master-key"><code class="language-plaintext highlighter-rouge">core:*:*</code> is not a master key</h2>

<p>Brightspace scopes are <code class="language-plaintext highlighter-rouge">&lt;resource-group&gt;:&lt;resource&gt;:&lt;action&gt;</code>, space-delimited, with <code class="language-plaintext highlighter-rouge">*</code> and comma wildcards (<code class="language-plaintext highlighter-rouge">users:userdata:create,read,update</code>, no spaces around the commas).</p>

<p><code class="language-plaintext highlighter-rouge">core:*:*</code> reads like an admin scope and isn’t one. It’s the general fallback, and it only covers API actions that have no specific scope assigned yet. Any action that does have a specific scope still needs that scope explicitly. Ship both: the fallback for the unscoped remainder, and the specific scopes for everything else.</p>

<p>The connector’s default set is read-focused, with write access to grades and announcements:</p>

<table>
  <thead>
    <tr>
      <th>Area</th>
      <th>Read</th>
      <th>Write</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Users</td>
      <td><code class="language-plaintext highlighter-rouge">users:userdata:read</code> <code class="language-plaintext highlighter-rouge">users:profile:read</code> <code class="language-plaintext highlighter-rouge">users:own_profile:read</code></td>
      <td><code class="language-plaintext highlighter-rouge">users:userdata:create,update,delete</code></td>
    </tr>
    <tr>
      <td>Organization</td>
      <td><code class="language-plaintext highlighter-rouge">organizations:organization:read</code></td>
      <td> </td>
    </tr>
    <tr>
      <td>Courses, org structure</td>
      <td><code class="language-plaintext highlighter-rouge">orgunits:course:read</code> <code class="language-plaintext highlighter-rouge">managecourses:courses:read</code></td>
      <td><code class="language-plaintext highlighter-rouge">orgunits:course:create,update,delete</code> <code class="language-plaintext highlighter-rouge">managecourses:courses:write</code></td>
    </tr>
    <tr>
      <td>Enrollments</td>
      <td><code class="language-plaintext highlighter-rouge">enrollment:own_enrollment:read</code> <code class="language-plaintext highlighter-rouge">enrollment:orgunit:read</code></td>
      <td><code class="language-plaintext highlighter-rouge">enrollment:orgunit:create,delete</code></td>
    </tr>
    <tr>
      <td>Grades</td>
      <td><code class="language-plaintext highlighter-rouge">grades:gradeobjects:read</code> <code class="language-plaintext highlighter-rouge">grades:gradevalues:read</code> <code class="language-plaintext highlighter-rouge">grades:gradecategories:read</code></td>
      <td><code class="language-plaintext highlighter-rouge">grades:gradevalues:write</code> <code class="language-plaintext highlighter-rouge">grades:gradeobjects:write</code></td>
    </tr>
    <tr>
      <td>Content</td>
      <td><code class="language-plaintext highlighter-rouge">content:toc:read</code> <code class="language-plaintext highlighter-rouge">content:modules:readonly</code> <code class="language-plaintext highlighter-rouge">content:topics:readonly</code></td>
      <td><code class="language-plaintext highlighter-rouge">content:topics:manage</code> <code class="language-plaintext highlighter-rouge">content:modules:manage</code></td>
    </tr>
    <tr>
      <td>Assignments</td>
      <td><code class="language-plaintext highlighter-rouge">dropbox:folders:read</code></td>
      <td><code class="language-plaintext highlighter-rouge">dropbox:folders:write</code></td>
    </tr>
    <tr>
      <td>Announcements</td>
      <td><code class="language-plaintext highlighter-rouge">news:newsitems:read</code></td>
      <td><code class="language-plaintext highlighter-rouge">news:newsitems:manage</code></td>
    </tr>
    <tr>
      <td>Discussions</td>
      <td><code class="language-plaintext highlighter-rouge">discussions:forums:readonly</code> <code class="language-plaintext highlighter-rouge">discussions:topics:readonly</code> <code class="language-plaintext highlighter-rouge">discussions:posts:readonly</code></td>
      <td><code class="language-plaintext highlighter-rouge">discussions:posts:manage</code></td>
    </tr>
    <tr>
      <td>Quizzes</td>
      <td><code class="language-plaintext highlighter-rouge">quizzing:quizzes:read</code> <code class="language-plaintext highlighter-rouge">quizzing:attempts:read</code></td>
      <td><code class="language-plaintext highlighter-rouge">quizzing:quizzes:write</code></td>
    </tr>
  </tbody>
</table>

<p>Add the write scopes you need to both <code class="language-plaintext highlighter-rouge">apiProperties.json</code> and the Brightspace registration. A token can only request scopes the registration already holds.</p>

<p>Scopes gate which API actions the token may call. They don’t widen what the user can do, because the signed-in user’s role in each org unit still decides that. A <code class="language-plaintext highlighter-rouge">403</code> almost always means a permissions problem rather than a malformed request.</p>

<h2 id="versions-are-pinned-to-the-oldest-supported-release">Versions are pinned to the oldest supported release</h2>

<p>Brightspace versions each product component separately, and the version sits in the path: <code class="language-plaintext highlighter-rouge">/d2l/api/{component}/{version}/{path}</code>.</p>

<table>
  <thead>
    <tr>
      <th>Component</th>
      <th>Covers</th>
      <th>Pinned version</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">lp</code></td>
      <td>Learning Platform: users, org structure, courses, enrollments</td>
      <td><strong>1.49</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">le</code></td>
      <td>Learning Environment: grades, content, assignments, discussions, quizzes, news</td>
      <td><strong>1.82</strong></td>
    </tr>
  </tbody>
</table>

<p>Both pin to the oldest version D2L still supports, so the connector works on any current tenant. Later contract versions are supersets, so raise a version only when you need a route added after that point.</p>

<p>REST actions expose <code class="language-plaintext highlighter-rouge">version</code> as an advanced path parameter, pre-filled with the pinned default. MCP tools read it from the <code class="language-plaintext highlighter-rouge">LP_VERSION</code> and <code class="language-plaintext highlighter-rouge">LE_VERSION</code> constants at the top of <code class="language-plaintext highlighter-rouge">script.csx</code>, so changing it there shifts every tool at once. To see what your tenant supports, call the <strong>Get API Versions</strong> action or have the agent call <code class="language-plaintext highlighter-rouge">check_brightspace_versions</code>.</p>

<h2 id="30-typed-actions-and-only-three-of-them-run-through-the-script">30 typed actions, and only three of them run through the script</h2>

<p>The typed actions cover users, org structure, courses, enrollments, grades, content, assignments, announcements, discussions, and quizzes:</p>

<table>
  <thead>
    <tr>
      <th>Action</th>
      <th>Route</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Get API Versions</td>
      <td><code class="language-plaintext highlighter-rouge">GET /d2l/api/versions/</code></td>
    </tr>
    <tr>
      <td>Get Current User</td>
      <td><code class="language-plaintext highlighter-rouge">GET /lp/{version}/users/whoami</code></td>
    </tr>
    <tr>
      <td>List Users</td>
      <td><code class="language-plaintext highlighter-rouge">GET /lp/{version}/users/</code></td>
    </tr>
    <tr>
      <td>Find User By Username</td>
      <td><code class="language-plaintext highlighter-rouge">GET /lp/{version}/users/?userName=</code></td>
    </tr>
    <tr>
      <td>Find Users By Org Defined ID</td>
      <td><code class="language-plaintext highlighter-rouge">GET /lp/{version}/users/?orgDefinedId=</code></td>
    </tr>
    <tr>
      <td>Get Org Unit Descendants</td>
      <td><code class="language-plaintext highlighter-rouge">GET /lp/{version}/orgstructure/{orgUnitId}/descendants/paged/</code></td>
    </tr>
    <tr>
      <td>Get My Enrollments</td>
      <td><code class="language-plaintext highlighter-rouge">GET /lp/{version}/enrollments/myenrollments/</code></td>
    </tr>
    <tr>
      <td>Enroll User</td>
      <td><code class="language-plaintext highlighter-rouge">POST /lp/{version}/enrollments/</code></td>
    </tr>
    <tr>
      <td>Get Grade Objects</td>
      <td><code class="language-plaintext highlighter-rouge">GET /le/{version}/{orgUnitId}/grades/</code></td>
    </tr>
    <tr>
      <td>Set Grade Value</td>
      <td><code class="language-plaintext highlighter-rouge">PUT /le/{version}/{orgUnitId}/grades/{gradeObjectId}/values/{userId}</code></td>
    </tr>
    <tr>
      <td>Get Assignment Submissions</td>
      <td><code class="language-plaintext highlighter-rouge">GET /le/{version}/{orgUnitId}/dropbox/folders/{folderId}/submissions/</code></td>
    </tr>
    <tr>
      <td>Create Announcement</td>
      <td><code class="language-plaintext highlighter-rouge">POST /le/{version}/{orgUnitId}/news/</code></td>
    </tr>
    <tr>
      <td>Get Quizzes</td>
      <td><code class="language-plaintext highlighter-rouge">GET /le/{version}/{orgUnitId}/quizzes/</code></td>
    </tr>
  </tbody>
</table>

<p>That’s a sample. The <a href="https://github.com/troystaylor/SharingIsCaring/blob/main/Brightspace/readme.md">readme</a> lists all 30.</p>

<p>Only three operations run through <code class="language-plaintext highlighter-rouge">script.csx</code>: <code class="language-plaintext highlighter-rouge">InvokeMCP</code>, which is the MCP server itself, and the two user-lookup actions. Everything else passes straight through to Brightspace, because the Swagger paths are the real Brightspace paths. There’s nothing to transform, and a script hop would only add a failure mode.</p>

<p>The two user-lookup actions exist because <code class="language-plaintext highlighter-rouge">GET /users/</code> changes its response shape depending on which query parameter it receives:</p>

<table>
  <thead>
    <tr>
      <th>Parameter</th>
      <th>Response</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">userName</code></td>
      <td>A single user object</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">orgDefinedId</code> or <code class="language-plaintext highlighter-rouge">externalEmail</code></td>
      <td>A plain array of users</td>
    </tr>
    <tr>
      <td>none, or <code class="language-plaintext highlighter-rouge">bookmark</code></td>
      <td>A bookmark-paged result set</td>
    </tr>
  </tbody>
</table>

<p>Precedence is <code class="language-plaintext highlighter-rouge">orgDefinedId</code>, then <code class="language-plaintext highlighter-rouge">userName</code>, then <code class="language-plaintext highlighter-rouge">externalEmail</code>, then <code class="language-plaintext highlighter-rouge">bookmark</code>, regardless of the order in the URL. One Swagger operation can’t describe three shapes honestly, so the connector declares three operations with accurate response schemas and the script maps the lookup variants back onto the real route.</p>

<h2 id="five-mcp-tools-cover-57-operations">Five MCP tools cover 57 operations</h2>

<p>Add the connector to a Copilot Studio agent as an MCP tool and it exposes five tools:</p>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">scan_brightspace</code></td>
      <td>Find the right operation from a natural-language intent. Always call first.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">launch_brightspace</code></td>
      <td>Execute any Brightspace route, with <code class="language-plaintext highlighter-rouge">{placeholder}</code> segments filled in.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">sequence_brightspace</code></td>
      <td>Run up to 20 operations in one call.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">check_brightspace_versions</code></td>
      <td>Compare the pinned versions against what the tenant supports.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">follow_brightspace_page</code></td>
      <td>Fetch the next page of a <code class="language-plaintext highlighter-rouge">Next</code>/<code class="language-plaintext highlighter-rouge">Objects</code> response.</td>
    </tr>
  </tbody>
</table>

<p>The capability index embedded in the script describes 57 operations across ten domains. Scanning that index costs a few hundred tokens instead of the tens of thousands that 57 typed tools would add to every agent turn. <code class="language-plaintext highlighter-rouge">launch_brightspace</code> isn’t restricted to the index either. It calls any route and warns when the endpoint is unrecognized, so the index serves discovery rather than enforcement.</p>

<p>Here’s how an agent answers “what’s the average grade on the midterm in BIO-101?”:</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">scan_brightspace("find a course by name")</code> returns <code class="language-plaintext highlighter-rouge">get_org_unit_descendants</code></li>
  <li><code class="language-plaintext highlighter-rouge">launch_brightspace</code> on <code class="language-plaintext highlighter-rouge">/lp/1.49/orgstructure/6606/descendants/paged/?ouTypeId=3</code> finds BIO-101 as org unit <code class="language-plaintext highlighter-rouge">7421</code></li>
  <li><code class="language-plaintext highlighter-rouge">scan_brightspace("grade items in a course")</code> returns <code class="language-plaintext highlighter-rouge">list_grade_objects</code></li>
  <li><code class="language-plaintext highlighter-rouge">launch_brightspace</code> on <code class="language-plaintext highlighter-rouge">/le/1.82/7421/grades/</code> finds the midterm as grade object <code class="language-plaintext highlighter-rouge">19</code></li>
  <li><code class="language-plaintext highlighter-rouge">launch_brightspace</code> on <code class="language-plaintext highlighter-rouge">/le/1.82/7421/grades/19/values/</code> returns grades, paged by <code class="language-plaintext highlighter-rouge">Next</code></li>
  <li><code class="language-plaintext highlighter-rouge">follow_brightspace_page</code> runs until <code class="language-plaintext highlighter-rouge">Next</code> is empty</li>
</ol>

<h2 id="brightspace-has-two-paging-schemes">Brightspace has two paging schemes</h2>

<p>They’re not interchangeable, and picking the wrong one silently returns one page.</p>

<p><strong>Bookmark paging</strong> returns a <code class="language-plaintext highlighter-rouge">PagedResultSet</code>:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"PagingInfo"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"Bookmark"</span><span class="p">:</span><span class="w"> </span><span class="s2">"..."</span><span class="p">,</span><span class="w"> </span><span class="nl">"HasMoreItems"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span><span class="p">},</span><span class="w">
  </span><span class="nl">"Items"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="err">...</span><span class="w"> </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Pass <code class="language-plaintext highlighter-rouge">PagingInfo.Bookmark</code> back as the <code class="language-plaintext highlighter-rouge">bookmark</code> query parameter and stop when <code class="language-plaintext highlighter-rouge">HasMoreItems</code> is <code class="language-plaintext highlighter-rouge">false</code>. List Users, Get My Enrollments, Get Org Unit Enrollments, Get Org Unit Children, and Get Org Unit Descendants use this scheme.</p>

<p><strong>URL paging</strong> returns an <code class="language-plaintext highlighter-rouge">ObjectListPage</code>:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"Next"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"Objects"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="err">...</span><span class="w"> </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Follow the <code class="language-plaintext highlighter-rouge">Next</code> URL verbatim, since it already carries the filters you used, and stop when <code class="language-plaintext highlighter-rouge">Next</code> is empty. Get Quizzes, the paged classlist, and per-grade-item values use this scheme.</p>

<p>Agents should use <code class="language-plaintext highlighter-rouge">follow_brightspace_page</code> for the second scheme. It rejects any URL pointing at a different host, so a returned URL can’t redirect the bearer token somewhere else.</p>

<p>Get Classlist is unbounded and returns every member of a course in one response. For large courses, prefer Get Org Unit Enrollments, which is bookmark-paged.</p>

<h2 id="conventions-worth-knowing-before-the-first-403">Conventions worth knowing before the first <code class="language-plaintext highlighter-rouge">403</code></h2>

<table>
  <thead>
    <tr>
      <th>Topic</th>
      <th>Detail</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Identifiers</td>
      <td><code class="language-plaintext highlighter-rouge">D2LID</code> values are positive 64-bit integers. Passing a non-numeric value into a route segment can make Brightspace match the wrong route handler, not just fail.</td>
    </tr>
    <tr>
      <td>Org units</td>
      <td>A course is an org unit. So is a department, a semester, and the organization itself. <code class="language-plaintext highlighter-rouge">orgUnitId</code> is used throughout.</td>
    </tr>
    <tr>
      <td>Dates</td>
      <td>UTC ISO 8601 with milliseconds: <code class="language-plaintext highlighter-rouge">yyyy-MM-ddTHH:mm:ss.fffZ</code>, every element zero-padded. Some fields are Unix timestamps instead.</td>
    </tr>
    <tr>
      <td>Rich text in</td>
      <td><code class="language-plaintext highlighter-rouge">{ "Content": "...", "Type": "Text" }</code> where <code class="language-plaintext highlighter-rouge">Type</code> is <code class="language-plaintext highlighter-rouge">Text</code> or <code class="language-plaintext highlighter-rouge">Html</code></td>
    </tr>
    <tr>
      <td>Rich text out</td>
      <td><code class="language-plaintext highlighter-rouge">{ "Text": "...", "Html": "..." }</code>, a different shape from the input</td>
    </tr>
    <tr>
      <td>Rate limits</td>
      <td>Token-bucket. Watch <code class="language-plaintext highlighter-rouge">X-Rate-Limit-Remaining</code>, <code class="language-plaintext highlighter-rouge">X-Request-Cost</code>, and <code class="language-plaintext highlighter-rouge">Retry-After</code>. A <code class="language-plaintext highlighter-rouge">429</code> means the bucket is empty, and the MCP layer retries those automatically.</td>
    </tr>
    <tr>
      <td>Errors</td>
      <td>RFC 7807 problem details: <code class="language-plaintext highlighter-rouge">{ "type", "status", "title", "detail", "instance" }</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">404</code> isn’t always missing</td>
      <td>It can also mean the tool isn’t enabled for that tenant. Not every deployment has quizzes or ePortfolio.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">403</code> versus empty</td>
      <td>Most routes return <code class="language-plaintext highlighter-rouge">403</code> when the user can’t see anything. <code class="language-plaintext highlighter-rouge">GET /quizzes/</code> returns an empty page instead. Handle both.</td>
    </tr>
    <tr>
      <td>Unknown query params</td>
      <td>Silently ignored rather than rejected, so a typo fails quietly.</td>
    </tr>
  </tbody>
</table>

<h2 id="deploy-it">Deploy it</h2>

<p>Replace the host placeholder in <code class="language-plaintext highlighter-rouge">apiDefinition.swagger.json</code> with your institution’s hostname:</p>

<div class="language-jsonc highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"host"</span><span class="p">:</span><span class="w"> </span><span class="s2">"[YOUR_INSTITUTION].brightspace.com"</span><span class="w">   </span><span class="c1">// -&gt; "school.brightspace.com"</span><span class="w">
</span></code></pre></div></div>

<p>This is the only place the host appears. The script derives the API base URL from the incoming request, so MCP tools automatically target the same host. You can’t deploy without replacing it, because square brackets aren’t legal in a hostname and Power Platform rejects the definition:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Error: ApiHubsRequestFailed
Invalid Api definition object. Please specify a valid Swagger 2.0 Url and valid list of ServiceUrls.
</code></pre></div></div>

<p>That failure is intentional. Deployment stops loudly rather than publishing a connector pointed at nothing.</p>

<p>Then replace <code class="language-plaintext highlighter-rouge">[YOUR_CLIENT_ID]</code> in <code class="language-plaintext highlighter-rouge">apiProperties.json</code>, and supply the client secret at deploy time or in the portal <strong>Security</strong> tab. Never commit it.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">auth</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="nt">--environment</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">environment-id</span><span class="err">&gt;</span><span class="w">

</span><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-definition-file</span><span class="w"> </span><span class="o">.</span><span class="nx">/apiDefinition.swagger.json</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-properties-file</span><span class="w"> </span><span class="o">.</span><span class="nx">/apiProperties.json</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--script-file</span><span class="w"> </span><span class="o">.</span><span class="nx">/script.csx</span><span class="w">
</span></code></pre></div></div>

<p>Validate first with <code class="language-plaintext highlighter-rouge">ppcv ./Brightspace</code>. Two of its warnings are expected: it reports unbalanced braces on <code class="language-plaintext highlighter-rouge">script.csx</code>, because its brace counter doesn’t understand C# verbatim strings, and it flags most operations as absent from <code class="language-plaintext highlighter-rouge">scriptOperations</code>, which is the deliberate pass-through design described above.</p>

<p>Deployment was verified on PAC CLI 2.11.2 against a live environment, then downloaded and compared. All 31 operations deployed, <code class="language-plaintext highlighter-rouge">x-ms-agentic-protocol</code> survived as <code class="language-plaintext highlighter-rouge">mcp-streamable-1.0</code>, <code class="language-plaintext highlighter-rouge">script.csx</code> round-tripped byte for byte, and all 24 OAuth scopes and both auth URLs were preserved. The platform rewrote <code class="language-plaintext highlighter-rouge">redirectMode</code> from <code class="language-plaintext highlighter-rouge">Global</code> to <code class="language-plaintext highlighter-rouge">GlobalPerConnector</code> and replaced <code class="language-plaintext highlighter-rouge">redirectUrl</code> with the generated per-connector URL, which is exactly the behavior the redirect step above depends on.</p>

<p>Telemetry is off by default. Set <code class="language-plaintext highlighter-rouge">APP_INSIGHTS_CONNECTION_STRING</code> near the top of <code class="language-plaintext highlighter-rouge">script.csx</code> to turn it on. Telemetry failures are swallowed, so they never break a call.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Brightspace">Brightspace connector source</a></li>
  <li><a href="https://docs.valence.desire2learn.com/reference.html">Brightspace API reference</a></li>
  <li><a href="https://docs.valence.desire2learn.com/basic/apicall.html">Calling conventions, paging, and rate limits</a></li>
  <li><a href="https://docs.valence.desire2learn.com/basic/oauth2.html">OAuth 2.0 and scopes</a></li>
  <li><a href="https://docs.valence.desire2learn.com/http-scopestable.html">Scopes index</a></li>
  <li><a href="https://docs.valence.desire2learn.com/basic/version.html">API versioning</a></li>
  <li><a href="https://learn.microsoft.com/connectors/custom-connectors/write-code">Write code in a custom connector</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="MCP" /><category term="Brightspace" /><category term="D2L" /><category term="Valence" /><category term="Custom Connectors" /><category term="MCP" /><category term="Copilot Studio" /><category term="Power Automate" /><category term="OAuth" /><summary type="html"><![CDATA[Brightspace is a custom connector that gives Power Automate 30 typed actions against the D2L Valence API and gives Copilot Studio an MCP endpoint that can call any Brightspace route.]]></summary></entry><entry><title type="html">Call a Copilot Studio agent from Power Automate, including GitHub Copilot harness agents</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-14-copilot-studio-agent-runtime-connector.html" rel="alternate" type="text/html" title="Call a Copilot Studio agent from Power Automate, including GitHub Copilot harness agents" /><published>2026-09-14T16:30:00+00:00</published><updated>2026-09-14T16:30:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/mcp/copilot-studio-agent-runtime-connector</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-14-copilot-studio-agent-runtime-connector.html"><![CDATA[<p>Power Automate can’t consume a stream. The Copilot Studio agentic runtime only speaks one, and the supported Copilot Studio client library doesn’t cover agents built on the GitHub Copilot harness at all.</p>

<p><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Studio%20Agent%20Runtime">Copilot Studio Agent Runtime</a> closes that gap. The connector opens a conversation with a published agent, reads the entire server-sent event stream, consolidates it, and returns one completed JSON answer. A flow gets a finished reply. A Copilot Studio agent gets a Model Context Protocol (MCP) tool it can call to delegate work to another agent.</p>

<h2 id="a-data-plane-connector-not-another-control-plane-one">A data plane connector, not another control plane one</h2>

<p>The other Copilot Studio connectors in the repository manage agents. This one runs them.</p>

<table>
  <thead>
    <tr>
      <th>Connector</th>
      <th>Plane</th>
      <th>What it does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Studio%20Bots">Copilot Studio Bots</a></td>
      <td>Control</td>
      <td>Evaluate, quarantine, inventory, migrate identity</td>
    </tr>
    <tr>
      <td><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Studio%20Analytics">Copilot Studio Analytics</a></td>
      <td>Reporting</td>
      <td>Read transcripts and session records after the fact</td>
    </tr>
    <tr>
      <td><strong>Copilot Studio Agent Runtime</strong></td>
      <td><strong>Data</strong></td>
      <td><strong>Open a conversation and get an answer</strong></td>
    </tr>
  </tbody>
</table>

<p>The two pair well. <code class="language-plaintext highlighter-rouge">List Agents</code> in Copilot Studio Bots returns <code class="language-plaintext highlighter-rouge">schemaName</code> and <code class="language-plaintext highlighter-rouge">isCLIAgent</code>, which tell you which agent to call here and whether it runs on the GitHub Copilot harness.</p>

<h2 id="the-endpoint-is-undocumented">The endpoint is undocumented</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST https://{environment-host}/copilotstudio/agenticruntime/3p/dataverse-backed/
     authenticated/bots/{schemaName}/conversations?api-version=1

POST https://{environment-host}/copilotstudio/agenticruntime/3p/dataverse-backed/
     authenticated/bots/{schemaName}/conversations/{conversationId}?api-version=1
</code></pre></div></div>

<p>This route doesn’t appear on Microsoft Learn and carries no compatibility guarantee. Microsoft’s position today is that the Copilot Studio client library “can only be used with agents created by using the standard harness,” and that “agents using the GitHub Copilot harness aren’t yet officially supported.” The <code class="language-plaintext highlighter-rouge">/3p</code> agentic runtime route is how Microsoft’s own experimental Copilot Studio plugin reaches those agents.</p>

<p>Treat it as experimental and keep a fallback.</p>

<p>The <code class="language-plaintext highlighter-rouge">agenticruntime</code> prefix and the <code class="language-plaintext highlighter-rouge">3p</code> third-party segment separate this from the classic Direct-to-Engine route at <code class="language-plaintext highlighter-rouge">/copilotstudio/dataverse-backed/</code> with <code class="language-plaintext highlighter-rouge">api-version=2022-03-01-preview</code>, which <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Power%20Agent%20Desktop">Power Agent Desktop</a> and <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Power%20Agent%20Tray">Power Agent Tray</a> use.</p>

<h2 id="what-the-agent-and-app-registration-need">What the agent and app registration need</h2>

<p>The agent must be published, because draft agents don’t resolve. It must use <strong>Authenticate with Microsoft</strong>, which is what the <code class="language-plaintext highlighter-rouge">/authenticated/</code> path segment means. And it must be shared with the signed-in user, or the call returns <code class="language-plaintext highlighter-rouge">403</code>.</p>

<p>The app registration needs the <strong>Power Platform API</strong> delegated permission <code class="language-plaintext highlighter-rouge">CopilotStudio.Copilots.Invoke</code> with admin consent granted, plus a web redirect URI matching the connector’s generated consent URL.</p>

<p>Sign-in is delegated only. The <code class="language-plaintext highlighter-rouge">/authenticated/</code> route rejects app-only client credential tokens and answers with <code class="language-plaintext highlighter-rouge">S2SDirectEngineRequiresNoAuthentication</code>. Service-to-service Direct-to-Engine exists, but it’s a private preview and applies only to <strong>No Authentication</strong> agents. Every connection here is a user sign-in.</p>

<h2 id="derive-the-environment-host">Derive the environment host</h2>

<p>The connector reads the host from the connection rather than hardcoding it. Take your environment GUID, strip the hyphens, and split after the 30th character:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$environmentId</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"0a1b2c3d-4e5f-6071-8293-a4b5c6d7e809"</span><span class="w">
</span><span class="nv">$id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$environmentId</span><span class="o">.</span><span class="nf">Replace</span><span class="p">(</span><span class="s2">"-"</span><span class="p">,</span><span class="w"> </span><span class="s2">""</span><span class="p">)</span><span class="o">.</span><span class="nf">ToLower</span><span class="p">()</span><span class="w">
</span><span class="s2">"</span><span class="si">$(</span><span class="nv">$id</span><span class="o">.</span><span class="nf">Substring</span><span class="p">(</span><span class="nx">0</span><span class="p">,</span><span class="w"> </span><span class="nx">30</span><span class="p">)</span><span class="s2">).</span><span class="si">$(</span><span class="nv">$id</span><span class="o">.</span><span class="nf">Substring</span><span class="p">(</span><span class="nx">30</span><span class="p">)</span><span class="s2">).environment.api.powerplatform.com"</span><span class="w">

</span><span class="c"># 0a1b2c3d4e5f60718293a4b5c6d7e8.09.environment.api.powerplatform.com</span><span class="w">
</span></code></pre></div></div>

<p>Enter the hostname only, with no <code class="language-plaintext highlighter-rouge">https://</code> and no trailing slash. Non-production clouds use <code class="language-plaintext highlighter-rouge">api.test.</code>, <code class="language-plaintext highlighter-rouge">api.preprod.</code>, or <code class="language-plaintext highlighter-rouge">api.dev.</code> suffixes and work the same way. Sovereign clouds aren’t verified — the agentic runtime may not be deployed there.</p>

<p>The schema name looks like <code class="language-plaintext highlighter-rouge">cr1a2_myAgent</code>. Read it from the agent’s <strong>Settings</strong> page, from the direct connection URL in channel settings, or from <code class="language-plaintext highlighter-rouge">List Agents</code> in Copilot Studio Bots.</p>

<h2 id="four-rest-actions-three-mcp-tools">Four REST actions, three MCP tools</h2>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Ask Agent</strong></td>
      <td>Opens a conversation, sends a message, returns the finished answer</td>
    </tr>
    <tr>
      <td><strong>Start Conversation</strong></td>
      <td>Opens a conversation and returns its ID plus any greeting</td>
    </tr>
    <tr>
      <td><strong>Send Message</strong></td>
      <td>Sends a turn to an existing conversation and returns the finished reply</td>
    </tr>
    <tr>
      <td><strong>Invoke Copilot Studio Agent Runtime MCP</strong></td>
      <td>The JSON-RPC endpoint — it shows in the action list, but skip it in a flow</td>
    </tr>
  </tbody>
</table>

<p>Use <strong>Ask Agent</strong> for stateless work. Use <strong>Start Conversation</strong> followed by <strong>Send Message</strong> when the agent needs to remember earlier turns. Both message operations accept an optional BCP 47 locale such as <code class="language-plaintext highlighter-rouge">en-GB</code>.</p>

<p>The MCP tools mirror the same three operations:</p>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Arguments</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ask_agent</code></td>
      <td><code class="language-plaintext highlighter-rouge">schemaName</code>*, <code class="language-plaintext highlighter-rouge">text</code>*, <code class="language-plaintext highlighter-rouge">locale</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">start_conversation</code></td>
      <td><code class="language-plaintext highlighter-rouge">schemaName</code>*, <code class="language-plaintext highlighter-rouge">emitStartConversationEvent</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">send_message</code></td>
      <td><code class="language-plaintext highlighter-rouge">schemaName</code>*, <code class="language-plaintext highlighter-rouge">conversationId</code>*, <code class="language-plaintext highlighter-rouge">text</code>*, <code class="language-plaintext highlighter-rouge">locale</code></td>
    </tr>
  </tbody>
</table>

<p>* required</p>

<p><code class="language-plaintext highlighter-rouge">emitStartConversationEvent</code> defaults to <code class="language-plaintext highlighter-rouge">true</code>. Set it to <code class="language-plaintext highlighter-rouge">false</code> to suppress the greeting.</p>

<p>This is what makes agent-to-agent invocation work. A Copilot Studio agent can call a GitHub Copilot harness agent as a tool, which no supported channel currently offers.</p>

<h2 id="consolidating-the-stream-takes-two-rules">Consolidating the stream takes two rules</h2>

<p>The runtime replies with <code class="language-plaintext highlighter-rouge">text/event-stream</code>. Power Platform can’t surface a stream to a flow, so the connector reads the whole thing and consolidates it.</p>

<p>Naive concatenation breaks, because the runtime streams in two shapes depending on the agent and client:</p>

<table>
  <thead>
    <tr>
      <th>Shape</th>
      <th>What arrives</th>
      <th>How it’s handled</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Cumulative</strong></td>
      <td>Each <code class="language-plaintext highlighter-rouge">typing</code> activity repeats everything so far</td>
      <td>Keep only the last one</td>
    </tr>
    <tr>
      <td><strong>Delta</strong></td>
      <td>Each <code class="language-plaintext highlighter-rouge">typing</code> activity carries a new fragment</td>
      <td>Concatenate in order</td>
    </tr>
    <tr>
      <td><strong>Final</strong></td>
      <td>A <code class="language-plaintext highlighter-rouge">message</code> activity with <code class="language-plaintext highlighter-rouge">streamType: "final"</code></td>
      <td>Authoritative — wins over both</td>
    </tr>
  </tbody>
</table>

<p>The connector detects the shape by testing whether each chunk begins with the previous one. Guessing wrong is visible in the output either way: treating cumulative text as deltas repeats the answer several times over, and treating deltas as cumulative truncates it to the last fragment.</p>

<p>The response keeps both the consolidated text and the raw activities:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"conversationId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"abc123"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"text"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Paris is the capital of France."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"isComplete"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"activityCount"</span><span class="p">:</span><span class="w"> </span><span class="mi">4</span><span class="p">,</span><span class="w">
  </span><span class="nl">"attachments"</span><span class="p">:</span><span class="w"> </span><span class="p">[],</span><span class="w">
  </span><span class="nl">"suggestedActions"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"imBack"</span><span class="p">,</span><span class="w"> </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Tell me more"</span><span class="p">,</span><span class="w"> </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="s2">"more"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">],</span><span class="w">
  </span><span class="nl">"citations"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"@type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Claim"</span><span class="p">,</span><span class="w"> </span><span class="nl">"position"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="nl">"appearance"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"France"</span><span class="p">,</span><span class="w"> </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://example.com"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">],</span><span class="w">
  </span><span class="nl">"activities"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="s2">"..."</span><span class="w"> </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Read <code class="language-plaintext highlighter-rouge">text</code> in almost every flow. Check <code class="language-plaintext highlighter-rouge">isComplete</code> when correctness matters — <code class="language-plaintext highlighter-rouge">false</code> means the runtime never signaled turn completion, so <code class="language-plaintext highlighter-rouge">text</code> is partial. <code class="language-plaintext highlighter-rouge">activityCount</code> covers this turn only, not the whole conversation. <strong>Ask Agent</strong> also returns <code class="language-plaintext highlighter-rouge">greetingActivities</code> separately, so the agent’s opening line never pollutes <code class="language-plaintext highlighter-rouge">text</code>.</p>

<h2 id="send-a-raw-activity-to-trigger-a-topic">Send a raw activity to trigger a topic</h2>

<p><strong>Send Message</strong> exposes a <strong>Raw Activity</strong> field for anything that isn’t a plain user message. An <code class="language-plaintext highlighter-rouge">event</code> or <code class="language-plaintext highlighter-rouge">invoke</code> activity can trigger a topic directly:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"activity"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"event"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"StartOrderLookup"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"orderId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"SO-4417"</span><span class="w"> </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Message</strong> is ignored when <strong>Raw Activity</strong> is present. The connector always overwrites the <code class="language-plaintext highlighter-rouge">conversation</code> property with the conversation in the request path, so a caller can’t post into a conversation it doesn’t own.</p>

<h2 id="errors-name-the-thing-to-check">Errors name the thing to check</h2>

<p>The runtime returns bare status codes for common misconfigurations, so the connector translates each one:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"status"</span><span class="p">:</span><span class="w"> </span><span class="mi">403</span><span class="p">,</span><span class="w">
  </span><span class="nl">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Forbidden. Share the agent with the signed-in user, and confirm admin consent was granted for CopilotStudio.Copilots.Invoke. This endpoint rejects app-only (service principal) tokens: the connection must be a delegated user sign-in."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"details"</span><span class="p">:</span><span class="w"> </span><span class="s2">"&lt;raw response body from the runtime&gt;"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>Status</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">400</code></td>
      <td>Missing <code class="language-plaintext highlighter-rouge">schemaName</code>, <code class="language-plaintext highlighter-rouge">conversationId</code>, or message text — raised before any call is made</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">401</code></td>
      <td>Wrong token audience, or the app registration is missing <code class="language-plaintext highlighter-rouge">CopilotStudio.Copilots.Invoke</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">403</code></td>
      <td>Agent not shared, admin consent not granted, or an app-only token was used</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">404</code></td>
      <td>Wrong host or schema name, agent not published, or not served by the agentic runtime</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">408</code> / <code class="language-plaintext highlighter-rouge">504</code></td>
      <td>The turn didn’t finish in time</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">429</code></td>
      <td>Throttled — retry after a delay</td>
    </tr>
  </tbody>
</table>

<p>MCP callers get the same payload as the tool result with <code class="language-plaintext highlighter-rouge">isError: true</code>, so the calling agent reads the explanation instead of seeing an opaque failure.</p>

<p>When a call fails, confirm each layer in order. It isolates the problem faster than reading the error alone:</p>

<ol>
  <li><strong>Host</strong> — does it match the <code class="language-plaintext highlighter-rouge">{30}.{2}</code> split of your environment GUID? A wrong host gives <code class="language-plaintext highlighter-rouge">404</code>, not a connection error.</li>
  <li><strong>Schema name</strong> — copy it from <code class="language-plaintext highlighter-rouge">List Agents</code> exactly. It’s case-sensitive.</li>
  <li><strong>Published</strong> — republish if in doubt. A draft-only agent gives <code class="language-plaintext highlighter-rouge">404</code>.</li>
  <li><strong>Sharing</strong> — confirm the signed-in user is on the agent’s share list.</li>
  <li><strong>Permission</strong> — check <code class="language-plaintext highlighter-rouge">CopilotStudio.Copilots.Invoke</code> has admin consent.</li>
</ol>

<p>A <code class="language-plaintext highlighter-rouge">401</code> is almost always the app registration rather than the agent.</p>

<h2 id="know-the-limits-before-you-build-on-it">Know the limits before you build on it</h2>

<p><strong>Turn length is the hard one.</strong> Power Platform cuts a connector request off at roughly 120 seconds. Agentic loop turns routinely run longer, and this endpoint has no polling or resume route, so a long turn is simply lost. Keep prompts tightly scoped and don’t build deep research tasks on it.</p>

<p>The rest:</p>

<ul>
  <li><strong>No streaming to the caller.</strong> The answer arrives all at once when the turn completes. Progressive rendering isn’t possible through a custom connector.</li>
  <li><strong>Delegated identity only.</strong> Every call runs as the signed-in user. There’s no unattended option.</li>
  <li><strong>Unversioned in practice.</strong> <code class="language-plaintext highlighter-rouge">api-version=1</code> is pinned because it’s what the route accepts today.</li>
  <li><strong>Cost.</strong> Agents on the GitHub Copilot harness bill 100–500+ Copilot Credits per task regardless of Microsoft 365 Copilot licensing. Calling one in a loop from a flow gets expensive fast. Check the harness with <code class="language-plaintext highlighter-rouge">List Agents</code> before wiring an agent into automation.</li>
</ul>

<h2 id="deploy-the-connector">Deploy the connector</h2>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">auth</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="nt">--environment</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">environment-id</span><span class="err">&gt;</span><span class="w">

</span><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-definition-file</span><span class="w"> </span><span class="nx">apiDefinition.swagger.json</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-properties-file</span><span class="w"> </span><span class="nx">apiProperties.json</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--script-file</span><span class="w"> </span><span class="nx">script.csx</span><span class="w">
</span></code></pre></div></div>

<p>Replace <code class="language-plaintext highlighter-rouge">[YOUR_CLIENT_ID]</code> in <code class="language-plaintext highlighter-rouge">apiProperties.json</code> with your app registration’s client ID and set the client secret in the portal <strong>Security</strong> tab. Then read back the generated redirect URL and register it:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">download</span><span class="w"> </span><span class="nt">--connector-id</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">id</span><span class="err">&gt;</span><span class="w"> </span><span class="nt">--outputDirectory</span><span class="w"> </span><span class="o">.</span><span class="nx">/verify</span><span class="w">
</span><span class="c"># properties.connectionParameters.token.oAuthSettings.redirectUrl</span><span class="w">

</span><span class="n">az</span><span class="w"> </span><span class="nx">ad</span><span class="w"> </span><span class="nx">app</span><span class="w"> </span><span class="nx">update</span><span class="w"> </span><span class="nt">--id</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">appId</span><span class="err">&gt;</span><span class="w"> </span><span class="nt">--web-redirect-uris</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="s2">"https://global.consent.azure-apim.net/redirect"</span><span class="w"> </span><span class="s2">"&lt;per-connector-url&gt;"</span><span class="w">
</span></code></pre></div></div>

<p>Each environment generates a different redirect URL. Consent fails if only the generic one is registered.</p>

<p>Validate before deploying:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">npx</span><span class="w"> </span><span class="nx">ppcv</span><span class="w"> </span><span class="s2">"./Copilot Studio Agent Runtime"</span><span class="w">
</span></code></pre></div></div>

<p>Finally, create a connection and supply the environment host derived above.</p>

<h2 id="three-ways-to-use-it">Three ways to use it</h2>

<h3 id="ask-an-agent-from-a-flow">Ask an agent from a flow</h3>

<p>Use <strong>Ask Agent</strong> when each call is independent.</p>

<table>
  <thead>
    <tr>
      <th>Field</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Agent Schema Name</td>
      <td><code class="language-plaintext highlighter-rouge">cr1a2_contractReviewer</code></td>
    </tr>
    <tr>
      <td>Message</td>
      <td><code class="language-plaintext highlighter-rouge">Summarize the risks in contract @{triggerBody()?['contractId']}</code></td>
    </tr>
  </tbody>
</table>

<p>Read <code class="language-plaintext highlighter-rouge">text</code> from the output. You don’t need to touch <code class="language-plaintext highlighter-rouge">activities</code>.</p>

<h3 id="hold-a-multi-turn-conversation">Hold a multi-turn conversation</h3>

<p>When later turns depend on earlier ones, keep the conversation open:</p>

<ol>
  <li><strong>Start Conversation</strong>, then store <code class="language-plaintext highlighter-rouge">conversationId</code> in a variable.</li>
  <li><strong>Send Message</strong> with that <code class="language-plaintext highlighter-rouge">conversationId</code>, then read <code class="language-plaintext highlighter-rouge">text</code>.</li>
  <li>Repeat step 2 for each follow-up. The agent retains context across turns.</li>
</ol>

<p>Don’t call <strong>Ask Agent</strong> in a loop for this. Each call opens a fresh conversation and the agent forgets everything.</p>

<h3 id="let-one-agent-call-another">Let one agent call another</h3>

<p>This is the scenario the MCP endpoint exists for, and the only way to reach a GitHub Copilot harness agent as a tool:</p>

<ol>
  <li>In Copilot Studio, open the calling agent and go to <strong>Tools</strong> &gt; <strong>Add a tool</strong> &gt; <strong>Model Context Protocol</strong>.</li>
  <li>Select this connector and create or pick a connection.</li>
  <li>Add <code class="language-plaintext highlighter-rouge">ask_agent</code> for one-shot delegation.</li>
  <li>
    <p>In the calling agent’s instructions, say when to delegate and which <code class="language-plaintext highlighter-rouge">schemaName</code> to pass. The model won’t know the schema name otherwise:</p>

    <blockquote>
      <p>When the user asks a contract question, call <code class="language-plaintext highlighter-rouge">ask_agent</code> with <code class="language-plaintext highlighter-rouge">schemaName</code> set to <code class="language-plaintext highlighter-rouge">cr1a2_contractReviewer</code> and the user’s question as <code class="language-plaintext highlighter-rouge">text</code>.</p>
    </blockquote>
  </li>
</ol>

<p>Add <code class="language-plaintext highlighter-rouge">start_conversation</code> and <code class="language-plaintext highlighter-rouge">send_message</code> only when the calling agent genuinely needs a multi-turn side conversation. For most delegation, <code class="language-plaintext highlighter-rouge">ask_agent</code> alone produces better behavior.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Studio%20Agent%20Runtime">Copilot Studio Agent Runtime source</a></li>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Studio%20Bots">Copilot Studio Bots connector</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-copilot-studio/publication-connect-bot-to-custom-application">Copilot Studio client library</a></li>
  <li><a href="https://learn.microsoft.com/connectors/custom-connectors/write-code">Write code in a custom connector</a></li>
  <li><a href="/power%20platform/custom%20connectors/mcp/2026-09-09-copilot-studio-bots-v1-2-channel-manifest.html">Copilot Studio Bots v1.2 post</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="MCP" /><category term="Copilot Studio" /><category term="Custom Connectors" /><category term="MCP" /><category term="Power Automate" /><category term="Agentic Runtime" /><category term="Server-Sent Events" /><category term="Power Platform API" /><category term="Entra ID" /><summary type="html"><![CDATA[Copilot Studio Agent Runtime is a custom connector that runs a turn against a published Copilot Studio agent, buffers the server-sent event stream, and returns one finished answer to a flow or a calling agent.]]></summary></entry><entry><title type="html">Read tenant capacity and consumption with Power Platform Capacity Intelligence</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-11-power-platform-capacity-intelligence.html" rel="alternate" type="text/html" title="Read tenant capacity and consumption with Power Platform Capacity Intelligence" /><published>2026-09-11T14:00:00+00:00</published><updated>2026-09-11T14:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/mcp/power-platform-capacity-intelligence</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-11-power-platform-capacity-intelligence.html"><![CDATA[<p>Ask how much Copilot Studio message capacity your tenant has left and you’ll visit three places: the capacity page for what you own, a downloaded report for what you’ve used, and a spreadsheet to work out the difference. Ask which agents consumed it and you’re out of options.</p>

<p>The licensing APIs Microsoft shipped in <a href="https://learn.microsoft.com/power-platform/admin/programmability-whats-new-changed#july-2026">July 2026</a> answer both questions from one host. <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Power%20Platform%20Capacity%20Intelligence">Power Platform Capacity Intelligence</a> wraps them in 12 typed Power Automate actions and the same 12 tools over Model Context Protocol, so a flow or a Copilot Studio agent can ask directly.</p>

<p>Every operation is read-only.</p>

<h2 id="reading-and-writing-stay-in-separate-connectors">Reading and writing stay in separate connectors</h2>

<p>Allocation writes live in the <a href="/power%20platform/custom%20connectors/mcp/2026-09-08-power-platform-admin-capacity-allocations.html">Power Platform Admin connector</a>, which shipped them in v1.4.</p>

<pre><code class="language-mermaid">flowchart LR
    A[Power Platform Capacity Intelligence&lt;br/&gt;12 read-only tools] --&gt;|reads| C[(licensing APIs&lt;br/&gt;api.powerplatform.com)]
    B[Power Platform Admin&lt;br/&gt;allocation writes] --&gt;|reads and writes| C
</code></pre>

<p>An agent holding only this connector can report that production has burned 94% of its AI capacity. It can’t change the allocation, enable <code class="language-plaintext highlighter-rouge">Deny</code>, or move capacity between environments. That makes it safe to hand to a wider audience than the admin connector — finance, a platform steering group, anyone who needs the number without needing the controls.</p>

<h2 id="what-the-12-tools-cover">What the 12 tools cover</h2>

<table>
  <thead>
    <tr>
      <th>Area</th>
      <th>Tool</th>
      <th>Returns</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Tenant capacity</td>
      <td><code class="language-plaintext highlighter-rouge">capacity_get_entitlement</code></td>
      <td>Entitled, consumed, allocated, available, and overage status</td>
    </tr>
    <tr>
      <td>Tenant capacity</td>
      <td><code class="language-plaintext highlighter-rouge">capacity_get_entitlement_posture</code></td>
      <td>Capacity, headroom, and thresholds in one call, with percent used</td>
    </tr>
    <tr>
      <td>Tenant capacity</td>
      <td><code class="language-plaintext highlighter-rouge">capacity_get_finops_license_summary</code></td>
      <td>Finance and Operations users: total, unlicensed, under-licensed, over-licensed</td>
    </tr>
    <tr>
      <td>Environment scope</td>
      <td><code class="language-plaintext highlighter-rouge">capacity_list_environment_entitlements</code></td>
      <td>Entitlements available to one environment, with enforcement rules</td>
    </tr>
    <tr>
      <td>Environment scope</td>
      <td><code class="language-plaintext highlighter-rouge">capacity_get_environment_resources</code></td>
      <td>Resources consuming capacity inside one environment</td>
    </tr>
    <tr>
      <td>Tenant scope</td>
      <td><code class="language-plaintext highlighter-rouge">capacity_get_tenant_resources</code></td>
      <td>Resources consuming capacity across the estate</td>
    </tr>
    <tr>
      <td>Tenant scope</td>
      <td><code class="language-plaintext highlighter-rouge">capacity_get_license_trends</code></td>
      <td>License counts over a date range, by model and tier</td>
    </tr>
    <tr>
      <td>Headroom</td>
      <td><code class="language-plaintext highlighter-rouge">capacity_get_allocation_availability</code></td>
      <td>Capacity not yet allocated to any environment</td>
    </tr>
    <tr>
      <td>Limits</td>
      <td><code class="language-plaintext highlighter-rouge">capacity_list_resource_thresholds</code></td>
      <td>Configured consumption caps</td>
    </tr>
    <tr>
      <td>Attribution</td>
      <td><code class="language-plaintext highlighter-rouge">capacity_list_tenant_users</code></td>
      <td>Users consuming an entitlement, with amounts</td>
    </tr>
    <tr>
      <td>Attribution</td>
      <td><code class="language-plaintext highlighter-rouge">capacity_get_user_consumption</code></td>
      <td>What one user consumed, by resource</td>
    </tr>
    <tr>
      <td>Attribution</td>
      <td><code class="language-plaintext highlighter-rouge">capacity_get_resource_consumers</code></td>
      <td>Which users consumed one resource</td>
    </tr>
  </tbody>
</table>

<p>Start with <strong>List Environment Entitlements</strong>. Entitlement IDs aren’t published anywhere convenient, and this tool returns the ones your environment actually has.</p>

<p>Each tool is also a typed Power Automate action with full request and response schemas, so it shows up in the designer with IntelliSense and output tokens. Every <code class="language-plaintext highlighter-rouge">environmentId</code> field renders as a picker of display names, backed by an internal dropdown operation — no GUIDs to paste.</p>

<p>The definition holds 15 operations: 12 typed actions, 2 internal dropdown sources, and the MCP endpoint.</p>

<h2 id="where-the-live-api-diverges-from-the-reference">Where the live API diverges from the reference</h2>

<p>Each contract was checked against a live tenant before the connector was written. Five published details didn’t hold, and each one changed the implementation.</p>

<p><strong>Allocation availability rejects an unfiltered call.</strong> The reference marks <code class="language-plaintext highlighter-rouge">$filter</code> optional. The service returns <code class="language-plaintext highlighter-rouge">400 Invalid filter options</code> without it. So <code class="language-plaintext highlighter-rouge">entitlementId</code> is required and the connector writes the filter:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">filter</span> <span class="p">=</span> <span class="s">$"entitlementId eq '</span><span class="p">{</span><span class="nf">EscapeODataLiteral</span><span class="p">(</span><span class="n">entitlementId</span><span class="p">)}</span><span class="s">'"</span><span class="p">;</span>
<span class="k">if</span> <span class="p">(!</span><span class="kt">string</span><span class="p">.</span><span class="nf">IsNullOrWhiteSpace</span><span class="p">(</span><span class="n">environmentId</span><span class="p">))</span>
    <span class="n">filter</span> <span class="p">=</span> <span class="s">$"environmentId eq '</span><span class="p">{</span><span class="nf">EscapeODataLiteral</span><span class="p">(</span><span class="n">environmentId</span><span class="p">.</span><span class="nf">Trim</span><span class="p">())}</span><span class="s">' and </span><span class="p">{</span><span class="n">filter</span><span class="p">}</span><span class="s">"</span><span class="p">;</span>
</code></pre></div></div>

<p><strong>Paged responses use a different token name.</strong> The reference documents <code class="language-plaintext highlighter-rouge">@odata.nextLink</code> and <code class="language-plaintext highlighter-rouge">@odata.count</code>. What arrives is <code class="language-plaintext highlighter-rouge">value</code> and a lowercase <code class="language-plaintext highlighter-rouge">continuationtoken</code>. The connector accepts all three spellings and returns whichever it finds.</p>

<p><strong>Dates are typed as bare strings.</strong> ISO <code class="language-plaintext highlighter-rouge">yyyy-MM-dd</code> works. The connector validates and normalizes before sending, so an inverted or unparseable range fails with a clear message instead of an opaque service error. Omit both dates and you get month-to-date, which matches how the service reports consumption.</p>

<p><strong><code class="language-plaintext highlighter-rouge">EntitlementUnit</code> isn’t a closed set.</strong> Four values are documented. The service also returns others, including <code class="language-plaintext highlighter-rouge">Messages</code>. The connector passes <code class="language-plaintext highlighter-rouge">unit</code> through as a display string and never switches on it.</p>

<p><strong>Two endpoints return <code class="language-plaintext highlighter-rouge">403</code> with an empty body.</strong> <code class="language-plaintext highlighter-rouge">Get Environment Resources</code> and <code class="language-plaintext highlighter-rouge">Get Tenant Resources</code> were denied for a tenant administrator whose sibling entitlement reads succeeded on the same token. No error detail came back at all, so the connector supplies the diagnosis:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="n">status</span> <span class="p">==</span> <span class="n">HttpStatusCode</span><span class="p">.</span><span class="n">Forbidden</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="s">$"Access denied reading </span><span class="p">{</span><span class="n">description</span><span class="p">}</span><span class="s"> (403). "</span> <span class="p">+</span>
           <span class="p">(</span><span class="kt">string</span><span class="p">.</span><span class="nf">IsNullOrWhiteSpace</span><span class="p">(</span><span class="n">body</span><span class="p">)</span>
               <span class="p">?</span> <span class="s">"The service returned no detail. Grant Licensing.Allocations.Read to the app registration and sign in as a Power Platform or Global administrator. "</span> <span class="p">+</span>
                 <span class="s">"Some per-resource consumption endpoints stay denied even for tenant administrators; if a sibling entitlement read succeeds, the permission is present and this endpoint is restricted separately."</span>
               <span class="p">:</span> <span class="nf">Truncate</span><span class="p">(</span><span class="n">body</span><span class="p">));</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That last one costs the most time during setup. If <strong>Get Entitlement</strong> works and <strong>Get Environment Resources</strong> doesn’t, granting the permission again won’t help. You’ve hit a separate gate on per-resource consumption.</p>

<h2 id="making-the-numbers-mean-what-they-say">Making the numbers mean what they say</h2>

<p>A capacity figure without its context invites the wrong conclusion. Three response fields carry that context.</p>

<h3 id="consumptiontype-tells-you-what-consumed-covers">consumptionType tells you what consumed covers</h3>

<p><code class="language-plaintext highlighter-rouge">consumed</code> isn’t always a running total. <code class="language-plaintext highlighter-rouge">MonthToDate</code> resets on the first of the month, so a low figure on the third says nothing about usage. <code class="language-plaintext highlighter-rouge">Snapshot</code> is a point-in-time reading. Every tool that returns <code class="language-plaintext highlighter-rouge">consumed</code> returns <code class="language-plaintext highlighter-rouge">consumptionType</code> next to it.</p>

<p><code class="language-plaintext highlighter-rouge">percentUsed</code> is computed by the connector as <code class="language-plaintext highlighter-rouge">consumed / entitled × 100</code>, rounded to two places, and only when <code class="language-plaintext highlighter-rouge">entitled</code> is greater than zero. It measures the past. The connector doesn’t extrapolate, and the response says so.</p>

<h3 id="an-empty-result-isnt-always-a-zero">An empty result isn’t always a zero</h3>

<p>Three tools distinguish absence from measurement. The Finance and Operations summary is the clearest case — a tenant that has never produced a report returns zeroed counters and a default timestamp rather than a <code class="language-plaintext highlighter-rouge">204</code>:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">neverRefreshed</span> <span class="p">=</span> <span class="nf">IsDefaultTimestamp</span><span class="p">(</span><span class="n">doc</span><span class="p">[</span><span class="s">"lastReportRefreshTime"</span><span class="p">]);</span>
</code></pre></div></div>

<p>When that’s true, <code class="language-plaintext highlighter-rouge">hasData</code> comes back <code class="language-plaintext highlighter-rouge">false</code> with an explanation: “This tenant has no Finance and Operations licensing report. The zero counts are the absence of a report, not a measured result.”</p>

<p>Check <code class="language-plaintext highlighter-rouge">hasData</code> before charting it. A chart of zeros looks like a measurement.</p>

<p><code class="language-plaintext highlighter-rouge">capacity_list_resource_thresholds</code> reports consumption as uncapped rather than returning a bare empty list, and <code class="language-plaintext highlighter-rouge">capacity_get_license_trends</code> explains that capacity-model entitlements have no assigned licenses to trend.</p>

<h3 id="posture-degrades-in-parts">Posture degrades in parts</h3>

<p><code class="language-plaintext highlighter-rouge">capacity_get_entitlement_posture</code> reads three sources — entitlement capacity, allocation availability, and thresholds. Those sources don’t share authorization behavior, so one failure shouldn’t blank the other two:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">try</span> <span class="p">{</span> <span class="n">entitlement</span> <span class="p">=</span> <span class="k">await</span> <span class="nf">HandleGetEntitlement</span><span class="p">(</span><span class="n">args</span><span class="p">).</span><span class="nf">ConfigureAwait</span><span class="p">(</span><span class="k">false</span><span class="p">);</span> <span class="p">}</span>
<span class="k">catch</span> <span class="p">(</span><span class="n">Exception</span> <span class="n">ex</span><span class="p">)</span> <span class="p">{</span> <span class="n">problems</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="s">"entitlement: "</span> <span class="p">+</span> <span class="n">ex</span><span class="p">.</span><span class="n">Message</span><span class="p">);</span> <span class="p">}</span>

<span class="k">try</span> <span class="p">{</span> <span class="n">availability</span> <span class="p">=</span> <span class="k">await</span> <span class="nf">HandleGetAllocationAvailability</span><span class="p">(</span><span class="n">args</span><span class="p">).</span><span class="nf">ConfigureAwait</span><span class="p">(</span><span class="k">false</span><span class="p">);</span> <span class="p">}</span>
<span class="k">catch</span> <span class="p">(</span><span class="n">Exception</span> <span class="n">ex</span><span class="p">)</span> <span class="p">{</span> <span class="n">problems</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="s">"availability: "</span> <span class="p">+</span> <span class="n">ex</span><span class="p">.</span><span class="n">Message</span><span class="p">);</span> <span class="p">}</span>

<span class="k">try</span> <span class="p">{</span> <span class="n">thresholds</span> <span class="p">=</span> <span class="k">await</span> <span class="nf">HandleListResourceThresholds</span><span class="p">(</span><span class="n">args</span><span class="p">).</span><span class="nf">ConfigureAwait</span><span class="p">(</span><span class="k">false</span><span class="p">);</span> <span class="p">}</span>
<span class="k">catch</span> <span class="p">(</span><span class="n">Exception</span> <span class="n">ex</span><span class="p">)</span> <span class="p">{</span> <span class="n">problems</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="s">"thresholds: "</span> <span class="p">+</span> <span class="n">ex</span><span class="p">.</span><span class="n">Message</span><span class="p">);</span> <span class="p">}</span>
</code></pre></div></div>

<p>The response carries <code class="language-plaintext highlighter-rouge">problems</code> and a <code class="language-plaintext highlighter-rouge">complete</code> flag. A partial posture is a normal result, not a failure — check <code class="language-plaintext highlighter-rouge">complete</code> before treating it as whole.</p>

<h2 id="paging-is-handed-back-to-the-caller">Paging is handed back to the caller</h2>

<p>Paged tools return one page and the service’s continuation token. Walking the full collection inside the script would risk the two-minute custom code limit on a large tenant, and a timeout is harder to diagnose than an explicit next-page token.</p>

<p>Loop while <code class="language-plaintext highlighter-rouge">hasMore</code> is true, passing <code class="language-plaintext highlighter-rouge">continuationToken</code> back each time:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. Get Tenant Resources          (entitlementId: MCSMessages)
2. Do Until: hasMore is false
   3. Append resources[] to an array variable
   4. Get Tenant Resources       (continuationToken from the previous response)
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">pageSize</code> defaults to 100 and clamps at 1,000. Raising it makes fewer calls, which matters when a sweep starts drawing <code class="language-plaintext highlighter-rouge">429</code> responses.</p>

<p>Rows arrive flattened. The service nests them as <code class="language-plaintext highlighter-rouge">value[].resources[]</code> and <code class="language-plaintext highlighter-rouge">value[].users[]</code>; the connector unwraps that level, so an apply-to-each runs directly over <code class="language-plaintext highlighter-rouge">resources</code> or <code class="language-plaintext highlighter-rouge">users</code>.</p>

<h2 id="user-attribution-needs-handling-rules">User attribution needs handling rules</h2>

<p>The three attribution tools return identifiable people alongside consumption figures. Their responses carry a <code class="language-plaintext highlighter-rouge">privacyNote</code> saying so.</p>

<p>Telemetry is off by default, and when enabled it records only the tool name and entitlement ID through <code class="language-plaintext highlighter-rouge">ToolCall</code> and <code class="language-plaintext highlighter-rouge">ToolCallError</code> events. Consumption figures and user identifiers never reach Application Insights. Sending them would move personal data outside the boundary the caller expects, and a telemetry pipeline is the wrong place to keep it. Audit inside your own tenant against the connector’s response instead.</p>

<p>Prefer resource-level attribution when it answers the question. Reach for user-level data when it doesn’t, then retain the output according to your privacy obligations.</p>

<h2 id="setting-it-up">Setting it up</h2>

<h3 id="grant-the-permission">Grant the permission</h3>

<p>Create a Microsoft Entra app registration with the delegated Power Platform API permission <code class="language-plaintext highlighter-rouge">Licensing.Allocations.Read</code>. The Power Platform API resource is <code class="language-plaintext highlighter-rouge">8578e004-a5c6-46e7-913e-12f58912df43</code>.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">az</span><span class="w"> </span><span class="nx">ad</span><span class="w"> </span><span class="nx">app</span><span class="w"> </span><span class="nx">permission</span><span class="w"> </span><span class="nx">add</span><span class="w"> </span><span class="nt">--id</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">appId</span><span class="err">&gt;</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--api</span><span class="w"> </span><span class="nx">8578e004-a5c6-46e7-913e-12f58912df43</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--api-permissions</span><span class="w"> </span><span class="nx">73cf5c38-5257-4f28-8bbb-f78acf3290a4</span><span class="o">=</span><span class="n">Scope</span><span class="w">

</span><span class="n">az</span><span class="w"> </span><span class="nx">ad</span><span class="w"> </span><span class="nx">app</span><span class="w"> </span><span class="nx">permission</span><span class="w"> </span><span class="nx">admin-consent</span><span class="w"> </span><span class="nt">--id</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">appId</span><span class="err">&gt;</span><span class="w">
</span></code></pre></div></div>

<p>The <a href="https://learn.microsoft.com/power-platform/admin/programmability-permission-reference">permission reference</a> doesn’t publish scope IDs. That value was read from the service — confirm it in your own tenant rather than trusting a pasted GUID:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">az</span><span class="w"> </span><span class="nx">ad</span><span class="w"> </span><span class="nx">sp</span><span class="w"> </span><span class="nx">show</span><span class="w"> </span><span class="nt">--id</span><span class="w"> </span><span class="nx">8578e004-a5c6-46e7-913e-12f58912df43</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--query</span><span class="w"> </span><span class="s2">"oauth2PermissionScopes[?starts_with(value,'Licensing')].{scope:value,id:id}"</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--output</span><span class="w"> </span><span class="nx">table</span><span class="w">
</span></code></pre></div></div>

<p>The signed-in user needs <strong>Power Platform Administrator</strong> or <strong>Global Administrator</strong>. These reads are tenant-scoped.</p>

<h3 id="deploy-all-three-files">Deploy all three files</h3>

<p><code class="language-plaintext highlighter-rouge">apiProperties.json</code> declares script operations, so the service rejects a deployment without a script to route them to:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">ppcv</span><span class="w"> </span><span class="s2">".\Power Platform Capacity Intelligence"</span><span class="w">

</span><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--api-definition-file</span><span class="w"> </span><span class="s2">".\Power Platform Capacity Intelligence\apiDefinition.swagger.json"</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--api-properties-file</span><span class="w"> </span><span class="s2">".\Power Platform Capacity Intelligence\apiProperties.json"</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--script-file</span><span class="w"> </span><span class="s2">".\Power Platform Capacity Intelligence\script.csx"</span><span class="w">
</span></code></pre></div></div>

<p>Search for <strong>Power Platform Capacity</strong> in the connector list, not the folder name. <code class="language-plaintext highlighter-rouge">pac connector create</code> caps titles at 30 characters and “Power Platform Capacity Intelligence” is 35. <code class="language-plaintext highlighter-rouge">ppcv</code> doesn’t check that limit, so the files validate cleanly and the deployment still fails.</p>

<p>Replace <code class="language-plaintext highlighter-rouge">[INSERT_YOUR_CLIENT_ID]</code> in <code class="language-plaintext highlighter-rouge">apiProperties.json</code> with your application ID first. The placeholder deploys verbatim and the connector won’t authenticate until you fix it.</p>

<h3 id="register-the-generated-redirect-url">Register the generated redirect URL</h3>

<p>Deployment rewrites <code class="language-plaintext highlighter-rouge">redirectMode</code> to <code class="language-plaintext highlighter-rouge">GlobalPerConnector</code> and replaces <code class="language-plaintext highlighter-rouge">redirectUrl</code> with a generated value. That value has to be on the app registration or OAuth consent fails. This step is easy to miss and the failure it causes looks like a permission problem.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">download</span><span class="w"> </span><span class="nt">--connector-id</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">CONNECTOR_ID</span><span class="err">&gt;</span><span class="w"> </span><span class="nt">--outputDirectory</span><span class="w"> </span><span class="s2">".\verify"</span><span class="w">
</span><span class="c"># read properties.connectionParameters.token.oAuthSettings.redirectUrl</span><span class="w">

</span><span class="n">az</span><span class="w"> </span><span class="nx">ad</span><span class="w"> </span><span class="nx">app</span><span class="w"> </span><span class="nx">update</span><span class="w"> </span><span class="nt">--id</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">appId</span><span class="err">&gt;</span><span class="w"> </span><span class="nt">--web-redirect-uris</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="s2">"https://global.consent.azure-apim.net/redirect"</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="s2">"&lt;generated-url&gt;"</span><span class="w">
</span></code></pre></div></div>

<p>The trailing segment identifies the environment, so a second environment produces a second URL and both need registering. Add to the existing list rather than replacing it if the app registration backs other connectors.</p>

<h2 id="putting-it-to-work">Putting it to work</h2>

<p>A monthly posture report starts with <strong>List Environment Entitlements</strong>, filters to entitlements whose status isn’t <code class="language-plaintext highlighter-rouge">WithinCapacity</code>, and calls <strong>Get Entitlement Posture</strong> for each survivor:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. Trigger: Scheduled (monthly, first of the month)
2. List Environment Entitlements  (production environment)
3. Filter array: status is not equal to 'WithinCapacity'
4. For each remaining entitlement:
   5. Get Entitlement Posture     (entitlementId from the filtered item)
   6. Append to an HTML table: entitlementId, percentUsed, status, consumed, entitled
7. Mail the table to the platform owners
</code></pre></div></div>

<p>Tracing Copilot Studio consumption takes three calls — the total, the resources behind it, and the headroom left:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. Get Entitlement                (MCSMessages)
2. Get Tenant Resources           (MCSMessages, paging until hasMore is false)
3. Sort by consumed descending, take the top 10
4. Get Allocation Availability    (MCSMessages)
</code></pre></div></div>

<p>An agent answers the same questions conversationally:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>How much Copilot Studio message capacity has the tenant used this month?
Which agents consumed the most MCSMessages capacity in August?
Which entitlements are in overage?
How much AI capacity is still unallocated?
What entitlements does my production environment have?
Are there consumption thresholds configured for MCSMessages?
Who consumed the most Copilot Studio capacity last month?
</code></pre></div></div>

<p>When the answer calls for a change, switch to Power Platform Admin. This connector reports the position; that one moves it.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Power%20Platform%20Capacity%20Intelligence">Source code</a></li>
  <li><a href="https://learn.microsoft.com/power-platform/admin/programmability-whats-new-changed#july-2026">July 2026 programmability additions</a></li>
  <li><a href="https://learn.microsoft.com/rest/api/power-platform/licensing/entitlement/get-entitlement">Get Entitlement</a></li>
  <li><a href="https://learn.microsoft.com/rest/api/power-platform/licensing/entitlement/get-many-environment-entitlements">Get Many Environment Entitlements</a></li>
  <li><a href="https://learn.microsoft.com/rest/api/power-platform/licensing/entitlement-insight/get-environment-resources">Get Environment Resources</a></li>
  <li><a href="https://learn.microsoft.com/rest/api/power-platform/licensing/entitlement-insight/get-tenant-users">Get Tenant Users</a></li>
  <li><a href="https://learn.microsoft.com/rest/api/power-platform/licensing/allocation/get-allocations-availability-v2">Get Allocations Availability V2</a></li>
  <li><a href="https://learn.microsoft.com/rest/api/power-platform/licensing/resource-threshold/get-all-resource-thresholds">Get All Resource Thresholds</a></li>
  <li><a href="https://learn.microsoft.com/rest/api/power-platform/licensing/fin-ops-licensing/get-fin-ops-license-summary-v2">Get Fin Ops License Summary V2</a></li>
  <li><a href="https://learn.microsoft.com/power-platform/admin/programmability-permission-reference">Programmability permission reference</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="MCP" /><category term="Power Platform" /><category term="Capacity" /><category term="Licensing" /><category term="Copilot Studio" /><category term="Power Automate" /><category term="Custom Connectors" /><category term="MCP" /><category term="Governance" /><summary type="html"><![CDATA[Power Platform Capacity Intelligence turns the July 2026 licensing APIs into 12 read-only Power Automate actions and MCP tools covering tenant capacity, consumption, headroom, thresholds, and user attribution.]]></summary></entry><entry><title type="html">Archive Copilot Studio channel manifests with v1.2</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-09-copilot-studio-bots-v1-2-channel-manifest.html" rel="alternate" type="text/html" title="Archive Copilot Studio channel manifests with v1.2" /><published>2026-09-09T20:31:00+00:00</published><updated>2026-09-09T20:31:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/mcp/copilot-studio-bots-v1-2-channel-manifest</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-09-copilot-studio-bots-v1-2-channel-manifest.html"><![CDATA[<p>A published Copilot Studio agent has another artifact worth keeping beside its source: the package sent to its channel.</p>

<p><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Studio%20Bots">Copilot Studio Bots v1.2</a> downloads that package for the Microsoft 365 channel. Save it before a release, archive it on a schedule, or compare two packages when a channel change behaves differently from the agent you tested.</p>

<p>The update adds the <strong>Download Agent Channel Manifest</strong> action for Power Automate and the <code class="language-plaintext highlighter-rouge">download_agent_channel_manifest</code> tool for Model Context Protocol (MCP) callers. It also hardens both ZIP download paths and fixes two problems found while testing the connector against the live Power Platform API.</p>

<h2 id="whats-new-in-v12">What’s new in v1.2</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th style="text-align: right">v1.1</th>
      <th style="text-align: right">v1.2</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Usable REST actions</td>
      <td style="text-align: right">16</td>
      <td style="text-align: right">17</td>
    </tr>
    <tr>
      <td>MCP tools</td>
      <td style="text-align: right">18</td>
      <td style="text-align: right">19</td>
    </tr>
    <tr>
      <td>OpenAPI operations, including MCP and two internal dropdowns</td>
      <td style="text-align: right">19</td>
      <td style="text-align: right">20</td>
    </tr>
    <tr>
      <td>Channel manifest export</td>
      <td style="text-align: right">No</td>
      <td style="text-align: right">Microsoft 365 ZIP</td>
    </tr>
  </tbody>
</table>

<p>The new REST action and MCP tool use the same operation:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">GET https://api.powerplatform.com/copilotstudio/
    environments/{environmentId}/agents/{botId}/
    channels/{channelName}/download
    ?includeAgentSchema={true|false}
    &amp;api-version=2024-10-01
</span></code></pre></div></div>

<p>This route differs from the other connector operations. Evaluations, quarantine, consent bypass, reassignment, deletion, and identity migration use <code class="language-plaintext highlighter-rouge">bots/{botId}</code>. Channel manifest export uses <code class="language-plaintext highlighter-rouge">agents/{botId}</code>.</p>

<p>Microsoft currently documents one channel value: <code class="language-plaintext highlighter-rouge">M365</code>. The connector defaults to it and rejects other values before making the request. When <code class="language-plaintext highlighter-rouge">includeAgentSchema</code> isn’t supplied, the connector leaves the query parameter out instead of changing an omitted value to <code class="language-plaintext highlighter-rouge">false</code>.</p>

<h2 id="keep-the-package-that-reaches-microsoft-365">Keep the package that reaches Microsoft 365</h2>

<p>The endpoint returns the channel manifest package as a ZIP. That makes it useful in agent application lifecycle management (ALM) workflows where the published package matters as much as the editable agent:</p>

<ul>
  <li>Export the package before and after a release, then compare the contents</li>
  <li>Save each production package to a versioned SharePoint document library</li>
  <li>Capture a known-good package before changing channels, authentication, or actions</li>
  <li>Attach the package to a release record for review or incident analysis</li>
  <li>Include the agent schema when a downstream check needs it</li>
</ul>

<p>A scheduled flow can inventory agents, call <strong>Download Agent Channel Manifest</strong> for each selected agent, and write the output directly to SharePoint or OneDrive. Keep the binary output out of string variables so the ZIP stays intact.</p>

<p>For an interactive MCP workflow, ask the agent to find the target first:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>User: List the agents in the production environment.

Agent:
  1. list_agents { environmentId: "&lt;production-environment-id&gt;" }
  2. Presents names, IDs, owners, harnesses, and publish states

User: Download the M365 channel manifest for Contoso Support.

Agent:
  3. download_agent_channel_manifest {
       environmentId: "&lt;production-environment-id&gt;",
       botId: "&lt;contoso-support-agent-id&gt;",
       channelName: "M365",
       includeAgentSchema: true
     }
</code></pre></div></div>

<p>Discovery stays separate from the download, so the user can confirm the named agent before retrieving its package.</p>

<h2 id="binary-downloads-now-share-one-path">Binary downloads now share one path</h2>

<p>The connector already downloaded evaluation snapshots. v1.2 moves snapshots and channel manifests onto one binary handler, with the same behavior for both:</p>

<ul>
  <li>ZIPs up to 4 MB return to an MCP caller as a base64 <code class="language-plaintext highlighter-rouge">resource</code></li>
  <li>Larger ZIPs return metadata and direct the caller to the REST action</li>
  <li>Power Automate receives the binary response without the MCP inline limit</li>
  <li>The file name comes from <code class="language-plaintext highlighter-rouge">Content-Disposition</code>, with a generated fallback</li>
</ul>

<p>The 4 MB ceiling keeps large base64 payloads out of an agent conversation. Use the REST action in a flow when a package exceeds it, and send the action output straight to storage.</p>

<p>File names also get stricter handling. A service response header eventually becomes part of an MCP resource URI, so the connector no longer trusts that value as-is. It:</p>

<ol>
  <li>Removes directory components</li>
  <li>Replaces characters that can break a URI</li>
  <li>Forces a <code class="language-plaintext highlighter-rouge">.zip</code> extension</li>
  <li>Caps the name at 120 characters</li>
  <li>Falls back to <code class="language-plaintext highlighter-rouge">agent-channel-manifest-M365.zip</code> when the header is missing or malformed</li>
</ol>

<p>The same checks now protect evaluation snapshot names.</p>

<h2 id="read-a-403-before-checking-the-ids">Read a 403 before checking the IDs</h2>

<p>Microsoft’s REST reference documents the request and <code class="language-plaintext highlighter-rouge">200</code> response but doesn’t list error responses for this operation. Live testing exposed an important order of operations: authorization runs before identifier validation.</p>

<p>Without <code class="language-plaintext highlighter-rouge">CopilotStudio.MakerOperations.Read</code>, the service returned <code class="language-plaintext highlighter-rouge">403 Forbidden</code> for both an invalid channel and a nonexistent agent. A 403 therefore doesn’t prove the agent ID or channel is wrong.</p>

<p>Read <code class="language-plaintext highlighter-rouge">innererror.code</code> and <code class="language-plaintext highlighter-rouge">innererror.message</code> first. An <code class="language-plaintext highlighter-rouge">InsufficientDelegatedPermissions</code> response names the accepted delegated permissions. Grant <code class="language-plaintext highlighter-rouge">CopilotStudio.MakerOperations.Read</code>, consent again, and retry before troubleshooting the identifiers.</p>

<p>This permission is also used for reading evaluation test sets, test runs, and snapshots. Starting an evaluation needs <code class="language-plaintext highlighter-rouge">CopilotStudio.MakerOperations.ReadWrite</code>, while the administrative operations use their own permission family.</p>

<h2 id="two-live-api-fixes-ship-with-the-update">Two live-API fixes ship with the update</h2>

<p>The source on <code class="language-plaintext highlighter-rouge">main</code> reports MCP server version <code class="language-plaintext highlighter-rouge">1.2.1</code> because testing found two defects after the channel export was added.</p>

<h3 id="environment-names-appear-in-the-picker-again">Environment names appear in the picker again</h3>

<p>The environment management API returns <code class="language-plaintext highlighter-rouge">displayName</code>, <code class="language-plaintext highlighter-rouge">type</code>, <code class="language-plaintext highlighter-rouge">state</code>, and <code class="language-plaintext highlighter-rouge">url</code> at the top level. The connector expected a nested <code class="language-plaintext highlighter-rouge">properties</code> object, so the environment picker showed raw GUIDs when that object was absent.</p>

<p>The parser now checks the flat response first and keeps a nested fallback for older response shapes.</p>

<h3 id="agent-inventory-retries-one-transient-400">Agent inventory retries one transient 400</h3>

<p>The undocumented <code class="language-plaintext highlighter-rouge">resourcequery</code> endpoint can intermittently return:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>400 Bad Request
"KQLOM format is wrong or it cannot be null"
</code></pre></div></div>

<p>Testing reproduced the response with a byte-for-byte identical payload that had succeeded moments earlier. The connector now retries that specific error up to three times with a short backoff. It also retries <code class="language-plaintext highlighter-rouge">429</code> and <code class="language-plaintext highlighter-rouge">5xx</code> responses, while unrelated <code class="language-plaintext highlighter-rouge">400</code> responses still fail immediately.</p>

<p>This matters beyond inventory. <code class="language-plaintext highlighter-rouge">List Agents</code>, the agent picker, <code class="language-plaintext highlighter-rouge">find_containment_candidates</code>, and <code class="language-plaintext highlighter-rouge">contain_agents</code> all depend on the same query.</p>

<h2 id="update-the-connector">Update the connector</h2>

<p>Replace <code class="language-plaintext highlighter-rouge">REPLACE_WITH_CLIENT_ID</code> in <code class="language-plaintext highlighter-rouge">apiProperties.json</code>, then validate and update the existing connector:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">ppcv</span><span class="w"> </span><span class="s2">"./Copilot Studio Bots"</span><span class="w">

</span><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">update</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--connector-id</span><span class="w"> </span><span class="nx">00000000-0000-0000-0000-000000000000</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-definition-file</span><span class="w"> </span><span class="nx">apiDefinition.swagger.json</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-properties-file</span><span class="w"> </span><span class="nx">apiProperties.json</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--script-file</span><span class="w"> </span><span class="nx">script.csx</span><span class="w">
</span></code></pre></div></div>

<p>Keep <code class="language-plaintext highlighter-rouge">--script-file</code>. The MCP tool, binary handling, dropdown fix, and inventory retry all run through <code class="language-plaintext highlighter-rouge">script.csx</code>.</p>

<p>Existing flows keep their actions. The new channel manifest action appears with the evaluation, inventory, containment, identity migration, and administrative actions after the update.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Studio%20Bots">Copilot Studio Bots v1.2 source</a></li>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/commit/c9b0ab6f6f45b4193f81d651074833750852649e">v1.2 commit</a></li>
  <li><a href="https://learn.microsoft.com/rest/api/power-platform/copilotstudio/agent-channels/download-agent-channel-manifest">Download Agent Channel Manifest</a></li>
  <li><a href="/power%20platform/custom%20connectors/mcp/2026-08-20-copilot-studio-bots-connector.html">Original Copilot Studio Bots post</a></li>
  <li><a href="/power%20platform/custom%20connectors/mcp/2026-08-27-copilot-studio-bots-v1-1-entra-agent-id-migration.html">Copilot Studio Bots v1.1</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="MCP" /><category term="Copilot Studio" /><category term="Custom Connectors" /><category term="MCP" /><category term="Power Automate" /><category term="Agent ALM" /><category term="Channel Manifest" /><category term="Microsoft 365" /><category term="Power Platform API" /><summary type="html"><![CDATA[Copilot Studio Bots v1.2 adds a Power Automate action and MCP tool to download Microsoft 365 channel manifest ZIPs, plus safer file handling and live-API reliability fixes.]]></summary></entry><entry><title type="html">Power Platform Admin v1.4 moves capacity allocations onto the documented API</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-08-power-platform-admin-capacity-allocations.html" rel="alternate" type="text/html" title="Power Platform Admin v1.4 moves capacity allocations onto the documented API" /><published>2026-09-08T18:00:00+00:00</published><updated>2026-09-08T18:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/mcp/power-platform-admin-capacity-allocations</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-08-power-platform-admin-capacity-allocations.html"><![CDATA[<p>Microsoft published the <strong>Allocations By Environment</strong> API in July 2026. That’s the supported route for reading and writing capacity allocations per environment, and it lives on <code class="language-plaintext highlighter-rouge">api.powerplatform.com</code> alongside everything else the connector already calls.</p>

<p><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Power%20Platform%20Admin">Power Platform Admin v1.4</a> moves onto it. Five new tools cover allocations, availability, and reserved entitlements. The tenant pool tools from <a href="/power%20platform/custom%20connectors/mcp/2026-08-05-power-platform-admin-tenant-pool-draw.html">v1.1</a> were rewritten to use the same surface, so the tenant-routed host they reached is gone. Nothing in the connector calls an undocumented licensing route anymore.</p>

<p>The <a href="/power%20platform/custom%20connectors/mcp/2026-08-06-power-platform-admin-agent-inventory.html">v1.2 post</a> covers agent inventory, <a href="/power%20platform/custom%20connectors/mcp/2026-08-13-power-platform-admin-resource-thresholds.html">v1.3</a> covers resource thresholds, and the <a href="/power%20platform/custom%20connectors/mcp/2026-05-13-power-platform-admin-mcp-connector.html">original post</a> covers the first 12 tools.</p>

<h2 id="whats-new-in-14">What’s new in 1.4</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>v1.3</th>
      <th>v1.4</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>MCP tools</td>
      <td>17</td>
      <td>22</td>
    </tr>
    <tr>
      <td>Power Automate actions</td>
      <td>9 typed operations</td>
      <td>14 typed operations</td>
    </tr>
    <tr>
      <td>Hosts called</td>
      <td><code class="language-plaintext highlighter-rouge">api.powerplatform.com</code> plus a tenant-routed host</td>
      <td><code class="language-plaintext highlighter-rouge">api.powerplatform.com</code> only</td>
    </tr>
    <tr>
      <td>Undocumented licensing routes</td>
      <td>1</td>
      <td>0</td>
    </tr>
    <tr>
      <td>Confirmation gate on writes</td>
      <td>Wording in tool descriptions</td>
      <td>Enforced in the tool router</td>
    </tr>
  </tbody>
</table>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Power Automate action</th>
      <th>What it does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">admin_list_environment_allocations</code></td>
      <td><strong>List Environment Allocations</strong></td>
      <td>Allocations for every environment, with enforcement rules per currency</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">admin_get_environment_allocations</code></td>
      <td><strong>Get Environment Allocations</strong></td>
      <td>The allocation document for one environment</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">admin_update_environment_allocation</code></td>
      <td><strong>Update Environment Allocation</strong></td>
      <td>Set the allocated amount and/or enforcement rules for one currency</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">admin_get_allocation_availability</code></td>
      <td><strong>Get Allocation Availability</strong></td>
      <td>How much of each entitlement is still available to allocate</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">admin_get_reserved_entitlements</code></td>
      <td><strong>Get Reserved Entitlements</strong></td>
      <td>Reserved quantity per entitlement</td>
    </tr>
  </tbody>
</table>

<h2 id="one-host-now">One host now</h2>

<p>v1.1 built a host from the <code class="language-plaintext highlighter-rouge">tid</code> claim of the access token — the tenant GUID lowercased, stripped of dashes, split into the first 30 hex characters and the last 2, assembled as <code class="language-plaintext highlighter-rouge">{prefix}.{suffix}.tenant.api.powerplatform.com</code>. It worked, and it was never in the published REST reference.</p>

<pre><code class="language-mermaid">flowchart LR
    A[v1.3] --&gt; B["api.powerplatform.com"]
    A --&gt; C["first30hex.last2hex.tenant.api.powerplatform.com&lt;br/&gt;licensing/allocations"]
    D[v1.4] --&gt; E["api.powerplatform.com&lt;br/&gt;including licensing/allocationsByEnvironment"]
</code></pre>

<p>The identifiers survived the move but the wire shape didn’t. <code class="language-plaintext highlighter-rouge">MCSMessages</code> and <code class="language-plaintext highlighter-rouge">MCSSessions</code> were entitlement IDs on the retired route. On the documented API they’re <code class="language-plaintext highlighter-rouge">ExternalCurrencyType</code> values inside a <code class="language-plaintext highlighter-rouge">currencyAllocations</code> array:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// MCSMessages and MCSSessions are ExternalCurrencyType values on the documented</span>
<span class="c1">// allocation API. The retired tenant-routed endpoint modelled them as entitlement</span>
<span class="c1">// IDs, so the wire shape differs even though the identifiers are spelled the same.</span>
<span class="k">private</span> <span class="k">static</span> <span class="k">readonly</span> <span class="kt">string</span><span class="p">[]</span> <span class="n">TENANT_POOL_CURRENCIES</span> <span class="p">=</span> <span class="p">{</span> <span class="s">"MCSMessages"</span><span class="p">,</span> <span class="s">"MCSSessions"</span> <span class="p">};</span>
</code></pre></div></div>

<p>Tenant pool draw is now an enforcement rule on those two currencies rather than a document of its own. <code class="language-plaintext highlighter-rouge">admin_get_tenant_pool_draw</code> and <code class="language-plaintext highlighter-rouge">admin_set_tenant_pool_draw</code> keep their names and arguments, so an agent or flow built on v1.1 keeps working.</p>

<h2 id="every-documented-currency-four-enforcement-rules">Every documented currency, four enforcement rules</h2>

<p>The allocation tools cover the full <code class="language-plaintext highlighter-rouge">ExternalCurrencyType</code> list — AI, Copilot Studio messages and sessions, Power Pages authenticated and anonymous, hosted and unattended RPA, Power Automate per-process, Process Mining storage, and the rest — with <code class="language-plaintext highlighter-rouge">Alert</code>, <code class="language-plaintext highlighter-rouge">PayGo</code>, <code class="language-plaintext highlighter-rouge">TenantPool</code>, and <code class="language-plaintext highlighter-rouge">Deny</code>.</p>

<p><code class="language-plaintext highlighter-rouge">Throttle</code> is missing from that list on purpose. It appears on the <code class="language-plaintext highlighter-rouge">allocationsV2</code> surface but not on the by-environment API, so the connector rejects it locally rather than sending a value the endpoint doesn’t document:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">throw</span> <span class="k">new</span> <span class="nf">ArgumentException</span><span class="p">(</span>
    <span class="s">$"ruleType '</span><span class="p">{</span><span class="n">trimmed</span><span class="p">}</span><span class="s">' is not a documented enforcement rule for the by-environment allocation "</span> <span class="p">+</span>
    <span class="s">"API. Expected one of: "</span> <span class="p">+</span> <span class="kt">string</span><span class="p">.</span><span class="nf">Join</span><span class="p">(</span><span class="s">", "</span><span class="p">,</span> <span class="n">ENFORCEMENT_RULE_TYPES</span><span class="p">)</span> <span class="p">+</span> <span class="s">"."</span><span class="p">);</span>
</code></pre></div></div>

<p>Currency spelling is validated the same way, against the documented list. Unknown values coming back <em>from</em> the service pass through untouched — Microsoft can add a member without a version bump, and a connector that filters reads would hide capacity that exists.</p>

<p><code class="language-plaintext highlighter-rouge">Deny</code> deserves care. Enabling it halts consumption once the allocation is exhausted, which can break running apps and agents mid-request.</p>

<h2 id="writes-read-back-and-report-whether-it-took">Writes read back and report whether it took</h2>

<p>The <code class="language-plaintext highlighter-rouge">PATCH</code> accepts a <code class="language-plaintext highlighter-rouge">currencyAllocations</code> array, and the published contract doesn’t say whether the service merges the collection or replaces it. Sending the whole array makes both behaviors produce the same result, so <code class="language-plaintext highlighter-rouge">admin_update_environment_allocation</code> reads the current document, changes only the currency you named, and sends everything back.</p>

<p>Then it re-reads and compares:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">applied</span> <span class="p">=</span> <span class="k">await</span> <span class="nf">PatchEnvironmentAllocation</span><span class="p">(</span><span class="n">envId</span><span class="p">,</span> <span class="n">document</span><span class="p">).</span><span class="nf">ConfigureAwait</span><span class="p">(</span><span class="k">false</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">after</span> <span class="p">=</span> <span class="k">await</span> <span class="nf">FetchEnvironmentAllocation</span><span class="p">(</span><span class="n">envId</span><span class="p">).</span><span class="nf">ConfigureAwait</span><span class="p">(</span><span class="k">false</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">verified</span> <span class="p">=</span> <span class="nf">VerifyCurrencyMatches</span><span class="p">(</span><span class="n">after</span><span class="p">,</span> <span class="n">currencyType</span><span class="p">,</span> <span class="n">target</span><span class="p">);</span>
</code></pre></div></div>

<p>The response carries <code class="language-plaintext highlighter-rouge">verified</code> plus a <code class="language-plaintext highlighter-rouge">changes</code> array of <code class="language-plaintext highlighter-rouge">field</code>, <code class="language-plaintext highlighter-rouge">from</code>, and <code class="language-plaintext highlighter-rouge">to</code> entries, so a flow logs what moved instead of what was requested. A <code class="language-plaintext highlighter-rouge">false</code> on <code class="language-plaintext highlighter-rouge">verified</code> means the read-back didn’t match:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Updated AI on environment {id}, but the read-back did not match the requested
values. Another administrator may have written concurrently — the service exposes
no ETag. Re-read the allocation before making further changes.
</code></pre></div></div>

<p>There’s still no ETag. Read-modify-write with verification narrows the window and tells you when you lost the race; it doesn’t close it. Don’t fan this out across 40 environments while someone is in PPAC.</p>

<p>A call that supplies neither <code class="language-plaintext highlighter-rouge">allocated</code> nor <code class="language-plaintext highlighter-rouge">enforcementRules</code> is rejected rather than rewriting the document with its own current values.</p>

<h2 id="confirmation-the-agent-cant-reason-its-way-past">Confirmation the agent can’t reason its way past</h2>

<p>“Confirm with the user before executing” in a tool description is a suggestion. An orchestrator resolving an ambiguous instruction can decide it already has consent — and <code class="language-plaintext highlighter-rouge">Deny</code> on a production currency is not a decision to leave to inference.</p>

<p>v1.4 puts the check in the tool router:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="k">static</span> <span class="k">readonly</span> <span class="n">HashSet</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="n">CONFIRMATION_REQUIRED_TOOLS</span> <span class="p">=</span>
    <span class="k">new</span> <span class="n">HashSet</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;(</span><span class="n">StringComparer</span><span class="p">.</span><span class="n">OrdinalIgnoreCase</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="s">"admin_update_setting"</span><span class="p">,</span>
        <span class="s">"admin_update_copilot_governance"</span><span class="p">,</span>
        <span class="s">"admin_set_tenant_pool_draw"</span><span class="p">,</span>
        <span class="s">"admin_upsert_resource_threshold"</span><span class="p">,</span>
        <span class="s">"admin_update_environment_allocation"</span><span class="p">,</span>
        <span class="s">"admin_install_package"</span>
    <span class="p">};</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">confirm</code> is required in each tool’s input schema, and the router refuses the call regardless:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">throw</span> <span class="k">new</span> <span class="nf">ArgumentException</span><span class="p">(</span>
    <span class="s">$"</span><span class="p">{</span><span class="n">toolName</span><span class="p">}</span><span class="s"> changes tenant or environment configuration and cannot be run from an "</span> <span class="p">+</span>
    <span class="s">"implied instruction. Show the caller what will change, then set confirm to true."</span><span class="p">);</span>
</code></pre></div></div>

<p>A missing, <code class="language-plaintext highlighter-rouge">false</code>, or non-boolean <code class="language-plaintext highlighter-rouge">confirm</code> all land in the same place. The string <code class="language-plaintext highlighter-rouge">"true"</code> is accepted because some agents stringify booleans, and nothing weaker than an explicit affirmative gets through.</p>

<p>The typed Power Automate operations are deliberately not gated. A maker who drags <strong>Update Environment Allocation</strong> into a designer has already made the decision, and adding a required body property would break every flow built on the previous version.</p>

<h2 id="in-a-flow">In a flow</h2>

<p>Five new actions, same environment picker as the rest of the connector — display names at design time, no GUIDs to paste.</p>

<p><strong>List Environment Allocations</strong> takes no inputs and returns every environment, which makes the tenant-wide reports practical: which environments have <code class="language-plaintext highlighter-rouge">Deny</code> enabled on any currency, which draw from the tenant pool without a resource threshold, which have AI capacity sitting allocated and unused.</p>

<p><strong>Get Allocation Availability</strong> and <strong>Get Reserved Entitlements</strong> each accept an OData <code class="language-plaintext highlighter-rouge">$filter</code>, but Microsoft doesn’t publish the filterable fields. Call them unfiltered and narrow the result in the flow. Reserved quantities don’t include enforcement rules — read the environment allocation for those.</p>

<h2 id="prompts-to-try">Prompts to try</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Show me capacity allocations across every environment
What AI capacity is allocated to my production environment?
How much AI capacity is still unallocated in the tenant?
Allocate 250 AI capacity to my sandbox and deny overage
Which environments have Deny enabled on any currency?
Which environments draw from the tenant pool but have no resource threshold?
</code></pre></div></div>

<p>That last one spans two APIs. With <a href="https://learn.microsoft.com/microsoft-copilot-studio/advanced-generative-actions">generative orchestration</a> enabled, the agent reads allocations, reads thresholds, and joins them on environment ID without a tool built for the question.</p>

<h2 id="permissions">Permissions</h2>

<p>No new scope. The allocation routes sit under <code class="language-plaintext highlighter-rouge">licensing</code>, so the <code class="language-plaintext highlighter-rouge">Licensing.Allocations.Read</code> and <code class="language-plaintext highlighter-rouge">Licensing.Allocations.ReadWrite</code> scopes already on the app registration cover them, plus <strong>Power Platform Administrator</strong> or <strong>Global Administrator</strong> on the caller. The endpoint reference advertises only <code class="language-plaintext highlighter-rouge">.default</code>, so verify with a read before relying on a write.</p>

<h2 id="updating-an-existing-install">Updating an existing install</h2>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">cd</span><span class="w"> </span><span class="s2">"Power Platform Admin"</span><span class="w">
</span><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">update</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--connector-id</span><span class="w"> </span><span class="err">&lt;</span><span class="nx">your-connector-id</span><span class="err">&gt;</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--api-definition-file</span><span class="w"> </span><span class="nx">apiDefinition.swagger.json</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--api-properties-file</span><span class="w"> </span><span class="nx">apiProperties.json</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--script-file</span><span class="w"> </span><span class="nx">script.csx</span><span class="w">
</span></code></pre></div></div>

<p>Set the <code class="language-plaintext highlighter-rouge">clientId</code> in <code class="language-plaintext highlighter-rouge">apiProperties.json</code> to your app registration first. If the update fails with “An unexpected error occurred,” push the definition and script without <code class="language-plaintext highlighter-rouge">--api-properties-file</code>, then set OAuth on the connector’s Security tab in the portal.</p>

<p>The connection doesn’t need to be recreated. Agents calling the six gated tools do need <code class="language-plaintext highlighter-rouge">confirm: true</code> added — that’s the one breaking change in this release.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Power%20Platform%20Admin">Source code</a></li>
  <li><a href="https://learn.microsoft.com/rest/api/power-platform/licensing/allocations-by-environment/list-allocations-by-environment">List Allocations By Environment</a></li>
  <li><a href="https://learn.microsoft.com/rest/api/power-platform/licensing/allocations-by-environment/get-allocations-by-environment">Get Allocations By Environment</a></li>
  <li><a href="https://learn.microsoft.com/rest/api/power-platform/licensing/allocations-by-environment/update-allocations-by-environment">Update Allocations By Environment</a></li>
  <li><a href="https://learn.microsoft.com/rest/api/power-platform/licensing/allocation/get-allocations-availability-v2">Get Allocations Availability V2</a></li>
  <li><a href="https://learn.microsoft.com/rest/api/power-platform/licensing/allocation/get-many-entitlements-reserved-v2">Get Many Entitlements Reserved V2</a></li>
  <li><a href="https://learn.microsoft.com/power-platform/admin/programmability-tutorial-manage-copilot-credit-allocations">Manage Copilot credit allocations programmatically</a></li>
  <li><a href="https://learn.microsoft.com/power-platform/admin/programmability-permission-reference">Permissions reference</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="MCP" /><category term="MCP" /><category term="Copilot Studio" /><category term="Power Platform Admin" /><category term="Custom Connectors" /><category term="Governance" /><category term="Licensing" /><category term="Capacity" /><category term="Power Automate" /><summary type="html"><![CDATA[Version 1.4 of the Power Platform Admin custom MCP connector adds five capacity allocation tools on the documented Allocations By Environment API, retires the tenant-routed host the tenant pool tools used, and gates every mutating tool behind an enforced confirm flag.]]></summary></entry><entry><title type="html">Connect Copilot Studio to an MCP server with public-client PKCE</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-04-mcp-public-client-pkce-connector.html" rel="alternate" type="text/html" title="Connect Copilot Studio to an MCP server with public-client PKCE" /><published>2026-09-04T20:30:00+00:00</published><updated>2026-09-04T20:30:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/mcp/mcp-public-client-pkce-connector</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-09-04-mcp-public-client-pkce-connector.html"><![CDATA[<p>Remote MCP servers that follow the <a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization">MCP authorization spec</a> expect the client to be a public client: authorization code, PKCE with <code class="language-plaintext highlighter-rouge">S256</code>, and no client secret. The Copilot Studio Add MCP Server wants a secret. <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/MCP%20Public%20Client%20PKCE">MCP Public Client PKCE</a> is a two-file connector template that skips the designer and configures a static public client through PAC CLI instead.</p>

<p>It’s based on Microsoft’s <a href="https://github.com/microsoft/PowerPlatformConnectors/tree/dev/custom-connectors/MCP-Streamable-HTTP">MCP Streamable HTTP connector template</a>, with the OAuth configuration swapped out.</p>

<h2 id="read-this-before-you-deploy-it">Read this before you deploy it</h2>

<p><code class="language-plaintext highlighter-rouge">oauth2pkcewithdcr</code> is a hidden Power Platform identity provider, and its current refresh template includes <code class="language-plaintext highlighter-rouge">client_secret={ClientSecret}</code> even when the connector defines no <code class="language-plaintext highlighter-rouge">clientSecret</code>. The initial authorization works. Refresh is the open question, and it depends on how your authorization server handles an unexpected empty <code class="language-plaintext highlighter-rouge">client_secret</code>.</p>

<p>Treat this template as experimental. The <a href="#validate-refresh-behavior">refresh validation</a> section below is the part that decides whether it’s usable for you.</p>

<h2 id="two-files-no-script">Two files, no script</h2>

<table>
  <thead>
    <tr>
      <th>File</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">apiDefinition.swagger.json</code></td>
      <td>The remote MCP Streamable HTTP operation and OAuth endpoints</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">apiProperties.json</code></td>
      <td>The hidden public-client PKCE identity provider</td>
    </tr>
  </tbody>
</table>

<p>There’s no <code class="language-plaintext highlighter-rouge">script.csx</code>. Power Platform forwards MCP traffic straight to the remote server, so nothing needs to sit in the middle.</p>

<p>You’ll need a remote MCP server using Streamable HTTP, an authorization server supporting authorization code with PKCE <code class="language-plaintext highlighter-rouge">S256</code>, a static public-client registration with no secret, and its client ID, authorization endpoint, token endpoint, refresh endpoint, and scopes. Also PAC CLI and permission to create connectors in a test environment. If refresh is part of your test, the authorization server has to issue refresh tokens.</p>

<h2 id="configure-the-api-definition">Configure the API definition</h2>

<p>Edit <code class="language-plaintext highlighter-rouge">apiDefinition.swagger.json</code>:</p>

<ol>
  <li>Replace <code class="language-plaintext highlighter-rouge">your-mcp-server.example.com</code> with the MCP hostname, without <code class="language-plaintext highlighter-rouge">https://</code> or a path.</li>
  <li>Replace <code class="language-plaintext highlighter-rouge">/mcp</code> in <code class="language-plaintext highlighter-rouge">basePath</code> with the MCP endpoint path.</li>
  <li>Replace the authorization and token endpoint URLs.</li>
  <li>Replace <code class="language-plaintext highlighter-rouge">mcp.tools</code> with the scope your server requires.</li>
</ol>

<p>For multiple scopes, add each one to both <code class="language-plaintext highlighter-rouge">securityDefinitions.oauth2_auth.scopes</code> and <code class="language-plaintext highlighter-rouge">security[0].oauth2_auth</code>.</p>

<p>The single operation carries the marker that tells Power Platform this is MCP traffic rather than a REST call:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"paths"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
  </span><span class="nl">"/"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"post"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"operationId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"InvokeMCP"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"summary"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Invoke MCP with public-client PKCE"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"x-ms-agentic-protocol"</span><span class="p">:</span><span class="w"> </span><span class="s2">"mcp-streamable-1.0"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"responses"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"200"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"MCP response"</span><span class="w"> </span><span class="p">}</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Keep <code class="language-plaintext highlighter-rouge">x-ms-agentic-protocol</code> and keep the operation at path <code class="language-plaintext highlighter-rouge">/</code> beneath <code class="language-plaintext highlighter-rouge">basePath</code>. The MCP path lives in <code class="language-plaintext highlighter-rouge">basePath</code>, not in the operation path.</p>

<h2 id="configure-the-connector-properties">Configure the connector properties</h2>

<p>The OAuth settings in <code class="language-plaintext highlighter-rouge">apiProperties.json</code> are where the public client is declared:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"oAuthSettings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
  </span><span class="nl">"identityProvider"</span><span class="p">:</span><span class="w"> </span><span class="s2">"oauth2pkcewithdcr"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"clientId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"[YOUR_CLIENT_ID]"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"scopes"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"mcp.tools"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"redirectMode"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Global"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"redirectUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"[YOUR_REDIRECT_URL]"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"IsFirstParty"</span><span class="p">:</span><span class="w"> </span><span class="s2">"False"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"IsOnbehalfofLoginSupported"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"customParameters"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"serverUrl"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://your-mcp-server.example.com"</span><span class="w"> </span><span class="p">},</span><span class="w">
    </span><span class="nl">"authorizationUrl"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://your-auth-server.example.com/oauth/authorize"</span><span class="w"> </span><span class="p">},</span><span class="w">
    </span><span class="nl">"tokenUrl"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://your-auth-server.example.com/oauth/token"</span><span class="w"> </span><span class="p">},</span><span class="w">
    </span><span class="nl">"refreshUrl"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://your-auth-server.example.com/oauth/token"</span><span class="w"> </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Replace the client ID, the scope, and the four URLs. <code class="language-plaintext highlighter-rouge">serverUrl</code> is the MCP server origin without the endpoint path — the path stays in the swagger <code class="language-plaintext highlighter-rouge">basePath</code>.</p>

<p>Leave <code class="language-plaintext highlighter-rouge">[YOUR_REDIRECT_URL]</code> alone. Power Platform overwrites it with the generated per-connector callback URL at deployment.</p>

<p>Never add a <code class="language-plaintext highlighter-rouge">clientSecret</code> property. Adding one turns this into a confidential client and defeats the whole exercise. The scope list here also has to match the scopes in <code class="language-plaintext highlighter-rouge">apiDefinition.swagger.json</code>.</p>

<h2 id="create-the-connector">Create the connector</h2>

<p>Confirm the target environment first:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">auth</span><span class="w"> </span><span class="nx">list</span><span class="w">
</span><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">list</span><span class="w"> </span><span class="nt">--environment</span><span class="w"> </span><span class="nx">POWER_PLATFORM_ENVIRONMENT_ID_OR_URL</span><span class="w">
</span></code></pre></div></div>

<p>Then create it:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--environment</span><span class="w"> </span><span class="nx">POWER_PLATFORM_ENVIRONMENT_ID_OR_URL</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-definition-file</span><span class="w"> </span><span class="o">.</span><span class="nx">\apiDefinition.swagger.json</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-properties-file</span><span class="w"> </span><span class="o">.</span><span class="nx">\apiProperties.json</span><span class="w">
</span></code></pre></div></div>

<p>Don’t pass <code class="language-plaintext highlighter-rouge">--script-file</code>. Record the connector ID that PAC returns — the next step needs it. Power Platform validates both files during creation, so resolve any errors PAC reports before continuing.</p>

<h2 id="register-the-generated-callback-url">Register the generated callback URL</h2>

<p>Power Platform generates the redirect URL, so you have to read it back out of the deployed connector:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">download</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--environment</span><span class="w"> </span><span class="nx">POWER_PLATFORM_ENVIRONMENT_ID_OR_URL</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--connector-id</span><span class="w"> </span><span class="nx">CONNECTOR_ID</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--outputDirectory</span><span class="w"> </span><span class="o">.</span><span class="nx">\deployed-connector</span><span class="w">
</span></code></pre></div></div>

<p>Open <code class="language-plaintext highlighter-rouge">deployed-connector\apiProperties.json</code> and find:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>properties.connectionParameters.token.oAuthSettings.redirectUrl
</code></pre></div></div>

<p>Register that exact URL with your authorization server’s public-client registration. It uses HTTPS and the <code class="language-plaintext highlighter-rouge">global.consent.azure-apim.net</code> host. The callback is specific to this connector in this environment, so a connector deployed to a second environment needs its own registration.</p>

<h2 id="create-and-test-the-connection">Create and test the connection</h2>

<ol>
  <li>Open <a href="https://make.powerautomate.com">Power Automate</a> and select the environment you deployed to.</li>
  <li>Open <strong>Custom connectors</strong> and edit <strong>MCP Public Client PKCE</strong>.</li>
  <li>Open <strong>Test</strong> and select <strong>New connection</strong>.</li>
  <li>If a client-secret field appears, leave it blank.</li>
  <li>Complete authorization and consent, then refresh the connection list.</li>
</ol>

<p>Then wire it into an agent:</p>

<ol>
  <li>Open <a href="https://copilotstudio.microsoft.com">Copilot Studio</a> and select the same environment.</li>
  <li>Open a non-production test agent, go to <strong>Tools</strong>, and select <strong>Add a tool</strong>.</li>
  <li>Select <strong>MCP Public Client PKCE</strong> and its connection, then add it to the agent.</li>
  <li>Confirm Copilot Studio discovers the remote tools, and invoke a safe read-only one.</li>
</ol>

<h2 id="validate-refresh-behavior">Validate refresh behavior</h2>

<p>A successful first tool call proves that authorization code with PKCE worked and that MCP connectivity is fine. It says nothing about refresh, which is where the <code class="language-plaintext highlighter-rouge">client_secret={ClientSecret}</code> template matters.</p>

<p>To test it:</p>

<ol>
  <li>Keep the same connection and agent.</li>
  <li>Wait for the access token to expire, plus a clock-skew buffer.</li>
  <li>Don’t repair or recreate the connection.</li>
  <li>Invoke the same read-only tool again.</li>
  <li>Check sanitized authorization-server logs for the refresh request.</li>
</ol>

<p>If the normal access-token lifetime is too long to wait out, use a non-production client or policy with a shorter lifetime. A long-lived access token postpones the question rather than answering it.</p>

<p>Record only these observations:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Refresh request observed: yes/no
grant_type: refresh_token/other
client_id present: yes/no
client_secret presence: absent/empty/populated
HTTP Basic client authentication present: true/false
Refresh outcome: succeeded/rejected
OAuth error code, if any:
Post-refresh MCP tool outcome: succeeded/failed
</code></pre></div></div>

<p>Never record tokens, codes, verifiers, secrets, cookies, or authorization headers.</p>

<h2 id="interpret-the-result">Interpret the result</h2>

<table>
  <thead>
    <tr>
      <th>Observation</th>
      <th>Interpretation</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">client_secret</code> absent, no HTTP Basic authentication, refresh succeeds</td>
      <td>Strict public-client refresh behavior works</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">client_secret</code> empty and refresh succeeds</td>
      <td>A compatibility path, but only if the authorization-server owner approves empty-value handling</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">client_secret</code> empty and refresh fails</td>
      <td>The current provider refresh template is incompatible</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">client_secret</code> populated</td>
      <td>Confidential-client behavior is being applied</td>
    </tr>
    <tr>
      <td>HTTP Basic authentication present</td>
      <td>Client authentication is being applied</td>
    </tr>
    <tr>
      <td>No refresh request observed</td>
      <td>The test is inconclusive — the token probably hadn’t expired</td>
    </tr>
  </tbody>
</table>

<p>The result you want:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>client_secret presence: absent
HTTP Basic client authentication present: false
Refresh outcome: succeeded
Post-refresh MCP tool outcome: succeeded
</code></pre></div></div>

<p>Nothing in the OAuth specifications requires an authorization server to ignore an unexpected empty <code class="language-plaintext highlighter-rouge">client_secret</code>. Confirm empty-value behavior with the authorization-server owner instead of assuming that empty and absent mean the same thing.</p>

<h2 id="update-the-connector">Update the connector</h2>

<p>After changing either file:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">update</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--environment</span><span class="w"> </span><span class="nx">POWER_PLATFORM_ENVIRONMENT_ID_OR_URL</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--connector-id</span><span class="w"> </span><span class="nx">CONNECTOR_ID</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-definition-file</span><span class="w"> </span><span class="o">.</span><span class="nx">\apiDefinition.swagger.json</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-properties-file</span><span class="w"> </span><span class="o">.</span><span class="nx">\apiProperties.json</span><span class="w">
</span></code></pre></div></div>

<p>Delete and recreate existing connections after any OAuth configuration change. Cached connections hold the old settings and will fail in ways that look like server problems.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/MCP%20Public%20Client%20PKCE">MCP Public Client PKCE template</a></li>
  <li><a href="https://github.com/microsoft/PowerPlatformConnectors/tree/dev/custom-connectors/MCP-Streamable-HTTP">Microsoft MCP Streamable HTTP connector template</a></li>
  <li><a href="https://troystaylor.com/power%20platform/custom%20connectors/2026-08-07-update-custom-connector-oauth-identity-providers-pac-cli.html">Update custom connector OAuth identity providers with PAC CLI</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-copilot-studio/mcp-add-existing-server-to-agent">Connect a Copilot Studio agent to an existing MCP server</a></li>
  <li><a href="https://learn.microsoft.com/connectors/custom-connectors/define-blank#step-5-test-the-connector">Create and test a custom connector</a></li>
  <li><a href="https://www.rfc-editor.org/rfc/rfc6749.html">RFC 6749: The OAuth 2.0 authorization framework</a></li>
  <li><a href="https://www.rfc-editor.org/rfc/rfc7636.html">RFC 7636: Proof key for code exchange by OAuth public clients</a></li>
  <li><a href="https://www.rfc-editor.org/rfc/rfc8252.html">RFC 8252: OAuth 2.0 for native apps</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="MCP" /><category term="PKCE" /><category term="OAuth" /><category term="MCP" /><category term="Copilot Studio" /><category term="Custom Connectors" /><category term="PAC CLI" /><category term="Power Automate" /><summary type="html"><![CDATA[A script-free Power Platform custom connector template that authenticates to a remote MCP Streamable HTTP server with authorization code, PKCE S256, and a static public client that has no client secret.]]></summary></entry><entry><title type="html">Run long Azure operations from Power Automate without connector timeouts</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/azure/2026-09-02-azure-async-operations-power-automate-custom-connector.html" rel="alternate" type="text/html" title="Run long Azure operations from Power Automate without connector timeouts" /><published>2026-09-02T21:00:00+00:00</published><updated>2026-09-02T21:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/azure/azure-async-operations-power-automate-custom-connector</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/azure/2026-09-02-azure-async-operations-power-automate-custom-connector.html"><![CDATA[<p>Creating a storage account might finish in seconds. Deploying a virtual machine, Azure Kubernetes Service (AKS) cluster, or Azure Resource Manager (ARM) template can take much longer. A Power Platform custom connector script has a two-minute execution limit, so waiting inside <code class="language-plaintext highlighter-rouge">script.csx</code> makes long operations unreliable.</p>

<p>Two projects solve this together:</p>

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Connector-Code/Azure%20Async%20Operation%20Polling">Azure Async Operation Polling</a> is the reusable script pattern. Copy it into a connector for ARM, Microsoft Fabric, Microsoft Foundry, Power BI, or another service with a long-running operation API.</li>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Connector-Code/Azure%20Async%20Operations">Azure Async Operations</a> is a complete ARM custom connector built from that pattern. It creates, updates, deletes, and invokes actions on Azure resources.</li>
</ul>

<p>The connector doesn’t wait for Azure to finish. A start action makes one request and returns an operation handle. A second action checks the status once. A Power Automate <strong>Do Until</strong> loop controls the delay and repeats the check.</p>

<h2 id="move-the-waiting-into-the-flow">Move the waiting into the flow</h2>

<p>The custom code follows one rule: each connector action makes no more than one outbound request.</p>

<pre><code class="language-mermaid">flowchart LR
    A[Power Automate&lt;br/&gt;start action] --&gt; B[Azure API]
    B --&gt; C[Operation handle]
    C --&gt; D[Do Until]
    D --&gt; E[Delay]
    E --&gt; F[Get Operation Status]
    F --&gt; B
    F --&gt;|Running| D
    F --&gt;|Succeeded, Failed, or Canceled| G[Continue flow]
</code></pre>

<p>Both actions return the same envelope:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Running"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"isComplete"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w">
  </span><span class="nl">"retryAfterSeconds"</span><span class="p">:</span><span class="w"> </span><span class="mi">15</span><span class="p">,</span><span class="w">
  </span><span class="nl">"operationHandle"</span><span class="p">:</span><span class="w"> </span><span class="s2">"eyJ1IjoiaHR0cHM6Ly9tYW5hZ2VtZW50..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"result"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
  </span><span class="nl">"error"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The script normalizes service-specific states into four values:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Running</code></li>
  <li><code class="language-plaintext highlighter-rouge">Succeeded</code></li>
  <li><code class="language-plaintext highlighter-rouge">Failed</code></li>
  <li><code class="language-plaintext highlighter-rouge">Canceled</code></li>
</ul>

<p>An unfamiliar status stays <code class="language-plaintext highlighter-rouge">Running</code>. Treating an unknown value as success could let the flow continue while Azure is still changing a resource.</p>

<h2 id="build-the-power-automate-loop">Build the Power Automate loop</h2>

<p>Use the same flow shape for every operation:</p>

<ol>
  <li>Add one of the connector’s start actions.</li>
  <li>Initialize <code class="language-plaintext highlighter-rouge">handle</code>, <code class="language-plaintext highlighter-rouge">isComplete</code>, and <code class="language-plaintext highlighter-rouge">finalStatus</code> from the response.</li>
  <li>Add a <strong>Do Until</strong> loop that runs until <code class="language-plaintext highlighter-rouge">isComplete</code> equals <code class="language-plaintext highlighter-rouge">true</code>.</li>
  <li>Inside the loop, add a <strong>Delay</strong> using <code class="language-plaintext highlighter-rouge">retryAfterSeconds</code>.</li>
  <li>Call <strong>Get Operation Status</strong> with <code class="language-plaintext highlighter-rouge">handle</code>.</li>
  <li>Set <code class="language-plaintext highlighter-rouge">isComplete</code> and <code class="language-plaintext highlighter-rouge">finalStatus</code> from the status response.</li>
  <li>If the new <code class="language-plaintext highlighter-rouge">operationHandle</code> isn’t empty, update <code class="language-plaintext highlighter-rouge">handle</code>.</li>
  <li>After the loop, branch on <code class="language-plaintext highlighter-rouge">finalStatus</code>. Read <code class="language-plaintext highlighter-rouge">result</code> for success or <code class="language-plaintext highlighter-rouge">error</code> for failure.</li>
</ol>

<p>Set the loop count and timeout high enough for the slowest expected operation. Every iteration uses one connector request.</p>

<p>The condition in step 7 matters. Power Automate rejects a variable update that references the same variable:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Self reference is not supported when updating the value of variable
</code></pre></div></div>

<p>An expression such as <code class="language-plaintext highlighter-rouge">coalesce(body('Get_Operation_Status')?['operationHandle'], variables('handle'))</code> won’t work. Use a condition and update the variable only when the response contains a handle. The final status response returns a null handle, so the condition also avoids replacing the last valid value with null.</p>

<h2 id="handle-both-azure-polling-shapes">Handle both Azure polling shapes</h2>

<p><a href="https://learn.microsoft.com/azure/azure-resource-manager/management/async-operations">Azure documents two ways to track an asynchronous operation</a>. The start response tells the caller which one to use.</p>

<table>
  <thead>
    <tr>
      <th>Polling shape</th>
      <th>Start response</th>
      <th>Status response</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Status document</td>
      <td><code class="language-plaintext highlighter-rouge">Azure-AsyncOperation</code> or <code class="language-plaintext highlighter-rouge">Operation-Location</code> header</td>
      <td>HTTP 200 with a status field</td>
    </tr>
    <tr>
      <td>Location</td>
      <td><code class="language-plaintext highlighter-rouge">Location</code> header on HTTP 202</td>
      <td>HTTP 202 while running, then HTTP 200 with the result</td>
    </tr>
  </tbody>
</table>

<p>The distinction has two sharp edges.</p>

<p>First, an async header takes precedence over the HTTP status. A network security group create can return HTTP 201 with <code class="language-plaintext highlighter-rouge">Azure-AsyncOperation</code>. Code that assumes every non-202 response has finished will report success too early.</p>

<p>Second, <code class="language-plaintext highlighter-rouge">Location</code> means “poll this URL” only when the response is HTTP 202. A <code class="language-plaintext highlighter-rouge">Location</code> header on HTTP 201 often points to the resource that was just created. Polling that URL would mistake the resource document for an operation status.</p>

<p>The script resolves these rules when the operation starts:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="nf">TryHeader</span><span class="p">(</span><span class="n">response</span><span class="p">,</span> <span class="s">"Azure-AsyncOperation"</span><span class="p">,</span> <span class="k">out</span> <span class="n">url</span><span class="p">))</span>
    <span class="k">return</span> <span class="k">new</span> <span class="n">JObject</span> <span class="p">{</span> <span class="p">[</span><span class="s">"u"</span><span class="p">]</span> <span class="p">=</span> <span class="n">url</span><span class="p">,</span> <span class="p">[</span><span class="s">"k"</span><span class="p">]</span> <span class="p">=</span> <span class="s">"azureAsync"</span><span class="p">,</span> <span class="p">[</span><span class="s">"v"</span><span class="p">]</span> <span class="p">=</span> <span class="n">HANDLE_VERSION</span> <span class="p">};</span>

<span class="k">if</span> <span class="p">(</span><span class="nf">TryHeader</span><span class="p">(</span><span class="n">response</span><span class="p">,</span> <span class="s">"Operation-Location"</span><span class="p">,</span> <span class="k">out</span> <span class="n">url</span><span class="p">))</span>
    <span class="k">return</span> <span class="k">new</span> <span class="n">JObject</span> <span class="p">{</span> <span class="p">[</span><span class="s">"u"</span><span class="p">]</span> <span class="p">=</span> <span class="n">url</span><span class="p">,</span> <span class="p">[</span><span class="s">"k"</span><span class="p">]</span> <span class="p">=</span> <span class="s">"azureAsync"</span><span class="p">,</span> <span class="p">[</span><span class="s">"v"</span><span class="p">]</span> <span class="p">=</span> <span class="n">HANDLE_VERSION</span> <span class="p">};</span>

<span class="k">if</span> <span class="p">(</span><span class="n">response</span><span class="p">.</span><span class="n">StatusCode</span> <span class="p">!=</span> <span class="n">HttpStatusCode</span><span class="p">.</span><span class="n">Accepted</span><span class="p">)</span>
    <span class="k">return</span> <span class="k">null</span><span class="p">;</span>

<span class="k">if</span> <span class="p">(</span><span class="n">response</span><span class="p">.</span><span class="n">Headers</span><span class="p">.</span><span class="n">Location</span> <span class="p">!=</span> <span class="k">null</span><span class="p">)</span>
    <span class="k">return</span> <span class="k">new</span> <span class="n">JObject</span> <span class="p">{</span> <span class="p">[</span><span class="s">"u"</span><span class="p">]</span> <span class="p">=</span> <span class="n">response</span><span class="p">.</span><span class="n">Headers</span><span class="p">.</span><span class="n">Location</span><span class="p">.</span><span class="nf">ToString</span><span class="p">(),</span> <span class="p">[</span><span class="s">"k"</span><span class="p">]</span> <span class="p">=</span> <span class="s">"location"</span><span class="p">,</span> <span class="p">[</span><span class="s">"v"</span><span class="p">]</span> <span class="p">=</span> <span class="n">HANDLE_VERSION</span> <span class="p">};</span>
</code></pre></div></div>

<p>Operations that return no async header have completed inline. An action such as <code class="language-plaintext highlighter-rouge">listKeys</code> can return <code class="language-plaintext highlighter-rouge">isComplete: true</code> and put its response body in <code class="language-plaintext highlighter-rouge">result</code> without entering the loop.</p>

<h2 id="keep-the-operation-handle-opaque">Keep the operation handle opaque</h2>

<p>The handle is compact, base64url-encoded JSON:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"u"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://management.azure.com/..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"k"</span><span class="p">:</span><span class="w"> </span><span class="s2">"location"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"v"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>It records the polling URL, polling shape, and schema version. The flow doesn’t need to parse any of them. Base64url makes the value convenient to carry through a flow, but it doesn’t sign or encrypt it.</p>

<p>A flow variable is user-editable, and each status request carries the connection’s bearer token. The script rejects a handle unless its polling URL is absolute HTTPS, which prevents cleartext forwarding. When adapting the template for production, also allowlist the service’s expected polling hosts or add an integrity check. HTTPS alone doesn’t prove that an edited handle still points to Azure.</p>

<h2 id="return-predictable-errors">Return predictable errors</h2>

<p>Each action returns HTTP 200 to Power Automate. A failed Azure operation appears in the response envelope:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Failed"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"isComplete"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"retryAfterSeconds"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
  </span><span class="nl">"operationHandle"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
  </span><span class="nl">"error"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"code"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ResourceGroupNotFound"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Resource group 'rg-missing' could not be found."</span><span class="p">,</span><span class="w">
    </span><span class="nl">"httpStatus"</span><span class="p">:</span><span class="w"> </span><span class="mi">404</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>This keeps the Do Until loop on one branch. The flow checks <code class="language-plaintext highlighter-rouge">status</code> instead of catching a connector exception midway through the loop.</p>

<p>The script also preserves nested Azure errors. If a template deployment fails because a storage account name is taken, the top-level <code class="language-plaintext highlighter-rouge">DeploymentFailed</code> message remains in <code class="language-plaintext highlighter-rouge">error</code>, and the underlying conflict remains in <code class="language-plaintext highlighter-rouge">error.details</code>.</p>

<p><code class="language-plaintext highlighter-rouge">error.httpStatus</code> appears only when the HTTP request failed. Azure can return HTTP 200 for a status document whose operation state is <code class="language-plaintext highlighter-rouge">Failed</code>; adding <code class="language-plaintext highlighter-rouge">"httpStatus": 200</code> to that error would be misleading.</p>

<h2 id="use-the-arm-connector">Use the ARM connector</h2>

<p>The complete Azure Async Operations connector exposes six actions.</p>

<table>
  <thead>
    <tr>
      <th>Action</th>
      <th>Method</th>
      <th>Use</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Create or Update Resource</strong></td>
      <td>PUT</td>
      <td>Create or update an ARM resource</td>
    </tr>
    <tr>
      <td><strong>Delete Resource</strong></td>
      <td>DELETE</td>
      <td>Delete an ARM resource</td>
    </tr>
    <tr>
      <td><strong>Invoke Resource Action</strong></td>
      <td>POST</td>
      <td>Run <code class="language-plaintext highlighter-rouge">restart</code>, <code class="language-plaintext highlighter-rouge">start</code>, <code class="language-plaintext highlighter-rouge">deallocate</code>, <code class="language-plaintext highlighter-rouge">regenerateKey</code>, or another action</td>
    </tr>
    <tr>
      <td><strong>Create or Update Child Resource</strong></td>
      <td>PUT</td>
      <td>Create or update a nested resource such as a subnet or VM extension</td>
    </tr>
    <tr>
      <td><strong>Delete Child Resource</strong></td>
      <td>DELETE</td>
      <td>Delete a nested resource</td>
    </tr>
    <tr>
      <td><strong>Get Operation Status</strong></td>
      <td>GET</td>
      <td>Perform one status check</td>
    </tr>
  </tbody>
</table>

<p>The start actions accept the target API version, so one connector can work across resource providers.</p>

<table>
  <thead>
    <tr>
      <th>Goal</th>
      <th>ARM path</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Create a storage account</td>
      <td><code class="language-plaintext highlighter-rouge">/subscriptions/{sub}/resourcegroups/{rg}/providers/Microsoft.Storage/storageAccounts/{name}</code></td>
    </tr>
    <tr>
      <td>Restart a virtual machine</td>
      <td><code class="language-plaintext highlighter-rouge">/subscriptions/{sub}/resourcegroups/{rg}/providers/Microsoft.Compute/virtualMachines/{vm}/restart</code></td>
    </tr>
    <tr>
      <td>Add a subnet</td>
      <td><code class="language-plaintext highlighter-rouge">/subscriptions/{sub}/resourcegroups/{rg}/providers/Microsoft.Network/virtualNetworks/{vnet}/subnets/{subnet}</code></td>
    </tr>
    <tr>
      <td>Deploy an ARM template</td>
      <td><code class="language-plaintext highlighter-rouge">/subscriptions/{sub}/resourcegroups/{rg}/providers/Microsoft.Resources/deployments/{name}</code></td>
    </tr>
  </tbody>
</table>

<p>Use <strong>Create or Update Resource</strong> for a template deployment. Its deployment resource follows the standard ARM resource path.</p>

<h2 id="deploy-the-arm-connector">Deploy the ARM connector</h2>

<p>You need:</p>

<ul>
  <li>An Azure subscription and permission to manage the target resources</li>
  <li>An Entra app registration</li>
  <li>A Power Platform environment that allows custom connectors</li>
  <li><a href="https://learn.microsoft.com/power-platform/developer/cli/introduction">Power Platform CLI</a></li>
</ul>

<p>Register an Entra application, add the <strong>Azure Service Management</strong> <code class="language-plaintext highlighter-rouge">user_impersonation</code> delegated permission, grant admin consent, and create a client secret. Put the application ID in <code class="language-plaintext highlighter-rouge">apiProperties.json</code>, but don’t commit the secret.</p>

<p>Deploy all three connector files:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-definition-file</span><span class="w"> </span><span class="nx">apiDefinition.swagger.json</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-properties-file</span><span class="w"> </span><span class="nx">apiProperties.json</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--script-file</span><span class="w"> </span><span class="nx">script.csx</span><span class="w">
</span></code></pre></div></div>

<p>The connector authenticates for the <code class="language-plaintext highlighter-rouge">https://management.azure.com</code> audience. Azure also requires enough permission to read the operation status URL. <a href="https://learn.microsoft.com/azure/azure-resource-manager/management/async-operations#permission-for-tracking-async-status">The ARM guidance</a> notes that status tracking can require resource group-level permission even when the caller can start an operation at the resource level.</p>

<h3 id="register-the-generated-redirect-url">Register the generated redirect URL</h3>

<p>After deployment, Power Platform replaces the placeholder redirect URL with a connector-specific URL. Download the deployed connector and read:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>properties.connectionParameters.token.oAuthSettings.redirectUrl
</code></pre></div></div>

<p>Add that generated URL to the Entra app registration. OAuth consent will fail if only the generic <code class="language-plaintext highlighter-rouge">https://global.consent.azure-apim.net/redirect</code> URL is registered. Each Power Platform environment generates a different redirect URL.</p>

<h2 id="adapt-the-polling-template">Adapt the polling template</h2>

<p>The standalone project contains <code class="language-plaintext highlighter-rouge">script.csx</code> without an OpenAPI definition. Copy it into an existing connector and change three settings:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="k">static</span> <span class="k">readonly</span> <span class="n">HashSet</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="n">START_OPERATIONS</span> <span class="p">=</span>
    <span class="k">new</span> <span class="n">HashSet</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;(</span><span class="n">StringComparer</span><span class="p">.</span><span class="n">Ordinal</span><span class="p">)</span>
<span class="p">{</span>
    <span class="s">"StartLongRunningOperation"</span>
<span class="p">};</span>

<span class="k">private</span> <span class="k">const</span> <span class="kt">string</span> <span class="n">POLL_OPERATION</span> <span class="p">=</span> <span class="s">"GetOperationStatus"</span><span class="p">;</span>
<span class="k">private</span> <span class="k">const</span> <span class="kt">string</span> <span class="n">HANDLE_PARAMETER</span> <span class="p">=</span> <span class="s">"operationHandle"</span><span class="p">;</span>
</code></pre></div></div>

<p>Then:</p>

<ol>
  <li>Add every start and poll operation to <code class="language-plaintext highlighter-rouge">scriptOperations</code> in <code class="language-plaintext highlighter-rouge">apiProperties.json</code>.</li>
  <li>Review <code class="language-plaintext highlighter-rouge">NormalizeStatus</code> for the service’s status vocabulary.</li>
  <li>Review <code class="language-plaintext highlighter-rouge">NormalizeError</code> for the service’s error body.</li>
  <li>Confirm which header or operation ID identifies the polling URL.</li>
  <li>Test synchronous success, synchronous failure, async success, async failure, and an unknown status.</li>
</ol>

<p>Build one connector per service. A connection carries one OAuth audience, so an ARM connector can’t also call Fabric or Foundry with the same token.</p>

<table>
  <thead>
    <tr>
      <th>Service</th>
      <th>OAuth audience</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Azure Resource Manager</td>
      <td><code class="language-plaintext highlighter-rouge">https://management.azure.com</code></td>
    </tr>
    <tr>
      <td>Microsoft Fabric</td>
      <td><code class="language-plaintext highlighter-rouge">https://api.fabric.microsoft.com</code></td>
    </tr>
    <tr>
      <td>Microsoft Foundry Agent Service</td>
      <td><code class="language-plaintext highlighter-rouge">https://ai.azure.com</code></td>
    </tr>
    <tr>
      <td>Azure Cognitive Services</td>
      <td><code class="language-plaintext highlighter-rouge">https://cognitiveservices.azure.com</code></td>
    </tr>
    <tr>
      <td>Power BI</td>
      <td><code class="language-plaintext highlighter-rouge">https://analysis.windows.net/powerbi/api</code></td>
    </tr>
  </tbody>
</table>

<h2 id="test-the-boundary-cases">Test the boundary cases</h2>

<p>The implementation was checked against live ARM with 66 assertions. Coverage included:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Location</code> polling from storage account creation</li>
  <li><code class="language-plaintext highlighter-rouge">Azure-AsyncOperation</code> polling from network security group creation</li>
  <li>A start call that returned in under one second</li>
  <li>A slowest observed poll of 0.3 seconds</li>
  <li>Synchronous HTTP 400 and 404 failures</li>
  <li>An ARM deployment that failed during provisioning</li>
  <li>Inline completion from <code class="language-plaintext highlighter-rouge">listKeys</code></li>
  <li>Subnet create and delete operations</li>
  <li>Malformed, empty, and non-HTTPS handles</li>
  <li>Known and unknown status values</li>
</ul>

<p>The connector was also deployed and run through a Power Automate Do Until loop. ARM supplied a 17-second retry interval, the flow completed after three polls, and the final result reported <code class="language-plaintext highlighter-rouge">provisioningState: Succeeded</code>.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Connector-Code/Azure%20Async%20Operation%20Polling">Azure Async Operation Polling template</a></li>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Connector-Code/Azure%20Async%20Operations">Azure Async Operations connector</a></li>
  <li><a href="https://learn.microsoft.com/azure/azure-resource-manager/management/async-operations">Track asynchronous Azure operations</a></li>
  <li><a href="https://learn.microsoft.com/connectors/custom-connectors/write-code">Write code in a custom connector</a></li>
  <li><a href="https://learn.microsoft.com/connectors/custom-connectors/define-openapi-definition">Create a custom connector from an OpenAPI definition</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="Azure" /><category term="Azure" /><category term="Azure Resource Manager" /><category term="Power Automate" /><category term="Custom Connectors" /><category term="Long-running Operations" /><category term="Polling" /><category term="Entra ID" /><category term="PAC CLI" /><summary type="html"><![CDATA[Use a reusable polling script and an Azure Resource Manager custom connector to start long-running operations, return immediately, and let Power Automate poll to completion.]]></summary></entry><entry><title type="html">Migrate Copilot Studio agents to Microsoft Entra Agent ID with v1.1</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-08-27-copilot-studio-bots-v1-1-entra-agent-id-migration.html" rel="alternate" type="text/html" title="Migrate Copilot Studio agents to Microsoft Entra Agent ID with v1.1" /><published>2026-08-27T21:30:00+00:00</published><updated>2026-08-27T21:30:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/mcp/copilot-studio-bots-v1-1-entra-agent-id-migration</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-08-27-copilot-studio-bots-v1-1-entra-agent-id-migration.html"><![CDATA[<p>Copilot Studio changed how it identifies agents in May 2026. New agents now receive a Microsoft Entra Agent ID, while older agents keep the app registration Copilot Studio created for them.</p>

<p>Microsoft plans to migrate those older agents in a future update. <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Studio%20Bots">Copilot Studio Bots v1.1</a> lets you move first, test the result, and roll back an agent that fails validation.</p>

<p>The release adds two Power Automate actions and two Model Context Protocol (MCP) tools to the evaluation, inventory, and containment connector introduced in the <a href="/power%20platform/custom%20connectors/mcp/2026-08-20-copilot-studio-bots-connector.html">original post</a>.</p>

<h2 id="whats-new-in-v11">What’s new in v1.1</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th style="text-align: right">v1.0</th>
      <th style="text-align: right">v1.1</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Documented Bots API operations</td>
      <td style="text-align: right">13</td>
      <td style="text-align: right">13</td>
    </tr>
    <tr>
      <td>Power Automate actions</td>
      <td style="text-align: right">16</td>
      <td style="text-align: right">18</td>
    </tr>
    <tr>
      <td>MCP tools</td>
      <td style="text-align: right">16</td>
      <td style="text-align: right">18</td>
    </tr>
    <tr>
      <td>OpenAPI operations, including MCP</td>
      <td style="text-align: right">17</td>
      <td style="text-align: right">19</td>
    </tr>
    <tr>
      <td>Agent identity migration</td>
      <td style="text-align: right">No</td>
      <td style="text-align: right">Migrate and roll back</td>
    </tr>
  </tbody>
</table>

<p>The two new Power Automate actions are:</p>

<table>
  <thead>
    <tr>
      <th>Action</th>
      <th>Result</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Migrate Agent Identity To Entra Agent ID</strong></td>
      <td>Converts an older agent’s app-registration identity to a Microsoft Entra Agent ID</td>
    </tr>
    <tr>
      <td><strong>Roll Back Agent Identity To App Registration</strong></td>
      <td>Reverts a migrated agent when validation fails</td>
    </tr>
  </tbody>
</table>

<p>Copilot Studio agents get matching tools:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">migrate_agent_identity</code></li>
  <li><code class="language-plaintext highlighter-rouge">rollback_agent_identity</code></li>
</ul>

<p>Both take an environment ID and agent ID. The Power Automate actions use the connector’s existing cascading dropdowns, while MCP callers can use <code class="language-plaintext highlighter-rouge">list_agents</code> to find the raw identifiers first.</p>

<h2 id="why-migrate-before-microsoft-does">Why migrate before Microsoft does</h2>

<p>A Microsoft Entra Agent ID gives administrators a first-class identity for each agent. Microsoft lists several benefits:</p>

<ul>
  <li>Agent sign-in and audit logs in Microsoft Entra</li>
  <li>Agent lifecycle management</li>
  <li>Connector permissions visible as API permissions</li>
  <li>Conditional Access policies scoped to agent activity</li>
  <li>Integration with Microsoft Entra ID Governance</li>
</ul>

<p>Manual migration gives you a window to test those controls against your own channels, connectors, actions, flows, and authentication settings before automatic migration reaches the tenant.</p>

<p>The migration converts the identity <strong>in place</strong>. The application (client) ID stays the same, so channel registrations and connectors continue to resolve to the same identifier. A successful response also returns the new <code class="language-plaintext highlighter-rouge">agentIdentityId</code> and <code class="language-plaintext highlighter-rouge">servicePrincipalObjectId</code> for lookup in the Microsoft Entra admin center.</p>

<h2 id="the-api-sits-next-to-the-bots-operations">The API sits next to the Bots operations</h2>

<p>The new routes use the same Power Platform host, agent path, OAuth connection, and <code class="language-plaintext highlighter-rouge">2024-10-01</code> API version as the connector’s 13 Bots operations:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST https://api.powerplatform.com/copilotstudio/
     environments/{environmentId}/bots/{botId}/
     api/agentidentitymigration/migrate?api-version=2024-10-01

POST https://api.powerplatform.com/copilotstudio/
     environments/{environmentId}/bots/{botId}/
     api/agentidentitymigration/rollback?api-version=2024-10-01
</code></pre></div></div>

<p>They aren’t part of the <a href="https://learn.microsoft.com/rest/api/power-platform/copilotstudio/bots">Bots REST operation group</a>. Microsoft documents them separately in the <a href="https://learn.microsoft.com/microsoft-copilot-studio/govern-migrate-api-entra-agent-identity">agent identity migration guide</a>, and the manual migration feature is in preview.</p>

<p>Neither request has a body. A migration returns one of two terminal statuses:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Migrated"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"cdsBotId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"00000000-0000-0000-0000-000000000000"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"environmentId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"00000000-0000-0000-0000-000000000000"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"tenantId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"00000000-0000-0000-0000-000000000000"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"agentIdentityId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"00000000-0000-0000-0000-000000000000"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"applicationId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"00000000-0000-0000-0000-000000000000"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"servicePrincipalObjectId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"00000000-0000-0000-0000-000000000000"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"completedAtUtc"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-08-27T20:00:00Z"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">AlreadyMigrated</code> is also a successful result. Migration is idempotent, so a repeated call doesn’t create a second identity.</p>

<p>Rollback returns <code class="language-plaintext highlighter-rouge">RolledBack</code> or <code class="language-plaintext highlighter-rouge">NotMigrated</code>. <code class="language-plaintext highlighter-rouge">NotMigrated</code> means there was nothing to undo, not that the request failed.</p>

<h2 id="migrate-in-stages">Migrate in stages</h2>

<p>Migration affects a live agent. Microsoft recommends a staged rollout instead of moving the whole estate at once:</p>

<ol>
  <li>Pick a small group of noncritical agents</li>
  <li>Include the channels, authentication modes, connectors, flows, and integrations you need to test</li>
  <li>Coordinate a validation window with the makers</li>
  <li>Migrate one agent or a small batch</li>
  <li>Test every published channel, action, connector, and authentication flow</li>
  <li>Review Microsoft Entra sign-in logs and Conditional Access results</li>
  <li>Roll back failures before starting the next batch</li>
</ol>

<p>The API migrates one agent per request. A Power Automate flow can loop over a planned batch, but leave room between calls. The service can return <code class="language-plaintext highlighter-rouge">429 Too Many Requests</code>, and the connector surfaces that response instead of hiding it behind automatic retries.</p>

<p>Administrative permissions still apply. The caller must be a Power Platform Administrator, Dynamics 365 Administrator, or Global Administrator. A caller without the required role receives <code class="language-plaintext highlighter-rouge">403 Forbidden</code>.</p>

<h2 id="use-an-agent-for-a-plan-and-apply-migration">Use an agent for a plan-and-apply migration</h2>

<p>The existing <code class="language-plaintext highlighter-rouge">list_agents</code> tool makes identity migration easier to control. Let the agent inventory the environment first, then name the agents to migrate:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>User: List the unpublished agents in the sandbox environment.

Agent:
  1. list_agents { environmentId: "&lt;sandbox-environment-id&gt;" }
  2. Presents agent names, owners, publish state, and IDs

User: Migrate Contoso Support Test and Expense Policy Test.

Agent:
  3. migrate_agent_identity { environmentId, botId } for each named agent
  4. Reports Migrated or AlreadyMigrated and the new agent identity IDs

User: Expense Policy Test can no longer authenticate in Teams. Roll it back.

Agent:
  5. rollback_agent_identity { environmentId, botId }
  6. Reports RolledBack
</code></pre></div></div>

<p>This keeps discovery separate from the write. The user sees the inventory and names the migration set before any identity changes.</p>

<p>Rollback makes the operation recoverable, but it doesn’t replace testing. Stop the batch when an agent fails validation, undo that agent, and find the cause before continuing.</p>

<h2 id="update-the-connector">Update the connector</h2>

<p>Replace <code class="language-plaintext highlighter-rouge">REPLACE_WITH_CLIENT_ID</code> in <code class="language-plaintext highlighter-rouge">apiProperties.json</code>, then validate and update the connector with the Power Platform Connector CLI and Power Platform CLI:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">ppcv</span><span class="w"> </span><span class="s2">"./Copilot Studio Bots"</span><span class="w">

</span><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">update</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--connector-id</span><span class="w"> </span><span class="nx">00000000-0000-0000-0000-000000000000</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-definition-file</span><span class="w"> </span><span class="nx">apiDefinition.swagger.json</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-properties-file</span><span class="w"> </span><span class="nx">apiProperties.json</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--script-file</span><span class="w"> </span><span class="nx">script.csx</span><span class="w">
</span></code></pre></div></div>

<p>Keep <code class="language-plaintext highlighter-rouge">--script-file</code>. The MCP endpoint and its two new tools run through <code class="language-plaintext highlighter-rouge">script.csx</code>, and the connector declares script operations in <code class="language-plaintext highlighter-rouge">apiProperties.json</code>.</p>

<p>Existing flows keep their actions. After the update, the two migration actions appear beside evaluation, quarantine, consent bypass, reassignment, deletion, and agent inventory.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Studio%20Bots">Copilot Studio Bots v1.1 source</a></li>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/commit/59247f57bfc530dfac84cc5a17c196bdfef8f904">v1.1 commit</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-copilot-studio/admin-use-entra-agent-identities">Microsoft Entra Agent IDs for Copilot Studio</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-copilot-studio/govern-migrate-api-entra-agent-identity">Migrate agents to Microsoft Entra Agent ID</a></li>
  <li><a href="/power%20platform/custom%20connectors/mcp/2026-08-20-copilot-studio-bots-connector.html">Original Copilot Studio Bots post</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="MCP" /><category term="Copilot Studio" /><category term="Microsoft Entra Agent ID" /><category term="Custom Connectors" /><category term="MCP" /><category term="Power Automate" /><category term="Agent Identity" /><category term="Governance" /><category term="Power Platform API" /><summary type="html"><![CDATA[Copilot Studio Bots v1.1 adds Power Automate actions and MCP tools to migrate older agents to Microsoft Entra Agent ID, validate them in stages, and roll them back when needed.]]></summary></entry><entry><title type="html">Control Deck for ZoomIt: drive Sysinternals ZoomIt from a Stream Deck</title><link href="https://troystaylor.com/developer%20tools/automation/2026-08-21-control-deck-for-zoomit-stream-deck-profile.html" rel="alternate" type="text/html" title="Control Deck for ZoomIt: drive Sysinternals ZoomIt from a Stream Deck" /><published>2026-08-21T21:00:00+00:00</published><updated>2026-08-21T21:00:00+00:00</updated><id>https://troystaylor.com/developer%20tools/automation/control-deck-for-zoomit-stream-deck-profile</id><content type="html" xml:base="https://troystaylor.com/developer%20tools/automation/2026-08-21-control-deck-for-zoomit-stream-deck-profile.html"><![CDATA[<p>ZoomIt has 45 addressable keyboard shortcuts. Mid-presentation, you’ll remember four of them. <a href="https://github.com/troystaylor/Control-Deck-for-ZoomIt">Control Deck for ZoomIt</a> is a free Stream Deck profile that puts the rest on physical keys: zoom, draw, pens, highlighters, the break timer, screen recording, snips, OCR, and scrolling panorama capture.</p>

<p>It’s a profile, not a plugin. That distinction turned out to be the whole design, so it’s worth explaining before anything else.</p>

<p><strong>Submitted to the Elgato Marketplace.</strong> If you want automatic updates, wait for that release rather than installing the GitHub build by hand. The <a href="https://github.com/troystaylor/Control-Deck-for-ZoomIt/releases">GitHub release</a> works today, but you’ll have to re-import it yourself when a new version ships.</p>

<h2 id="why-a-profile-beats-a-plugin-here">Why a profile beats a plugin here</h2>

<p>ZoomIt exposes no API, no IPC, and no command-line trigger. Its entire control surface is global hotkeys plus single keystrokes while a mode is active. Stream Deck’s built-in <strong>Hotkey</strong> action already sends those, for keys and dials alike.</p>

<p>That built-in action is marked <code class="language-plaintext highlighter-rouge">"PrivateAPI": true</code>, so third-party plugins can’t call it, and the SDK has no keyboard API at all. A plugin would mean re-implementing keystroke injection in native code — FFI to <code class="language-plaintext highlighter-rouge">SendInput</code> or a helper executable — and shipping per-architecture binaries through review to do a worse job than the action Elgato already ships.</p>

<p>A plugin could add exactly two things a profile can’t: live state feedback and reading the user’s remapped hotkeys. ZoomIt exposes no state, and its defaults never touch disk, so neither is achievable. PowerToys writes to <code class="language-plaintext highlighter-rouge">HKCU\Software\Sysinternals\ZoomIt</code> only when a hotkey <em>changes</em> from its default, so a fresh install has one value: <code class="language-plaintext highlighter-rouge">EulaAccepted</code>.</p>

<h2 id="whats-on-the-deck">What’s on the deck</h2>

<p>Three pages on a 5×3 MK.2, 45 keys, 41 of ZoomIt’s 45 shortcuts.</p>

<table>
  <thead>
    <tr>
      <th>Page</th>
      <th>Contents</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Main</td>
      <td>Zoom, Draw, Break, Timer, Snip, Snip Save, OCR, Mirror, DemoType, Record, Rec Crop, Rec Window, Whiteboard</td>
    </tr>
    <tr>
      <td>Pens</td>
      <td>Undo, Erase All, Size +, Blur, five pens, five highlighters</td>
    </tr>
    <tr>
      <td>Tools</td>
      <td>LiveZoom, LiveDraw, Text, Text Right, Panorama, Pano Save, Size -, Center, Copy, Copy Crop, Save, Save Crop, Demo Prev</td>
    </tr>
  </tbody>
</table>

<p>Zoom, Draw, Break, LiveZoom, LiveDraw, DemoType, Record, and Panorama are toggles. A second press sends <code class="language-plaintext highlighter-rouge">Esc</code>, and the icon flips to show the mode is live.</p>

<p>Whiteboard is a Multi Action Switch: one press sends <code class="language-plaintext highlighter-rouge">Ctrl+1</code> → <code class="language-plaintext highlighter-rouge">K</code> → <code class="language-plaintext highlighter-rouge">Ctrl+W</code> — zoom, black pen, blank to white. Ordering matters. Send <code class="language-plaintext highlighter-rouge">K</code> after <code class="language-plaintext highlighter-rouge">Ctrl+W</code> and drawing reverts the blank.</p>

<p>Four shortcuts are impossible from a Stream Deck at all: straight line, rectangle, ellipse, and arrow each need a modifier <em>held</em> through a mouse drag, and a key only sends press-release.</p>

<h2 id="three-device-layouts">Three device layouts</h2>

<p>The grid is parameterised in a <code class="language-plaintext highlighter-rouge">device</code> block, so a new variant is a config file rather than a code change. Model IDs and grid sizes came from Elgato’s own per-device defaults in <code class="language-plaintext highlighter-rouge">Program Files\Elgato\StreamDeck\DefaultProfiles</code>.</p>

<table>
  <thead>
    <tr>
      <th>Layout</th>
      <th>Device</th>
      <th>Keys</th>
      <th>Coverage</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">layout.config.json</code></td>
      <td>MK.2 5×3</td>
      <td>45 over 3 pages</td>
      <td>41 of 45</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">layout.xl.config.json</code></td>
      <td>XL 8×4</td>
      <td>44 over 2 pages</td>
      <td>43 of 45</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">layout.mini.config.json</code></td>
      <td>Mini 3×2</td>
      <td>18 over 3 pages</td>
      <td>16 essentials</td>
    </tr>
  </tbody>
</table>

<p>The Mini is deliberately minimal. Fitting the full set into six keys needs roughly nine folder pages, which is worse than useless mid-presentation, so it carries four desktop triggers, five pen colours, and five drawing tools.</p>

<p>Coordinates are validated against the declared grid, so a key that would land off the device fails the build instead of being silently dropped. One caution if you build your own variants: the shipped Stream Deck + default profile reports a 5×3 keypad, which contradicts the hardware. Treat 4×2 as correct.</p>

<h2 id="the-profile-format-rules-nobody-documents">The profile format rules nobody documents</h2>

<p>These came from diffing against working profiles and reading <code class="language-plaintext highlighter-rouge">logs\StreamDeck.log</code>. Break any of them and you get a silent or misleading failure.</p>

<table>
  <thead>
    <tr>
      <th>Requirement</th>
      <th>Symptom if violated</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Page directories named with <strong>UPPERCASE</strong> GUIDs, while manifest references stay lowercase</td>
      <td><code class="language-plaintext highlighter-rouge">RT ERROR: no pages in umbrella</code> → “content corrupted”</td>
    </tr>
    <tr>
      <td>Each controller needs <code class="language-plaintext highlighter-rouge">"Type": "Keypad"</code>; page manifest needs <code class="language-plaintext highlighter-rouge">Name</code> and <code class="language-plaintext highlighter-rouge">Icon</code></td>
      <td>Imports successfully, every page is empty</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Pages.Default</code> must be a separate, empty page not listed in <code class="language-plaintext highlighter-rouge">Pages</code></td>
      <td>Malformed umbrella</td>
    </tr>
    <tr>
      <td>Parent-folder (“back”) action must sit at <code class="language-plaintext highlighter-rouge">0,0</code></td>
      <td>Stream Deck relocates it there, silently overwriting whatever was at <code class="language-plaintext highlighter-rouge">0,0</code></td>
    </tr>
  </tbody>
</table>

<p>Folder pages aren’t listed in <code class="language-plaintext highlighter-rouge">Pages</code> at all. They’re reached only through a Create Folder action’s <code class="language-plaintext highlighter-rouge">Settings.ProfileUUID</code>.</p>

<p>Key encoding was reverse-engineered from real configured Hotkey actions: <code class="language-plaintext highlighter-rouge">NativeCode</code> always matches <code class="language-plaintext highlighter-rouge">VKeyCode</code>, <code class="language-plaintext highlighter-rouge">QTKeyCode</code> matches for letters and digits but uses ASCII for punctuation and the Qt <code class="language-plaintext highlighter-rouge">0x01000000</code> enum range for special keys, and <code class="language-plaintext highlighter-rouge">KeyModifiers</code> is a bitmask with Shift=1, Ctrl=2, Alt=4, Cmd=8. Unconfigured slots use <code class="language-plaintext highlighter-rouge">VKeyCode: -1</code>, <code class="language-plaintext highlighter-rouge">QTKeyCode: 33554431</code>, <code class="language-plaintext highlighter-rouge">NativeCode: 146</code>.</p>

<p>Multi Action compatibility constrains what one key press can do. Hotkey, Delay, and Switch Profile combine; Create Folder, Parent Folder, and Hotkey Switch don’t. That’s why no key can both send a hotkey and open a folder.</p>

<h2 id="import-dont-hand-install">Import, don’t hand-install</h2>

<p>Stream Deck keeps its profile index in a binary <code class="language-plaintext highlighter-rouge">Devices</code> blob under <code class="language-plaintext highlighter-rouge">HKCU:\Software\Elgato Systems GmbH\StreamDeck</code>. A folder copied into <code class="language-plaintext highlighter-rouge">ProfilesV3</code> is never registered and is silently ignored. Import is the only reliable path, and the <code class="language-plaintext highlighter-rouge">streamdeck://app/openfile/&lt;url-encoded-path&gt;</code> deep link works more consistently than the Windows file association.</p>

<p>Two things cost real debugging time here:</p>

<ul>
  <li><strong>Never force-kill Stream Deck.</strong> After <code class="language-plaintext highlighter-rouge">Stop-Process -Force</code>, the next instance either wedges its import subsystem or exits outright. Imports then log <code class="language-plaintext highlighter-rouge">import:</code> with no <code class="language-plaintext highlighter-rouge">import finished</code> and hang forever, with no error.</li>
  <li><strong>Imports hang transiently for no visible reason.</strong> No dialog, no error, nothing after the <code class="language-plaintext highlighter-rouge">import:</code> line. It isn’t the package. Wait a minute and import again — a retry on the same instance usually succeeds.</li>
</ul>

<p>Stream Deck also flushes profile edits to disk lazily, roughly every 12 seconds, so reading a file back immediately after saving in the UI can show stale data.</p>

<h2 id="zoomits-published-shortcuts-are-wrong-in-four-places">ZoomIt’s published shortcuts are wrong in four places</h2>

<p>Reading ZoomIt’s source in the PowerToys repository turned up four errors in the documented shortcut table:</p>

<table>
  <thead>
    <tr>
      <th>Docs say</th>
      <th>Actually</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Size adjust is “Ctrl + Mouse Scroll Up/Down <strong>or Arrow Keys</strong>”</td>
      <td>Bare arrows always change zoom level; size needs <code class="language-plaintext highlighter-rouge">Ctrl+Arrow</code></td>
    </tr>
    <tr>
      <td>“Whiteboard | <strong>W</strong>” and “Blackboard | <strong>K</strong>”</td>
      <td>Those are the white and black <em>pens</em>. The boards are <code class="language-plaintext highlighter-rouge">Ctrl+W</code> and <code class="language-plaintext highlighter-rouge">Ctrl+K</code>, gated on <code class="language-plaintext highlighter-rouge">GetKeyState(VK_CONTROL)</code></td>
    </tr>
    <tr>
      <td>Pen colours are R/G/B/Y/O/P</td>
      <td>There are eight. <code class="language-plaintext highlighter-rouge">W</code> and <code class="language-plaintext highlighter-rouge">K</code> are undocumented pens</td>
    </tr>
    <tr>
      <td>“Start/Stop Panorama <strong>recording</strong>”</td>
      <td>Panorama is a scrolling screenshot, stitched to a still image</td>
    </tr>
  </tbody>
</table>

<p>Two shortcuts are missing from the table entirely: <code class="language-plaintext highlighter-rouge">Ctrl+Shift+8</code> for panorama to file and <code class="language-plaintext highlighter-rouge">Ctrl+9</code> for DemoMirror.</p>

<p>Whiteboard has undocumented conditions too. While the break timer runs, <code class="language-plaintext highlighter-rouge">Ctrl+W</code> and <code class="language-plaintext highlighter-rouge">Ctrl+K</code> change the timer background colour instead. Screen blanking is gated on being zoomed, and it’s blocked in LiveDraw for user-driven input.</p>

<h2 id="two-things-that-will-bite-you">Two things that will bite you</h2>

<p><strong>Toggles track their own state, not ZoomIt’s.</strong> Exit ZoomIt with the physical <code class="language-plaintext highlighter-rouge">Esc</code> key or a right-click and the toggle desyncs, so the next press sends the wrong command. Press twice to resync. ZoomIt exposes no state to query, so a plugin couldn’t fix this either.</p>

<p><strong>Elevation has to match.</strong> If ZoomIt runs elevated, which PowerToys can be configured to do, but Stream Deck doesn’t, UIPI blocks injected input entirely. Run both at the same integrity level.</p>

<p>If the zoom-to-whiteboard transition feels slow, that’s ZoomIt’s telescoping animation, not the profile. Turn off <strong>Animate zoom</strong> in ZoomIt Options → Zoom.</p>

<h2 id="validation">Validation</h2>

<p>Verified on 2026-08-18 against PowerToys ZoomIt v0.100.2 and Stream Deck 7.4.2:</p>

<table>
  <thead>
    <tr>
      <th>Check</th>
      <th>Result</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl+1</code> via synthetic input</td>
      <td>Spawned a <code class="language-plaintext highlighter-rouge">ZoomitClass</code> window</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl+2</code> via synthetic input</td>
      <td>Spawned <code class="language-plaintext highlighter-rouge">ZoomitClass</code>, held foreground</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">R</code> (in-mode red pen)</td>
      <td>Accepted, overlay persisted</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Esc</code></td>
      <td>Dismissed cleanly</td>
    </tr>
    <tr>
      <td>ZoomIt elevation</td>
      <td>Not elevated</td>
    </tr>
    <tr>
      <td>Stream Deck elevation</td>
      <td>Not elevated</td>
    </tr>
  </tbody>
</table>

<p>Because ZoomIt’s overlay takes foreground and both processes run at the same integrity level, injected keystrokes reach it through the same <code class="language-plaintext highlighter-rouge">SendInput</code> path the built-in Hotkey action uses.</p>

<p>One testing note worth passing on: GDI <code class="language-plaintext highlighter-rouge">CopyFromScreen</code> doesn’t capture ZoomIt’s overlay, so pixel sampling can’t verify that a mode opened. Enumerate windows instead.</p>

<h2 id="icons">Icons</h2>

<p>The 47 icons are generated from <a href="https://github.com/microsoft/fluentui-system-icons">Fluent UI System Icons</a>. Fluent SVGs hardcode <code class="language-plaintext highlighter-rouge">fill="#212121"</code>, invisible against black keys, so the build recolours every glyph: recording actions red, pens and highlighters in their own ink colour, everything else white.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install
</span>npm run build:icons       <span class="c"># -&gt; dist/icons/*.png (144x144), dist/svg/*.svg</span>
npm run contact-sheet     <span class="c"># -&gt; dist/contact-sheet.png for visual QA</span>
</code></pre></div></div>

<p>The build resolves the largest available source size, since Fluent redraws each size on its own grid and larger sources carry more detail, and prefers <code class="language-plaintext highlighter-rouge">filled</code> variants, which read better at key size. It fails loudly if a named icon doesn’t exist or has no recolorable fill.</p>

<h2 id="build-it-yourself">Build it yourself</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm run build:profile            <span class="c"># MK.2 (5x3)</span>
npm run build:profile:xl         <span class="c"># Stream Deck XL (8x4)</span>
npm run build:profile:mini       <span class="c"># Stream Deck Mini (3x2)</span>
npm run build:release:all        <span class="c"># every variant -&gt; dist/release/*.streamDeckProfile</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">--release</code> omits keys marked <code class="language-plaintext highlighter-rouge">"machineSpecific": true</code>, which is currently just the ZoomIt launcher key and its absolute path. Paths in configs use <code class="language-plaintext highlighter-rouge">%LOCALAPPDATA%</code>-style variables expanded at build time, so no username ever lands in source.</p>

<p>ZoomIt isn’t included or redistributed here — install it free from <a href="https://learn.microsoft.com/en-us/sysinternals/downloads/zoomit">Sysinternals</a> or as part of <a href="https://github.com/microsoft/PowerToys">Microsoft PowerToys</a>. This project isn’t affiliated with, endorsed by, or sponsored by Microsoft Corporation.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/troystaylor/Control-Deck-for-ZoomIt">Control Deck for ZoomIt</a> — layout configs, generator scripts, and icon build</li>
  <li><a href="https://github.com/troystaylor/Control-Deck-for-ZoomIt/releases">Releases</a> — MK.2, XL, and Mini profiles</li>
  <li><a href="https://learn.microsoft.com/en-us/sysinternals/downloads/zoomit">ZoomIt on Sysinternals</a></li>
  <li><a href="https://github.com/microsoft/PowerToys">Microsoft PowerToys</a></li>
  <li><a href="https://github.com/microsoft/fluentui-system-icons">Fluent UI System Icons</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Developer Tools" /><category term="Automation" /><category term="ZoomIt" /><category term="Stream Deck" /><category term="Sysinternals" /><category term="PowerToys" /><category term="Windows" /><category term="Presentation" /><category term="Screen Recording" /><category term="Elgato" /><summary type="html"><![CDATA[A free Stream Deck profile that puts ZoomIt's zoom, annotation, recording, and snipping shortcuts on physical keys, built by reverse-engineering the Stream Deck profile format and reading ZoomIt's source.]]></summary></entry><entry><title type="html">Fill emailed forms in Copilot Cowork with skills alone</title><link href="https://troystaylor.com/copilot%20studio/automation/2026-08-21-form-autofill-cowork-plugin.html" rel="alternate" type="text/html" title="Fill emailed forms in Copilot Cowork with skills alone" /><published>2026-08-21T19:00:00+00:00</published><updated>2026-08-21T19:00:00+00:00</updated><id>https://troystaylor.com/copilot%20studio/automation/form-autofill-cowork-plugin</id><content type="html" xml:base="https://troystaylor.com/copilot%20studio/automation/2026-08-21-form-autofill-cowork-plugin.html"><![CDATA[<p>Somebody emails you a new starter packet: a Word form, a travel spreadsheet, a benefits guide you’re not supposed to touch, and a PDF declaration. Half the fields are already in your directory profile. The other half you’ve typed into three forms this year. <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Cowork%20Plugins/Form%20Autofill">Form Autofill for Copilot Cowork</a> triages the attachments, fills what it can resolve, asks you about the rest in one batch, verifies the files, and drafts a reply back to the sender for your approval.</p>

<p>It needs Copilot Cowork and nothing else. No MCP server, no custom connector, no OAuth app, no API keys, no Azure resources. The <code class="language-plaintext highlighter-rouge">manifest.json</code> has no <code class="language-plaintext highlighter-rouge">agentConnectors</code> block at all, because there’s nothing to connect to. Testing also ruled out a hosted PDF-filling tool: Cowork fills AcroForms natively and keeps the form layer editable.</p>

<h2 id="three-skills">Three skills</h2>

<table>
  <thead>
    <tr>
      <th>Skill</th>
      <th>Role</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">form-autofill</code></td>
      <td>Orchestrates triage, fill, verify, and reply</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">profile-interview</code></td>
      <td>Collects missing details in one batched ask</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">profile-vault</code></td>
      <td>Persists reusable answers so you aren’t asked twice</td>
    </tr>
  </tbody>
</table>

<h2 id="every-field-lands-in-one-of-three-tiers">Every field lands in one of three tiers</h2>

<p>The tier decides the behavior, and the behavior is the whole design.</p>

<table>
  <thead>
    <tr>
      <th>Tier</th>
      <th>Source</th>
      <th>Persisted</th>
      <th>Behavior</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>Directory profile, fetched live</td>
      <td>No</td>
      <td>Filled silently</td>
    </tr>
    <tr>
      <td>2</td>
      <td>You, once</td>
      <td>Yes, in the vault</td>
      <td>Filled from the vault if fresh</td>
    </tr>
    <tr>
      <td>3</td>
      <td>You, every time</td>
      <td><strong>Never</strong></td>
      <td>Always asked, never stored</td>
    </tr>
  </tbody>
</table>

<p>Tier 3 covers government identifiers, tax numbers, bank and payment details, passports, visas, health information, credentials, and signatures. The friction is deliberate. If you ask the vault to save one of these for convenience, it declines and explains why.</p>

<p>Tier 1 values are never cached. Caching them creates a second source of truth that goes stale without telling you. When the directory profile can’t be retrieved, those fields drop to Tier 2 and get asked instead of guessed. Degrading into more questions is always correct; inventing a value never is.</p>

<p>For Tier 3 fields the skills go further and recommend leaving the field blank so you complete it by hand before sending. Typing a national ID into a conversation is a disclosure on its own, whatever happens to it afterwards. A form returned with three blanks and a note explaining them beats one filled at that cost.</p>

<h2 id="the-vault-is-optional-and-it-lives-outside-the-skills-folder">The vault is optional, and it lives outside the skills folder</h2>

<p>Saved answers go to <code class="language-plaintext highlighter-rouge">/Documents/Personal/form-profile.md</code> in your OneDrive, as plain Markdown you can read, correct, or delete yourself.</p>

<p>The location matters. Cowork lets you share skills with colleagues, and a vault stored inside a skill folder would be shared along with it. Keeping the data separate means sharing the skill shares the protocol and nothing else.</p>

<p>The vault is opt-in and nothing depends on it. Decline, and the skills ask you for Tier 2 details each time. Filling, verification, and the reply all behave identically. The vault exists only to stop you retyping the same answers. It’s never created silently either: on first use the skill explains what it will hold, where it lives, what it will never hold, and that you can delete it whenever you want.</p>

<p>The file keeps a permanent <strong>Never Stored</strong> section listing the fields that are deliberately absent, so a later session doesn’t helpfully fill the gap. Apply a <strong>Confidential sensitivity label</strong> once it exists. Purview governs Cowork, so the label gives you real DLP enforcement and audit instead of relying on the skills’ own good behavior.</p>

<h2 id="completed-forms-are-not-data-sources">Completed forms are not data sources</h2>

<p>Testing surfaced a contamination path worth knowing about, whether or not you use this plugin.</p>

<p>A filled form sitting in OneDrive becomes discoverable to later runs. In testing, a completed travel questionnaire was found by name and used as a data source for an external credit application. Output from one task became input to another, with a different audience and no confirmation the values were still current.</p>

<p>The skills now prohibit that explicitly. Values come from exactly three places: the directory profile, the vault, and answers you give in the current run. An absent vault means ask. No searching OneDrive for a lookalike file, no reading a completed form as a stand-in. If a file that looks like a profile turns up, the skill says it exists and asks whether you want it used rather than reading it for values.</p>

<p>Two things follow for anyone running agents over a document library:</p>

<ul>
  <li>Clear out completed forms you don’t need. They’re findable.</li>
  <li>A governed vault beats an ad-hoc file. One confirmed source with provenance dates and an explicit prohibited-field list is safer than whatever a filename search happens to surface.</li>
</ul>

<h2 id="what-the-skills-can-and-cannot-promise">What the skills can and cannot promise</h2>

<p>The skills control one thing: whether a value is written to the vault. They don’t control platform-level retention, and they don’t claim to.</p>

<p>Microsoft 365 Copilot personalization and memory can retain details inferred from chat history, stored in your Exchange mailbox, and enhanced personalization is on by default. Whether anything is actually retained depends on tenant and user settings. In testing the memory store was queried directly and came back empty, so treat this as a capability to know about rather than an observed leak. Review what’s kept under <strong>Copilot &gt; Settings &gt; Personalization and memory</strong>, where memories can be viewed and cleared. Admins can turn enhanced personalization off tenant-wide.</p>

<h2 id="what-testing-established">What testing established</h2>

<p>The plugin ships five fixtures in <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Cowork%20Plugins/Form%20Autofill/test-fixtures">test-fixtures/</a> so a run can be scored rather than eyeballed: a 25-field Word form labeled entirely with synonyms, a 10-field Excel form that duplicates eight of those concepts under different labels, a prose benefits guide with no blanks, a real 17-field AcroForm PDF, and a flat scanned PDF with no form layer.</p>

<table>
  <thead>
    <tr>
      <th>Question</th>
      <th>Answer</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Can Cowork fill an AcroForm and preserve it?</td>
      <td>Yes. Fields stayed editable, and the read-only field was left intact</td>
    </tr>
    <tr>
      <td>Does it handle a flat, unfillable PDF honestly?</td>
      <td>Yes. It reported the problem, offered an alternative, and asked first</td>
    </tr>
    <tr>
      <td>Is the directory profile retrievable?</td>
      <td>Yes. Tier 1 fields filled silently, and missing ones became questions</td>
    </tr>
    <tr>
      <td>Does it tick consent checkboxes unasked?</td>
      <td>No. It asked which consents to give and ticked only those</td>
    </tr>
    <tr>
      <td>Does it dedupe fields across a packet?</td>
      <td>Yes. Overlapping concepts were asked once, not once per form</td>
    </tr>
    <tr>
      <td>Does the email trigger reach attachments?</td>
      <td>Yes. Read from the triggering message with no re-upload</td>
    </tr>
    <tr>
      <td>Does the reply wait for approval?</td>
      <td>Yes. Drafted to the original sender, never sent</td>
    </tr>
    <tr>
      <td>Does the reply body leak values?</td>
      <td>No. Field names only, with every blank itemized</td>
    </tr>
    <tr>
      <td>Does it flag an external recipient?</td>
      <td>Yes. It warned that the form was leaving the tenant</td>
    </tr>
    <tr>
      <td>Does it surface conflicting sources?</td>
      <td>Yes. It disclosed that two sources held different addresses and asked which to use</td>
    </tr>
  </tbody>
</table>

<p>Three things remain unproven. The source-restriction rules were added after the contamination incident and haven’t been re-exercised, which is the top priority for the next run. The vault itself has never been used in anger, so whether Cowork can reliably rewrite a file it also reads as context is still open. And concurrent event-driven runs may collide on vault writes.</p>

<h2 id="trigger-it-from-email">Trigger it from email</h2>

<p>Set up an event-driven task in Cowork:</p>

<blockquote>
  <p>When I receive an email with “Forms to complete” in the subject, triage any PDF, Word, or Excel attachments, fill out the ones that are forms, ask me about anything you can’t fill, and draft a reply to the sender with the completed files attached.</p>
</blockquote>

<p>Scope the trigger with a subject keyword. Without a filter it fires on every email with an attachment and starts drafting replies to real correspondents. Event-driven tasks default to draft-and-approve, so the reply waits for you, and attachments on the triggering message are readable directly.</p>

<p>Keep trigger instructions minimal. Anything in the trigger prompt overrides skill behavior. In testing, a prompt telling the agent to “use the profile vault / saved personal details” sent it searching OneDrive, where it read an unrelated file because no vault existed. Describe what you want done and let the skills decide how.</p>

<h2 id="install-it">Install it</h2>

<p>As personal skills, copy the three folders under <code class="language-plaintext highlighter-rouge">skills/</code> into <code class="language-plaintext highlighter-rouge">/Documents/Cowork/skills/</code> in your OneDrive. Cowork discovers them at the start of your next session.</p>

<p>As a plugin, package it with the shared template:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">..</span><span class="n">\..\</span><span class="s1">'Cowork Plugin Template'</span><span class="nx">\package.ps1</span><span class="w">
</span></code></pre></div></div>

<p>Icons are included: <code class="language-plaintext highlighter-rouge">color.png</code> at 192x192 and <code class="language-plaintext highlighter-rouge">outline.png</code> at 32x32, rendered from the Fluent UI System Icons <strong>Form</strong> glyph. During development, <code class="language-plaintext highlighter-rouge">-SkipIcons</code> packages without them.</p>

<h2 id="watch-out-for-reserved-skill-names">Watch out for reserved skill names</h2>

<p>Cowork’s built-in skills silently override plugin skills with matching names. Observed built-ins include <code class="language-plaintext highlighter-rouge">PDF</code>, <code class="language-plaintext highlighter-rouge">Word</code>, <code class="language-plaintext highlighter-rouge">Excel</code>, <code class="language-plaintext highlighter-rouge">PowerPoint</code>, <code class="language-plaintext highlighter-rouge">html</code>, <code class="language-plaintext highlighter-rouge">Calendar Management</code>, <code class="language-plaintext highlighter-rouge">Daily Briefing</code>, <code class="language-plaintext highlighter-rouge">Meetings</code>, <code class="language-plaintext highlighter-rouge">Scheduling</code>, <code class="language-plaintext highlighter-rouge">Communications</code>, <code class="language-plaintext highlighter-rouge">Skill Management</code>, and <code class="language-plaintext highlighter-rouge">goal</code>. The list varies by tenant and release, so check <strong>Customize &gt; Skills</strong> before naming a new skill. The three names here are clear of every built-in observed so far.</p>

<h2 id="a-few-design-rules-worth-stealing">A few design rules worth stealing</h2>

<ul>
  <li><strong>One batched ask, never field by field.</strong> In a background run each round trip can sit unanswered for hours, so every outstanding field across every attachment is collected and asked once, grouped by theme, with sensitive questions last.</li>
  <li><strong>Blank beats wrong.</strong> Nothing is inferred or approximated to make a form look finished. A blank field is a question; a wrong field is a defect that travels to a third party.</li>
  <li><strong>Verification is mandatory.</strong> Files are re-read after filling to confirm values landed in the fields they were meant for. Silent write failures and off-by-one placement are the likeliest defects and are invisible unless checked.</li>
  <li><strong>Values never appear in summaries.</strong> Progress narration, recaps, and email bodies reference field names, never values. A recap quoting your date of birth can be forwarded somewhere it doesn’t belong.</li>
  <li><strong>Refusals are respected.</strong> “Skip” is a valid answer to any question. The field is left blank, noted, and not raised again.</li>
</ul>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Cowork%20Plugins/Form%20Autofill">Form Autofill for Copilot Cowork</a> — skills, manifest, icons, and test fixtures</li>
  <li><a href="https://learn.microsoft.com/microsoft-365/copilot/cowork/cowork-plugin-development">Build plugins for Copilot Cowork</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-365/copilot/cowork/use-cowork#create-custom-skills">Copilot Cowork custom skills</a></li>
  <li><a href="https://learn.microsoft.com/en-us/purview/sensitivity-labels">Microsoft Purview sensitivity labels</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Copilot Studio" /><category term="Automation" /><category term="Copilot Cowork" /><category term="Microsoft 365 Copilot" /><category term="Agent Skills" /><category term="Forms" /><category term="PDF" /><category term="AcroForm" /><category term="Purview" /><category term="Privacy" /><category term="Automation" /><summary type="html"><![CDATA[A Copilot Cowork plugin that triages emailed PDF, Word, and Excel attachments, fills what it can resolve, asks about the rest in one batch, verifies the result, and drafts a reply to the sender. No MCP server, no connector, nothing to configure.]]></summary></entry><entry><title type="html">Find, evaluate, and contain Copilot Studio agents with one connector</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-08-20-copilot-studio-bots-connector.html" rel="alternate" type="text/html" title="Find, evaluate, and contain Copilot Studio agents with one connector" /><published>2026-08-20T14:00:00+00:00</published><updated>2026-08-20T14:00:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/mcp/copilot-studio-bots-connector</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-08-20-copilot-studio-bots-connector.html"><![CDATA[<p>The <a href="https://learn.microsoft.com/en-us/rest/api/power-platform/copilotstudio/bots">Bots operation group</a> in the Power Platform API covers two jobs that belong together: measuring whether a Copilot Studio agent answers well, and containing it when it doesn’t. <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Studio%20Bots">Copilot Studio Bots</a> is a custom connector for all 13 documented operations, with MCP support for agents and REST actions for flows.</p>

<p>It also answers the question the Bots API can’t: which agents are worth containing. The API quarantines an agent by ID but exposes no harness, template, or recognizer field, so the connector reads the Power Platform resource query API for a tenant-wide inventory that flags GitHub Copilot harness agents — the ones billing 100 to 500+ Copilot Credits per task.</p>

<p>This replaces an earlier connector, <a href="/power%20platform/custom%20connectors/2026-04-17-copilot-studio-evaluations-mcp-connector.html">Copilot Studio Evaluations</a>, which covered five operations from the same API group and stopped at measurement. A nightly quality check that quarantines a regressed agent needs the administrative half too, so all 13 operations now share one connection.</p>

<h2 id="what-the-13-operations-do">What the 13 operations do</h2>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>Method</th>
      <th>Endpoint</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Get Agent Test Sets</td>
      <td>GET</td>
      <td><code class="language-plaintext highlighter-rouge">{base}/api/makerevaluation/testsets</code></td>
    </tr>
    <tr>
      <td>Get Agent Test Set Details</td>
      <td>GET</td>
      <td><code class="language-plaintext highlighter-rouge">{base}/api/makerevaluation/testsets/{testSetId}</code></td>
    </tr>
    <tr>
      <td>Start Agent Evaluation</td>
      <td>POST</td>
      <td><code class="language-plaintext highlighter-rouge">{base}/api/makerevaluation/testsets/{testSetId}/run</code></td>
    </tr>
    <tr>
      <td>Get Agent Test Runs</td>
      <td>GET</td>
      <td><code class="language-plaintext highlighter-rouge">{base}/api/makerevaluation/testruns</code></td>
    </tr>
    <tr>
      <td>Get Agent Test Run Details</td>
      <td>GET</td>
      <td><code class="language-plaintext highlighter-rouge">{base}/api/makerevaluation/testruns/{testRunId}</code></td>
    </tr>
    <tr>
      <td>Download Agent Evaluation Snapshot</td>
      <td>GET</td>
      <td><code class="language-plaintext highlighter-rouge">{base}/api/makerevaluation/testruns/{testRunId}/snapshot</code></td>
    </tr>
    <tr>
      <td>Get Agent Quarantine Status</td>
      <td>GET</td>
      <td><code class="language-plaintext highlighter-rouge">{base}/api/botQuarantine</code></td>
    </tr>
    <tr>
      <td>Quarantine Agent</td>
      <td>POST</td>
      <td><code class="language-plaintext highlighter-rouge">{base}/api/botQuarantine/SetAsQuarantined</code></td>
    </tr>
    <tr>
      <td>Release Agent From Quarantine</td>
      <td>POST</td>
      <td><code class="language-plaintext highlighter-rouge">{base}/api/botQuarantine/SetAsUnquarantined</code></td>
    </tr>
    <tr>
      <td>Get Connector Consent Bypass</td>
      <td>GET</td>
      <td><code class="language-plaintext highlighter-rouge">{base}/api/connectorConsentBypass</code></td>
    </tr>
    <tr>
      <td>Set Connector Consent Bypass</td>
      <td>PUT</td>
      <td><code class="language-plaintext highlighter-rouge">{base}/api/connectorConsentBypass</code></td>
    </tr>
    <tr>
      <td>Reassign Agent Owner</td>
      <td>POST</td>
      <td><code class="language-plaintext highlighter-rouge">{base}/api/botAdminOperations/reassign</code></td>
    </tr>
    <tr>
      <td>Delete Agent</td>
      <td>DELETE</td>
      <td><code class="language-plaintext highlighter-rouge">{base}/api/botAdminOperations</code></td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">{base}</code> is <code class="language-plaintext highlighter-rouge">/environments/{environmentId}/bots/{botId}</code> on <code class="language-plaintext highlighter-rouge">https://api.powerplatform.com/copilotstudio</code>, with <code class="language-plaintext highlighter-rouge">api-version</code> pinned to <code class="language-plaintext highlighter-rouge">2024-10-01</code>.</p>

<p>The first six are maker operations — anyone with access to the agent can run them. The last seven need Power Platform or Dynamics 365 administrator rights, and the API returns <code class="language-plaintext highlighter-rouge">403</code> to everyone else no matter how the connector or DLP policy is configured. The inventory reads tenant-wide resources, so it needs administrator rights too.</p>

<p>Each operation is also an MCP tool: <code class="language-plaintext highlighter-rouge">get_test_sets</code>, <code class="language-plaintext highlighter-rouge">get_test_set_details</code>, <code class="language-plaintext highlighter-rouge">start_evaluation</code>, <code class="language-plaintext highlighter-rouge">list_test_runs</code>, <code class="language-plaintext highlighter-rouge">get_run_details</code>, <code class="language-plaintext highlighter-rouge">download_evaluation_snapshot</code>, <code class="language-plaintext highlighter-rouge">get_quarantine_status</code>, <code class="language-plaintext highlighter-rouge">quarantine_agent</code>, <code class="language-plaintext highlighter-rouge">unquarantine_agent</code>, <code class="language-plaintext highlighter-rouge">get_connector_consent_bypass</code>, <code class="language-plaintext highlighter-rouge">set_connector_consent_bypass</code>, <code class="language-plaintext highlighter-rouge">reassign_agent</code>, and <code class="language-plaintext highlighter-rouge">delete_agent</code>. Three more tools — <code class="language-plaintext highlighter-rouge">list_agents</code>, <code class="language-plaintext highlighter-rouge">find_containment_candidates</code>, and <code class="language-plaintext highlighter-rouge">contain_agents</code> — come from the inventory rather than the Bots API, for 16 in total.</p>

<p>Tooling reports a third number. <code class="language-plaintext highlighter-rouge">ppcv</code> and PAC CLI count 17 operations in the OpenAPI definition: the 13 Bots operations, <code class="language-plaintext highlighter-rouge">List Agents</code>, two internal dropdown sources, and the JSON-RPC endpoint. The Power Automate action list shows 16, since the internal two are hidden. That the MCP count also lands on 16 is a coincidence — the REST side includes two dropdown operations that aren’t tools, and the MCP side includes two containment tools that have no REST equivalent.</p>

<h2 id="the-metrics-are-booleans-not-scores">The metrics are booleans, not scores</h2>

<p><code class="language-plaintext highlighter-rouge">get_run_details</code> returns results nested three levels deep:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>run
├── state              Pending | Running | Completed | Failed
├── totalTestCases
└── testCaseResults[]
    ├── testCaseId
    ├── state          Passed | Failed | Error
    ├── errorReason    populated when the case failed
    ├── aiResultReason AI-generated explanation of the outcome
    └── metricsResults[]
        ├── type       e.g. GeneralQuality, Hallucination
        └── result
            ├── abstention    boolean
            ├── relevance     boolean
            └── completeness  boolean
</code></pre></div></div>

<p>Abstention, relevance, and completeness are true or false per test case. There’s no percentage or confidence value anywhere in the response, so a quality figure has to be aggregated yourself — “relevance false in 3 of 20 cases” rather than “85% relevant.” Models left to their own devices report these as percentages, which is why the sample system prompt says not to.</p>

<p>Read <code class="language-plaintext highlighter-rouge">abstention: true</code> as neutral. An agent that declines an out-of-scope question is behaving correctly. And <code class="language-plaintext highlighter-rouge">aiResultReason</code> is the field worth quoting to a human, since it explains why a case landed where it did.</p>

<h2 id="guards-on-the-destructive-operations">Guards on the destructive operations</h2>

<p>Handing an agent a tool that permanently deletes a copilot needs a gate. Three are built in:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">delete_agent</code> refuses to run without <code class="language-plaintext highlighter-rouge">confirm: true</code>.</strong> The API has no undelete, so an ambiguous instruction can’t cost you an agent. The REST <code class="language-plaintext highlighter-rouge">Delete Agent</code> action has no such guard — treat it carefully in flows.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">contain_agents</code> accepts no filter and requires <code class="language-plaintext highlighter-rouge">confirm: true</code>.</strong> It takes an explicit list of agents, so no single call can quarantine a population the caller hasn’t already seen and named.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">reassign_agent</code> converts <code class="language-plaintext highlighter-rouge">204 No Content</code> to <code class="language-plaintext highlighter-rouge">{ "status": 204, "succeeded": true }</code>.</strong> An empty body reads like a failure to a calling model, which then retries a reassignment that already worked.</li>
</ul>

<p>Quarantine is the reversible option and usually the right first move. A quarantined agent stays editable for makers and administrators; only end users lose access. Enabling connector consent bypass goes the other way — it removes an end user safeguard, so audit which connections the agent uses before turning it on.</p>

<h2 id="snapshots-are-binary">Snapshots are binary</h2>

<p><code class="language-plaintext highlighter-rouge">download_evaluation_snapshot</code> returns a ZIP. The connector reads the response as bytes rather than text, because string handling corrupts the archive. Files 4 MB or smaller come back as an MCP <code class="language-plaintext highlighter-rouge">resource</code> with a base64 <code class="language-plaintext highlighter-rouge">blob</code>. Larger ones return metadata and a pointer to the REST operation, which is the better path anyway — stream the archive to SharePoint or OneDrive in a flow instead of through a conversation. The file name comes from <code class="language-plaintext highlighter-rouge">Content-Disposition</code>, falling back to <code class="language-plaintext highlighter-rouge">evaluation-snapshot-{testRunId}.zip</code>.</p>

<h2 id="which-agents-run-the-expensive-harness">Which agents run the expensive harness</h2>

<p><code class="language-plaintext highlighter-rouge">list_agents</code> returns <code class="language-plaintext highlighter-rouge">isCLIAgent</code> for every agent in the tenant:</p>

<table>
  <thead>
    <tr>
      <th><code class="language-plaintext highlighter-rouge">isCLIAgent</code></th>
      <th><code class="language-plaintext highlighter-rouge">harness</code></th>
      <th>Cost profile</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">"true"</code></td>
      <td>GitHub Copilot</td>
      <td>100–500+ Copilot Credits per task, billed regardless of M365 Copilot licensing</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">"false"</code></td>
      <td>Standard or Copilot Chat</td>
      <td>1–20 credits per run, no charge for licensed employees</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">"unknown"</code></td>
      <td>unknown</td>
      <td>Not reported — investigate, don’t assume</td>
    </tr>
  </tbody>
</table>

<p>Three things to know before building on it:</p>

<ul>
  <li><strong>It’s a string, not a boolean.</strong> A flow condition testing it as a boolean silently never matches.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">"unknown"</code> is not <code class="language-plaintext highlighter-rouge">"false"</code>.</strong> An absent field isn’t evidence of the cheap harness, and that’s the direction you least want a cost report to guess in.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">"false"</code> covers two harnesses.</strong> It can’t separate Standard from Copilot Chat, so the <code class="language-plaintext highlighter-rouge">harness</code> label says <code class="language-plaintext highlighter-rouge">Standard or Copilot Chat</code> rather than inventing precision the source doesn’t have.</li>
</ul>

<p>To confirm a single agent definitively, clone it with the VS Code extension. The GitHub Copilot harness projects <code class="language-plaintext highlighter-rouge">template: cliagent-1.0.0</code> and <code class="language-plaintext highlighter-rouge">recognizer.kind: CLICopilotRecognizer</code>, against <code class="language-plaintext highlighter-rouge">default-2.1.0</code> and <code class="language-plaintext highlighter-rouge">GenerativeAIRecognizer</code> for Standard.</p>

<h2 id="plan-then-apply">Plan, then apply</h2>

<p>Discovery and containment are separate tools on purpose.</p>

<p><code class="language-plaintext highlighter-rouge">find_containment_candidates</code> is read-only and quarantines nothing. It returns each matching agent with the <code class="language-plaintext highlighter-rouge">reasons</code> it qualified, plus <code class="language-plaintext highlighter-rouge">skippedUnknownHarness</code> and <code class="language-plaintext highlighter-rouge">skippedAlreadyQuarantined</code> counts so you can see what it declined to touch.</p>

<table>
  <thead>
    <tr>
      <th>Parameter</th>
      <th>Default</th>
      <th>Effect</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">environmentId</code></td>
      <td>all</td>
      <td>Scope to one environment</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">requireTenantWide</code></td>
      <td><code class="language-plaintext highlighter-rouge">true</code></td>
      <td>Only agents shared with the entire tenant</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">requireNeverPublished</code></td>
      <td><code class="language-plaintext highlighter-rouge">false</code></td>
      <td>Only agents that have never been published</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">includeUnknownHarness</code></td>
      <td><code class="language-plaintext highlighter-rouge">false</code></td>
      <td>Include agents whose harness wasn’t reported</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">contain_agents</code> takes an explicit array of <code class="language-plaintext highlighter-rouge">{ environmentId, botId }</code> pairs and <code class="language-plaintext highlighter-rouge">confirm: true</code>. Failures are per-agent, so one <code class="language-plaintext highlighter-rouge">403</code> doesn’t abandon the rest of the batch, and the result reports <code class="language-plaintext highlighter-rouge">succeeded</code>, <code class="language-plaintext highlighter-rouge">failed</code>, and a per-agent breakdown.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>User: "Find GitHub Copilot harness agents shared with everyone that were never published"
Agent:
  1. find_containment_candidates { requireNeverPublished: true }
     → 2 candidates, each with reasons
       [githubCopilotHarness, sharedWithEntireTenant, neverPublished]
     → skippedUnknownHarness: 1   (reported, not contained)
  2. Presents them with owners and environments
User: "Quarantine both"
  3. contain_agents { agents: [ {...}, {...} ], confirm: true }
     → succeeded: 2
</code></pre></div></div>

<p>The three composite tools break the usual error convention. They report outcomes inside the payload and set <code class="language-plaintext highlighter-rouge">isError</code> only when the whole operation failed, so a batch where some agents were quarantined and others rejected returns <code class="language-plaintext highlighter-rouge">isError: false</code> with the failures itemized in <code class="language-plaintext highlighter-rouge">results</code>. Read <code class="language-plaintext highlighter-rouge">succeeded</code> and <code class="language-plaintext highlighter-rouge">failed</code>, not the flag.</p>

<p>Two limits shape the inventory. It pages 1,000 rows at a time and stops after 10 pages, setting <code class="language-plaintext highlighter-rouge">truncated: true</code> rather than quietly returning a partial estate — scope to an environment if you hit it. And <code class="language-plaintext highlighter-rouge">SkipToken</code> doesn’t work: the service returns one but it never advances, so the connector pages with <code class="language-plaintext highlighter-rouge">Skip</code> offsets ordered by a unique tiebreaker to keep the window stable.</p>

<p>The inventory reads <code class="language-plaintext highlighter-rouge">resourcequery</code> at <code class="language-plaintext highlighter-rouge">api-version=2022-03-01-preview</code>, which returns the resource provider’s raw property bag. <code class="language-plaintext highlighter-rouge">isCLIAgent</code> isn’t in the published reference and can change shape or disappear without notice. Use it for reporting and chargeback triage, not as a hard enforcement gate.</p>

<h2 id="pickers-and-what-cant-be-one">Pickers, and what can’t be one</h2>

<p>Environment ID, Agent ID, Test Set ID, and Test Run ID are all dropdowns in the Power Automate and Logic Apps designers, each cascading from the one before it.</p>

<p>The environment picker runs on an internal operation at <code class="language-plaintext highlighter-rouge">/metadata/environments</code> that the script rewrites to <code class="language-plaintext highlighter-rouge">https://api.powerplatform.com/environmentmanagement/environments</code>. That endpoint reports the environment identifier inconsistently, sometimes as a bare GUID in <code class="language-plaintext highlighter-rouge">name</code> and sometimes as an ARM-style path in <code class="language-plaintext highlighter-rouge">id</code>, so the connector normalizes both to the bare GUID the Bots operations expect.</p>

<p>The agent picker works the same way from <code class="language-plaintext highlighter-rouge">/metadata/agents</code>, backed by the inventory query. Because it already knows the harness, it appends <code class="language-plaintext highlighter-rouge">(GitHub Copilot)</code> to those entries — you see which agent is the expensive one at the moment you pick it.</p>

<p>Owner ID isn’t a picker. Resolving users means Microsoft Graph, a different host, and a connector can’t reach across hosts to back a dropdown.</p>

<p>Dropdowns are a designer convenience. MCP tools receive raw IDs, so an agent calling <code class="language-plaintext highlighter-rouge">quarantine_agent</code> still needs both GUIDs.</p>

<h2 id="the-flow-that-motivated-the-merge">The flow that motivated the merge</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. Trigger: Scheduled (daily at 2 AM)
2. Get Agent Test Sets
3. For Each test set (Apply-to-each concurrency OFF)
   4. Start Agent Evaluation
   5. Poll Get Agent Test Run Details until state is Completed or Failed
   6. Count test cases where relevance or completeness is false
   7. Condition: failing count above your threshold
      8. Quarantine Agent
      9. Download Agent Evaluation Snapshot and save to SharePoint
      10. Email the agent owner with the failing cases and aiResultReason
</code></pre></div></div>

<p>Concurrency stays off for a reason. An agent runs one evaluation at a time, and <code class="language-plaintext highlighter-rouge">Start Agent Evaluation</code> returns <code class="language-plaintext highlighter-rouge">422</code> if a run is already in progress. Poll each run to completion before starting the next, and treat <code class="language-plaintext highlighter-rouge">Failed</code> as terminal so the loop can’t spin forever. Test sets also have to be Active — inactive sets come back from <code class="language-plaintext highlighter-rouge">Get Agent Test Sets</code> but won’t run.</p>

<p>A second flow reclaims ownership drift: <code class="language-plaintext highlighter-rouge">List Agents</code> weekly with a blank environment ID, check each <code class="language-plaintext highlighter-rouge">ownerId</code> against Entra ID, and reassign anything owned by a disabled account to a governance service account.</p>

<p>A third reports cost exposure monthly — <code class="language-plaintext highlighter-rouge">List Agents</code> across the tenant, filter <code class="language-plaintext highlighter-rouge">isCLIAgent</code> equal to <code class="language-plaintext highlighter-rouge">'true'</code>, and mail a table of display name, environment, owner, and <code class="language-plaintext highlighter-rouge">lastPublishedAt</code>. Filter <code class="language-plaintext highlighter-rouge">'unknown'</code> separately and label those “harness unresolved,” never Standard.</p>

<p>Three details catch flow authors out. <code class="language-plaintext highlighter-rouge">List Agents</code> returns an object, not an array, so apply-to-each over <code class="language-plaintext highlighter-rouge">agents</code> and check <code class="language-plaintext highlighter-rouge">truncated</code> before treating the result as the whole estate. An empty <code class="language-plaintext highlighter-rouge">lastPublishedAt</code> means never published, not an unknown date. And <code class="language-plaintext highlighter-rouge">channels</code> and <code class="language-plaintext highlighter-rouge">reasons</code> are string arrays — flatten with <code class="language-plaintext highlighter-rouge">join(item()?['reasons'], ', ')</code> for a table or email body.</p>

<h2 id="status-codes">Status codes</h2>

<table>
  <thead>
    <tr>
      <th>Status</th>
      <th>Meaning</th>
      <th>What to do</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>400</td>
      <td>Malformed body</td>
      <td>Check <code class="language-plaintext highlighter-rouge">adminConsentBypass</code> is a boolean and <code class="language-plaintext highlighter-rouge">NewOwnerAadUserId</code> is a valid Entra object ID</td>
    </tr>
    <tr>
      <td>401</td>
      <td>Token invalid or expired</td>
      <td>Reauthorize the connection and confirm the app registration has Power Platform API permission</td>
    </tr>
    <tr>
      <td>403</td>
      <td>Caller lacks tenant admin rights</td>
      <td>Sign in as a Power Platform or Dynamics 365 administrator</td>
    </tr>
    <tr>
      <td>404</td>
      <td>Agent, environment, test set, or run not found</td>
      <td>A 404 on snapshot usually means a wrong run ID or a run that never completed</td>
    </tr>
    <tr>
      <td>422</td>
      <td>An evaluation run is already in progress</td>
      <td>Wait for <code class="language-plaintext highlighter-rouge">Completed</code> or <code class="language-plaintext highlighter-rouge">Failed</code>, then retry</td>
    </tr>
    <tr>
      <td>500</td>
      <td>Service-side failure on reassign</td>
      <td>Retry; if it persists, confirm the new owner has access to the environment</td>
    </tr>
    <tr>
      <td>Empty agent dropdown</td>
      <td>The inventory query failed or returned nothing</td>
      <td>The picker swallows errors to avoid breaking the designer. Call <code class="language-plaintext highlighter-rouge">List Agents</code> directly to see the real one</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">List Agents</code> returns 400</td>
      <td>The resource query was rejected</td>
      <td>Usually a schema change in the preview API. Confirm you can still read agents in PPAC, then check whether the query shape changed</td>
    </tr>
  </tbody>
</table>

<p>MCP tool calls surface these as <code class="language-plaintext highlighter-rouge">isError: true</code> with the status code in the message text, so an agent can read and explain the failure. JSON-RPC errors (<code class="language-plaintext highlighter-rouge">-32601</code>, <code class="language-plaintext highlighter-rouge">-32602</code>) are reserved for unknown methods and malformed tool calls.</p>

<h2 id="setup">Setup</h2>

<p>Register an Entra application with Power Platform API permissions and the <code class="language-plaintext highlighter-rouge">https://api.powerplatform.com/.default</code> scope, then put the client ID in <code class="language-plaintext highlighter-rouge">apiProperties.json</code>. Deploying with <code class="language-plaintext highlighter-rouge">REPLACE_WITH_CLIENT_ID</code> still in place succeeds, but no connection can be created, so set the real value first.</p>

<p>Validate with <code class="language-plaintext highlighter-rouge">ppcv "./Copilot Studio Bots"</code>, then deploy. <code class="language-plaintext highlighter-rouge">--script-file</code> is mandatory:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pac</span><span class="w"> </span><span class="nx">connector</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-definition-file</span><span class="w"> </span><span class="nx">apiDefinition.swagger.json</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--api-properties-file</span><span class="w"> </span><span class="nx">apiProperties.json</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--script-file</span><span class="w"> </span><span class="nx">script.csx</span><span class="w">
</span></code></pre></div></div>

<p>Omitting it fails with <code class="language-plaintext highlighter-rouge">InvalidScriptDefinitionUrlWithNonNullOperations</code>, because <code class="language-plaintext highlighter-rouge">apiProperties.json</code> declares <code class="language-plaintext highlighter-rouge">scriptOperations</code> and the service won’t accept them with no script to route them to. A second failure, <code class="language-plaintext highlighter-rouge">CustomScriptProvisioningFailed</code> or <code class="language-plaintext highlighter-rouge">FindAndAssignFunctionApp</code>, means the region has no unassigned function app for custom code — creating a new script-enabled connector draws from that pool, while updating an existing one reuses its assignment and usually still works.</p>

<p>Without the script the 13 Bots operations still pass through untouched, but the MCP endpoint, both dropdowns, <code class="language-plaintext highlighter-rouge">List Agents</code>, and the containment tools don’t.</p>

<p>Evaluation with an authenticated agent connection needs an MCS Connection ID. Open the Connections page in Power Automate, select the Microsoft Copilot Studio connection, and copy <code class="language-plaintext highlighter-rouge">mcsConnectionId</code> from the URL.</p>

<p>Application Insights telemetry ships in <code class="language-plaintext highlighter-rouge">script.csx</code>, disabled by default. Set <code class="language-plaintext highlighter-rouge">APP_INSIGHTS_ENABLED = true</code>, drop in your instrumentation key, and redeploy to log every MCP request, tool call, and exception. That gives you an audit trail for the administrative tools:</p>

<pre><code class="language-kusto">customEvents
| where name == "MCP_ToolCall"
| summarize Count = count() by tostring(customDimensions.tool), tostring(customDimensions.status)
</code></pre>

<h2 id="related-connectors">Related connectors</h2>

<table>
  <thead>
    <tr>
      <th>Connector</th>
      <th>Coverage</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="/power%20platform/custom%20connectors/mcp/2026-08-06-power-platform-admin-agent-inventory.html">Power Platform Admin</a></td>
      <td>Environment settings, and the original tenant-wide agent inventory that <code class="language-plaintext highlighter-rouge">List Agents</code> is ported from</td>
    </tr>
    <tr>
      <td><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Studio%20Analytics">Copilot Studio Analytics</a></td>
      <td>Dataverse conversation transcripts and session analytics</td>
    </tr>
    <tr>
      <td><a href="/power%20platform/custom%20connectors/mcp/2026-08-07-microsoft-365-copilot-package-management-agent-registry.html">Microsoft 365 Copilot Package Management</a></td>
      <td>Package catalog: block, unblock, and reassign</td>
    </tr>
  </tbody>
</table>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Copilot%20Studio%20Bots">Copilot Studio Bots connector on GitHub</a></li>
  <li><a href="https://learn.microsoft.com/en-us/rest/api/power-platform/copilotstudio/bots">Power Platform API Bots operations</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-copilot-studio/analytics-agent-evaluation-overview">Agent evaluations in Copilot Studio</a></li>
  <li><a href="https://learn.microsoft.com/microsoft-copilot-studio/admin-api-quarantine">Quarantine noncompliant agents with the Power Platform API</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="MCP" /><category term="Copilot Studio" /><category term="Custom Connectors" /><category term="MCP" /><category term="Power Automate" /><category term="Evaluations" /><category term="Governance" /><category term="Quarantine" /><category term="Agent Inventory" /><category term="GitHub Copilot Harness" /><category term="Power Platform API" /><summary type="html"><![CDATA[A Power Platform custom connector covering all 13 Bots operations in the Power Platform API — maker evaluation, quarantine, consent bypass, reassignment, and deletion — plus a tenant-wide inventory that flags which agents run on the expensive GitHub Copilot harness.]]></summary></entry><entry><title type="html">Govern agent identities with the Entra Agent ID connector</title><link href="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-08-18-entra-agent-id-mcp-connector.html" rel="alternate" type="text/html" title="Govern agent identities with the Entra Agent ID connector" /><published>2026-08-18T15:30:00+00:00</published><updated>2026-08-18T15:30:00+00:00</updated><id>https://troystaylor.com/power%20platform/custom%20connectors/mcp/entra-agent-id-mcp-connector</id><content type="html" xml:base="https://troystaylor.com/power%20platform/custom%20connectors/mcp/2026-08-18-entra-agent-id-mcp-connector.html"><![CDATA[<p>Agents need identities, and until recently the only way to give them one in Entra was an app registration and a set of conventions. <a href="https://learn.microsoft.com/entra/agent-id/">Microsoft Entra Agent ID</a> replaces that with first-class objects in Microsoft Graph: blueprints, blueprint principals, agent identities, and agent users. <a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Entra%20Agent%20ID">Entra Agent ID</a> is a Power Platform custom connector for the whole lifecycle.</p>

<p>One connector, two ways to use it. The <code class="language-plaintext highlighter-rouge">InvokeMCP</code> operation speaks JSON-RPC 2.0 and exposes 73 tools, 7 resources, and 5 prompts to any MCP client — Copilot Studio, Microsoft Foundry, Agent Framework. The other 116 operations call Graph directly for Power Automate and Logic Apps. Both share one connection and one set of delegated permissions.</p>

<h2 id="four-layers-created-in-order">Four layers, created in order</h2>

<p>Getting this order wrong is the most common source of opaque <code class="language-plaintext highlighter-rouge">400</code> responses.</p>

<table>
  <thead>
    <tr>
      <th>Order</th>
      <th>Resource</th>
      <th>Graph collection</th>
      <th>What it is</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td><code class="language-plaintext highlighter-rouge">agentIdentityBlueprint</code></td>
      <td><code class="language-plaintext highlighter-rouge">/applications/microsoft.graph.agentIdentityBlueprint</code></td>
      <td>The application template for a class of agent, carrying the permissions its agent identities inherit</td>
    </tr>
    <tr>
      <td>2</td>
      <td><code class="language-plaintext highlighter-rouge">agentIdentityBlueprintPrincipal</code></td>
      <td><code class="language-plaintext highlighter-rouge">/servicePrincipals/microsoft.graph.agentIdentityBlueprintPrincipal</code></td>
      <td>The record of a blueprint being added to a tenant, required before any agent identity is created from it here</td>
    </tr>
    <tr>
      <td>3</td>
      <td><code class="language-plaintext highlighter-rouge">agentIdentity</code></td>
      <td><code class="language-plaintext highlighter-rouge">/servicePrincipals/microsoft.graph.agentIdentity</code></td>
      <td>What the agent authenticates as. Conditional Access, sign-in logs, and access reviews all act on this object</td>
    </tr>
    <tr>
      <td>4</td>
      <td><code class="language-plaintext highlighter-rouge">agentUser</code></td>
      <td><code class="language-plaintext highlighter-rouge">/users/microsoft.graph.agentUser</code></td>
      <td>The optional user-shaped account for agents needing a mailbox, a Teams presence, or a place in the org chart</td>
    </tr>
  </tbody>
</table>

<p>The <code class="language-plaintext highlighter-rouge">provision_agent</code> tool runs steps 2 through 4 in a single call and reports what it did at each one, including any step that was already satisfied.</p>

<p>Three rules save a lot of debugging:</p>

<ul>
  <li><strong>Create an agent identity with the blueprint’s <code class="language-plaintext highlighter-rouge">appId</code>, not its object <code class="language-plaintext highlighter-rouge">id</code>.</strong> The <code class="language-plaintext highlighter-rouge">agentIdentityBlueprintId</code> property takes the client ID. Graph reports the mistake as <code class="language-plaintext highlighter-rouge">AgentIdentity_IncompatibleParentType</code>.</li>
  <li><strong>Deletion cascades downward, not upward.</strong> Deleting a blueprint or blueprint principal starts an asynchronous background cleanup that soft-deletes every child agent identity and agent user. It can lag by hours or days and appears in audit logs as <em>Delete Agent Identities Task</em>. Deleting an agent identity on its own leaves its agent user behind, so remove that explicitly.</li>
  <li><strong>Everything soft-deletes with a 30 day restore window.</strong> Restoring a blueprint principal after the cascade has run does not bring its children back — restore each one individually. Soft-deleted objects keep consuming quota until you permanently delete them.</li>
</ul>

<h2 id="tools-worth-knowing">Tools worth knowing</h2>

<p>The 73 MCP tools group into blueprints (13), blueprint principals (6), federation and inherited permissions (6), agent identities (18), agent users (10), lifecycle and governance (7), and risk, inherited permissions, and registry (13, beta).</p>

<p>A handful carry most of the weight:</p>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>What it does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">provision_agent</code></td>
      <td>The ordered sequence in one call — instantiate the blueprint if needed, create the agent identity with its sponsors, optionally create the agent user and assign its manager</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">get_agent_overview</code></td>
      <td>One call returns an agent’s identity, blueprint, owners, sponsors, app role grants, group memberships, and agent user</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">list_blueprint_principal_agents</code></td>
      <td>The blast radius of a blueprint. Run it before any blueprint deletion</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">check_blocked_permissions</code></td>
      <td>Screens a proposed permission set against the list Entra refuses to grant to agents</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">set_agent_identity_enabled</code></td>
      <td>The reversible kill switch. Prefer it over deletion when an agent misbehaves</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">agent_id_graph_request</code></td>
      <td>A guarded passthrough for anything unnamed, restricted to the identity surface so it can’t read mail or files</td>
    </tr>
  </tbody>
</table>

<p>Five prompts ship with the server — <code class="language-plaintext highlighter-rouge">onboard_agent</code>, <code class="language-plaintext highlighter-rouge">federate_third_party_agent</code>, <code class="language-plaintext highlighter-rouge">audit_agent_governance</code>, <code class="language-plaintext highlighter-rouge">investigate_agent_risk</code>, and <code class="language-plaintext highlighter-rouge">offboard_agent</code>. Each encodes the confirmation gates and ordering constraints so the model doesn’t rediscover them. Seven resources expose the lifecycle model, federation patterns, the beta surface and its licensing, the blocked permission list, the error catalogue, the connector’s scopes, and the directory roles each operation needs.</p>

<h2 id="error-codes-that-tell-the-model-what-to-do">Error codes that tell the model what to do</h2>

<p>Graph returns agent identity failures as opaque code strings. Given only a code, a model retries the identical call. The connector maps all 17 documented <a href="https://learn.microsoft.com/entra/agent-id/identity-platform/error-codes">error codes</a> to the concrete next step and appends it to the failure:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="s">"AgentIdentity_AgentBlueprintPrincipalDoesNotExist"</span><span class="p">]</span> <span class="p">=</span>
    <span class="s">"The blueprint has no blueprint principal in this tenant, which is a prerequisite for creating agent identities from it. Call create_blueprint_principal with the blueprint's appId first, or use provision_agent which handles the ordering."</span><span class="p">,</span>
<span class="p">[</span><span class="s">"AgentIdentity_CredentialsNotSupported"</span><span class="p">]</span> <span class="p">=</span>
    <span class="s">"Agent identities cannot hold credentials. Every secret, certificate, and federation trust belongs on the blueprint — use add_blueprint_password or add_blueprint_federated_credential instead."</span><span class="p">,</span>
</code></pre></div></div>

<p>Two failures arrive without a usable code, so the connector reads the path instead. A <code class="language-plaintext highlighter-rouge">403</code> on a sponsors collection means Graph supports that route with application permissions only, and the message points at <code class="language-plaintext highlighter-rouge">get_agent_overview</code>, which expands sponsors from the identity itself. A <code class="language-plaintext highlighter-rouge">400</code> on <code class="language-plaintext highlighter-rouge">appRoleAssignments</code> suggests a blocked permission — Entra rejects those without naming the offender — and the message points at <code class="language-plaintext highlighter-rouge">check_blocked_permissions</code>.</p>

<h2 id="third-party-agents-authenticate-without-secrets">Third-party agents authenticate without secrets</h2>

<p>An agent running on AWS, n8n, or Kubernetes shouldn’t hold a client secret. A blueprint doubles as a token factory through workload identity federation: the external platform’s own token is exchanged for an Entra token, with nothing stored on the agent.</p>

<table>
  <thead>
    <tr>
      <th>Pattern</th>
      <th>How it works</th>
      <th>Best for</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Workload identity federation</td>
      <td>The platform’s native token — AWS STS, a Kubernetes service account, a GCP workload identity — is exchanged directly for an Entra token</td>
      <td>AWS agents using STS and OIDC, and anywhere federation already exists</td>
    </tr>
    <tr>
      <td>Auth SDK sidecar</td>
      <td>A companion container acquires tokens on the agent’s behalf, so agent code never touches credentials</td>
      <td>Containerized agents, AWS Bedrock, local Docker Compose development</td>
    </tr>
    <tr>
      <td>Blueprint as token factory</td>
      <td>The blueprint trusts Entra itself and issues tokens for its own agent identities, supporting app-only and on-behalf-of flows</td>
      <td>Platforms with a community node that acquires tokens per run, such as n8n</td>
    </tr>
  </tbody>
</table>

<p>Configure the trust with <code class="language-plaintext highlighter-rouge">add_blueprint_federated_credential</code>. Setting <code class="language-plaintext highlighter-rouge">platform: entra_agent_identity</code> builds the issuer from your tenant ID and uses the agent identity as the subject. Setting <code class="language-plaintext highlighter-rouge">platform: custom</code> takes the issuer and subject your external provider puts in its tokens — get those from that platform’s own configuration, because a wrong value fails at runtime with an unhelpful error. The audience defaults to <code class="language-plaintext highlighter-rouge">api://AzureADTokenExchange</code>, a blueprint holds at most 20 credentials, and each issuer/subject pair must be unique.</p>

<p>Credentials live on the blueprint, never on the agent identity. Graph reports violations as <code class="language-plaintext highlighter-rouge">AgentIdentity_CredentialsNotSupported</code>.</p>

<h2 id="inheritable-permissions-change-what-an-audit-means">Inheritable permissions change what an audit means</h2>

<p>A blueprint grants its agent identities delegated scopes automatically, with no separate consent prompt. Three patterns apply: <code class="language-plaintext highlighter-rouge">enumerated</code> inherits only the scopes you list, <code class="language-plaintext highlighter-rouge">all_allowed</code> inherits everything the resource application publishes, and <code class="language-plaintext highlighter-rouge">none</code> inherits nothing. Prefer <code class="language-plaintext highlighter-rouge">enumerated</code>.</p>

<p>An agent’s effective access is its own assignments plus what its blueprint grants. Auditing only the former understates its reach — read the rest with <code class="language-plaintext highlighter-rouge">list_agent_inherited_permissions</code>. Moving to a more restrictive pattern means agents still needing a removed scope must obtain fresh consent, so check what’s live with <code class="language-plaintext highlighter-rouge">list_inheritable_permissions</code> before narrowing.</p>

<h2 id="the-beta-surface">The beta surface</h2>

<p>Four areas run on Graph beta. The connector’s <code class="language-plaintext highlighter-rouge">basePath</code> is <code class="language-plaintext highlighter-rouge">/v1.0</code> and it rewrites the version segment for these operations only, so v1.0 and beta calls never cross over.</p>

<table>
  <thead>
    <tr>
      <th>Area</th>
      <th>Tools</th>
      <th>Requires</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Agent risk</td>
      <td><code class="language-plaintext highlighter-rouge">list_risky_agents</code>, <code class="language-plaintext highlighter-rouge">get_risky_agent</code>, <code class="language-plaintext highlighter-rouge">list_agent_risk_detections</code>, <code class="language-plaintext highlighter-rouge">get_agent_risk_detection</code>, <code class="language-plaintext highlighter-rouge">confirm_agents_compromised</code>, <code class="language-plaintext highlighter-rouge">confirm_agents_safe</code>, <code class="language-plaintext highlighter-rouge">dismiss_agent_risk</code></td>
      <td>A Microsoft Agent 365 license, <code class="language-plaintext highlighter-rouge">IdentityRiskyAgent.*</code>, <code class="language-plaintext highlighter-rouge">IdentityRiskEvent.Read.All</code>. Security Reader to read, Security Administrator to act</td>
    </tr>
    <tr>
      <td>Inherited permissions</td>
      <td><code class="language-plaintext highlighter-rouge">list_agent_inherited_permissions</code></td>
      <td><code class="language-plaintext highlighter-rouge">Application.Read.All</code> or <code class="language-plaintext highlighter-rouge">Directory.Read.All</code></td>
    </tr>
    <tr>
      <td>Conditional Access what-if</td>
      <td><code class="language-plaintext highlighter-rouge">evaluate_conditional_access</code></td>
      <td><code class="language-plaintext highlighter-rouge">Policy.Read.ConditionalAccess</code></td>
    </tr>
    <tr>
      <td>Agent registry</td>
      <td><code class="language-plaintext highlighter-rouge">list_agent_instances</code>, <code class="language-plaintext highlighter-rouge">get_agent_instance</code>, <code class="language-plaintext highlighter-rouge">list_agent_collections</code>, <code class="language-plaintext highlighter-rouge">quarantine_agent_instance</code></td>
      <td><code class="language-plaintext highlighter-rouge">AgentInstance.*</code>, <code class="language-plaintext highlighter-rouge">AgentCollection.ReadWrite.All</code>. Agent Registry Administrator</td>
    </tr>
  </tbody>
</table>

<p>Containment is not blocking. Confirming an agent compromised raises its risk level, and quarantining moves it in the registry. Neither stops it authenticating — only <code class="language-plaintext highlighter-rouge">set_agent_identity_enabled</code> does that.</p>

<p>The agent registry is transitional. Microsoft replaces it with the <a href="https://learn.microsoft.com/microsoft-agent-365/admin/graph-api">Agent Registry powered by Microsoft Agent 365</a> from May 2026. Every tenant reserves two immutable collections: Global (<code class="language-plaintext highlighter-rouge">…0001</code>) and Quarantined (<code class="language-plaintext highlighter-rouge">…0002</code>).</p>

<p>Skip the six beta scopes for a v1.0-only deployment.</p>

<h2 id="in-power-automate">In Power Automate</h2>

<p>Every operation other than <code class="language-plaintext highlighter-rouge">InvokeMCP</code> is a plain Graph call — 90 on v1.0 and 27 on beta. An onboarding flow runs the same four layers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. CreateBlueprint          → displayName, sponsors@odata.bind
2. CreateBlueprintPrincipal → appId from step 1
3. CreateAgentIdentity      → agentIdentityBlueprintId = appId from step 1, sponsors@odata.bind
4. CreateAgentUser          → identityParentId = id from step 3
5. SetAgentUserManager      → @odata.id pointing at the human manager
</code></pre></div></div>

<p>Reference bodies use the OData bind syntax Graph expects:</p>

<div class="language-jsonc highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// sponsors, on create</span><span class="w">
</span><span class="p">{</span><span class="w"> </span><span class="nl">"sponsors@odata.bind"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"https://graph.microsoft.com/v1.0/users/{userId}"</span><span class="p">]</span><span class="w"> </span><span class="p">}</span><span class="w">

</span><span class="c1">// owners, sponsors, and managers, on $ref endpoints</span><span class="w">
</span><span class="p">{</span><span class="w"> </span><span class="nl">"@odata.id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://graph.microsoft.com/v1.0/directoryObjects/{objectId}"</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>A few limits shape flow design. An agent identity holds one agent user, and a second <code class="language-plaintext highlighter-rouge">CreateAgentUser</code> against the same <code class="language-plaintext highlighter-rouge">identityParentId</code> returns <code class="language-plaintext highlighter-rouge">400</code>. Agent users don’t support <code class="language-plaintext highlighter-rouge">$skip</code>, so page with the returned <code class="language-plaintext highlighter-rouge">@odata.nextLink</code>. Blueprints, blueprint principals, and agent identities cap at 100 per page and 250 agent identities per blueprint. Soft-deleted objects still count against that ceiling, so <code class="language-plaintext highlighter-rouge">permanently_delete_agent_object</code> is what actually frees room.</p>

<h2 id="setup">Setup</h2>

<p>Register an Entra application with the redirect URI <code class="language-plaintext highlighter-rouge">https://global.consent.azure-apim.net/redirect</code>, then add the delegated Graph permissions. The v1.0 set covers <code class="language-plaintext highlighter-rouge">AgentIdentity.*</code>, <code class="language-plaintext highlighter-rouge">AgentIdentityBlueprint.*</code>, <code class="language-plaintext highlighter-rouge">AgentIdentityBlueprintPrincipal.*</code>, <code class="language-plaintext highlighter-rouge">AgentIdUser.ReadWrite.All</code>, <code class="language-plaintext highlighter-rouge">AppRoleAssignment.ReadWrite.All</code>, <code class="language-plaintext highlighter-rouge">User.Read</code>, and <code class="language-plaintext highlighter-rouge">offline_access</code>. Add <code class="language-plaintext highlighter-rouge">IdentityRiskyAgent.ReadWrite.All</code>, <code class="language-plaintext highlighter-rouge">IdentityRiskEvent.Read.All</code>, <code class="language-plaintext highlighter-rouge">Policy.Read.ConditionalAccess</code>, <code class="language-plaintext highlighter-rouge">AgentInstance.ReadWrite.All</code>, <code class="language-plaintext highlighter-rouge">AgentCollection.ReadWrite.All</code>, and <code class="language-plaintext highlighter-rouge">Application.Read.All</code> only if you want the beta tools. Grant admin consent for all of them.</p>

<p>Then edit <code class="language-plaintext highlighter-rouge">apiProperties.json</code> with your client ID and secret, import <code class="language-plaintext highlighter-rouge">apiDefinition.swagger.json</code> through the Maker portal or <code class="language-plaintext highlighter-rouge">paconn create</code>, and sign in as an Agent ID Administrator. Agent ID Developer works for blueprint creation only.</p>

<p>Watch ownership as closely as role assignments. A principal that creates a blueprint or blueprint principal becomes its owner automatically and can then manage the derived agent identities with no role at all.</p>

<h2 id="where-it-fits">Where it fits</h2>

<table>
  <thead>
    <tr>
      <th>Connector</th>
      <th>Surface</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Entra Agent ID (this)</td>
      <td>Graph Agent ID APIs</td>
      <td>Identity lifecycle — blueprints, principals, agent identities, agent users — plus risk and the registry</td>
    </tr>
    <tr>
      <td><a href="/power%20platform/mcp/2026-07-07-agent-365-blueprint-connector.html">Agent 365 Blueprint</a></td>
      <td>Graph <code class="language-plaintext highlighter-rouge">/applications</code></td>
      <td>The earlier app-registration approach, plus Work IQ permission grants</td>
    </tr>
    <tr>
      <td><a href="/power%20platform/mcp/2026-07-07-agent-365-mcp-connector.html">Agent 365 MCP</a></td>
      <td>Agent 365 platform</td>
      <td>Runtime access to Work IQ tools, MCPManagement, and AdminTools</td>
    </tr>
  </tbody>
</table>

<p>Use this connector for identity and governance. Use Agent 365 MCP for what the agent does at runtime.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/troystaylor/SharingIsCaring/tree/main/Entra%20Agent%20ID">Entra Agent ID connector on GitHub</a></li>
  <li><a href="https://learn.microsoft.com/graph/api/resources/agentid-platform-overview?view=graph-rest-1.0">Microsoft Entra Agent ID APIs in Microsoft Graph</a></li>
  <li><a href="https://learn.microsoft.com/entra/agent-id/what-is-microsoft-entra-agent-id">What is Microsoft Entra Agent ID?</a></li>
  <li><a href="https://learn.microsoft.com/entra/agent-id/agent-blueprint">Agent identity blueprints</a></li>
  <li><a href="https://learn.microsoft.com/entra/agent-id/agent-users">The agent’s user account</a></li>
  <li><a href="https://learn.microsoft.com/entra/agent-id/configure-third-party-agents">Integrate third-party agents</a></li>
  <li><a href="https://learn.microsoft.com/entra/agent-id/integrate-aws-bedrock-agent">Secure an Amazon Bedrock agent</a></li>
  <li><a href="https://learn.microsoft.com/entra/agent-id/integrate-n8n-agent">Secure an n8n agent</a></li>
  <li><a href="https://learn.microsoft.com/entra/id-protection/concept-risky-agents">ID Protection for agents</a></li>
  <li><a href="https://learn.microsoft.com/entra/identity/conditional-access/agent-id">Conditional Access for agents</a></li>
  <li><a href="https://learn.microsoft.com/entra/agent-id/identity-platform/error-codes">Agent identity platform error codes</a></li>
</ul>]]></content><author><name>Troy Taylor</name></author><category term="Power Platform" /><category term="Custom Connectors" /><category term="MCP" /><category term="Entra Agent ID" /><category term="Microsoft Graph" /><category term="Custom Connectors" /><category term="MCP" /><category term="Copilot Studio" /><category term="Power Automate" /><category term="Governance" /><category term="Workload Identity Federation" /><summary type="html"><![CDATA[A dual-purpose Power Platform custom connector for Microsoft Entra Agent ID — 73 MCP tools for Copilot Studio and 116 REST operations for Power Automate, covering blueprints, agent identities, agent users, risk, and federation.]]></summary></entry></feed>